diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4e13718..7950ab7 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,33 +1,54 @@ -image: scalableminds/sbt:sbt-13-17 +default: + image: sbtscala/scala-sbt:eclipse-temurin-jammy-8u352-b08_1.8.3_2.13.10 + + cache: + key: "$CI_COMMIT_REF_SLUG" + paths: + - "sbt-cache" + - "target/streams" + - "lib/ocelot-brain/target" variables: GIT_SUBMODULE_STRATEGY: normal SBT_OPTS: "-Dsbt.global.base=sbt-cache/.sbtboot -Dsbt.boot.directory=sbt-cache/.boot -Dsbt.ivy.home=sbt-cache/.ivy" SBT_CACHE_DIR: "sbt-cache/.ivy/cache" + PACKAGE_REGISTRY_URL: "${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/packages/generic/ocelot-desktop/${CI_COMMIT_TAG}" + PACKAGE_NAME: "ocelot-desktop-${CI_COMMIT_TAG}.jar" -cache: - key: "$CI_COMMIT_REF_SLUG" - paths: - - "sbt-cache" - - "target/streams" - - "lib/ocelot-brain/target" - -before_script: - - sbt -v sbtVersion +stages: + - build + - upload + - deploy + - release build: stage: build + before_script: + - sbt -v sbtVersion + tags: + - public script: - sbt assembly artifacts: paths: - target/scala-2.13/ocelot-desktop.jar +upload: + stage: upload + image: curlimages/curl:latest + rules: + - if: $CI_COMMIT_TAG + script: + - | + curl --fail-with-body --header "JOB-TOKEN: ${CI_JOB_TOKEN}" --upload-file target/scala-2.13/ocelot-desktop.jar "${PACKAGE_REGISTRY_URL}/${PACKAGE_NAME}" + pages: stage: deploy before_script: [] rules: - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_COMMIT_BRANCH == "develop" + tags: + - public script: - rm -rf public - mkdir public @@ -36,3 +57,17 @@ pages: artifacts: paths: - public + +release: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + rules: + - if: $CI_COMMIT_TAG + script: + - echo "Creating a new release for tag $CI_COMMIT_TAG." + - | + release-cli create \ + --name "Release $CI_COMMIT_TAG" \ + --tag-name "$CI_COMMIT_TAG" \ + --description "$CI_COMMIT_TAG_MESSAGE" \ + --assets-link "{\"name\":\"${PACKAGE_NAME}\",\"link_type\":\"package\",\"url\":\"${PACKAGE_REGISTRY_URL}/${PACKAGE_NAME}\"}" \ No newline at end of file diff --git a/README.md b/README.md index ecac393..75c5d2e 100644 --- a/README.md +++ b/README.md @@ -3,131 +3,188 @@ A desktop version of the renowned OpenComputers emulator Ocelot. -[Download the latest build][download] / [mirror][download-mirror] +[**Download** the latest build][download] / [mirror][download-mirror] ## Why -You might already be happy with your choice of an OC emulator; after all, there -is already a plenty of them. -So why would you want to reconsider your life choices now all of a sudden? +You might already be happy with your choice of an OC emulator; +there is a plenty of them, after all. +Why would you want to reconsider your life choices now all of a sudden? A fine question, indeed; perhaps, a list of features will persuade you. ### Powered by ocelot-brain At the heart of this emulator is [ocelot-brain][ocelot-brain] (uh, don't ask me), -which is essentially the source code of OpenComputers stripped of everything -Minecraft-specific and packaged as a Scala library. +which is essentially the source code of OpenComputers decoupled of everything +Minecraft-specific and repurposed as a Scala library. This makes Ocelot Desktop **the most accurate emulator** ever made. -Your programs will run on the same version of Lua as used by the mod and have +Your programs will run on the Lua implementation used by the mod and exhibit similar timings. The performance and memory constraints present in OC are also emulated. ### Customizable setups -Computers can have the following components: +Taylor your computer build to your needs! +We provide a variety of components to choose from: -- the 3 tiers of graphics cards -- all kinds of network cards (wired, wireless) -- a linked card -- an internet card -- a sound card (Computronics) -- a redstone card in the both tiers -- a data card (again, you can pick any of the three tiers) +- graphics cards +- network cards (wired, wireless) +- linked cards +- internet cards +- redstone cards (including the second tier!) +- data cards - hard disks -- a floppy disk (in T3+ computer cases only) +- floppy disks (in T3+ computer cases only, just like in OpenComputers) -The choice is restricted by the tier of a computer case, just like in -OpenComputers, to avoid building impossible configurations. -Oh, did I forget to mention that Ocelot Desktop has both the CPUs and the APUs? -Memory can likewise be installed according to your needs. +Feel limited by the vanilla cards? +No problem — we've even integrated some components from popular addons! -If one computer is not enough, you can add another one. -Or two, or a thousand, as long as your host doesn't collapse under the load, -of course. -The network cards are there for a reason — these newly spawned machines can -communicate with each other. -And relays may help you manage the wired networks. +![Addon showcase][addon-showcase] -Or, instead of employing an army of computers, you might want to connect a dozen -of screens to a single machine, like in the movies. -No problem — we've got that covered, too. +- **Computronics:** + - `computer.beep()` does not excite your music sense enough? + Check out the **sound card**'s synthesis engine, + or string notes together on a bunch of **note blocks**! -### Pretty graphical interface + - You have privacy concerns? + An ultra-precision blast of the **self-destructing card** might save your day! + + - If, on the contrary, you're the kind to violate the privacy of others, + we've got a **camera** that reads video data from a real webcam. + + - Those who seem to find no color in their life may appreciate the + **colorful lamps**. + Their iridescent glow will provide comfort in the darkest hour. + And they won't desert you. + +- **OpenFM:** for those lo-fi beats to chill and relax to while writing code. + +We'll make sure your setups are grounded in reality +by having the maximum card tier in a slot depend on your computer case. +Oh, did I forget to mention Ocelot Desktop has APUs as well? + +Explore the wonders of distributed computing by adding a couple of other +computers to your workspace. +(Or a thousand — if you think your host can handle this.) +Network cards allow these newly spawned machines to talk to each other. +And relays may prove useful to manage the wired networks. + +Perhaps, instead of enlisting a computer army, +you want to attach a dozen of screens to a single machine +(as they do in the movies). +Well, no problem — we've got that covered, too. + +### Gorgeous graphical interface ![GUI][gui] -A slick interface allows you to customize the setup to your liking. -Add more computers, organize the connections between components, build complex -setups, and manage your screen real estate to avoid distractions. +Manage your screen real estate to avoid distractions. +All nodes are draggable, as are windows. +And screen windows in particular are also resizeable — +click and drag the bottom-right corner if they take up too much space. +Or hold Shift and let the window consume it all. -Many additional options are hidden in the context menu — try hitting the -right mouse button on the various things. -For example, components will let you copy their address to the clipboard, and -right-clicking on the TPS counter on the bottom allows you to change the -simulation speed. +![Window scaling][window-scaling] -The emulator uses hardware acceleration to offload the daunting task of -rendering its interface to a specialized device, so make sure you have a OpenGL -2.1-capable graphics card. +Many additional options are hidden in the context menus — +try hitting the right mouse button on various things. +For example, components will let you copy their address to the clipboard, +and right-clicking on the TPS counter (on the bottom right) +allows you to change the simulation speed. + +![TPS rate menu][tps-menu] + +Ocelot Desktop uses hardware acceleration to offload the daunting task of +rendering its interface to a special-purposed device (your GPU), +so make sure you have an **OpenGL 3.0-capable** graphics card. +(Though, honestly, it's harder to find one that isn't, really.) ### Persistable workspaces -It would be sad if, after all the hard work you put into adjusting the -workspace, you have to do that again. +Imagine putting many hours into wiring things up only to have to do it all from +scratch the next time you open the emulator. +That... would be sad and disappointing. I mean, OpenComputers can persist its machines just fine, right? -By basing the emulator on its code, we've essentially inherited the ability -to save workspaces on the disk and load them afterwards. +Good news: by reusing its code, we've essentially inherited the ability +to save workspaces on the disk and load them afterwards! Just in case, Ocelot Desktop will warn you if you smash the quit button without saving. +We'd rather you didn't feel sad and disappointed. ### Cool features -![Performance graphs][graphs] +![Performance graphs][perf-graphs] ![Sound card GUI][sound-card] A few smaller features are worth mentioning, too: -- Screens are resizeable — drag the bottom-right corner. -- Windows are labeled with the corresponding block's address; however, you can - set a custom label — look for an option in the context menu. -- The button in a computer case window shows the performance graphs: - the used memory, processor time and call budget. -- Hold the Ctrl key while dragging blocks to have them snap to the grid. +- Windows show the corresponding block's address by default. + However, you can relabel them: look for the option in the context menu. +- The button ![][drawer-button] at the bottom of a computer case window + shows performance graphs: the memory, processor time, and call budget. +- Hold the Ctrl key while dragging blocks to snap them to the grid. ## Download -Decided to give Ocelot Desktop a shot? [Download the latest build][download] / [mirror][download-mirror] +Decided to give Ocelot Desktop a shot? -## How to build it? -Just import the project in your favorite IDE. -Make sure to have Scala and SBT installed (manually or through IDE). +[**Download** the latest build][download] / [mirror][download-mirror] -Ocelot Brain library is added as a Git submodule, so do not forget to fetch it too. -Something like `git submodule update --init --recursive` should do the trick. +## Hacking +Just import the project in your favorite IDE. +Make sure to have Scala and SBT installed (your IDE may assist you with that). -Use `sbt run` to start Ocelot Desktop. Use `sbt assembly` to generate JAR file. -(It will appear at `target/scala-2.13/ocelot-desktop.jar` location.) +We include [ocelot-brain][] as a Git submodule: don't forget to fetch it! -If the compiler is complaining about missing BuildInfo class, or the version in -the window title / logs looks outdated, use `sbt buildInfo` to generate fresh class. +```sh +$ git submodule update --init --recursive +``` + +To build from source and start Ocelot Desktop, type: + +```sh +$ sbt run +``` + +If you want to get a JAR, use this: + +```sh +$ sbt assembly +``` + +(You'll find it at `target/scala-2.13/ocelot-desktop.jar`.) + +In case you see the compiler complain about `BuildInfo` or just want to refresh +the version displayed in the window title and the logs, run: + +```sh +$ sbt buildInfo +``` ## Credits -- **LeshaInc**, the author and maintainer of Ocelot Desktop. -- **Totoro**, the creator of ocelot-brain and ocelot-online. -- **bpm140**, who created marvelous Ocelot Desktop landing page. -- **rason**, who stirred the development at the critical moment. -- **NE0**, the bug extermination specialist. -- **ECS**, who fearlessly jumped right into Scala jungle. -- **fingercomp**, who wrote this README. +- **LeshaInc:** the original author of Ocelot Desktop. +- **Totoro:** the creator of [ocelot-brain][] and [ocelot.online][ocelot-online]. +- **bpm140:** produced the marvelous Ocelot Desktop landing page. +- **rason:** stirred the development at the critical moment! +- **NE0:** the bug extermination specialist. +- **ECS:** leaped fearlessly into the Scala jungle. +- **Smok1e:** added some light and color with new components from Computronics. +- **Saphire:** scaled the UI for HiDPI screens. +- **fingercomp:** wrote this README. ## See also -- [Ocelot Desktop][ocelot-desktop] web page (link to the latest build, FAQ) -- [ocelot.online][ocelot-online], a rudimentary web version of Ocelot -- [ocelot-brain][ocelot-brain], the backend library of Ocelot Desktop +- The [Ocelot Desktop][ocelot-desktop] web page + (links to the latest build, FAQ). +- [ocelot.online][ocelot-online], a rudimentary web version of Ocelot. +- [ocelot-brain][], the backend library of Ocelot Desktop. - [#cc.ru on IRC][irc] if you have any questions - (Russian, but we're fine with English too) -- [Discord][discord] if you prefer so (we will not judge) + (Russian, but we're fine with English too). +- Or [Discord][discord] if that's your fancy (we won't judge). -[banner]: https://i.imgur.com/OzkpQZv.png +[banner]: ./assets/banner.png "The Ocelot banner" [download]: https://cc-ru.gitlab.io/ocelot/ocelot-desktop/ocelot.jar [download-mirror]: https://ocelot.fomalhaut.me/ocelot.jar -[gui]: https://i.imgur.com/O4bF7I8.png -[graphs]: https://i.imgur.com/mG8UjhV.png -[sound-card]: https://i.imgur.com/gnh3D6N.png +[addon-showcase]: ./assets/addon-showcase.png "A workspace with colorful lamps and an OpenFM radio." +[gui]: ./assets/gui.png "A screenshot of the GUI." +[window-scaling]: ./assets/window-scale.gif "Demonstrates the screen scaling." +[tps-menu]: ./assets/tps.png "The simulation speed menu (right-click on the TPS)." +[perf-graphs]: ./assets/perf-graphs.gif "A demo of performance graphs." +[sound-card]: ./assets/sound-card.gif "Shows the sound card UI while playing a melody." +[drawer-button]: ./sprites/buttons/BottomDrawerOpen.png [ocelot-brain]: https://gitlab.com/cc-ru/ocelot/ocelot-brain [ocelot-online]: https://ocelot.fomalhaut.me/ [ocelot-desktop]: https://ocelot.fomalhaut.me/desktop/ diff --git a/assets/addon-showcase.png b/assets/addon-showcase.png new file mode 100644 index 0000000..d050320 Binary files /dev/null and b/assets/addon-showcase.png differ diff --git a/assets/banner.png b/assets/banner.png new file mode 100644 index 0000000..96d65af Binary files /dev/null and b/assets/banner.png differ diff --git a/assets/gui.png b/assets/gui.png new file mode 100644 index 0000000..2882bce Binary files /dev/null and b/assets/gui.png differ diff --git a/assets/perf-graphs.gif b/assets/perf-graphs.gif new file mode 100644 index 0000000..9c92811 Binary files /dev/null and b/assets/perf-graphs.gif differ diff --git a/assets/sound-card.gif b/assets/sound-card.gif new file mode 100644 index 0000000..fd74ef4 Binary files /dev/null and b/assets/sound-card.gif differ diff --git a/assets/tps.png b/assets/tps.png new file mode 100644 index 0000000..ac7d7af Binary files /dev/null and b/assets/tps.png differ diff --git a/assets/window-scale.gif b/assets/window-scale.gif new file mode 100644 index 0000000..6df6e1a Binary files /dev/null and b/assets/window-scale.gif differ diff --git a/build.sbt b/build.sbt index 6ec6027..203675a 100644 --- a/build.sbt +++ b/build.sbt @@ -1,6 +1,6 @@ name := "ocelot-desktop" -version := "1.5.0" -scalaVersion := "2.13.8" +version := "1.7.1" +scalaVersion := "2.13.10" lazy val root = project.in(file(".")) .dependsOn(brain % "compile->compile") @@ -16,6 +16,8 @@ lazy val root = project.in(file(".")) lazy val brain = ProjectRef(file("lib/ocelot-brain"), "ocelot-brain") +libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value + libraryDependencies += "org.apache.logging.log4j" % "log4j-core" % "2.20.0" libraryDependencies += "org.apache.logging.log4j" % "log4j-api" % "2.20.0" libraryDependencies += "org.apache.logging.log4j" % "log4j-slf4j-impl" % "2.20.0" @@ -33,7 +35,6 @@ libraryDependencies += "com.github.wendykierp" % "JTransforms" % "3.1" libraryDependencies += "com.github.sarxos" % "webcam-capture" % "0.3.12" // For OpenFM -libraryDependencies += "javazoom" % "jlayer" % "1.0.1" libraryDependencies += "com.googlecode.soundlibs" % "mp3spi" % "1.9.5.4" libraryDependencies += "com.googlecode.soundlibs" % "vorbisspi" % "1.0.3.3" @@ -42,4 +43,4 @@ assembly / assemblyMergeStrategy := { case _ => MergeStrategy.first } -assembly / assemblyJarName := s"ocelot-desktop.jar" +assembly / assemblyJarName := "ocelot-desktop.jar" diff --git a/lib/ocelot-brain b/lib/ocelot-brain index 42dd5a9..fbea0b4 160000 --- a/lib/ocelot-brain +++ b/lib/ocelot-brain @@ -1 +1 @@ -Subproject commit 42dd5a966456654968af777d625c3e2f8b9265fa +Subproject commit fbea0b4295d3866f7bff71b442afce0a859e3712 diff --git a/project/assembly.sbt b/project/assembly.sbt index c46ce74..f55a62c 100644 --- a/project/assembly.sbt +++ b/project/assembly.sbt @@ -1 +1 @@ -addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "1.2.0") +addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "2.1.1") diff --git a/project/build.properties b/project/build.properties index d738b85..d0ac2d5 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1,2 @@ -sbt.version = 1.7.1 +# suppress inspection "UnusedProperty" for whole file +sbt.version = 1.8.3 diff --git a/sprites/items/SelfDestructingCard.png b/sprites/items/SelfDestructingCard.png new file mode 100644 index 0000000..38aa027 Binary files /dev/null and b/sprites/items/SelfDestructingCard.png differ diff --git a/src/main/resources/ocelot/desktop/colorscheme.txt b/src/main/resources/ocelot/desktop/colorscheme.txt index aa6091d..be0868d 100644 --- a/src/main/resources/ocelot/desktop/colorscheme.txt +++ b/src/main/resources/ocelot/desktop/colorscheme.txt @@ -58,10 +58,13 @@ TextInputForeground = #333333 TextInputBorderError = #aa8888 TextInputBorderErrorFocused = #cc6666 -ButtonBackground = #aaaaaa -ButtonBorder = #888888 -ButtonForeground = #333333 -ButtonConfirm = #336633 +ButtonBackground = #aaaaaa +ButtonBorder = #888888 +ButtonForeground = #333333 +ButtonBackgroundDisabled = #333333 +ButtonBorderDisabled = #666666 +ButtonForegroundDisabled = #888888 +ButtonConfirm = #336633 BottomDrawerBorder = #888888 @@ -80,6 +83,7 @@ VerticalMenuBorder = #dfdfdf SliderBackground = #aaaaaa SliderBorder = #888888 +SliderTick = #989898 SliderHandler = #bbbbbb SliderForeground = #333333 @@ -134,4 +138,6 @@ SoundCardWire3 = #69237f SoundCardWire4 = #7f2331 SoundCardWire5 = #7f5123 SoundCardWire6 = #7f7723 -SoundCardWire7 = #000000 \ No newline at end of file +SoundCardWire7 = #000000 + +ErrorMessage = #ff3366 \ No newline at end of file diff --git a/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.png b/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.png index 1ee8c92..5865407 100644 Binary files a/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.png and b/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.png differ diff --git a/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.txt b/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.txt index 8c11ffd..3601d5e 100644 --- a/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.txt +++ b/src/main/resources/ocelot/desktop/images/spritesheet/spritesheet.txt @@ -1,16 +1,16 @@ BackgroundPattern 0 0 304 304 -BarSegment 329 349 16 4 +BarSegment 357 349 16 4 ComputerMotherboard 305 129 79 70 Empty 506 126 1 1 -EmptySlot 318 330 18 18 +EmptySlot 335 330 18 18 Knob 385 129 50 50 KnobCenter 436 129 50 50 KnobLimits 305 200 50 50 ShadowBorder 505 15 1 24 ShadowCorner 301 305 24 24 TabArrow 502 0 8 14 -buttons/BottomDrawerClose 337 330 18 18 -buttons/BottomDrawerOpen 356 330 18 18 +buttons/BottomDrawerClose 354 330 18 18 +buttons/BottomDrawerOpen 373 330 18 18 buttons/OpenFMRadioCloseOff 242 402 7 8 buttons/OpenFMRadioCloseOn 250 402 7 8 buttons/OpenFMRadioRedstoneOff 502 87 8 8 @@ -19,20 +19,20 @@ buttons/OpenFMRadioStartOff 326 305 24 24 buttons/OpenFMRadioStartOn 351 305 24 24 buttons/OpenFMRadioStopOff 376 305 24 24 buttons/OpenFMRadioStopOn 401 305 24 24 -buttons/OpenFMRadioVolumeDownOff 482 330 10 10 -buttons/OpenFMRadioVolumeDownOn 493 330 10 10 -buttons/OpenFMRadioVolumeUpOff 501 363 10 10 -buttons/OpenFMRadioVolumeUpOn 318 349 10 10 -buttons/PowerOff 375 330 18 18 -buttons/PowerOn 394 330 18 18 +buttons/OpenFMRadioVolumeDownOff 499 330 10 10 +buttons/OpenFMRadioVolumeDownOn 501 363 10 10 +buttons/OpenFMRadioVolumeUpOff 335 349 10 10 +buttons/OpenFMRadioVolumeUpOn 346 349 10 10 +buttons/PowerOff 392 330 18 18 +buttons/PowerOn 411 330 18 18 icons/ButtonCheck 301 363 17 17 icons/ButtonClipboard 319 363 17 17 icons/ButtonRandomize 337 363 17 17 icons/CPU 301 381 16 16 icons/Card 318 381 16 16 icons/ComponentBus 335 381 16 16 -icons/DragLMB 413 330 21 14 -icons/DragRMB 435 330 21 14 +icons/DragLMB 430 330 21 14 +icons/DragRMB 452 330 21 14 icons/EEPROM 352 381 16 16 icons/Floppy 369 381 16 16 icons/HDD 386 381 16 16 @@ -54,7 +54,7 @@ icons/WaveNoise 401 363 24 10 icons/WaveSawtooth 426 363 24 10 icons/WaveSine 451 363 24 10 icons/WaveSquare 476 363 24 10 -icons/WaveTriangle 457 330 24 10 +icons/WaveTriangle 474 330 24 10 icons/WireArrowLeft 507 15 4 8 icons/WireArrowRight 507 24 4 8 items/APU0 233 305 16 96 @@ -108,6 +108,7 @@ items/Memory5 424 251 16 16 items/NetworkCard 441 251 16 16 items/RedstoneCard0 458 251 16 16 items/RedstoneCard1 475 251 16 16 +items/SelfDestructingCard 318 330 16 32 items/Server0 492 251 16 16 items/Server1 305 268 16 16 items/Server2 322 268 16 16 diff --git a/src/main/resources/ocelot/desktop/messages.txt b/src/main/resources/ocelot/desktop/messages.txt new file mode 100644 index 0000000..e3dda46 --- /dev/null +++ b/src/main/resources/ocelot/desktop/messages.txt @@ -0,0 +1,5 @@ +Error.NoCPU = No CPU is installed in the computer. +Error.ComponentOverflow = Too many components connected to the computer. +Error.NoRAM = No RAM is installed in the computer. +Error.OutOfMemory = Out of memory. +Error.InternalError = Internal error, please see the log file. This is probably a bug. \ No newline at end of file diff --git a/src/main/resources/ocelot/desktop/sounds/minecraft/explosion.ogg b/src/main/resources/ocelot/desktop/sounds/minecraft/explosion.ogg new file mode 100644 index 0000000..dc55636 Binary files /dev/null and b/src/main/resources/ocelot/desktop/sounds/minecraft/explosion.ogg differ diff --git a/src/main/scala/ocelot/desktop/OcelotDesktop.scala b/src/main/scala/ocelot/desktop/OcelotDesktop.scala index 842bfa9..ae43109 100644 --- a/src/main/scala/ocelot/desktop/OcelotDesktop.scala +++ b/src/main/scala/ocelot/desktop/OcelotDesktop.scala @@ -2,6 +2,7 @@ package ocelot.desktop import buildinfo.BuildInfo import li.flor.nativejfilechooser.NativeJFileChooser +import ocelot.desktop.inventory.Items import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.swing.SplashScreen import ocelot.desktop.ui.widget._ @@ -27,7 +28,7 @@ import scala.collection.mutable.ArrayBuffer import scala.concurrent.duration.Duration import scala.io.Source import scala.jdk.CollectionConverters._ -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Success, Try, Using} object OcelotDesktop extends Logging { private val splashScreen = new SplashScreen() @@ -46,7 +47,7 @@ object OcelotDesktop extends Logging { private val tickLock: Lock = new ReentrantLock() - def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock, f) + def withTickLockAcquired(f: => Unit): Unit = withLockAcquired(tickLock)(f) private def mainInner(args: mutable.HashMap[Argument, Option[String]]): Unit = { logger.info("Starting up Ocelot Desktop") @@ -61,19 +62,21 @@ object OcelotDesktop extends Logging { else Paths.get(customConfigPath.get) Settings.load(settingsFile) + Messages.load(Source.fromURL(getClass.getResource("/ocelot/desktop/messages.txt"))) ColorScheme.load(Source.fromURL(getClass.getResource("/ocelot/desktop/colorscheme.txt"))) + Items.init() + splashScreen.setStatus("Initializing GUI...", 0.30f) createWorkspace() val loadRecentWorkspace = Settings.get.recentWorkspace.isDefined && Settings.get.openLastWorkspace root = new RootWidget(!loadRecentWorkspace) + UiHandler.loadLibraries() UiHandler.init(root) splashScreen.setStatus("Loading resources...", 0.60f) - // loading resources _after_ the UiHandler was initialized, because the native libraries are not available before - ResourceManager.initResources() splashScreen.setStatus("Loading workspace...", 0.90f) val cmdLineWorkspaceArgument = args.get(CommandLine.WorkspacePath).flatten @@ -102,28 +105,39 @@ object OcelotDesktop extends Logging { } } - new Thread(() => { - while (true) { - Profiler.startTimeMeasurement("tick") + val updateThread = new Thread(() => try { + val currentThread = Thread.currentThread() - withTickLockAcquired { - workspace.update() - tpsCounter.tick() + while (!currentThread.isInterrupted) { + Profiler.measure("tick") { + withTickLockAcquired { + workspace.update() + tpsCounter.tick() + } } - Profiler.endTimeMeasurement("tick") ticker.waitNext() } - }).start() + } 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(settingsFile) - ResourceManager.freeResources() UiHandler.terminate() + ResourceManager.checkEmpty() Ocelot.shutdown() @@ -173,22 +187,27 @@ object OcelotDesktop extends Logging { private var savePath: Option[Path] = None private val tmpPath = Files.createTempDirectory("ocelot-save") - def newWorkspace(): Unit = { + Runtime.getRuntime.addShutdownHook(new Thread(() => { + FileUtils.deleteDirectory(tmpPath.toFile) + })) + + def newWorkspace(): Unit = showCloseConfirmationDialog("Save workspace before opening a new one?") { root.workspaceView.newWorkspace() + savePath = None Settings.get.recentWorkspace = None } - def save(): Unit = { - if (savePath.isEmpty) { - saveAs() - return - } - + private def saveTo(outputPath: Path): Unit = { val oldPath = workspace.path - val newPath = savePath.get - if (oldPath != newPath) { - val oldFiles = Files.list(oldPath).iterator.asScala.toArray - val newFiles = Files.list(newPath).iterator.asScala.toArray + + 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) { @@ -201,7 +220,7 @@ object OcelotDesktop extends Logging { for (path <- oldFiles) { val oldFile = oldPath.resolve(path.getFileName).toFile - val newFile = newPath.resolve(path.getFileName).toFile + val newFile = outputPath.resolve(path.getFileName).toFile if (Files.isDirectory(path)) { FileUtils.copyDirectory(oldFile, newFile) } else { @@ -209,81 +228,80 @@ object OcelotDesktop extends Logging { } } - workspace.path = newPath + workspace.path = outputPath } - val path = newPath + "/workspace.nbt" - val writer = new DataOutputStream(new FileOutputStream(path)) - val nbt = new NBTTagCompound - saveWorld(nbt) - CompressedStreamTools.writeCompressed(nbt, writer) - writer.flush() - logger.info(s"Saved workspace to: $newPath") + val path = outputPath + "/workspace.nbt" + + Using.resource(new DataOutputStream(new FileOutputStream(path))) { writer => + val nbt = new NBTTagCompound + saveWorld(nbt) + CompressedStreamTools.writeCompressed(nbt, writer) + } + + logger.info(s"Saved workspace to: $outputPath") } - def saveAs(): Unit = { - showFileChooserDialog( - dir => Try { - if (dir.isEmpty) - return + def save(continuation: => Unit): Unit = savePath match { + case Some(savePath) => + saveTo(savePath) + continuation - savePath = dir.map(_.toPath) - Settings.get.recentWorkspace = dir.map(_.getCanonicalPath) - save() - }, - JFileChooser.SAVE_DIALOG, - JFileChooser.DIRECTORIES_ONLY - ) + case None => showSaveDialog(continuation) } - def open(): Unit = { - showFileChooserDialog({ + def saveAs(): Unit = showSaveDialog() + + def showOpenDialog(): Unit = + showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) { case Some(dir) => load(dir) case None => Success(()) - }, JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) - } + } def load(dir: File): Try[Unit] = { val path = Paths.get(dir.getCanonicalPath, "workspace.nbt") if (Files.exists(path)) { Try { - val reader = new DataInputStream(Files.newInputStream(path)) - val nbt = CompressedStreamTools.readCompressed(reader) - savePath = Some(dir.toPath) - Settings.get.recentWorkspace = Some(dir.getCanonicalPath) - workspace.path = dir.toPath - loadWorld(nbt) + Using.resource(new DataInputStream(Files.newInputStream(path))) { reader => + val nbt = CompressedStreamTools.readCompressed(reader) + savePath = Some(dir.toPath) + Settings.get.recentWorkspace = Some(dir.getCanonicalPath) + workspace.path = dir.toPath + loadWorld(nbt) + } } } else Failure(new FileNotFoundException("Specified directory does not contain 'workspace.nbt'")) } - def showFileChooserDialog(fun: Option[File] => Try[Unit], dialogType: Int, selectionMode: Int): Unit = { + def showFileChooserDialog(dialogType: Int, selectionMode: Int)(f: Option[File] => Try[Unit]): Unit = { new Thread(() => { val lastFile = savePath.map(_.toFile).orNull - val chooser: JFileChooser = try { - new NativeJFileChooser(lastFile) - } catch { - case _: Throwable => new JFileChooser(lastFile) - } + val chooser: JFileChooser = Option.when(Settings.get.useNativeFileChooser) { + Try(new NativeJFileChooser(lastFile)).toOption + }.flatten.getOrElse( + new JFileChooser(lastFile) + ) chooser.setFileSelectionMode(selectionMode) chooser.setDialogType(dialogType) - val option = chooser.showDialog(null, null) + val selectedFile = + Option.when(chooser.showDialog(null, null) == JFileChooser.APPROVE_OPTION)(chooser.getSelectedFile) + val result = f(selectedFile) - val result = fun(if (option == JFileChooser.APPROVE_OPTION) Some(chooser.getSelectedFile) else None) result match { case Failure(exception) => new NotificationDialog(s"Something went wrong!\n($exception)\nCheck the log file for a full stacktrace.", NotificationType.Error) .addCloseButton() .show() + case Success(_) => } }).start() } - def addPlayerDialog(): Unit = new InputDialog( + def showAddPlayerDialog(): Unit = new InputDialog( "Add new player", text => OcelotDesktop.selectPlayer(text) ).show() @@ -307,54 +325,10 @@ object OcelotDesktop extends Logging { } } - def settings(): Unit = { - new SettingsDialog().show() - } + def showSettings(): Unit = new SettingsDialog().show() - private def cleanup(): Unit = { - FileUtils.deleteDirectory(tmpPath.toFile) - } - - def exit(): Unit = { - if (UiHandler.root.modalDialogPool.children.exists(_.isInstanceOf[ExitConfirmationDialog])) - return - - if (Settings.get.saveOnExit && savePath.isDefined) { - save() - UiHandler.exit() - cleanup() - return - } - - new ExitConfirmationDialog { - override def onSaveSelected(): Unit = { - if (savePath.isDefined) { - save() - UiHandler.exit() - cleanup() - } else { - showFileChooserDialog( - dir => Try { - if (dir.isEmpty) - return - - savePath = dir.map(_.toPath) - Settings.get.recentWorkspace = dir.map(_.getCanonicalPath) - save() - UiHandler.exit() - cleanup() - }, - JFileChooser.SAVE_DIALOG, - JFileChooser.DIRECTORIES_ONLY - ) - } - } - - override def onExitSelected(): Unit = { - UiHandler.exit() - cleanup() - } - }.show() + def exit(): Unit = showCloseConfirmationDialog() { + UiHandler.exit() } var workspace: Workspace = _ @@ -362,4 +336,50 @@ object OcelotDesktop extends Logging { private def createWorkspace(): Unit = { workspace = new Workspace(tmpPath) } + + private def showSaveDialog(continuation: => Unit): Unit = + showFileChooserDialog(JFileChooser.SAVE_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir => + Try { + if (dir.nonEmpty) { + savePath = dir.map(_.toPath) + Settings.get.recentWorkspace = dir.map(_.getCanonicalPath) + save(continuation) + } + } + } + + 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) } diff --git a/src/main/scala/ocelot/desktop/Settings.scala b/src/main/scala/ocelot/desktop/Settings.scala index 7c191aa..67033d5 100644 --- a/src/main/scala/ocelot/desktop/Settings.scala +++ b/src/main/scala/ocelot/desktop/Settings.scala @@ -12,15 +12,23 @@ import java.util import scala.io.{Codec, Source} class Settings(val config: Config) extends SettingsData { + // TODO: refactor this mess (having to declare every field 3 times is extremely error-prone) + volumeMaster = (config.getDouble("ocelot.sound.volumeMaster") max 0 min 1).toFloat volumeBeep = (config.getDouble("ocelot.sound.volumeBeep") max 0 min 1).toFloat volumeEnvironment = (config.getDouble("ocelot.sound.volumeEnvironment") max 0 min 1).toFloat volumeInterface = (if (config.hasPath("ocelot.sound.volumeInterface")) config.getDouble("ocelot.sound.volumeInterface") max 0 min 1 else 0.5).toFloat + audioDisable = config.getBooleanOrElse("ocelot.sound.audioDisable", default = false) + logAudioErrorStacktrace = config.getBooleanOrElse("ocelot.sound.logAudioErrorStacktrace", default = false) + scaleFactor = (if (config.hasPath("ocelot.window.scaleFactor")) config.getDouble("ocelot.window.scaleFactor") max 1 min 3 else 1).toFloat windowSize = config.getInt2D("ocelot.window.size") windowPosition = config.getInt2D("ocelot.window.position") windowValidatePosition = config.getBooleanOrElse("ocelot.window.validatePosition", default = true) windowFullscreen = config.getBooleanOrElse("ocelot.window.fullscreen", default = false) + disableVsync = config.getBooleanOrElse("ocelot.window.disableVsync", default = false) + debugLwjgl = config.getBooleanOrElse("ocelot.window.debugLwjgl", default = false) + useNativeFileChooser = config.getBooleanOrElse("ocelot.window.useNativeFileChooser", default = true) // Windows uses life-hack when it sets position to (-8,-8) for maximized windows to hide the frame. // (https://devblogs.microsoft.com/oldnewthing/20150304-00/?p=44543) @@ -129,10 +137,16 @@ object Settings extends Logging { .withValuePreserveOrigin("ocelot.sound.volumeBeep", settings.volumeBeep) .withValuePreserveOrigin("ocelot.sound.volumeEnvironment", settings.volumeEnvironment) .withValuePreserveOrigin("ocelot.sound.volumeInterface", settings.volumeInterface) + .withValuePreserveOrigin("ocelot.sound.audioDisable", settings.audioDisable) + .withValuePreserveOrigin("ocelot.sound.logAudioErrorStacktrace", settings.logAudioErrorStacktrace) + .withValuePreserveOrigin("ocelot.window.scaleFactor", settings.scaleFactor.doubleValue) .withValue("ocelot.window.position", settings.windowPosition) .withValuePreserveOrigin("ocelot.window.validatePosition", settings.windowValidatePosition) .withValue("ocelot.window.size", settings.windowSize) .withValuePreserveOrigin("ocelot.window.fullscreen", settings.windowFullscreen) + .withValuePreserveOrigin("ocelot.window.disableVsync", settings.disableVsync) + .withValuePreserveOrigin("ocelot.window.debugLwjgl", settings.debugLwjgl) + .withValuePreserveOrigin("ocelot.window.useNativeFileChooser", settings.useNativeFileChooser) .withValue("ocelot.workspace.recent", settings.recentWorkspace) .withValuePreserveOrigin("ocelot.workspace.stickyWindows", settings.stickyWindows) .withValuePreserveOrigin("ocelot.workspace.saveOnExit", settings.saveOnExit) diff --git a/src/main/scala/ocelot/desktop/audio/AL10W.scala b/src/main/scala/ocelot/desktop/audio/AL10W.scala index cbf6820..26c7d85 100644 --- a/src/main/scala/ocelot/desktop/audio/AL10W.scala +++ b/src/main/scala/ocelot/desktop/audio/AL10W.scala @@ -1,6 +1,7 @@ package ocelot.desktop.audio -import ocelot.desktop.util.Logging +import ocelot.desktop.Settings +import ocelot.desktop.util.{Logging, OpenAlException} import org.lwjgl.openal.AL10 import java.nio.{ByteBuffer, IntBuffer} @@ -17,71 +18,97 @@ object AL10W extends Logging { case _: Exception => false } }).map(_.getName).getOrElse(err.toHexString) - logger.error(s"OpenAL error: ${func}: ${errName}") + val exc = OpenAlException(func, errName, err) + + if (Settings.get.logAudioErrorStacktrace) { + logger.error(exc) + } else { + logger.error(exc.getMessage) + } + + throw exc } + res } - def alIsExtensionPresent(name: String): Boolean = run("alIsExtensionPresent") { - AL10.alIsExtensionPresent(name) + def alIsExtensionPresent(name: String): Boolean = OpenAlException.defaulting(false) { + run("alIsExtensionPresent") { + AL10.alIsExtensionPresent(name) + } } + @throws[OpenAlException] def alGenBuffers(): Int = run("alGenBuffers") { AL10.alGenBuffers() } + @throws[OpenAlException] def alBufferData(buffer: Int, format: Int, data: ByteBuffer, freq: Int): Unit = run("alBufferData") { AL10.alBufferData(buffer, format, data, freq) } + @throws[OpenAlException] def alGetBufferi(buffer: Int, pname: Int): Int = run("alGetBufferi") { AL10.alGetBufferi(buffer, pname) } + @throws[OpenAlException] def alDeleteBuffers(buffer: Int): Unit = run("alDeleteBuffers") { AL10.alDeleteBuffers(buffer) } + @throws[OpenAlException] def alGenSources(): Int = run("alGenSources") { AL10.alGenSources() } + @throws[OpenAlException] def alSourceQueueBuffers(source: Int, buffer: Int): Unit = run("alSourceQueueBuffers") { AL10.alSourceQueueBuffers(source, buffer) } + @throws[OpenAlException] def alSourceUnqueueBuffers(source: Int, buffers: IntBuffer): Unit = run("alSourceUnqueueBuffers") { AL10.alSourceUnqueueBuffers(source, buffers) } + @throws[OpenAlException] def alGetSourcei(source: Int, pname: Int): Int = run("alGetSourcei") { AL10.alGetSourcei(source, pname) } + @throws[OpenAlException] def alSourcei(source: Int, pname: Int, value: Int): Unit = run("alSourcei") { AL10.alSourcei(source, pname, value) } + @throws[OpenAlException] def alSourcef(source: Int, pname: Int, value: Float): Unit = run("alSourcef") { AL10.alSourcef(source, pname, value) } + @throws[OpenAlException] def alSource3f(source: Int, pname: Int, v1: Float, v2: Float, v3: Float): Unit = run("alSource3f") { AL10.alSource3f(source, pname, v1, v2, v3) } + @throws[OpenAlException] def alSourcePlay(source: Int): Unit = run("alSourcePlay") { AL10.alSourcePlay(source) } + @throws[OpenAlException] def alSourcePause(source: Int): Unit = run("alSourcePause") { AL10.alSourcePause(source) } + @throws[OpenAlException] def alSourceStop(source: Int): Unit = run("alSourceStop") { AL10.alSourceStop(source) } + @throws[OpenAlException] def alDeleteSources(source: Int): Unit = run("alDeleteSources") { AL10.alDeleteSources(source) } diff --git a/src/main/scala/ocelot/desktop/audio/Audio.scala b/src/main/scala/ocelot/desktop/audio/Audio.scala index 63b2242..bb01d83 100644 --- a/src/main/scala/ocelot/desktop/audio/Audio.scala +++ b/src/main/scala/ocelot/desktop/audio/Audio.scala @@ -1,12 +1,13 @@ package ocelot.desktop.audio import ocelot.desktop.Settings -import ocelot.desktop.util.Logging +import ocelot.desktop.util.{Logging, OpenAlException, Transaction} import org.lwjgl.LWJGLException import org.lwjgl.openal.{AL, AL10, ALC10} import java.nio.{ByteBuffer, ByteOrder} import scala.collection.mutable +import scala.util.control.Exception.catching object Audio extends Logging { val sampleRate: Int = 44100 @@ -35,32 +36,52 @@ object Audio extends Logging { sources.size } - def newStream(soundCategory: SoundCategory.Value, pitch: Float = 1f, - volume: Float = 1f): (SoundStream, SoundSource) = - { + def newStream( + soundCategory: SoundCategory.Value, + pitch: Float = 1f, + volume: Float = 1f + ): (SoundStream, SoundSource) = { var source: SoundSource = null val stream = new SoundStream { override def enqueue(samples: SoundSamples): Unit = Audio.synchronized { - val sourceId = if (sources.contains(source)) { - sources(source) - } else { - val sourceId = AL10W.alGenSources() + OpenAlException.ignoring { + Transaction.runAbortable { tx => + if (!Audio.isDisabled) { + val sourceId = if (sources.contains(source)) { + sources(source) + } else { + Transaction.run { tx => + val sourceId = AL10W.alGenSources() + tx.onFailure { + AL10W.alDeleteSources(sourceId) + } - AL10W.alSourcef(sourceId, AL10.AL_PITCH, source.pitch) - AL10W.alSource3f(sourceId, AL10.AL_POSITION, 0f, 0f, 0f) - AL10W.alSourcef(sourceId, AL10.AL_GAIN, source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster) - sources.put(source, sourceId) + AL10W.alSourcef(sourceId, AL10.AL_PITCH, source.pitch) + AL10W.alSource3f(sourceId, AL10.AL_POSITION, 0f, 0f, 0f) + AL10W.alSourcef( + sourceId, + AL10.AL_GAIN, + source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster + ) + sources.put(source, sourceId) - sourceId + sourceId + } + } + + cleanupSourceBuffers(sourceId) + + val bufferId = samples.genBuffer().getOrElse { tx.abort() } + tx.onFailure { AL10W.alDeleteBuffers(bufferId) } + AL10W.alSourceQueueBuffers(sourceId, bufferId) + + if (AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) != AL10.AL_PLAYING) { + AL10W.alSourcePlay(sourceId) + } + } + } } - - cleanupSourceBuffers(sourceId) - - val bufferId = samples.genBuffer() - AL10W.alSourceQueueBuffers(sourceId, bufferId) - if (AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) != AL10.AL_PLAYING) - AL10W.alSourcePlay(sourceId) } } @@ -73,56 +94,96 @@ object Audio extends Logging { return SoundSource.Status.Stopped val sourceId = sources(source) - AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) match { - case AL10.AL_PLAYING => SoundSource.Status.Playing - case AL10.AL_PAUSED => SoundSource.Status.Paused + + catching(classOf[OpenAlException]) opt AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) match { + case Some(AL10.AL_PLAYING) => SoundSource.Status.Playing + case Some(AL10.AL_PAUSED) => SoundSource.Status.Paused case _ => SoundSource.Status.Stopped } } def playSource(source: SoundSource): Unit = synchronized { - if (getSourceStatus(source) == SoundSource.Status.Playing) - return + OpenAlException.ignoring { + if (Audio.isDisabled) { + return + } - if (sources.contains(source)) { - AL10W.alSourcePlay(sources(source)) - return + if (getSourceStatus(source) == SoundSource.Status.Playing) { + return + } + + if (sources.contains(source)) { + AL10W.alSourcePlay(sources(source)) + return + } + + Transaction.runAbortable { tx => + val sourceId = AL10W.alGenSources() + tx.onFailure { AL10W.alDeleteSources(sourceId) } + + source.kind match { + case SoundSource.KindSoundBuffer(buffer) => + buffer.bufferId match { + case Some(bufferId) => + AL10W.alSourcei(sourceId, AL10.AL_BUFFER, bufferId) + + case None => + logger.error(s"Called play on a SoundBuffer $buffer with bufferId = None") + tx.abort() + } + + case SoundSource.KindSoundSamples(samples) => + Transaction.run { innerTx => + val bufferId = samples.genBuffer().getOrElse { tx.abort() } + innerTx.onFailure { AL10W.alDeleteBuffers(bufferId) } + AL10W.alSourceQueueBuffers(sourceId, bufferId) + } + + case SoundSource.KindStream(_) => + } + + AL10W.alSourcef(sourceId, AL10.AL_PITCH, source.pitch) + AL10W.alSource3f(sourceId, AL10.AL_POSITION, 0f, 0f, 0f) + AL10W.alSourcei(sourceId, AL10.AL_LOOPING, if (source.looping) AL10.AL_TRUE else AL10.AL_FALSE) + AL10W.alSourcef( + sourceId, + AL10.AL_GAIN, + source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster + ) + AL10W.alSourcePlay(sourceId) + + sources.put(source, sourceId) + } } - - val sourceId = AL10W.alGenSources() - source.kind match { - case SoundSource.KindSoundBuffer(buffer) => - AL10W.alSourcei(sourceId, AL10.AL_BUFFER, buffer.bufferId) - case SoundSource.KindSoundSamples(samples) => - val bufferId = samples.genBuffer() - if (bufferId != -1) AL10W.alSourceQueueBuffers(sourceId, bufferId) - case SoundSource.KindStream(_) => - } - - AL10W.alSourcef(sourceId, AL10.AL_PITCH, source.pitch) - AL10W.alSource3f(sourceId, AL10.AL_POSITION, 0f, 0f, 0f) - AL10W.alSourcei(sourceId, AL10.AL_LOOPING, if (source.looping) AL10.AL_TRUE else AL10.AL_FALSE) - AL10W.alSourcef(sourceId, AL10.AL_GAIN, source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster) - AL10W.alSourcePlay(sourceId) - - sources.put(source, sourceId) } def pauseSource(source: SoundSource): Unit = synchronized { - if (getSourceStatus(source) == SoundSource.Status.Paused) - return + OpenAlException.ignoring { + if (Audio.isDisabled) { + return + } - if (sources.contains(source)) { - AL10W.alSourcePause(sources(source)) + if (getSourceStatus(source) == SoundSource.Status.Paused) + return + + if (sources.contains(source)) { + AL10W.alSourcePause(sources(source)) + } } } def stopSource(source: SoundSource): Unit = synchronized { - if (getSourceStatus(source) == SoundSource.Status.Stopped) - return + OpenAlException.ignoring { + if (Audio.isDisabled) { + return + } - if (sources.contains(source)) { - AL10W.alSourceStop(sources(source)) + if (getSourceStatus(source) == SoundSource.Status.Stopped) + return + + if (sources.contains(source)) { + AL10W.alSourceStop(sources(source)) + } } } @@ -130,14 +191,21 @@ object Audio extends Logging { if (isDisabled) return sources.filterInPlace { case (source, sourceId) => - cleanupSourceBuffers(sourceId) + OpenAlException.defaulting(false) { + cleanupSourceBuffers(sourceId) - AL10W.alSourcef(sourceId, AL10.AL_GAIN, source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster) - AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) match { - case AL10.AL_STOPPED => - deleteSource(sourceId) - false - case _ => true + AL10W.alSourcef( + sourceId, + AL10.AL_GAIN, + source.volume * SoundCategory.getSettingsValue(source.soundCategory) * Settings.get.volumeMaster + ) + + AL10W.alGetSourcei(sourceId, AL10.AL_SOURCE_STATE) match { + case AL10.AL_STOPPED => + deleteSource(sourceId) + false + case _ => true + } } } } @@ -146,19 +214,24 @@ object Audio extends Logging { if (isDisabled) return for ((_, sourceId) <- sources) { - deleteSource(sourceId) + OpenAlException.ignoring { + deleteSource(sourceId) + } } sources.clear() + AL.destroy() _disabled = true } + @throws[OpenAlException] private def deleteSource(sourceId: Int): Unit = { AL10W.alSourceStop(sourceId) cleanupSourceBuffers(sourceId) AL10W.alDeleteSources(sourceId) } + @throws[OpenAlException] private def cleanupSourceBuffers(sourceId: Int): Unit = { val count = AL10W.alGetSourcei(sourceId, AL10.AL_BUFFERS_PROCESSED) if (count <= 0) return diff --git a/src/main/scala/ocelot/desktop/audio/SoundBuffer.scala b/src/main/scala/ocelot/desktop/audio/SoundBuffer.scala index 926dcab..2622fc1 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundBuffer.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundBuffer.scala @@ -1,27 +1,29 @@ package ocelot.desktop.audio -import ocelot.desktop.util.{FileUtils, Logging, Resource, ResourceManager} +import ocelot.desktop.util.{FileUtils, Logging, OpenAlException, Resource} import org.lwjgl.openal.AL10 class SoundBuffer(val file: String) extends Resource with Logging { - private var _bufferId: Int = -1 + private var _bufferId: Option[Int] = None - ResourceManager.registerResource(this) + Audio.synchronized { + OpenAlException.ignoring { + if (!Audio.isDisabled) { + logger.debug(s"Loading sound buffer from '$file'...") + _bufferId = Some(AL10W.alGenBuffers()) - override def initResource(): Unit = { - if (Audio.isDisabled) - return - - logger.debug(s"Loading sound buffer from '$file'...") - _bufferId = AL10W.alGenBuffers() - - if (AL10W.alIsExtensionPresent("AL_EXT_vorbis")) { - initWithExt() - } else { - initFallback() + if (AL10W.alIsExtensionPresent("AL_EXT_vorbis")) { + initWithExt() + } else { + initFallback() + } + } else { + logger.debug(s"Skipping loading sound buffer from '$file' because audio is not available") + } } } + @throws[OpenAlException] private def initWithExt(): Unit = { val fileBuffer = FileUtils.load(file) if (fileBuffer == null) { @@ -29,40 +31,49 @@ class SoundBuffer(val file: String) extends Resource with Logging { return } - AL10W.alBufferData(bufferId, AL10.AL_FORMAT_VORBIS_EXT, fileBuffer, -1) + AL10W.alBufferData(bufferId.get, AL10.AL_FORMAT_VORBIS_EXT, fileBuffer, -1) } + @throws[OpenAlException] private def initFallback(): Unit = { val ogg = OggDecoder.decode(getClass.getResourceAsStream(file)) _bufferId = ogg.genBuffer() } - def numSamples: Int = { - if (bufferId == -1) { - return 0 + def numSamples: Int = bufferId match { + case Some(bufferId) => + OpenAlException.defaulting(0) { + val sizeBytes = AL10W.alGetBufferi(bufferId, AL10.AL_SIZE) + val channels = AL10W.alGetBufferi(bufferId, AL10.AL_CHANNELS) + val bits = AL10W.alGetBufferi(bufferId, AL10.AL_BITS) + + sizeBytes * 8 / channels / bits } - val sizeBytes = AL10W.alGetBufferi(bufferId, AL10.AL_SIZE) - val channels = AL10W.alGetBufferi(bufferId, AL10.AL_CHANNELS) - val bits = AL10W.alGetBufferi(bufferId, AL10.AL_BITS) - - sizeBytes * 8 / channels / bits + case None => 0 } - val sampleRate: Int = { - if (bufferId == -1) { - 44100 - } else { - AL10W.alGetBufferi(bufferId, AL10.AL_FREQUENCY) - } + val sampleRate: Int = bufferId match { + case Some(bufferId) => + OpenAlException.defaulting(44100) { + Audio.synchronized { + AL10W.alGetBufferi(bufferId, AL10.AL_FREQUENCY) + } + } + + case None => 44100 } - def bufferId: Int = _bufferId + def bufferId: Option[Int] = _bufferId - def freeResource(): Unit = { - if (bufferId != -1) { - AL10W.alDeleteBuffers(bufferId) - logger.debug(s"Destroyed sound buffer (ID: $bufferId) loaded from $file") + override def freeResource(): Unit = { + super.freeResource() + + bufferId.foreach { bufferId => + OpenAlException.ignoring { + AL10W.alDeleteBuffers(bufferId) + logger.debug(s"Destroyed sound buffer (ID: $bufferId) loaded from $file") + } } } } diff --git a/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala b/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala index 779f67f..7f202ac 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundBuffers.scala @@ -1,33 +1,51 @@ package ocelot.desktop.audio -object SoundBuffers { - val MachineComputerRunning = new SoundBuffer("/ocelot/desktop/sounds/machine/computer_running.ogg") - val MachineFloppyAccess: Array[SoundBuffer] = Array( - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access1.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access2.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access3.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access4.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access5.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_access6.ogg"), - ) - val MachineFloppyEject = new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_eject.ogg") - val MachineFloppyInsert = new SoundBuffer("/ocelot/desktop/sounds/machine/floppy_insert.ogg") - val MachineHDDAccess: Array[SoundBuffer] = Array( - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access1.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access2.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access3.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access4.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access5.ogg"), - new SoundBuffer("/ocelot/desktop/sounds/machine/hdd_access6.ogg"), - ) - val InterfaceClick = new SoundBuffer("/ocelot/desktop/sounds/interface/click.ogg") - val InterfaceTick = new SoundBuffer("/ocelot/desktop/sounds/interface/tick.ogg") - val MinecraftClick = new SoundBuffer("/ocelot/desktop/sounds/minecraft/click.ogg") +import ocelot.desktop.util.Resource - val NoteBlock: Map[String, SoundBuffer] = List( +import scala.collection.mutable.ArrayBuffer + +object SoundBuffers extends Resource { + lazy val MachineComputerRunning: SoundBuffer = load("/ocelot/desktop/sounds/machine/computer_running.ogg") + lazy val MachineFloppyAccess: Array[SoundBuffer] = Array( + load("/ocelot/desktop/sounds/machine/floppy_access1.ogg"), + load("/ocelot/desktop/sounds/machine/floppy_access2.ogg"), + load("/ocelot/desktop/sounds/machine/floppy_access3.ogg"), + load("/ocelot/desktop/sounds/machine/floppy_access4.ogg"), + load("/ocelot/desktop/sounds/machine/floppy_access5.ogg"), + load("/ocelot/desktop/sounds/machine/floppy_access6.ogg"), + ) + lazy val MachineFloppyEject: SoundBuffer = load("/ocelot/desktop/sounds/machine/floppy_eject.ogg") + lazy val MachineFloppyInsert: SoundBuffer = load("/ocelot/desktop/sounds/machine/floppy_insert.ogg") + lazy val MachineHDDAccess: Array[SoundBuffer] = Array( + load("/ocelot/desktop/sounds/machine/hdd_access1.ogg"), + load("/ocelot/desktop/sounds/machine/hdd_access2.ogg"), + load("/ocelot/desktop/sounds/machine/hdd_access3.ogg"), + load("/ocelot/desktop/sounds/machine/hdd_access4.ogg"), + load("/ocelot/desktop/sounds/machine/hdd_access5.ogg"), + load("/ocelot/desktop/sounds/machine/hdd_access6.ogg"), + ) + lazy val InterfaceClick: SoundBuffer = load("/ocelot/desktop/sounds/interface/click.ogg") + lazy val InterfaceTick: SoundBuffer = load("/ocelot/desktop/sounds/interface/tick.ogg") + lazy val MinecraftClick: SoundBuffer = load("/ocelot/desktop/sounds/minecraft/click.ogg") + lazy val MinecraftExplosion: SoundBuffer = load("/ocelot/desktop/sounds/minecraft/explosion.ogg") + + lazy val NoteBlock: Map[String, SoundBuffer] = List( "banjo", "basedrum", "bass", "bell", "bit", "chime", "cow_bell", "didgeridoo", "flute", "guitar", "harp", "hat", "iron_xylophone", "pling", "snare", "xylophone" ).map(name => { - (name, new SoundBuffer(s"/ocelot/desktop/sounds/minecraft/note_block/$name.ogg")) + (name, load(s"/ocelot/desktop/sounds/minecraft/note_block/$name.ogg")) }).toMap + + private val loaded = new ArrayBuffer[SoundBuffer]() + + private def load(file: String): SoundBuffer = { + val buffer = new SoundBuffer(file) + loaded.append(buffer) + buffer + } + + override def freeResource(): Unit = { + super.freeResource() + loaded.foreach(_.freeResource()) + } } diff --git a/src/main/scala/ocelot/desktop/audio/SoundSamples.scala b/src/main/scala/ocelot/desktop/audio/SoundSamples.scala index ccf172b..6fddac2 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundSamples.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundSamples.scala @@ -1,24 +1,28 @@ package ocelot.desktop.audio -import ocelot.desktop.util.Logging +import ocelot.desktop.util.{Logging, OpenAlException} import org.lwjgl.openal.AL10 import java.nio.ByteBuffer +import scala.util.control.Exception.catching case class SoundSamples(data: ByteBuffer, rate: Int, format: SoundSamples.Format.Value) extends Logging { - def genBuffer(): Int = { - val bufferId = AL10W.alGenBuffers() - val formatId = format match { - case SoundSamples.Format.Stereo16 => AL10.AL_FORMAT_STEREO16 - case SoundSamples.Format.Mono16 => AL10.AL_FORMAT_MONO16 - case SoundSamples.Format.Mono8 => AL10.AL_FORMAT_MONO8 + def genBuffer(): Option[Int] = Audio.synchronized { + if (Audio.isDisabled) return None + + catching(classOf[OpenAlException]) opt { + val bufferId = AL10W.alGenBuffers() + val formatId = format match { + case SoundSamples.Format.Stereo16 => AL10.AL_FORMAT_STEREO16 + case SoundSamples.Format.Mono16 => AL10.AL_FORMAT_MONO16 + case SoundSamples.Format.Mono8 => AL10.AL_FORMAT_MONO8 + } + + AL10W.alBufferData(bufferId, formatId, data, rate) + + bufferId } - - AL10W.alBufferData(bufferId, formatId, data, rate) - bufferId } - - } object SoundSamples { diff --git a/src/main/scala/ocelot/desktop/audio/SoundSource.scala b/src/main/scala/ocelot/desktop/audio/SoundSource.scala index 6784653..b0fd365 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundSource.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundSource.scala @@ -1,6 +1,7 @@ package ocelot.desktop.audio -import java.time.Duration +import java.util.concurrent.TimeUnit +import scala.concurrent.duration.Duration class SoundSource(val kind: SoundSource.Kind, val soundCategory: SoundCategory.Value, @@ -8,20 +9,21 @@ class SoundSource(val kind: SoundSource.Kind, val pitch: Float, var volume: Float) { - def duration: Duration = { - val seconds = kind match { - case SoundSource.KindSoundBuffer(buffer) => - buffer.numSamples.toFloat / buffer.sampleRate - case SoundSource.KindSoundSamples(SoundSamples(buffer, rate, format)) => - val bps = format match { - case SoundSamples.Format.Stereo16 => 2 - case SoundSamples.Format.Mono8 => 1 - case SoundSamples.Format.Mono16 => 2 - } - buffer.limit().toFloat / (rate * bps) - } + def duration: Option[Duration] = kind match { + case SoundSource.KindSoundBuffer(buffer) => + Some(Duration(buffer.numSamples.toFloat / buffer.sampleRate, TimeUnit.SECONDS)) - Duration.ofNanos((seconds * 1e9).toLong) + case SoundSource.KindSoundSamples(SoundSamples(buffer, rate, format)) => + val bps = format match { + case SoundSamples.Format.Stereo16 => 2 + case SoundSamples.Format.Mono8 => 1 + case SoundSamples.Format.Mono16 => 2 + } + + Some(Duration(buffer.limit().toFloat / (rate * bps), TimeUnit.SECONDS)) + + case SoundSource.KindStream(_) => + None } def status: SoundSource.Status.Value = { @@ -73,8 +75,7 @@ object SoundSource { } def fromStream(stream: SoundStream, soundCategory: SoundCategory.Value, - looping: Boolean = false, pitch: Float = 1f, volume: Float = 1f): SoundSource = - { + looping: Boolean = false, pitch: Float = 1f, volume: Float = 1f): SoundSource = { new SoundSource(SoundSource.KindStream(stream), soundCategory, looping, pitch, volume) } diff --git a/src/main/scala/ocelot/desktop/audio/SoundSources.scala b/src/main/scala/ocelot/desktop/audio/SoundSources.scala index 3dfd972..b788cde 100644 --- a/src/main/scala/ocelot/desktop/audio/SoundSources.scala +++ b/src/main/scala/ocelot/desktop/audio/SoundSources.scala @@ -7,4 +7,5 @@ object SoundSources { val InterfaceTick = SoundSource.fromBuffer(SoundBuffers.InterfaceTick, SoundCategory.Interface) val MinecraftClick = SoundSource.fromBuffer(SoundBuffers.MinecraftClick, SoundCategory.Interface) + val MinecraftExplosion = SoundSource.fromBuffer(SoundBuffers.MinecraftExplosion, SoundCategory.Environment) } diff --git a/src/main/scala/ocelot/desktop/entity/Camera.scala b/src/main/scala/ocelot/desktop/entity/Camera.scala index 4336d02..a1895ba 100644 --- a/src/main/scala/ocelot/desktop/entity/Camera.scala +++ b/src/main/scala/ocelot/desktop/entity/Camera.scala @@ -2,19 +2,15 @@ package ocelot.desktop.entity import ocelot.desktop.util.WebcamCapture import totoro.ocelot.brain.Constants -import totoro.ocelot.brain.entity.machine.{Arguments, Callback, Context} +import totoro.ocelot.brain.entity.machine.{Arguments, Context} import totoro.ocelot.brain.entity.traits.DeviceInfo.{DeviceAttribute, DeviceClass} import totoro.ocelot.brain.entity.traits.{DeviceInfo, Entity, GenericCamera} import totoro.ocelot.brain.nbt.NBTTagCompound import totoro.ocelot.brain.util.ResultWrapper.result import totoro.ocelot.brain.workspace.Workspace -object Camera { - private val DistanceCallCost = 20 -} - class Camera extends Entity with GenericCamera with DeviceInfo { - var webcamCapture: WebcamCapture = WebcamCapture.getDefault + var webcamCapture: Option[WebcamCapture] = WebcamCapture.getDefault var flipHorizontally: Boolean = false var flipVertically: Boolean = false var directCalls: Boolean = false @@ -23,31 +19,38 @@ class Camera extends Entity with GenericCamera with DeviceInfo { if (!directCalls) context.consumeCallBudget(1.0 / Camera.DistanceCallCost) - var x: Float = 0f - var y: Float = 0f - if (args.count() == 2) { - // [-1; 1] => [0; 1] - x = (args.checkDouble(0).toFloat + 1f) / 2f - y = (args.checkDouble(1).toFloat + 1f) / 2f - } + result(webcamCapture match { + case Some(webcamCapture) => + var x: Float = 0f + var y: Float = 0f - if (!flipHorizontally) x = 1f - x - if (!flipVertically) y = 1f - y - result(webcamCapture.ray(x, y)) + if (args.count() == 2) { + // [-1; 1] => [0; 1] + x = (args.checkDouble(0).toFloat + 1f) / 2f + y = (args.checkDouble(1).toFloat + 1f) / 2f + } + + if (!flipHorizontally) x = 1f - x + if (!flipVertically) y = 1f - y + + webcamCapture.ray(x, y) + + case None => 0f + }) } override def getDeviceInfo: Map[String, String] = Map( DeviceAttribute.Class -> DeviceClass.Multimedia, DeviceAttribute.Description -> "Dungeon Scanner 2.5D", DeviceAttribute.Vendor -> Constants.DeviceInfo.DefaultVendor, - DeviceAttribute.Product -> webcamCapture.name + DeviceAttribute.Product -> webcamCapture.map(_.name).getOrElse("Blind Pirate") ) override def load(nbt: NBTTagCompound, workspace: Workspace): Unit = { super.load(nbt, workspace) if (nbt.hasKey("device")) - webcamCapture = WebcamCapture.getInstance(nbt.getString("device")) + webcamCapture = WebcamCapture.getInstance(nbt.getString("device")).orElse(WebcamCapture.getDefault) if (nbt.hasKey("flipHorizontally")) flipHorizontally = nbt.getBoolean("flipHorizontally") @@ -62,9 +65,13 @@ class Camera extends Entity with GenericCamera with DeviceInfo { override def save(nbt: NBTTagCompound): Unit = { super.save(nbt) - nbt.setString("device", webcamCapture.name) + webcamCapture.foreach(capture => nbt.setString("device", capture.name)) nbt.setBoolean("flipHorizontally", flipHorizontally) nbt.setBoolean("flipVertically", flipVertically) nbt.setBoolean("directCalls", directCalls) } } + +object Camera { + private val DistanceCallCost = 20 +} diff --git a/src/main/scala/ocelot/desktop/entity/OpenFMRadio.scala b/src/main/scala/ocelot/desktop/entity/OpenFMRadio.scala index b677782..f674091 100644 --- a/src/main/scala/ocelot/desktop/entity/OpenFMRadio.scala +++ b/src/main/scala/ocelot/desktop/entity/OpenFMRadio.scala @@ -5,7 +5,6 @@ import ocelot.desktop.audio.{Audio, SoundCategory, SoundSamples, SoundSource} import ocelot.desktop.color.IntColor import ocelot.desktop.util.Logging import org.lwjgl.BufferUtils -import totoro.ocelot.brain.Ocelot.logger import totoro.ocelot.brain.entity.machine.{Arguments, Callback, Context} import totoro.ocelot.brain.entity.traits.{DeviceInfo, Entity, Environment} import totoro.ocelot.brain.nbt.NBTTagCompound @@ -21,7 +20,8 @@ import javax.sound.sampled.{AudioFormat, AudioSystem} class OpenFMRadio extends Entity with Environment with DeviceInfo with Logging { override val node: Component = - Network.newNode(this, Visibility.Network) + Network + .newNode(this, Visibility.Network) .withComponent("openfm_radio", Visibility.Network) .create() @@ -105,22 +105,13 @@ class OpenFMRadio extends Entity with Environment with DeviceInfo with Logging { bytesRead = inputStream.read(buffer, 0, buffer.length) } + + logger.info("OpenFM input audio stream has reached EOF, closing thread") } catch { case _: InterruptedException => case e: Exception => logger.error("OpenFM playback exception", e) } - - // Stopping playback and cleaning up resources after exit from read loop - this.synchronized { - if (playbackSoundSource.isDefined) { - playbackSoundSource.get.stop() - playbackSoundSource = None - } - - if (playbackThread.isDefined) - playbackThread = None - } } def play(): Boolean = { @@ -139,24 +130,35 @@ class OpenFMRadio extends Entity with Environment with DeviceInfo with Logging { true } - def stop(): Boolean = { + def stop(): Unit = { this.synchronized { - if (playbackThread.isDefined) + // Stopping reading data from url + if (playbackThread.isDefined) { playbackThread.get.interrupt() - } + playbackThread = None + } - true + // Stopping playback + if (playbackSoundSource.isDefined) { + playbackSoundSource.get.stop() + playbackSoundSource = None + } + } } - def isPlaying: Boolean = playbackThread.isDefined + def isPlaying: Boolean = + playbackSoundSource.isDefined && playbackSoundSource.get.isPlaying || playbackThread.isDefined @Callback() def start(context: Context, args: Arguments): Array[AnyRef] = result(play()) @Callback() - def stop(context: Context, args: Arguments): Array[AnyRef] = - result(stop()) + def stop(context: Context, args: Arguments): Array[AnyRef] = { + stop() + + result(true) + } @Callback() def isPlaying(context: Context, args: Arguments): Array[AnyRef] = diff --git a/src/main/scala/ocelot/desktop/geometry/Rect2D.scala b/src/main/scala/ocelot/desktop/geometry/Rect2D.scala index 4cbbe9a..d0d652e 100644 --- a/src/main/scala/ocelot/desktop/geometry/Rect2D.scala +++ b/src/main/scala/ocelot/desktop/geometry/Rect2D.scala @@ -8,9 +8,9 @@ object Rect2D { //noinspection ScalaWeakerAccess,ScalaUnusedSymbol case class Rect2D(x: Float, y: Float, w: Float, h: Float) { - def contains(p: Vector2D): Boolean = p.x >= x && p.y >= y && p.x <= x + w && p.y <= y + h + def contains(p: Vector2D): Boolean = p.x >= x && p.y >= y && p.x < x + w && p.y < y + h - def contains(r: Rect2D): Boolean = r.x >= x && r.y >= y && r.x + r.w <= x + w && r.y + r.h <= y + h + def contains(r: Rect2D): Boolean = r.x >= x && r.y >= y && r.x + r.w < x + w && r.y + r.h < y + h def origin: Vector2D = Vector2D(x, y) diff --git a/src/main/scala/ocelot/desktop/graphics/Graphics.scala b/src/main/scala/ocelot/desktop/graphics/Graphics.scala index c655f70..396ba52 100644 --- a/src/main/scala/ocelot/desktop/graphics/Graphics.scala +++ b/src/main/scala/ocelot/desktop/graphics/Graphics.scala @@ -13,7 +13,7 @@ import scala.collection.mutable import scala.util.control.Breaks._ //noinspection ScalaWeakerAccess,ScalaUnusedSymbol -class Graphics extends Logging with Resource { +class Graphics(private var scalingFactor: Float) extends Logging with Resource { private var time = 0f private var projection = Transform2D.viewport(800, 600) @@ -29,6 +29,7 @@ class Graphics extends Logging with Resource { private var oldFont: Font = _font private val stack = mutable.Stack[GraphicsState](GraphicsState()) + private var spriteRect = Spritesheet.sprites("Empty") private val emptySpriteTrans = Transform2D.translate(spriteRect.x, spriteRect.y) >> Transform2D.scale(spriteRect.w, spriteRect.h) @@ -43,16 +44,43 @@ class Graphics extends Logging with Resource { shaderProgram.set("uTexture", 0) shaderProgram.set("uTextTexture", 1) - def resize(width: Int, height: Int): Unit = { - offscreenTexture.bind() - GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGB, width, height, 0, GL11.GL_RGB, - GL11.GL_UNSIGNED_BYTE, null.asInstanceOf[ByteBuffer]) - GL11.glViewport(0, 0, width, height) + scale(scalingFactor) + + def resize(width: Int, height: Int, scaling: Float): Boolean = { + var viewportChanged = false + + if (scaling != scalingFactor) { + scalingFactor = scaling + stack.last.transform = Transform2D.scale(scalingFactor) + viewportChanged = true + } + + if (this.width != width || this.height != height) { + this.width = width + this.height = height + + offscreenTexture.bind() + GL11.glTexImage2D(GL11.GL_TEXTURE_2D, 0, GL11.GL_RGB, width, height, 0, GL11.GL_RGB, + GL11.GL_UNSIGNED_BYTE, null.asInstanceOf[ByteBuffer]) + GL11.glViewport(0, 0, width, height) + + viewportChanged = true + } + viewportChanged } override def freeResource(): Unit = { - logger.debug(s"Destroyed FBO (ID: $offscreenFramebuffer)") + super.freeResource() + GL30.glDeleteFramebuffers(offscreenFramebuffer) + logger.debug(s"Destroyed FBO (ID: $offscreenFramebuffer)") + + offscreenTexture.freeResource() + Spritesheet.freeResource() + smallFont.freeResource() + normalFont.freeResource() + renderer.freeResource() + shaderProgram.freeResource() } def font: Font = _font @@ -106,16 +134,13 @@ class Graphics extends Logging with Resource { offscreenTexture.bind() foreground = RGBAColorNorm(1, 1, 1, alpha) spriteRect = Rect2D(0, 1f, 1f, -1f) - _rect(0, 0, width, height, fixUV = false) + + _rect(0, 0, width / scalingFactor, height / scalingFactor, fixUV = false) renderer.flush() } - def setViewport(width: Int, height: Int): Unit = { - projection = Transform2D.viewport(width, height) - this.width = width - this.height = height - - shaderProgram.set("uProj", projection) + def startViewport(): Unit = { + shaderProgram.set("uProj", Transform2D.viewport(width, height)) } def clear(): Unit = { @@ -124,16 +149,19 @@ class Graphics extends Logging with Resource { GL11.glClear(GL11.GL_COLOR_BUFFER_BIT) } - def setScissor(x: Int, y: Int, width: Int, height: Int): Unit = { + def setScissor(x: Float, y: Float, width: Float, height: Float): Unit = { flush() - stack.head.scissor = Some((x, y, width, height)) - GL11.glScissor(x, this.height - height - y, width, height) + stack.head.scissor = Some((x, y, width * scalingFactor, height * scalingFactor)) + // TODO: make them aware of the transform stack + GL11.glScissor( + Math.round(x * scalingFactor), + Math.round(this.height - height * scalingFactor - y * scalingFactor), + Math.round(width * scalingFactor), + Math.round(height * scalingFactor) + ) GL11.glEnable(GL11.GL_SCISSOR_TEST) } - def setScissor(x: Float, y: Float, width: Float, height: Float): Unit = { - setScissor(x.round, y.round, width.round, height.round) - } def setScissor(): Unit = { flush() @@ -207,7 +235,7 @@ class Graphics extends Logging with Resource { } } - def char(_x: Float, _y: Float, c: Char, scaleX: Float = 1f, scaleY: Float = 1f): Unit = { + def char(_x: Float, _y: Float, c: Int, scaleX: Float = 1f, scaleY: Float = 1f): Unit = { val fontSize = fontSizeMultiplier * _font.fontSize val x = _x.round val y = _y.round diff --git a/src/main/scala/ocelot/desktop/graphics/GraphicsState.scala b/src/main/scala/ocelot/desktop/graphics/GraphicsState.scala index 620a089..16235cd 100644 --- a/src/main/scala/ocelot/desktop/graphics/GraphicsState.scala +++ b/src/main/scala/ocelot/desktop/graphics/GraphicsState.scala @@ -9,6 +9,6 @@ case class GraphicsState( var fontSizeMultiplier: Float = 1f, var alphaMultiplier: Float = 1f, var sprite: String = "Empty", - var scissor: Option[(Int, Int, Int, Int)] = None, + var scissor: Option[(Float, Float, Float, Float)] = None, var transform: Transform2D = Transform2D.identity ) diff --git a/src/main/scala/ocelot/desktop/graphics/Icons.scala b/src/main/scala/ocelot/desktop/graphics/Icons.scala new file mode 100644 index 0000000..5ab8510 --- /dev/null +++ b/src/main/scala/ocelot/desktop/graphics/Icons.scala @@ -0,0 +1,96 @@ +package ocelot.desktop.graphics + +import ocelot.desktop.graphics.IconDef.Animation +import ocelot.desktop.ui.widget.modal.notification.NotificationType.NotificationType +import totoro.ocelot.brain.util.DyeColor +import totoro.ocelot.brain.util.ExtendedTier.ExtendedTier +import totoro.ocelot.brain.util.Tier.Tier + +object Icons { + // Icons + val CardIcon: IconDef = IconDef("icons/Card") + val CpuIcon: IconDef = IconDef("icons/CPU") + val HddIcon: IconDef = IconDef("icons/HDD") + val EepromIcon: IconDef = IconDef("icons/EEPROM") + val FloppyIcon: IconDef = IconDef("icons/Floppy") + val MemoryIcon: IconDef = IconDef("icons/Memory") + + val TierIcon: Tier => IconDef = { tier => + IconDef(s"icons/Tier${tier.id}") + } + + val NotificationIcon: NotificationType => IconDef = { notificationType => + IconDef(s"icons/Notification$notificationType", 4) + } + + val SettingsSound: IconDef = IconDef("icons/SettingsSound") + val SettingsUI: IconDef = IconDef("icons/SettingsUI") + + // Items + val Cpu: Tier => IconDef = { tier => + IconDef(s"items/CPU${tier.id}") + } + + val Apu: Tier => IconDef = { tier => + IconDef(s"items/APU${tier.id}", animation = Some(Animations.Apu)) + } + + val GraphicsCard: Tier => IconDef = { tier => + IconDef(s"items/GraphicsCard${tier.id}") + } + + val NetworkCard: IconDef = IconDef("items/NetworkCard") + + val WirelessNetworkCard: Tier => IconDef = { tier => + IconDef(s"items/WirelessNetworkCard${tier.id}") + } + + val LinkedCard: IconDef = IconDef("items/LinkedCard", animation = Some(Animations.LinkedCard)) + + val InternetCard: IconDef = IconDef("items/InternetCard", animation = Some(Animations.InternetCard)) + + val RedstoneCard: Tier => IconDef = { tier => + IconDef(s"items/RedstoneCard${tier.id}") + } + + val DataCard: Tier => IconDef = { tier => + IconDef(s"items/DataCard${tier.id}", animation = Some(Animations.DataCard)) + } + + val SoundCard: IconDef = IconDef("items/SoundCard", animation = Some(Animations.DataCard)) + + val SelfDestructingCard: IconDef = IconDef("items/SelfDestructingCard", animation = Some(Animations.SelfDestructingCard)) + + val HardDiskDrive: Tier => IconDef = { tier => + IconDef(s"items/HardDiskDrive${tier.id}") + } + + val Eeprom: IconDef = IconDef("items/EEPROM") + + val FloppyDisk: DyeColor => IconDef = { color => + IconDef(s"items/FloppyDisk_${color.name}") + } + + val Memory: ExtendedTier => IconDef = { tier => + IconDef(s"items/Memory${tier.id}") + } + + //noinspection ScalaWeakerAccess + object Animations { + val Apu: Animation = Array( + (0, 3f), (1, 3f), (2, 3f), (3, 3f), (4, 3f), (5, 3f), + (4, 3f), (3, 3f), (2, 3f), (1, 3f), (0, 3f)) + + val LinkedCard: Animation = + Array((0, 3f), (1, 3f), (2, 3f), (3, 3f), (4, 3f), (5, 3f)) + + val InternetCard: Animation = Array( + (0, 2f), (1, 7f), (0, 5f), (1, 4f), (0, 7f), (1, 2f), (0, 8f), + (1, 9f), (0, 6f), (1, 4f)) + + val DataCard: Animation = Array( + (0, 4f), (1, 4f), (2, 4f), (3, 4f), (4, 4f), (5, 4f), (6, 4f), (7, 4f)) + + val SelfDestructingCard: Animation = Array((0, 4f), (1, 4f)) + } +} diff --git a/src/main/scala/ocelot/desktop/graphics/ShaderProgram.scala b/src/main/scala/ocelot/desktop/graphics/ShaderProgram.scala index 1b3f749..15650b9 100644 --- a/src/main/scala/ocelot/desktop/graphics/ShaderProgram.scala +++ b/src/main/scala/ocelot/desktop/graphics/ShaderProgram.scala @@ -1,7 +1,7 @@ package ocelot.desktop.graphics import ocelot.desktop.geometry.Transform2D -import ocelot.desktop.util.{Logging, Resource, ResourceManager} +import ocelot.desktop.util.{Logging, Resource} import org.lwjgl.BufferUtils import org.lwjgl.opengl.GL20 @@ -34,13 +34,12 @@ class ShaderProgram(name: String) extends Logging with Resource { } } - ResourceManager.registerResource(this) - - def freeResource(): Unit = { - logger.debug(s"Destroyed shader program ($name)") + override def freeResource(): Unit = { + super.freeResource() GL20.glDeleteProgram(shaderProgram) GL20.glDeleteShader(vertexShader) GL20.glDeleteShader(fragmentShader) + logger.debug(s"Destroyed shader program ($name)") } def bind(): Unit = { diff --git a/src/main/scala/ocelot/desktop/graphics/Texture.scala b/src/main/scala/ocelot/desktop/graphics/Texture.scala index 5a1ca6c..5a7254a 100644 --- a/src/main/scala/ocelot/desktop/graphics/Texture.scala +++ b/src/main/scala/ocelot/desktop/graphics/Texture.scala @@ -1,6 +1,6 @@ package ocelot.desktop.graphics -import ocelot.desktop.util.{Logging, Resource, ResourceManager} +import ocelot.desktop.util.{Logging, Resource} import org.lwjgl.BufferUtils import org.lwjgl.opengl._ @@ -10,13 +10,6 @@ import java.nio.ByteBuffer class Texture extends Logging with Resource { val texture: Int = GL11.glGenTextures() - ResourceManager.registerResource(this) - - def freeResource(): Unit = { - logger.debug(s"Destroyed texture (ID: $texture)") - GL11.glDeleteTextures(texture) - } - bind() GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MIN_FILTER, GL11.GL_LINEAR) GL11.glTexParameteri(GL11.GL_TEXTURE_2D, GL11.GL_TEXTURE_MAG_FILTER, GL11.GL_NEAREST) @@ -75,4 +68,10 @@ class Texture extends Logging with Resource { GL13.glActiveTexture(GL13.GL_TEXTURE0 + unit) GL11.glBindTexture(GL11.GL_TEXTURE_2D, texture) } + + override def freeResource(): Unit = { + super.freeResource() + GL11.glDeleteTextures(texture) + logger.debug(s"Destroyed texture (ID: $texture)") + } } diff --git a/src/main/scala/ocelot/desktop/graphics/buffer/Buffer.scala b/src/main/scala/ocelot/desktop/graphics/buffer/Buffer.scala index 092dc7a..bf83198 100644 --- a/src/main/scala/ocelot/desktop/graphics/buffer/Buffer.scala +++ b/src/main/scala/ocelot/desktop/graphics/buffer/Buffer.scala @@ -12,9 +12,10 @@ class Buffer[T <: BufferPut] extends Logging with Resource { var capacity: Int = _ var stride: Int = _ - def freeResource(): Unit = { - logger.debug(s"Destroyed buffer (ID: $buffer) of ${capacity * stride} bytes") + override def freeResource(): Unit = { + super.freeResource() GL15.glDeleteBuffers(buffer) + logger.debug(s"Destroyed buffer (ID: $buffer) of ${capacity * stride} bytes") } def this(elements: Seq[T]) = { diff --git a/src/main/scala/ocelot/desktop/graphics/mesh/VertexArray.scala b/src/main/scala/ocelot/desktop/graphics/mesh/VertexArray.scala index 8a28f14..9547454 100644 --- a/src/main/scala/ocelot/desktop/graphics/mesh/VertexArray.scala +++ b/src/main/scala/ocelot/desktop/graphics/mesh/VertexArray.scala @@ -2,7 +2,7 @@ package ocelot.desktop.graphics.mesh import ocelot.desktop.graphics.ShaderProgram import ocelot.desktop.graphics.buffer.{IndexBuffer, VertexBuffer} -import ocelot.desktop.util.{Logging, Resource, ResourceManager} +import ocelot.desktop.util.{Logging, Resource} import org.lwjgl.opengl._ class VertexArray(shader: ShaderProgram) extends Logging with Resource { @@ -13,16 +13,6 @@ class VertexArray(shader: ShaderProgram) extends Logging with Resource { else ARBVertexArrayObject.glGenVertexArrays() - ResourceManager.registerResource(this) - - def freeResource(): Unit = { - logger.debug(s"Destroyed VAO (ID: $array)") - if (isMacOS) - APPLEVertexArrayObject.glDeleteVertexArraysAPPLE(array) - else - ARBVertexArrayObject.glDeleteVertexArrays(array) - } - def addVertexBuffer[V <: Vertex](buffer: VertexBuffer[V], instanced: Boolean = false): Unit = { bind() @@ -47,4 +37,13 @@ class VertexArray(shader: ShaderProgram) extends Logging with Resource { APPLEVertexArrayObject.glBindVertexArrayAPPLE(array) else ARBVertexArrayObject.glBindVertexArray(array) + + override def freeResource(): Unit = { + super.freeResource() + if (isMacOS) + APPLEVertexArrayObject.glDeleteVertexArraysAPPLE(array) + else + ARBVertexArrayObject.glDeleteVertexArrays(array) + logger.debug(s"Destroyed VAO (ID: $array)") + } } diff --git a/src/main/scala/ocelot/desktop/graphics/render/InstanceRenderer.scala b/src/main/scala/ocelot/desktop/graphics/render/InstanceRenderer.scala index dc3389e..5deb931 100644 --- a/src/main/scala/ocelot/desktop/graphics/render/InstanceRenderer.scala +++ b/src/main/scala/ocelot/desktop/graphics/render/InstanceRenderer.scala @@ -3,14 +3,14 @@ package ocelot.desktop.graphics.render import ocelot.desktop.graphics.ShaderProgram import ocelot.desktop.graphics.buffer.{IndexBuffer, VertexBuffer} import ocelot.desktop.graphics.mesh.{Mesh, MeshInstance, MeshVertex, VertexArray} -import ocelot.desktop.util.Logging +import ocelot.desktop.util.{Logging, Resource} import org.lwjgl.BufferUtils import org.lwjgl.opengl._ import java.nio.ByteBuffer import scala.collection.mutable.ArrayBuffer -class InstanceRenderer(mesh: Mesh, shader: ShaderProgram) extends Logging { +class InstanceRenderer(mesh: Mesh, shader: ShaderProgram) extends Resource with Logging { private val InitialCapacity: Int = 4096 private val vertexBuffer = new VertexBuffer[MeshVertex](mesh.vertices) @@ -81,4 +81,12 @@ class InstanceRenderer(mesh: Mesh, shader: ShaderProgram) extends Logging { data.flip data } + + override def freeResource(): Unit = { + super.freeResource() + vertexArray.freeResource() + instanceBuffer.freeResource() + indexBuffer.foreach(_.freeResource()) + vertexBuffer.freeResource() + } } diff --git a/src/main/scala/ocelot/desktop/inventory/Inventory.scala b/src/main/scala/ocelot/desktop/inventory/Inventory.scala new file mode 100644 index 0000000..48570f6 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/Inventory.scala @@ -0,0 +1,232 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.inventory.Inventory.SlotObserver + +import scala.collection.mutable + +/** + * Provides an inventory — a collection of [[Item]]s indexed by slots. + */ +trait Inventory { + // parallels totoro.ocelot.brain.entity.traits.Inventory + // this is intentional + + /** + * The type of items stored in this inventory. + */ + type I <: Item + + private type WeakHashSet[A] = mutable.WeakHashMap[A, Unit] + + private val slotItems = mutable.HashMap.empty[Int, I] + private val itemSlots = mutable.HashMap.empty[I, Int] + private val observers = mutable.HashMap.empty[Int, WeakHashSet[SlotObserver]] + + /** + * Called after a new item is added to the inventory. + * + * @param slot the slot the item was added to + */ + def onItemAdded(slot: Slot): Unit + + /** + * Called after an item is removed from the inventory. + * + * When the item is replaced by another one, the event are sequenced in the following order: + * + * 1. the old item is removed + * 1. [[onItemRemoved]] is called, with `replacedBy` containing the new item + * 1. the new item is added + * 1. [[onItemAdded]] is called + * + * @param slot the slot the item was removed from + * @param removedItem the previously present item + * @param replacedBy if known, the item it was replaced by ([[onItemAdded]] is still called) + */ + def onItemRemoved(slot: Slot, removedItem: I, replacedBy: Option[I]): Unit + + /** + * An iterator over all slots occupied in this inventory. + */ + def inventoryIterator: Iterator[Slot] = slotItems.keysIterator.map(Slot(_)) + + def clearInventory(): Unit = { + for (slot <- slotItems.keys.toArray) { + Slot(slot).remove() + } + } + + private def slotObservers(slotIndex: Int): Iterator[SlotObserver] = + observers.get(slotIndex).iterator.flatMap(_.view.keys) + + private def onItemAddedImpl(slot: Slot): Unit = { + onItemAdded(slot) + slotObservers(slot.index).foreach(_.onItemAdded()) + } + + private def onItemRemovedImpl(slot: Slot, removedItem: I, replacedBy: Option[I]): Unit = { + onItemRemoved(slot, removedItem, replacedBy) + slotObservers(slot.index).foreach(_.onItemRemoved(removedItem, replacedBy)) + } + + /** + * A proxy to access a slot of the inventory. + */ + final class Slot private[Inventory](val index: Int) { + require(index >= 0) + + def isEmpty: Boolean = get.isEmpty + + def nonEmpty: Boolean = !isEmpty + + /** + * Inserts the `item` into this slot (replacing the previous item if any). + */ + def put(item: inventory.I): Unit = { + setSlot(index, Some(item)) + } + + /** + * Allows inserting/removing the item in this slot. + */ + def set(item: Option[inventory.I]): Unit = { + setSlot(index, item) + } + + /** + * Removes the item contained in this slot if there is one. + */ + def remove(): Unit = { + setSlot(index, None) + } + + /** + * The [[Item]] contained in this slot. + */ + def get: Option[inventory.I] = slotItems.get(index) + + val inventory: Inventory.this.type = Inventory.this + + /** + * Registers an observer to receive item added/removed events. + * + * @note The inventory keeps a '''weak''' reference to the `observer`. + */ + def addObserver(observer: SlotObserver): Unit = { + observers.getOrElseUpdate(index, new WeakHashSet) += observer -> () + } + + def removeObserver(observer: SlotObserver): Unit = { + for (slotObservers <- observers.get(index)) { + slotObservers.remove(observer) + + if (slotObservers.isEmpty) { + observers.remove(index) + } + } + } + + private[inventory] def notifySlot(notification: Item.Notification): Unit = { + slotObservers(index).foreach(_.onItemNotification(notification)) + } + + override def equals(other: Any): Boolean = other match { + case that: Inventory#Slot => + // in case you're wondering wtf this is: + // IntelliJ IDEA has a bug that wrongly rejects `inventory == that.inventory` due to a typing error + // (the code is accepted by the compiler though) + // stripping this.type seems to help + val thisInv = classOf[Inventory].cast(inventory) + val thatInv = classOf[Inventory].cast(that.inventory) + thisInv == thatInv && index == that.index + + case _ => false + } + + override def hashCode(): Int = { + val state = Seq(inventory, index) + state.map(_.hashCode()).foldLeft(0)((a, b) => 31 * a + b) + } + } + + final object Slot { + /** + * Creates a proxy to an inventory slot. + */ + def apply(index: Int) = new Slot(index) + } + + private def setSlot(index: Int, item: Option[I]): Unit = { + val slot = Slot(index) + + (slotItems.get(index), item) match { + case (Some(oldItem), Some(newItem)) if oldItem == newItem => + // no-op + + case (Some(oldItem), Some(newItem)) => + // replace old with new + doRemove(index) + onItemRemovedImpl(slot, oldItem, Some(newItem)) + doInsert(index, newItem) + onItemAddedImpl(slot) + + case (Some(oldItem), None) => + // remove old + doRemove(index) + onItemRemovedImpl(slot, oldItem, None) + + case (None, Some(newItem)) => + // add new + doInsert(index, newItem) + onItemAddedImpl(slot) + + case (None, None) => + // no-op + } + } + + private def doRemove(index: Int): Unit = { + for (oldItem <- slotItems.remove(index)) { + assert(oldItem.slot.exists(_.index == index)) + oldItem.slot = None + + val oldIndex = itemSlots.remove(oldItem) + assert(oldIndex.contains(index)) + } + } + + private def doInsert(index: Int, item: I): Unit = { + assert(!itemSlots.contains(item)) + assert(!slotItems.contains(index)) + assert(item.slot.isEmpty) + + slotItems(index) = item + itemSlots(item) = index + item.slot = Some(Slot(index)) + } +} + +object Inventory { + trait SlotObserver { + /** + * Called after an item was inserted into this slot. + * + * @note [[Inventory.onItemAdded]] is called before this method. + */ + def onItemAdded(): Unit + + /** + * Called after an item was removed from this slot. + * + * In particular, the slot no longer contains the removed item. + * + * @note [[Inventory.onItemRemoved]] is called before this method. + */ + def onItemRemoved(removedItem: Item, replacedBy: Option[Item]): Unit + + /** + * Called when an item contained in this slot sends a notification via [[Item.notifySlot]]. + */ + def onItemNotification(notification: Item.Notification): Unit + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/Item.scala b/src/main/scala/ocelot/desktop/inventory/Item.scala new file mode 100644 index 0000000..3fe127b --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/Item.scala @@ -0,0 +1,92 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.ColorScheme +import ocelot.desktop.color.Color +import ocelot.desktop.graphics.IconDef +import ocelot.desktop.ui.widget.contextmenu.ContextMenu +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +/** + * Something that can be stored in an [[Inventory]]. + */ +trait Item { + private var _slot: Option[Inventory#Slot] = None + + /** + * The slot this item is stored in. + */ + def slot: Option[Inventory#Slot] = _slot + + private[inventory] def slot_=(slot: Option[Inventory#Slot]): Unit = { + _slot = slot + } + + /** + * Sends a `notification` to observers of the `slot`. + * + * (Which means, if the item is not put into any slot, nobody will receive the message.) + */ + final def notifySlot(notification: Item.Notification): Unit = { + slot.foreach(_.notifySlot(notification)) + } + + /** + * The name of the item (as shown in the tooltip). + */ + def name: String = factory.name + + /** + * The icon used to draw the item in a slot. + */ + def icon: IconDef = factory.icon + + /** + * The tier of the item (if it has one). + * + * This affects the color of the item name in the tooltip. + */ + def tier: Option[Tier] = factory.tier + + protected def tooltipNameColor: Color = ColorScheme("Tier" + tier.getOrElse(Tier.One).id) + + /** + * Override this in subclasses to customize the contents of the tooltip. + * + * @example {{{ + * override protected def fillTooltipBody(body: Widget): Unit = { + * super.fillTooltipBody(body) + * body.children :+= new Label { + * override def text: String = "Hello world" + * override def color: Color = Color.Grey + * } + * } + * }}} + */ + def fillTooltip(tooltip: ItemTooltip): Unit = { + tooltip.addLine(name, tooltipNameColor) + } + + /** + * Override this in subclasses to add new entries to the context menu. + * + * It usually makes sense to call `super.fillRmbMenu` ''after'' you've added your own entries. + */ + def fillRmbMenu(menu: ContextMenu): Unit = {} + + /** + * The factory that can be used to build an independent instance of this [[Item]] + * in a way equivalent to this one (e.g. the same tier, label, EEPROM code). + * + * Keep this rather cheap. + */ + def factory: ItemFactory +} + +object Item { + /** + * A notification that can be sent with [[Item.notifySlot]]. + */ + trait Notification +} diff --git a/src/main/scala/ocelot/desktop/inventory/ItemFactory.scala b/src/main/scala/ocelot/desktop/inventory/ItemFactory.scala new file mode 100644 index 0000000..a451cd1 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/ItemFactory.scala @@ -0,0 +1,59 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.graphics.IconDef +import totoro.ocelot.brain.util.Tier.Tier + +/** + * Provides information about a class of [[Item]]s and allows to build an item instance. + * + * Used by [[ocelot.desktop.ui.widget.slot.SlotWidget SlotWidgets]] + * (and [[ocelot.desktop.ui.widget.slot.ItemChooser]]) to tell if you can insert an item into the slot + * without having to actually construct one. + * In particular, make sure the [[tier]] and [[itemClass]] provided by the factory are accurate. + */ +trait ItemFactory { + /** + * The concrete type of the [[Item]] built by the factory. + */ + type I <: Item + + /** + * The runtime class of the [[Item]] built by the factory. + * + * @note It's expected that `build().getClass == itemClass`. + */ + def itemClass: Class[I] + + /** + * A name that represents what will be built by the factory. + * + * Usually [[name]] and [[Item.name]] are the same (in fact, the latter defaults to this unless overridden). + * + * Used by the [[ocelot.desktop.ui.widget.slot.ItemChooser ItemChooser]] in its entries. + */ + def name: String + + /** + * The tier of an item this factory will construct in its [[build]] method. + * + * @note It's expected that `build().tier == tier`. + */ + def tier: Option[Tier] + + /** + * The icon of an item this factory will construct in its [[build]] method. + * + * Used by the [[ocelot.desktop.ui.widget.slot.ItemChooser ItemChooser]] in its entries. + */ + def icon: IconDef + + /** + * Constructs a new item instance. + */ + def build(): I + + /** + * Returns a list of recoverers provided by the factory, + */ + def recoverers: Iterable[ItemRecoverer[_, _]] +} diff --git a/src/main/scala/ocelot/desktop/inventory/ItemRecoverer.scala b/src/main/scala/ocelot/desktop/inventory/ItemRecoverer.scala new file mode 100644 index 0000000..7a41969 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/ItemRecoverer.scala @@ -0,0 +1,18 @@ +package ocelot.desktop.inventory + +import scala.reflect.{ClassTag, classTag} + +/** + * Allows recovering an [[Item]] from [[S]] (typically an [[totoro.ocelot.brain.entity.traits.Entity Entity]]). + * + * This is used for migration from old saves before the inventory system was introduced to Ocelot Desktop. + */ +final class ItemRecoverer[S: ClassTag, I <: Item](f: S => I) { + val sourceClass: Class[_] = classTag[S].runtimeClass + + def recover(source: S): I = f(source) +} + +object ItemRecoverer { + def apply[S: ClassTag, I <: Item](f: S => I): ItemRecoverer[S, I] = new ItemRecoverer(f) +} diff --git a/src/main/scala/ocelot/desktop/inventory/Items.scala b/src/main/scala/ocelot/desktop/inventory/Items.scala new file mode 100644 index 0000000..73f8263 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/Items.scala @@ -0,0 +1,142 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.graphics.IconDef +import ocelot.desktop.inventory.item._ +import ocelot.desktop.util.Logging +import ocelot.desktop.util.ReflectionUtils.linearizationOrder +import totoro.ocelot.brain.loot.Loot +import totoro.ocelot.brain.util.ExtendedTier.ExtendedTier +import totoro.ocelot.brain.util.Tier.Tier +import totoro.ocelot.brain.util.{ExtendedTier, Tier} + +import scala.collection.mutable +import scala.collection.mutable.ArrayBuffer + +object Items extends Logging { + private val _groups = ArrayBuffer.empty[ItemGroup] + private val _recoverers = mutable.Map.empty[Class[_], ItemRecoverer[_, _]] + + // this is just to force load the class during initialization + def init(): Unit = {} + + /** + * Registers a recoverer for [[ItemRecoverer.sourceClass]]. + */ + def registerRecoverer(recoverer: ItemRecoverer[_, _]): Unit = { + if (!_recoverers.contains(recoverer.sourceClass)) { + _recoverers(recoverer.sourceClass) = recoverer + logger.info(s"Registered a recoverer for ${recoverer.sourceClass.getName}") + } + } + + private def registerItemFactoryRecoverers(factory: ItemFactory): Unit = { + for (recoverer <- factory.recoverers) { + registerRecoverer(recoverer) + } + } + + def registerSingleton(factory: ItemFactory): Unit = { + _groups += SingletonItemGroup(factory.name, factory) + registerItemFactoryRecoverers(factory) + } + + def registerTiered(name: String, tiers: IterableOnce[Tier])(factory: Tier => ItemFactory): Unit = { + val group = TieredItemGroup(name, tiers.iterator.map(tier => (tier, factory(tier))).toSeq) + _groups += group + + for ((_, factory) <- group.factories) { + registerItemFactoryRecoverers(factory) + } + } + + def registerExtendedTiered(name: String, tiers: IterableOnce[ExtendedTier])( + factory: ExtendedTier => ItemFactory + ): Unit = { + val group = ExtendedTieredItemGroup(name, tiers.iterator.map(tier => (tier, factory(tier))).toSeq) + _groups += group + + for ((_, factory) <- group.factories) { + registerItemFactoryRecoverers(factory) + } + } + + def registerArbitrary(name: String, icon: IconDef, factories: IterableOnce[(String, ItemFactory)]): Unit = { + val group = ArbitraryItemGroup(name, icon, factories.iterator.toSeq) + _groups += group + + for ((_, factory) <- group.factories) { + registerItemFactoryRecoverers(factory) + } + } + + def groups: Iterable[ItemGroup] = _groups + + /** + * Attempts to recover an [[Item]] from `source`. + * + * Checks superclasses and traits while looking for a recoverer. + */ + def recover[A](source: A): Option[Item] = { + linearizationOrder(source.getClass.asInstanceOf[Class[_]]) + .flatMap(_recoverers.get) + .map(_.asInstanceOf[ItemRecoverer[_ >: A, _ <: Item]].recover(source)) + .nextOption() + } + + sealed trait ItemGroup { + def name: String + } + + case class SingletonItemGroup(name: String, factory: ItemFactory) extends ItemGroup + + case class TieredItemGroup(name: String, factories: Seq[(Tier, ItemFactory)]) extends ItemGroup + + case class ExtendedTieredItemGroup(name: String, factories: Seq[(ExtendedTier, ItemFactory)]) extends ItemGroup + + case class ArbitraryItemGroup(name: String, icon: IconDef, factories: Seq[(String, ItemFactory)]) extends ItemGroup + + ////////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + registerTiered("CPU", Tier.One to Tier.Three)(new CpuItem.Factory(_)) + registerTiered("APU", Tier.Two to Tier.Creative)(tier => new ApuItem.Factory(tier.saturatingSub(1))) + + registerExtendedTiered("Memory", ExtendedTier.One to ExtendedTier.ThreeHalf)(new MemoryItem.Factory(_)) + + registerTiered("HDD", Tier.One to Tier.Three)(new HddItem.Factory(managed = true, _)) + + registerArbitrary( + "Floppy", + FloppyItem.Factory.Empty.icon, + Loot.Floppies.iterator + .map(new FloppyItem.Factory.Loot(_)) + .map(factory => (factory.name, factory)) ++ Some(("Empty", FloppyItem.Factory.Empty)) + ) + + registerArbitrary( + "EEPROM", + EepromItem.Factory.Empty.icon, + Loot.Eeproms.iterator + .map(new EepromItem.Factory.Loot(_)) + .map(factory => (factory.name, factory)) ++ Some(("Empty", EepromItem.Factory.Empty)) + ) + + registerTiered("Graphics Card", Tier.One to Tier.Three)(new GraphicsCardItem.Factory(_)) + registerSingleton(NetworkCardItem.Factory) + registerTiered("Wireless Net. Card", Tier.One to Tier.Two) { + case Tier.One => WirelessNetworkCardItem.Tier1.Factory + case Tier.Two => WirelessNetworkCardItem.Tier2.Factory + } + registerSingleton(LinkedCardItem.Factory) + registerSingleton(InternetCardItem.Factory) + registerTiered("Redstone Card", Tier.One to Tier.Two) { + case Tier.One => RedstoneCardItem.Tier1.Factory + case Tier.Two => RedstoneCardItem.Tier2.Factory + } + registerTiered("Data Card", Tier.One to Tier.Three) { + case Tier.One => DataCardItem.Tier1.Factory + case Tier.Two => DataCardItem.Tier2.Factory + case Tier.Three => DataCardItem.Tier3.Factory + } + registerSingleton(SoundCardItem.Factory) + registerSingleton(SelfDestructingCardItem.Factory) +} diff --git a/src/main/scala/ocelot/desktop/inventory/PersistedInventory.scala b/src/main/scala/ocelot/desktop/inventory/PersistedInventory.scala new file mode 100644 index 0000000..22739ce --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/PersistedInventory.scala @@ -0,0 +1,119 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.OcelotDesktop +import ocelot.desktop.inventory.PersistedInventory._ +import ocelot.desktop.inventory.traits.{ComponentItem, PersistableItem} +import ocelot.desktop.util.Logging +import ocelot.desktop.util.ReflectionUtils.findUnaryConstructor +import totoro.ocelot.brain.nbt.ExtendedNBT.{extendNBTTagCompound, extendNBTTagList} +import totoro.ocelot.brain.nbt.persistence.NBTPersistence +import totoro.ocelot.brain.nbt.{NBT, NBTTagCompound} + +import scala.collection.mutable + +/** + * Provides persistence for an [[Inventory]]. + * + * [[ComponentItem]]s are treated specially: their [[ComponentItem.component component]] is persisted + * along with the item when saving. When loading the item, it first loads the component and uses it to instantiate + * the item (as required by [[ComponentItem]]). + */ +trait PersistedInventory extends Inventory with Logging { + override type I <: Item with PersistableItem + + def load(nbt: NBTTagCompound): Unit = { + val slotsToRemove = inventoryIterator.map(_.index).to(mutable.Set) + + for (slotNbt <- nbt.getTagList(InventoryTag, NBT.TAG_COMPOUND).iterator[NBTTagCompound]) { + val slotIndex = slotNbt.getInteger(SlotIndexTag) + slotsToRemove -= slotIndex + + val itemNbt = slotNbt.getCompoundTag(SlotItemTag) + val itemClass = Class.forName(slotNbt.getString(SlotItemClassTag)) + + try { + val item = if (classOf[ComponentItem].isAssignableFrom(itemClass)) + loadComponentItem(itemClass, slotNbt) + else + loadPlainItem(itemClass) + + item.load(itemNbt) + Slot(slotIndex).put(item) + } catch { + case ItemLoadException(message) => + logger.error( + s"Could not restore an item in the slot $slotIndex of " + + s"the inventory $this (class ${this.getClass.getName}): $message", + ) + onSlotLoadFailed(slotIndex) + } + } + + for (slotIndex <- slotsToRemove) { + Slot(slotIndex).remove() + } + } + + def save(nbt: NBTTagCompound): Unit = { + nbt.setNewTagList( + InventoryTag, inventoryIterator.map { slot => + val item = slot.get.get + + val slotNbt = new NBTTagCompound + slotNbt.setInteger(SlotIndexTag, slot.index) + + val itemNbt = new NBTTagCompound + item.save(itemNbt) + slotNbt.setTag(SlotItemTag, itemNbt) + slotNbt.setString(SlotItemClassTag, item.getClass.getName) + + item match { + case item: ComponentItem => saveComponentItem(slotNbt, item) + case _ => + } + + slotNbt + } + ) + } + + @throws[ItemLoadException]("if the item could not be loaded") + protected def loadComponentItem(itemClass: Class[_], slotNbt: NBTTagCompound): I = { + val entityNbt = slotNbt.getCompoundTag(SlotEntityTag) + val entity = NBTPersistence.load(entityNbt, OcelotDesktop.workspace) + + val constructor = findUnaryConstructor(itemClass, entity.getClass) match { + case Some(constructor) => constructor + case None => + throw ItemLoadException( + s"an item class ${itemClass.getName} cannot be instantiated with $entity (class ${entity.getClass.getName})" + ) + } + + constructor.newInstance(entity).asInstanceOf[I] + } + + //noinspection ScalaWeakerAccess + @throws[ItemLoadException]("if the item could not be loaded") + protected def loadPlainItem(itemClass: Class[_]): I = { + itemClass.getConstructor().newInstance().asInstanceOf[I] + } + + protected def onSlotLoadFailed(slotIndex: Int): Unit = { + Slot(slotIndex).remove() + } + + protected def saveComponentItem(slotNbt: NBTTagCompound, item: ComponentItem): Unit = { + slotNbt.setTag(SlotEntityTag, NBTPersistence.save(item.component)) + } +} + +object PersistedInventory { + private val InventoryTag = "inventory" + private val SlotIndexTag = "slotIndex" + private val SlotItemTag = "item" + private val SlotItemClassTag = "class" + private val SlotEntityTag = "entity" + + case class ItemLoadException(message: String) extends Exception(message) +} diff --git a/src/main/scala/ocelot/desktop/inventory/SyncedInventory.scala b/src/main/scala/ocelot/desktop/inventory/SyncedInventory.scala new file mode 100644 index 0000000..51466dd --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/SyncedInventory.scala @@ -0,0 +1,294 @@ +package ocelot.desktop.inventory + +import ocelot.desktop.OcelotDesktop +import ocelot.desktop.inventory.PersistedInventory.ItemLoadException +import ocelot.desktop.inventory.SyncedInventory.SlotStatus.SlotStatus +import ocelot.desktop.inventory.SyncedInventory.SyncDirection.SyncDirection +import ocelot.desktop.inventory.SyncedInventory._ +import ocelot.desktop.inventory.traits.ComponentItem +import ocelot.desktop.ui.event.BrainEvent +import ocelot.desktop.ui.widget.EventHandlers +import ocelot.desktop.util.Logging +import ocelot.desktop.util.ReflectionUtils.findUnaryConstructor +import totoro.ocelot.brain.entity.traits.{Entity, Environment, Inventory => BrainInventory} +import totoro.ocelot.brain.event.{InventoryEntityAddedEvent, InventoryEntityRemovedEvent} +import totoro.ocelot.brain.nbt.NBTTagCompound + +import scala.annotation.tailrec + +/** + * A [[PersistedInventory]] backed by a [[BrainInventory brain Inventory]]. + * + * Synchronizes the contents of the two inventories, propagating changes from one to the other. + * + * When a new [[Item]] is added to the Desktop inventory, its [[ComponentItem.component]] is added to + * the [[brainInventory]]. When a new [[Entity]] is added to the [[brainInventory]], an [[Item]] is recovered from it + * by using an appropriate [[ItemRecoverer]] from [[Items the registry]]. + * + * While synchronizing, relies on the convergence of changes, but guards against stack overflows + * by limiting the recursion depth. + */ +trait SyncedInventory extends PersistedInventory with Logging { + override type I <: Item with ComponentItem + + // to avoid synchronization while we're loading stuff + private var isLoading = false + + @volatile + private var syncFuel: Int = _ + refuel() + + protected def eventHandlers: EventHandlers + + /** + * The backing [[BrainInventory brain Inventory]]. + */ + def brainInventory: BrainInventory + + override def load(nbt: NBTTagCompound): Unit = { + isLoading = true + + try { + super.load(nbt) + } finally { + isLoading = false + } + + val occupiedSlots = inventoryIterator.map(_.index).toSet + .union(brainInventory.inventory.iterator.map(_.index).toSet) + + for (slotIndex <- occupiedSlots) { + sync(slotIndex, SyncDirection.Reconcile) + } + } + + @throws[ItemLoadException]("if the item could not be loaded") + override protected def loadComponentItem(itemClass: Class[_], slotNbt: NBTTagCompound): I = { + val entityAddress = slotNbt.getString(SlotEntityAddressTag) + + // why not OcelotDesktop.workspace.entityByAddress? + // well, that one only looks for tile entities (computers, screens, etc.), and our items are none of that... + val matchingEntities = brainInventory.inventory.iterator + .flatMap(_.get) + .collect { case env: Environment if env.node.address == entityAddress => env } + + val entity = matchingEntities.nextOption() match { + case Some(entity) => entity + case None => throw ItemLoadException(s"entity $entityAddress has disappeared") + } + + findUnaryConstructor(itemClass, entity.getClass) match { + case Some(constructor) => constructor.newInstance(entity).asInstanceOf[I] + + case None => + throw ItemLoadException( + s"an item class ${itemClass.getName} cannot be instantiated " + + s"with $entity (class ${entity.getClass.getName})", + ) + } + } + + override protected def onSlotLoadFailed(slotIndex: Int): Unit = { + // we'll deal with it the during synchronization + } + + override protected def saveComponentItem(slotNbt: NBTTagCompound, item: ComponentItem): Unit = { + slotNbt.setString(SlotEntityAddressTag, item.component.node.address) + } + + eventHandlers += { + case BrainEvent(InventoryEntityAddedEvent(slot, _)) if slot.inventory.owner eq brainInventory => + sync(slot.index, SyncDirection.BrainToDesktop) + + case BrainEvent(InventoryEntityRemovedEvent(slot, _)) if slot.inventory.owner eq brainInventory => + sync(slot.index, SyncDirection.BrainToDesktop) + } + + override def onItemAdded(slot: Slot): Unit = { + if (!isLoading) { + sync(slot.index, SyncDirection.DesktopToBrain) + } + } + + override def onItemRemoved(slot: Slot, removedItem: I, replacedBy: Option[I]): Unit = { + if (!isLoading) { + sync(slot.index, SyncDirection.DesktopToBrain) + } + } + + private def sync(slotIndex: Int, direction: SyncDirection): Unit = { + val initialSync = syncFuel == InitialSyncFuel + + try { + doSync(slotIndex, direction) + } finally { + if (initialSync) { + refuel() + } + } + } + + private def refuel(): Unit = { + syncFuel = InitialSyncFuel + } + + @tailrec + private def doSync(slotIndex: Int, direction: SyncDirection): Unit = { + syncFuel -= 1 + + if (syncFuel < 0) { + // ignore: the limit has already been reached + } else if (syncFuel == 0) { + logger.error( + s"Got trapped in an infinite loop while trying to synchronize the slot $slotIndex " + + s"in $this (class ${this.getClass.getName})!", + ) + logger.error( + "The item in the slot: " + + Slot(slotIndex).get.map(item => s"$item (class ${item.getClass.getName})").getOrElse(""), + ) + logger.error( + "The entity if the slot: " + + brainInventory.inventory(slotIndex) + .get + .map(entity => s"$entity (class ${entity.getClass.getName})") + .getOrElse(""), + ) + + logger.error("Breaking the loop forcefully by removing the items.") + Slot(slotIndex).remove() + brainInventory.inventory(slotIndex).remove() + } else { + direction match { + case _ if checkSlotStatus(slotIndex) == SlotStatus.Synchronized => + + case SyncDirection.DesktopToBrain => + // the `asInstanceOf` is indeed entirely superfluous, but IntelliJ IDEA complains otherwise... + OcelotDesktop.withTickLockAcquired { + brainInventory.inventory(slotIndex).set(Slot(slotIndex).get.map(_.asInstanceOf[ComponentItem].component)) + } + + case SyncDirection.BrainToDesktop => + val item = brainInventory.inventory(slotIndex).get match { + case Some(entity) => Items.recover(entity) match { + case Some(item) => Some(item.asInstanceOf[I]) + + case None => + logger.error( + s"An entity ($entity class ${entity.getClass.getName}) was inserted into a slot " + + s"(index: $slotIndex) of a brain inventory $brainInventory, " + + s"but we were unable to recover an Item from it.", + ) + logger.error( + s"A Desktop inventory $this (class ${getClass.getName}) could not recover the item. Removing.", + ) + logEntityLoss(slotIndex, entity) + + None + } + + case None => None + } + + Slot(slotIndex).set(item) + + case SyncDirection.Reconcile => checkSlotStatus(slotIndex) match { + case SlotStatus.Synchronized => // no-op + + // let's just grab whatever we have + case SlotStatus.DesktopNonEmpty => doSync(slotIndex, SyncDirection.DesktopToBrain) + case SlotStatus.BrainNonEmpty => doSync(slotIndex, SyncDirection.BrainToDesktop) + + case SlotStatus.Conflict => + // so, the brain inventory and the Desktop inventory have conflicting views on what the slot contains + // we'll let Desktop win because it has more info + + (brainInventory.inventory(slotIndex).get, Slot(slotIndex).get) match { + case (Some(entity), Some(item)) => + logger.error( + s"Encountered an inventory conflict for slot $slotIndex! " + + s"The Desktop inventory believes the slot contains $item (class ${item.getClass.getName}), " + + s"but the brain inventory believes the slot contains $entity (class ${entity.getClass.getName}).", + ) + logger.error("Resolving the conflict in favor of Ocelot Desktop.") + + case _ => + // this really should not happen... but alright, let's throw an exception at least + throw new IllegalStateException( + "an inventory conflict was detected even though one of the slots is empty", + ) + } + + doSync(slotIndex, SyncDirection.DesktopToBrain) + } + } + } + } + + private def logEntityLoss(slotIndex: Int, entity: Entity): Unit = { + logger.error( + s"Encountered a data loss! " + + s"In the brain inventory $brainInventory (class ${brainInventory.getClass.getName}), " + + s"the entity $entity (class ${entity.getClass.getName}) is deleted from the slot $slotIndex.", + ) + } + + private def checkSlotStatus(slotIndex: Int): SlotStatus = + (Slot(slotIndex).get, brainInventory.inventory(slotIndex).get) match { + case (Some(item), Some(entity)) if item.component eq entity => SlotStatus.Synchronized + case (Some(_), Some(_)) => SlotStatus.Conflict + case (Some(_), None) => SlotStatus.DesktopNonEmpty + case (None, Some(_)) => SlotStatus.BrainNonEmpty + case (None, None) => SlotStatus.Synchronized + } +} + +object SyncedInventory { + private val SlotEntityAddressTag = "entity" + + private val InitialSyncFuel = 5 + + object SyncDirection extends Enumeration { + type SyncDirection = Value + + /** + * Apply a change from the [[SyncedInventory]] to [[SyncedInventory.brainInventory]]. + */ + val DesktopToBrain: SyncDirection = Value + + /** + * Apply a change from the [[SyncedInventory.brainInventory]] to [[SyncedInventory]]. + */ + val BrainToDesktop: SyncDirection = Value + + /** + * Try to reconcile conflicts between [[SyncedInventory]] and its [[SyncedInventory.brainInventory]]. + */ + val Reconcile: SyncDirection = Value + } + + object SlotStatus extends Enumeration { + type SlotStatus = Value + + /** + * The slots are in sync (both are empty or they contain the same entity). + */ + val Synchronized: SlotStatus = Value + + /** + * [[SyncedInventory]]'s slot is non-empty; [[SyncedInventory.brainInventory]]'s is empty. + */ + val DesktopNonEmpty: SlotStatus = Value + + /** + * [[SyncedInventory]]'s slot is empty; [[SyncedInventory.brainInventory]]'s isn't. + */ + val BrainNonEmpty: SlotStatus = Value + + /** + * The [[SyncedInventory]] and the [[SyncedInventory.brainInventory]] are both non-empty + * and contain different items. + */ + val Conflict: SlotStatus = Value + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/ApuItem.scala b/src/main/scala/ocelot/desktop/inventory/item/ApuItem.scala new file mode 100644 index 0000000..3a00fc8 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/ApuItem.scala @@ -0,0 +1,35 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{ComponentItem, CpuLikeItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.APU +import totoro.ocelot.brain.entity.traits.{Entity, GenericCPU} +import totoro.ocelot.brain.util.Tier.Tier + +class ApuItem(val apu: APU) extends Item with ComponentItem with PersistableItem with CpuLikeItem { + override def component: Entity with GenericCPU = apu + + override def factory: ApuItem.Factory = new ApuItem.Factory(apu.tier) +} + +object ApuItem { + class Factory(_tier: Tier) extends ItemFactory { + override type I = ApuItem + + override def itemClass: Class[I] = classOf + + override def name: String = s"APU (${tier.get.label})" + + // there are T2, T3, and creative APUs + // however, the APU class starts counting from one (so it's actually T1, T2, and T3) + // we keep the latter tier internally and increment it when dealing with the rest of the world + override def tier: Option[Tier] = Some(_tier.saturatingAdd(1)) + + override def icon: IconDef = Icons.Apu(_tier) + + override def build(): ApuItem = new ApuItem(new APU(_tier)) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new ApuItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/CpuItem.scala b/src/main/scala/ocelot/desktop/inventory/item/CpuItem.scala new file mode 100644 index 0000000..90d8e1a --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/CpuItem.scala @@ -0,0 +1,32 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{ComponentItem, CpuLikeItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.CPU +import totoro.ocelot.brain.entity.traits.{Entity, GenericCPU} +import totoro.ocelot.brain.util.Tier.Tier + +class CpuItem(val cpu: CPU) extends Item with ComponentItem with PersistableItem with CpuLikeItem { + override def component: Entity with GenericCPU = cpu + + override def factory: CpuItem.Factory = new CpuItem.Factory(cpu.tier) +} + +object CpuItem { + class Factory(_tier: Tier) extends ItemFactory { + override type I = CpuItem + + override def itemClass: Class[I] = classOf + + override def name: String = s"CPU (${_tier.label})" + + override def tier: Option[Tier] = Some(_tier) + + override def icon: IconDef = Icons.Cpu(_tier) + + override def build(): CpuItem = new CpuItem(new CPU(_tier)) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new CpuItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/DataCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/DataCardItem.scala new file mode 100644 index 0000000..82b0cd5 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/DataCardItem.scala @@ -0,0 +1,79 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.DataCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +abstract class DataCardItem extends Item with ComponentItem with PersistableItem with CardItem + +object DataCardItem { + abstract class Factory extends ItemFactory { + override def name: String = s"Data Card (${tier.get.label})" + + override def icon: IconDef = Icons.DataCard(tier.get) + } + + class Tier1(val dataCard: DataCard.Tier1) extends DataCardItem { + override def component: Entity with Environment = dataCard + + override def factory: Factory = DataCardItem.Tier1.Factory + } + + object Tier1 { + object Factory extends Factory { + override type I = DataCardItem.Tier1 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.One) + + override def build(): DataCardItem.Tier1 = new DataCardItem.Tier1(new DataCard.Tier1) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new DataCardItem.Tier1(_))) + } + } + + class Tier2(val dataCard: DataCard.Tier2) extends DataCardItem { + override def component: Entity with Environment = dataCard + + override def factory: Factory = DataCardItem.Tier2.Factory + } + + object Tier2 { + object Factory extends Factory { + override type I = DataCardItem.Tier2 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.Two) + + override def build(): DataCardItem.Tier2 = new DataCardItem.Tier2(new DataCard.Tier2) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new DataCardItem.Tier2(_))) + } + } + + class Tier3(val dataCard: DataCard.Tier3) extends DataCardItem { + override def component: Entity with Environment = dataCard + + override def factory: Factory = DataCardItem.Tier3.Factory + } + + object Tier3 { + object Factory extends Factory { + override type I = DataCardItem.Tier3 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.Three) + + override def build(): DataCardItem.Tier3 = new DataCardItem.Tier3(new DataCard.Tier3) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new DataCardItem.Tier3(_))) + } + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/EepromItem.scala b/src/main/scala/ocelot/desktop/inventory/item/EepromItem.scala new file mode 100644 index 0000000..5401261 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/EepromItem.scala @@ -0,0 +1,124 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.OcelotDesktop +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.InputDialog +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.EEPROM +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.loot.Loot.{EEPROMFactory => LootEepromFactory} +import totoro.ocelot.brain.util.Tier.Tier + +import java.net.{MalformedURLException, URL} +import javax.swing.JFileChooser +import scala.util.Try + +class EepromItem(val eeprom: EEPROM) extends Item with ComponentItem with PersistableItem { + override def component: Entity with Environment = eeprom + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + val source = eeprom.codePath + .map(path => s"Source path: $path") + .orElse(eeprom.codeURL.map(url => s"Source URL: $url")) + + for (source <- source) { + tooltip.addLine(source) + } + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + menu.addEntry( + new ContextMenuSubmenu("External data source") { + addEntry( + ContextMenuEntry("Local file") { + OcelotDesktop.showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.FILES_ONLY) { file => + Try { + for (file <- file) { + eeprom.codePath = Some(file.toPath) + } + } + } + } + ) + + addEntry( + ContextMenuEntry("File via URL") { + new InputDialog( + title = "File via URL", + onConfirmed = { text => + eeprom.codeURL = Some(new URL(text)) + }, + inputValidator = text => + try { + new URL(text) + true + } catch { + case _: MalformedURLException => false + }, + ).show() + } + ) + + if (eeprom.codePath.nonEmpty || eeprom.codeURL.nonEmpty) { + addEntry( + ContextMenuEntry("Detach") { + eeprom.codeBytes = Some(Array.empty) + } + ) + } + } + ) + + super.fillRmbMenu(menu) + } + + override def factory: EepromItem.Factory = new EepromItem.Factory.Code( + code = eeprom.getBytes, + name = eeprom.label, + readonly = eeprom.readonly, + ) +} + +object EepromItem { + abstract class Factory extends ItemFactory { + override type I = EepromItem + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = None + + override def icon: IconDef = Icons.Eeprom + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new EepromItem(_))) + } + + object Factory { + class Loot(factory: LootEepromFactory) extends Factory { + override def name: String = factory.label + + override def build(): EepromItem = new EepromItem(factory.create()) + } + + class Code(code: Array[Byte], override val name: String, readonly: Boolean) extends Factory { + override def build(): EepromItem = { + val eeprom = new EEPROM + eeprom.codeBytes = Some(code) + eeprom.label = name + eeprom.readonly = readonly + + new EepromItem(eeprom) + } + } + + object Empty extends Factory { + override def name: String = "EEPROM" + + override def build(): EepromItem = new EepromItem(new EEPROM) + } + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/FloppyItem.scala b/src/main/scala/ocelot/desktop/inventory/item/FloppyItem.scala new file mode 100644 index 0000000..ba712dc --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/FloppyItem.scala @@ -0,0 +1,94 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{ComponentItem, DiskItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.contextmenu.ContextMenu +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.traits.{Entity, Environment, Floppy} +import totoro.ocelot.brain.entity.{FloppyManaged, FloppyUnmanaged} +import totoro.ocelot.brain.loot.Loot.{LootFloppy, FloppyFactory => LootFloppyFactory} +import totoro.ocelot.brain.util.DyeColor +import totoro.ocelot.brain.util.Tier.Tier + +class FloppyItem(val floppy: Floppy) extends Item with ComponentItem with PersistableItem with DiskItem { + def color: DyeColor = floppy.color + + override def component: Entity with Environment = floppy + + override def name: String = floppy.name.getOrElse(super.name) + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + for (label <- floppy.label.labelOption) { + if (label != name) { + // this is true for loot floppies + addDiskLabelTooltip(tooltip, label) + } + } + + floppy match { + case floppy: FloppyManaged => addSourcePathTooltip(tooltip, floppy) + case _ => // unmanaged floppies don't need any special entries + } + + addManagedTooltip(tooltip, floppy) + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + floppy match { + case floppy: FloppyManaged => addSetDirectoryEntry(menu, floppy) + case _ => // unmanaged floppies don't need any special entries + } + + super.fillRmbMenu(menu) + } + + override def factory: FloppyItem.Factory = floppy match { + case floppy: LootFloppy => + new FloppyItem.Factory.Loot(new LootFloppyFactory(floppy.name.get, floppy.color, floppy.path)) + + case floppy: FloppyManaged => + new FloppyItem.Factory.Managed(floppy.label.labelOption, floppy.color) + + case floppy: FloppyUnmanaged => + new FloppyItem.Factory.Unmanaged(floppy.label.labelOption, floppy.color) + } +} + +object FloppyItem { + abstract class Factory(val label: Option[String], val color: DyeColor, managed: Boolean) extends ItemFactory { + override type I = FloppyItem + + override def itemClass: Class[I] = classOf + + override def name: String = label.getOrElse("Floppy Disk") + + override def tier: Option[Tier] = None + + override def icon: IconDef = Icons.FloppyDisk(color) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new FloppyItem(_))) + } + + object Factory { + class Loot(factory: LootFloppyFactory) extends Factory(Some(factory.name), factory.color, managed = true) { + override def name: String = factory.name + + override def build(): FloppyItem = new FloppyItem(factory.create()) + } + + object Empty extends Factory(None, DyeColor.Gray, managed = true) { + override def build(): FloppyItem = new FloppyItem(new FloppyManaged) + } + + class Managed(label: Option[String], color: DyeColor) extends Factory(label, color, managed = true) { + override def build(): FloppyItem = new FloppyItem(new FloppyManaged(label, color)) + } + + class Unmanaged(label: Option[String], color: DyeColor) extends Factory(label, color, managed = false) { + override def build(): FloppyItem = new FloppyItem(new FloppyUnmanaged(label, color)) + } + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/GraphicsCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/GraphicsCardItem.scala new file mode 100644 index 0000000..d9f4801 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/GraphicsCardItem.scala @@ -0,0 +1,30 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.GraphicsCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier.Tier + +class GraphicsCardItem(val gpu: GraphicsCard) extends Item with ComponentItem with PersistableItem with CardItem { + override def component: Entity with Environment = gpu + + override def factory: GraphicsCardItem.Factory = new GraphicsCardItem.Factory(gpu.tier) +} + +object GraphicsCardItem { + class Factory(_tier: Tier) extends ItemFactory { + override type I = GraphicsCardItem + + override def itemClass: Class[I] = classOf + + override def name: String = s"Graphics Card (${_tier.label})" + override def tier: Option[Tier] = Some(_tier) + override def icon: IconDef = Icons.GraphicsCard(_tier) + + override def build(): GraphicsCardItem = new GraphicsCardItem(new GraphicsCard(_tier)) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new GraphicsCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/HddItem.scala b/src/main/scala/ocelot/desktop/inventory/item/HddItem.scala new file mode 100644 index 0000000..00b7ac3 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/HddItem.scala @@ -0,0 +1,91 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.item.HddItem.Hdd +import ocelot.desktop.inventory.traits.{ComponentItem, DiskItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.contextmenu.ContextMenu +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.traits.{Disk, Entity, Environment} +import totoro.ocelot.brain.entity.{HDDManaged, HDDUnmanaged} +import totoro.ocelot.brain.util.Tier.Tier + +class HddItem(val hdd: Hdd) extends Item with ComponentItem with PersistableItem with DiskItem { + // constructors for deserialization as required by [[ComponentItem]] + def this(hdd: HDDManaged) = { + this(Hdd.Managed(hdd)) + } + + def this(hdd: HDDUnmanaged) = { + this(Hdd.Unmanaged(hdd)) + } + + override def component: Entity with Environment = hdd.hdd + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + val label = hdd match { + case Hdd.Managed(hdd) => hdd.fileSystem.label.labelOption + case Hdd.Unmanaged(hdd) => hdd.label.labelOption + } + + label.foreach(addDiskLabelTooltip(tooltip, _)) + + hdd match { + case Hdd.Managed(hdd) => addSourcePathTooltip(tooltip, hdd) + case Hdd.Unmanaged(_) => // unmanaged HDDs don't need any special entries + } + + addManagedTooltip(tooltip, hdd.hdd) + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + hdd match { + case Hdd.Managed(hdd) => + addSetDirectoryEntry(menu, hdd) + + case _ => // unmanaged HDDs don't need any special entries + } + super.fillRmbMenu(menu) + } + + override def factory: HddItem.Factory = hdd match { + case Hdd.Managed(hdd) => new HddItem.Factory(true, hdd.tier) + case Hdd.Unmanaged(hdd) => new HddItem.Factory(false, hdd.tier) + } +} + +object HddItem { + class Factory(managed: Boolean, _tier: Tier) extends ItemFactory { + override type I = HddItem + + override def itemClass: Class[I] = classOf + + override def name: String = s"Hard Disk Drive (${_tier.label})" + + override def tier: Option[Tier] = Some(_tier) + + override def icon: IconDef = Icons.HardDiskDrive(_tier) + + override def build(): HddItem = new HddItem( + if (managed) Hdd.Managed(new HDDManaged(_tier)) + else Hdd.Unmanaged(new HDDUnmanaged(_tier)) + ) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Seq( + ItemRecoverer((hdd: HDDManaged) => new HddItem(Hdd.Managed(hdd))), + ItemRecoverer((hdd: HDDUnmanaged) => new HddItem(Hdd.Unmanaged(hdd))), + ) + } + + sealed trait Hdd { + def hdd: Disk with Entity + } + + object Hdd { + case class Managed(hdd: HDDManaged) extends Hdd + + case class Unmanaged(hdd: HDDUnmanaged) extends Hdd + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/InternetCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/InternetCardItem.scala new file mode 100644 index 0000000..c644b16 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/InternetCardItem.scala @@ -0,0 +1,33 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.InternetCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +class InternetCardItem(val card: InternetCard) extends Item with ComponentItem with PersistableItem with CardItem { + override def component: Entity with Environment = card + + override def factory: ItemFactory = InternetCardItem.Factory +} + +object InternetCardItem { + object Factory extends ItemFactory { + override type I = InternetCardItem + + override def itemClass: Class[InternetCardItem] = classOf + + override def name: String = "Internet Card" + + override def tier: Option[Tier] = Some(Tier.Two) + + override def icon: IconDef = Icons.InternetCard + + override def build(): InternetCardItem = new InternetCardItem(new InternetCard) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new InternetCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/LinkedCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/LinkedCardItem.scala new file mode 100644 index 0000000..6844df5 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/LinkedCardItem.scala @@ -0,0 +1,54 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.TunnelDialog +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.LinkedCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +class LinkedCardItem(val linkedCard: LinkedCard) extends Item with ComponentItem with PersistableItem with CardItem { + override def component: Entity with Environment = linkedCard + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + tooltip.addLine(s"Channel: ${linkedCard.tunnel}") + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + menu.addEntry( + ContextMenuEntry("Set channel") { + new TunnelDialog( + tunnel => linkedCard.tunnel = tunnel, + linkedCard.tunnel, + ).show() + } + ) + super.fillRmbMenu(menu) + } + + override def factory: ItemFactory = LinkedCardItem.Factory +} + +object LinkedCardItem { + object Factory extends ItemFactory { + override type I = LinkedCardItem + + override def itemClass: Class[I] = classOf + + override def name: String = "Linked Card" + + override def tier: Option[Tier] = Some(Tier.Three) + + override def icon: IconDef = Icons.LinkedCard + + override def build(): LinkedCardItem = new LinkedCardItem(new LinkedCard) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new LinkedCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/MemoryItem.scala b/src/main/scala/ocelot/desktop/inventory/item/MemoryItem.scala new file mode 100644 index 0000000..4d1e0f0 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/MemoryItem.scala @@ -0,0 +1,33 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.Memory +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.ExtendedTier.ExtendedTier +import totoro.ocelot.brain.util.Tier.Tier + +class MemoryItem(val memory: Memory) extends Item with ComponentItem with PersistableItem { + override def component: Entity with Environment = memory + + override def factory: ItemFactory = new MemoryItem.Factory(memory.memoryTier) +} + +object MemoryItem { + class Factory(memoryTier: ExtendedTier) extends ItemFactory { + override type I = MemoryItem + + override def itemClass: Class[I] = classOf + + override def name: String = s"Memory (${memoryTier.label})" + + override def tier: Option[Tier] = Some(memoryTier.toTier) + + override def icon: IconDef = Icons.Memory(memoryTier) + + override def build(): MemoryItem = new MemoryItem(new Memory(memoryTier)) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new MemoryItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/NetworkCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/NetworkCardItem.scala new file mode 100644 index 0000000..a5f4839 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/NetworkCardItem.scala @@ -0,0 +1,31 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.NetworkCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +class NetworkCardItem(val networkCard: NetworkCard) extends Item with ComponentItem with PersistableItem with CardItem { + override def component: Entity with Environment = networkCard + + override def factory: ItemFactory = NetworkCardItem.Factory +} + +object NetworkCardItem { + object Factory extends ItemFactory { + override type I = NetworkCardItem + + override def itemClass: Class[I] = classOf + + override def name: String = "Network Card" + override def tier: Option[Tier] = Some(Tier.One) + override def icon: IconDef = Icons.NetworkCard + + override def build(): NetworkCardItem = new NetworkCardItem(new NetworkCard) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new NetworkCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/RedstoneCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/RedstoneCardItem.scala new file mode 100644 index 0000000..37cce12 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/RedstoneCardItem.scala @@ -0,0 +1,97 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem, WindowProvider} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.card.{Redstone1Window, Redstone2Window} +import ocelot.desktop.ui.widget.contextmenu.ContextMenu +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import ocelot.desktop.ui.widget.window.Window +import totoro.ocelot.brain.entity.Redstone +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +abstract class RedstoneCardItem extends Item with ComponentItem with PersistableItem with CardItem + +object RedstoneCardItem { + abstract class Factory extends ItemFactory { + override def name: String = s"Redstone Card (${tier.get.label})" + + override def icon: IconDef = Icons.RedstoneCard(tier.get) + } + + class Tier1(val redstoneCard: Redstone.Tier1) extends RedstoneCardItem { + private val redstoneIoWindow: WindowProvider = new WindowProvider { + override def windowEntryLabel: String = "Redstone I/O" + + override def makeWindow(): Window = new Redstone1Window(redstoneCard) + } + + override def component: Entity with Environment = redstoneCard + + protected def addWindowEntries(menu: ContextMenu): Unit = { + redstoneIoWindow.fillRmbMenu(menu) + } + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + if (redstoneCard.wakeThreshold > 0) { + tooltip.addLine(s"Wake threshold: ${redstoneCard.wakeThreshold}") + } + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + addWindowEntries(menu) + super.fillRmbMenu(menu) + } + + override def factory: Factory = RedstoneCardItem.Tier1.Factory + } + + object Tier1 { + object Factory extends Factory { + override type I = RedstoneCardItem.Tier1 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.One) + + override def build(): RedstoneCardItem.Tier1 = new RedstoneCardItem.Tier1(new Redstone.Tier1) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new RedstoneCardItem.Tier1(_))) + } + } + + class Tier2(override val redstoneCard: Redstone.Tier2) extends RedstoneCardItem.Tier1(redstoneCard) { + private val bundledIoWindow: WindowProvider = new WindowProvider { + override def windowEntryLabel: String = "Bundled I/O" + + override def makeWindow(): Window = new Redstone2Window(redstoneCard) + } + + override def component: Entity with Environment = redstoneCard + + override def addWindowEntries(menu: ContextMenu): Unit = { + super.addWindowEntries(menu) + bundledIoWindow.fillRmbMenu(menu) + } + + override def factory: Factory = RedstoneCardItem.Tier2.Factory + } + + object Tier2 { + object Factory extends Factory { + override type I = RedstoneCardItem.Tier2 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.Two) + + override def build(): RedstoneCardItem.Tier2 = new RedstoneCardItem.Tier2(new Redstone.Tier2) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new RedstoneCardItem.Tier2(_))) + } + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/SelfDestructingCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/SelfDestructingCardItem.scala new file mode 100644 index 0000000..dc10530 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/SelfDestructingCardItem.scala @@ -0,0 +1,38 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.SelfDestructingCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +class SelfDestructingCardItem(val card: SelfDestructingCard) + extends Item + with ComponentItem + with PersistableItem + with CardItem { + + override def component: Entity with Environment = card + + override def factory: ItemFactory = SelfDestructingCardItem.Factory +} + +object SelfDestructingCardItem { + object Factory extends ItemFactory { + override type I = SelfDestructingCardItem + + override def itemClass: Class[I] = classOf + + override def name: String = "Self-Destructing Card" + + override def tier: Option[Tier] = Some(Tier.Two) + + override def icon: IconDef = Icons.SelfDestructingCard + + override def build(): SelfDestructingCardItem = new SelfDestructingCardItem(new SelfDestructingCard) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new SelfDestructingCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/SoundCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/SoundCardItem.scala new file mode 100644 index 0000000..a858053 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/SoundCardItem.scala @@ -0,0 +1,47 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem, WindowProvider} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import ocelot.desktop.ui.widget.card.SoundCardWindow +import ocelot.desktop.ui.widget.contextmenu.ContextMenu +import ocelot.desktop.ui.widget.window.Window +import totoro.ocelot.brain.entity.sound_card.SoundCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +class SoundCardItem(val soundCard: SoundCard) extends Item with ComponentItem with PersistableItem with CardItem { + private val window = new WindowProvider { + override def windowEntryLabel: String = "Open" + + override def makeWindow(): Window = new SoundCardWindow(soundCard) + } + + override def component: Entity with Environment = soundCard + + override def fillRmbMenu(menu: ContextMenu): Unit = { + window.fillRmbMenu(menu) + super.fillRmbMenu(menu) + } + + override def factory: ItemFactory = SoundCardItem.Factory +} + +object SoundCardItem { + object Factory extends ItemFactory { + override type I = SoundCardItem + + override def itemClass: Class[I] = classOf + + override def name: String = "Sound Card" + + override def tier: Option[Tier] = Some(Tier.Two) + + override def icon: IconDef = Icons.SoundCard + + override def build(): SoundCardItem = new SoundCardItem(new SoundCard) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new SoundCardItem(_))) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/item/WirelessNetworkCardItem.scala b/src/main/scala/ocelot/desktop/inventory/item/WirelessNetworkCardItem.scala new file mode 100644 index 0000000..8ec811a --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/item/WirelessNetworkCardItem.scala @@ -0,0 +1,64 @@ +package ocelot.desktop.inventory.item + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.traits.{CardItem, ComponentItem, PersistableItem} +import ocelot.desktop.inventory.{Item, ItemFactory, ItemRecoverer} +import totoro.ocelot.brain.entity.WirelessNetworkCard +import totoro.ocelot.brain.entity.traits.{Entity, Environment} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier + +abstract class WirelessNetworkCardItem(val card: WirelessNetworkCard) + extends Item + with ComponentItem + with PersistableItem + with CardItem { + + override def component: Entity with Environment = card +} + +object WirelessNetworkCardItem { + abstract class Factory extends ItemFactory { + override def name: String = s"Wireless Net. Card (${tier.get.label})" + + override def icon: IconDef = Icons.WirelessNetworkCard(tier.get) + } + + class Tier1(override val card: WirelessNetworkCard.Tier1) extends WirelessNetworkCardItem(card) { + override def factory: Factory = WirelessNetworkCardItem.Tier1.Factory + } + + object Tier1 { + object Factory extends Factory { + override type I = WirelessNetworkCardItem.Tier1 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.One) + + override def build(): WirelessNetworkCardItem.Tier1 = + new WirelessNetworkCardItem.Tier1(new WirelessNetworkCard.Tier1) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new WirelessNetworkCardItem.Tier1(_))) + } + } + + class Tier2(override val card: WirelessNetworkCard.Tier2) extends WirelessNetworkCardItem(card) { + override def factory: Factory = WirelessNetworkCardItem.Tier2.Factory + } + + object Tier2 { + object Factory extends Factory { + override type I = WirelessNetworkCardItem.Tier2 + + override def itemClass: Class[I] = classOf + + override def tier: Option[Tier] = Some(Tier.Two) + + override def build(): WirelessNetworkCardItem.Tier2 = + new WirelessNetworkCardItem.Tier2(new WirelessNetworkCard.Tier2) + + override def recoverers: Iterable[ItemRecoverer[_, _]] = Some(ItemRecoverer(new WirelessNetworkCardItem.Tier2(_))) + } + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/traits/CardItem.scala b/src/main/scala/ocelot/desktop/inventory/traits/CardItem.scala new file mode 100644 index 0000000..d91bdb6 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/CardItem.scala @@ -0,0 +1,8 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.inventory.Item + +/** + * A marker trait implemented by [[Item]]s that can be put into card slots. + */ +trait CardItem extends ComponentItem diff --git a/src/main/scala/ocelot/desktop/inventory/traits/ComponentItem.scala b/src/main/scala/ocelot/desktop/inventory/traits/ComponentItem.scala new file mode 100644 index 0000000..7ed8ecb --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/ComponentItem.scala @@ -0,0 +1,30 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.inventory.Item +import ocelot.desktop.ui.UiHandler +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.traits.{Entity, Environment} + +/** + * Implemented by [[Item]]s that wrap a component. + * + * @note Subclasses must provide a unary constructor that accepts the [[component]]. + */ +trait ComponentItem extends Item with PersistableItem { + def component: Entity with Environment + + override def fillTooltip(tooltip: ItemTooltip): Unit = { + super.fillTooltip(tooltip) + + tooltip.addLine(s"Address: ${component.node.address}") + } + + override def fillRmbMenu(menu: ContextMenu): Unit = { + menu.addEntry(ContextMenuEntry("Copy address") { + UiHandler.clipboard = component.node.address + }) + + super.fillRmbMenu(menu) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/traits/CpuLikeItem.scala b/src/main/scala/ocelot/desktop/inventory/traits/CpuLikeItem.scala new file mode 100644 index 0000000..f13eb65 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/CpuLikeItem.scala @@ -0,0 +1,36 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.inventory.Item +import ocelot.desktop.inventory.traits.CpuLikeItem.CpuArchitectureChangedNotification +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} +import totoro.ocelot.brain.entity.machine.MachineAPI +import totoro.ocelot.brain.entity.traits.{Entity, GenericCPU} + +/** + * An [[Item]] that acts as a CPU and can therefore be inserted into a CPU slot. + */ +trait CpuLikeItem extends ComponentItem { + override def component: Entity with GenericCPU + + override def fillRmbMenu(menu: ContextMenu): Unit = { + menu.addEntry(new ContextMenuSubmenu("Set architecture") { + for (arch <- component.allArchitectures) { + val name = MachineAPI.getArchitectureName(arch) + + (if (arch == component.architecture) " (current)" else "") + val entry = ContextMenuEntry(name) { + component.setArchitecture(arch) + notifySlot(CpuArchitectureChangedNotification) + } + + entry.setEnabled(arch != component.architecture) + addEntry(entry) + } + }) + + super.fillRmbMenu(menu) + } +} + +object CpuLikeItem { + case object CpuArchitectureChangedNotification extends Item.Notification +} diff --git a/src/main/scala/ocelot/desktop/inventory/traits/DiskItem.scala b/src/main/scala/ocelot/desktop/inventory/traits/DiskItem.scala new file mode 100644 index 0000000..4c5581a --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/DiskItem.scala @@ -0,0 +1,54 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.OcelotDesktop +import ocelot.desktop.inventory.Item +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.ui.widget.tooltip.ItemTooltip +import totoro.ocelot.brain.entity.traits.{Disk, DiskManaged, DiskUnmanaged} + +import javax.swing.JFileChooser +import scala.util.Try + +/** + * A utility mixin for HDDs and floppies. + */ +trait DiskItem { + this: Item => + + protected def addSetDirectoryEntry(menu: ContextMenu, disk: DiskManaged): Unit = { + menu.addEntry( + ContextMenuEntry("Set directory") { + OcelotDesktop.showFileChooserDialog(JFileChooser.OPEN_DIALOG, JFileChooser.DIRECTORIES_ONLY) { dir => + Try { + for (dir <- dir) { + // trigger component_removed / component_added signals + val slot = this.slot + slot.foreach(_.remove()) + disk.customRealPath = Some(dir.toPath.toAbsolutePath) + slot.foreach(slot => slot.put(this.asInstanceOf[slot.inventory.I])) + } + } + } + } + ) + + // TODO: unset directory?????? anyone? + } + + protected def addDiskLabelTooltip(tooltip: ItemTooltip, label: String): Unit = { + tooltip.addLine(s"Label: $label") + } + + protected def addSourcePathTooltip(tooltip: ItemTooltip, disk: DiskManaged): Unit = { + for (customRealPath <- disk.customRealPath) { + tooltip.addLine(s"Source path: $customRealPath") + } + } + + protected def addManagedTooltip(tooltip: ItemTooltip, disk: Disk): Unit = { + tooltip.addLine(disk match { + case _: DiskManaged => "Mode: managed" + case _: DiskUnmanaged => "Mode: unmanaged" + }) + } +} diff --git a/src/main/scala/ocelot/desktop/inventory/traits/PersistableItem.scala b/src/main/scala/ocelot/desktop/inventory/traits/PersistableItem.scala new file mode 100644 index 0000000..2c09150 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/PersistableItem.scala @@ -0,0 +1,13 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.inventory.Item +import totoro.ocelot.brain.nbt.NBTTagCompound + +/** + * Provides serialization and deserialization for an [[Item]]. + */ +trait PersistableItem extends Item { + def load(nbt: NBTTagCompound): Unit = {} + + def save(nbt: NBTTagCompound): Unit = {} +} diff --git a/src/main/scala/ocelot/desktop/inventory/traits/WindowProvider.scala b/src/main/scala/ocelot/desktop/inventory/traits/WindowProvider.scala new file mode 100644 index 0000000..24a9239 --- /dev/null +++ b/src/main/scala/ocelot/desktop/inventory/traits/WindowProvider.scala @@ -0,0 +1,26 @@ +package ocelot.desktop.inventory.traits + +import ocelot.desktop.ui.UiHandler +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.ui.widget.window.Window + +/** + * Potentially manages the windows of an [[ocelot.desktop.inventory.Item Item]] that has them (e.g., the sound card). + * Though at the moment it just provides a convenient way to add an entry to the item's context menu + * for opening a window. + */ +trait WindowProvider { + def windowEntryLabel: String + + def makeWindow(): Window + + def fillRmbMenu(menu: ContextMenu): Unit = { + // TODO: disable the entry while the window is open + // TODO: serialize the window + menu.addEntry( + ContextMenuEntry(windowEntryLabel) { + UiHandler.root.workspaceView.windowPool.openWindow(makeWindow()) + }, + ) + } +} diff --git a/src/main/scala/ocelot/desktop/node/Node.scala b/src/main/scala/ocelot/desktop/node/Node.scala index 85f818f..060277f 100644 --- a/src/main/scala/ocelot/desktop/node/Node.scala +++ b/src/main/scala/ocelot/desktop/node/Node.scala @@ -4,6 +4,7 @@ import ocelot.desktop.OcelotDesktop import ocelot.desktop.color.{Color, RGBAColor} import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} import ocelot.desktop.graphics.Graphics +import ocelot.desktop.node.Node.{HoveredHighlight, MovingHighlight, NoHighlight} import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.handlers.{ClickHandler, DragHandler, HoverHandler} import ocelot.desktop.ui.event.{ClickEvent, DragEvent, HoverEvent, MouseEvent} @@ -20,17 +21,20 @@ import totoro.ocelot.brain.util.Direction import scala.collection.mutable.ArrayBuffer -abstract class Node(val entity: Entity with Environment) extends Widget with DragHandler with ClickHandler with HoverHandler { +abstract class Node(val entity: Entity with Environment) + extends Widget + with DragHandler + with ClickHandler + with HoverHandler { + if (!OcelotDesktop.workspace.getEntitiesIter.contains(entity)) OcelotDesktop.workspace.add(entity) var workspaceView: WorkspaceView = _ - protected val MovingHighlight: RGBAColor = RGBAColor(240, 250, 240) - protected val HoveredHighlight: RGBAColor = RGBAColor(160, 160, 160) - protected val NoHighlight: RGBAColor = RGBAColor(160, 160, 160, 0) protected val highlight = new ColorAnimation(RGBAColor(0, 0, 0, 0)) protected val canOpen = false + protected def exposeAddress = true protected var isMoving = false @@ -42,10 +46,12 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra def load(nbt: NBTTagCompound): Unit = { position = new Vector2D(nbt.getCompoundTag("pos")) - window.foreach(window => { - val tag = nbt.getCompoundTag("window") - window.load(tag) - }) + window.foreach( + window => { + val tag = nbt.getCompoundTag("window") + window.load(tag) + }, + ) val lbl = nbt.getString("label") label = if (lbl == "") None else Some(lbl) @@ -65,11 +71,13 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra position.save(posTag) nbt.setTag("pos", posTag) - window.foreach(window => { - val tag = new NBTTagCompound - window.save(tag) - nbt.setTag("window", tag) - }) + window.foreach( + window => { + val tag = new NBTTagCompound + window.save(tag) + nbt.setTag("window", tag) + }, + ) nbt.setString("label", label.getOrElse("")) } @@ -110,37 +118,47 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra def setupContextMenu(menu: ContextMenu): Unit = { if (exposeAddress) { // TODO: lift the restriction - menu.addEntry(new ContextMenuEntry( - "Set label", - () => new InputDialog( - "Set label", - text => label = if (text.isEmpty) None else Some(text), - label.getOrElse("") - ).show() - )) + menu.addEntry( + ContextMenuEntry("Set label") { + new InputDialog( + "Set label", + text => label = if (text.isEmpty) None else Some(text), + label.getOrElse(""), + ).show() + }, + ) } if (exposeAddress && entity.node != null && entity.node.address != null) { - menu.addEntry(new ContextMenuEntry("Copy address", () => { - UiHandler.clipboard = entity.node.address - })) + menu.addEntry( + ContextMenuEntry("Copy address") { + UiHandler.clipboard = entity.node.address + } + ) } - menu.addEntry(new ContextMenuEntry("Disconnect", () => { - disconnectFromAll() - })) + menu.addEntry( + ContextMenuEntry("Disconnect") { + disconnectFromAll() + }, + ) - menu.addEntry(new ContextMenuEntry("Delete", () => { - dispose() - workspaceView.nodes = workspaceView.nodes.filter(_ != this) - })) + menu.addEntry( + ContextMenuEntry("Delete") { + destroy() + }, + ) } override def update(): Unit = { super.update() if (isHovered || isMoving) { - if (canOpen) - root.get.statusBar.addMouseEntry("icons/LMB", "Open") + if (canOpen) { + if (isClosed) + root.get.statusBar.addMouseEntry("icons/LMB", "Open") + else + root.get.statusBar.addMouseEntry("icons/LMB", "Close") + } root.get.statusBar.addMouseEntry("icons/RMB", "Menu") root.get.statusBar.addMouseEntry("icons/DragLMB", "Move node") root.get.statusBar.addMouseEntry("icons/DragRMB", "Connect/Disconnect") @@ -173,6 +191,11 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra node.onConnectionRemoved(portB, this, portA) } + def destroy(): Unit = { + dispose() + workspaceView.nodes = workspaceView.nodes.filter(_ != this) + } + def disconnectFromAll(): Unit = { for ((a, node, b) <- connections.toArray) { disconnect(a, node, b) @@ -194,7 +217,10 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra def onClick(event: ClickEvent): Unit = { event match { case ClickEvent(MouseEvent.Button.Left, _) => - window.foreach(window => workspaceView.windowPool.openWindow(window)) + if (isClosed) + window.foreach(workspaceView.windowPool.openWindow(_)) + else + window.foreach(_.hide()) case ClickEvent(MouseEvent.Button.Right, _) => val menu = new ContextMenu setupContextMenu(menu) @@ -203,6 +229,8 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra } } + private def isClosed: Boolean = window.exists(!_.isOpen) + def portsBounds: Iterator[(NodePort, Array[Rect2D])] = { val length = -4 val thickness = 4 @@ -221,14 +249,16 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra val left = Rect2D(centers(3) + Vector2D(-length, -thickness / 2), hsize) val centersBounds = Array[Rect2D](top, right, bottom, left) - val portBounds = (0 until 4).map(side => { - val offset = thickness - numPorts * stride / 2 + portIdx * stride - val rect = centersBounds(side) - side match { - case 0 | 2 => rect.mapX(_ + offset) - case 1 | 3 => rect.mapY(_ + offset) - } - }) + val portBounds = (0 until 4).map( + side => { + val offset = thickness - numPorts * stride / 2 + portIdx * stride + val rect = centersBounds(side) + side match { + case 0 | 2 => rect.mapX(_ + offset) + case 1 | 3 => rect.mapY(_ + offset) + } + }, + ) (port, portBounds.toArray) } @@ -251,7 +281,10 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra val oldPos = position val desiredPos = if (Keyboard.isKeyDown(Keyboard.KEY_LCONTROL)) - (pos - workspaceView.cameraOffset).snap(68) + workspaceView.cameraOffset + Vector2D(34 - width / 2, 34 - height / 2) + (pos - workspaceView.cameraOffset).snap(68) + workspaceView.cameraOffset + Vector2D( + 34 - width / 2, + 34 - height / 2, + ) else pos - grabPoint @@ -312,3 +345,9 @@ abstract class Node(val entity: Entity with Environment) extends Widget with Dra OcelotDesktop.workspace.remove(entity) } } + +object Node { + protected val MovingHighlight: RGBAColor = RGBAColor(240, 250, 240) + protected val HoveredHighlight: RGBAColor = RGBAColor(160, 160, 160) + protected val NoHighlight: RGBAColor = RGBAColor(160, 160, 160, 0) +} diff --git a/src/main/scala/ocelot/desktop/node/NodeRegistry.scala b/src/main/scala/ocelot/desktop/node/NodeRegistry.scala index 3245da3..b92f8c0 100644 --- a/src/main/scala/ocelot/desktop/node/NodeRegistry.scala +++ b/src/main/scala/ocelot/desktop/node/NodeRegistry.scala @@ -1,60 +1,60 @@ package ocelot.desktop.node -import ocelot.desktop.node.nodes._ -import totoro.ocelot.brain.entity.traits.GenericCamera -import totoro.ocelot.brain.entity.{Cable, Case, ColorfulLamp, FloppyDiskDrive, IronNoteBlock, NoteBlock, Relay, Screen} import ocelot.desktop.entity.{Camera, OpenFMRadio} +import ocelot.desktop.node.nodes._ +import totoro.ocelot.brain.entity.{Cable, Case, ColorfulLamp, FloppyDiskDrive, IronNoteBlock, NoteBlock, Relay, Screen} +import totoro.ocelot.brain.util.Tier import scala.collection.mutable object NodeRegistry { val types: mutable.ArrayBuffer[NodeType] = mutable.ArrayBuffer[NodeType]() - def register(t: NodeType): Unit = { + private def register(t: NodeType): Unit = { types += t } - for (tier <- 0 to 2) { - register(NodeType("Screen" + tier, "nodes/Screen", tier, () => { + for (tier <- Tier.One to Tier.Three) { + register(NodeType(s"Screen (${tier.label})", "nodes/Screen", tier) { new ScreenNode(new Screen(tier)).setup() - })) + }) } - register(NodeType("Disk Drive", "nodes/DiskDrive", -1, () => { - new DiskDriveNode(new FloppyDiskDrive()) - })) + register(NodeType("Disk Drive", "nodes/DiskDrive", None) { + new DiskDriveNode(new FloppyDiskDrive(), initDisk = true) + }) - for (tier <- 0 to 3) { - register(NodeType("Computer" + tier, "nodes/Computer", tier, () => { + for (tier <- Tier.One to Tier.Creative) { + register(NodeType(s"Computer Case (${tier.label})", "nodes/Computer", tier) { new ComputerNode(new Case(tier)).setup() - })) + }) } - register(NodeType("Relay", "nodes/Relay", -1, () => { + register(NodeType("Relay", "nodes/Relay", None) { new RelayNode(new Relay) - })) + }) - register(NodeType("Cable", "nodes/Cable", -1, () => { + register(NodeType("Cable", "nodes/Cable", None) { new CableNode(new Cable) - })) + }) - register(NodeType("NoteBlock", "nodes/NoteBlock", -1, () => { + register(NodeType("Note Block", "nodes/NoteBlock", None) { new NoteBlockNode(new NoteBlock) - })) + }) - register(NodeType("IronNoteBlock", "nodes/IronNoteBlock", -1, () => { + register(NodeType("Iron Note Block", "nodes/IronNoteBlock", None) { new IronNoteBlockNode(new IronNoteBlock) - })) + }) - register(NodeType("Camera", "nodes/Camera", -1, () => { + register(NodeType("Camera", "nodes/Camera", None) { new CameraNode(new Camera) - })) + }) - register(NodeType("ColorfulLamp", "nodes/Lamp", -1, () => { + register(NodeType("Colorful Lamp", "nodes/Lamp", None) { new ColorfulLampNode(new ColorfulLamp) - })) + }) - register(NodeType("OpenFM radio", "nodes/OpenFMRadio", -1, () => { + register(NodeType("OpenFM radio", "nodes/OpenFMRadio", None) { new OpenFMRadioNode(new OpenFMRadio) - })) + }) } diff --git a/src/main/scala/ocelot/desktop/node/NodeType.scala b/src/main/scala/ocelot/desktop/node/NodeType.scala index 8a97419..2e0f22c 100644 --- a/src/main/scala/ocelot/desktop/node/NodeType.scala +++ b/src/main/scala/ocelot/desktop/node/NodeType.scala @@ -1,5 +1,17 @@ package ocelot.desktop.node -case class NodeType(name: String, icon: String, tier: Int, factory: () => Node) extends Ordered[NodeType] { +import totoro.ocelot.brain.util.Tier.Tier + +class NodeType(val name: String, val icon: String, val tier: Option[Tier], factory: => Node) extends Ordered[NodeType] { + def make(): Node = factory + override def compare(that: NodeType): Int = this.name.compare(that.name) } + +object NodeType { + def apply(name: String, icon: String, tier: Tier)(factory: => Node): NodeType = + new NodeType(name, icon, Some(tier), factory) + + def apply(name: String, icon: String, tier: Option[Tier])(factory: => Node): NodeType = + new NodeType(name, icon, tier, factory) +} diff --git a/src/main/scala/ocelot/desktop/node/NodeTypeWidget.scala b/src/main/scala/ocelot/desktop/node/NodeTypeWidget.scala index d7d8727..2e52530 100644 --- a/src/main/scala/ocelot/desktop/node/NodeTypeWidget.scala +++ b/src/main/scala/ocelot/desktop/node/NodeTypeWidget.scala @@ -1,10 +1,12 @@ package ocelot.desktop.node +import ocelot.desktop.color.Color import ocelot.desktop.geometry.Size2D import ocelot.desktop.graphics.Graphics import ocelot.desktop.ui.event.handlers.{ClickHandler, HoverHandler} -import ocelot.desktop.ui.event.{ClickEvent, MouseEvent} +import ocelot.desktop.ui.event.{ClickEvent, HoverEvent, MouseEvent} import ocelot.desktop.ui.widget.Widget +import ocelot.desktop.ui.widget.tooltip.LabelTooltip import ocelot.desktop.util.{Spritesheet, TierColor} class NodeTypeWidget(val nodeType: NodeType) extends Widget with ClickHandler with HoverHandler { @@ -19,11 +21,29 @@ class NodeTypeWidget(val nodeType: NodeType) extends Widget with ClickHandler wi eventHandlers += { case ClickEvent(MouseEvent.Button.Left, _) => onClick() + case HoverEvent(state) => + if (state == HoverEvent.State.Enter) onHoverEnter() + else onHoverLeave() } + private val labelTooltip = new LabelTooltip(nodeType.name) + + def onHoverEnter(): Unit = + root.get.tooltipPool.addTooltip(labelTooltip) + + def onHoverLeave(): Unit = + root.get.tooltipPool.closeTooltip(labelTooltip) + override def draw(g: Graphics): Unit = { val size = Spritesheet.spriteSize(nodeType.icon) * 4 - g.sprite(nodeType.icon, position.x + 34 - size.width / 2, position.y + 34 - size.height / 2, size.width, size.height, TierColor.get(nodeType.tier)) + g.sprite( + nodeType.icon, + position.x + 34 - size.width / 2, + position.y + 34 - size.height / 2, + size.width, + size.height, + nodeType.tier.map(TierColor.get).getOrElse(Color.White) + ) } override def update(): Unit = { diff --git a/src/main/scala/ocelot/desktop/node/nodes/ColorfulLampNode.scala b/src/main/scala/ocelot/desktop/node/nodes/ColorfulLampNode.scala index b9b227d..17cd16c 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/ColorfulLampNode.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/ColorfulLampNode.scala @@ -9,7 +9,7 @@ import totoro.ocelot.brain.entity.ColorfulLamp class ColorfulLampNode(val lamp: ColorfulLamp) extends Node(lamp) { private var lastColor: RGBAColor = RGBAColor(0, 0, 0) private var mouseHover: Boolean = false - override def exposeAddress = mouseHover + override def exposeAddress: Boolean = mouseHover override def draw(g: Graphics): Unit = { super.draw(g) diff --git a/src/main/scala/ocelot/desktop/node/nodes/ComputerNode.scala b/src/main/scala/ocelot/desktop/node/nodes/ComputerNode.scala index 290a1b9..b21fd16 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/ComputerNode.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/ComputerNode.scala @@ -1,46 +1,115 @@ package ocelot.desktop.node.nodes +import ocelot.desktop.ColorScheme import ocelot.desktop.audio._ import ocelot.desktop.color.Color +import ocelot.desktop.geometry.Vector2D import ocelot.desktop.graphics.Graphics +import ocelot.desktop.inventory.item._ +import ocelot.desktop.inventory.traits.ComponentItem +import ocelot.desktop.inventory.{Item, SyncedInventory} import ocelot.desktop.node.Node +import ocelot.desktop.node.nodes.ComputerNode.{ErrorMessageMoveSpeed, MaxErrorMessageDistance} +import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.sources.KeyEvents import ocelot.desktop.ui.event.{BrainEvent, ClickEvent, MouseEvent} +import ocelot.desktop.ui.widget.Label import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} import ocelot.desktop.ui.widget.slot._ -import ocelot.desktop.util.{Logging, TierColor} +import ocelot.desktop.util.{Logging, Messages, TierColor} import ocelot.desktop.windows.ComputerWindow import org.lwjgl.input.Keyboard import totoro.ocelot.brain.Settings -import totoro.ocelot.brain.entity.traits.{Entity, Environment, Floppy, GenericCPU, Inventory} -import totoro.ocelot.brain.entity.{CPU, Case, EEPROM, GraphicsCard, HDDManaged, HDDUnmanaged, Memory} +import totoro.ocelot.brain.entity.Case +import totoro.ocelot.brain.entity.traits.{Environment, Inventory} import totoro.ocelot.brain.event.FileSystemActivityType.Floppy -import totoro.ocelot.brain.event.{BeepEvent, BeepPatternEvent, FileSystemActivityEvent, MachineCrashEvent, SoundCardAudioEvent} +import totoro.ocelot.brain.event._ import totoro.ocelot.brain.loot.Loot +import totoro.ocelot.brain.nbt.NBTTagCompound import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier -import java.nio.ByteBuffer +import scala.collection.mutable +import scala.math.Ordering.Implicits.infixOrderingOps import scala.reflect.ClassTag import scala.util.Random -class ComputerNode(val computer: Case) extends Node(computer) with Logging { - var eepromSlot: EEPROMSlot = _ - var cpuSlot: CPUSlot = _ - var memorySlots: Array[MemorySlot] = _ - var cardSlots: Array[CardSlot] = _ - var diskSlots: Array[DiskSlot] = _ - var floppySlot: Option[FloppySlot] = None +class ComputerNode(val computer: Case) extends Node(computer) with Logging with SyncedInventory { + node => - private val soundComputerRunning = SoundSource.fromBuffer(SoundBuffers.MachineComputerRunning, SoundCategory.Environment, looping = true) + override type I = Item with ComponentItem + + var eepromSlot: EepromSlotWidget = _ + var cpuSlot: CpuSlotWidget = _ + var memorySlots: Array[MemorySlotWidget] = Array.empty + var cardSlots: Array[CardSlotWidget] = Array.empty + var diskSlots: Array[HddSlotWidget] = Array.empty + var floppySlot: Option[FloppySlotWidget] = None + + override def brainInventory: Inventory = computer.inventory.owner + + private def slots: IterableOnce[SlotWidget[I]] = ( + // slots may be null during initialization + Option(eepromSlot).iterator ++ + Option(cpuSlot).iterator ++ + memorySlots.iterator ++ + cardSlots.iterator ++ + diskSlots.iterator ++ + floppySlot.iterator + ).map(_.asInstanceOf[SlotWidget[I]]) + + // NOTE: `soundComputerRunning` must be lazy so that it doesn't get loaded before the audio subsystem is initialized + private lazy val soundComputerRunning = SoundSource.fromBuffer( + SoundBuffers.MachineComputerRunning, + SoundCategory.Environment, + looping = true, + ) + // TODO: Scala has lazy vals. Use them. private var soundCardStream: SoundStream = _ private var soundCardSource: SoundSource = _ setupSlots() - refitSlots() + + override def load(nbt: NBTTagCompound): Unit = { + super[Node].load(nbt) + super[SyncedInventory].load(nbt) + } + + override def save(nbt: NBTTagCompound): Unit = { + super[Node].save(nbt) + super[SyncedInventory].save(nbt) + } + + private class ErrorMessageLabel(override val text: String) extends Label { + override def isSmall: Boolean = true + + var alpha: Float = 1f + + override def color: Color = ColorScheme("ErrorMessage").toRGBANorm.mapA(_ * alpha) + + val initialPosition: Vector2D = { + val position = node.position + val size = node.size + + position + Vector2D(size.width / 2 - minimumSize.width / 2, -minimumSize.height) + } + + position = initialPosition + } eventHandlers += { case BrainEvent(event: MachineCrashEvent) => - logger.info(s"[EVENT] Machine crash! (address = ${event.address}, ${event.message})") + val message = Messages.lift(event.message) match { + case Some(message) => + logger.info(s"[EVENT] Machine crash (address = ${event.address})! Message code ${event.message}: $message") + message + + case None => + logger.info(s"[EVENT] Machine crash (address = ${event.address})! Message: ${event.message}") + event.message + } + + addErrorMessage(new ErrorMessageLabel(message)) case BrainEvent(event: BeepEvent) if !Audio.isDisabled => BeepGenerator.newBeep(".", event.frequency, event.duration).play() @@ -49,8 +118,10 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { BeepGenerator.newBeep(event.pattern, 1000, 200).play() case BrainEvent(event: FileSystemActivityEvent) if !Audio.isDisabled => - val soundFloppyAccess = SoundBuffers.MachineFloppyAccess.map(buffer => SoundSource.fromBuffer(buffer, SoundCategory.Environment)) - val soundHDDAccess = SoundBuffers.MachineHDDAccess.map(buffer => SoundSource.fromBuffer(buffer, SoundCategory.Environment)) + val soundFloppyAccess = SoundBuffers.MachineFloppyAccess + .map(buffer => SoundSource.fromBuffer(buffer, SoundCategory.Environment)) + val soundHDDAccess = SoundBuffers.MachineHDDAccess + .map(buffer => SoundSource.fromBuffer(buffer, SoundCategory.Environment)) val sound = if (event.activityType == Floppy) soundFloppyAccess else soundHDDAccess sound(Random.between(0, sound.length)).play() @@ -63,19 +134,31 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { } soundCardStream.enqueue(samples) soundCardSource.volume = event.volume + + case BrainEvent(_: SelfDestructingCardBoomEvent) => + computer.workspace.runLater( + () => { + SoundSources.MinecraftExplosion.play() + destroy() + }, + ) } override def shouldReceiveEventsFor(address: String): Boolean = super.shouldReceiveEventsFor(address) || computer.inventory.entities.exists { case env: Environment => env.node.address == address } def setup(): ComputerNode = { - cpuSlot.owner.put(new CPU(computer.tier.min(2))) - memorySlots(0).owner.put(new Memory(computer.tier.min(2) * 2 + 1)) - memorySlots(1).owner.put(new Memory(computer.tier.min(2) * 2 + 1)) - cardSlots(0).owner.put(new GraphicsCard(computer.tier.min(1))) - floppySlot.map(_.owner).foreach(_.put(Loot.OpenOsFloppy.create())) - eepromSlot.owner.put(Loot.LuaBiosEEPROM.create()) - refitSlots() + cpuSlot.item = new CpuItem.Factory(computer.tier min Tier.Three).build() + memorySlots(0).item = new MemoryItem.Factory((computer.tier min Tier.Three).toExtended(true)).build() + memorySlots(1).item = new MemoryItem.Factory((computer.tier min Tier.Three).toExtended(true)).build() + cardSlots(0).item = new GraphicsCardItem.Factory(computer.tier min Tier.Two).build() + + for (floppySlot <- floppySlot) { + floppySlot.item = new FloppyItem.Factory.Loot(Loot.OpenOsFloppy).build() + } + + eepromSlot.item = new EepromItem.Factory.Loot(Loot.LuaBiosEEPROM).build() + this } @@ -99,20 +182,36 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { override def setupContextMenu(menu: ContextMenu): Unit = { if (isRunning) { - menu.addEntry(new ContextMenuEntry("Turn off", () => turnOff())) - menu.addEntry(new ContextMenuEntry("Reboot", () => { - computer.turnOff() - computer.turnOn() - })) - } else - menu.addEntry(new ContextMenuEntry("Turn on", () => turnOn())) + menu.addEntry( + ContextMenuEntry("Turn off") { + turnOff() + }, + ) + menu.addEntry( + ContextMenuEntry("Reboot") { + computer.turnOff() + computer.turnOn() + }, + ) + } else { + menu.addEntry( + ContextMenuEntry("Turn on") { + turnOn() + }, + ) + } - menu.addEntry(new ContextMenuSubmenu("Set tier") { - addEntry(new ContextMenuEntry("Tier 1", () => changeTier(Tier.One))) - addEntry(new ContextMenuEntry("Tier 2", () => changeTier(Tier.Two))) - addEntry(new ContextMenuEntry("Tier 3", () => changeTier(Tier.Three))) - addEntry(new ContextMenuEntry("Creative", () => changeTier(Tier.Four))) - }) + menu.addEntry( + new ContextMenuSubmenu("Set tier") { + for (tier <- Tier.One to Tier.Creative) { + addEntry( + ContextMenuEntry(tier.label) { + changeTier(tier) + }, + ) + } + }, + ) menu.addSeparator() super.setupContextMenu(menu) @@ -121,107 +220,63 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { override def onClick(event: ClickEvent): Unit = { event match { case ClickEvent(MouseEvent.Button.Left, _) => - if (KeyEvents.isDown(Keyboard.KEY_LSHIFT)) - if (isRunning) + if (KeyEvents.isDown(Keyboard.KEY_LSHIFT)) { + if (isRunning) { turnOff() - else + } else { turnOn() - else + } + } else { super.onClick(event) + } + case event => super.onClick(event) } } - private def changeTier(n: Int): Unit = { - computer.tier = n + private def changeTier(tier: Tier): Unit = { + computer.tier = tier + + val items = slots.iterator.flatMap(_.item).toArray + clearInventory() setupSlots() - refitSlots() - if (currentWindow != null) currentWindow.updateSlots() - } + insertItems(items) - private def slotAccepts(slot: Inventory#Slot, entity: Entity): Boolean = entity match { - case cpu: GenericCPU => cpuSlot.owner.index == slot.index && cpuSlot.tier >= cpu.cpuTier - case mem: Memory => memorySlots - .exists(memSlot => memSlot.owner.index == slot.index && memSlot.tier >= (mem.tier + 1) / 2 - 1) - case hdd: HDDManaged => diskSlots - .exists(diskSlot => diskSlot.owner.index == slot.index && diskSlot.tier >= hdd.tier) - case hdd: HDDUnmanaged => diskSlots - .exists(diskSlot => diskSlot.owner.index == slot.index && diskSlot.tier >= hdd.tier) - case _: EEPROM => eepromSlot.owner.index == slot.index - case _: Floppy => floppySlot.exists(_.owner.index == slot.index) - case card: Entity => cardSlots - .exists(cardSlot => cardSlot.owner.index == slot.index && cardSlot.tier >= CardRegistry.getTier(card)) - } - - private def isSlotValid(slot: Inventory#Slot): Boolean = slot.get.exists(slotAccepts(slot, _)) - - private def reloadSlots(): Unit = { - cpuSlot.reloadItem() - memorySlots.foreach(_.reloadItem()) - cardSlots.foreach(_.reloadItem()) - diskSlots.foreach(_.reloadItem()) - eepromSlot.reloadItem() - floppySlot.foreach(_.reloadItem()) - } - - private def refitSlots(): Unit = { - if (computer.inventory.forall(isSlotValid)) { - reloadSlots() - - return + if (currentWindow != null) { + currentWindow.reloadWindow() } + } - val entities = computer.inventory.entities.toArray - computer.inventory.clear() - - cpuSlot._item = None - for (slot <- memorySlots) slot._item = None - for (slot <- cardSlots) slot._item = None - for (slot <- diskSlots) slot._item = None - eepromSlot._item = None - floppySlot.foreach(_._item = None) - - def findBestSlot[T <: InventorySlot[_]](entity: Entity, candidates: Array[T], tierProvider: T => Option[Int]): Option[T] = { + private def insertItems(items: IterableOnce[I]): Unit = { + def findBestSlot[A <: I](item: A, candidates: IterableOnce[SlotWidget[A]]): Option[SlotWidget[A]] = { candidates.iterator - .filter(_.owner.isEmpty) - .filter(slot => slotAccepts(slot.owner, entity)) - .minByOption(tierProvider(_).getOrElse(Int.MinValue)) + .filter(_.item.isEmpty) + .filter(_.isItemAccepted(item.factory)) + .minByOption(_.slotTier) } - for (entity <- entities) { - val newSlot = entity match { - case _: GenericCPU => findBestSlot[CPUSlot](entity, Array(cpuSlot), slot => Some(slot.tier)) - case _: Memory => findBestSlot[MemorySlot](entity, memorySlots, slot => Some(slot.tier)) - case _: HDDManaged => findBestSlot[DiskSlot](entity, diskSlots, slot => Some(slot.tier)) - case _: HDDUnmanaged => findBestSlot[DiskSlot](entity, diskSlots, slot => Some(slot.tier)) - case _: EEPROM => findBestSlot[EEPROMSlot](entity, Array(eepromSlot), _ => None) - case _: Floppy => findBestSlot[FloppySlot](entity, floppySlot.toArray, _ => None) - case _: Entity => findBestSlot[CardSlot](entity, cardSlots, slot => Some(slot.tier)) - } - - newSlot.foreach(_.owner.put(entity)) + for (item <- items; newSlot <- findBestSlot(item, slots)) { + newSlot.item = item } - - reloadSlots() } private def setupSlots(): Unit = { var slotIndex = 0 - def nextSlot(): computer.Slot = { - val result = computer.inventory(slotIndex) + def nextSlot(): Slot = { + val result = Slot(slotIndex) slotIndex += 1 result } - def addSlot[T <: InventorySlot[_]](factory: computer.Slot => T): T = { + def addSlot[T <: SlotWidget[_]](factory: Slot => T): T = { val slot = nextSlot() val widget = factory(slot) widget } - def addSlots[T <: InventorySlot[_] : ClassTag](factories: (computer.Slot => T)*): Array[T] = { + def addSlots[T <: SlotWidget[_] : ClassTag](factories: (Slot => T)*): Array[T] = { val array = Array.newBuilder[T] for (factory <- factories) { @@ -231,43 +286,51 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { array.result() } + for (slot <- slots) { + slot.dispose() + } + computer.tier match { case Tier.One => - cardSlots = addSlots(new CardSlot(_, Tier.One), new CardSlot(_, Tier.One)) - memorySlots = addSlots(new MemorySlot(_, Tier.One)) - diskSlots = addSlots(new DiskSlot(_, Tier.One)) + cardSlots = addSlots(new CardSlotWidget(_, Tier.One), new CardSlotWidget(_, Tier.One)) + memorySlots = addSlots(new MemorySlotWidget(_, Tier.One)) + diskSlots = addSlots(new HddSlotWidget(_, Tier.One)) floppySlot = None - cpuSlot = addSlot(new CPUSlot(_, this, Tier.One)) + cpuSlot = addSlot(new CpuSlotWidget(_, this, Tier.One)) // no idea why on earth the memory slots are split in two here - memorySlots :+= addSlot(new MemorySlot(_, Tier.One)) - eepromSlot = addSlot(new EEPROMSlot(_)) + memorySlots :+= addSlot(new MemorySlotWidget(_, Tier.One)) + eepromSlot = addSlot(new EepromSlotWidget(_)) case Tier.Two => - cardSlots = addSlots(new CardSlot(_, Tier.Two), new CardSlot(_, Tier.One)) - memorySlots = addSlots(new MemorySlot(_, Tier.Two), new MemorySlot(_, Tier.Two)) - diskSlots = addSlots(new DiskSlot(_, Tier.Two), new DiskSlot(_, Tier.One)) + cardSlots = addSlots(new CardSlotWidget(_, Tier.Two), new CardSlotWidget(_, Tier.One)) + memorySlots = addSlots(new MemorySlotWidget(_, Tier.Two), new MemorySlotWidget(_, Tier.Two)) + diskSlots = addSlots(new HddSlotWidget(_, Tier.Two), new HddSlotWidget(_, Tier.One)) floppySlot = None - cpuSlot = addSlot(new CPUSlot(_, this, Tier.Two)) - eepromSlot = addSlot(new EEPROMSlot(_)) + cpuSlot = addSlot(new CpuSlotWidget(_, this, Tier.Two)) + eepromSlot = addSlot(new EepromSlotWidget(_)) case _ => cardSlots = if (computer.tier == Tier.Three) { - addSlots(new CardSlot(_, Tier.Three), new CardSlot(_, Tier.Two), new CardSlot(_, Tier.Two)) + addSlots(new CardSlotWidget(_, Tier.Three), new CardSlotWidget(_, Tier.Two), new CardSlotWidget(_, Tier.Two)) } else { - addSlots(new CardSlot(_, Tier.Three), new CardSlot(_, Tier.Three), new CardSlot(_, Tier.Three)) + addSlots( + new CardSlotWidget(_, Tier.Three), + new CardSlotWidget(_, Tier.Three), + new CardSlotWidget(_, Tier.Three), + ) } - memorySlots = addSlots(new MemorySlot(_, Tier.Three), new MemorySlot(_, Tier.Three)) + memorySlots = addSlots(new MemorySlotWidget(_, Tier.Three), new MemorySlotWidget(_, Tier.Three)) diskSlots = if (computer.tier == Tier.Three) { - addSlots(new DiskSlot(_, Tier.Three), new DiskSlot(_, Tier.Two)) + addSlots(new HddSlotWidget(_, Tier.Three), new HddSlotWidget(_, Tier.Two)) } else { - addSlots(new DiskSlot(_, Tier.Three), new DiskSlot(_, Tier.Three)) + addSlots(new HddSlotWidget(_, Tier.Three), new HddSlotWidget(_, Tier.Three)) } - floppySlot = Some(addSlot(new FloppySlot(_))) - cpuSlot = addSlot(new CPUSlot(_, this, Tier.Three)) - eepromSlot = addSlot(new EEPROMSlot(_)) + floppySlot = Some(addSlot(new FloppySlotWidget(_))) + cpuSlot = addSlot(new CpuSlotWidget(_, this, Tier.Three)) + eepromSlot = addSlot(new EepromSlotWidget(_)) } } @@ -288,10 +351,21 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { override def update(): Unit = { super.update() - if (!isRunning && soundComputerRunning.isPlaying) + + if (!isRunning && soundComputerRunning.isPlaying) { soundComputerRunning.stop() - if (isHovered || isMoving) + } else if (isRunning && !soundComputerRunning.isPlaying && !Audio.isDisabled) { + soundComputerRunning.play() + } + + if (isHovered || isMoving) { root.get.statusBar.addKeyMouseEntry("icons/LMB", "SHIFT", if (isRunning) "Turn Off" else "Turn On") + } + } + + override def dispose(): Unit = { + super.dispose() + soundComputerRunning.stop() } private var currentWindow: ComputerWindow = _ @@ -303,4 +377,25 @@ class ComputerNode(val computer: Case) extends Node(computer) with Logging { } else Some(currentWindow) } + private val messages = mutable.ArrayBuffer[(Float, ErrorMessageLabel)]() + + private def addErrorMessage(message: ErrorMessageLabel): Unit = synchronized { + messages += ((0f, message)) + } + + override def drawParticles(g: Graphics): Unit = synchronized { + for ((time, message) <- messages.reverseIterator) { + message.position = message.initialPosition + Vector2D(0, -MaxErrorMessageDistance * time) + message.alpha = 1 - time + message.draw(g) + } + + messages.mapInPlace { case (t, message) => (t + ErrorMessageMoveSpeed * UiHandler.dt, message) } + messages.filterInPlace(_._1 <= 1f) + } +} + +object ComputerNode { + private val MaxErrorMessageDistance: Float = 50 + private val ErrorMessageMoveSpeed: Float = 0.5f } diff --git a/src/main/scala/ocelot/desktop/node/nodes/DiskDriveNode.scala b/src/main/scala/ocelot/desktop/node/nodes/DiskDriveNode.scala index fe702df..67b38ed 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/DiskDriveNode.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/DiskDriveNode.scala @@ -2,57 +2,83 @@ package ocelot.desktop.node.nodes import ocelot.desktop.color.IntColor import ocelot.desktop.graphics.Graphics +import ocelot.desktop.inventory.SyncedInventory +import ocelot.desktop.inventory.item.FloppyItem import ocelot.desktop.node.Node -import ocelot.desktop.ui.widget.slot.FloppySlot +import ocelot.desktop.ui.widget.slot.FloppySlotWidget import ocelot.desktop.windows.DiskDriveWindow import totoro.ocelot.brain.entity.FloppyDiskDrive -import totoro.ocelot.brain.entity.traits.Floppy +import totoro.ocelot.brain.entity.traits.Inventory import totoro.ocelot.brain.loot.Loot +import totoro.ocelot.brain.nbt.NBTTagCompound import totoro.ocelot.brain.util.DyeColor -class DiskDriveNode(val diskDrive: FloppyDiskDrive) extends Node(diskDrive) { - val slot: FloppySlot = new FloppySlot(diskDrive.inventory(0)) - slot.item = floppy.getOrElse(Loot.OpenOsFloppy.create()) +class DiskDriveNode(val diskDrive: FloppyDiskDrive, initDisk: Boolean) extends Node(diskDrive) with SyncedInventory { + def this(diskDrive: FloppyDiskDrive) = { + this(diskDrive, false) + } + + override type I = FloppyItem + + override def brainInventory: Inventory = diskDrive.inventory.owner + + val slot: FloppySlotWidget = new FloppySlotWidget(Slot(0)) + + if (initDisk) { + slot.item = new FloppyItem.Factory.Loot(Loot.OpenOsFloppy).build() + } + + override def load(nbt: NBTTagCompound): Unit = { + super[Node].load(nbt) + super[SyncedInventory].load(nbt) + } + + override def save(nbt: NBTTagCompound): Unit = { + super[Node].save(nbt) + super[SyncedInventory].save(nbt) + } override def icon: String = "nodes/DiskDrive" override protected val canOpen = true private val colorMap: Map[DyeColor, Int] = Map( - DyeColor.BLACK -> 0x444444, // 0x1E1B1B - DyeColor.RED -> 0xB3312C, - DyeColor.GREEN -> 0x339911, // 0x3B511A - DyeColor.BROWN -> 0x51301A, - DyeColor.BLUE -> 0x6666FF, // 0x253192 - DyeColor.PURPLE -> 0x7B2FBE, - DyeColor.CYAN -> 0x66FFFF, // 0x287697 - DyeColor.SILVER -> 0xABABAB, - DyeColor.GRAY -> 0x666666, // 0x434343 - DyeColor.PINK -> 0xD88198, - DyeColor.LIME -> 0x66FF66, // 0x41CD34 - DyeColor.YELLOW -> 0xFFFF66, // 0xDECF2A - DyeColor.LIGHT_BLUE -> 0xAAAAFF, // 0x6689D3 - DyeColor.MAGENTA -> 0xC354CD, - DyeColor.ORANGE -> 0xEB8844, - DyeColor.WHITE -> 0xF0F0F0 + DyeColor.Black -> 0x444444, // 0x1E1B1B + DyeColor.Red -> 0xB3312C, + DyeColor.Green -> 0x339911, // 0x3B511A + DyeColor.Brown -> 0x51301A, + DyeColor.Blue -> 0x6666FF, // 0x253192 + DyeColor.Purple -> 0x7B2FBE, + DyeColor.Cyan -> 0x66FFFF, // 0x287697 + DyeColor.Silver -> 0xABABAB, + DyeColor.Gray -> 0x666666, // 0x434343 + DyeColor.Pink -> 0xD88198, + DyeColor.Lime -> 0x66FF66, // 0x41CD34 + DyeColor.Yellow -> 0xFFFF66, // 0xDECF2A + DyeColor.LightBlue -> 0xAAAAFF, // 0x6689D3 + DyeColor.Magenta -> 0xC354CD, + DyeColor.Orange -> 0xEB8844, + DyeColor.White -> 0xF0F0F0 ) override def draw(g: Graphics): Unit = { super.draw(g) - if (System.currentTimeMillis() - diskDrive.lastDiskAccess < 400 && Math.random() > 0.1) + if (System.currentTimeMillis() - diskDrive.lastDiskAccess < 400 && Math.random() > 0.1) { g.sprite("nodes/DiskDriveActivity", position.x + 2, position.y + 2, size.width - 4, size.height - 4) + } - if (slot.item.isDefined) - g.sprite("nodes/DiskDriveFloppy", position.x + 2, position.y + 2, size.width - 4, size.height - 4, IntColor(colorMap(slot.item.get.color))) + for (item <- slot.item) { + g.sprite( + "nodes/DiskDriveFloppy", + position.x + 2, + position.y + 2, + size.width - 4, + size.height - 4, + IntColor(colorMap(item.color)), + ) + } } override val window: Option[DiskDriveWindow] = Some(new DiskDriveWindow(this)) - - private def floppy: Option[Floppy] = { - diskDrive.inventory(0).get match { - case Some(floppy: Floppy) => Some(floppy) - case _ => None - } - } } diff --git a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNode.scala b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNode.scala index 1290050..59794e6 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNode.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNode.scala @@ -12,7 +12,9 @@ class NoteBlockNode(val noteBlock: NoteBlock) extends NoteBlockNodeBase(noteBloc val maxLen = NoteBlockNode.Instruments.map(_._2.length).max for ((instrument, name) <- NoteBlockNode.Instruments) { val dot = if (noteBlock.instrument == instrument) '•' else ' ' - addEntry(new ContextMenuEntry(name.padTo(maxLen, ' ') + dot, () => noteBlock.instrument = instrument)) + addEntry(ContextMenuEntry(name.padTo(maxLen, ' ') + dot) { + noteBlock.instrument = instrument + }) } } }) diff --git a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala index 5edfb77..b430277 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/NoteBlockNodeBase.scala @@ -22,7 +22,7 @@ abstract class NoteBlockNodeBase(entity: Entity with Environment) extends Node(e private val particles = mutable.ArrayBuffer[(Float, Int)]() - def addParticle(pitch: Int): Unit = { + private def addParticle(pitch: Int): Unit = { synchronized { particles += ((0f, pitch)) } diff --git a/src/main/scala/ocelot/desktop/node/nodes/ScreenNode.scala b/src/main/scala/ocelot/desktop/node/nodes/ScreenNode.scala index a9332c6..8ef42a8 100644 --- a/src/main/scala/ocelot/desktop/node/nodes/ScreenNode.scala +++ b/src/main/scala/ocelot/desktop/node/nodes/ScreenNode.scala @@ -50,9 +50,9 @@ class ScreenNode(val screen: Screen) extends Node(screen) { override def setupContextMenu(menu: ContextMenu): Unit = { if (screen.getPowerState) - menu.addEntry(new ContextMenuEntry("Turn off", () => screen.setPowerState(false))) + menu.addEntry(ContextMenuEntry("Turn off") { screen.setPowerState(false) }) else - menu.addEntry(new ContextMenuEntry("Turn on", () => screen.setPowerState(true))) + menu.addEntry(ContextMenuEntry("Turn on") { screen.setPowerState(true) }) menu.addSeparator() super.setupContextMenu(menu) diff --git a/src/main/scala/ocelot/desktop/ui/UiHandler.scala b/src/main/scala/ocelot/desktop/ui/UiHandler.scala index 619654b..b0f0abd 100644 --- a/src/main/scala/ocelot/desktop/ui/UiHandler.scala +++ b/src/main/scala/ocelot/desktop/ui/UiHandler.scala @@ -1,8 +1,8 @@ package ocelot.desktop.ui import buildinfo.BuildInfo -import ocelot.desktop.audio.Audio -import ocelot.desktop.geometry.{Size2D, Vector2D} +import ocelot.desktop.audio.{Audio, SoundBuffers} +import ocelot.desktop.geometry.{Rect2D, Size2D, Vector2D} import ocelot.desktop.graphics.Graphics import ocelot.desktop.ui.event.MouseEvent import ocelot.desktop.ui.event.handlers.HoverHandler @@ -34,8 +34,9 @@ object UiHandler extends Logging { private var shouldUpdateHierarchy = true private val fpsCalculator = new FPSCalculator private val ticker = new Ticker + var scalingFactor = 1.0f - ticker.tickInterval = 1.second / 60 + ticker.tickInterval = 1.second / 144 def getHierarchy: Array[Widget] = hierarchy.toArray @@ -62,7 +63,7 @@ object UiHandler extends Logging { def dt: Float = fpsCalculator.dt def mousePosition: Vector2D = { - Vector2D(Mouse.getX, Display.getHeight - Mouse.getY) + Vector2D(Mouse.getX, Display.getHeight - Mouse.getY) / scalingFactor } private val _clipboard = Toolkit.getDefaultToolkit.getSystemClipboard @@ -80,19 +81,25 @@ object UiHandler extends Logging { _clipboard.setContents(data, data) } - def isFullScreen: Boolean = Display.isFullscreen + def fullScreen: Boolean = Display.isFullscreen - def isFullScreen_=(value: Boolean): Any = { + def fullScreen_=(value: Boolean): Unit = { if (value) { Display.setDisplayModeAndFullscreen(Display.getDesktopDisplayMode) } else { // Updating size from settings val settingsSize = Settings.get.windowSize - root.size = if (settingsSize.isSet) Size2D(settingsSize.x, settingsSize.y) else Size2D(800, 600) + + val unscaledSize = sanitizeWindowSize( + if (settingsSize.isSet) Size2D(settingsSize.x, settingsSize.y) + else Size2D(800, 600) + ) + + root.size = unscaledSize / scalingFactor // Setting normal display mode (non-fullscreen by default) - Display.setDisplayMode(new DisplayMode(root.size.width.toInt, root.size.height.toInt)) + Display.setDisplayMode(new DisplayMode(unscaledSize.width.toInt, unscaledSize.height.toInt)) // Updating position from settings if (Settings.get.windowPosition.isSet) { @@ -131,39 +138,100 @@ object UiHandler extends Logging { Settings.get.windowFullscreen = value } + private def formatRect(rect: Rect2D): String = { + val Rect2D(x, y, w, h) = rect + + f"$w%.0f×$h%.0f$x%+.0f$y%+.0f" + } + + private def windowGeometry: Rect2D = new Rect2D(Display.getX, Display.getY, Display.getWidth, Display.getHeight) + + private def sanitizeWindowSize(size: Size2D): Size2D = + Size2D( + if (size.width < 10) 800 else size.width, + if (size.height < 10) 600 else size.height, + ) + + private def sanitizeWindowGeometry(currentGeometry: Rect2D): Rect2D = { + val Rect2D(x, y, w, h) = currentGeometry + val newSize = sanitizeWindowSize(Size2D(w, h)) + + Rect2D( + x max 0, + y max 0, + newSize.width, + newSize.height + ) + } + + private def fixInsaneInitialWindowGeometry(): Unit = { + // what I mean by insane: ocelot.desktop.ui.UiHandler$ - Created window: 0×0 (at 960, -569) + + val currentGeometry = windowGeometry + val geometry = sanitizeWindowGeometry(currentGeometry) + + if (geometry != currentGeometry) { + logger.warn(s"Window geometry sanity check failed: ${formatRect(currentGeometry)} is officially insane") + logger.warn(s"Resetting to ${formatRect(geometry)}") + Display.setDisplayMode(new DisplayMode(geometry.w.toInt, geometry.h.toInt)) + Display.setLocation(geometry.x.toInt, geometry.y.toInt) + } + } + def init(root: RootWidget): Unit = { this.root = root root.relayout() - loadLibraries() - - isFullScreen = Settings.get.windowFullscreen + scalingFactor = Settings.get.scaleFactor + fullScreen = Settings.get.windowFullscreen windowTitle = "Ocelot Desktop v" + BuildInfo.version loadIcons() - Display.setVSyncEnabled(true) + + if (!Settings.get.disableVsync) { + logger.info("VSync enabled") + Display.setVSyncEnabled(true) + } else { + logger.info("VSync disabled (via config)") + } + Display.create() + + if (Settings.get.windowValidatePosition) { + fixInsaneInitialWindowGeometry() + } + KeyEvents.init() MouseEvents.init() - logger.info(s"Created window with ${root.size}") + logger.info(s"Created window: ${formatRect(windowGeometry)}") logger.info(s"OpenGL vendor: ${GL11.glGetString(GL11.GL_VENDOR)}") logger.info(s"OpenGL renderer: ${GL11.glGetString(GL11.GL_RENDERER)}") + logger.info(s"OpenGL version: ${GL11.glGetString(GL11.GL_VERSION)}") Spritesheet.load() - graphics = new Graphics + graphics = new Graphics(scalingFactor) - Audio.init() + if (Settings.get.audioDisable) { + logger.warn("Sound disabled (via config)") + } else { + Audio.init() + } } private var nativeLibrariesDir: String = _ - private def loadLibraries(): Unit = { + def loadLibraries(): Unit = { // we cannot remove DLL files on Windows after they were loaded by Ocelot // therefore we will create them in local directory and keep for future - nativeLibrariesDir = if (SystemUtils.IS_OS_WINDOWS) - Paths.get(this.getClass.getProtectionDomain.getCodeSource.getLocation.toURI.resolve( "natives")).toString - else Files.createTempDirectory("ocelot-desktop").toString + nativeLibrariesDir = if (SystemUtils.IS_OS_WINDOWS) { + Paths.get(this.getClass.getProtectionDomain.getCodeSource.getLocation.toURI.resolve("natives")).toString + } else { + val directory = Files.createTempDirectory("ocelot-desktop") + Runtime.getRuntime.addShutdownHook(new Thread(() => FileUtils.deleteDirectory(new File(nativeLibrariesDir)))) + + directory.toString + } val arch = System.getProperty("os.arch") val is64bit = arch.startsWith("amd64") @@ -202,6 +270,11 @@ object UiHandler extends Logging { } System.setProperty("org.lwjgl.librarypath", nativeLibrariesDir) + + if (Settings.get.debugLwjgl) { + logger.info("Enabling LWJGL debug mode") + System.setProperty("org.lwjgl.util.Debug", true.toString) + } } private def loadIcons(): Unit = { @@ -257,20 +330,21 @@ object UiHandler extends Logging { KeyEvents.update() MouseEvents.update() - Profiler.startTimeMeasurement("000_update") - update() - Profiler.endTimeMeasurement("000_update") + Profiler.measure("000_update") { + update() + } - Profiler.startTimeMeasurement("001_draw") - draw() - Profiler.endTimeMeasurement("001_draw") + Profiler.measure("001_draw") { + draw() + } + } + + Profiler.measure("002_sleep") { + ticker.waitNext() } - Profiler.startTimeMeasurement("002_sleep") - ticker.waitNext() Display.update() fpsCalculator.tick() - Profiler.endTimeMeasurement("002_sleep") } } @@ -278,11 +352,10 @@ object UiHandler extends Logging { root.workspaceView.dispose() KeyEvents.destroy() MouseEvents.destroy() + graphics.freeResource() + SoundBuffers.freeResource() Display.destroy() Audio.destroy() - - if (!SystemUtils.IS_OS_WINDOWS) - FileUtils.deleteDirectory(new File(nativeLibrariesDir)) } private def update(): Unit = { @@ -332,11 +405,15 @@ object UiHandler extends Logging { val width = Display.getWidth val height = Display.getHeight - graphics.resize(width, height) - root.size = Size2D(width, height) + if (graphics.resize(width, height, scalingFactor)) { + // Checking for window position changes here seems to fail (on linux at least) + // It instead happens on the previous frame + + root.size = Size2D(width, height) / scalingFactor + } // Settings fields should be updated only in non-fullscreen mode - if (isFullScreen) + if (fullScreen) return Settings.get.windowSize.set(width, height) @@ -344,7 +421,7 @@ object UiHandler extends Logging { } private def draw(): Unit = { - graphics.setViewport(root.width.asInstanceOf[Int], root.height.asInstanceOf[Int]) + graphics.startViewport() graphics.clear() root.draw(graphics) diff --git a/src/main/scala/ocelot/desktop/ui/widget/Button.scala b/src/main/scala/ocelot/desktop/ui/widget/Button.scala index 350ad7e..ee5dd38 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/Button.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/Button.scala @@ -11,26 +11,40 @@ import ocelot.desktop.util.DrawUtils class Button extends Widget with ClickHandler with ClickSoundSource { def text: String = "" + def onClick(): Unit = {} + def enabled: Boolean = true + override def receiveMouseEvents: Boolean = true eventHandlers += { - case ClickEvent(MouseEvent.Button.Left, _) => + case ClickEvent(MouseEvent.Button.Left, _) if enabled => onClick() clickSoundSource.play() } override def minimumSize: Size2D = Size2D(24 + text.length * 8, 24) + override def maximumSize: Size2D = minimumSize override def draw(g: Graphics): Unit = { - g.rect(bounds, ColorScheme("ButtonBackground")) - DrawUtils.ring(g, position.x, position.y, width, height, 2, ColorScheme("ButtonBorder")) + val (background, border, foreground) = if (enabled) ( + ColorScheme("ButtonBackground"), + ColorScheme("ButtonBorder"), + ColorScheme("ButtonForeground") + ) else ( + ColorScheme("ButtonBackgroundDisabled"), + ColorScheme("ButtonBorderDisabled"), + ColorScheme("ButtonForegroundDisabled") + ) + + g.rect(bounds, background) + DrawUtils.ring(g, position.x, position.y, width, height, 2, border) g.background = Color.Transparent - g.foreground = ColorScheme("ButtonForeground") + g.foreground = foreground val textWidth = text.iterator.map(g.font.charWidth(_)).sum g.text(position.x + ((width - textWidth) / 2).round, position.y + 4, text) } diff --git a/src/main/scala/ocelot/desktop/ui/widget/ChangeSimulationSpeedDialog.scala b/src/main/scala/ocelot/desktop/ui/widget/ChangeSimulationSpeedDialog.scala index dbbe63a..d81f630 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/ChangeSimulationSpeedDialog.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/ChangeSimulationSpeedDialog.scala @@ -91,10 +91,10 @@ class ChangeSimulationSpeedDialog() extends ModalDialog { override def onClick(): Unit = close() }, Padding2D(right = 8)) - // TODO: disable the button if tickInterval.isEmpty children :+= new Button { override def text: String = "Apply" override def onClick(): Unit = confirm() + override def enabled: Boolean = tickInterval.nonEmpty } } }, Padding2D.equal(16)) diff --git a/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala b/src/main/scala/ocelot/desktop/ui/widget/CloseConfirmationDialog.scala similarity index 82% rename from src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala rename to src/main/scala/ocelot/desktop/ui/widget/CloseConfirmationDialog.scala index c53e336..f90952b 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/ExitConfirmationDialog.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/CloseConfirmationDialog.scala @@ -6,12 +6,14 @@ import ocelot.desktop.ui.layout.LinearLayout import ocelot.desktop.ui.widget.modal.ModalDialog import ocelot.desktop.util.Orientation -class ExitConfirmationDialog extends ModalDialog { +class CloseConfirmationDialog extends ModalDialog { + protected def prompt: String = "Save workspace before exiting?" + children :+= new PaddingBox(new Widget { override val layout = new LinearLayout(this, orientation = Orientation.Vertical) children :+= new PaddingBox(new Label { - override def text = "Save workspace before exiting?" + override def text: String = prompt }, Padding2D(bottom = 16)) children :+= new Widget { @@ -25,7 +27,7 @@ class ExitConfirmationDialog extends ModalDialog { children :+= new PaddingBox(new Button { override def text: String = "No" - override def onClick(): Unit = onExitSelected() + override def onClick(): Unit = onNoSaveSelected() }, Padding2D(right = 8)) children :+= new PaddingBox(new Button { @@ -37,5 +39,5 @@ class ExitConfirmationDialog extends ModalDialog { def onSaveSelected(): Unit = {} - def onExitSelected(): Unit = {} + def onNoSaveSelected(): Unit = {} } diff --git a/src/main/scala/ocelot/desktop/ui/widget/IconButton.scala b/src/main/scala/ocelot/desktop/ui/widget/IconButton.scala index 9ed531f..4122ef2 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/IconButton.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/IconButton.scala @@ -7,6 +7,7 @@ import ocelot.desktop.geometry.Size2D import ocelot.desktop.graphics.Graphics import ocelot.desktop.ui.event.handlers.{ClickHandler, HoverHandler} import ocelot.desktop.ui.event.{ClickEvent, HoverEvent, MouseEvent} +import ocelot.desktop.ui.widget.tooltip.LabelTooltip import ocelot.desktop.util.animation.{ColorAnimation, ValueAnimation} import ocelot.desktop.util.{DrawUtils, Spritesheet} diff --git a/src/main/scala/ocelot/desktop/ui/widget/Knob.scala b/src/main/scala/ocelot/desktop/ui/widget/Knob.scala index 58bbed5..1dbefc0 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/Knob.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/Knob.scala @@ -7,7 +7,7 @@ import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.MouseEvent import totoro.ocelot.brain.util.DyeColor -abstract class Knob(dyeColor: DyeColor = DyeColor.RED) extends Widget { +abstract class Knob(dyeColor: DyeColor = DyeColor.Red) extends Widget { def input: Int def input_=(v: Int): Unit diff --git a/src/main/scala/ocelot/desktop/ui/widget/LabelTooltip.scala b/src/main/scala/ocelot/desktop/ui/widget/LabelTooltip.scala deleted file mode 100644 index c77b193..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/LabelTooltip.scala +++ /dev/null @@ -1,28 +0,0 @@ -package ocelot.desktop.ui.widget - -import ocelot.desktop.color.Color -import ocelot.desktop.geometry.Padding2D -import ocelot.desktop.ui.layout.LinearLayout -import ocelot.desktop.ui.widget.tooltip.Tooltip -import ocelot.desktop.util.Orientation - -class LabelTooltip(text: String) extends Tooltip { - override val DelayTime: Float = 1.0f - - private val inner: Widget = new Widget { - override val layout = new LinearLayout(this, orientation = Orientation.Vertical) - } - - for (line <- text.split("\n")) { - inner.children :+= new Label { - override def text: String = line - override def color: Color = Color.White - } - } - - children :+= new PaddingBox(inner, Padding2D.equal(4)) - - def onSaveSelected(): Unit = {} - - def onExitSelected(): Unit = {} -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala b/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala index 6ce0774..02f965b 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/MenuBar.scala @@ -3,8 +3,10 @@ package ocelot.desktop.ui.widget import ocelot.desktop.audio.SoundSources import ocelot.desktop.geometry.{Padding2D, Size2D} import ocelot.desktop.graphics.Graphics +import ocelot.desktop.ui.event.KeyEvent import ocelot.desktop.ui.widget.contextmenu.{ContextMenuEntry, ContextMenuSubmenu} import ocelot.desktop.{ColorScheme, OcelotDesktop} +import org.lwjgl.input.Keyboard class MenuBar extends Widget { override def receiveMouseEvents: Boolean = true @@ -16,37 +18,40 @@ class MenuBar extends Widget { private def addEntry(w: Widget): Unit = entries.children :+= w addEntry(new MenuBarSubmenu("File", menu => { - menu.addEntry(new ContextMenuEntry("New", () => OcelotDesktop.newWorkspace())) - menu.addEntry(new ContextMenuEntry("Open", () => OcelotDesktop.open())) - menu.addEntry(new ContextMenuEntry("Save", () => OcelotDesktop.save())) - menu.addEntry(new ContextMenuEntry("Save as…", () => OcelotDesktop.saveAs())) + menu.addEntry(ContextMenuEntry("New") { OcelotDesktop.newWorkspace() }) + menu.addEntry(ContextMenuEntry("Open") { OcelotDesktop.showOpenDialog() }) + menu.addEntry(ContextMenuEntry("Save") { OcelotDesktop.save() }) + menu.addEntry(ContextMenuEntry("Save as…") { OcelotDesktop.saveAs() }) menu.addSeparator() - menu.addEntry(new ContextMenuEntry("Exit", () => OcelotDesktop.exit())) + menu.addEntry(ContextMenuEntry("Exit") { OcelotDesktop.exit() }) })) addEntry(new MenuBarSubmenu("Player", menu => { - menu.addEntry(new ContextMenuEntry("Add...", () => OcelotDesktop.addPlayerDialog())) + menu.addEntry(ContextMenuEntry("Add...") { OcelotDesktop.showAddPlayerDialog() }) menu.addSeparator() OcelotDesktop.players.foreach(player => { menu.addEntry(new ContextMenuSubmenu( (if (player == OcelotDesktop.players.head) "● " else " ") + player.nickname, () => OcelotDesktop.selectPlayer(player.nickname) ) { - addEntry(new ContextMenuEntry( - "Remove", - () => OcelotDesktop.removePlayer(player.nickname), - sound = SoundSources.InterfaceClickLow - )) + addEntry(ContextMenuEntry("Remove", sound = SoundSources.InterfaceClickLow) { + OcelotDesktop.removePlayer(player.nickname) + }) }) }) })) - addEntry(new MenuBarButton("Settings", () => OcelotDesktop.settings())) + addEntry(new MenuBarButton("Settings", () => OcelotDesktop.showSettings())) addEntry(new Widget { override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 1) }) // fill remaining space + eventHandlers += { + case KeyEvent(KeyEvent.State.Press, Keyboard.KEY_F5, _) => OcelotDesktop.save() + case KeyEvent(KeyEvent.State.Press, Keyboard.KEY_F9, _) => OcelotDesktop.showOpenDialog() + } + override def draw(g: Graphics): Unit = { g.rect(bounds, ColorScheme("TitleBarBackground")) drawChildren(g) diff --git a/src/main/scala/ocelot/desktop/ui/widget/Oscilloscope.scala b/src/main/scala/ocelot/desktop/ui/widget/Oscilloscope.scala index 92b8781..49b219c 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/Oscilloscope.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/Oscilloscope.scala @@ -7,6 +7,7 @@ import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, Cont import org.jtransforms.fft.FloatFFT_1D import totoro.ocelot.brain.Settings +import scala.collection.immutable.ArraySeq import scala.collection.mutable class Oscilloscope(isTiny: Boolean = false) extends Widget with ClickHandler { @@ -101,7 +102,7 @@ class Oscilloscope(isTiny: Boolean = false) extends Widget with ClickHandler { private def updateMode(f: Int => Int): Unit = { modeIdx = f(modeIdx) - children = Array(modes(modeIdx)()) + children = ArraySeq(modes(modeIdx)()) } override def minimumSize: Size2D = if (isTiny) Size2D(100, 68) else Size2D(500, 120) @@ -110,7 +111,7 @@ class Oscilloscope(isTiny: Boolean = false) extends Widget with ClickHandler { menu.addEntry(new ContextMenuSubmenu("FFT Size") { for (v <- List(512, 1024, 2048, 4096, 8192, 16384, 32768)) { val dot = if (v == fftSize) '•' else ' ' - addEntry(new ContextMenuEntry(s"$v".padTo(14, ' ') + dot, () => setFFTSize(v))) + addEntry(ContextMenuEntry(s"$v".padTo(14, ' ') + dot) { setFFTSize(v) }) } }) } @@ -120,7 +121,7 @@ class Oscilloscope(isTiny: Boolean = false) extends Widget with ClickHandler { for (v <- List(None, Some(0.1f), Some(0.3f), Some(0.5f), Some(0.7f), Some(0.9f))) { val dot = if (v == smoothing) '•' else ' ' val msg = if (v.isEmpty) "None" else s"${v.get}" - addEntry(new ContextMenuEntry(msg.padTo(14, ' ') + dot, () => smoothing = v)) + addEntry(ContextMenuEntry(msg.padTo(14, ' ') + dot) { smoothing = v }) } }) } diff --git a/src/main/scala/ocelot/desktop/ui/widget/PaddingBox.scala b/src/main/scala/ocelot/desktop/ui/widget/PaddingBox.scala index 4367a5e..ec8f633 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/PaddingBox.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/PaddingBox.scala @@ -3,8 +3,10 @@ package ocelot.desktop.ui.widget import ocelot.desktop.geometry.Padding2D import ocelot.desktop.ui.layout.PaddingLayout +import scala.collection.immutable.ArraySeq + class PaddingBox(inner: Widget, padding: Padding2D) extends Widget { override protected val layout = new PaddingLayout(this, padding) - children = Array(inner) + children = ArraySeq(inner) } diff --git a/src/main/scala/ocelot/desktop/ui/widget/RootWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/RootWidget.scala index f02bb07..1d6501c 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/RootWidget.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/RootWidget.scala @@ -60,7 +60,7 @@ class RootWidget(setupDefaultWorkspace: Boolean = true) extends Widget { } case KeyEvent(KeyEvent.State.Release, Keyboard.KEY_F11, _) => - UiHandler.isFullScreen = !UiHandler.isFullScreen + UiHandler.fullScreen = !UiHandler.fullScreen } override def draw(g: Graphics): Unit = { diff --git a/src/main/scala/ocelot/desktop/ui/widget/Slider.scala b/src/main/scala/ocelot/desktop/ui/widget/Slider.scala index 035fd57..a0da399 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/Slider.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/Slider.scala @@ -10,8 +10,9 @@ import ocelot.desktop.ui.event.{ClickEvent, DragEvent, MouseEvent} import ocelot.desktop.util.DrawUtils import ocelot.desktop.util.MathUtils.ExtendedFloat -class Slider(var value: Float, text: String) extends Widget with ClickHandler with DragHandler with ClickSoundSource { +class Slider(var value: Float, val text: String, val snapPoints: Int = 0) extends Widget with ClickHandler with DragHandler with ClickSoundSource { def onValueChanged(value: Float): Unit = {} + def onValueFinal(value: Float): Unit = {} override def receiveMouseEvents: Boolean = true @@ -24,10 +25,26 @@ class Slider(var value: Float, text: String) extends Widget with ClickHandler wi onValueChanged(value) } + eventHandlers += { case ClickEvent(MouseEvent.Button.Left, pos) => calculateValue(pos.x) clickSoundSource.play() + if (snapPoints > 1) { + value = (value * (snapPoints - 1)).round / (snapPoints - 1).toFloat + } + onValueFinal(value) + + case DragEvent(DragEvent.State.Stop, MouseEvent.Button.Left, pos) => + calculateValue(pos.x) + if (lastSoundX == 0 || (pos.x - lastSoundX).abs > soundInterval) { + lastSoundX = pos.x + clickSoundSource.play() + } + if (snapPoints > 1) { + value = (value * (snapPoints - 1)).round / (snapPoints - 1).toFloat + } + onValueFinal(value) case DragEvent(_, MouseEvent.Button.Left, pos) => calculateValue(pos.x) @@ -40,18 +57,23 @@ class Slider(var value: Float, text: String) extends Widget with ClickHandler wi override def minimumSize: Size2D = Size2D(24 + text.length * 8, 24) override def maximumSize: Size2D = minimumSize.copy(width = Float.PositiveInfinity) + def formatText: String = f"$text: ${value * 100}%.0f%%" + override def draw(g: Graphics): Unit = { g.rect(bounds, ColorScheme("SliderBackground")) DrawUtils.ring(g, position.x, position.y, width, height, 2, ColorScheme("SliderBorder")) + for (i <- 1 until snapPoints - 1) { + g.rect(position.x + (i / (snapPoints.toFloat - 1)) * (bounds.w - handleWidth / 2), position.y + 6, handleWidth / 2, height - 12, ColorScheme("SliderTick")) + } + g.rect(position.x + value * (bounds.w - handleWidth), position.y, handleWidth, height, ColorScheme("SliderHandler")) DrawUtils.ring(g, position.x + value * (bounds.w - handleWidth), position.y, handleWidth, height, 2, ColorScheme("SliderBorder")) g.background = Color.Transparent g.foreground = ColorScheme("SliderForeground") - val fullText = f"$text: ${value * 100}%.0f%%" - val textWidth = fullText.iterator.map(g.font.charWidth(_)).sum - g.text(position.x + ((width - textWidth) / 2).round, position.y + 4, fullText) + val textWidth = formatText.iterator.map(g.font.charWidth(_)).sum + g.text(position.x + ((width - textWidth) / 2).round, position.y + 4, formatText) } override protected def clickSoundSource: SoundSource = SoundSources.InterfaceTick diff --git a/src/main/scala/ocelot/desktop/ui/widget/Widget.scala b/src/main/scala/ocelot/desktop/ui/widget/Widget.scala index b714afb..2907e88 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/Widget.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/Widget.scala @@ -6,10 +6,12 @@ import ocelot.desktop.ui.UiHandler import ocelot.desktop.ui.event.Event import ocelot.desktop.ui.layout.{Layout, LinearLayout} +import scala.collection.immutable.ArraySeq + class Widget { protected val layout: Layout = new LinearLayout(this) - protected var _children: Array[Widget] = Array[Widget]() + protected var _children: ArraySeq[Widget] = ArraySeq.empty protected var _parent: Option[Widget] = None protected var _root: Option[RootWidget] = None @@ -33,10 +35,10 @@ class Widget { UiHandler.updateHierarchy() } - final def children: Array[Widget] = _children + final def children: ArraySeq[Widget] = _children - final def children_=(value: Array[Widget]): Unit = { - if (_children sameElements value) return + final def children_=(value: ArraySeq[Widget]): Unit = { + if (_children == value) return _children = value for (child <- _children) { @@ -114,7 +116,7 @@ class Widget { Rect2D(position, size).intersect(parentBounds) } - def hierarchy: Array[Widget] = children + def hierarchy: ArraySeq[Widget] = children def shouldClip: Boolean = false diff --git a/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala b/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala index 4798f33..8f07609 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/WorkspaceView.scala @@ -15,11 +15,12 @@ import ocelot.desktop.{ColorScheme, OcelotDesktop, Settings} import org.lwjgl.input.Keyboard import totoro.ocelot.brain.entity.traits.{Entity, Environment, SidedEnvironment} import totoro.ocelot.brain.entity.{Case, Screen} -import totoro.ocelot.brain.event.{EventBus, NodeEvent} +import totoro.ocelot.brain.event.{EventBus, InventoryEntityAddedEvent, InventoryEvent, NodeEvent} import totoro.ocelot.brain.nbt.ExtendedNBT._ import totoro.ocelot.brain.nbt.{NBT, NBTBase, NBTTagCompound} import totoro.ocelot.brain.util.{Direction, Tier} +import scala.collection.immutable.ArraySeq import scala.collection.{immutable, mutable} import scala.jdk.CollectionConverters._ @@ -40,13 +41,17 @@ class WorkspaceView extends Widget with DragHandler with ClickHandler with Hover override protected val layout: Layout = new CopyLayout(this) children +:= windowPool children +:= new NoLayoutBox { - override def hierarchy: Array[Widget] = nodes.toArray + override def hierarchy: ArraySeq[Widget] = ArraySeq.from(nodes) } - private val eventSubscription = EventBus.subscribe { case event: NodeEvent => - nodes - .filter(_.shouldReceiveEventsFor(event.address)) - .foreach(_.handleEvent(BrainEvent(event))) + private val eventSubscription = EventBus.subscribe { + case event: NodeEvent => + nodes + .filter(_.shouldReceiveEventsFor(event.address)) + .foreach(_.handleEvent(BrainEvent(event))) + + case event: InventoryEvent => + nodes.foreach(_.handleEvent(BrainEvent(event))) } def reset(): Unit = { @@ -73,7 +78,9 @@ class WorkspaceView extends Widget with DragHandler with ClickHandler with Hover val address = nbt.getString("address") var entity = OcelotDesktop.workspace.entityByAddress(address).orNull + if (entity == null) { + // FIXME: 1. won't work for many components 2. should not be done this way at all 3. needs logging val entityClass = Class.forName(nbt.getString("entityClass")) entity = entityClass.getConstructor().newInstance().asInstanceOf[Entity] } @@ -177,7 +184,7 @@ class WorkspaceView extends Widget with DragHandler with ClickHandler with Hover def createDefaultWorkspace(): Unit = { addNode(new ComputerNode(new Case(Tier.Three)).setup(), Vector2D(68, 68)) addNode(new ScreenNode(new Screen(Tier.Two)).setup(), Vector2D(204, 136)) - nodes(0).connect(NodePort(), nodes(1), NodePort()) + nodes.head.connect(NodePort(), nodes(1), NodePort()) } override def receiveMouseEvents = true @@ -449,11 +456,11 @@ class WorkspaceView extends Widget with DragHandler with ClickHandler with Hover val numRepeatsX = math.ceil((size.width + backgroundOffsetX) / 304f).asInstanceOf[Int] val numRepeatsY = math.ceil((size.height + backgroundOffsetY) / 304f).asInstanceOf[Int] - g.translate(-backgroundOffsetX, -backgroundOffsetY) + g.translate(-backgroundOffsetX.toFloat, -backgroundOffsetY.toFloat) for (x <- 0 to numRepeatsX) { for (y <- 0 to numRepeatsY) { - g.sprite("BackgroundPattern", x * 304, y * 304, 304, 304) + g.sprite("BackgroundPattern", x.toFloat * 304, y.toFloat * 304, 304, 304) } } diff --git a/src/main/scala/ocelot/desktop/ui/widget/card/Redstone1Window.scala b/src/main/scala/ocelot/desktop/ui/widget/card/Redstone1Window.scala index c1c7fd2..c426730 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/card/Redstone1Window.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/card/Redstone1Window.scala @@ -7,22 +7,22 @@ import ocelot.desktop.ui.widget.window.BasicWindow import ocelot.desktop.ui.widget.{Knob, Label, PaddingBox, Widget} import ocelot.desktop.util.{DrawUtils, Orientation} import totoro.ocelot.brain.entity.Redstone -import totoro.ocelot.brain.util.DyeColor +import totoro.ocelot.brain.util.{Direction, DyeColor} class Redstone1Window(card: Redstone.Tier1) extends BasicWindow { - private def redstoneKnob(side: Int) = new Knob(DyeColor.RED) { + private def redstoneKnob(side: Direction.Value) = new Knob(DyeColor.Red) { override def input: Int = { - card.redstoneInput(side) min 15 max 0 + card.redstoneInput(side.id) min 15 max 0 } override def input_=(v: Int): Unit = { - card.redstoneInput(side) = v + card.setRedstoneInput(side, v) } - override def output: Int = card.redstoneOutput(side) min 15 max 0 + override def output: Int = card.redstoneOutput(side.id) min 15 max 0 } - private def redstoneBlock(side: Int, name: String) = new PaddingBox(new Widget { + private def redstoneBlock(side: Direction.Value, name: String) = new PaddingBox(new Widget { override protected val layout: Layout = new LinearLayout(this, Orientation.Horizontal, contentAlignment = Alignment.Center) children :+= new PaddingBox(new Label { @@ -44,18 +44,18 @@ class Redstone1Window(card: Redstone.Tier1) extends BasicWindow { children :+= new Widget { children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= redstoneBlock(0, "Bottom") - children :+= redstoneBlock(1, " Top") + children :+= redstoneBlock(Direction.Down, "Bottom") + children :+= redstoneBlock(Direction.Up, " Top") } children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= redstoneBlock(2, " Back") - children :+= redstoneBlock(3, "Front") + children :+= redstoneBlock(Direction.Back, " Back") + children :+= redstoneBlock(Direction.Front, "Front") } children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= redstoneBlock(4, "Right") - children :+= redstoneBlock(5, " Left") + children :+= redstoneBlock(Direction.Right, "Right") + children :+= redstoneBlock(Direction.Left, " Left") } } }, Padding2D.equal(8)) diff --git a/src/main/scala/ocelot/desktop/ui/widget/card/Redstone2Window.scala b/src/main/scala/ocelot/desktop/ui/widget/card/Redstone2Window.scala index 694d714..6fd7bab 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/card/Redstone2Window.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/card/Redstone2Window.scala @@ -7,22 +7,22 @@ import ocelot.desktop.ui.widget.window.BasicWindow import ocelot.desktop.ui.widget.{Knob, Label, PaddingBox, Widget} import ocelot.desktop.util.{DrawUtils, Orientation} import totoro.ocelot.brain.entity.Redstone -import totoro.ocelot.brain.util.DyeColor +import totoro.ocelot.brain.util.{Direction, DyeColor} class Redstone2Window(card: Redstone.Tier2) extends BasicWindow { - private def bundledKnob(side: Int, col: Int) = new Knob(DyeColor.byCode(col)) { + private def bundledKnob(side: Direction.Value, col: Int) = new Knob(DyeColor.byCode(col)) { override def input: Int = { - card.bundledRedstoneInput(side)(col) min 15 max 0 + card.bundledRedstoneInput(side.id)(col) min 15 max 0 } override def input_=(v: Int): Unit = { - card.bundledRedstoneInput(side)(col) = v + card.setBundledInput(side, col, v) } - override def output: Int = card.bundledRedstoneOutput(side)(col) min 15 max 0 + override def output: Int = card.bundledRedstoneOutput(side.id)(col) min 15 max 0 } - private def bundledBlock(side: Int, name: String) = new PaddingBox(new Widget { + private def bundledBlock(side: Direction.Value, name: String) = new PaddingBox(new Widget { override protected val layout: Layout = new LinearLayout(this) { orientation = Orientation.Vertical contentAlignment = Alignment.Center @@ -69,18 +69,18 @@ class Redstone2Window(card: Redstone.Tier2) extends BasicWindow { children :+= new Widget { children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= bundledBlock(0, "Bottom") - children :+= bundledBlock(1, "Top") + children :+= bundledBlock(Direction.Down, "Bottom") + children :+= bundledBlock(Direction.Up, "Top") } children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= bundledBlock(2, "Back") - children :+= bundledBlock(3, "Front") + children :+= bundledBlock(Direction.Back, "Back") + children :+= bundledBlock(Direction.Front, "Front") } children :+= new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - children :+= bundledBlock(4, "Right") - children :+= bundledBlock(5, "Left") + children :+= bundledBlock(Direction.Right, "Right") + children :+= bundledBlock(Direction.Left, "Left") } } }, Padding2D.equal(8)) diff --git a/src/main/scala/ocelot/desktop/ui/widget/component/ComponentSelectors.scala b/src/main/scala/ocelot/desktop/ui/widget/component/ComponentSelectors.scala index cf2b6ca..189030a 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/component/ComponentSelectors.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/component/ComponentSelectors.scala @@ -7,10 +7,12 @@ import ocelot.desktop.ui.layout.Layout import ocelot.desktop.ui.widget.Widget import org.lwjgl.input.Keyboard +import scala.collection.immutable.ArraySeq + class ComponentSelectors extends Widget { override protected val layout: Layout = new Layout(this) - private def selectors: Array[ComponentSelector] = children.map(_.asInstanceOf[ComponentSelector]) + private def selectors: ArraySeq[ComponentSelector] = children.map(_.asInstanceOf[ComponentSelector]) override def receiveAllMouseEvents: Boolean = true diff --git a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenu.scala b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenu.scala index 5876f8f..3dd2efb 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenu.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenu.scala @@ -9,6 +9,8 @@ import ocelot.desktop.util.animation.ValueAnimation import ocelot.desktop.util.animation.easing.EaseInOutQuad import ocelot.desktop.util.{DrawUtils, Orientation} +import scala.collection.immutable.ArraySeq + class ContextMenu extends Widget { private[contextmenu] var isClosing = false private[contextmenu] var isOpening = false @@ -38,7 +40,7 @@ class ContextMenu extends Widget { inner.children :+= new Separator } - private[contextmenu] def entries: Array[ContextMenuEntry] = inner.children + private[contextmenu] def entries: ArraySeq[ContextMenuEntry] = inner.children .filter(_.isInstanceOf[ContextMenuEntry]) .map(_.asInstanceOf[ContextMenuEntry]) diff --git a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenuEntry.scala b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenuEntry.scala index af59adf..8a5c51a 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenuEntry.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenuEntry.scala @@ -12,13 +12,17 @@ import ocelot.desktop.ui.widget._ import ocelot.desktop.util.animation.ValueAnimation import ocelot.desktop.util.animation.easing.{EaseInQuad, EaseOutQuad} -class ContextMenuEntry(label: String, - onClick: () => Unit = () => {}, - icon: Option[IconDef] = None, - sound: SoundSource = SoundSources.InterfaceClick, - soundDisabled: SoundSource = SoundSources.InterfaceClickLow) - extends Widget with ClickHandler with HoverHandler with ClickSoundSource -{ +class ContextMenuEntry( + label: String, + onClick: () => Unit = () => {}, + icon: Option[IconDef] = None, + sound: SoundSource = SoundSources.InterfaceClick, + soundDisabled: SoundSource = SoundSources.InterfaceClickLow +) extends Widget + with ClickHandler + with HoverHandler + with ClickSoundSource { + private[contextmenu] val alpha = new ValueAnimation(0f, 10f) private[contextmenu] val textAlpha = new ValueAnimation(0f, 5f) private[contextmenu] val trans = new ValueAnimation(0f, 20f) @@ -32,22 +36,27 @@ class ContextMenuEntry(label: String, case _ => 12f } - children :+= new PaddingBox(new Widget { - override val layout: Layout = new LinearLayout(this) { - contentAlignment = Alignment.Center - } + children :+= new PaddingBox( + new Widget { + override val layout: Layout = new LinearLayout(this) { + contentAlignment = Alignment.Center + } - icon match { - case Some(icon) => - children :+= new PaddingBox(new Icon(icon), Padding2D(left = 8f, right = 6f)) - case _ => - } + icon match { + case Some(icon) => + children :+= new PaddingBox(new Icon(icon), Padding2D(left = 8f, right = 6f)) + case _ => + } - children :+= new PaddingBox(new Label { - override def text: String = label - override def color: Color = ColorScheme("ContextMenuText") - }, Padding2D(top = 3f, bottom = 3f)) - }, Padding2D(left = padLeft, right = 16f, top = 2f, bottom = 2f)) + children :+= new PaddingBox( + new Label { + override def text: String = label + + override def color: Color = ColorScheme("ContextMenuText") + }, Padding2D(top = 3f, bottom = 3f) + ) + }, Padding2D(left = padLeft, right = 16f, top = 2f, bottom = 2f) + ) override def receiveMouseEvents: Boolean = !isGhost @@ -113,3 +122,19 @@ class ContextMenuEntry(label: String, override protected def clickSoundSource: SoundSource = if (isEnabled) sound else soundDisabled } + +object ContextMenuEntry { + def apply( + label: String, + icon: Option[IconDef] = None, + sound: SoundSource = SoundSources.InterfaceClick, + soundDisabled: SoundSource = SoundSources.InterfaceClickLow + )(onClick: => Unit): ContextMenuEntry = + new ContextMenuEntry( + label, + onClick = onClick _, + icon = icon, + sound = sound, + soundDisabled = soundDisabled + ) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenus.scala b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenus.scala index 3c345ae..214723d 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenus.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/contextmenu/ContextMenus.scala @@ -8,12 +8,14 @@ import ocelot.desktop.ui.layout.Layout import ocelot.desktop.ui.widget.Widget import org.lwjgl.input.Keyboard +import scala.collection.immutable.ArraySeq + class ContextMenus extends Widget { override protected val layout: Layout = new Layout(this) private var ghost: Option[ContextMenuEntry] = None - private def menus: Array[ContextMenu] = children.map(_.asInstanceOf[ContextMenu]) + private def menus: ArraySeq[ContextMenu] = children.map(_.asInstanceOf[ContextMenu]) private[contextmenu] def setGhost(g: ContextMenuEntry): Unit = { ghost = Some(g) diff --git a/src/main/scala/ocelot/desktop/ui/widget/modal/ModalDialogPool.scala b/src/main/scala/ocelot/desktop/ui/widget/modal/ModalDialogPool.scala index 562c46b..6dde9c4 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/modal/ModalDialogPool.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/modal/ModalDialogPool.scala @@ -9,6 +9,8 @@ import ocelot.desktop.ui.layout.Layout import ocelot.desktop.ui.widget.Widget import ocelot.desktop.util.animation.UnitAnimation +import scala.collection.immutable.ArraySeq + class ModalDialogPool extends Widget with ClickHandler { override protected val layout: Layout = new Layout(this) { override def relayout(): Unit = { @@ -22,7 +24,7 @@ class ModalDialogPool extends Widget with ClickHandler { private val curtainsAlphaAnimation = UnitAnimation.easeInOutQuad(0.13f) - private def dialogs: Array[ModalDialog] = children.map(_.asInstanceOf[ModalDialog]) + private def dialogs: ArraySeq[ModalDialog] = children.map(_.asInstanceOf[ModalDialog]) def isVisible: Boolean = dialogs.nonEmpty @@ -60,7 +62,7 @@ class ModalDialogPool extends Widget with ClickHandler { else curtainsAlphaAnimation.goUp() if (dialogs.head.isClosed) - children = Array() + children = ArraySeq.empty } else if (dialogs.head.isClosed) { children = children.take(children.length - 1) } diff --git a/src/main/scala/ocelot/desktop/ui/widget/modal/notification/NotificationDialog.scala b/src/main/scala/ocelot/desktop/ui/widget/modal/notification/NotificationDialog.scala index 6f7db03..cb79f19 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/modal/notification/NotificationDialog.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/modal/notification/NotificationDialog.scala @@ -3,7 +3,7 @@ package ocelot.desktop.ui.widget.modal.notification import ocelot.desktop.ColorScheme import ocelot.desktop.color.Color import ocelot.desktop.geometry.Padding2D -import ocelot.desktop.graphics.{Graphics, IconDef} +import ocelot.desktop.graphics.{Graphics, IconDef, Icons} import ocelot.desktop.ui.layout.LinearLayout import ocelot.desktop.ui.widget._ import ocelot.desktop.ui.widget.modal.ModalDialog @@ -32,7 +32,7 @@ class NotificationDialog(message: String, notificationType: NotificationType = N // Icon children :+= new PaddingBox( - new Icon(new IconDef(s"icons/Notification${notificationType.toString}", 4)), + new Icon(Icons.NotificationIcon(notificationType)), Padding2D.equal(10) ) diff --git a/src/main/scala/ocelot/desktop/ui/widget/settings/SettingsTab.scala b/src/main/scala/ocelot/desktop/ui/widget/settings/SettingsTab.scala index 8c36f98..a077ba7 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/settings/SettingsTab.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/settings/SettingsTab.scala @@ -1,5 +1,6 @@ package ocelot.desktop.ui.widget.settings +import ocelot.desktop.graphics.IconDef import ocelot.desktop.ui.layout.LinearLayout import ocelot.desktop.ui.widget.Widget import ocelot.desktop.util.Orientation @@ -7,7 +8,7 @@ import ocelot.desktop.util.Orientation trait SettingsTab extends Widget { override protected val layout = new LinearLayout(this, orientation = Orientation.Vertical) - val icon: String + val icon: IconDef val label: String /** diff --git a/src/main/scala/ocelot/desktop/ui/widget/settings/SoundSettingsTab.scala b/src/main/scala/ocelot/desktop/ui/widget/settings/SoundSettingsTab.scala index f932a09..203c35b 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/settings/SoundSettingsTab.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/settings/SoundSettingsTab.scala @@ -2,10 +2,11 @@ package ocelot.desktop.ui.widget.settings import ocelot.desktop.Settings import ocelot.desktop.geometry.{Padding2D, Size2D} +import ocelot.desktop.graphics.{IconDef, Icons} import ocelot.desktop.ui.widget.{PaddingBox, Slider} class SoundSettingsTab extends SettingsTab { - override val icon: String = "icons/SettingsSound" + override val icon: IconDef = Icons.SettingsSound override val label: String = "Sound" children :+= new PaddingBox(new Slider(Settings.get.volumeMaster, "Master Volume") { diff --git a/src/main/scala/ocelot/desktop/ui/widget/settings/UISettingsTab.scala b/src/main/scala/ocelot/desktop/ui/widget/settings/UISettingsTab.scala index ce84078..43727ec 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/settings/UISettingsTab.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/settings/UISettingsTab.scala @@ -2,16 +2,22 @@ package ocelot.desktop.ui.widget.settings import ocelot.desktop.Settings import ocelot.desktop.geometry.{Padding2D, Size2D} +import ocelot.desktop.graphics.{IconDef, Icons} import ocelot.desktop.ui.UiHandler -import ocelot.desktop.ui.widget.{Checkbox, PaddingBox} +import ocelot.desktop.ui.widget.{Checkbox, PaddingBox, Slider} class UISettingsTab extends SettingsTab { - override val icon: String = "icons/SettingsUI" + override val icon: IconDef = Icons.SettingsUI override val label: String = "UI" override def applySettings(): Unit = { - if (UiHandler.isFullScreen != Settings.get.windowFullscreen) - UiHandler.isFullScreen = Settings.get.windowFullscreen + if (UiHandler.fullScreen != Settings.get.windowFullscreen) { + UiHandler.fullScreen = Settings.get.windowFullscreen + } + + if (UiHandler.scalingFactor != Settings.get.scaleFactor) { + UiHandler.scalingFactor = Settings.get.scaleFactor + } } children :+= new PaddingBox(new Checkbox("Move workspace windows together with workspace", @@ -50,4 +56,18 @@ class UISettingsTab extends SettingsTab { Settings.get.openLastWorkspace = value } }, Padding2D(bottom = 8)) + + children :+= new PaddingBox(new Slider((Settings.get.scaleFactor - 1) / 2, "Interface scale", 5) { + override def minimumSize: Size2D = Size2D(512, 24) + + // Interpolates [0; 1] as [1; 3] rounded to nearest 0.5 + private def convertToScale(slider: Float): Float = (slider * 4 + 2).round.max(2).min(6) / 2.0f + + override def formatText: String = f"$text: ${convertToScale(value)}%.1fx" + + override def onValueFinal(value: Float): Unit = { + Settings.get.scaleFactor = convertToScale(value) + applySettings() + } + }, Padding2D(bottom = 8)) } diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/CPUSlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/CPUSlot.scala deleted file mode 100644 index e7bb27c..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/CPUSlot.scala +++ /dev/null @@ -1,88 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.node.nodes.ComputerNode -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} -import totoro.ocelot.brain.entity.machine.MachineAPI -import totoro.ocelot.brain.entity.traits.{Entity, GenericCPU, Inventory} -import totoro.ocelot.brain.entity.{APU, CPU} -import totoro.ocelot.brain.util.Tier - -class CPUSlot(owner: Inventory#Slot, node: ComputerNode, val tier: Int) extends InventorySlot[GenericCPU with Entity](owner) { - private val APUAnimation = Some(Array( - (0, 3f), (1, 3f), (2, 3f), (3, 3f), (4, 3f), (5, 3f), - (4, 3f), (3, 3f), (2, 3f), (1, 3f), (0, 3f))) - - override def itemIcon: Option[IconDef] = item match { - case Some(cpu: CPU) => Some(new IconDef("items/CPU" + cpu.tier)) - case Some(apu: APU) => Some(new IconDef("items/APU" + apu.tier, animation = APUAnimation)) - case _ => None - } - - override def icon: IconDef = new IconDef("icons/CPU") - override def tierIcon: Option[IconDef] = Some(new IconDef("icons/Tier" + tier)) - - override def fillLmbMenu(menu: ContextMenu): Unit = { - menu.addEntry(new ContextMenuEntry("CPU (Tier 1)", - () => item = new CPU(Tier.One), - icon = Some(new IconDef("items/CPU0")))) - - if (tier >= Tier.Two) { - menu.addEntry(new ContextMenuEntry("CPU (Tier 2)", - () => item = new CPU(Tier.Two), - icon = Some(new IconDef("items/CPU1")))) - } - - if (tier >= Tier.Three) { - menu.addEntry(new ContextMenuEntry("CPU (Tier 3)", - () => item = new CPU(Tier.Three), - icon = Some(new IconDef("items/CPU2")))) - } - - if (tier < Tier.Two) return - - menu.addEntry(new ContextMenuSubmenu("APU (CPU + GPU)", icon = Some(new IconDef("items/GraphicsCard1"))) { - addEntry(new ContextMenuEntry("Tier 2", - () => item = new APU(Tier.One), - icon = Some(new IconDef("items/APU0", animation = APUAnimation)))) - - if (tier >= Tier.Three) { - addEntry(new ContextMenuEntry("Tier 3", - () => item = new APU(Tier.Two), - icon = Some(new IconDef("items/APU1", animation = APUAnimation)))) - - addEntry(new ContextMenuEntry("Creative", - () => item = new APU(Tier.Three), - icon = Some(new IconDef("items/APU2", animation = APUAnimation)))) - } - }) - } - - override def fillRmbMenu(menu: ContextMenu): Unit = { - if (item.isEmpty) return - - val cpu = item.get - - menu.addEntry(new ContextMenuSubmenu("Set architecture") { - for (arch <- cpu.allArchitectures) { - val name = MachineAPI.getArchitectureName(arch) + - (if (arch == cpu.architecture) " (current)" else "") - - addEntry(new ContextMenuEntry(name, () => { - val machine = node.computer.machine - if (machine.isRunning) { - machine.stop() - cpu.setArchitecture(arch) - machine.start() - } else { - cpu.setArchitecture(arch) - } - })) - } - }) - - super.fillRmbMenu(menu) - } - - override def lmbMenuEnabled: Boolean = true -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/CardRegistry.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/CardRegistry.scala deleted file mode 100644 index 544b3d7..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/CardRegistry.scala +++ /dev/null @@ -1,81 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.graphics.IconDef -import totoro.ocelot.brain.entity.sound_card.SoundCard -import totoro.ocelot.brain.entity.traits.{Entity, Tiered} -import totoro.ocelot.brain.entity.{DataCard, GraphicsCard, InternetCard, LinkedCard, NetworkCard, Redstone, WirelessNetworkCard} - -import scala.collection.mutable -import scala.reflect.{ClassTag, classTag} - -object CardRegistry { - case class Entry(name: String, tier: Int, icon: IconDef, factory: () => Entity) - - val iconByClass: mutable.HashMap[String, IconDef] = mutable.HashMap() - val tierByClass: mutable.HashMap[String, Int] = mutable.HashMap() - val iconByClassAndTier: mutable.HashMap[(String, Int), IconDef] = mutable.HashMap() - val entries: mutable.ArrayBuffer[Entry] = mutable.ArrayBuffer() - - def addEntry[T <: Entity : ClassTag](name: String, tier: Int, icon: IconDef, factory: () => T): Unit = { - val entry = Entry(name, tier, icon, factory) - entries += entry - val clazz = classTag[T].runtimeClass.getName - iconByClass.addOne((clazz, icon)) - tierByClass.addOne((clazz, tier)) - iconByClassAndTier.addOne(((clazz, entry.tier), icon)) - } - - def getIcon(entity: Entity): Option[IconDef] = { - val clazz = entity.getClass.getName - entity match { - case t: Tiered => iconByClassAndTier.get((clazz, t.tier)) - case _ => iconByClass.get(clazz) - } - } - - def getTier(entity: Entity): Int = { - val clazz = entity.getClass.getName - entity match { - case t: Tiered => t.tier - case _ => tierByClass.getOrElse(clazz, 0) - } - } - - addEntry("Graphics Card", 0, new IconDef("items/GraphicsCard0"), () => new GraphicsCard(0)) - addEntry("Graphics Card", 1, new IconDef("items/GraphicsCard1"), () => new GraphicsCard(1)) - addEntry("Graphics Card", 2, new IconDef("items/GraphicsCard2"), () => new GraphicsCard(2)) - - addEntry("Network Card", 0, new IconDef("items/NetworkCard"), - () => new NetworkCard) - - addEntry("Wireless Net. Card", 0, new IconDef("items/WirelessNetworkCard0"), - () => new WirelessNetworkCard.Tier1) - addEntry("Wireless Net. Card", 1, new IconDef("items/WirelessNetworkCard1"), - () => new WirelessNetworkCard.Tier2) - - private val LinkedCardAnimation = Some(Array((0, 3f), (1, 3f), (2, 3f), (3, 3f), (4, 3f), (5, 3f))) - - addEntry("Linked Card", 2, new IconDef("items/LinkedCard", animation = LinkedCardAnimation), - () => new LinkedCard) - - private val InternetCardAnimation = Some(Array((0, 2f), (1, 7f), (0, 5f), (1, 4f), (0, 7f), (1, 2f), (0, 8f), (1, 9f), (0, 6f), (1, 4f))) - - addEntry("Internet Card", 1, new IconDef("items/InternetCard", animation = InternetCardAnimation), - () => new InternetCard) - - addEntry("Redstone Card", 0, new IconDef("items/RedstoneCard0"), () => new Redstone.Tier1) - addEntry("Redstone Card", 1, new IconDef("items/RedstoneCard1"), () => new Redstone.Tier2) - - private val DataCardAnimation = Some(Array((0, 4f), (1, 4f), (2, 4f), (3, 4f), (4, 4f), (5, 4f), (6, 4f), (7, 4f))) - - addEntry("Data Card", 0, new IconDef("items/DataCard0", animation = DataCardAnimation), - () => new DataCard.Tier1) - addEntry("Data Card", 1, new IconDef("items/DataCard1", animation = DataCardAnimation), - () => new DataCard.Tier2) - addEntry("Data Card", 2, new IconDef("items/DataCard2", animation = DataCardAnimation), - () => new DataCard.Tier3) - - addEntry("Sound Card", 1, new IconDef("items/SoundCard", animation = DataCardAnimation), - () => new SoundCard) -} - diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlot.scala deleted file mode 100644 index aa5520f..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlot.scala +++ /dev/null @@ -1,85 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.color.Color -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.ui.UiHandler -import ocelot.desktop.ui.widget.{Label, TunnelDialog, Widget} -import ocelot.desktop.ui.widget.card.{Redstone1Window, Redstone2Window, SoundCardWindow} -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} -import totoro.ocelot.brain.entity.sound_card.SoundCard -import totoro.ocelot.brain.entity.{LinkedCard, Redstone} -import totoro.ocelot.brain.entity.traits.{Entity, Inventory} -import totoro.ocelot.brain.util.Tier - -class CardSlot(owner: Inventory#Slot, val tier: Int) extends InventorySlot[Entity](owner) { - override def itemIcon: Option[IconDef] = item.flatMap(it => CardRegistry.getIcon(it)) - - override def icon: IconDef = new IconDef("icons/Card") - override def tierIcon: Option[IconDef] = Some(new IconDef("icons/Tier" + tier)) - - override def fillRmbMenu(menu: ContextMenu): Unit = { - val pool = UiHandler.root.workspaceView.windowPool - item match { - case Some(card: Redstone.Tier2) => - menu.addEntry(new ContextMenuEntry("Redstone I/O", () => pool.openWindow(new Redstone1Window(card)))) - menu.addEntry(new ContextMenuEntry("Bundled I/O", () => pool.openWindow(new Redstone2Window(card)))) - case Some(card: Redstone.Tier1) => - menu.addEntry(new ContextMenuEntry("Redstone I/O", () => pool.openWindow(new Redstone1Window(card)))) - case Some(card: LinkedCard) => - menu.addEntry(new ContextMenuEntry("Set channel", () => new TunnelDialog( - tunnel => card.tunnel = tunnel, - card.tunnel - ).show())) - case Some(card: SoundCard) => - menu.addEntry(new ContextMenuEntry("Open", () => pool.openWindow(new SoundCardWindow(card)))) - case _ => - } - super.fillRmbMenu(menu) - } - - override def fillLmbMenu(menu: ContextMenu): Unit = { - val entries = CardRegistry.entries - - var i = 0 - while (i < entries.length) { - val entry = entries(i) - val nextEntry = entries.lift(i + 1) - - if (nextEntry.isDefined && nextEntry.get.name == entry.name) { - val groupName = entry.name - if (tier >= entry.tier) { - menu.addEntry(new ContextMenuSubmenu(groupName, icon = Some(nextEntry.get.icon)) { - entries.view.slice(i, entries.length).takeWhile(_.name == groupName).foreach(entry => { - val name = if (entry.tier == Tier.Four) "Creative" else "Tier " + (entry.tier + 1) - if (tier >= entry.tier) { - addEntry(new ContextMenuEntry(name, () => item = entry.factory(), Some(entry.icon))) - } - i += 1 - }) - }) - } - } else { - if (tier >= entry.tier) { - menu.addEntry(new ContextMenuEntry(entry.name, () => item = entry.factory(), Some(entry.icon))) - } - - i += 1 - } - } - } - - override def lmbMenuEnabled: Boolean = true - - override def tooltipChildrenAdder(inner: Widget): Unit = { - super.tooltipChildrenAdder(inner) - - item match { - case Some(card: LinkedCard) => - inner.children :+= new Label { - override def text: String = s"Channel: ${card.tunnel}" - override def color: Color = Color.Grey - } - case _ => - } - } -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlotWidget.scala new file mode 100644 index 0000000..a91fa31 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/CardSlotWidget.scala @@ -0,0 +1,11 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.Inventory +import ocelot.desktop.inventory.traits.CardItem +import totoro.ocelot.brain.util.Tier.Tier + +class CardSlotWidget(slot: Inventory#Slot, _tier: Tier) extends SlotWidget[CardItem](slot) { + override def ghostIcon: Option[IconDef] = Some(Icons.CardIcon) + override def slotTier: Option[Tier] = Some(_tier) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/CpuSlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/CpuSlotWidget.scala new file mode 100644 index 0000000..7603630 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/CpuSlotWidget.scala @@ -0,0 +1,25 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.{Inventory, Item} +import ocelot.desktop.inventory.traits.CpuLikeItem +import ocelot.desktop.node.nodes.ComputerNode +import totoro.ocelot.brain.util.Tier.Tier + +class CpuSlotWidget(slot: Inventory#Slot, node: ComputerNode, _tier: Tier) extends SlotWidget[CpuLikeItem](slot) { + override def ghostIcon: Option[IconDef] = Some(Icons.CpuIcon) + override def slotTier: Option[Tier] = Some(_tier) + + protected override def onItemNotification(notification: Item.Notification): Unit = { + super.onItemNotification(notification) + + notification match { + case CpuLikeItem.CpuArchitectureChangedNotification => + val machine = node.computer.machine + machine.stop() + machine.start() + + case _ => + } + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/DiskOrFloppySlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/DiskOrFloppySlot.scala deleted file mode 100644 index f785546..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/DiskOrFloppySlot.scala +++ /dev/null @@ -1,31 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.OcelotDesktop -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} -import totoro.ocelot.brain.entity.traits.{DiskManaged, Entity} - -import javax.swing.JFileChooser -import scala.util.Try - -trait DiskOrFloppySlot { - def addSetDirectoryEntryToRmbMenu(menu: ContextMenu, slot: InventorySlot[DiskManaged with Entity]): Unit = { - menu.addEntry(new ContextMenuEntry( - "Set directory", - () => OcelotDesktop.showFileChooserDialog( - dir => Try { - if (dir.isDefined && slot.item.isDefined) { - val item = slot.item - // first - unload the item with old filesystem (triggers 'component_removed' signal) - slot.item = None - // then - trigger filesystem rebuild with a new path - item.get.customRealPath = Some(dir.get.toPath.toAbsolutePath) - // finally - add the item back (triggers 'component_added' signal) - slot.item = item - } - }, - JFileChooser.OPEN_DIALOG, - JFileChooser.DIRECTORIES_ONLY - ) - )) - } -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/DiskSlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/DiskSlot.scala deleted file mode 100644 index 6a3d1a2..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/DiskSlot.scala +++ /dev/null @@ -1,43 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} -import totoro.ocelot.brain.entity.HDDManaged -import totoro.ocelot.brain.entity.traits.{DiskManaged, Entity, Inventory} -import totoro.ocelot.brain.util.Tier - -class DiskSlot(owner: Inventory#Slot, val tier: Int) extends InventorySlot[HDDManaged](owner) with DiskOrFloppySlot { - override def itemIcon: Option[IconDef] = item.map(disk => new IconDef("items/HardDiskDrive" + disk.tier)) - - override def icon: IconDef = new IconDef("icons/HDD") - override def tierIcon: Option[IconDef] = Some(new IconDef("icons/Tier" + tier)) - - override def fillLmbMenu(menu: ContextMenu): Unit = { - menu.addEntry(new ContextMenuEntry("HDD (Tier 1)", - () => item = new HDDManaged(Tier.One), - icon = Some(new IconDef("items/HardDiskDrive0")))) - - if (tier >= Tier.Two) { - menu.addEntry(new ContextMenuEntry("HDD (Tier 2)", - () => item = new HDDManaged(Tier.Two), - icon = Some(new IconDef("items/HardDiskDrive1")))) - } - - if (tier >= Tier.Three) { - menu.addEntry(new ContextMenuEntry("HDD (Tier 3)", - () => item = new HDDManaged(Tier.Three), - icon = Some(new IconDef("items/HardDiskDrive2")))) - } - } - - override def lmbMenuEnabled: Boolean = true - - override def fillRmbMenu(menu: ContextMenu): Unit = { - addSetDirectoryEntryToRmbMenu(menu, this.asInstanceOf[InventorySlot[DiskManaged with Entity]]) - - super.fillRmbMenu(menu) - } - - override def tooltipLine1Text: String = getTooltipDeviceInfoText(item.get.fileSystem, item.get.tier) - -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/EEPROMSlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/EEPROMSlot.scala deleted file mode 100644 index 5a749d5..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/EEPROMSlot.scala +++ /dev/null @@ -1,96 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.OcelotDesktop.showFileChooserDialog -import ocelot.desktop.color.Color -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} -import ocelot.desktop.ui.widget.{InputDialog, Label, Widget} -import totoro.ocelot.brain.entity.EEPROM -import totoro.ocelot.brain.entity.traits.Inventory -import totoro.ocelot.brain.loot.Loot - -import java.net.{MalformedURLException, URL} -import javax.swing.JFileChooser -import scala.util.Try - -class EEPROMSlot(owner: Inventory#Slot) extends InventorySlot[EEPROM](owner) { - override def itemIcon: Option[IconDef] = item.map(_ => new IconDef("items/EEPROM")) - override def icon: IconDef = new IconDef("icons/EEPROM") - - override def lmbMenuEnabled: Boolean = true - - override def fillLmbMenu(menu: ContextMenu): Unit = { - menu.addEntry(new ContextMenuEntry("Lua BIOS", - () => item = Loot.LuaBiosEEPROM.create(), - icon = Some(new IconDef("items/EEPROM")) - )) - - menu.addEntry(new ContextMenuEntry("AdvLoader", - () => item = Loot.AdvLoaderEEPROM.create(), - icon = Some(new IconDef("items/EEPROM")) - )) - - menu.addEntry(new ContextMenuEntry("Empty", - () => item = new EEPROM, - icon = Some(new IconDef("items/EEPROM")) - )) - } - - override def fillRmbMenu(menu: ContextMenu): Unit = { - val dataSourceMenu = new ContextMenuSubmenu("External data source") - - dataSourceMenu.addEntry(new ContextMenuEntry("Local file", () => showFileChooserDialog( - file => Try { - if (file.isDefined) - item.get.codePath = Some(file.get.toPath) - }, - JFileChooser.OPEN_DIALOG, - JFileChooser.FILES_ONLY - ))) - - dataSourceMenu.addEntry(new ContextMenuEntry("File via URL", () => - new InputDialog( - title = "File via URL", - text => item.get.codeURL = Some(new URL(text)), - "", - text => { - try { - new URL(text) - true - } - catch { - case _: MalformedURLException => false - } - } - ).show() - )) - - if (item.get.codePath.isDefined || item.get.codeURL.isDefined) - dataSourceMenu.addEntry(new ContextMenuEntry("Detach", () => item.get.codeBytes = Some(Array.empty[Byte]))) - - menu.addEntry(dataSourceMenu) - - super.fillRmbMenu(menu) - } - - override def tooltipLine1Text: String = getTooltipSuffixedText(getTooltipDeviceInfoText(item.get), item.get.label) - - override def tooltipChildrenAdder(inner: Widget): Unit = { - super.tooltipChildrenAdder(inner) - - if (item.get.codePath.isEmpty && item.get.codeURL.isEmpty) - return - - inner.children :+= new Label { - override def text: String = { - if (item.isDefined) { - if (item.get.codePath.isDefined) s"Source path: ${item.get.codePath.get.toString}" - else if (item.get.codeURL.isDefined) s"Source URL: ${item.get.codeURL.get.toString}" - else "" - } else "" - } - - override def color: Color = Color.Grey - } - } -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/EepromSlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/EepromSlotWidget.scala new file mode 100644 index 0000000..1395088 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/EepromSlotWidget.scala @@ -0,0 +1,9 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.Inventory +import ocelot.desktop.inventory.item.EepromItem + +class EepromSlotWidget(slot: Inventory#Slot) extends SlotWidget[EepromItem](slot) { + override def ghostIcon: Option[IconDef] = Some(Icons.EepromIcon) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlot.scala deleted file mode 100644 index 1c3e5eb..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlot.scala +++ /dev/null @@ -1,60 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.audio.{SoundBuffers, SoundCategory, SoundSource} -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} -import totoro.ocelot.brain.entity.FloppyManaged -import totoro.ocelot.brain.entity.traits.{DiskManaged, Entity, Floppy, Inventory} -import totoro.ocelot.brain.loot.Loot -import totoro.ocelot.brain.util.DyeColor - -class FloppySlot(owner: Inventory#Slot) extends InventorySlot[Floppy](owner) with DiskOrFloppySlot { - private val soundFloppyInsert = SoundSource.fromBuffer(SoundBuffers.MachineFloppyInsert, SoundCategory.Environment) - private val soundFloppyEject = SoundSource.fromBuffer(SoundBuffers.MachineFloppyEject, SoundCategory.Environment) - - override def itemIcon: Option[IconDef] = item.map(fl => new IconDef("items/FloppyDisk_" + fl.color.name)) - - override def icon: IconDef = new IconDef("icons/Floppy") - - override def fillLmbMenu(menu: ContextMenu): Unit = { - for (f <- FloppySlot.FloppyFactories) { - val floppy = f.create() - menu.addEntry(new ContextMenuEntry(floppy.label.getLabel, - () => item = floppy, - icon = Some(new IconDef("items/FloppyDisk_" + floppy.color.name)))) - } - - menu.addEntry(new ContextMenuEntry("Empty", - () => item = new FloppyManaged("Floppy Disk", DyeColor.GRAY), - icon = Some(new IconDef("items/FloppyDisk_dyeGray")))) - } - - override def lmbMenuEnabled: Boolean = true - - override def fillRmbMenu(menu: ContextMenu): Unit = { - item match { - case Some(_: FloppyManaged) => - addSetDirectoryEntryToRmbMenu(menu, this.asInstanceOf[InventorySlot[DiskManaged with Entity]]) - case _ => - } - - super.fillRmbMenu(menu) - } - - override def onAdded(item: Floppy): Unit = { - super.onAdded(item) - soundFloppyInsert.play() - } - - override def onRemoved(item: Floppy): Unit = { - super.onRemoved(item) - soundFloppyEject.play() - } - - override def tooltipLine1Text: String = item.get.label.getLabel -} - -object FloppySlot { - private val FloppyFactories = Array(Loot.OpenOsFloppy, Loot.Plan9kFloppy, Loot.OPPMFloppy, - Loot.OpenLoaderFloppy, Loot.NetworkFloppy, Loot.IrcFloppy, Loot.DataFloppy) -} \ No newline at end of file diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlotWidget.scala new file mode 100644 index 0000000..076fcdc --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/FloppySlotWidget.scala @@ -0,0 +1,34 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.audio.{Audio, SoundBuffers, SoundCategory, SoundSource} +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.Inventory +import ocelot.desktop.inventory.item.FloppyItem + +class FloppySlotWidget(slot: Inventory#Slot) extends SlotWidget[FloppyItem](slot) { + private lazy val soundFloppyInsert = + SoundSource.fromBuffer(SoundBuffers.MachineFloppyInsert, SoundCategory.Environment) + private lazy val soundFloppyEject = SoundSource.fromBuffer(SoundBuffers.MachineFloppyEject, SoundCategory.Environment) + + override def ghostIcon: Option[IconDef] = Some(Icons.FloppyIcon) + + override def onItemAdded(): Unit = { + super.onItemAdded() + // TODO: don't play the sound when loading a workspace + if (!Audio.isDisabled) { + soundFloppyInsert.play() + } + } + + override def onItemRemoved( + removedItem: FloppyItem, + replacedBy: Option[FloppyItem], + ): Unit = { + super.onItemRemoved(removedItem, replacedBy) + + // TODO: don't play the sound when loading a workspace + if (!Audio.isDisabled) { + soundFloppyEject.play() + } + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/HddSlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/HddSlotWidget.scala new file mode 100644 index 0000000..cbd9252 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/HddSlotWidget.scala @@ -0,0 +1,11 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.Inventory +import ocelot.desktop.inventory.item.HddItem +import totoro.ocelot.brain.util.Tier.Tier + +class HddSlotWidget(slot: Inventory#Slot, _tier: Tier) extends SlotWidget[HddItem](slot) { + override def ghostIcon: Option[IconDef] = Some(Icons.HddIcon) + override def slotTier: Option[Tier] = Some(_tier) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/InventorySlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/InventorySlot.scala deleted file mode 100644 index 9d003d8..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/InventorySlot.scala +++ /dev/null @@ -1,122 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.color.Color -import ocelot.desktop.geometry.Padding2D -import ocelot.desktop.ui.UiHandler -import ocelot.desktop.ui.layout.LinearLayout -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} -import ocelot.desktop.ui.widget.tooltip.Tooltip -import ocelot.desktop.ui.widget.{Label, PaddingBox, Widget} -import ocelot.desktop.util.Orientation -import totoro.ocelot.brain.entity.HDDManaged -import totoro.ocelot.brain.entity.traits.{DeviceInfo, Entity, Environment, Inventory, Tiered} - -abstract class InventorySlot[T <: Entity](val owner: Inventory#Slot) extends SlotWidget[T] { - reloadItem() - - override def onAdded(item: T): Unit = { - super.onAdded(item) - owner.put(item) - } - - override def onRemoved(item: T): Unit = { - super.onRemoved(item) - owner.remove() - } - - override def fillRmbMenu(menu: ContextMenu): Unit = { - item match { - case Some(env: Environment) => - menu.addEntry(new ContextMenuEntry("Copy address", () => { - UiHandler.clipboard = env.node.address - })) - case _ => - } - super.fillRmbMenu(menu) - } - - // --------------------------- Tooltip --------------------------- - - private var tooltip: Option[Tooltip] = None - - def tooltipLine1Color: Color = item match { - case Some(tiered: Tiered) => - tiered.tier match { - case 1 => Color.Yellow - case 2 => Color.Cyan - case _ => Color.White - } - case _ => Color.White - } - - final protected def getTooltipSuffixedText(part1: String, part2: String): String = s"$part1 ($part2)" - final protected def getTooltipTieredText(tier: String): String = s"Tier $tier" - final protected def getTooltipTieredText(tier: Int): String = getTooltipTieredText((tier + 1).toString) - final protected def getTooltipDeviceInfoText(deviceInfo: DeviceInfo): String = - deviceInfo.getDeviceInfo(DeviceInfo.DeviceAttribute.Description) - final protected def getTooltipDeviceInfoText(deviceInfo: DeviceInfo, part2: String): String = - getTooltipSuffixedText(getTooltipDeviceInfoText(deviceInfo), part2) - final protected def getTooltipDeviceInfoText(deviceInfo: DeviceInfo, tier: Int): String = - getTooltipDeviceInfoText(deviceInfo, getTooltipTieredText(tier)) - - def tooltipLine1Text: String = item.get match { - case deviceInfoTiered: DeviceInfo with Tiered => - getTooltipDeviceInfoText(deviceInfoTiered, deviceInfoTiered.tier) - case _ => "" - } - - def tooltipChildrenAdder(inner: Widget): Unit = { - inner.children :+= new Label { - override def text: String = tooltipLine1Text - override def color: Color = tooltipLine1Color - } - - item match { - case Some(environment: Environment) => - inner.children :+= new Label { - override def text: String = s"Address: ${environment.node.address}" - override def color: Color = Color.Grey - } - case _ => - } - - item match { - case Some(hddManaged: HDDManaged) => - if (hddManaged.customRealPath.isDefined) { - inner.children :+= new Label { - override def text: String = s"Source path: ${hddManaged.customRealPath.get}" - override def color: Color = Color.Grey - } - } - case _ => - } - } - - override def onHoverEnter(): Unit = { - super.onHoverEnter() - - // Showing tooltip only if item is present in slot - if (item.isEmpty) - return - - tooltip = Some(new Tooltip()) - - val inner: Widget = new Widget { - override val layout = new LinearLayout(this, orientation = Orientation.Vertical) - } - - tooltipChildrenAdder(inner) - - tooltip.get.children :+= new PaddingBox(inner, Padding2D.equal(5)) - - root.get.tooltipPool.addTooltip(tooltip.get) - } - - override def onHoverLeave(): Unit = { - super.onHoverLeave() - - tooltip.foreach(root.get.tooltipPool.closeTooltip(_)) - } - - def reloadItem(): Unit = _item = owner.get.map(_.asInstanceOf[T]) -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/ItemChooser.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/ItemChooser.scala new file mode 100644 index 0000000..81ca0bb --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/ItemChooser.scala @@ -0,0 +1,63 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.IconDef +import ocelot.desktop.inventory.Items.{ArbitraryItemGroup, ExtendedTieredItemGroup, SingletonItemGroup, TieredItemGroup} +import ocelot.desktop.inventory._ +import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry, ContextMenuSubmenu} +import totoro.ocelot.brain.util.ExtendedTier.ExtendedTier +import totoro.ocelot.brain.util.Tier.Tier + +class ItemChooser[I <: Item](slot: SlotWidget[I]) extends ContextMenu { + import ItemChooser._ + + private def makeMenuEntry(factory: ItemFactory, label: String): ContextMenuEntry = { + ContextMenuEntry(label, icon = Some(factory.icon)) { + slot.item = factory.build().asInstanceOf[I] + } + } + + private def addSubmenu[T: HasLabel]( + name: String, + factories: Seq[(T, ItemFactory)], + icon: Option[IconDef] = None + ): Unit = { + val tierLabel = implicitly[HasLabel[T]].label _ + + val acceptedFactories = factories.iterator.filter(p => slot.isItemAccepted(p._2)).toSeq + + if (acceptedFactories.nonEmpty) { + addEntry( + new ContextMenuSubmenu(name, icon = Some(icon.getOrElse(acceptedFactories.last._2.icon))) { + for ((tier, factory) <- acceptedFactories) { + addEntry(makeMenuEntry(factory, tierLabel(tier))) + } + } + ) + } + } + + for (group <- Items.groups) { + group match { + case SingletonItemGroup(name, factory) => + if (slot.isItemAccepted(factory)) { + addEntry(makeMenuEntry(factory, name)) + } + + case TieredItemGroup(name, factories) => addSubmenu(name, factories) + + case ExtendedTieredItemGroup(name, factories) => addSubmenu(name, factories) + + case ArbitraryItemGroup(name, icon, factories) => addSubmenu(name, factories, Some(icon)) + } + } +} + +object ItemChooser { + private trait HasLabel[A] { + def label(value: A): String + } + + private implicit val TierHasLabel: HasLabel[Tier] = _.label + private implicit val ExtendedTierHasLabel: HasLabel[ExtendedTier] = _.label + private implicit val StringHasLabel: HasLabel[String] = identity +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlot.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlot.scala deleted file mode 100644 index e081335..0000000 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlot.scala +++ /dev/null @@ -1,66 +0,0 @@ -package ocelot.desktop.ui.widget.slot - -import ocelot.desktop.color.Color -import ocelot.desktop.graphics.IconDef -import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} -import totoro.ocelot.brain.entity.Memory -import totoro.ocelot.brain.entity.traits.{Inventory, Tiered} -import totoro.ocelot.brain.util.Tier - -class MemorySlot(owner: Inventory#Slot, val tier: Int) extends InventorySlot[Memory](owner) { - override def itemIcon: Option[IconDef] = item.map(mem => new IconDef("items/Memory" + mem.tier)) - - override def icon: IconDef = new IconDef("icons/Memory") - override def tierIcon: Option[IconDef] = Some(new IconDef("icons/Tier" + tier)) - - override def fillLmbMenu(menu: ContextMenu): Unit = { - menu.addEntry(new ContextMenuEntry("RAM (Tier 1)", - () => item = new Memory(Tier.One), - icon = Some(new IconDef("items/Memory0")))) - - menu.addEntry(new ContextMenuEntry("RAM (Tier 1.5)", - () => item = new Memory(Tier.Two), - icon = Some(new IconDef("items/Memory1")))) - - if (tier >= Tier.Two) { - menu.addEntry(new ContextMenuEntry("RAM (Tier 2)", - () => item = new Memory(Tier.Three), - icon = Some(new IconDef("items/Memory2")))) - - menu.addEntry(new ContextMenuEntry("RAM (Tier 2.5)", - () => item = new Memory(Tier.Four), - icon = Some(new IconDef("items/Memory3")))) - } - - if (tier >= Tier.Three) { - menu.addEntry(new ContextMenuEntry("RAM (Tier 3)", - () => item = new Memory(Tier.Five), - icon = Some(new IconDef("items/Memory4")))) - - menu.addEntry(new ContextMenuEntry("RAM (Tier 3.5)", - () => item = new Memory(Tier.Six), - icon = Some(new IconDef("items/Memory5")))) - } - } - - override def lmbMenuEnabled: Boolean = true - - override def tooltipLine1Color: Color = item.get match { - case tiered: Tiered => tiered.tier match { - case 0 => Color.White - case 1 => Color.White - case 2 => Color.Yellow - case 3 => Color.Yellow - case 4 => Color.Cyan - case _ => Color.Cyan - } - case _ => Color.White - } - - override def tooltipLine1Text: String = { - val tier = (item.get.tier + 2) / 2f - val intTier = tier.toInt - - getTooltipDeviceInfoText(item.get, getTooltipTieredText(if (tier - intTier > 0) tier.toString else intTier.toString)) - } -} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlotWidget.scala new file mode 100644 index 0000000..109ecbf --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/MemorySlotWidget.scala @@ -0,0 +1,11 @@ +package ocelot.desktop.ui.widget.slot + +import ocelot.desktop.graphics.{IconDef, Icons} +import ocelot.desktop.inventory.Inventory +import ocelot.desktop.inventory.item.MemoryItem +import totoro.ocelot.brain.util.Tier.Tier + +class MemorySlotWidget(slot: Inventory#Slot, _tier: Tier) extends SlotWidget[MemoryItem](slot) { + override def ghostIcon: Option[IconDef] = Some(Icons.MemoryIcon) + override def slotTier: Option[Tier] = Some(_tier) +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/slot/SlotWidget.scala b/src/main/scala/ocelot/desktop/ui/widget/slot/SlotWidget.scala index f89ad9f..35afb61 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/slot/SlotWidget.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/slot/SlotWidget.scala @@ -2,84 +2,153 @@ package ocelot.desktop.ui.widget.slot import ocelot.desktop.audio.SoundSources import ocelot.desktop.geometry.Size2D -import ocelot.desktop.graphics.{Graphics, IconDef} +import ocelot.desktop.graphics.{Graphics, IconDef, Icons} +import ocelot.desktop.inventory.{Inventory, Item, ItemFactory} import ocelot.desktop.ui.event.handlers.{ClickHandler, HoverHandler} import ocelot.desktop.ui.event.{ClickEvent, HoverEvent, MouseEvent} import ocelot.desktop.ui.widget.Widget import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} +import ocelot.desktop.ui.widget.tooltip.{ItemTooltip, Tooltip} +import totoro.ocelot.brain.util.Tier +import totoro.ocelot.brain.util.Tier.Tier -abstract class SlotWidget[T] extends Widget with ClickHandler with HoverHandler { - def icon: IconDef +import scala.math.Ordering.Implicits.infixOrderingOps +import scala.reflect.ClassTag - def tierIcon: Option[IconDef] = None +class SlotWidget[I <: Item](private val slot: Inventory#Slot)(implicit slotItemTag: ClassTag[I]) + extends Widget + with ClickHandler + with HoverHandler { - def itemIcon: Option[IconDef] = None + slotWidget => - def onRemoved(item: T): Unit = {} + // NOTE: this must remain as a separate object + // (i. e. don't you dare inline it into the addObserver call below): + // this is the only strong reference to the observer + private val observer = new Inventory.SlotObserver { + override def onItemAdded(): Unit = { + slotWidget.onItemAdded() + } - def onAdded(item: T): Unit = {} + override def onItemRemoved(removedItem: Item, replacedBy: Option[Item]): Unit = { + slotWidget.onItemRemoved(removedItem.asInstanceOf[I], replacedBy.map(_.asInstanceOf[I])) + } - def fillLmbMenu(menu: ContextMenu): Unit = {} - - def fillRmbMenu(menu: ContextMenu): Unit = { - menu.addEntry(new ContextMenuEntry("Remove", () => item = None, sound = SoundSources.InterfaceClickLow)) + override def onItemNotification(notification: Item.Notification): Unit = { + slotWidget.onItemNotification(notification) + } } - def lmbMenuEnabled: Boolean = false + slot.addObserver(observer) - def rmbMenuEnabled: Boolean = _item.isDefined - - final var _item: Option[T] = None - - final def item_=(v: Option[T]): Unit = { - if (_item.isDefined) - onRemoved(_item.get) - - _item = v - - if (v.isDefined) - onAdded(v.get) + def dispose(): Unit = { + closeTooltip() + slot.removeObserver(observer) } - final def item_=(v: T): Unit = item = Some(v) + def ghostIcon: Option[IconDef] = None - final def item: Option[T] = _item + def slotTier: Option[Tier] = None + + def tierIcon: Option[IconDef] = slotTier.map(Icons.TierIcon) + + def itemIcon: Option[IconDef] = item.map(_.icon) + + def item: Option[I] = slot.get.map(_.asInstanceOf[I]) + + def item_=(item: Option[I]): Unit = { + slot.set(item.map(_.asInstanceOf[slot.inventory.I])) + } + + def item_=(item: I): Unit = { + slot.put(item.asInstanceOf[slot.inventory.I]) + } + + def isItemAccepted(factory: ItemFactory): Boolean = { + // yes, you can compare Options. isn't it nice? + slotItemTag.runtimeClass.isAssignableFrom(factory.itemClass) && factory.tier.map(_ min Tier.Three) <= slotTier + } + + protected def onItemAdded(): Unit = {} + + protected def onItemRemoved(removedItem: I, replacedBy: Option[I]): Unit = { + closeTooltip() + } + + protected def onItemNotification(notification: Item.Notification): Unit = {} final override def minimumSize: Size2D = Size2D(36, 36) final override def maximumSize: Size2D = minimumSize - final override def receiveMouseEvents: Boolean = true + protected def lmbMenuEnabled: Boolean = true + + protected def rmbMenuEnabled: Boolean = item.nonEmpty + + protected def fillRmbMenu(menu: ContextMenu): Unit = { + for (item <- item) { + item.fillRmbMenu(menu) + menu.addEntry( + ContextMenuEntry("Remove", sound = SoundSources.InterfaceClickLow) { + slot.remove() + } + ) + } + } + + private var _tooltip: Option[Tooltip] = None + + def onHoverEnter(): Unit = { + for (item <- item) { + // just in case + closeTooltip() + + val tooltip = new ItemTooltip + item.fillTooltip(tooltip) + _tooltip = Some(tooltip) + root.get.tooltipPool.addTooltip(tooltip) + } + } + + def onHoverLeave(): Unit = { + closeTooltip() + } + + private def closeTooltip(): Unit = { + for (tooltip <- _tooltip) { + root.get.tooltipPool.closeTooltip(tooltip) + } + } + eventHandlers += { case ClickEvent(MouseEvent.Button.Left, _) if lmbMenuEnabled => - val menu = new ContextMenu - fillLmbMenu(menu) - root.get.contextMenus.open(menu) + root.get.contextMenus.open(new ItemChooser(this)) + case ClickEvent(MouseEvent.Button.Right, _) if rmbMenuEnabled => val menu = new ContextMenu fillRmbMenu(menu) root.get.contextMenus.open(menu) - case HoverEvent(state) => - if (state == HoverEvent.State.Enter) - onHoverEnter() - else - onHoverLeave() + + case HoverEvent(HoverEvent.State.Enter) => onHoverEnter() + case HoverEvent(HoverEvent.State.Leave) => onHoverLeave() } - def onHoverEnter(): Unit = {} - - def onHoverLeave(): Unit = {} - final override def draw(g: Graphics): Unit = { g.sprite("EmptySlot", bounds) val iconBounds = bounds.inflate(-2) - if (itemIcon.isDefined) { - g.sprite(itemIcon.get, iconBounds) - } else { - if (tierIcon.isDefined) g.sprite(tierIcon.get, iconBounds) - g.sprite(icon, iconBounds) + itemIcon match { + case Some(itemIcon) => g.sprite(itemIcon, iconBounds) + + case None => + for (tierIcon <- tierIcon) { + g.sprite(tierIcon, iconBounds) + } + + for (ghostIcon <- ghostIcon) { + g.sprite(ghostIcon, iconBounds) + } } } } diff --git a/src/main/scala/ocelot/desktop/ui/widget/statusbar/StatusBar.scala b/src/main/scala/ocelot/desktop/ui/widget/statusbar/StatusBar.scala index 8f68bd2..794b3b4 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/statusbar/StatusBar.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/statusbar/StatusBar.scala @@ -12,6 +12,7 @@ import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry} import ocelot.desktop.{ColorScheme, OcelotDesktop} import org.lwjgl.input.Keyboard +import scala.collection.immutable.ArraySeq import scala.concurrent.duration.DurationInt class StatusBar extends Widget { @@ -56,18 +57,18 @@ class StatusBar extends Widget { eventHandlers += { case ClickEvent(MouseEvent.Button.Right, pos) => val menu = new ContextMenu - menu.addEntry(new ContextMenuEntry("Change simulation speed", () => { + menu.addEntry(ContextMenuEntry("Change simulation speed") { new ChangeSimulationSpeedDialog().show() - })) - menu.addEntry(new ContextMenuEntry("Reset simulation speed", () => { + }) + menu.addEntry(ContextMenuEntry("Reset simulation speed") { OcelotDesktop.ticker.tickInterval = 50.millis - })) + }) if (OcelotDesktop.tickerIntervalHistory.nonEmpty) menu.addSeparator() for (elem <- OcelotDesktop.tickerIntervalHistory.reverseIterator) { - menu.addEntry(new ContextMenuEntry((1_000_000f / elem.toMicros).toString, () => { + menu.addEntry(ContextMenuEntry((1_000_000f / elem.toMicros).toString) { OcelotDesktop.ticker.tickInterval = elem - })) + }) } root.get.contextMenus.open(menu, pos) } @@ -97,7 +98,7 @@ class StatusBar extends Widget { g.rect(bounds, ColorScheme("StatusBar")) g.line(position, position + Size2D(width, 0), 1, ColorScheme("StatusBarBorder")) drawChildren(g) - keyEntries.children = Array() - keyMouseEntries.children = Array() + keyEntries.children = ArraySeq.empty + keyMouseEntries.children = ArraySeq.empty } } diff --git a/src/main/scala/ocelot/desktop/ui/widget/tooltip/ItemTooltip.scala b/src/main/scala/ocelot/desktop/ui/widget/tooltip/ItemTooltip.scala new file mode 100644 index 0000000..0f8760a --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/tooltip/ItemTooltip.scala @@ -0,0 +1,16 @@ +package ocelot.desktop.ui.widget.tooltip + +import ocelot.desktop.color.Color +import ocelot.desktop.geometry.Padding2D +import ocelot.desktop.ui.widget.Label + +class ItemTooltip extends Tooltip { + override def bodyPadding: Padding2D = Padding2D.equal(5) + + def addLine(_text: String, _color: Color = Color.Grey): Unit = { + body.children :+= new Label { + override def text: String = _text + override def color: Color = _color + } + } +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/tooltip/LabelTooltip.scala b/src/main/scala/ocelot/desktop/ui/widget/tooltip/LabelTooltip.scala new file mode 100644 index 0000000..0c69340 --- /dev/null +++ b/src/main/scala/ocelot/desktop/ui/widget/tooltip/LabelTooltip.scala @@ -0,0 +1,22 @@ +package ocelot.desktop.ui.widget.tooltip + +import ocelot.desktop.color.Color +import ocelot.desktop.geometry.Padding2D +import ocelot.desktop.ui.widget.Label + +class LabelTooltip(text: String) extends Tooltip { + override val DelayTime: Float = 1.0f + + for (line <- text.split("\n")) { + body.children :+= new Label { + override def text: String = line + override def color: Color = Color.White + } + } + + override def bodyPadding: Padding2D = Padding2D.equal(4) + + def onSaveSelected(): Unit = {} + + def onExitSelected(): Unit = {} +} diff --git a/src/main/scala/ocelot/desktop/ui/widget/tooltip/Tooltip.scala b/src/main/scala/ocelot/desktop/ui/widget/tooltip/Tooltip.scala index 26b73b2..0f456af 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/tooltip/Tooltip.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/tooltip/Tooltip.scala @@ -1,15 +1,26 @@ package ocelot.desktop.ui.widget.tooltip import ocelot.desktop.color.{Color, RGBAColor} +import ocelot.desktop.geometry.Padding2D import ocelot.desktop.graphics.Graphics import ocelot.desktop.ui.event.KeyEvent -import ocelot.desktop.ui.widget.Widget +import ocelot.desktop.ui.layout.LinearLayout +import ocelot.desktop.ui.widget.{PaddingBox, Widget} +import ocelot.desktop.util.Orientation import ocelot.desktop.util.animation.UnitAnimation -class Tooltip extends Widget { +abstract class Tooltip extends Widget { protected def tooltipPool: TooltipPool = parent.get.asInstanceOf[TooltipPool] protected val openCloseAnimation: UnitAnimation = UnitAnimation.easeInOutQuad(0.3f) + val body: Widget = new Widget { + override val layout = new LinearLayout(this, orientation = Orientation.Vertical) + } + + def bodyPadding: Padding2D + + children :+= new PaddingBox(body, bodyPadding) + /** * Time before the tooltip will become visible (in seconds) */ diff --git a/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala index d6fbb93..1638473 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/verticalmenu/VerticalMenuButton.scala @@ -12,7 +12,7 @@ import ocelot.desktop.ui.widget._ import ocelot.desktop.util.Orientation import ocelot.desktop.util.animation.ColorAnimation -class VerticalMenuButton(icon: String, label: String, handler: VerticalMenuButton => Unit = _ => {}) +class VerticalMenuButton(icon: IconDef, label: String, handler: VerticalMenuButton => Unit = _ => {}) extends Widget with ClickHandler with HoverHandler with ClickSoundSource { val colorAnimation: ColorAnimation = new ColorAnimation(ColorScheme("VerticalMenuBackground"), 0.6f) @@ -22,7 +22,7 @@ class VerticalMenuButton(icon: String, label: String, handler: VerticalMenuButto override val layout = new LinearLayout(this, orientation = Orientation.Horizontal) children :+= new PaddingBox( - new Icon(IconDef(icon, 1f, color = ColorScheme("VerticalMenuEntryIcon"))), + new Icon(icon.copy(sizeMultiplier = 1f, color = ColorScheme("VerticalMenuEntryIcon"))), Padding2D(right = 8) ) diff --git a/src/main/scala/ocelot/desktop/ui/widget/window/NodeSelector.scala b/src/main/scala/ocelot/desktop/ui/widget/window/NodeSelector.scala index 359711e..c55fd36 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/window/NodeSelector.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/window/NodeSelector.scala @@ -11,6 +11,8 @@ import ocelot.desktop.ui.widget.{PaddingBox, ScrollView, Widget, WorkspaceView} import ocelot.desktop.util.animation.{ColorAnimation, UnitAnimation} import ocelot.desktop.util.{DrawUtils, Logging, Orientation} +import scala.collection.immutable.ArraySeq + class NodeSelector extends Window with Logging { override protected val layout = new LinearLayout(this) @@ -39,10 +41,10 @@ class NodeSelector extends Window with Logging { private def workspaceView: WorkspaceView = windowPool.parent.get.asInstanceOf[WorkspaceView] - private def rows: Array[Widget] = rowsWidget.children + private def rows: ArraySeq[Widget] = rowsWidget.children // noinspection ScalaUnusedSymbol - private def rows_=(rows: Array[Widget]): Unit = rowsWidget.children = rows + private def rows_=(rows: ArraySeq[Widget]): Unit = rowsWidget.children = rows private def addNodeWidget(widget: Widget): Unit = { val row = if (rows.lastOption.exists(row => row.children.length < 4)) { @@ -60,7 +62,7 @@ class NodeSelector extends Window with Logging { private def initNodeTypes(): Unit = { NodeRegistry.types.foreach(t => addNodeWidget(new NodeTypeWidget(t) { override def onClick(): Unit = { - workspaceView.addNode(nodeType.factory()) + workspaceView.addNode(nodeType.make()) hide() } })) diff --git a/src/main/scala/ocelot/desktop/ui/widget/window/ProfilerWindow.scala b/src/main/scala/ocelot/desktop/ui/widget/window/ProfilerWindow.scala index cafce53..27bfa8b 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/window/ProfilerWindow.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/window/ProfilerWindow.scala @@ -6,6 +6,8 @@ import ocelot.desktop.ui.layout.LinearLayout import ocelot.desktop.ui.widget.{Label, PaddingBox, Widget} import ocelot.desktop.util.{DrawUtils, Orientation, Profiler} +import scala.collection.immutable.ArraySeq + class ProfilerWindow extends BasicWindow { private val inner: Widget = new Widget { override val layout = new LinearLayout(this, orientation = Orientation.Vertical) @@ -14,7 +16,7 @@ class ProfilerWindow extends BasicWindow { children :+= new PaddingBox(inner, Padding2D.equal(8)) override def draw(g: Graphics): Unit = { - inner.children = Array() + inner.children = ArraySeq.empty for (line <- Profiler.report()) { inner.children :+= new Label { override def text: String = line diff --git a/src/main/scala/ocelot/desktop/ui/widget/window/WindowPool.scala b/src/main/scala/ocelot/desktop/ui/widget/window/WindowPool.scala index b9950ed..7ee8030 100644 --- a/src/main/scala/ocelot/desktop/ui/widget/window/WindowPool.scala +++ b/src/main/scala/ocelot/desktop/ui/widget/window/WindowPool.scala @@ -5,10 +5,12 @@ import ocelot.desktop.ui.event.handlers.DragHandler import ocelot.desktop.ui.layout.Layout import ocelot.desktop.ui.widget.Widget +import scala.collection.immutable.ArraySeq + class WindowPool extends Widget with DragHandler { override protected val layout: Layout = new Layout(this) - private def windows: Array[Window] = children.map(_.asInstanceOf[Window]) + private def windows: ArraySeq[Window] = children.map(_.asInstanceOf[Window]) def addWindow(window: Window): Unit = { if (!children.contains(window)) { diff --git a/src/main/scala/ocelot/desktop/util/Font.scala b/src/main/scala/ocelot/desktop/util/Font.scala index 84cbc65..8cd465a 100644 --- a/src/main/scala/ocelot/desktop/util/Font.scala +++ b/src/main/scala/ocelot/desktop/util/Font.scala @@ -3,6 +3,7 @@ package ocelot.desktop.util import ocelot.desktop.geometry.Rect2D import ocelot.desktop.graphics.Texture import org.lwjgl.opengl.GL11 +import totoro.ocelot.brain.util.FontUtils import java.awt.image.{BufferedImage, DataBufferByte, IndexColorModel} import java.io.InputStream @@ -10,9 +11,7 @@ import java.nio.ByteBuffer import scala.collection.mutable import scala.io.{Codec, Source} -class Font(val name: String, val fontSize: Int) extends Logging { - val CodepointLimit: Int = 0x10000 - +class Font(val name: String, val fontSize: Int) extends Resource with Logging { val AtlasWidth = 4096 val AtlasHeight = 4096 var glyphCount = 0 @@ -24,12 +23,12 @@ class Font(val name: String, val fontSize: Int) extends Logging { new BufferedImage(AtlasWidth, AtlasHeight, BufferedImage.TYPE_BYTE_BINARY, icm) } - val map = new mutable.HashMap[Char, Rect2D] + val map = new mutable.HashMap[Int, Rect2D] var texture: Texture = _ init() - def charWidth(char: Char): Int = (map.getOrElse(char, map('?')).w * AtlasWidth).toInt + def charWidth(codePoint: Int): Int = (map.getOrElse(codePoint, map('?')).w * AtlasWidth).toInt private def init(): Unit = { logger.info(f"Loading font $name") @@ -43,47 +42,53 @@ class Font(val name: String, val fontSize: Int) extends Logging { for (line <- source.getLines()) { val colon = line.indexOf(':') val charCode = Integer.parseInt(line.substring(0, colon), 16) - if (charCode >= 0 && charCode < CodepointLimit) { - val char = charCode.toChar - val width = (line.length - colon - 1) * 4 / fontSize - if (ox + width > AtlasWidth) { - ox = 0 - oy += fontSize - } + if (charCode >= 0 && charCode < FontUtils.codepoint_limit) { + val expectedWidth = FontUtils.wcwidth(charCode) - map(char) = Rect2D( - ox.asInstanceOf[Float] / AtlasWidth.asInstanceOf[Float], - oy.asInstanceOf[Float] / AtlasHeight.asInstanceOf[Float], - width.asInstanceOf[Float] / AtlasWidth.asInstanceOf[Float], - fontSize.asInstanceOf[Float] / AtlasHeight.asInstanceOf[Float] - ) + // skip control characters + if (expectedWidth >= 1) { + val width = (line.length - colon - 1) * 4 / fontSize - var x = 0 - var y = 0 + if (ox + width > AtlasWidth) { + ox = 0 + oy += fontSize + } - for (char <- line.substring(colon + 1)) { - for (i <- 3 to 0 by -1) { - val bit = (Character.digit(char, 16) >> i) & 1 + map(charCode) = Rect2D( + ox.asInstanceOf[Float] / AtlasWidth.asInstanceOf[Float], + oy.asInstanceOf[Float] / AtlasHeight.asInstanceOf[Float], + width.asInstanceOf[Float] / AtlasWidth.asInstanceOf[Float], + fontSize.asInstanceOf[Float] / AtlasHeight.asInstanceOf[Float] + ) - atlas.setRGB(ox + x, oy + y, bit match { - case 0 => 0x00000000 - case 1 => 0xFFFFFFFF - }) + var x = 0 + var y = 0 - x += 1 + for (char <- line.substring(colon + 1)) { + for (i <- 3 to 0 by -1) { + val bit = (Character.digit(char, 16) >> i) & 1 - if (x == width) { - x = 0 - y += 1 + atlas.setRGB(ox + x, oy + y, bit match { + case 0 => 0x00000000 + case 1 => 0xFFFFFFFF + }) + + x += 1 + + if (x == width) { + x = 0 + y += 1 + } } } - } - ox += width - glyphCount += 1 + ox += width + glyphCount += 1 + } } else { outOfRangeGlyphCount += 1 + logger.warn(String.format("Unicode font contained unexpected glyph: U+%04X, ignoring", charCode)) } } @@ -118,4 +123,9 @@ class Font(val name: String, val fontSize: Int) extends Logging { tex } + + override def freeResource(): Unit = { + super.freeResource() + texture.freeResource() + } } \ No newline at end of file diff --git a/src/main/scala/ocelot/desktop/util/Messages.scala b/src/main/scala/ocelot/desktop/util/Messages.scala new file mode 100644 index 0000000..9075145 --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/Messages.scala @@ -0,0 +1,30 @@ +package ocelot.desktop.util + +import scala.collection.mutable +import scala.io.Source + +object Messages extends Logging with PartialFunction[String, String] { + private val entries = new mutable.HashMap[String, String]() + + override def apply(key: String): String = entries(key) + + override def isDefinedAt(key: String): Boolean = entries.isDefinedAt(key) + + def load(source: Source): Unit = { + logger.info(s"Loading messages") + + val oldSize = entries.size + + for (line_ <- source.getLines) { + val line = line_.trim + if (line.nonEmpty) { + val split = line.split("\\s*=\\s*") + val key = split(0) + val value = split(1) + entries(key) = value + } + } + + logger.info(s"Loaded ${entries.size - oldSize} messages") + } +} diff --git a/src/main/scala/ocelot/desktop/util/OpenAlException.scala b/src/main/scala/ocelot/desktop/util/OpenAlException.scala new file mode 100644 index 0000000..5ff4a83 --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/OpenAlException.scala @@ -0,0 +1,14 @@ +package ocelot.desktop.util + +import scala.util.control +import scala.util.control.Exception.Catch + +case class OpenAlException(func: String, errName: String, code: Int) + extends Exception(s"OpenAL error: $func: $errName") + +object OpenAlException { + def defaulting[T](default: => T): Catch[T] = control.Exception.failAsValue(classOf[OpenAlException])(default) + + def ignoring: Catch[Unit] = defaulting(()) +} + diff --git a/src/main/scala/ocelot/desktop/util/Profiler.scala b/src/main/scala/ocelot/desktop/util/Profiler.scala index 9c8ebca..52e719e 100644 --- a/src/main/scala/ocelot/desktop/util/Profiler.scala +++ b/src/main/scala/ocelot/desktop/util/Profiler.scala @@ -6,12 +6,22 @@ import scala.collection.mutable.ArrayBuffer object Profiler { private val timeEntries = mutable.HashMap[String, TimeEntry]() - def startTimeMeasurement(name: String): Unit = { + def measure(name: String)(f: => Unit): Unit = { + startTimeMeasurement(name) + + try { + f + } finally { + endTimeMeasurement(name) + } + } + + private def startTimeMeasurement(name: String): Unit = { val entry = timeEntries.getOrElseUpdate(name, new TimeEntry) entry.startTime = System.nanoTime().toDouble / 1e9 } - def endTimeMeasurement(name: String): Unit = { + private def endTimeMeasurement(name: String): Unit = { val entry = timeEntries.getOrElseUpdate(name, new TimeEntry) entry.addSample(System.nanoTime().toDouble / 1e9 - entry.startTime) } diff --git a/src/main/scala/ocelot/desktop/util/ReflectionUtils.scala b/src/main/scala/ocelot/desktop/util/ReflectionUtils.scala new file mode 100644 index 0000000..bb89169 --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/ReflectionUtils.scala @@ -0,0 +1,53 @@ +package ocelot.desktop.util + +import java.lang.reflect.Constructor +import scala.collection.mutable.ArrayBuffer + +object ReflectionUtils { + /** + * Returns the linearization order of a class. + * + * Roughly speaking, it's the order method calls are resolved in. + * + * It also acts as a list of superclasses (each occurring once). + */ + def linearizationOrder(clazz: Class[_]): Iterator[Class[_]] = { + val result = ArrayBuffer[Class[_]](clazz) + + for (interface <- clazz.getInterfaces.reverseIterator) { + result ++= linearizationOrder(interface) + } + + for (superclass <- Option(clazz.getSuperclass)) { + result ++= linearizationOrder(superclass) + } + + val merged = result.reverseIterator.distinct.toBuffer + + merged.reverseIterator + } + + /** + * Finds a (most specific) unary constructor of `constructedClass` that accepts `argumentClass` (or its subtype). + */ + def findUnaryConstructor[A](constructedClass: Class[A], argumentClass: Class[_]): Option[Constructor[A]] = { + try { + // happy case: just grab the constructor directly + return Some(constructedClass.getConstructor(argumentClass)) + } catch { + case _: NoSuchMethodException => + // uh-oh, we'll have to try harder... + } + + val constructors = constructedClass.getConstructors + .iterator + .filter(_.getParameterCount == 1) + .map(_.asInstanceOf[Constructor[A]]) + .map(constructor => (constructor.getParameterTypes()(0), constructor)) + .toMap + + linearizationOrder(argumentClass) + .flatMap(constructors.get) + .nextOption() + } +} diff --git a/src/main/scala/ocelot/desktop/util/Resource.scala b/src/main/scala/ocelot/desktop/util/Resource.scala index 6c7e9e2..391e100 100644 --- a/src/main/scala/ocelot/desktop/util/Resource.scala +++ b/src/main/scala/ocelot/desktop/util/Resource.scala @@ -1,6 +1,9 @@ package ocelot.desktop.util trait Resource { - def initResource(): Unit = {} - def freeResource(): Unit + ResourceManager.registerResource(this) + + def freeResource(): Unit = { + ResourceManager.unregisterResource(this) + } } diff --git a/src/main/scala/ocelot/desktop/util/ResourceManager.scala b/src/main/scala/ocelot/desktop/util/ResourceManager.scala index 1aedeb4..bf77359 100644 --- a/src/main/scala/ocelot/desktop/util/ResourceManager.scala +++ b/src/main/scala/ocelot/desktop/util/ResourceManager.scala @@ -2,37 +2,25 @@ package ocelot.desktop.util import scala.collection.mutable.ArrayBuffer -object ResourceManager { - private var initialized = false +object ResourceManager extends Logging { private val resources = new ArrayBuffer[Resource] def registerResource(resource: Resource): Unit = { resources += resource - if (initialized) resource.initResource() } def unregisterResource(resource: Resource): Unit = { resources -= resource } - def initResources(): Unit = { - if (!initialized) { - forEach(_.initResource()) - initialized = true + def checkEmpty(): Unit = { + if (resources.isEmpty) + return + + logger.error(s"${resources.length} resources have not been freed properly") + + for ((groupClass, groupResources) <- resources.groupBy(_.getClass)) { + logger.error(s" - ${groupResources.length} resources of type ${groupClass.getName}") } } - - def forEach(predicate: Resource => Unit): Unit = { - resources.foreach(predicate) - } - - def freeResource(resource: Resource): Unit = { - resource.freeResource() - unregisterResource(resource) - } - - def freeResources(): Unit = { - forEach(_.freeResource()) - resources.clear() - } } diff --git a/src/main/scala/ocelot/desktop/util/SettingsData.scala b/src/main/scala/ocelot/desktop/util/SettingsData.scala index 021fc0d..53720fd 100644 --- a/src/main/scala/ocelot/desktop/util/SettingsData.scala +++ b/src/main/scala/ocelot/desktop/util/SettingsData.scala @@ -1,8 +1,15 @@ package ocelot.desktop.util import ocelot.desktop.Settings.Int2D +import ocelot.desktop.util.SettingsData.Fields + +import scala.reflect.runtime.universe class SettingsData { + // implementation notes: + // this class relies on reflection to implement updateWith + // it's assumed all fields that should be copied are declared as **public var** + def this(data: SettingsData) { this() updateWith(data) @@ -12,11 +19,17 @@ class SettingsData { var volumeBeep: Float = 1f var volumeEnvironment: Float = 1f var volumeInterface: Float = 0.5f + var audioDisable: Boolean = false + var logAudioErrorStacktrace: Boolean = false + var scaleFactor: Float = 1f var windowSize: Int2D = new Int2D() var windowPosition: Int2D = new Int2D() var windowValidatePosition: Boolean = true var windowFullscreen: Boolean = false + var disableVsync: Boolean = false + var debugLwjgl: Boolean = false + var useNativeFileChooser: Boolean = true var recentWorkspace: Option[String] = None @@ -24,21 +37,24 @@ class SettingsData { var saveOnExit: Boolean = true var openLastWorkspace: Boolean = true + private val mirror = universe.runtimeMirror(getClass.getClassLoader).reflect(this) + def updateWith(data: SettingsData): Unit = { - // TODO: maybe apply some automated mapping solution - // TODO: please do that ☝ - this.volumeMaster = data.volumeMaster - this.volumeBeep = data.volumeBeep - this.volumeEnvironment = data.volumeEnvironment - this.volumeInterface = data.volumeInterface - - this.windowPosition = data.windowPosition - this.windowValidatePosition = data.windowValidatePosition - this.windowSize = data.windowSize - this.windowFullscreen = data.windowFullscreen - - this.stickyWindows = data.stickyWindows - this.saveOnExit = data.saveOnExit - this.openLastWorkspace = data.openLastWorkspace + for (fieldSym <- Fields) { + val value = data.mirror.reflectMethod(fieldSym.getter.asMethod)() + mirror.reflectMethod(fieldSym.setter.asMethod)(value) + } } } + +object SettingsData { + private val Fields = + universe.typeOf[SettingsData] + .decls + .filter(_.isTerm) + .map(_.asTerm) + .filter(_.isVar) + // the var itself is private: check setter and getter + .filter(v => v.setter.isPublic && v.getter.isPublic) + .toList +} diff --git a/src/main/scala/ocelot/desktop/util/Spritesheet.scala b/src/main/scala/ocelot/desktop/util/Spritesheet.scala index f58fe60..042e529 100644 --- a/src/main/scala/ocelot/desktop/util/Spritesheet.scala +++ b/src/main/scala/ocelot/desktop/util/Spritesheet.scala @@ -7,7 +7,7 @@ import javax.imageio.ImageIO import scala.collection.mutable import scala.io.Source -object Spritesheet extends Logging { +object Spritesheet extends Resource with Logging { val sprites = new mutable.HashMap[String, Rect2D]() var texture: Texture = _ @@ -40,4 +40,9 @@ object Spritesheet extends Logging { logger.info(s"Loaded ${sprites.size} sprites") } + + override def freeResource(): Unit = { + super.freeResource() + texture.freeResource() + } } diff --git a/src/main/scala/ocelot/desktop/util/TierColor.scala b/src/main/scala/ocelot/desktop/util/TierColor.scala index f965856..7012711 100644 --- a/src/main/scala/ocelot/desktop/util/TierColor.scala +++ b/src/main/scala/ocelot/desktop/util/TierColor.scala @@ -1,7 +1,8 @@ package ocelot.desktop.util import ocelot.desktop.ColorScheme -import ocelot.desktop.color.{Color, RGBAColorNorm} +import ocelot.desktop.color.RGBAColorNorm +import totoro.ocelot.brain.util.Tier.Tier object TierColor { val Tier0: RGBAColorNorm = ColorScheme("Tier0") @@ -11,5 +12,5 @@ object TierColor { val Tiers: Array[RGBAColorNorm] = Array(Tier0, Tier1, Tier2, Tier3) - def get(tier: Int): RGBAColorNorm = if (tier >= 0 && tier <= 3) Tiers(tier.min(3)) else Color.White + def get(tier: Tier): RGBAColorNorm = Tiers(tier.id) } diff --git a/src/main/scala/ocelot/desktop/util/Transaction.scala b/src/main/scala/ocelot/desktop/util/Transaction.scala new file mode 100644 index 0000000..e2160ee --- /dev/null +++ b/src/main/scala/ocelot/desktop/util/Transaction.scala @@ -0,0 +1,63 @@ +package ocelot.desktop.util + +import scala.collection.mutable.ArrayBuffer + +class Transaction protected() { + private val cleanupCallbacks = ArrayBuffer.empty[() => Unit] + + def onFailure(f: => Unit): Unit = { + cleanupCallbacks += f _ + } + + protected def runCleanupCallbacks(exc: Exception): exc.type = { + for (callback <- cleanupCallbacks) { + try { + callback() + } catch { + case cleanupExc: Exception => + exc.addSuppressed(cleanupExc) + } + } + + exc + } +} + +object Transaction { + private[Transaction] case class AbortedException(tx: AbortableTransaction) extends RuntimeException + + class AbortableTransaction protected[Transaction]() extends Transaction { + def abort(): Nothing = { + throw Transaction.AbortedException(this) + } + } + + def run[T](f: Transaction => T): T = { + val tx = new Transaction + + try { + f(tx) + } catch { + case exc: Exception => throw tx.runCleanupCallbacks(exc) + } + } + + def runAbortable[T](f: AbortableTransaction => T): Option[T] = { + val tx = new AbortableTransaction + + try { + Some(f(tx)) + } catch { + case exc @ AbortedException(`tx`) => + val suppressed = tx.runCleanupCallbacks(exc).getSuppressed + + if (suppressed.nonEmpty) { + throw exc + } + + None + case exc: Exception => + throw tx.runCleanupCallbacks(exc) + } + } +} diff --git a/src/main/scala/ocelot/desktop/util/WebcamCapture.scala b/src/main/scala/ocelot/desktop/util/WebcamCapture.scala index b37b2aa..d5558ed 100644 --- a/src/main/scala/ocelot/desktop/util/WebcamCapture.scala +++ b/src/main/scala/ocelot/desktop/util/WebcamCapture.scala @@ -1,6 +1,6 @@ package ocelot.desktop.util -import com.github.sarxos.webcam.{Webcam} +import com.github.sarxos.webcam.Webcam import java.awt.Color import java.awt.image.BufferedImage @@ -14,23 +14,24 @@ object WebcamCapture extends Logging { private final val InvGamma: Float = 1f / 2.2f private val instances = new mutable.HashMap[String, WebcamCapture] - def getInstance(name: String): WebcamCapture = { + def getInstance(name: String): Option[WebcamCapture] = { if (instances.contains(name)) - return instances(name) + return Some(instances(name)) - val webcam = Webcam.getWebcamByName(name) - if (webcam == null) { - logger.warn(s"No such webcam: $name") - return getDefault + Option(Webcam.getWebcamByName(name)) match { + case Some(webcam) => + val webcamCapture = new WebcamCapture(webcam) + instances(name) = webcamCapture + Some(webcamCapture) + + case None => + logger.warn(s"No such webcam: $name") + None } - - val webcamCapture = new WebcamCapture(webcam) - instances(name) = webcamCapture - webcamCapture } - def getInstance(webcam: Webcam): WebcamCapture = getInstance(webcam.getName) - def getDefault: WebcamCapture = getInstance(Webcam.getDefault) + def getInstance(webcam: Webcam): Option[WebcamCapture] = getInstance(webcam.getName) + def getDefault: Option[WebcamCapture] = Option(Webcam.getDefault).flatMap(getInstance) def cleanup(): Unit = { for (instance <- instances.values) @@ -40,7 +41,7 @@ object WebcamCapture extends Logging { instance.join() } - def toLinear(value: Int): Float = Math.pow(value / 255f, InvGamma).toFloat + private def toLinear(value: Int): Float = Math.pow(value / 255f, InvGamma).toFloat } class WebcamCapture(private val webcam: Webcam) extends Thread(s"WebcamCaptureThread-${webcam.getName}") with Logging { diff --git a/src/main/scala/ocelot/desktop/util/package.scala b/src/main/scala/ocelot/desktop/util/package.scala index c596766..5b9d54c 100644 --- a/src/main/scala/ocelot/desktop/util/package.scala +++ b/src/main/scala/ocelot/desktop/util/package.scala @@ -3,7 +3,7 @@ package ocelot.desktop import java.util.concurrent.locks.Lock package object util { - final def withLockAcquired[T](lock: Lock, f: => T): T = { + final def withLockAcquired[T](lock: Lock)(f: => T): T = { lock.lock() try { diff --git a/src/main/scala/ocelot/desktop/windows/CameraWindow.scala b/src/main/scala/ocelot/desktop/windows/CameraWindow.scala index 00b875d..12404a7 100644 --- a/src/main/scala/ocelot/desktop/windows/CameraWindow.scala +++ b/src/main/scala/ocelot/desktop/windows/CameraWindow.scala @@ -19,7 +19,7 @@ class CameraWindow(cameraNode: CameraNode) extends BasicWindow { children :+= new PaddingBox(new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - val label = new Label { + private val label = new Label { override def text: String = s"Camera — ${cameraNode.labelOrAddress}" override val isSmall: Boolean = true } @@ -29,12 +29,15 @@ class CameraWindow(cameraNode: CameraNode) extends BasicWindow { override def minimumSize: Size2D = Size2D(256, 24) override def maximumSize: Size2D = Size2D(512, 24) override def size: Size2D = Size2D(label.size.width - 8, 24) - override def text: String = camera.webcamCapture.name + override def text: String = camera.webcamCapture.map(_.name).getOrElse("") override def onClick(): Unit = { val menu = new ContextMenu - for (webcam <- Webcam.getWebcams().asScala) - menu.addEntry(new ContextMenuEntry(webcam.getName, () => cameraNode.camera.webcamCapture = WebcamCapture.getInstance(webcam))) + for (webcam <- Webcam.getWebcams().asScala) { + menu.addEntry(ContextMenuEntry(webcam.getName) { + cameraNode.camera.webcamCapture = WebcamCapture.getInstance(webcam) + }) + } root.get.contextMenus.open(menu, Vector2D(position.x, position.y + size.height)) } diff --git a/src/main/scala/ocelot/desktop/windows/ComputerWindow.scala b/src/main/scala/ocelot/desktop/windows/ComputerWindow.scala index 19f5c91..498afe0 100644 --- a/src/main/scala/ocelot/desktop/windows/ComputerWindow.scala +++ b/src/main/scala/ocelot/desktop/windows/ComputerWindow.scala @@ -7,7 +7,7 @@ import ocelot.desktop.graphics.Graphics import ocelot.desktop.node.nodes.ComputerNode import ocelot.desktop.ui.layout.{Layout, LinearLayout} import ocelot.desktop.ui.widget._ -import ocelot.desktop.ui.widget.tooltip.Tooltip +import ocelot.desktop.ui.widget.tooltip.{LabelTooltip, Tooltip} import ocelot.desktop.ui.widget.window.BasicWindow import ocelot.desktop.util.animation.UnitAnimation import ocelot.desktop.util.{DrawUtils, Orientation} @@ -15,101 +15,20 @@ import ocelot.desktop.{ColorScheme, OcelotDesktop} import totoro.ocelot.brain.entity.Case import totoro.ocelot.brain.nbt.NBTTagCompound +import scala.collection.immutable.ArraySeq + class ComputerWindow(computerNode: ComputerNode) extends BasicWindow { + private var bottomDrawer: Widget = _ + def computer: Case = computerNode.computer - def updateSlots(): Unit = { - eepromBox.children(0) = computerNode.eepromSlot - inner.children(1).children(2) = slotsWidget + def reloadWindow(): Unit = { + children = makeChildren() } - private val eepromBox = new PaddingBox(computerNode.eepromSlot, Padding2D(right = 10)) private val bottomDrawerAnimation = UnitAnimation.easeInOutQuad(0.2f) bottomDrawerAnimation.goDown() - private val bottomDrawer = new Widget { - override def shouldClip: Boolean = true - override def minimumSize: Size2D = Size2D(layout.minimumSize.width, 0) - - children :+= new PaddingBox(new Widget { - children :+= new PaddingBox(new Widget { - override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - - class PerTickHistogram extends Histogram { - private var lastTick = OcelotDesktop.ticker.tick - - def tickUpdate(): Unit = {} - - def reset(): Unit = { - text = "N/A" - history = Seq.fill(21)(0.0f).toArray - } - - override def update(): Unit = { - super.update() - val curTick = OcelotDesktop.ticker.tick - if (curTick > lastTick) { - if (!computer.machine.isRunning) reset() - lastTick = curTick - tickUpdate() - } - } - } - - children :+= new PerTickHistogram { - override def tickUpdate(): Unit = { - val (free, total) = computer.machine.latestMemoryUsage - val used = total - free - - val ratio = if (total == 0) 0 else used.toFloat / total.toFloat - - text = f"${used.toFloat / 1024f / 1024f}%.1fM" - history = history.slice(1, history.length) ++ Array(ratio) - } - override protected val tooltip: Option[Tooltip] = Some( - new LabelTooltip("Memory usage (in Mb)") - ) - } - - children :+= new PaddingBox(new PerTickHistogram { - override def tickUpdate(): Unit = { - val (start, _end) = computer.machine.latestExecutionInfo - val end = if (start < _end) _end else System.nanoTime() - val cpuUsage = if (start == 0) 0 else ((end - start).toFloat / 1000000000f * OcelotDesktop.tpsCounter.fps).min(1f) - text = f"${cpuUsage * 100}%.0f%%" - history = history.slice(1, history.length) ++ Array(cpuUsage) - } - override protected val tooltip: Option[Tooltip] = Some( - new LabelTooltip("CPU usage (in %)") - ) - }, Padding2D(top = 8)) - - children :+= new PaddingBox(new PerTickHistogram { - override def tickUpdate(): Unit = { - val (budget, maxBudget) = computer.machine.latestCallBudget - text = f"${maxBudget - budget}%.1f" - history = history.slice(1, history.length) ++ Array((1.0 - budget / maxBudget).toFloat) - } - override protected val tooltip: Option[Tooltip] = Some( - new LabelTooltip("CPU usage (call budget)") - ) - }, Padding2D(top = 8)) - }, Padding2D.equal(7)) - - override def draw(g: Graphics): Unit = { - DrawUtils.panel(g, position.x, position.y, width, height) - super.draw(g) - } - }, Padding2D(10, 12, 0, 12)) - - override def draw(g: Graphics): Unit = { - if (height < 1) return - - super.draw(g) - g.rect(position.x + 6, position.y + 2, width - 12, 2, ColorScheme("BottomDrawerBorder")) - } - } - private val drawerButton = new IconButton( "buttons/BottomDrawerOpen", "buttons/BottomDrawerClose", isSwitch = true, tooltip = Some("Toggle computer usage histogram") @@ -118,69 +37,169 @@ class ComputerWindow(computerNode: ComputerNode) extends BasicWindow { override def onReleased(): Unit = bottomDrawerAnimation.goDown() } - private val inner = new Widget { - override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) + private def makeChildren(): ArraySeq[Widget] = { + var children = ArraySeq.empty[Widget] - children :+= new PaddingBox(new Label { - override def text: String = computerNode.labelOrAddress.take(36) - override def isSmall: Boolean = true - override def color: Color = ColorScheme("ComputerAddress") - }, Padding2D(bottom = 8)) + val slotsWidget = { + val rows = Array( + (19, computerNode.cardSlots), + (8, Array(computerNode.cpuSlot) ++ computerNode.memorySlots), + (8, computerNode.diskSlots ++ computerNode.floppySlot.toArray) + ) - children :+= new Widget { - children :+= new PaddingBox(drawerButton, Padding2D(top = 120)) + new Widget { + for ((padding, row) <- rows) { + children :+= new PaddingBox(new Widget { + override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) + for (slot <- row) children :+= slot + }, Padding2D(left = padding.toFloat, top = 8)) + } + + override def minimumSize: Size2D = Size2D(158, 140) + + override def draw(g: Graphics): Unit = { + g.sprite("ComputerMotherboard", bounds.mapX(_ - 4).mapW(_ - 4)) + drawChildren(g) + } + } + } + + val inner = new Widget { + override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) + + children :+= new PaddingBox(new Label { + override def text: String = computerNode.labelOrAddress.take(36) + + override def isSmall: Boolean = true + + override def color: Color = ColorScheme("ComputerAddress") + }, Padding2D(bottom = 8)) + + children :+= new Widget { + children :+= new PaddingBox(drawerButton, Padding2D(top = 120)) + + children :+= new PaddingBox(new Widget { + children :+= new PaddingBox(computerNode.eepromSlot, Padding2D(right = 10)) + + children :+= new IconButton( + "buttons/PowerOff", + "buttons/PowerOn", + isSwitch = true, + sizeMultiplier = 2 + ) { + override def isOn: Boolean = computer.machine.isRunning + + override def onPressed(): Unit = computerNode.turnOn() + + override def onReleased(): Unit = computerNode.turnOff() + + protected override def clickSoundSource: SoundSource = SoundSources.MinecraftClick + } + }, Padding2D(top = 44, left = 22)) + + children :+= slotsWidget + } + } + + bottomDrawer = new Widget { + override def shouldClip: Boolean = true + + override def minimumSize: Size2D = Size2D(layout.minimumSize.width, 0) children :+= new PaddingBox(new Widget { - children :+= new PaddingBox(computerNode.eepromSlot, Padding2D(right = 10)) - - children :+= new IconButton( - "buttons/PowerOff", - "buttons/PowerOn", - isSwitch = true, - sizeMultiplier = 2 - ) { - override def isOn: Boolean = computer.machine.isRunning - override def onPressed(): Unit = computerNode.turnOn() - override def onReleased(): Unit = computerNode.turnOff() - protected override def clickSoundSource: SoundSource = SoundSources.MinecraftClick - } - }, Padding2D(top = 44, left = 22)) - - children :+= slotsWidget - } - } - - children :+= new PaddingBox(new Widget { - override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - - children :+= new PaddingBox(inner, Padding2D(10, 12, 0, 12)) - children :+= bottomDrawer - }, Padding2D(bottom = 10)) - - private def slotsWidget: Widget = { - val rows = Array( - (19, computerNode.cardSlots), - (8, Array(computerNode.cpuSlot) ++ computerNode.memorySlots), - (8, computerNode.diskSlots ++ computerNode.floppySlot.toArray) - ) - - new Widget { - for ((padding, row) <- rows) { children :+= new PaddingBox(new Widget { override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) - for (slot <- row) children :+= slot - }, Padding2D(left = padding, top = 8)) - } - override def minimumSize: Size2D = Size2D(158, 140) + class PerTickHistogram extends Histogram { + private var lastTick = OcelotDesktop.ticker.tick + + def tickUpdate(): Unit = {} + + def reset(): Unit = { + text = "N/A" + history = Seq.fill(21)(0.0f).toArray + } + + override def update(): Unit = { + super.update() + val curTick = OcelotDesktop.ticker.tick + if (curTick > lastTick) { + if (!computer.machine.isRunning) reset() + lastTick = curTick + tickUpdate() + } + } + } + + children :+= new PerTickHistogram { + override def tickUpdate(): Unit = { + val (free, total) = computer.machine.latestMemoryUsage + val used = total - free + + val ratio = if (total == 0) 0 else used.toFloat / total.toFloat + + text = f"${used.toFloat / 1024f / 1024f}%.1fM" + history = history.slice(1, history.length) ++ Array(ratio) + } + + override protected val tooltip: Option[Tooltip] = Some( + new LabelTooltip("Memory usage (in Mb)") + ) + } + + children :+= new PaddingBox(new PerTickHistogram { + override def tickUpdate(): Unit = { + val (start, _end) = computer.machine.latestExecutionInfo + val end = if (start < _end) _end else System.nanoTime() + val cpuUsage = if (start == 0) 0 else ((end - start).toFloat / 1000000000f * OcelotDesktop.tpsCounter.fps).min(1f) + text = f"${cpuUsage * 100}%.0f%%" + history = history.slice(1, history.length) ++ Array(cpuUsage) + } + + override protected val tooltip: Option[Tooltip] = Some( + new LabelTooltip("CPU usage (in %)") + ) + }, Padding2D(top = 8)) + + children :+= new PaddingBox(new PerTickHistogram { + override def tickUpdate(): Unit = { + val (budget, maxBudget) = computer.machine.latestCallBudget + text = f"${maxBudget - budget}%.1f" + history = history.slice(1, history.length) ++ Array((1.0 - budget / maxBudget).toFloat) + } + + override protected val tooltip: Option[Tooltip] = Some( + new LabelTooltip("CPU usage (call budget)") + ) + }, Padding2D(top = 8)) + }, Padding2D.equal(7)) + + override def draw(g: Graphics): Unit = { + DrawUtils.panel(g, position.x, position.y, width, height) + super.draw(g) + } + }, Padding2D(10, 12, 0, 12)) override def draw(g: Graphics): Unit = { - g.sprite("ComputerMotherboard", bounds.mapX(_ - 4).mapW(_ - 4)) - drawChildren(g) + if (height < 1) return + + super.draw(g) + g.rect(position.x + 6, position.y + 2, width - 12, 2, ColorScheme("BottomDrawerBorder")) } } + + children :+= new PaddingBox(new Widget { + override protected val layout: Layout = new LinearLayout(this, orientation = Orientation.Vertical) + + children :+= new PaddingBox(inner, Padding2D(10, 12, 0, 12)) + children :+= bottomDrawer + }, Padding2D(bottom = 10)) + + children } + children = makeChildren() + override def update(): Unit = { super.update() bottomDrawerAnimation.update() diff --git a/src/main/scala/ocelot/desktop/windows/OpenFMRadioWindow.scala b/src/main/scala/ocelot/desktop/windows/OpenFMRadioWindow.scala index 92f906d..ff82050 100644 --- a/src/main/scala/ocelot/desktop/windows/OpenFMRadioWindow.scala +++ b/src/main/scala/ocelot/desktop/windows/OpenFMRadioWindow.scala @@ -54,7 +54,7 @@ class OpenFMRadioWindow(radioNode: OpenFMRadioNode) extends BasicWindow { ) } - addVolumeUpOrDownButton(false, 0) + addVolumeUpOrDownButton(isUp = false, 0) // Volume label children :+= new PaddingBox( @@ -66,7 +66,7 @@ class OpenFMRadioWindow(radioNode: OpenFMRadioNode) extends BasicWindow { Padding2D(2, 0, 0, 20) ) - addVolumeUpOrDownButton(true, 8) + addVolumeUpOrDownButton(isUp = true, 8) // Redstone button children :+= new PaddingBox( diff --git a/src/main/scala/ocelot/desktop/windows/ScreenWindow.scala b/src/main/scala/ocelot/desktop/windows/ScreenWindow.scala index 4fb89ef..b2b0448 100644 --- a/src/main/scala/ocelot/desktop/windows/ScreenWindow.scala +++ b/src/main/scala/ocelot/desktop/windows/ScreenWindow.scala @@ -9,6 +9,7 @@ import ocelot.desktop.ui.event.sources.{KeyEvents, MouseEvents} import ocelot.desktop.ui.event.{DragEvent, KeyEvent, MouseEvent, ScrollEvent} import ocelot.desktop.ui.widget.window.BasicWindow import ocelot.desktop.util.{DrawUtils, Logging} +import ocelot.desktop.windows.ScreenWindow.{BorderBottom, BorderHorizontal, BorderLeft, BorderRight, BorderTop, BorderVertical} import ocelot.desktop.{ColorScheme, OcelotDesktop} import org.apache.commons.lang3.StringUtils import org.lwjgl.input.Keyboard @@ -44,7 +45,10 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { private def screenHeight: Int = screen.getHeight - override def minimumSize: Size2D = Size2D(screenWidth * fontWidth * scaleX + 32, screenHeight * scaleY * fontHeight + 36) + override def minimumSize: Size2D = Size2D( + screenWidth * fontWidth * scaleX + BorderHorizontal, + screenHeight * scaleY * fontHeight + BorderVertical + ) override def receiveScrollEvents: Boolean = true @@ -102,6 +106,11 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { val sx = point.x - mousePos.x val sy = point.y - mousePos.y + val uiScale = UiHandler.scalingFactor + var newScale = scale + + // TODO: refactor this mess, make it consider both sizes and not have two nearby slightly off "snap points" + if (sx.abs > sy.abs) { val newWidth = startingWidth - sx val maxWidth = screenWidth * fontWidth @@ -110,15 +119,10 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { if (!KeyEvents.isDown(Keyboard.KEY_LSHIFT) && scale <= 1.001) midScale = midScale.min(1f) - var lowScale = (fontWidth * midScale).floor / fontWidth - val highScale = (fontWidth * midScale).ceil / fontWidth + val lowScale = (fontWidth * midScale * uiScale).floor / fontWidth / uiScale + val highScale = (fontWidth * midScale * uiScale).ceil / fontWidth / uiScale - // enforce minimal screen size - if (lowScale < 0.3f) { - lowScale = 0.25f - } - - scale = if (midScale - lowScale > highScale - midScale) highScale else lowScale + newScale = if (midScale - lowScale > highScale - midScale) highScale else lowScale } else { val newHeight = startingWidth * (screenHeight * fontHeight / screenWidth / fontWidth) - sy @@ -128,9 +132,18 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { if (!KeyEvents.isDown(Keyboard.KEY_LSHIFT) && scale <= 1.001) midScale = midScale.min(1f) - val lowScale = (fontHeight * midScale).floor / fontHeight - val highScale = (fontHeight * midScale).ceil / fontHeight - scale = if (midScale - lowScale > highScale - midScale) highScale else lowScale + val lowScale = (fontHeight * midScale * uiScale).floor / fontHeight / uiScale + val highScale = (fontHeight * midScale * uiScale).ceil / fontHeight / uiScale + + newScale = if (midScale - lowScale > highScale - midScale) highScale else lowScale + } + + if (newScale != scale) + scale = newScale + + // enforce minimal screen size + if (scale <= 0.249f) { + scale = 0.25f } } @@ -152,8 +165,8 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { override def show(): Unit = { scale = math.min( - ((UiHandler.root.width - 16) / (screenWidth * fontWidth + 32)).min(1f).max(0f), - ((UiHandler.root.height - 32) / (screenHeight * fontHeight + 36)).min(1f).max(0f) + ((UiHandler.root.width - BorderLeft) / (screenWidth * fontWidth + BorderHorizontal)).min(1f).max(0f), + ((UiHandler.root.height - BorderTop) / (screenHeight * fontHeight + BorderVertical)).min(1f).max(0f) ) super.show() @@ -164,25 +177,32 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { private def convertMousePos(p: Vector2D): Vector2D = { if (screenNode.screen.getPrecisionMode) { Vector2D( - (p.x - 16f - position.x) / fontWidth / scaleX, - (p.y - 16f - position.y) / fontHeight / scaleY + (p.x - BorderLeft - position.x) / fontWidth / scaleX, + (p.y - BorderTop - position.y) / fontHeight / scaleY ) } else { Vector2D( - math.floor((p.x - 16f - position.x) / fontWidth / scaleX), - math.floor((p.y - 16f - position.y) / fontHeight / scaleY) + math.floor((p.x - BorderLeft - position.x) / fontWidth / scaleX), + math.floor((p.y - BorderTop - position.y) / fontHeight / scaleY) ) } } override protected def dragRegions: Iterator[Rect2D] = Iterator( - Rect2D(position.x, position.y, size.width, 20), - Rect2D(position.x, position.y + size.height - 16, size.width - 16, 16), - Rect2D(position.x, position.y, 16, size.height), - Rect2D(position.x + size.width - 16, position.y, 16, size.height - 16), + Rect2D(position.x, position.y, size.width, BorderTop.toFloat), + Rect2D(position.x, position.y, BorderLeft.toFloat, size.height), + + // these two must not include `scaleDragRegion` + Rect2D(position.x, position.y + size.height - BorderBottom, size.width - BorderRight, BorderBottom.toFloat), + Rect2D(position.x + size.width - BorderRight, position.y, BorderRight.toFloat, size.height - BorderBottom), ) - private def scaleDragRegion: Rect2D = Rect2D(position.x + size.width - 16, position.y + size.height - 16, 16, 16) + private def scaleDragRegion: Rect2D = Rect2D( + position.x + size.width - BorderRight, + position.y + size.height - BorderBottom, + BorderRight.toFloat, + BorderBottom.toFloat, + ) override def update(): Unit = { super.update() @@ -210,8 +230,8 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { override def draw(g: Graphics): Unit = { beginDraw(g) - val startX = position.x + 16 - val startY = position.y + 20 + val startX = position.x + BorderLeft + val startY = position.y + BorderTop val windowWidth = screenWidth * fontWidth * scaleX val windowHeight = screenHeight * fontHeight * scaleY @@ -263,3 +283,13 @@ class ScreenWindow(screenNode: ScreenNode) extends BasicWindow with Logging { endDraw(g) } } + +object ScreenWindow { + private val BorderTop = 20 + private val BorderLeft = 16 + private val BorderRight = 16 + private val BorderBottom = 16 + + private val BorderVertical = BorderTop + BorderBottom + private val BorderHorizontal = BorderLeft + BorderRight +}