diff --git a/common/src/main/scala/hexacraft/util/events.scala b/common/src/main/scala/hexacraft/util/events.scala index fd0f20f1..fce1f13a 100644 --- a/common/src/main/scala/hexacraft/util/events.scala +++ b/common/src/main/scala/hexacraft/util/events.scala @@ -2,7 +2,6 @@ package hexacraft.util import scala.collection.mutable import scala.collection.mutable.ArrayBuffer -import scala.reflect.ClassTag trait Tracker[E] { def notify(event: E): Unit @@ -86,11 +85,4 @@ object Channel { tx.rx = rx (tx, rx) } - - inline def wrap[E] = [T] => - (wrapFn: Sender[E] => T) => { - val (tx, rx) = Channel[E]() - val value = wrapFn(tx) - (value, rx) - } } diff --git a/game/src/main/scala/hexacraft/game/Menus.scala b/game/src/main/scala/hexacraft/game/Menus.scala index c6814a51..03319e68 100644 --- a/game/src/main/scala/hexacraft/game/Menus.scala +++ b/game/src/main/scala/hexacraft/game/Menus.scala @@ -46,6 +46,15 @@ object Menus { case class WorldInfo(saveFile: File, name: String) + // These classes exist because the router tests use an "instance of" check. Those tests should really be checking for something else. + class MainMenu private extends MenuScene + class HostWorldChooserMenu private extends MenuScene + class JoinWorldChooserMenu private extends MenuScene + class MultiplayerMenu private extends MenuScene + class WorldChooserMenu private extends MenuScene + class NewWorldMenu private extends MenuScene + + // Below are the factories for many of the menus in the game object MainMenu { enum Event { case Play @@ -53,26 +62,30 @@ object Menus { case Settings case Quit } - } - class MainMenu(multiplayerEnabled: Boolean)(onEvent: Channel.Sender[MainMenu.Event]) extends MenuScene { - import MainMenu.Event + def create(multiplayerEnabled: Boolean): (MainMenu, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() - addComponent(new Label("Hexacraft", LocationInfo.from16x9(0, 0.8f, 1, 0.2f), 10).withColor(1, 1, 1)) - addComponent(Button("Play", LocationInfo.from16x9(0.4f, 0.55f, 0.2f, 0.1f))(onEvent.send(Event.Play))) + val menu = new MainMenu - if multiplayerEnabled then { - addComponent( - Button("Multiplayer", LocationInfo.from16x9(0.4f, 0.4f, 0.2f, 0.1f))(onEvent.send(Event.Multiplayer)) - ) - } + menu.addComponent(new Label("Hexacraft", LocationInfo.from16x9(0, 0.8f, 1, 0.2f), 10).withColor(1, 1, 1)) + menu.addComponent(Button("Play", LocationInfo.from16x9(0.4f, 0.55f, 0.2f, 0.1f))(tx.send(Event.Play))) + + if multiplayerEnabled then { + menu.addComponent( + Button("Multiplayer", LocationInfo.from16x9(0.4f, 0.4f, 0.2f, 0.1f))(tx.send(Event.Multiplayer)) + ) + } - addComponent( - Button("Settings", LocationInfo.from16x9(0.4f, if multiplayerEnabled then 0.25f else 0.4f, 0.2f, 0.1f))( - onEvent.send(Event.Settings) + menu.addComponent( + Button("Settings", LocationInfo.from16x9(0.4f, if multiplayerEnabled then 0.25f else 0.4f, 0.2f, 0.1f))( + tx.send(Event.Settings) + ) ) - ) - addComponent(Button("Quit", LocationInfo.from16x9(0.4f, 0.05f, 0.2f, 0.1f))(onEvent.send(Event.Quit))) + menu.addComponent(Button("Quit", LocationInfo.from16x9(0.4f, 0.05f, 0.2f, 0.1f))(tx.send(Event.Quit))) + + (menu, rx) + } } object HostWorldChooserMenu { @@ -81,35 +94,22 @@ object Menus { case GoBack } - def create(saveFolder: File, fs: FileSystem): (HostWorldChooserMenu, Channel.Receiver[Event]) = { - Channel.wrap[Event](tx => new HostWorldChooserMenu(saveFolder, fs)(tx)) - } - } - - class HostWorldChooserMenu(saveFolder: File, fs: FileSystem)(onEvent: Channel.Sender[HostWorldChooserMenu.Event]) - extends MenuScene { + def create(saveFolder: File, fs: FileSystem): (MenuScene, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() - import HostWorldChooserMenu.Event + val menu = new HostWorldChooserMenu - addComponent(new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1)) + val worlds = getWorlds(saveFolder, fs) + val scrollPane = makeScrollPane(worlds, tx) - private val scrollPane = new ScrollPane(LocationInfo.from16x9(0.285f, 0.225f, 0.43f, 0.635f), 0.025f * 2) + menu.addComponent(new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1)) + menu.addComponent(scrollPane) + menu.addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.4f, 0.1f))(tx.send(Event.GoBack))) - for (f, i) <- getWorlds.zipWithIndex do { - scrollPane.addComponent( - Button(f.name, LocationInfo.from16x9(0.3f, 0.75f - 0.1f * i, 0.4f, 0.075f)) { - onEvent.send(Event.Host(f)) - // TODO: the network manager should repeatedly connect to the server registry. - // This will be blocking until a client wants to connect or after a timeout - // If this is not done in a certain time period the server will be deregistered from the server registry - } - ) + (menu, rx) } - addComponent(scrollPane) - - addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.4f, 0.1f))(onEvent.send(Event.GoBack))) - private def getWorlds: Seq[WorldInfo] = { + private def getWorlds(saveFolder: File, fs: FileSystem): Seq[WorldInfo] = { val baseFolder = new File(saveFolder, "saves") if baseFolder.exists() then { baseFolder @@ -121,6 +121,23 @@ object Menus { Seq.empty[WorldInfo] } } + + private def makeScrollPane(worlds: Seq[WorldInfo], tx: Channel.Sender[Event]) = { + val scrollPane = new ScrollPane(LocationInfo.from16x9(0.285f, 0.225f, 0.43f, 0.635f), 0.025f * 2) + + for (f, i) <- worlds.zipWithIndex do { + scrollPane.addComponent( + Button(f.name, LocationInfo.from16x9(0.3f, 0.75f - 0.1f * i, 0.4f, 0.075f)) { + tx.send(Event.Host(f)) + // TODO: the network manager should repeatedly connect to the server registry. + // This will be blocking until a client wants to connect or after a timeout + // If this is not done in a certain time period the server will be deregistered from the server registry + } + ) + } + + scrollPane + } } object JoinWorldChooserMenu { @@ -129,32 +146,27 @@ object Menus { case GoBack } - private case class OnlineWorldInfo(id: Long, name: String, description: String) - - private case class OnlineWorldConnectionDetails(address: String, port: Int, time: Long) - } + def create(): (MenuScene, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() - class JoinWorldChooserMenu(onEvent: Channel.Sender[JoinWorldChooserMenu.Event]) extends MenuScene { + val scrollPane = new ScrollPane(LocationInfo.from16x9(0.285f, 0.225f, 0.43f, 0.635f), 0.025f * 2) - import JoinWorldChooserMenu.* - - addComponent(new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1)) - private val scrollPane = new ScrollPane(LocationInfo.from16x9(0.285f, 0.225f, 0.43f, 0.635f), 0.025f * 2) - addComponent(scrollPane) - - addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.4f, 0.1f))(onEvent.send(Event.GoBack))) - - updateServerList() - - private def updateServerList(): Unit = { for (f, i) <- getWorlds.zipWithIndex do { scrollPane.addComponent( Button(f.name, LocationInfo.from16x9(0.3f, 0.75f - 0.1f * i, 0.4f, 0.075f)) { val connectionDetails = loadOnlineWorld(f.id) - onEvent.send(Event.Join(connectionDetails.address, connectionDetails.port)) + tx.send(Event.Join(connectionDetails.address, connectionDetails.port)) } ) } + + val menu = new JoinWorldChooserMenu + + menu.addComponent(new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1)) + menu.addComponent(scrollPane) + menu.addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.4f, 0.1f))(tx.send(Event.GoBack))) + + (menu, rx) } private def getWorlds: Seq[OnlineWorldInfo] = { @@ -172,6 +184,10 @@ object Menus { System.currentTimeMillis() + 10 ) } + + private case class OnlineWorldInfo(id: Long, name: String, description: String) + + private case class OnlineWorldConnectionDetails(address: String, port: Int, time: Long) } object MultiplayerMenu { @@ -180,15 +196,18 @@ object Menus { case Host case GoBack } - } - class MultiplayerMenu(onEvent: Channel.Sender[MultiplayerMenu.Event]) extends MenuScene { - import MultiplayerMenu.Event + def create(): (MultiplayerMenu, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() + + val menu = new MultiplayerMenu + menu.addComponent(new Label("Multiplayer", LocationInfo.from16x9(0, 0.8f, 1, 0.2f), 10).withColor(1, 1, 1)) + menu.addComponent(Button("Join", LocationInfo.from16x9(0.4f, 0.55f, 0.2f, 0.1f))(tx.send(Event.Join))) + menu.addComponent(Button("Host", LocationInfo.from16x9(0.4f, 0.4f, 0.2f, 0.1f))(tx.send(Event.Host))) + menu.addComponent(Button("Back", LocationInfo.from16x9(0.4f, 0.05f, 0.2f, 0.1f))(tx.send(Event.GoBack))) - addComponent(new Label("Multiplayer", LocationInfo.from16x9(0, 0.8f, 1, 0.2f), 10).withColor(1, 1, 1)) - addComponent(Button("Join", LocationInfo.from16x9(0.4f, 0.55f, 0.2f, 0.1f))(onEvent.send(Event.Join))) - addComponent(Button("Host", LocationInfo.from16x9(0.4f, 0.4f, 0.2f, 0.1f))(onEvent.send(Event.Host))) - addComponent(Button("Back", LocationInfo.from16x9(0.4f, 0.05f, 0.2f, 0.1f))(onEvent.send(Event.GoBack))) + (menu, rx) + } } class SettingsMenu(onBack: () => Unit) extends MenuScene { @@ -206,49 +225,32 @@ object Menus { case CreateNewWorld case GoBack } - } - - class WorldChooserMenu(saveFolder: File, fs: FileSystem)(onEvent: Channel.Sender[WorldChooserMenu.Event]) - extends MenuScene { - import WorldChooserMenu.Event - addComponent( - new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1) - ) + def create(saveFolder: File, fs: FileSystem): (WorldChooserMenu, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() + val menu = new WorldChooserMenu - addComponent(makeScrollPane) + val worlds = getWorlds(saveFolder, fs) + val scrollPane = makeScrollPane(worlds, tx) - addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.19f, 0.1f)) { - onEvent.send(Event.GoBack) - }) - addComponent(Button("New world", LocationInfo.from16x9(0.51f, 0.05f, 0.19f, 0.1f)) { - onEvent.send(Event.CreateNewWorld) - }) - - private def makeScrollPane: ScrollPane = { - val scrollPaneLocation = LocationInfo.from16x9(0.3f, 0.25f, 0.4f, 0.575f).expand(0.025f * 2) - val scrollPane = new ScrollPane(scrollPaneLocation, 0.025f * 2) - - val buttons = for (f, i) <- getWorlds.zipWithIndex yield makeWorldButton(f, i) - for b <- buttons do { - scrollPane.addComponent(b) - } - - scrollPane - } - - private def makeWorldButton(world: WorldInfo, listIndex: Int): Button = { - val buttonLocation = LocationInfo.from16x9(0.3f, 0.75f - 0.1f * listIndex, 0.4f, 0.075f) - - Button(world.name, buttonLocation) { - onEvent.send(Event.StartGame(world.saveFile, WorldSettings.none)) - } + menu.addComponent( + new Label("Choose world", LocationInfo.from16x9(0, 0.85f, 1, 0.15f), 6).withColor(1, 1, 1) + ) + menu.addComponent(scrollPane) + menu.addComponent(Button("Back to menu", LocationInfo.from16x9(0.3f, 0.05f, 0.19f, 0.1f)) { + tx.send(Event.GoBack) + }) + menu.addComponent(Button("New world", LocationInfo.from16x9(0.51f, 0.05f, 0.19f, 0.1f)) { + tx.send(Event.CreateNewWorld) + }) + + (menu, rx) } - private def getWorlds: Seq[WorldInfo] = { + private def getWorlds(saveFolder: File, fs: FileSystem): Seq[WorldInfo] = { val baseFolder = new File(saveFolder, "saves") if fs.exists(baseFolder.toPath) then { - for saveFile <- saveFoldersSortedBy(baseFolder, p => -fs.lastModified(p).toEpochMilli) yield { + for saveFile <- saveFoldersSortedBy(fs, baseFolder, p => -fs.lastModified(p).toEpochMilli) yield { WorldInfo.fromFile(saveFile.toFile, fs) } } else { @@ -256,13 +258,35 @@ object Menus { } } - private def saveFoldersSortedBy[S](baseFolder: File, sortFunc: Path => S)(using Ordering[S]): Seq[Path] = { + private def saveFoldersSortedBy[S](fs: FileSystem, baseFolder: File, sortFunc: Path => S)(using + Ordering[S] + ): Seq[Path] = { fs.listFiles(baseFolder.toPath) .map(worldFolder => (worldFolder, worldFolder.resolve("world.dat"))) .filter(t => fs.exists(t._2)) .sortBy(t => sortFunc(t._2)) .map(_._1) } + + private def makeScrollPane(worlds: Seq[WorldInfo], tx: Channel.Sender[Event]): ScrollPane = { + val scrollPaneLocation = LocationInfo.from16x9(0.3f, 0.25f, 0.4f, 0.575f).expand(0.025f * 2) + val scrollPane = new ScrollPane(scrollPaneLocation, 0.025f * 2) + + val buttons = for (f, i) <- worlds.zipWithIndex yield makeWorldButton(f, i, tx) + for b <- buttons do { + scrollPane.addComponent(b) + } + + scrollPane + } + + private def makeWorldButton(world: WorldInfo, listIndex: Int, tx: Channel.Sender[Event]): Button = { + val buttonLocation = LocationInfo.from16x9(0.3f, 0.75f - 0.1f * listIndex, 0.4f, 0.075f) + + Button(world.name, buttonLocation) { + tx.send(Event.StartGame(world.saveFile, WorldSettings.none)) + } + } } object NewWorldMenu { @@ -270,52 +294,55 @@ object Menus { case StartGame(saveDir: File, settings: WorldSettings) case GoBack } - } - class NewWorldMenu(saveFolder: File)(onEvent: Channel.Sender[NewWorldMenu.Event]) extends MenuScene { - import NewWorldMenu.Event + def create(saveFolder: File): (NewWorldMenu, Channel.Receiver[Event]) = { + val (tx, rx) = Channel[Event]() + val menu = new NewWorldMenu + + val nameTF = new TextField(LocationInfo.from16x9(0.3f, 0.7f, 0.4f, 0.075f), maxFontSize = 2.5f) + val sizeTF = new TextField(LocationInfo.from16x9(0.3f, 0.55f, 0.4f, 0.075f), maxFontSize = 2.5f) + val seedTF = new TextField(LocationInfo.from16x9(0.3f, 0.4f, 0.4f, 0.075f), maxFontSize = 2.5f) + + val createWorld = () => { + try { + val baseFolder = new File(saveFolder, "saves") + val file = uniqueFile(baseFolder, cleanupFileName(nameTF.text)) + val size = sizeTF.text.toByteOption.filter(s => s >= 0 && s <= 20) + val seed = Some(seedTF.text) + .filter(_.nonEmpty) + .map(s => s.toLongOption.getOrElse(new Random(s.##.toLong << 32 | s.reverse.##).nextLong())) + + tx.send(Event.StartGame(file, WorldSettings(Some(nameTF.text), size, seed))) + } catch { + case _: Exception => + // TODO: complain about the input + } + } - addComponent( - new Label("World name", LocationInfo.from16x9(0.3f, 0.7f + 0.075f, 0.2f, 0.05f), 3f, false) + val nameLabel = new Label("World name", LocationInfo.from16x9(0.3f, 0.7f + 0.075f, 0.2f, 0.05f), 3f, false) .withColor(1, 1, 1) - ) - private val nameTF = new TextField(LocationInfo.from16x9(0.3f, 0.7f, 0.4f, 0.075f), maxFontSize = 2.5f) - addComponent(nameTF) - - addComponent( - new Label("World size", LocationInfo.from16x9(0.3f, 0.55f + 0.075f, 0.2f, 0.05f), 3f, false) + val sizeLabel = new Label("World size", LocationInfo.from16x9(0.3f, 0.55f + 0.075f, 0.2f, 0.05f), 3f, false) .withColor(1, 1, 1) - ) - private val sizeTF = - new TextField(LocationInfo.from16x9(0.3f, 0.55f, 0.4f, 0.075f), maxFontSize = 2.5f) - addComponent(sizeTF) - - addComponent( - new Label("World seed", LocationInfo.from16x9(0.3f, 0.4f + 0.075f, 0.2f, 0.05f), 3f, false) + val seedLabel = new Label("World seed", LocationInfo.from16x9(0.3f, 0.4f + 0.075f, 0.2f, 0.05f), 3f, false) .withColor(1, 1, 1) - ) - private val seedTF = new TextField(LocationInfo.from16x9(0.3f, 0.4f, 0.4f, 0.075f), maxFontSize = 2.5f) - addComponent(seedTF) - addComponent(Button("Cancel", LocationInfo.from16x9(0.3f, 0.05f, 0.19f, 0.1f)) { - onEvent.send(Event.GoBack) - }) - addComponent(Button("Create world", LocationInfo.from16x9(0.51f, 0.05f, 0.19f, 0.1f))(createWorld())) - - private def createWorld(): Unit = { - try { - val baseFolder = new File(saveFolder, "saves") - val file = uniqueFile(baseFolder, cleanupFileName(nameTF.text)) - val size = sizeTF.text.toByteOption.filter(s => s >= 0 && s <= 20) - val seed = Some(seedTF.text) - .filter(_.nonEmpty) - .map(s => s.toLongOption.getOrElse(new Random(s.##.toLong << 32 | s.reverse.##).nextLong())) - - onEvent.send(Event.StartGame(file, WorldSettings(Some(nameTF.text), size, seed))) - } catch { - case _: Exception => - // TODO: complain about the input - } + menu.addComponent(nameLabel) + menu.addComponent(nameTF) + + menu.addComponent(sizeLabel) + menu.addComponent(sizeTF) + + menu.addComponent(seedLabel) + menu.addComponent(seedTF) + + menu.addComponent(Button("Cancel", LocationInfo.from16x9(0.3f, 0.05f, 0.19f, 0.1f)) { + tx.send(Event.GoBack) + }) + menu.addComponent(Button("Create world", LocationInfo.from16x9(0.51f, 0.05f, 0.19f, 0.1f)) { + createWorld() + }) + + (menu, rx) } private def uniqueFile(baseFolder: File, fileName: String): File = { diff --git a/game/src/main/scala/hexacraft/main/MainRouter.scala b/game/src/main/scala/hexacraft/main/MainRouter.scala index f59a9b0e..06f0e634 100644 --- a/game/src/main/scala/hexacraft/main/MainRouter.scala +++ b/game/src/main/scala/hexacraft/main/MainRouter.scala @@ -35,7 +35,7 @@ class MainRouter( case SceneRoute.Main => import Menus.MainMenu.Event - val (scene, rx) = Channel.wrap[Event](tx => Menus.MainMenu(multiplayerEnabled)(tx)) + val (scene, rx) = Menus.MainMenu.create(multiplayerEnabled) rx.onEvent { case Event.Play => route(SceneRoute.WorldChooser) @@ -49,7 +49,7 @@ class MainRouter( case SceneRoute.WorldChooser => import Menus.WorldChooserMenu.Event - val (scene, rx) = Channel.wrap[Event](tx => Menus.WorldChooserMenu(saveFolder, fs)(tx)) + val (scene, rx) = Menus.WorldChooserMenu.create(saveFolder, fs) rx.onEvent { case Event.StartGame(saveDir, settings) => @@ -63,7 +63,7 @@ class MainRouter( case SceneRoute.NewWorld => import Menus.NewWorldMenu.Event - val (scene, rx) = Channel.wrap[Event](tx => Menus.NewWorldMenu(saveFolder)(tx)) + val (scene, rx) = Menus.NewWorldMenu.create(saveFolder) rx.onEvent { case Event.StartGame(saveDir, settings) => @@ -76,7 +76,7 @@ class MainRouter( case SceneRoute.Multiplayer => import Menus.MultiplayerMenu.Event - val (scene, rx) = Channel.wrap[Event](tx => Menus.MultiplayerMenu(tx)) + val (scene, rx) = Menus.MultiplayerMenu.create() rx.onEvent { case Event.Join => route(SceneRoute.JoinWorld) @@ -89,7 +89,7 @@ class MainRouter( case SceneRoute.JoinWorld => import Menus.JoinWorldChooserMenu.Event - val (scene, rx) = Channel.wrap[Event](tx => new Menus.JoinWorldChooserMenu(tx)) + val (scene, rx) = Menus.JoinWorldChooserMenu.create() rx.onEvent { case Event.Join(address, port) =>