From 35423bdbc5cca74e74bc85f5c352940c372c2c04 Mon Sep 17 00:00:00 2001 From: Dave Smith Date: Fri, 24 Nov 2023 08:56:16 +0000 Subject: [PATCH] Added RogueUI classes + demo scene --- build.sbt | 11 +- demo/src/main/scala/demo/Assets.scala | 14 - demo/src/main/scala/demo/RogueLikeGame.scala | 91 ++++--- .../demo/RogueTerminalEmulatorScene.scala | 28 +- .../scala/demo/TerminalEmulatorScene.scala | 30 +-- .../main/scala/demo/TerminalTextScene.scala | 38 +-- demo/src/main/scala/demo/UIScene.scala | 243 +++++++++++++++++ project/plugins.sbt | 2 +- .../scala/roguelikestarterkit/package.scala | 73 +++++ .../ui/component/Component.scala | 19 ++ .../ui/component/ComponentEntry.scala | 6 + .../ui/component/ComponentFragment.scala | 52 ++++ .../ui/component/ComponentGroup.scala | 143 ++++++++++ .../ui/component/ComponentLayout.scala | 55 ++++ .../ui/components/Button.scala | 95 +++++++ .../ui/components/Label.scala | 36 +++ .../ui/datatypes/Bounds.scala | 75 ++++++ .../ui/datatypes/CharSheet.scala | 10 + .../ui/datatypes/Coords.scala | 35 +++ .../ui/datatypes/Dimensions.scala | 41 +++ .../ui/datatypes/UiContext.scala | 36 +++ .../ui/shaders/MaskedLayer.scala | 57 ++++ .../ui/window/DragData.scala | 13 + .../ui/window/Window.scala | 253 ++++++++++++++++++ .../ui/window/WindowEvent.scala | 11 + .../ui/window/WindowId.scala | 6 + .../ui/window/WindowManager.scala | 61 +++++ .../ui/window/WindowManagerEvent.scala | 8 + .../ui/window/WindowManagerModel.scala | 35 +++ .../ui/window/WindowManagerViewModel.scala | 19 ++ .../ui/window/WindowModel.scala | 124 +++++++++ .../ui/window/WindowViewModel.scala | 148 ++++++++++ rogueui/src/main/scala/ui/package.scala | 74 +++++ 33 files changed, 1849 insertions(+), 93 deletions(-) delete mode 100644 demo/src/main/scala/demo/Assets.scala create mode 100644 demo/src/main/scala/demo/UIScene.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/Component.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentEntry.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentFragment.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentGroup.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentLayout.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Button.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Label.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Bounds.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/CharSheet.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Coords.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Dimensions.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/UiContext.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/shaders/MaskedLayer.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/DragData.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/Window.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowEvent.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowId.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManager.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerEvent.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerModel.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerViewModel.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowModel.scala create mode 100644 roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowViewModel.scala create mode 100644 rogueui/src/main/scala/ui/package.scala diff --git a/build.sbt b/build.sbt index 97db572e..612409f6 100644 --- a/build.sbt +++ b/build.sbt @@ -2,7 +2,7 @@ import scala.sys.process._ import scala.language.postfixOps import sbtwelcome._ -import indigoplugin.IndigoOptions +import indigoplugin._ val scala3Version = "3.3.1" val indigoVersion = "0.15.2-SNAPSHOT" @@ -75,6 +75,7 @@ lazy val demoOptions: IndigoOptions = .withTitle("Indigo Roguelike!") .withBackgroundColor("black") .withAssetDirectory("demo/assets") + .withWindowSize(800, 600) lazy val demo = (project in file("demo")) @@ -94,6 +95,14 @@ lazy val demo = publish / skip := true, publishLocal / skip := true ) + .settings( + Compile / sourceGenerators += Def.task { + IndigoGenerators("demo") + .listAssets("Assets", demoOptions.assets) + .generateConfig("Config", demoOptions) + .toSourceFiles((Compile / sourceManaged).value) + } + ) .dependsOn(roguelike) lazy val benchmarks = diff --git a/demo/src/main/scala/demo/Assets.scala b/demo/src/main/scala/demo/Assets.scala deleted file mode 100644 index 441887c0..00000000 --- a/demo/src/main/scala/demo/Assets.scala +++ /dev/null @@ -1,14 +0,0 @@ -package demo - -import indigo._ - -object Assets: - - val tileMap = AssetName("Anikki_square_10x10") - - val assets: Set[AssetType] = - Set( - AssetType.Image(tileMap, AssetPath("assets/" + tileMap.toString + ".png")) - ) - -end Assets diff --git a/demo/src/main/scala/demo/RogueLikeGame.scala b/demo/src/main/scala/demo/RogueLikeGame.scala index e6fc61f2..30f5a11c 100644 --- a/demo/src/main/scala/demo/RogueLikeGame.scala +++ b/demo/src/main/scala/demo/RogueLikeGame.scala @@ -8,59 +8,86 @@ import roguelikestarterkit.* import scala.scalajs.js.annotation.JSExportTopLevel @JSExportTopLevel("IndigoGame") -object RogueLikeGame extends IndigoGame[Unit, Unit, Unit, Unit]: +object RogueLikeGame extends IndigoGame[Size, Size, Model, ViewModel]: - val maxTileCount: Int = 4000 + def initialScene(bootData: Size): Option[SceneName] = + None - def initialScene(bootData: Unit): Option[SceneName] = - Option(RogueTerminalEmulatorScene.name) - - def scenes(bootData: Unit): NonEmptyList[Scene[Unit, Unit, Unit]] = - NonEmptyList(TerminalTextScene, TerminalEmulatorScene, RogueTerminalEmulatorScene) + def scenes(bootData: Size): NonEmptyList[Scene[Size, Model, ViewModel]] = + NonEmptyList(UIScene, RogueTerminalEmulatorScene, TerminalTextScene, TerminalEmulatorScene) val eventFilters: EventFilters = EventFilters.Permissive - def boot(flags: Map[String, String]): Outcome[BootResult[Unit]] = + def boot(flags: Map[String, String]): Outcome[BootResult[Size]] = Outcome( - BootResult - .noData( - GameConfig.default - .withMagnification(2) - .withFrameRateLimit(FPS.`60`) - ) + BootResult( + Config.config, + Config.config.viewport.size / 2 + ) .withFonts(RoguelikeTiles.Size10x10.Fonts.fontInfo) - .withAssets(Assets.assets) + .withAssets(Assets.assets.assetSet) .withShaders( - TerminalText.standardShader, - TerminalMaterial.standardShader, - TerminalTextScene.customShader(ShaderId("my shader")) + uiShaders ++ Set( + TerminalText.standardShader, + TerminalMaterial.standardShader, + TerminalTextScene.customShader(ShaderId("my shader")) + ) ) .withSubSystems(FPSCounter(Point(10, 350))) ) - def initialModel(startupData: Unit): Outcome[Unit] = - Outcome(()) + def initialModel(startupData: Size): Outcome[Model] = + Outcome(Model.initial) - def initialViewModel(startupData: Unit, model: Unit): Outcome[Unit] = - Outcome(()) + def initialViewModel(startupData: Size, model: Model): Outcome[ViewModel] = + Outcome(ViewModel.initial(startupData)) - def setup(bootData: Unit, assetCollection: AssetCollection, dice: Dice): Outcome[Startup[Unit]] = - Outcome(Startup.Success(())) + def setup(bootData: Size, assetCollection: AssetCollection, dice: Dice): Outcome[Startup[Size]] = + Outcome(Startup.Success(bootData)) - def updateModel(context: FrameContext[Unit], model: Unit): GlobalEvent => Outcome[Unit] = + def updateModel(context: FrameContext[Size], model: Model): GlobalEvent => Outcome[Model] = _ => Outcome(model) def updateViewModel( - context: FrameContext[Unit], - model: Unit, - viewModel: Unit - ): GlobalEvent => Outcome[Unit] = + context: FrameContext[Size], + model: Model, + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = _ => Outcome(viewModel) def present( - context: FrameContext[Unit], - model: Unit, - viewModel: Unit + context: FrameContext[Size], + model: Model, + viewModel: ViewModel ): Outcome[SceneUpdateFragment] = Outcome(SceneUpdateFragment.empty) + +final case class CustomContext() // Placeholder, not used. + +final case class Model(windowManager: WindowManagerModel[Size, CustomContext]) + +object Model: + + val defaultCharSheet: CharSheet = + CharSheet( + Assets.assets.AnikkiSquare10x10, + Size(10), + RoguelikeTiles.Size10x10.charCrops + ) + + val initial: Model = + Model( + WindowManagerModel + .initial[Size, CustomContext] + .add( + ColourWindow.window( + defaultCharSheet + ) + ) + ) + +final case class ViewModel(windowManager: WindowManagerViewModel[Size, CustomContext]) +object ViewModel: + def initial(viewportSize: Size): ViewModel = + ViewModel(WindowManagerViewModel.initial) diff --git a/demo/src/main/scala/demo/RogueTerminalEmulatorScene.scala b/demo/src/main/scala/demo/RogueTerminalEmulatorScene.scala index 92e33d5c..640174e4 100644 --- a/demo/src/main/scala/demo/RogueTerminalEmulatorScene.scala +++ b/demo/src/main/scala/demo/RogueTerminalEmulatorScene.scala @@ -4,18 +4,18 @@ import indigo.* import indigo.scenes.* import roguelikestarterkit.* -object RogueTerminalEmulatorScene extends Scene[Unit, Unit, Unit]: +object RogueTerminalEmulatorScene extends Scene[Size, Model, ViewModel]: - type SceneModel = Unit - type SceneViewModel = Unit + type SceneModel = Model + type SceneViewModel = ViewModel val name: SceneName = SceneName("RogueTerminalEmulatorScene") - val modelLens: Lens[Unit, Unit] = + val modelLens: Lens[Model, Model] = Lens.keepLatest - val viewModelLens: Lens[Unit, Unit] = + val viewModelLens: Lens[ViewModel, ViewModel] = Lens.keepLatest val eventFilters: EventFilters = @@ -24,7 +24,7 @@ object RogueTerminalEmulatorScene extends Scene[Unit, Unit, Unit]: val subSystems: Set[SubSystem] = Set() - def updateModel(context: SceneContext[Unit], model: Unit): GlobalEvent => Outcome[Unit] = + def updateModel(context: SceneContext[Size], model: Model): GlobalEvent => Outcome[Model] = case KeyboardEvent.KeyUp(Key.SPACE) => Outcome(model).addGlobalEvents(SceneEvent.JumpTo(TerminalTextScene.name)) @@ -32,10 +32,10 @@ object RogueTerminalEmulatorScene extends Scene[Unit, Unit, Unit]: Outcome(model) def updateViewModel( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit - ): GlobalEvent => Outcome[Unit] = + context: SceneContext[Size], + model: Model, + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = _ => Outcome(viewModel) // This shouldn't live here really, just keeping it simple for demo purposes. @@ -50,9 +50,9 @@ object RogueTerminalEmulatorScene extends Scene[Unit, Unit, Unit]: .put(Point(5, 5), MapTile(Tile.`@`, RGBA.Cyan)) def present( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit + context: SceneContext[Size], + model: Model, + viewModel: ViewModel ): Outcome[SceneUpdateFragment] = val tiles = terminal.toCloneTiles( @@ -60,7 +60,7 @@ object RogueTerminalEmulatorScene extends Scene[Unit, Unit, Unit]: Point.zero, RoguelikeTiles.Size10x10.charCrops ) { (fg, bg) => - Graphic(10, 10, TerminalMaterial(Assets.tileMap, fg, bg)) + Graphic(10, 10, TerminalMaterial(Assets.assets.AnikkiSquare10x10, fg, bg)) } Outcome(tiles.toSceneUpdateFragment) diff --git a/demo/src/main/scala/demo/TerminalEmulatorScene.scala b/demo/src/main/scala/demo/TerminalEmulatorScene.scala index 79f50478..b7f9dfd1 100644 --- a/demo/src/main/scala/demo/TerminalEmulatorScene.scala +++ b/demo/src/main/scala/demo/TerminalEmulatorScene.scala @@ -4,18 +4,18 @@ import indigo.* import indigo.scenes.* import roguelikestarterkit.* -object TerminalEmulatorScene extends Scene[Unit, Unit, Unit]: +object TerminalEmulatorScene extends Scene[Size, Model, ViewModel]: - type SceneModel = Unit - type SceneViewModel = Unit + type SceneModel = Model + type SceneViewModel = ViewModel val name: SceneName = SceneName("TerminalEmulatorScene") - val modelLens: Lens[Unit, Unit] = + val modelLens: Lens[Model, Model] = Lens.keepLatest - val viewModelLens: Lens[Unit, Unit] = + val viewModelLens: Lens[ViewModel, ViewModel] = Lens.keepLatest val eventFilters: EventFilters = @@ -24,18 +24,18 @@ object TerminalEmulatorScene extends Scene[Unit, Unit, Unit]: val subSystems: Set[SubSystem] = Set() - def updateModel(context: SceneContext[Unit], model: Unit): GlobalEvent => Outcome[Unit] = + def updateModel(context: SceneContext[Size], model: Model): GlobalEvent => Outcome[Model] = case KeyboardEvent.KeyUp(Key.SPACE) => - Outcome(model).addGlobalEvents(SceneEvent.JumpTo(RogueTerminalEmulatorScene.name)) + Outcome(model).addGlobalEvents(SceneEvent.JumpTo(UIScene.name)) case _ => Outcome(model) def updateViewModel( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit - ): GlobalEvent => Outcome[Unit] = + context: SceneContext[Size], + model: Model, + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = _ => Outcome(viewModel) // This shouldn't live here really, just keeping it simple for demo purposes. @@ -54,9 +54,9 @@ object TerminalEmulatorScene extends Scene[Unit, Unit, Unit]: ) def present( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit + context: SceneContext[Size], + model: Model, + viewModel: ViewModel ): Outcome[SceneUpdateFragment] = val tiles = terminal.toCloneTiles( @@ -64,7 +64,7 @@ object TerminalEmulatorScene extends Scene[Unit, Unit, Unit]: Point.zero, RoguelikeTiles.Size10x10.charCrops ) { (fg, bg) => - Graphic(10, 10, TerminalMaterial(Assets.tileMap, fg, bg)) + Graphic(10, 10, TerminalMaterial(Assets.assets.AnikkiSquare10x10, fg, bg)) } Outcome( diff --git a/demo/src/main/scala/demo/TerminalTextScene.scala b/demo/src/main/scala/demo/TerminalTextScene.scala index 5f93688a..c743c345 100644 --- a/demo/src/main/scala/demo/TerminalTextScene.scala +++ b/demo/src/main/scala/demo/TerminalTextScene.scala @@ -4,18 +4,18 @@ import indigo.* import indigo.scenes.* import roguelikestarterkit.* -object TerminalTextScene extends Scene[Unit, Unit, Unit]: +object TerminalTextScene extends Scene[Size, Model, ViewModel]: - type SceneModel = Unit - type SceneViewModel = Unit + type SceneModel = Model + type SceneViewModel = ViewModel val name: SceneName = SceneName("TerminalText scene") - val modelLens: Lens[Unit, Unit] = + val modelLens: Lens[Model, Model] = Lens.keepLatest - val viewModelLens: Lens[Unit, Unit] = + val viewModelLens: Lens[ViewModel, ViewModel] = Lens.keepLatest val eventFilters: EventFilters = @@ -24,7 +24,7 @@ object TerminalTextScene extends Scene[Unit, Unit, Unit]: val subSystems: Set[SubSystem] = Set() - def updateModel(context: SceneContext[Unit], model: Unit): GlobalEvent => Outcome[Unit] = + def updateModel(context: SceneContext[Size], model: Model): GlobalEvent => Outcome[Model] = case KeyboardEvent.KeyUp(Key.SPACE) => Outcome(model).addGlobalEvents(SceneEvent.JumpTo(TerminalEmulatorScene.name)) @@ -32,10 +32,10 @@ object TerminalTextScene extends Scene[Unit, Unit, Unit]: Outcome(model) def updateViewModel( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit - ): GlobalEvent => Outcome[Unit] = + context: SceneContext[Size], + model: Model, + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = _ => Outcome(viewModel) val size = Size(30) @@ -48,26 +48,32 @@ object TerminalTextScene extends Scene[Unit, Unit, Unit]: |""".stripMargin def present( - context: SceneContext[Unit], - model: Unit, - viewModel: Unit + context: SceneContext[Size], + model: Model, + viewModel: ViewModel ): Outcome[SceneUpdateFragment] = Outcome( SceneUpdateFragment( Text( message, RoguelikeTiles.Size10x10.Fonts.fontKey, - TerminalText(Assets.tileMap, RGBA.Cyan, RGBA.Blue) + TerminalText(Assets.assets.AnikkiSquare10x10, RGBA.Cyan, RGBA.Blue) ), Text( message, RoguelikeTiles.Size10x10.Fonts.fontKey, - TerminalText(Assets.tileMap, RGBA.Yellow, RGBA.Red).withShaderId(ShaderId("my shader")) + TerminalText(Assets.assets.AnikkiSquare10x10, RGBA.Yellow, RGBA.Red) + .withShaderId(ShaderId("my shader")) ).moveBy(0, 40), Text( message, RoguelikeTiles.Size10x10.Fonts.fontKey, - TerminalText(Assets.tileMap, RGBA.White, RGBA.Zero, RGBA.Magenta.withAlpha(0.75)) + TerminalText( + Assets.assets.AnikkiSquare10x10, + RGBA.White, + RGBA.Zero, + RGBA.Magenta.withAlpha(0.75) + ) ).moveBy(0, 80) ) ) diff --git a/demo/src/main/scala/demo/UIScene.scala b/demo/src/main/scala/demo/UIScene.scala new file mode 100644 index 00000000..0ef88eb2 --- /dev/null +++ b/demo/src/main/scala/demo/UIScene.scala @@ -0,0 +1,243 @@ +package demo + +import indigo.* +import indigo.scenes.* +import roguelikestarterkit.* + +object UIScene extends Scene[Size, Model, ViewModel]: + + type SceneModel = Model + type SceneViewModel = ViewModel + + val name: SceneName = + SceneName("UI scene") + + val modelLens: Lens[Model, Model] = + Lens.keepLatest + + val viewModelLens: Lens[ViewModel, ViewModel] = + Lens.keepLatest + + val eventFilters: EventFilters = + EventFilters.Permissive + + val subSystems: Set[SubSystem] = + Set() + + def updateModel( + context: SceneContext[Size], + model: Model + ): GlobalEvent => Outcome[Model] = + case KeyboardEvent.KeyUp(Key.SPACE) => + Outcome(model).addGlobalEvents(SceneEvent.JumpTo(RogueTerminalEmulatorScene.name)) + + case e => + val updated = + model.windowManager.update( + UiContext( + context.frameContext, + Model.defaultCharSheet, + context.mouse.position, + CustomContext() + ), + e + ) + + updated.map(w => model.copy(windowManager = w)) + + def updateViewModel( + context: SceneContext[Size], + model: Model, + viewModel: ViewModel + ): GlobalEvent => Outcome[ViewModel] = + case e => + val updated = viewModel.windowManager.update( + UiContext( + context.frameContext, + Model.defaultCharSheet, + context.mouse.position, + CustomContext() + ), + model.windowManager, + e + ) + + updated.map(w => viewModel.copy(windowManager = w)) + + def present( + context: SceneContext[Size], + model: Model, + viewModel: ViewModel + ): Outcome[SceneUpdateFragment] = + WindowManager + .present( + UiContext( + context.frameContext, + Model.defaultCharSheet, + context.mouse.position, + CustomContext() + ), + model.windowManager, + viewModel.windowManager + ) + +import indigo.* +import roguelikestarterkit.* + +object ColourWindow { + + final case class ColorPaletteReference(name: String, count: Int, colors: Batch[RGBA]) + + val outrunner16 = ColorPaletteReference( + "outrunner-16", + 16, + Batch( + RGBA.fromHexString("4d004c"), + RGBA.fromHexString("8f0076"), + RGBA.fromHexString("c70083"), + RGBA.fromHexString("f50078"), + RGBA.fromHexString("ff4764"), + RGBA.fromHexString("ff9393"), + RGBA.fromHexString("ffd5cc"), + RGBA.fromHexString("fff3f0"), + RGBA.fromHexString("000221"), + RGBA.fromHexString("000769"), + RGBA.fromHexString("00228f"), + RGBA.fromHexString("0050c7"), + RGBA.fromHexString("008bf5"), + RGBA.fromHexString("00bbff"), + RGBA.fromHexString("47edff"), + RGBA.fromHexString("93fff8") + ) + ) + + final case class ColorPalette(components: ComponentGroup) + + private val graphic = Graphic(0, 0, TerminalMaterial(AssetName(""), RGBA.White, RGBA.Black)) + + def window( + charSheet: CharSheet + ): WindowModel[Size, CustomContext, ColorPalette] = + WindowModel( + WindowId("Color palette"), + charSheet, + ColorPalette( + ComponentGroup(Bounds(0, 0, 23, 23)) + .withLayout(ComponentLayout.Vertical()) + .add( + ComponentGroup(Bounds(0, 0, 23, 10)) + .withLayout(ComponentLayout.Horizontal(Overflow.Wrap)) + .add( + outrunner16.colors.map { rgba => + Button(Bounds(0, 0, 3, 3))(presentSwatch(charSheet, rgba, None)) + // .onClick() + .presentOver(presentSwatch(charSheet, rgba, Option(RGBA.White))) + .presentDown(presentSwatch(charSheet, rgba, Option(RGBA.Black))) + } + ) + ) + .add( + Button(Bounds(0, 0, 14, 3))( + presentButton(charSheet, "Load palette", RGBA.Silver, RGBA.Black) + ) + // .onClick() + .presentOver(presentButton(charSheet, "Load palette", RGBA.White, RGBA.Black)) + .presentDown(presentButton(charSheet, "Load palette", RGBA.Black, RGBA.White)) + ) + ) + ) + .withTitle("Colour Palette") + .moveTo(0, 0) + .resizeTo(25, 25) + .isDraggable + .isResizable + .isCloseable + .updateModel(updateModel) + .present(present) + + def updateModel( + context: UiContext[Size, CustomContext], + model: ColorPalette + ): GlobalEvent => Outcome[ColorPalette] = + case e => + model.components.update(context)(e).map { c => + model.copy(components = c) + } + + def present( + context: UiContext[Size, CustomContext], + model: ColorPalette + ): Outcome[SceneUpdateFragment] = + model.components.present(context).map { c => + SceneUpdateFragment(c.nodes).addCloneBlanks(c.cloneBlanks) + } + + def presentSwatch( + charSheet: CharSheet, + colour: RGBA, + stroke: Option[RGBA] + ): (Coords, Bounds) => Outcome[ComponentFragment] = + (offset, bounds) => + Outcome( + ComponentFragment( + stroke match + case None => + Shape.Box( + Rectangle( + offset.toScreenSpace(charSheet.size), + bounds.dimensions.toScreenSpace(charSheet.size) + ), + Fill.Color(colour) + ) + + case Some(strokeColor) => + Shape.Box( + Rectangle( + offset.toScreenSpace(charSheet.size), + bounds.dimensions.toScreenSpace(charSheet.size) + ), + Fill.Color(colour), + Stroke(2, strokeColor) + ) + ) + ) + + def presentButton( + charSheet: CharSheet, + text: String, + fgColor: RGBA, + bgColor: RGBA + ): (Coords, Bounds) => Outcome[ComponentFragment] = + (offset, bounds) => + val hBar = Batch.fill(text.length)("─").mkString + val size = bounds.dimensions.unsafeToSize + + val terminal = + RogueTerminalEmulator(size) + .put(Point(0, 0), Tile.`┌`, fgColor, bgColor) + .put(Point(size.width - 1, 0), Tile.`┐`, fgColor, bgColor) + .put(Point(0, size.height - 1), Tile.`└`, fgColor, bgColor) + .put(Point(size.width - 1, size.height - 1), Tile.`┘`, fgColor, bgColor) + .put(Point(0, 1), Tile.`│`, fgColor, bgColor) + .put(Point(size.width - 1, 1), Tile.`│`, fgColor, bgColor) + .putLine(Point(1, 0), hBar, fgColor, bgColor) + .putLine(Point(1, 1), text, fgColor, bgColor) + .putLine(Point(1, 2), hBar, fgColor, bgColor) + .toCloneTiles( + CloneId("button"), + bounds.coords + .toScreenSpace(charSheet.size) + .moveBy(offset.toScreenSpace(charSheet.size)), + charSheet.charCrops + ) { case (fg, bg) => + graphic.withMaterial(TerminalMaterial(charSheet.assetName, fg, bg)) + } + + Outcome( + ComponentFragment( + terminal.clones + ).addCloneBlanks(terminal.blanks) + ) +} + +final case class ColorPalette(components: ComponentGroup) diff --git a/project/plugins.sbt b/project/plugins.sbt index 3fa40d7f..b2377a5a 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,5 +1,5 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.14.0") -addSbtPlugin("io.indigoengine" %% "sbt-indigo" % "0.15.0") +addSbtPlugin("io.indigoengine" %% "sbt-indigo" % "0.15.2-SNAPSHOT") addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.2") addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.9.31") addSbtPlugin("org.xerial.sbt" %% "sbt-sonatype" % "3.9.7") diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/package.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/package.scala index f42858b6..fe826167 100644 --- a/roguelike-starterkit/src/main/scala/roguelikestarterkit/package.scala +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/package.scala @@ -54,3 +54,76 @@ val RoguelikeTiles: tiles.RoguelikeTiles.type = tiles.RoguelikeTiles type Tile = tiles.Tile val Tile: tiles.Tile.type = tiles.Tile + +// UI Shaders + +val uiShaders: Set[indigo.Shader] = + Set( + ui.shaders.LayerMask.shader + ) + +// UI General Datatypes + +type UiContext[StartUpData, A] = ui.datatypes.UiContext[StartUpData, A] +val UiContext: ui.datatypes.UiContext.type = ui.datatypes.UiContext + +type CharSheet = ui.datatypes.CharSheet +val CharSheet: ui.datatypes.CharSheet.type = ui.datatypes.CharSheet + +type Coords = ui.datatypes.Coords +val Coords: ui.datatypes.Coords.type = ui.datatypes.Coords + +type Dimensions = ui.datatypes.Dimensions +val Dimensions: ui.datatypes.Dimensions.type = ui.datatypes.Dimensions + +type Bounds = ui.datatypes.Bounds +val Bounds: ui.datatypes.Bounds.type = ui.datatypes.Bounds + +// UI Windows + +val WindowManager: ui.window.WindowManager.type = ui.window.WindowManager + +type WindowManagerModel[StartupData, A] = ui.window.WindowManagerModel[StartupData, A] +val WindowManagerModel: ui.window.WindowManagerModel.type = ui.window.WindowManagerModel + +type WindowManagerViewModel[StartupData, A] = ui.window.WindowManagerViewModel[StartupData, A] +val WindowManagerViewModel: ui.window.WindowManagerViewModel.type = ui.window.WindowManagerViewModel + +type WindowId = ui.window.WindowId +val WindowId: ui.window.WindowId.type = ui.window.WindowId + +type WindowModel[StartupData, CA, A] = ui.window.WindowModel[StartupData, CA, A] +val WindowModel: ui.window.WindowModel.type = ui.window.WindowModel + +type WindowViewModel = ui.window.WindowViewModel +val WindowViewModel: ui.window.WindowViewModel.type = ui.window.WindowViewModel + +type WindowEvent = ui.window.WindowEvent +val WindowEvent: ui.window.WindowEvent.type = ui.window.WindowEvent + +// UI Components + +type Component[A] = ui.component.Component[A] + +type ComponentGroup = ui.component.ComponentGroup +val ComponentGroup: ui.component.ComponentGroup.type = ui.component.ComponentGroup + +type ComponentFragment = ui.component.ComponentFragment +val ComponentFragment: ui.component.ComponentFragment.type = ui.component.ComponentFragment + +type ComponentLayout = ui.component.ComponentLayout +val ComponentLayout: ui.component.ComponentLayout.type = ui.component.ComponentLayout + +type Overflow = ui.component.Overflow +val Overflow: ui.component.Overflow.type = ui.component.Overflow + +type Padding = ui.component.Padding +val Padding: ui.component.Padding.type = ui.component.Padding + +// UI Built in components + +type Button = ui.components.Button +val Button: ui.components.Button.type = ui.components.Button + +type Label = ui.components.Label +val Label: ui.components.Label.type = ui.components.Label diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/Component.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/Component.scala new file mode 100644 index 00000000..3a0e7de9 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/Component.scala @@ -0,0 +1,19 @@ +package roguelikestarterkit.ui.component + +import indigo.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.UiContext + +trait Component[A]: + + def bounds(model: A): Bounds + + def updateModel[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: A + ): GlobalEvent => Outcome[A] + + def present[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: A + ): Outcome[ComponentFragment] diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentEntry.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentEntry.scala new file mode 100644 index 00000000..dafa66de --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentEntry.scala @@ -0,0 +1,6 @@ +package roguelikestarterkit.ui.component + +import indigo.* +import roguelikestarterkit.ui.datatypes.Coords + +final case class ComponentEntry[A](offset: Coords, model: A, component: Component[A]) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentFragment.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentFragment.scala new file mode 100644 index 00000000..add2eb55 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentFragment.scala @@ -0,0 +1,52 @@ +package roguelikestarterkit.ui.component + +import indigo.shared.collections.Batch +import indigo.shared.scenegraph.CloneBlank +import indigo.shared.scenegraph.SceneNode + +final case class ComponentFragment( + nodes: Batch[SceneNode], + cloneBlanks: Batch[CloneBlank] +): + import Batch.* + + def |+|(other: ComponentFragment): ComponentFragment = + ComponentFragment.append(this, other) + + def withNodes(newNodes: Batch[SceneNode]): ComponentFragment = + this.copy(nodes = newNodes) + def withNodes(newNodes: SceneNode*): ComponentFragment = + withNodes(newNodes.toBatch) + def addNodes(moreNodes: Batch[SceneNode]): ComponentFragment = + withNodes(nodes ++ moreNodes) + def addNodes(moreNodes: SceneNode*): ComponentFragment = + addNodes(moreNodes.toBatch) + def ++(moreNodes: Batch[SceneNode]): ComponentFragment = + addNodes(moreNodes) + + def addCloneBlanks(blanks: CloneBlank*): ComponentFragment = + addCloneBlanks(blanks.toBatch) + + def addCloneBlanks(blanks: Batch[CloneBlank]): ComponentFragment = + this.copy(cloneBlanks = cloneBlanks ++ blanks) + +object ComponentFragment: + import Batch.* + + def apply(nodes: SceneNode*): ComponentFragment = + ComponentFragment(nodes.toBatch) + + def apply(nodes: Batch[SceneNode]): ComponentFragment = + ComponentFragment(nodes, Batch.empty) + + def apply(maybeNode: Option[SceneNode]): ComponentFragment = + ComponentFragment(Batch.fromOption(maybeNode), Batch.empty) + + val empty: ComponentFragment = + ComponentFragment(Batch.empty, Batch.empty) + + def append(a: ComponentFragment, b: ComponentFragment): ComponentFragment = + ComponentFragment( + a.nodes ++ b.nodes, + a.cloneBlanks ++ b.cloneBlanks + ) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentGroup.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentGroup.scala new file mode 100644 index 00000000..5af96d0e --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentGroup.scala @@ -0,0 +1,143 @@ +package roguelikestarterkit.ui.component + +import indigo.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.Dimensions +import roguelikestarterkit.ui.datatypes.UiContext + +import scala.annotation.tailrec + +final case class ComponentGroup( + bounds: Bounds, + layout: ComponentLayout, + components: Batch[ComponentEntry[_]] +): + + extension (b: Bounds) + def withPadding(p: Padding): Bounds = + b.moveBy(p.left, p.top).resize(b.width + p.right, b.height + p.bottom) + + def add[A](entry: A)(using c: Component[A]): ComponentGroup = + val offset: Coords = + layout match + case ComponentLayout.None => + Coords.zero + + case ComponentLayout.Horizontal(padding, Overflow.Hidden) => + components + .takeRight(1) + .headOption + .map(c => c.offset + Coords(c.component.bounds(c.model).withPadding(padding).right, 0)) + .getOrElse(Coords(padding.left, padding.top)) + + case ComponentLayout.Horizontal(padding, Overflow.Wrap) => + val maxY = components + .map(c => c.offset.y + c.component.bounds(c.model).withPadding(padding).height) + .sortWith(_ > _) + .headOption + .getOrElse(0) + + components + .takeRight(1) + .headOption + .map { c => + val padded = c.component.bounds(c.model).withPadding(padding) + val maybeOffset = c.offset + Coords(padded.right, 0) + + if padded.moveBy(maybeOffset).right < bounds.width then maybeOffset + else Coords(0, maxY) + } + .getOrElse(Coords(padding.left, padding.top)) + + case ComponentLayout.Vertical(padding) => + components + .takeRight(1) + .headOption + .map(c => c.offset + Coords(0, c.component.bounds(c.model).withPadding(padding).bottom)) + .getOrElse(Coords(padding.left, padding.top)) + + this.copy(components = components :+ ComponentEntry(offset, entry, c)) + + def add[A](entries: Batch[A])(using c: Component[A]): ComponentGroup = + entries.foldLeft(this) { case (acc, next) => acc.add(next) } + def add[A](entries: A*)(using c: Component[A]): ComponentGroup = + add(Batch.fromSeq(entries)) + + def update[StartupData, ContextData]( + context: UiContext[StartupData, ContextData] + ): GlobalEvent => Outcome[ComponentGroup] = + e => + components + .map { c => + c.component + .updateModel(context.copy(bounds = context.bounds.moveBy(c.offset)), c.model)(e) + .map { updated => + c.copy(model = updated) + } + } + .sequence + .map { updatedComponents => + this.copy( + components = updatedComponents + ) + } + + def present[StartupData, ContextData]( + context: UiContext[StartupData, ContextData] + ): Outcome[ComponentFragment] = + components + .map { c => + c.component.present(context.copy(bounds = context.bounds.moveBy(c.offset)), c.model) + } + .sequence + .map(_.foldLeft(ComponentFragment.empty)(_ |+| _)) + + def withBounds(value: Bounds): ComponentGroup = + this.copy(bounds = value) + + def withLayout(value: ComponentLayout): ComponentGroup = + this.copy(layout = value) + + def withPosition(value: Coords): ComponentGroup = + withBounds(bounds.withPosition(value)) + def moveTo(position: Coords): ComponentGroup = + withPosition(position) + def moveTo(x: Int, y: Int): ComponentGroup = + moveTo(Coords(x, y)) + def moveBy(amount: Coords): ComponentGroup = + withPosition(bounds.coords + amount) + def moveBy(x: Int, y: Int): ComponentGroup = + moveBy(Coords(x, y)) + + def withDimensions(value: Dimensions): ComponentGroup = + withBounds(bounds.withDimensions(value)) + def resizeTo(size: Dimensions): ComponentGroup = + withDimensions(size) + def resizeTo(x: Int, y: Int): ComponentGroup = + resizeTo(Dimensions(x, y)) + def resizeBy(amount: Dimensions): ComponentGroup = + withDimensions(bounds.dimensions + amount) + def resizeBy(x: Int, y: Int): ComponentGroup = + resizeBy(Dimensions(x, y)) + +object ComponentGroup: + def apply(bounds: Bounds): ComponentGroup = + ComponentGroup(bounds, ComponentLayout.None, Batch.empty) + + given Component[ComponentGroup] with + + def bounds(model: ComponentGroup): Bounds = + model.bounds + + def updateModel[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: ComponentGroup + ): GlobalEvent => Outcome[ComponentGroup] = + case e => model.update(context)(e) + + def present[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: ComponentGroup + ): Outcome[ComponentFragment] = + model.present(context) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentLayout.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentLayout.scala new file mode 100644 index 00000000..4cce73a5 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/component/ComponentLayout.scala @@ -0,0 +1,55 @@ +package roguelikestarterkit.ui.component + +enum ComponentLayout: + case None + case Horizontal(padding: Padding, overflow: Overflow) + case Vertical(padding: Padding) + +object ComponentLayout: + + object Horizontal: + def apply(): Horizontal = + Horizontal(Padding.zero, Overflow.Hidden) + def apply(padding: Padding): Horizontal = + Horizontal(padding, Overflow.Hidden) + def apply(overflow: Overflow): Horizontal = + Horizontal(Padding.zero, overflow) + + extension (h: Horizontal) + def withPadding(value: Padding): Horizontal = h.copy(padding = value) + def withOverflow(value: Overflow): Horizontal = h.copy(overflow = value) + + object Vertical: + def apply(): Vertical = + Vertical(Padding.zero) + + extension (h: Vertical) def withPadding(value: Padding): Vertical = h.copy(padding = value) + +final case class Padding(top: Int, right: Int, bottom: Int, left: Int): + def withTop(amount: Int): Padding = this.copy(top = amount) + def withRight(amount: Int): Padding = this.copy(right = amount) + def withBottom(amount: Int): Padding = this.copy(bottom = amount) + def withLeft(amount: Int): Padding = this.copy(left = amount) + def withHorizontal(amount: Int): Padding = this.copy(right = amount, left = amount) + def withVerticl(amount: Int): Padding = this.copy(top = amount, bottom = amount) + +object Padding: + def apply(amount: Int): Padding = + Padding(amount, amount, amount, amount) + def apply(topAndBottom: Int, leftAndRight: Int): Padding = + Padding(topAndBottom, leftAndRight, topAndBottom, leftAndRight) + def apply(top: Int, leftAndRight: Int, bottom: Int): Padding = + Padding(top, leftAndRight, bottom, leftAndRight) + + val zero: Padding = Padding(0) + val one: Padding = Padding(1) + + def top(amount: Int): Padding = Padding(amount, 0, 0, 0) + def right(amount: Int): Padding = Padding(0, amount, 0, 0) + def bottom(amount: Int): Padding = Padding(0, 0, amount, 0) + def left(amount: Int): Padding = Padding(0, 0, 0, amount) + def horizontal(amount: Int): Padding = Padding(0, amount, 0, amount) + def verticl(amount: Int): Padding = Padding(amount, 0, amount, 0) + +enum Overflow: + case Hidden, Wrap diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Button.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Button.scala new file mode 100644 index 00000000..d45a79e2 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Button.scala @@ -0,0 +1,95 @@ +package roguelikestarterkit.ui.components + +import indigo.* +import indigo.syntax.* +import roguelikestarterkit.* +import roguelikestarterkit.tiles.RoguelikeTiles10x10 +import roguelikestarterkit.tiles.RoguelikeTiles5x6 +import roguelikestarterkit.ui.component.Component +import roguelikestarterkit.ui.component.ComponentFragment +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.CharSheet +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.UiContext + +final case class Button( + bounds: Bounds, + state: ButtonState, + up: (Coords, Bounds) => Outcome[ComponentFragment], + over: Option[(Coords, Bounds) => Outcome[ComponentFragment]], + down: Option[(Coords, Bounds) => Outcome[ComponentFragment]], + click: Batch[GlobalEvent] +): + export bounds.* + + def presentUp(up: (Coords, Bounds) => Outcome[ComponentFragment]): Button = + this.copy(up = up) + + def presentOver(over: (Coords, Bounds) => Outcome[ComponentFragment]): Button = + this.copy(over = Option(over)) + + def presentDown(down: (Coords, Bounds) => Outcome[ComponentFragment]): Button = + this.copy(down = Option(down)) + + def onClick(events: Batch[GlobalEvent]): Button = + this.copy(click = events) + def onClick(events: GlobalEvent*): Button = + onClick(Batch.fromSeq(events)) + +object Button: + + def apply(bounds: Bounds)(present: (Coords, Bounds) => Outcome[ComponentFragment]): Button = + val p = (_: Coords, _: Bounds) => Outcome(ComponentFragment.empty) + Button(bounds, ButtonState.Up, present, None, None, Batch.empty) + + given Component[Button] with + def bounds(model: Button): Bounds = + model.bounds + + def updateModel[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: Button + ): GlobalEvent => Outcome[Button] = + case FrameTick => + Outcome( + model.copy(state = + if model.bounds.moveBy(context.bounds.coords).contains(context.mouseCoords) then + if context.mouse.isLeftDown then ButtonState.Down + else ButtonState.Over + else ButtonState.Up + ) + ) + + case _: MouseEvent.Click + if model.bounds.moveBy(context.bounds.coords).contains(context.mouseCoords) => + Outcome(model).addGlobalEvents(model.click) + + case _ => + Outcome(model) + + def present[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: Button + ): Outcome[ComponentFragment] = + model.state match + case ButtonState.Up => model.up(context.bounds.coords, model.bounds) + case ButtonState.Over => model.over.getOrElse(model.up)(context.bounds.coords, model.bounds) + case ButtonState.Down => model.down.getOrElse(model.up)(context.bounds.coords, model.bounds) + +enum ButtonState: + case Up, Over, Down + + def isUp: Boolean = + this match + case Up => true + case _ => false + + def is: Boolean = + this match + case Over => true + case _ => false + + def isDown: Boolean = + this match + case Down => true + case _ => false diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Label.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Label.scala new file mode 100644 index 00000000..11df0f93 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/components/Label.scala @@ -0,0 +1,36 @@ +package roguelikestarterkit.ui.components + +import indigo.* +import indigo.syntax.* +import roguelikestarterkit.* +import roguelikestarterkit.tiles.RoguelikeTiles10x10 +import roguelikestarterkit.tiles.RoguelikeTiles5x6 +import roguelikestarterkit.ui.component.Component +import roguelikestarterkit.ui.component.ComponentFragment +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.CharSheet +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.UiContext + +final case class Label(text: String, render: (Coords, String) => Outcome[ComponentFragment]): + def withText(value: String): Label = + this.copy(text = value) + +object Label: + + given Component[Label] with + def bounds(model: Label): Bounds = + Bounds(0, 0, model.text.length, 1) + + def updateModel[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: Label + ): GlobalEvent => Outcome[Label] = + case _ => + Outcome(model) + + def present[StartupData, ContextData]( + context: UiContext[StartupData, ContextData], + model: Label + ): Outcome[ComponentFragment] = + model.render(context.bounds.coords, model.text) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Bounds.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Bounds.scala new file mode 100644 index 00000000..54547358 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Bounds.scala @@ -0,0 +1,75 @@ +package roguelikestarterkit.ui.datatypes + +import indigo.* + +/** Represents a rectangle on the ui grid, rather than a rectangle on the screen. + */ +opaque type Bounds = Rectangle + +object Bounds: + + inline def apply(r: Rectangle): Bounds = r + inline def apply(x: Int, y: Int, width: Int, height: Int): Bounds = Rectangle(x, y, width, height) + inline def apply(dimensions: Dimensions): Bounds = Rectangle(dimensions.toSize) + inline def apply(coords: Coords, dimensions: Dimensions): Bounds = + Rectangle(coords.toPoint, dimensions.toSize) + + val zero: Bounds = Bounds(0, 0, 0, 0) + + extension (r: Bounds) + private[datatypes] inline def toRectangle: Rectangle = r + private[datatypes] inline def unsafeToRectangle: Rectangle = r + inline def coords: Coords = Coords(r.position) + inline def dimensions: Dimensions = Dimensions(r.size) + inline def toScreenSpace(charSize: Size): Rectangle = + Rectangle(r.position * charSize.toPoint, r.size * charSize) + + inline def x: Int = r.x + inline def y: Int = r.y + inline def width: Int = r.width + inline def height: Int = r.height + + inline def left: Int = if width >= 0 then x else x + width + inline def right: Int = if width >= 0 then x + width else x + inline def top: Int = if height >= 0 then y else y + height + inline def bottom: Int = if height >= 0 then y + height else y + + inline def horizontalCenter: Int = x + (width / 2) + inline def verticalCenter: Int = y + (height / 2) + + inline def topLeft: Coords = Coords(left, top) + inline def topRight: Coords = Coords(right, top) + inline def bottomRight: Coords = Coords(right, bottom) + inline def bottomLeft: Coords = Coords(left, bottom) + inline def center: Coords = Coords(horizontalCenter, verticalCenter) + inline def halfSize: Dimensions = (dimensions / 2).abs + + def contains(coords: Coords): Boolean = + r.contains(coords.unsafetoPoint) + def contains(x: Int, y: Int): Boolean = + contains(Coords(x, y)) + + def moveBy(coords: Coords): Bounds = + r.moveBy(coords.toPoint) + def moveBy(x: Int, y: Int): Bounds = + moveBy(Point(x, y)) + + def moveTo(coords: Coords): Bounds = + r.moveTo(coords.toPoint) + def moveTo(x: Int, y: Int): Bounds = + moveTo(Point(x, y)) + + def resize(newSize: Dimensions): Bounds = + r.resize(newSize.toSize) + def resize(x: Int, y: Int): Bounds = + resize(Size(x, y)) + + def withPosition(coords: Coords): Bounds = + moveTo(coords.toPoint) + def withPosition(x: Int, y: Int): Bounds = + moveTo(Point(x, y)) + + def withDimensions(newSize: Dimensions): Bounds = + resize(newSize) + def withDimensions(x: Int, y: Int): Bounds = + resize(Size(x, y)) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/CharSheet.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/CharSheet.scala new file mode 100644 index 00000000..7a98fb58 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/CharSheet.scala @@ -0,0 +1,10 @@ +package roguelikestarterkit.ui.datatypes + +import indigo.* + +final case class CharSheet( + assetName: AssetName, + size: Size, + charCrops: Batch[(Int, Int, Int, Int)] +): + val charSize: Int = size.width diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Coords.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Coords.scala new file mode 100644 index 00000000..cc5911be --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Coords.scala @@ -0,0 +1,35 @@ +package roguelikestarterkit.ui.datatypes + +import indigo.* + +/** Represents a position on the ui grid, rather than a position on the screen. + */ +opaque type Coords = Point + +object Coords: + + inline def apply(value: Int): Coords = Point(value) + inline def apply(x: Int, y: Int): Coords = Point(x, y) + inline def apply(point: Point): Coords = point + + val zero: Coords = Coords(0, 0) + + extension (c: Coords) + private[datatypes] inline def toPoint: Point = c + inline def unsafetoPoint: Point = c + inline def toDimensions: Dimensions = Dimensions(c.toSize) + inline def toScreenSpace(charSize: Size): Point = c * charSize.toPoint + + inline def x: Int = c.x + inline def y: Int = c.y + + inline def +(other: Coords): Coords = c + other + inline def +(i: Int): Coords = c + i + inline def -(other: Coords): Coords = c - other + inline def -(i: Int): Coords = c - i + inline def *(other: Coords): Coords = c * other + inline def *(i: Int): Coords = c * i + inline def /(other: Coords): Coords = c / other + inline def /(i: Int): Coords = c / i + + inline def abs: Coords = c.abs diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Dimensions.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Dimensions.scala new file mode 100644 index 00000000..59105d4e --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/Dimensions.scala @@ -0,0 +1,41 @@ +package roguelikestarterkit.ui.datatypes + +import indigo.* + +/** Represents a size on the ui grid, rather than a position on the screen. + */ +opaque type Dimensions = Size + +object Dimensions: + + inline def apply(value: Int): Dimensions = Size(value) + inline def apply(width: Int, height: Int): Dimensions = Size(width, height) + inline def apply(size: Size): Dimensions = size + + val zero: Dimensions = Dimensions(0, 0) + + extension (d: Dimensions) + private[datatypes] inline def toSize: Size = d + inline def unsafeToSize: Size = d + inline def toCoords: Coords = Coords(d.toPoint) + inline def toScreenSpace(charSize: Size): Size = d * charSize + + inline def width: Int = d.width + inline def height: Int = d.height + + inline def +(other: Dimensions): Dimensions = d + other + inline def +(i: Int): Dimensions = d + i + inline def -(other: Dimensions): Dimensions = d - other + inline def -(i: Int): Dimensions = d - i + inline def *(other: Dimensions): Dimensions = d * other + inline def *(i: Int): Dimensions = d * i + inline def /(other: Dimensions): Dimensions = d / other + inline def /(i: Int): Dimensions = d / i + + inline def min(other: Dimensions): Dimensions = d.min(other) + inline def min(value: Int): Dimensions = d.min(value) + + inline def max(other: Dimensions): Dimensions = d.max(other) + inline def max(value: Int): Dimensions = d.max(value) + + inline def abs: Dimensions = d.abs diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/UiContext.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/UiContext.scala new file mode 100644 index 00000000..240bb945 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/datatypes/UiContext.scala @@ -0,0 +1,36 @@ +package roguelikestarterkit.ui.datatypes + +import indigo.* + +final case class UiContext[StartUpData, A]( + bounds: Bounds, + charSheet: CharSheet, + mouseCoords: Coords, + data: A, + frameContext: FrameContext[StartUpData] +): + export frameContext.gameTime + export frameContext.dice + export frameContext.inputState + export frameContext.boundaryLocator + export frameContext.startUpData + export frameContext.gameTime.running + export frameContext.gameTime.delta + export frameContext.inputState.mouse + export frameContext.inputState.keyboard + export frameContext.inputState.gamepad + export frameContext.findBounds + export frameContext.bounds + + lazy val screenSpaceBounds: Rectangle = + bounds.toScreenSpace(charSheet.size) + +object UiContext: + def apply[StartUpData, A]( + frameContext: FrameContext[StartUpData], + charSheet: CharSheet, + mousePosition: Point, + data: A + ): UiContext[StartUpData, A] = + val mouseCoords = Coords(mousePosition / charSheet.size.toPoint) + UiContext(Bounds.zero, charSheet, mouseCoords, data, frameContext) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/shaders/MaskedLayer.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/shaders/MaskedLayer.scala new file mode 100644 index 00000000..40a12297 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/shaders/MaskedLayer.scala @@ -0,0 +1,57 @@ +package roguelikestarterkit.ui.shaders + +import indigo.* +import indigo.syntax.shaders.* + +final case class LayerMask(mask: Rectangle) extends BlendMaterial: + lazy val toShaderData: BlendShaderData = + BlendShaderData( + LayerMask.shader.id, + UniformBlock( + UniformBlockName("MaskBounds"), + Batch( + Uniform("MASK_BOUNDS") -> mask.asVec4 + ) + ) + ) + +object LayerMask: + val shader: UltravioletShader = + UltravioletShader.blendFragment( + ShaderId("rogueui-masked-layer"), + BlendShader.fragment( + fragment, + Env.ref + ) + ) + + import ultraviolet.syntax.* + + final case class Env( + MASK_BOUNDS: vec4 + ) extends BlendFragmentEnvReference + + object Env: + val ref = + Env( + vec4(1.0f) + ) + + final case class MaskBounds( + MASK_BOUNDS: vec4 + ) + + inline def fragment = + Shader[Env] { env => + + ubo[MaskBounds] + + def fragment(color: vec4): vec4 = + val x = env.MASK_BOUNDS.x / env.SIZE.x + val y = env.MASK_BOUNDS.y / env.SIZE.y + val w = env.MASK_BOUNDS.z / env.SIZE.x + val h = env.MASK_BOUNDS.w / env.SIZE.y + + if env.UV.x > x && env.UV.x < x + w && env.UV.y > y && env.UV.y < y + h then env.SRC + else vec4(0.0f) + } diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/DragData.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/DragData.scala new file mode 100644 index 00000000..6fe78701 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/DragData.scala @@ -0,0 +1,13 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.Coords + +final case class DragData(by: Coords, offset: Coords) + +object DragData: + def apply(d: Coords): DragData = + DragData(d, d) + + val zero: DragData = + DragData(Coords.zero, Coords.zero) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/Window.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/Window.scala new file mode 100644 index 00000000..f61b18d5 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/Window.scala @@ -0,0 +1,253 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.Bounds.dimensions +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.Dimensions +import roguelikestarterkit.ui.datatypes.UiContext +import roguelikestarterkit.ui.shaders.LayerMask + +object Window: + + private val graphic10x10: Graphic[TerminalMaterial] = + Graphic(0, 0, TerminalMaterial(AssetName(""), RGBA.White, RGBA.Black)) + + def updateModel[StartupData, CA, A]( + context: UiContext[StartupData, CA], + model: WindowModel[StartupData, CA, A] + ): GlobalEvent => Outcome[WindowModel[StartupData, CA, A]] = + case WindowEvent.MoveBy(id, dragData) if model.id == id => + Outcome( + model.copy( + bounds = model.bounds.moveBy(dragData.by - dragData.offset) + ) + ) + + case WindowEvent.MoveTo(id, position) if model.id == id => + Outcome( + model.copy( + bounds = model.bounds.moveTo(position) + ) + ) + + case WindowEvent.ResizeBy(id, dragData) if model.id == id => + Outcome( + model.copy( + bounds = model.bounds.resize( + model.bounds.dimensions + (dragData.by - dragData.offset).toDimensions + ) + ) + ) + + case e => + val b = model.bounds + val contentRectangle = + if model.title.isDefined then + b + .resize((b.dimensions - Dimensions(2, 4)).max(Dimensions.zero)) + .moveTo(b.coords + Coords(1, 3)) + else + b + .resize((b.dimensions - Dimensions(2, 2)).max(Dimensions.zero)) + .moveTo(b.coords + Coords(1, 1)) + + model + .updateContentModel(context.copy(bounds = contentRectangle), model.contentModel)(e) + .map(model.withModel) + + def calculateDragBy(charSize: Int, mousePosition: Point, windowPosition: Coords): Coords = + Coords(mousePosition / charSize) - windowPosition + + def redraw[StartupData, CA, A]( + context: UiContext[StartupData, CA], + model: WindowModel[StartupData, CA, A], + viewModel: WindowViewModel + ): WindowViewModel = + val tempModel = + model.copy( + bounds = model.bounds + .resize( + model.bounds.dimensions + viewModel.resizeData + .map(d => d.by - d.offset) + .getOrElse(Coords.zero) + .toDimensions + ) + .moveBy( + viewModel.dragData + .map(d => d.by - d.offset) + .getOrElse(Coords.zero) + ) + ) + val vm = viewModel.resize(tempModel) + val clones = + vm.terminal.toCloneTiles( + CloneId("window_tile"), + tempModel.bounds.coords.toScreenSpace(model.charSheet.size), + model.charSheet.charCrops + ) { case (fg, bg) => + graphic10x10.withMaterial(TerminalMaterial(model.charSheet.assetName, fg, bg)) + } + + val b = model.bounds + .resize( + model.bounds.dimensions + viewModel.resizeData + .map(d => d.by - d.offset) + .getOrElse(Coords.zero) + .toDimensions + ) + .moveBy( + viewModel.dragData + .map(d => d.by - d.offset) + .getOrElse(Coords.zero) + ) + + val contentRectangle = + if model.title.isDefined then + b + .resize((b.dimensions - Dimensions(2, 4)).max(Dimensions.zero)) + .moveTo(b.coords + Coords(1, 3)) + else + b + .resize((b.dimensions - Dimensions(2, 2)).max(Dimensions.zero)) + .moveTo(b.coords + Coords(1, 1)) + + vm.copy( + terminalClones = clones, + modelHashCode = model.bounds.hashCode(), + contentRectangle = contentRectangle + ) + + def updateViewModel[StartupData, CA, A]( + context: UiContext[StartupData, CA], + model: WindowModel[StartupData, CA, A], + viewModel: WindowViewModel + ): GlobalEvent => Outcome[WindowViewModel] = + case FrameTick + if model.bounds.hashCode() != viewModel.modelHashCode || + viewModel.dragData.isDefined || + viewModel.resizeData.isDefined => + Outcome(redraw(context, model, viewModel)) + + case WindowEvent.Redraw => + Outcome(redraw(context, model, viewModel)) + + case WindowEvent.ClearData => + Outcome(viewModel.copy(resizeData = None, dragData = None)) + + case e: MouseEvent.Click => + val gridPos = context.mouseCoords + + val actionsAllowed = viewModel.dragData.isEmpty && viewModel.resizeData.isEmpty + + val close = + if actionsAllowed && model.closeable && gridPos == model.bounds.topRight + Coords(-1, 0) + then Batch(WindowManagerEvent.Close(model.id)) + else Batch.empty + val focus = + if actionsAllowed && !model.static && model.bounds + .resize(model.bounds.dimensions - 1) + .contains(gridPos) + then Batch(WindowManagerEvent.GiveFocusAt(gridPos)) + else Batch.empty + + Outcome(viewModel) + .addGlobalEvents(close ++ focus) + + case e: MouseEvent.MouseDown + if model.draggable && + viewModel.dragData.isEmpty && + model.bounds.withDimensions(model.bounds.width, 3).contains(context.mouseCoords) && + context.mouseCoords != model.bounds.topRight + Coords(-1, 0) => + val d = calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords) + + Outcome(viewModel.copy(dragData = Option(DragData(d, d)))) + .addGlobalEvents(WindowManagerEvent.GiveFocusAt(context.mouseCoords)) + + case e: MouseEvent.MouseDown + if model.resizable && + viewModel.resizeData.isEmpty && + model.bounds.bottomRight - Coords(1) == (context.mouseCoords) => + val d = calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords) + + Outcome(viewModel.copy(resizeData = Option(DragData(d, d)))) + .addGlobalEvents(WindowManagerEvent.GiveFocusAt(context.mouseCoords)) + + case e: MouseEvent.MouseUp if viewModel.dragData.isDefined => + Outcome(viewModel) + .addGlobalEvents( + WindowEvent.MoveBy( + model.id, + viewModel.dragData + .map( + _.copy(by = + calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords) + ) + ) + .getOrElse(DragData.zero) + ), + WindowEvent.ClearData + ) + + case e: MouseEvent.MouseUp if viewModel.resizeData.isDefined => + Outcome(viewModel) + .addGlobalEvents( + WindowEvent.ResizeBy( + model.id, + viewModel.resizeData + .map( + _.copy(by = + calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords) + ) + ) + .getOrElse(DragData.zero) + ), + WindowEvent.ClearData + ) + + case e: MouseEvent.Move if viewModel.dragData.isDefined => + Outcome( + viewModel.copy( + dragData = viewModel.dragData.map( + _.copy(by = calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords)) + ) + ) + ) + + case e: MouseEvent.Move if viewModel.resizeData.isDefined => + Outcome( + viewModel.copy( + resizeData = viewModel.resizeData.map( + _.copy(by = calculateDragBy(model.charSheet.charSize, e.position, model.bounds.coords)) + ) + ) + ) + + case _ => + Outcome(viewModel) + + def present[StartupData, CA, A]( + context: UiContext[StartupData, CA], + model: WindowModel[StartupData, CA, A], + viewModel: WindowViewModel + ): Outcome[SceneUpdateFragment] = + model + .presentContentModel( + context.copy(bounds = viewModel.contentRectangle), + model.contentModel + ) + .map { cm => + val masked = + cm.copy(layers = + cm.layers.map( + _.withBlendMaterial( + LayerMask(viewModel.contentRectangle.toScreenSpace(context.charSheet.size)) + ) + ) + ) + + SceneUpdateFragment( + viewModel.terminalClones.clones + ).addCloneBlanks(viewModel.terminalClones.blanks) |+| masked + } diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowEvent.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowEvent.scala new file mode 100644 index 00000000..d18c5fd1 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowEvent.scala @@ -0,0 +1,11 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.Coords + +enum WindowEvent extends GlobalEvent: + case MoveBy(id: WindowId, dragData: DragData) + case MoveTo(id: WindowId, position: Coords) + case ResizeBy(id: WindowId, dragData: DragData) + case Redraw + case ClearData diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowId.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowId.scala new file mode 100644 index 00000000..5e7d5b91 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowId.scala @@ -0,0 +1,6 @@ +package roguelikestarterkit.ui.window + +opaque type WindowId = String +object WindowId: + def apply(id: String): WindowId = id + extension (id: WindowId) def toString: String = id diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManager.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManager.scala new file mode 100644 index 00000000..aefa8bce --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManager.scala @@ -0,0 +1,61 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.UiContext + +object WindowManager: + + def updateModel[StartupData, A]( + context: UiContext[StartupData, A], + model: WindowManagerModel[StartupData, A] + ): GlobalEvent => Outcome[WindowManagerModel[StartupData, A]] = + case WindowManagerEvent.Close(id) => + Outcome(model.remove(id)) + + case WindowManagerEvent.GiveFocusAt(position) => + Outcome(model.giveFocusAndSurfaceAt(position)) + .addGlobalEvents(WindowEvent.Redraw) + + case e => + model.windows + .map(w => Window.updateModel(context, w)(e)) + .sequence + .map(m => model.copy(windows = m)) + + def updateViewModel[StartupData, A]( + context: UiContext[StartupData, A], + model: WindowManagerModel[StartupData, A], + viewModel: WindowManagerViewModel[StartupData, A] + ): GlobalEvent => Outcome[WindowManagerViewModel[StartupData, A]] = + case e => + val updated = + model.windows.flatMap { m => + viewModel.prune(model).windows.find(_.id == m.id) match + case None => + Batch(Outcome(WindowViewModel.initial(m.id))) + + case Some(vm) => + Batch(vm.update(context, m, e)) + } + + updated.sequence.map(vm => viewModel.copy(windows = vm)) + + def present[StartupData, A]( + context: UiContext[StartupData, A], + model: WindowManagerModel[StartupData, A], + viewModel: WindowManagerViewModel[StartupData, A] + ): Outcome[SceneUpdateFragment] = + model.windows + .flatMap { m => + viewModel.windows.find(_.id == m.id) match + case None => + // Shouldn't get here. + Batch.empty + + case Some(vm) => + Batch(Window.present(context, m, vm)) + } + .sequence + .map( + _.foldLeft(SceneUpdateFragment.empty)(_ |+| _) + ) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerEvent.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerEvent.scala new file mode 100644 index 00000000..78965a0b --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerEvent.scala @@ -0,0 +1,8 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.Coords + +enum WindowManagerEvent extends GlobalEvent: + case Close(id: WindowId) + case GiveFocusAt(coords: Coords) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerModel.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerModel.scala new file mode 100644 index 00000000..50c9cb62 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerModel.scala @@ -0,0 +1,35 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.Dimensions +import roguelikestarterkit.ui.datatypes.UiContext + +final case class WindowManagerModel[StartupData, A](windows: Batch[WindowModel[StartupData, A, _]]): + def add(model: WindowModel[StartupData, A, _]): WindowManagerModel[StartupData, A] = + this.copy(windows = windows :+ model) + + def remove(id: WindowId): WindowManagerModel[StartupData, A] = + this.copy(windows = windows.filterNot(_.id == id)) + + def giveFocusAndSurfaceAt(coords: Coords): WindowManagerModel[StartupData, A] = + val reordered = + windows.reverse.find(w => !w.static && w.bounds.contains(coords)) match + case None => + windows + + case Some(w) => + windows.filterNot(_.id == w.id).map(_.blur) :+ w.focus + + this.copy(windows = reordered) + + def update( + context: UiContext[StartupData, A], + event: GlobalEvent + ): Outcome[WindowManagerModel[StartupData, A]] = + WindowManager.updateModel(context, this)(event) + +object WindowManagerModel: + def initial[StartupData, A]: WindowManagerModel[StartupData, A] = + WindowManagerModel(Batch.empty) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerViewModel.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerViewModel.scala new file mode 100644 index 00000000..54a5ca39 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowManagerViewModel.scala @@ -0,0 +1,19 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.UiContext + +final case class WindowManagerViewModel[StartupData, A](windows: Batch[WindowViewModel]): + def prune(model: WindowManagerModel[StartupData, A]): WindowManagerViewModel[StartupData, A] = + this.copy(windows = windows.filter(w => model.windows.exists(_.id == w.id))) + + def update( + context: UiContext[StartupData, A], + model: WindowManagerModel[StartupData, A], + event: GlobalEvent + ): Outcome[WindowManagerViewModel[StartupData, A]] = + WindowManager.updateViewModel(context, model, this)(event) + +object WindowManagerViewModel: + def initial[StartupData, A]: WindowManagerViewModel[StartupData, A] = + WindowManagerViewModel(Batch.empty) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowModel.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowModel.scala new file mode 100644 index 00000000..ddf58858 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowModel.scala @@ -0,0 +1,124 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.CharSheet +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.Dimensions +import roguelikestarterkit.ui.datatypes.UiContext + +final case class WindowModel[StartupData, CA, A]( + id: WindowId, + charSheet: CharSheet, + bounds: Bounds, + title: Option[String], + contentModel: A, + updateContentModel: (UiContext[StartupData, CA], A) => GlobalEvent => Outcome[A], + presentContentModel: (UiContext[StartupData, CA], A) => Outcome[SceneUpdateFragment], + draggable: Boolean, + resizable: Boolean, + closeable: Boolean, + hasFocus: Boolean, + static: Boolean +): + + def withId(value: WindowId): WindowModel[StartupData, CA, A] = + this.copy(id = value) + + def withBounds(value: Bounds): WindowModel[StartupData, CA, A] = + this.copy(bounds = value) + + def withPosition(value: Coords): WindowModel[StartupData, CA, A] = + withBounds(bounds.moveTo(value)) + def moveTo(position: Coords): WindowModel[StartupData, CA, A] = + withPosition(position) + def moveTo(x: Int, y: Int): WindowModel[StartupData, CA, A] = + moveTo(Coords(x, y)) + def moveBy(amount: Coords): WindowModel[StartupData, CA, A] = + withPosition(bounds.coords + amount) + def moveBy(x: Int, y: Int): WindowModel[StartupData, CA, A] = + moveBy(Coords(x, y)) + + def withDimensions(value: Dimensions): WindowModel[StartupData, CA, A] = + withBounds(bounds.withDimensions(value)) + def resizeTo(size: Dimensions): WindowModel[StartupData, CA, A] = + withDimensions(size) + def resizeTo(x: Int, y: Int): WindowModel[StartupData, CA, A] = + resizeTo(Dimensions(x, y)) + def resizeBy(amount: Dimensions): WindowModel[StartupData, CA, A] = + withDimensions(bounds.dimensions + amount) + def resizeBy(x: Int, y: Int): WindowModel[StartupData, CA, A] = + resizeBy(Dimensions(x, y)) + + def withTitle(value: String): WindowModel[StartupData, CA, A] = + this.copy(title = Option(value)) + + def withModel(value: A): WindowModel[StartupData, CA, A] = + this.copy(contentModel = value) + + def updateModel( + f: (UiContext[StartupData, CA], A) => GlobalEvent => Outcome[A] + ): WindowModel[StartupData, CA, A] = + this.copy(updateContentModel = f) + + def present( + f: (UiContext[StartupData, CA], A) => Outcome[SceneUpdateFragment] + ): WindowModel[StartupData, CA, A] = + this.copy(presentContentModel = f) + + def withDraggable(value: Boolean): WindowModel[StartupData, CA, A] = + this.copy(draggable = value) + def isDraggable: WindowModel[StartupData, CA, A] = + withDraggable(true) + def notDraggable: WindowModel[StartupData, CA, A] = + withDraggable(false) + + def withResizable(value: Boolean): WindowModel[StartupData, CA, A] = + this.copy(resizable = value) + def isResizable: WindowModel[StartupData, CA, A] = + withResizable(true) + def notResizable: WindowModel[StartupData, CA, A] = + withResizable(false) + + def withCloseable(value: Boolean): WindowModel[StartupData, CA, A] = + this.copy(closeable = value) + def isCloseable: WindowModel[StartupData, CA, A] = + withCloseable(true) + def notCloseable: WindowModel[StartupData, CA, A] = + withCloseable(false) + + def withFocus(value: Boolean): WindowModel[StartupData, CA, A] = + this.copy(hasFocus = value) + def focus: WindowModel[StartupData, CA, A] = + withFocus(true) + def blur: WindowModel[StartupData, CA, A] = + withFocus(false) + + def withStatic(value: Boolean): WindowModel[StartupData, CA, A] = + this.copy(static = value) + def isStatic: WindowModel[StartupData, CA, A] = + withStatic(true) + def notStatic: WindowModel[StartupData, CA, A] = + withStatic(false) + +object WindowModel: + + def apply[StartupData, CA, A]( + id: WindowId, + charSheet: CharSheet, + content: A + ): WindowModel[StartupData, CA, A] = + WindowModel( + id, + charSheet, + Bounds(Coords.zero, Dimensions.zero), + None, + contentModel = content, + updateContentModel = (_, _) => _ => Outcome(content), + presentContentModel = (_, _) => Outcome(SceneUpdateFragment.empty), + false, + false, + false, + false, + false + ) diff --git a/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowViewModel.scala b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowViewModel.scala new file mode 100644 index 00000000..3077a028 --- /dev/null +++ b/roguelike-starterkit/src/main/scala/roguelikestarterkit/ui/window/WindowViewModel.scala @@ -0,0 +1,148 @@ +package roguelikestarterkit.ui.window + +import indigo.* +import indigo.syntax.* +import roguelikestarterkit.* +import roguelikestarterkit.ui.datatypes.Bounds +import roguelikestarterkit.ui.datatypes.Coords +import roguelikestarterkit.ui.datatypes.Dimensions +import roguelikestarterkit.ui.datatypes.UiContext + +final case class WindowViewModel( + id: WindowId, + modelHashCode: Int, + terminal: RogueTerminalEmulator, + terminalClones: TerminalClones, + contentRectangle: Bounds, + dragData: Option[DragData], + resizeData: Option[DragData] +): + + def update[StartupData, CA, A]( + context: UiContext[StartupData, CA], + model: WindowModel[StartupData, CA, A], + event: GlobalEvent + ): Outcome[WindowViewModel] = + Window.updateViewModel(context, model, this)(event) + + def resize[StartupData, CA, A](model: WindowModel[StartupData, CA, A]): WindowViewModel = + this.copy(terminal = WindowViewModel.makeWindowTerminal(model, terminal)) + +object WindowViewModel: + + def initial(id: WindowId): WindowViewModel = + WindowViewModel( + id, + 0, + RogueTerminalEmulator(Size.zero), + TerminalClones.empty, + Bounds.zero, + None, + None + ) + + def makeWindowTerminal[StartupData, CA, A]( + model: WindowModel[StartupData, CA, A], + current: RogueTerminalEmulator + ): RogueTerminalEmulator = + val validSize = + model.bounds.dimensions.max(if model.title.isDefined then Dimensions(3) else Dimensions(2)) + + val tiles: Batch[(Point, MapTile)] = + val grey = RGBA.White.mix(RGBA.Black, if model.hasFocus then 0.4 else 0.8) + val title = model.title.getOrElse("").take(model.bounds.dimensions.width - 2).toCharArray() + + (0 to validSize.height).toBatch.flatMap { _y => + (0 to validSize.width).toBatch.map { _x => + val maxX = validSize.width - 1 + val maxY = validSize.height - 1 + val coords = Point(_x, _y) + + coords match + // When there is a title + case Point(0, 1) if model.title.isDefined => + // Title bar left + coords -> MapTile(Tile.`│`, RGBA.White, RGBA.Black) + + case Point(x, 1) if model.title.isDefined && x == maxX => + // Title bar right + coords -> MapTile(Tile.`│`, RGBA.White, RGBA.Black) + + case Point(x, 1) if model.title.isDefined => + // Title text, x starts at 2 + val idx = x - 1 + val tile = + if idx >= 0 && idx < title.length then + val c = title(idx) + Tile.charCodes.get(if c == '\\' then "\\" else c.toString) match + case None => Tile.SPACE + case Some(char) => Tile(char) + else Tile.SPACE + + coords -> MapTile(tile, RGBA.White, RGBA.Black) + + case Point(0, 2) if model.title.isDefined => + // Title bar line left + val tile = if maxY > 2 then Tile.`├` else Tile.`└` + coords -> MapTile(Tile.`│`, RGBA.White, RGBA.Black) + + case Point(x, 2) if model.title.isDefined && x == maxX => + // Title bar line right + val tile = if maxY > 2 then Tile.`┤` else Tile.`┘` + coords -> MapTile(tile, RGBA.White, RGBA.Black) + + case Point(x, 2) if model.title.isDefined => + // Title bar line + coords -> MapTile(Tile.`─`, RGBA.White, RGBA.Black) + + // Normal window frame + + case Point(0, 0) => + // top left + coords -> MapTile(Tile.`┌`, RGBA.White, RGBA.Black) + + case Point(x, 0) if model.closeable && x == maxX => + // top right closable + coords -> MapTile(Tile.`x`, RGBA.Black, RGBA.White) + + case Point(x, 0) if x == maxX => + // top right + coords -> MapTile(Tile.`┐`, RGBA.White, RGBA.Black) + + case Point(x, 0) => + // top + coords -> MapTile(Tile.`─`, RGBA.White, RGBA.Black) + + case Point(0, y) if y == maxY => + // bottom left + coords -> MapTile(Tile.`└`, RGBA.White, RGBA.Black) + + case Point(x, y) if model.resizable && x == maxX && y == maxY => + // bottom right with resize + coords -> MapTile(Tile.`▼`, RGBA.White, RGBA.Black) + + case Point(x, y) if x == maxX && y == maxY => + // bottom right + coords -> MapTile(Tile.`┘`, RGBA.White, RGBA.Black) + + case Point(x, y) if y == maxY => + // bottom + coords -> MapTile(Tile.`─`, RGBA.White, RGBA.Black) + + case Point(0, y) => + // Middle left + coords -> MapTile(Tile.`│`, RGBA.White, RGBA.Black) + + case Point(x, y) if x == maxX => + // Middle right + coords -> MapTile(Tile.`│`, RGBA.White, RGBA.Black) + + case Point(x, y) => + // Window background + coords -> MapTile(Tile.`░`, grey, RGBA.Black) + + } + } + + RogueTerminalEmulator(validSize.unsafeToSize) + .put(tiles) diff --git a/rogueui/src/main/scala/ui/package.scala b/rogueui/src/main/scala/ui/package.scala new file mode 100644 index 00000000..d1c2d93e --- /dev/null +++ b/rogueui/src/main/scala/ui/package.scala @@ -0,0 +1,74 @@ +package roguelikestarterkit.ui + +// UI Shaders + +val uiShaders: Set[indigo.Shader] = + Set( + rogueui.shaders.LayerMask.shader + ) + +// UI General Datatypes + +type UiContext[StartUpData, A] = datatypes.UiContext[StartUpData, A] +val UiContext: datatypes.UiContext.type = datatypes.UiContext + +type CharSheet = datatypes.CharSheet +val CharSheet: datatypes.CharSheet.type = datatypes.CharSheet + +type Coords = datatypes.Coords +val Coords: datatypes.Coords.type = datatypes.Coords + +type Dimensions = datatypes.Dimensions +val Dimensions: datatypes.Dimensions.type = datatypes.Dimensions + +type Bounds = datatypes.Bounds +val Bounds: datatypes.Bounds.type = datatypes.Bounds + +// UI Windows + +val WindowManager: window.WindowManager.type = window.WindowManager + +type WindowManagerModel[StartupData, A] = window.WindowManagerModel[StartupData, A] +val WindowManagerModel: window.WindowManagerModel.type = window.WindowManagerModel + +type WindowManagerViewModel[StartupData, A] = window.WindowManagerViewModel[StartupData, A] +val WindowManagerViewModel: window.WindowManagerViewModel.type = window.WindowManagerViewModel + +type WindowId = window.WindowId +val WindowId: window.WindowId.type = window.WindowId + +type WindowModel[StartupData, CA, A] = window.WindowModel[StartupData, CA, A] +val WindowModel: window.WindowModel.type = window.WindowModel + +type WindowViewModel = window.WindowViewModel +val WindowViewModel: window.WindowViewModel.type = window.WindowViewModel + +type WindowEvent = window.WindowEvent +val WindowEvent: window.WindowEvent.type = window.WindowEvent + +// UI Components + +type Component[A] = component.Component[A] + +type ComponentGroup = component.ComponentGroup +val ComponentGroup: component.ComponentGroup.type = component.ComponentGroup + +type ComponentFragment = component.ComponentFragment +val ComponentFragment: component.ComponentFragment.type = component.ComponentFragment + +type ComponentLayout = component.ComponentLayout +val ComponentLayout: component.ComponentLayout.type = component.ComponentLayout + +type Overflow = component.Overflow +val Overflow: component.Overflow.type = component.Overflow + +type Padding = component.Padding +val Padding: component.Padding.type = component.Padding + +// UI Built in components + +type Button = components.Button +val Button: components.Button.type = components.Button + +type Label = components.Label +val Label: components.Label.type = components.Label