From ccba59e2ba4e8d3c7231f76fb32008f5d89a5236 Mon Sep 17 00:00:00 2001 From: LimePotato Date: Sun, 29 Oct 2023 02:24:00 -0600 Subject: [PATCH] Player move! --- README.md | 2 + build.gradle.kts | 3 + gradle.properties | 1 + .../group/ouroboros/potrogue/GameConfig.kt | 4 +- .../potrogue/attributes/EntityPosition.kt | 23 ++++ .../potrogue/attributes/EntityTile.kt | 7 + .../potrogue/attributes/types/Player.kt | 7 + .../ouroboros/potrogue/blocks/GameBlock.kt | 52 +++++++- .../potrogue/builders/EntityFactory.kt | 30 +++++ .../ouroboros/potrogue/builders/GameColors.kt | 3 + .../potrogue/builders/GameTileRepository.kt | 8 ++ .../potrogue/extensions/TypeAliases.kt | 34 +++++ .../ouroboros/potrogue/messages/MoveCamera.kt | 13 ++ .../ouroboros/potrogue/messages/MoveTo.kt | 13 ++ .../ouroboros/potrogue/systems/CameraMover.kt | 40 ++++++ .../potrogue/systems/InputReceiver.kt | 43 ++++++ .../ouroboros/potrogue/systems/Movable.kt | 61 +++++++++ .../group/ouroboros/potrogue/view/PlayView.kt | 12 +- .../group/ouroboros/potrogue/world/Game.kt | 28 ++-- .../ouroboros/potrogue/world/GameBuilder.kt | 67 ++++++++++ .../ouroboros/potrogue/world/GameContext.kt | 19 +++ .../group/ouroboros/potrogue/world/World.kt | 123 ++++++++++++++++++ 22 files changed, 575 insertions(+), 18 deletions(-) create mode 100644 src/main/kotlin/group/ouroboros/potrogue/attributes/EntityPosition.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/attributes/EntityTile.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/attributes/types/Player.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/builders/EntityFactory.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/extensions/TypeAliases.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/messages/MoveCamera.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/messages/MoveTo.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/systems/CameraMover.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/systems/InputReceiver.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/systems/Movable.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/world/GameBuilder.kt create mode 100644 src/main/kotlin/group/ouroboros/potrogue/world/GameContext.kt diff --git a/README.md b/README.md index 92703d6..19aa670 100644 --- a/README.md +++ b/README.md @@ -3,3 +3,5 @@ The plan is to be able to self-contain the entire game on a DVD/Blue-Ray once "completed." thatd be so cool and so epic. +Big help from [The Hexworks Tutorial](https://hexworks.org/posts/tutorials/2018/12/04/how-to-make-a-roguelike.html), which helped me understand the basics of zircon, +much of this code will be heavily transformed once complete. \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 9f0961c..8b4a7af 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,6 +1,7 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar val zircon_version: String by project +val amethyst_version: String by project val slf4j_version: String by project val junit_version: String by project val mockito_version: String by project @@ -31,6 +32,8 @@ dependencies { implementation("org.hexworks.zircon:zircon.core-jvm:$zircon_version") implementation("org.hexworks.zircon:zircon.jvm.swing:$zircon_version") + implementation("org.hexworks.amethyst:amethyst.core-jvm:$amethyst_version") + implementation(kotlin("stdlib-jdk8")) testImplementation("junit:junit:$junit_version") diff --git a/gradle.properties b/gradle.properties index 209fc96..fe1dfe8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -3,6 +3,7 @@ org.gradle.parallel=true org.gradle.jvmargs=-Xmx2048M org.gradle.daemon=true +amethyst_version=2020.1.1-RELEASE zircon_version=2021.1.0-RELEASE junit_version=4.12 mockito_version=1.10.19 diff --git a/src/main/kotlin/group/ouroboros/potrogue/GameConfig.kt b/src/main/kotlin/group/ouroboros/potrogue/GameConfig.kt index 1d505b1..247db01 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/GameConfig.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/GameConfig.kt @@ -17,10 +17,12 @@ object GameConfig { const val LOG_AREA_HEIGHT = 12 // sizing + const val BORDERLESS_WINDOW_WIDTH = 120 + const val BORDERLESS_WINDOW_HEIGHT = 65 const val WINDOW_WIDTH = 80 const val WINDOW_HEIGHT = 50 - val WORLD_SIZE = Size3D.create(WINDOW_WIDTH, WINDOW_HEIGHT, DUNGEON_LEVELS) + val WORLD_SIZE = Size3D.create(WINDOW_WIDTH * 2, WINDOW_HEIGHT * 2 , DUNGEON_LEVELS) val GAME_AREA_SIZE = Size3D.create( xLength = WINDOW_WIDTH - SIDEBAR_WIDTH, yLength = WINDOW_HEIGHT - LOG_AREA_HEIGHT, diff --git a/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityPosition.kt b/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityPosition.kt new file mode 100644 index 0000000..9c71857 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityPosition.kt @@ -0,0 +1,23 @@ +package group.ouroboros.potrogue.attributes + +import org.hexworks.amethyst.api.base.BaseAttribute +import org.hexworks.cobalt.databinding.api.extension.toProperty +import org.hexworks.zircon.api.data.Position3D +class EntityPosition( + // We add initialPosition as a constructor parameter to our class and its default value is unknown. + // What’s this? Position3D comes from Zircon and can be used to represent a point in 3D space (as we have discussed before), + // and unknown impelments the Null Object Pattern for us. + initialPosition: Position3D = Position3D.unknown() +) : BaseAttribute() { + + // Here we create a private Property from the initialPosition. + // What’s a Property you might ask? Well, it is used for data binding. + // A Property is a wrapper for a value that can change over time. + // It can be bound to other Property objects so their values change together and you can also add change listeners to them. + // Property comes from the Cobalt library we use and it works in a very similar way as properties work in JavaFX. + private val positionProperty = initialPosition.toProperty() + + // We create a Kotlin delegate from our Property. + // This means that position will be accessible to the outside world as if it was a simple field, but it takes its value from our Property under the hood. + var position: Position3D by positionProperty.asDelegate() +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityTile.kt b/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityTile.kt new file mode 100644 index 0000000..c48eeb6 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/attributes/EntityTile.kt @@ -0,0 +1,7 @@ +package group.ouroboros.potrogue.attributes + +import org.hexworks.amethyst.api.base.BaseAttribute +import org.hexworks.zircon.api.data.Tile + +// EntityTile is an Attribute which holds the Tile of an Entity we use to display it in our world +data class EntityTile(val tile: Tile = Tile.empty()) : BaseAttribute() diff --git a/src/main/kotlin/group/ouroboros/potrogue/attributes/types/Player.kt b/src/main/kotlin/group/ouroboros/potrogue/attributes/types/Player.kt new file mode 100644 index 0000000..b25ebf9 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/attributes/types/Player.kt @@ -0,0 +1,7 @@ +package group.ouroboros.potrogue.attributes.types + +import org.hexworks.amethyst.api.base.BaseEntityType + +object Player : BaseEntityType( + name = "player" +) \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/blocks/GameBlock.kt b/src/main/kotlin/group/ouroboros/potrogue/blocks/GameBlock.kt index 9668fff..2b13857 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/blocks/GameBlock.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/blocks/GameBlock.kt @@ -2,20 +2,62 @@ package group.ouroboros.potrogue.blocks import group.ouroboros.potrogue.builders.GameTileRepository.EMPTY import group.ouroboros.potrogue.builders.GameTileRepository.FLOOR +import group.ouroboros.potrogue.builders.GameTileRepository.PLAYER import group.ouroboros.potrogue.builders.GameTileRepository.WALL +import group.ouroboros.potrogue.extensions.GameEntity +import group.ouroboros.potrogue.extensions.tile import kotlinx.collections.immutable.persistentMapOf +import org.hexworks.amethyst.api.entity.EntityType import org.hexworks.zircon.api.data.BlockTileType import org.hexworks.zircon.api.data.Tile import org.hexworks.zircon.api.data.base.BaseBlock -class GameBlock (content: Tile = FLOOR) : BaseBlock( - emptyTile = EMPTY, - tiles = persistentMapOf(BlockTileType.CONTENT to content) +class GameBlock( + private var defaultTile: Tile = FLOOR, + // We added currentEntities which is just a mutable list of Entity objects which is empty by default + private val currentEntities: MutableList> = mutableListOf(), +) : BaseBlock( + emptyTile = Tile.empty(), + tiles = persistentMapOf(BlockTileType.CONTENT to defaultTile) ) { val isFloor: Boolean - get() = content == FLOOR + get() = defaultTile == FLOOR val isWall: Boolean - get() = content == WALL + get() = defaultTile == WALL + + // We add a property which tells whether this block is just a floor (similar to isWall) + val isEmptyFloor: Boolean + get() = currentEntities.isEmpty() + + // Exposed a getter for entities which takes a snapshot (defensive copy) of the current entities and returns them. + // We do this because we don’t want to expose the internals of GameBlock which would make currentEntities mutable to the outside world + val entities: Iterable> + get() = currentEntities.toList() + + // We expose a function for adding an Entity to our block + fun addEntity(entity: GameEntity) { + currentEntities.add(entity) + updateContent() + } + + // And also for removing one + fun removeEntity(entity: GameEntity) { + currentEntities.remove(entity) + updateContent() + } + + // Incorporated our entities to how we display a block by + private fun updateContent() { + val entityTiles = currentEntities.map { it.tile } + content = when { + // Checking if the player is at this block. If yes it is displayed on top + entityTiles.contains(PLAYER) -> PLAYER + // Otherwise the first Entity is displayed if present + entityTiles.isNotEmpty() -> entityTiles.first() + // Or the default tile if not + else -> defaultTile + } + } } diff --git a/src/main/kotlin/group/ouroboros/potrogue/builders/EntityFactory.kt b/src/main/kotlin/group/ouroboros/potrogue/builders/EntityFactory.kt new file mode 100644 index 0000000..4e2c20b --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/builders/EntityFactory.kt @@ -0,0 +1,30 @@ +package group.ouroboros.potrogue.builders + +import group.ouroboros.potrogue.attributes.EntityPosition +import group.ouroboros.potrogue.attributes.EntityTile +import group.ouroboros.potrogue.attributes.types.Player +import group.ouroboros.potrogue.systems.CameraMover +import group.ouroboros.potrogue.systems.InputReceiver +import group.ouroboros.potrogue.systems.Movable +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.builder.EntityBuilder +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.amethyst.api.newEntityOfType + +// We add a function which calls Entities.newEntityOfType and pre-fills the generic type parameter for Context with GameContext. +fun newGameEntityOfType( + type: T, + init: EntityBuilder.() -> Unit +) = newEntityOfType(type, init) + +// We define our factory as an object since we’ll only ever have a single instance of it. +object EntityFactory { + + // We add a function for creating a newPlayer and call newGameEntityOfType with our previously created Player type. + fun newPlayer() = newGameEntityOfType(Player) { + // We specify our Attributes, Behaviors and Facets. We only have Attributes so far though. + attributes(EntityPosition(), EntityTile(GameTileRepository.PLAYER)) + behaviors(InputReceiver) + facets(Movable, CameraMover) + } +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/builders/GameColors.kt b/src/main/kotlin/group/ouroboros/potrogue/builders/GameColors.kt index 2dec323..469530a 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/builders/GameColors.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/builders/GameColors.kt @@ -10,4 +10,7 @@ object GameColors { val FLOOR_FOREGROUND = TileColor.fromString("#75715E") val FLOOR_BACKGROUND = TileColor.fromString("#1e2320") + + // Player Color? + val ACCENT_COLOR = TileColor.fromString("#FFCD22") } \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/builders/GameTileRepository.kt b/src/main/kotlin/group/ouroboros/potrogue/builders/GameTileRepository.kt index 9b96b3a..f9ff0c1 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/builders/GameTileRepository.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/builders/GameTileRepository.kt @@ -1,5 +1,6 @@ package group.ouroboros.potrogue.builders +import group.ouroboros.potrogue.builders.GameColors.ACCENT_COLOR import group.ouroboros.potrogue.builders.GameColors.FLOOR_BACKGROUND import group.ouroboros.potrogue.builders.GameColors.FLOOR_FOREGROUND import group.ouroboros.potrogue.builders.GameColors.WALL_BACKGROUND @@ -28,4 +29,11 @@ object GameTileRepository { .withBackgroundColor(WALL_BACKGROUND) .buildCharacterTile() + //Player Tile + val PLAYER = Tile.newBuilder() + .withCharacter('☺') + .withBackgroundColor(FLOOR_BACKGROUND) + .withForegroundColor(ACCENT_COLOR) + .buildCharacterTile() + } \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/extensions/TypeAliases.kt b/src/main/kotlin/group/ouroboros/potrogue/extensions/TypeAliases.kt new file mode 100644 index 0000000..3d18d32 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/extensions/TypeAliases.kt @@ -0,0 +1,34 @@ +package group.ouroboros.potrogue.extensions + +import group.ouroboros.potrogue.attributes.EntityPosition +import group.ouroboros.potrogue.attributes.EntityTile +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.Attribute +import org.hexworks.amethyst.api.entity.Entity +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.zircon.api.data.Tile +import kotlin.reflect.KClass +import org.hexworks.amethyst.api.Message + +typealias AnyGameEntity = GameEntity +typealias GameEntity = Entity +typealias GameMessage = Message + +// Create an extension property (works the same way as an extension function) on AnyGameEntity. +var AnyGameEntity.position + // Define a getter for it which tries to find the EntityPosition attribute in our Entity and throws and exception if the Entity has no position. + get() = tryToFindAttribute(EntityPosition::class).position + // We also define a setter for it which sets the Property we defined before + set(value) { + findAttribute(EntityPosition::class).map { + it.position = value + } + } + +val AnyGameEntity.tile: Tile + get() = this.tryToFindAttribute(EntityTile::class).tile + +// Define a function which implements the “try to find or throw an exception” logic for both of our properties. +fun AnyGameEntity.tryToFindAttribute(klass: KClass): T = findAttribute(klass).orElseThrow { + NoSuchElementException("Entity '$this' has no property with type '${klass.simpleName}'.") +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/messages/MoveCamera.kt b/src/main/kotlin/group/ouroboros/potrogue/messages/MoveCamera.kt new file mode 100644 index 0000000..93192a3 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/messages/MoveCamera.kt @@ -0,0 +1,13 @@ +package group.ouroboros.potrogue.messages + +import group.ouroboros.potrogue.extensions.GameEntity +import group.ouroboros.potrogue.extensions.GameMessage +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.zircon.api.data.Position3D + +data class MoveCamera( + override val context: GameContext, + override val source: GameEntity, + val previousPosition: Position3D +) : GameMessage diff --git a/src/main/kotlin/group/ouroboros/potrogue/messages/MoveTo.kt b/src/main/kotlin/group/ouroboros/potrogue/messages/MoveTo.kt new file mode 100644 index 0000000..66b1130 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/messages/MoveTo.kt @@ -0,0 +1,13 @@ +package group.ouroboros.potrogue.messages + +import group.ouroboros.potrogue.extensions.GameEntity +import group.ouroboros.potrogue.extensions.GameMessage +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.zircon.api.data.Position3D + +data class MoveTo( + override val context: GameContext, + override val source: GameEntity, + val position: Position3D +) : GameMessage diff --git a/src/main/kotlin/group/ouroboros/potrogue/systems/CameraMover.kt b/src/main/kotlin/group/ouroboros/potrogue/systems/CameraMover.kt new file mode 100644 index 0000000..c428668 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/systems/CameraMover.kt @@ -0,0 +1,40 @@ +package group.ouroboros.potrogue.systems + +import group.ouroboros.potrogue.extensions.position +import group.ouroboros.potrogue.messages.MoveCamera +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.Consumed +import org.hexworks.amethyst.api.Response +import org.hexworks.amethyst.api.base.BaseFacet + +object CameraMover : BaseFacet(MoveCamera::class) { + + override suspend fun receive(message: MoveCamera): Response { + val (context, source, previousPosition) = message + val world = context.world + // The player’s position on the screen can be calculated by subtracting the World’s visibleOffset from the player’s position. + // The visibleOffset is the top left position of the visible part of the World relative to the top left corner of the whole World (which is 0, 0). + val screenPos = source.position - world.visibleOffset + // We calculate the center position of the visible part of the world here + val halfHeight = world.visibleSize.yLength / 2 + val halfWidth = world.visibleSize.xLength / 2 + val currentPosition = source.position + // And we only move the camera if we moved in a certain direction (left for example) and the Entity’s position on the screen is left of the middle position. + // The logic is the same for all directions, but we use the corresponding x or y coordinate + when { + previousPosition.y > currentPosition.y && screenPos.y < halfHeight -> { + world.scrollOneBackward() + } + previousPosition.y < currentPosition.y && screenPos.y > halfHeight -> { + world.scrollOneForward() + } + previousPosition.x > currentPosition.x && screenPos.x < halfWidth -> { + world.scrollOneLeft() + } + previousPosition.x < currentPosition.x && screenPos.x > halfWidth -> { + world.scrollOneRight() + } + } + return Consumed + } +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/systems/InputReceiver.kt b/src/main/kotlin/group/ouroboros/potrogue/systems/InputReceiver.kt new file mode 100644 index 0000000..bb895ef --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/systems/InputReceiver.kt @@ -0,0 +1,43 @@ +package group.ouroboros.potrogue.systems + +import group.ouroboros.potrogue.extensions.position +import group.ouroboros.potrogue.messages.MoveTo +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.base.BaseBehavior +import org.hexworks.amethyst.api.entity.Entity +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.zircon.api.uievent.KeyCode +import org.hexworks.zircon.api.uievent.KeyboardEvent +import kotlin.system.exitProcess + +// InputReceiver is pretty simple, it just checks for WASD, and acts accordingly +object InputReceiver : BaseBehavior() { + + override suspend fun update(entity: Entity, context: GameContext): Boolean { + // We destructure our context object so its properties are easy to access. + // Destructuring is positional, so here _ means that we don’t care about that specific property. + val (_, _, uiEvent, player) = context + val currentPos = player.position + // We only want KeyboardEvents for now so we check with the is operator. + // This is similar as the instanceof operator in Java but a bit more useful. + if (uiEvent is KeyboardEvent) { + // We use when which is similar to switch in Java to check which key was pressed. + // Zircon has a KeyCode for all keys which can be pressed. when in Kotlin is also an expression, + // and not a statement so it returns a value. We can change it into our newPosition variable. + val newPosition = when (uiEvent.code) { + KeyCode.KEY_W -> currentPos.withRelativeY(-1) + KeyCode.KEY_A -> currentPos.withRelativeX(-1) + KeyCode.KEY_S -> currentPos.withRelativeY(1) + KeyCode.KEY_D -> currentPos.withRelativeX(1) + KeyCode.ESCAPE -> exitProcess(0) + else -> { + // If some key is pressed other than WASD, then we just return the current position, so no movement will happen + currentPos + } + } + // We receive the MoveTo message on our player here. + player.receiveMessage(MoveTo(context, player, newPosition)) + } + return true + } +} diff --git a/src/main/kotlin/group/ouroboros/potrogue/systems/Movable.kt b/src/main/kotlin/group/ouroboros/potrogue/systems/Movable.kt new file mode 100644 index 0000000..9d1cd37 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/systems/Movable.kt @@ -0,0 +1,61 @@ +package group.ouroboros.potrogue.systems + +import group.ouroboros.potrogue.messages.MoveTo +import group.ouroboros.potrogue.world.GameContext +import org.hexworks.amethyst.api.Consumed +import org.hexworks.amethyst.api.Pass +import org.hexworks.amethyst.api.Response +import org.hexworks.amethyst.api.base.BaseFacet +import group.ouroboros.potrogue.attributes.types.Player +import group.ouroboros.potrogue.extensions.position +import group.ouroboros.potrogue.messages.MoveCamera +import org.hexworks.amethyst.api.MessageResponse + +/* +* Hey, what’s Pass and Consumed? +* Why do we have to return anything? Good question! When an Entity receives a Message it tries to send the given message to its Facets in order. +* Each Facet has to return a Response. There are 3 kinds: Pass, Consumed and MessageResponse. If we return Pass, the loop continues and the entity tries the next Facet. +* If we return Consumed, the loop stops. MessageResponse is special, we can return a new message using it and the entity will continue the loop using the new Message! +* This is useful for implementing complex interactions between entities + */ + +// A Facet accepts only a specific message, so we have to indicate that we only handle MoveTo. +object Movable : BaseFacet(MoveTo::class) { + + override suspend fun receive(message: MoveTo): Response { + /* + * This funky (context, entity, position) code is called Destructuring. + * This might be familiar for Python folks and what it does is that it unpacks the values from an object which supports it. So writing this: + * val (context, entity, position) = myObj + * + * is the equivalent of writing this: + * val context = myObj.context + * val entity = myObj.entity + * val position = myObj.position + */ + val (context, entity, position) = message + val world = context.world + // we save the previous position before we change it + val previousPosition = entity.position + // Here we say that we’ll return Pass as a default + var result: Response = Pass + // Then we check whether moving the entity was successful or not (remember the success return value?) + if (world.moveEntity(entity, position)) { + // If the move was successful and the entity we moved is the player + result = if (entity.type == Player) { + MessageResponse( + // We return the MessageResponse + MoveCamera( + context = context, + source = entity, + previousPosition = previousPosition + ) + ) + // Otherwise we keep the Consumed response + } else Consumed + } + // Finally we return the result + return result + } + +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/view/PlayView.kt b/src/main/kotlin/group/ouroboros/potrogue/view/PlayView.kt index a094695..d91e033 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/view/PlayView.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/view/PlayView.kt @@ -2,10 +2,10 @@ package group.ouroboros.potrogue.view import group.ouroboros.potrogue.GameConfig import group.ouroboros.potrogue.GameConfig.LOG_AREA_HEIGHT -import group.ouroboros.potrogue.GameConfig.SIDEBAR_WIDTH import group.ouroboros.potrogue.GameConfig.WINDOW_WIDTH import group.ouroboros.potrogue.builders.GameTileRepository import group.ouroboros.potrogue.world.Game +import group.ouroboros.potrogue.world.GameBuilder import org.hexworks.cobalt.databinding.api.extension.toProperty import org.hexworks.zircon.api.ComponentDecorations.box import org.hexworks.zircon.api.Components @@ -15,9 +15,11 @@ import org.hexworks.zircon.api.game.ProjectionMode import org.hexworks.zircon.api.grid.TileGrid import org.hexworks.zircon.api.view.base.BaseView import org.hexworks.zircon.internal.game.impl.GameAreaComponentRenderer +import org.hexworks.zircon.api.uievent.KeyboardEventType +import org.hexworks.zircon.api.uievent.Processed -class PlayView (private val grid: TileGrid, private val game: Game = Game.create(), theme: ColorTheme = GameConfig.THEME) : BaseView(grid, theme) { +class PlayView (private val grid: TileGrid, private val game: Game = GameBuilder.create(), theme: ColorTheme = GameConfig.THEME) : BaseView(grid, theme) { init { //Create Sidebar val sidebar = Components.panel() @@ -47,5 +49,11 @@ class PlayView (private val grid: TileGrid, private val game: Game = Game.create screen.addComponents(sidebar, logArea, gameComponent) + // modify our PlayView to update our world whenever the user presses a key + screen.handleKeyboardEvents(KeyboardEventType.KEY_PRESSED) {event, _ -> + game.world.update(screen, event, game) + Processed + } + } } \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/world/Game.kt b/src/main/kotlin/group/ouroboros/potrogue/world/Game.kt index 6b597d9..c137acd 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/world/Game.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/world/Game.kt @@ -1,21 +1,29 @@ package group.ouroboros.potrogue.world -import group.ouroboros.potrogue.GameConfig.GAME_AREA_SIZE -import group.ouroboros.potrogue.GameConfig.WORLD_SIZE -import group.ouroboros.potrogue.builders.WorldBuilder -import org.hexworks.zircon.api.data.Size3D +import group.ouroboros.potrogue.attributes.types.Player +import group.ouroboros.potrogue.extensions.GameEntity -class Game (val world: World) { +/* + * The TL;DR for DIP is this: By stating what we need (the World here) but not how we get it we let the outside world decide how to provide it for us. + * This is also called “Wishful Thinking”. + * This kind of dependency inversion lets the users of our program inject any kind of object that corresponds to the World contract. + * For example we can create an in-memory world, one which is stored in a database or one which is generated on the fly. Game won’t care! + * This is in stark contrast to what we had before: an explicit instantiation of World by using the WorldBuilder. + */ + +class Game ( + val world: World, + val player: GameEntity +) { companion object { fun create( - worldSize: Size3D = WORLD_SIZE, - visibleSize: Size3D = GAME_AREA_SIZE + player: GameEntity, + world: World ) = Game( - WorldBuilder(worldSize) - .makeCaves() - .build(visibleSize) + world = world, + player = player ) } } diff --git a/src/main/kotlin/group/ouroboros/potrogue/world/GameBuilder.kt b/src/main/kotlin/group/ouroboros/potrogue/world/GameBuilder.kt new file mode 100644 index 0000000..1ea1f50 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/world/GameBuilder.kt @@ -0,0 +1,67 @@ +package group.ouroboros.potrogue.world + +import group.ouroboros.potrogue.GameConfig +import group.ouroboros.potrogue.GameConfig.LOG_AREA_HEIGHT +import group.ouroboros.potrogue.GameConfig.SIDEBAR_WIDTH +import group.ouroboros.potrogue.GameConfig.WINDOW_HEIGHT +import group.ouroboros.potrogue.GameConfig.WINDOW_WIDTH +import group.ouroboros.potrogue.GameConfig.WORLD_SIZE +import group.ouroboros.potrogue.attributes.types.Player +import group.ouroboros.potrogue.builders.EntityFactory +import group.ouroboros.potrogue.builders.WorldBuilder +import group.ouroboros.potrogue.extensions.GameEntity +import org.hexworks.zircon.api.data.Position3D +import org.hexworks.zircon.api.data.Size3D + +// Take the size of the World as a parameter +class GameBuilder (val worldSize: Size3D) { + + // We define the visible size which is our viewport of the world + private val visibleSize = Size3D.create( + xLength = WINDOW_WIDTH - SIDEBAR_WIDTH, + yLength = WINDOW_HEIGHT - LOG_AREA_HEIGHT, + zLength = 1 + ) + + // We build our World here as part of the Game + val world = WorldBuilder(worldSize) + .makeCaves() + .build(visibleSize = visibleSize) + + fun buildGame(): Game { + + prepareWorld() + + val player = addPlayer() + + return Game.create( + player = player, + world = world + ) + } + + // prepareWorld can be called with method chaining here, since also will return the GameBuilder object + private fun prepareWorld() = also { + world.scrollUpBy(world.actualSize.zLength) + } + + private fun addPlayer(): GameEntity { + // We create the player entity here since we’re going to pass it as a parameter to other objects + val player = EntityFactory.newPlayer() + world.addAtEmptyPosition( + // We immediately add the player to the World which takes an offset and a size as a parameter + player, + // offset determines the position where the search for empty positions will start. Here we specify that the top level will be searched starting at (0, 0) + offset = Position3D.create(0, 0, GameConfig.DUNGEON_LEVELS - 1), + size = world.visibleSize.copy(zLength = 0) + ) // And we also determine that we should search only the throughout the viewport. This ensures that the player will be visible on the screen when we start the game + return player + } + + companion object { + + fun create() = GameBuilder( + worldSize = WORLD_SIZE + ).buildGame() + } +} \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/world/GameContext.kt b/src/main/kotlin/group/ouroboros/potrogue/world/GameContext.kt new file mode 100644 index 0000000..fbeb130 --- /dev/null +++ b/src/main/kotlin/group/ouroboros/potrogue/world/GameContext.kt @@ -0,0 +1,19 @@ +package group.ouroboros.potrogue.world + +import group.ouroboros.potrogue.extensions.GameEntity +import org.hexworks.amethyst.api.Context +import org.hexworks.zircon.api.screen.Screen +import org.hexworks.zircon.api.uievent.UIEvent +import group.ouroboros.potrogue.attributes.types.Player + +data class GameContext( + // The world itself + val world: World, + // The Screen object which we can use to open dialogs and interact with the UI in general + val screen: Screen, + // The UIEvent which caused the update of the world (a key press for example) + val uiEvent: UIEvent, + // The object representing the player. This is optional, but because we use the player in a lot of places it makes sense to add it here + val player: GameEntity + +) : Context \ No newline at end of file diff --git a/src/main/kotlin/group/ouroboros/potrogue/world/World.kt b/src/main/kotlin/group/ouroboros/potrogue/world/World.kt index 2de412a..aa6cc36 100644 --- a/src/main/kotlin/group/ouroboros/potrogue/world/World.kt +++ b/src/main/kotlin/group/ouroboros/potrogue/world/World.kt @@ -1,11 +1,20 @@ package group.ouroboros.potrogue.world import group.ouroboros.potrogue.blocks.GameBlock +import group.ouroboros.potrogue.extensions.GameEntity +import group.ouroboros.potrogue.extensions.position +import org.hexworks.amethyst.api.Engine +import org.hexworks.amethyst.api.entity.Entity +import org.hexworks.amethyst.api.entity.EntityType +import org.hexworks.amethyst.internal.TurnBasedEngine +import org.hexworks.cobalt.datatypes.Maybe import org.hexworks.zircon.api.builder.game.GameAreaBuilder import org.hexworks.zircon.api.data.Position3D import org.hexworks.zircon.api.data.Size3D import org.hexworks.zircon.api.data.Tile import org.hexworks.zircon.api.game.GameArea +import org.hexworks.zircon.api.screen.Screen +import org.hexworks.zircon.api.uievent.UIEvent class World ( // A World object is about holding the world data in memory, but it is not about generating it, so we take the initial state of the world as a parameter. @@ -20,10 +29,124 @@ class World ( .withActualSize(actualSize) .build() { + // We added the Engine to the world which handles our entities. + // We could have used dependency inversion here, but this is not likely to change in the future so we’re keeping it simple. + private val engine: TurnBasedEngine = Engine.create() + init { startingBlocks.forEach { (pos, block) -> // a World takes a Map of GameBlocks, so we need to add them to the GameArea. Where these blocks come from? We’ll see soon enough wen we implement the WorldBuilder! setBlockAt(pos, block) + block.entities.forEach { entity -> + // Also added the Entities in the starting blocks to our engine + engine.addEntity(entity) + // Saved their position + entity.position = pos } } +} + /** + * Adds the given [Entity] at the given [Position3D]. + * Has no effect if this world already contains the + * given [Entity]. + */ + // Added a function for adding new entities + fun addEntity(entity: Entity, position: Position3D) { + entity.position = position + engine.addEntity(entity) + fetchBlockAt(position).map { + it.addEntity(entity) + } + } + + // Added a function for adding an Entity at an empty position. + // This function needs a little explanation though. + // What happens here is that we try to find and empty position in our World within the given bounds (offset and size). + // Using this function we can limit the search for empty positions to a single level or multiple levels, and also within a given level. + // This will be very useful later. + fun addAtEmptyPosition( + entity: GameEntity, + offset: Position3D = Position3D.create(0, 0, 0), + size: Size3D = actualSize + ): Boolean { + return findEmptyLocationWithin(offset, size).fold( + // If we didn’t find an empty position, then we return with false indicating that we were not successful + whenEmpty = { + false + }, + // Otherwise we add the Entity at the position which was found. + whenPresent = { location -> + addEntity(entity, location) + true + }) + + } + + /** + * Finds an empty location within the given area (offset and size) on this [World]. + */ + // This function performs a random serach for an empty position. + // To prevent seraching endlessly in a World which has none, we limit the maximum number of tries to 10. + fun findEmptyLocationWithin(offset: Position3D, size: Size3D): Maybe { + var position = Maybe.empty() + val maxTries = 10 + var currentTry = 0 + while (position.isPresent.not() && currentTry < maxTries) { + val pos = Position3D.create( + x = (Math.random() * size.xLength).toInt() + offset.x, + y = (Math.random() * size.yLength).toInt() + offset.y, + z = (Math.random() * size.zLength).toInt() + offset.z + ) + fetchBlockAt(pos).map { + if (it.isEmptyFloor) { + position = Maybe.of(pos) + } + } + currentTry++ + } + return position + } + + // Create the function update which takes all the necessary objects as parameters + fun update(screen: Screen, uiEvent: UIEvent, game: Game) { + // We use the context object which we created before to update the engine. + // If you were wondering before why this class will be necessary now you know: + // a Context object holds all the information which might be necessary to update the entity objects within our world + engine.executeTurn(GameContext( + world = this, + // We pass the screen because we’ll be using it to display dialogs and similar things + screen = screen, + // We’ll inspect the UIEvent to determine what the user wants to do (like moving around). + // We’re using UIEvent instead of KeyboardEvent here because it is possible that at some time we also want to use mouse events. + uiEvent = uiEvent, + // Adding the player entity to the context is not mandatory, + // but since we use it almost everywhere this little optimization will make our life easier. + player = game.player)) + } + + // We pass the entity we want to move and the position where we want to move it. + fun moveEntity(entity: GameEntity, position: Position3D): Boolean { + // We create a success variable which holds a Boolean value representing whether the operation was successful + var success = false + // We fetch both blocks + val oldBlock = fetchBlockAt(entity.position) + val newBlock = fetchBlockAt(position) + + // We only proceed if both blocks are present + if (bothBlocksPresent(oldBlock, newBlock)) { + // In that case success is true + success = true + oldBlock.get().removeEntity(entity) + entity.position = position + newBlock.get().addEntity(entity) + } + //Then we return success + return success + } + + // This is an example of giving a name to a logical operation. + // In this case it is very simple but sometimes logical operations become very complex and it makes sense to give them a name like this (“both blocks present?”) + // so they are easy to reason about. + private fun bothBlocksPresent(oldBlock: Maybe, newBlock: Maybe) = + oldBlock.isPresent && newBlock.isPresent } \ No newline at end of file