mirror of
https://gitlab.com/cc-ru/ocelot/ocelot-desktop.git
synced 2025-12-20 02:59:19 +01:00
551 lines
17 KiB
Scala
551 lines
17 KiB
Scala
package ocelot.desktop
|
|
|
|
import buildinfo.BuildInfo
|
|
import ocelot.desktop.geometry.Size2D
|
|
import ocelot.desktop.inventory.Items
|
|
import ocelot.desktop.ui.UiHandler
|
|
import ocelot.desktop.ui.swing.SplashScreen
|
|
import ocelot.desktop.ui.widget._
|
|
import ocelot.desktop.ui.widget.modal.notification.{NotificationDialog, NotificationType}
|
|
import ocelot.desktop.util.CommandLine.Argument
|
|
import ocelot.desktop.util._
|
|
import org.apache.commons.io.FileUtils
|
|
import org.apache.commons.lang3.SystemUtils
|
|
import org.lwjgl.opengl.Display
|
|
import totoro.ocelot.brain.Ocelot
|
|
import totoro.ocelot.brain.nbt.ExtendedNBT.{extendNBTTagCompound, extendNBTTagList}
|
|
import totoro.ocelot.brain.nbt.{CompressedStreamTools, NBT, NBTTagCompound, NBTTagString}
|
|
import totoro.ocelot.brain.user.User
|
|
import totoro.ocelot.brain.workspace.Workspace
|
|
|
|
import java.awt.Component
|
|
import java.io._
|
|
import java.nio.file._
|
|
import java.util.concurrent.locks.{Lock, ReentrantLock}
|
|
import javax.swing.{JDialog, JFileChooser, UIManager}
|
|
import scala.collection.mutable
|
|
import scala.collection.mutable.ArrayBuffer
|
|
import scala.concurrent.duration.Duration
|
|
import scala.io.Source
|
|
import scala.jdk.CollectionConverters._
|
|
import scala.util.{Failure, Success, Try, Using}
|
|
|
|
object OcelotDesktop
|
|
// This configures Log4j appenders & loggers, it should come before Logging in inheritance hierarchy
|
|
extends LoggingConfiguration
|
|
with Logging
|
|
{
|
|
System.setProperty("awt.useSystemAAFontSettings", "on")
|
|
System.setProperty("swing.aatext", "true")
|
|
System.setProperty("LWJGL_WM_CLASS", "Ocelot Desktop")
|
|
|
|
// Required to make all subsequent swing components look "native"
|
|
try UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName)
|
|
catch {
|
|
case ex: Exception => logger.error("Unable to perform setLookAndFeel()", ex)
|
|
}
|
|
|
|
private val splashScreen = new SplashScreen()
|
|
var root: RootWidget = _
|
|
val players: ArrayBuffer[User] = ArrayBuffer(User("myself"))
|
|
val tpsCounter = new FPSCalculator
|
|
val ticker = new Ticker
|
|
|
|
private val TickerIntervalHistorySize = 5
|
|
val tickerIntervalHistory = new mutable.Queue[Duration](TickerIntervalHistorySize)
|
|
|
|
def pushToTickerIntervalHistory(interval: Duration): Unit = {
|
|
if (tickerIntervalHistory.size >= TickerIntervalHistorySize) tickerIntervalHistory.dequeue()
|
|
tickerIntervalHistory.enqueue(interval)
|
|
}
|
|
|
|
private val tickLock: Lock = new ReentrantLock()
|
|
|
|
def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock)(f)
|
|
|
|
private def mainInner(args: mutable.HashMap[Argument, Option[String]]): Unit = {
|
|
logger.info("Starting up Ocelot Desktop")
|
|
logger.info(s"Version: ${BuildInfo.version} (${BuildInfo.commit.take(7)})")
|
|
|
|
splashScreen.setStatus("Loading configuration...", 0.10f)
|
|
val customConfigPath = args.get(CommandLine.ConfigPath).flatten
|
|
|
|
val desktopConfigPath: Path =
|
|
if (customConfigPath.isDefined) {
|
|
Paths.get(customConfigPath.get)
|
|
}
|
|
else {
|
|
// TODO: migration for old locations of ocelot.conf, can be safely removed later
|
|
// TODO: uncomment this line and delete everything below it when you're ready!
|
|
// OcelotPaths.desktopConfig
|
|
|
|
val newConfigPath = OcelotPaths.desktopConfig
|
|
|
|
try {
|
|
if (!Files.exists(newConfigPath)) {
|
|
val oldConfigPath =
|
|
if (SystemUtils.IS_OS_WINDOWS)
|
|
Paths.get(OcelotPaths.windowsAppDataDirectoryName, "Ocelot", "ocelot.conf")
|
|
else
|
|
Paths.get(OcelotPaths.linuxHomeDirectoryName, ".config", "ocelot", "ocelot.conf")
|
|
|
|
if (Files.exists(oldConfigPath))
|
|
Files.move(oldConfigPath, newConfigPath)
|
|
}
|
|
}
|
|
catch {
|
|
case _: Throwable =>
|
|
}
|
|
|
|
// TODO: end of upper todo <3
|
|
newConfigPath
|
|
}
|
|
|
|
Settings.load(desktopConfigPath)
|
|
|
|
Messages.load(Source.fromURL(getClass.getResource("/ocelot/desktop/messages.txt")))
|
|
ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt")))
|
|
|
|
splashScreen.setStatus("Initializing brain...", 0.20f)
|
|
Ocelot.configPath = Settings.get.brainCustomConfigPath.map(Paths.get(_))
|
|
Ocelot.librariesPath = Some(OcelotPaths.libraries)
|
|
Ocelot.isPlayerOnlinePredicate = Some(player => players.exists(_.nickname == player))
|
|
Ocelot.initialize(logger)
|
|
|
|
Items.init()
|
|
|
|
splashScreen.setStatus("Initializing GUI...", 0.30f)
|
|
createWorkspace()
|
|
|
|
UiHandler.loadLibraries()
|
|
|
|
splashScreen.setStatus("Loading resources...", 0.60f)
|
|
UiHandler.init()
|
|
|
|
val loadRecentWorkspace = Settings.get.recentWorkspace.isDefined && Settings.get.openLastWorkspace
|
|
|
|
root = new RootWidget(!loadRecentWorkspace)
|
|
root.size = Size2D(Display.getWidth / Settings.get.scaleFactor, Display.getHeight / Settings.get.scaleFactor)
|
|
UiHandler.setRoot(root)
|
|
|
|
splashScreen.setStatus("Loading workspace...", 0.90f)
|
|
val cmdLineWorkspaceArgument = args.get(CommandLine.WorkspacePath).flatten
|
|
if (loadRecentWorkspace || cmdLineWorkspaceArgument.isDefined) {
|
|
val result = cmdLineWorkspaceArgument
|
|
.orElse(Settings.get.recentWorkspace)
|
|
.map(new File(_))
|
|
.toRight(new IllegalArgumentException("Received None as a directory path"))
|
|
.toTry
|
|
.map(load)
|
|
|
|
result match {
|
|
case Failure(exception) =>
|
|
val errorMessage = if (cmdLineWorkspaceArgument.isDefined)
|
|
"Could not open the specified workspace..."
|
|
else
|
|
"Could not open the recent workspace..."
|
|
|
|
logger.error(errorMessage, exception)
|
|
|
|
new NotificationDialog(s"$errorMessage\n($exception)\nI will create a default one", NotificationType.Info)
|
|
.addCloseButton()
|
|
.show()
|
|
|
|
root.workspaceView.createDefaultWorkspace()
|
|
Settings.get.recentWorkspace = None
|
|
case Success(_) =>
|
|
}
|
|
}
|
|
|
|
val updateThread = new Thread(() => try {
|
|
val currentThread = Thread.currentThread()
|
|
|
|
while (!currentThread.isInterrupted) {
|
|
Profiler.measure("tick") {
|
|
withTickLockAcquired {
|
|
workspace.update()
|
|
updateThreadTasks.run()
|
|
|
|
tpsCounter.tick()
|
|
}
|
|
}
|
|
|
|
ticker.waitNext()
|
|
}
|
|
} catch {
|
|
case _: InterruptedException => // ignore
|
|
}, "update-thread")
|
|
|
|
updateThread.start()
|
|
splashScreen.dispose()
|
|
logger.info("Ocelot Desktop is up and ready!")
|
|
UiHandler.start()
|
|
|
|
logger.info("Cleaning up")
|
|
updateThread.interrupt()
|
|
|
|
try updateThread.join() catch {
|
|
case _: InterruptedException =>
|
|
}
|
|
|
|
WebcamCapture.cleanup()
|
|
Settings.save(desktopConfigPath)
|
|
UiHandler.terminate()
|
|
ResourceManager.checkEmpty()
|
|
|
|
Ocelot.shutdown()
|
|
|
|
logger.info("Thanks for using Ocelot Desktop")
|
|
System.exit(0)
|
|
}
|
|
|
|
def main(rawArgs: Array[String]): Unit = {
|
|
val args = CommandLine.parse(rawArgs)
|
|
if (args.contains(CommandLine.Help)) {
|
|
println(CommandLine.doc)
|
|
} else {
|
|
try mainInner(args)
|
|
catch {
|
|
case e: Exception =>
|
|
val sw = new StringWriter
|
|
val pw = new PrintWriter(sw)
|
|
e.printStackTrace(pw)
|
|
logger.error(s"${sw.toString}")
|
|
|
|
System.exit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
private def saveWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
|
|
val backendNBT = new NBTTagCompound
|
|
val frontendNBT = new NBTTagCompound
|
|
workspace.save(backendNBT)
|
|
root.workspaceView.save(frontendNBT)
|
|
frontendNBT.setNewTagList("players", players.map(player => new NBTTagString(player.nickname)))
|
|
nbt.setTag("back", backendNBT)
|
|
nbt.setTag("front", frontendNBT)
|
|
}
|
|
|
|
private def loadWorld(nbt: NBTTagCompound): Unit = withTickLockAcquired {
|
|
val backendNBT = nbt.getCompoundTag("back")
|
|
val frontendNBT = nbt.getCompoundTag("front")
|
|
workspace.load(backendNBT)
|
|
root.workspaceView.load(frontendNBT)
|
|
if (frontendNBT.hasKey("players")) {
|
|
players.clear()
|
|
players.addAll(frontendNBT.getTagList("players", NBT.TAG_STRING).map((player: NBTTagString) => User(player.getString)))
|
|
}
|
|
}
|
|
|
|
private var lastAutosave: Long = -1
|
|
|
|
def resetAutosave(): Unit = {
|
|
if (Settings.get.autosave) {
|
|
lastAutosave = System.currentTimeMillis()
|
|
logger.debug("Autosave interval was reset.")
|
|
}
|
|
}
|
|
|
|
def updateAutosave(): Unit = {
|
|
if (Settings.get.autosave) {
|
|
if (lastAutosave == -1 || System.currentTimeMillis() - lastAutosave > Settings.get.autosavePeriod * 1000) {
|
|
val path = savePath.getOrElse(tmpPath)
|
|
logger.debug(s"Autosave to $path...")
|
|
saveTo(path)
|
|
lastAutosave = System.currentTimeMillis()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var _savePath: Option[Path] = None
|
|
|
|
private def savePath: Option[Path] = _savePath
|
|
|
|
private def savePath_=(path: Option[Path]): Unit = {
|
|
_savePath = path
|
|
|
|
UiHandler.windowTitleSuffix = path.map(_.toString)
|
|
}
|
|
|
|
private val tmpPath = Files.createTempDirectory("ocelot-save")
|
|
|
|
Runtime.getRuntime.addShutdownHook(new Thread(() => {
|
|
// if autosave is turned on and the project is not saved properly, we would like to keep the temporary project
|
|
if (!Settings.get.autosave || savePath.isEmpty) {
|
|
FileUtils.deleteDirectory(tmpPath.toFile)
|
|
}
|
|
}))
|
|
|
|
def newWorkspace(): Unit = showCloseConfirmationDialog("Save workspace before opening a new one?") {
|
|
root.workspaceView.newWorkspace()
|
|
savePath = None
|
|
Settings.get.recentWorkspace = None
|
|
resetAutosave()
|
|
}
|
|
|
|
private def saveTo(outputPath: Path): Unit = {
|
|
val oldPath = workspace.path
|
|
|
|
if (oldPath != outputPath) {
|
|
val (oldFiles, newFiles) =
|
|
Using.resources(Files.list(oldPath), Files.list(outputPath)) { (oldDirStream, newDirStream) =>
|
|
val oldFiles = oldDirStream.iterator.asScala.toArray
|
|
val newFiles = newDirStream.iterator.asScala.toArray
|
|
(oldFiles, newFiles)
|
|
}
|
|
|
|
val toRemove = newFiles.intersect(oldFiles)
|
|
|
|
for (path <- toRemove) {
|
|
if (Files.isDirectory(path)) {
|
|
FileUtils.deleteDirectory(path.toFile)
|
|
} else {
|
|
Files.delete(path)
|
|
}
|
|
}
|
|
|
|
for (path <- oldFiles) {
|
|
val oldFile = oldPath.resolve(path.getFileName).toFile
|
|
val newFile = outputPath.resolve(path.getFileName).toFile
|
|
|
|
if (Files.isDirectory(path)) {
|
|
FileUtils.copyDirectory(oldFile, newFile)
|
|
}
|
|
else {
|
|
FileUtils.copyFile(oldFile, newFile)
|
|
}
|
|
}
|
|
|
|
workspace.path = outputPath
|
|
}
|
|
|
|
val path = outputPath.resolve("workspace.nbt")
|
|
|
|
try {
|
|
Files.move(path, path.resolveSibling("workspace.nbt.bak"), StandardCopyOption.REPLACE_EXISTING)
|
|
} catch {
|
|
case _: FileNotFoundException | _: NoSuchFileException => // no workspace file to back up -- that's okay
|
|
}
|
|
|
|
Using.resource(new DataOutputStream(new FileOutputStream(path.toFile))) { writer =>
|
|
val nbt = new NBTTagCompound
|
|
saveWorld(nbt)
|
|
CompressedStreamTools.writeCompressed(nbt, writer)
|
|
}
|
|
|
|
logger.info(s"Saved workspace to: $outputPath")
|
|
}
|
|
|
|
def save(continuation: => Unit): Unit = savePath match {
|
|
case Some(savePath) =>
|
|
Try {
|
|
saveTo(savePath)
|
|
continuation
|
|
} match {
|
|
case f @ Failure(_) =>
|
|
this.savePath = None
|
|
showFailureMessage(f)
|
|
|
|
case Success(_) =>
|
|
}
|
|
|
|
case None => showSaveDialog(continuation)
|
|
}
|
|
|
|
def saveAs(): Unit = showSaveDialog()
|
|
|
|
def showOpenDialog(): Unit =
|
|
showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) {
|
|
case Some(dir) => load(dir)
|
|
case None => Success(())
|
|
}
|
|
|
|
def load(dir: File): Try[Unit] = {
|
|
val path = Paths.get(dir.getCanonicalPath, "workspace.nbt")
|
|
|
|
if (Files.exists(path)) {
|
|
Using(new DataInputStream(Files.newInputStream(path))) { reader =>
|
|
val nbt = CompressedStreamTools.readCompressed(reader)
|
|
|
|
Transaction.run { tx =>
|
|
val oldSavePath = savePath
|
|
savePath = Some(dir.toPath)
|
|
tx.onFailure { savePath = oldSavePath }
|
|
|
|
Settings.get.recentWorkspace = Some(dir.getCanonicalPath)
|
|
workspace.path = dir.toPath
|
|
loadWorld(nbt)
|
|
|
|
resetAutosave()
|
|
}
|
|
}
|
|
} else Failure(new FileNotFoundException("Specified directory does not contain 'workspace.nbt'"))
|
|
}
|
|
|
|
def showFileChooserDialog(dialogType: Int, selectionMode: Int)(f: Option[File] => Try[Unit]): Unit = {
|
|
new Thread(() => {
|
|
val lastFile = savePath.map(_.toFile).orNull
|
|
|
|
val chooser = new JFileChooser(lastFile) {
|
|
override def createDialog(parent: Component): JDialog = {
|
|
val dialog = super.createDialog(parent)
|
|
|
|
dialog.setModal(true)
|
|
dialog.setAlwaysOnTop(true)
|
|
|
|
dialog
|
|
}
|
|
}
|
|
|
|
chooser.setFileSelectionMode(selectionMode)
|
|
chooser.setDialogType(dialogType)
|
|
|
|
val selectedFile =
|
|
Option.when(chooser.showDialog(null, null) == JFileChooser.APPROVE_OPTION)(chooser.getSelectedFile)
|
|
|
|
// users of [showFileChooserDialog] expect that the continuation is run on the main thread.
|
|
// let's meet this expectation.
|
|
UiHandler.UiThreadTasks.add(() => {
|
|
val result = f(selectedFile)
|
|
|
|
result match {
|
|
case f@Failure(_) => showFailureMessage(f)
|
|
case Success(_) =>
|
|
}
|
|
})
|
|
}).start()
|
|
}
|
|
|
|
private def showFailureMessage(failure: Failure[Unit]): Unit = {
|
|
val Failure(exception) = failure
|
|
|
|
logger.error("File operation failed", exception)
|
|
|
|
new NotificationDialog(
|
|
s"Something went wrong!\n($exception)\nCheck the log file for a full stacktrace.",
|
|
NotificationType.Error
|
|
).addCloseButton().show()
|
|
}
|
|
|
|
def showAddPlayerDialog(): Unit = new InputDialog(
|
|
"Add new player",
|
|
text => OcelotDesktop.selectPlayer(text)
|
|
).show()
|
|
|
|
def player: User = if (players.nonEmpty) players.head else User("myself")
|
|
|
|
def selectPlayer(name: String): Unit = {
|
|
players.indexWhere(_.nickname == name) match {
|
|
case -1 => players.prepend(User(name))
|
|
case i =>
|
|
val player = players(i)
|
|
players.remove(i)
|
|
players.prepend(player)
|
|
}
|
|
}
|
|
|
|
def removePlayer(name: String): Unit = {
|
|
players.indexWhere(_.nickname == name) match {
|
|
case -1 =>
|
|
case i => players.remove(i)
|
|
}
|
|
}
|
|
|
|
def exit(): Unit = showCloseConfirmationDialog() {
|
|
UiHandler.exit()
|
|
}
|
|
|
|
var workspace: Workspace = _
|
|
val updateThreadTasks = new TaskQueue()
|
|
|
|
private def createWorkspace(): Unit = {
|
|
workspace = new Workspace(tmpPath)
|
|
}
|
|
|
|
private def prepareSavePath(path: Path)(continuation: => Unit): Unit = {
|
|
val nonEmpty = try Using.resource(Files.list(path))(_.iterator.asScala.nonEmpty) catch {
|
|
case _: FileNotFoundException | _: NoSuchFileException =>
|
|
logger.info(s"Save path $path does not exist: creating a new directory")
|
|
Files.createDirectory(path)
|
|
|
|
false
|
|
}
|
|
|
|
if (nonEmpty) {
|
|
new NotificationDialog(
|
|
"""Chosen save path is not empty.
|
|
|Files in the save directory will be included in the workspace.
|
|
|They may be overwritten, causing loss of data.
|
|
|Proceed with saving anyway?""".stripMargin,
|
|
NotificationType.Warning
|
|
) {
|
|
addButton("Cancel") {
|
|
close()
|
|
}
|
|
addButton("Yes") {
|
|
close()
|
|
continuation
|
|
}
|
|
}.show()
|
|
} else continuation
|
|
}
|
|
|
|
private def showSaveDialog(continuation: => Unit): Unit =
|
|
showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir =>
|
|
Try {
|
|
if (dir.nonEmpty) {
|
|
prepareSavePath(dir.get.toPath) {
|
|
Transaction.run { tx =>
|
|
val oldSavePath = savePath
|
|
savePath = dir.map(_.toPath)
|
|
tx.onFailure {
|
|
savePath = oldSavePath
|
|
}
|
|
|
|
save(continuation)
|
|
Settings.get.recentWorkspace = dir.map(_.getCanonicalPath)
|
|
|
|
resetAutosave()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private def showCloseConfirmationDialog(prompt: Option[String])(continuation: => Unit): Unit = {
|
|
if (UiHandler.root.modalDialogPool.children.exists(_.isInstanceOf[CloseConfirmationDialog])) {
|
|
return
|
|
}
|
|
|
|
for (savePath <- savePath if Settings.get.saveOnExit) {
|
|
saveTo(savePath)
|
|
continuation
|
|
|
|
return
|
|
}
|
|
|
|
val prompt_ = prompt
|
|
|
|
new CloseConfirmationDialog {
|
|
override def prompt: String = prompt_.getOrElse(super.prompt)
|
|
|
|
override def onSaveSelected(): Unit = save {
|
|
close()
|
|
continuation
|
|
}
|
|
|
|
override def onNoSaveSelected(): Unit = {
|
|
close()
|
|
continuation
|
|
}
|
|
}.show()
|
|
}
|
|
|
|
private def showCloseConfirmationDialog(prompt: String)(continuation: => Unit): Unit =
|
|
showCloseConfirmationDialog(Some(prompt))(continuation)
|
|
|
|
private def showCloseConfirmationDialog()(continuation: => Unit): Unit =
|
|
showCloseConfirmationDialog(None)(continuation)
|
|
}
|