Player move!

This commit is contained in:
nelle 2023-10-29 02:24:00 -06:00
parent f2a68b708b
commit ccba59e2ba
22 changed files with 575 additions and 18 deletions

View file

@ -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. 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.

View file

@ -1,6 +1,7 @@
import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar
val zircon_version: String by project val zircon_version: String by project
val amethyst_version: String by project
val slf4j_version: String by project val slf4j_version: String by project
val junit_version: String by project val junit_version: String by project
val mockito_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.core-jvm:$zircon_version")
implementation("org.hexworks.zircon:zircon.jvm.swing:$zircon_version") implementation("org.hexworks.zircon:zircon.jvm.swing:$zircon_version")
implementation("org.hexworks.amethyst:amethyst.core-jvm:$amethyst_version")
implementation(kotlin("stdlib-jdk8")) implementation(kotlin("stdlib-jdk8"))
testImplementation("junit:junit:$junit_version") testImplementation("junit:junit:$junit_version")

View file

@ -3,6 +3,7 @@ org.gradle.parallel=true
org.gradle.jvmargs=-Xmx2048M org.gradle.jvmargs=-Xmx2048M
org.gradle.daemon=true org.gradle.daemon=true
amethyst_version=2020.1.1-RELEASE
zircon_version=2021.1.0-RELEASE zircon_version=2021.1.0-RELEASE
junit_version=4.12 junit_version=4.12
mockito_version=1.10.19 mockito_version=1.10.19

View file

@ -17,10 +17,12 @@ object GameConfig {
const val LOG_AREA_HEIGHT = 12 const val LOG_AREA_HEIGHT = 12
// sizing // sizing
const val BORDERLESS_WINDOW_WIDTH = 120
const val BORDERLESS_WINDOW_HEIGHT = 65
const val WINDOW_WIDTH = 80 const val WINDOW_WIDTH = 80
const val WINDOW_HEIGHT = 50 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( val GAME_AREA_SIZE = Size3D.create(
xLength = WINDOW_WIDTH - SIDEBAR_WIDTH, xLength = WINDOW_WIDTH - SIDEBAR_WIDTH,
yLength = WINDOW_HEIGHT - LOG_AREA_HEIGHT, yLength = WINDOW_HEIGHT - LOG_AREA_HEIGHT,

View file

@ -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.
// Whats 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.
// Whats 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()
}

View file

@ -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()

View file

@ -0,0 +1,7 @@
package group.ouroboros.potrogue.attributes.types
import org.hexworks.amethyst.api.base.BaseEntityType
object Player : BaseEntityType(
name = "player"
)

View file

@ -2,20 +2,62 @@ package group.ouroboros.potrogue.blocks
import group.ouroboros.potrogue.builders.GameTileRepository.EMPTY import group.ouroboros.potrogue.builders.GameTileRepository.EMPTY
import group.ouroboros.potrogue.builders.GameTileRepository.FLOOR 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.builders.GameTileRepository.WALL
import group.ouroboros.potrogue.extensions.GameEntity
import group.ouroboros.potrogue.extensions.tile
import kotlinx.collections.immutable.persistentMapOf 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.BlockTileType
import org.hexworks.zircon.api.data.Tile import org.hexworks.zircon.api.data.Tile
import org.hexworks.zircon.api.data.base.BaseBlock import org.hexworks.zircon.api.data.base.BaseBlock
class GameBlock (content: Tile = FLOOR) : BaseBlock<Tile>( class GameBlock(
emptyTile = EMPTY, private var defaultTile: Tile = FLOOR,
tiles = persistentMapOf(BlockTileType.CONTENT to content) // We added currentEntities which is just a mutable list of Entity objects which is empty by default
private val currentEntities: MutableList<GameEntity<EntityType>> = mutableListOf(),
) : BaseBlock<Tile>(
emptyTile = Tile.empty(),
tiles = persistentMapOf(BlockTileType.CONTENT to defaultTile)
) { ) {
val isFloor: Boolean val isFloor: Boolean
get() = content == FLOOR get() = defaultTile == FLOOR
val isWall: Boolean 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 dont want to expose the internals of GameBlock which would make currentEntities mutable to the outside world
val entities: Iterable<GameEntity<EntityType>>
get() = currentEntities.toList()
// We expose a function for adding an Entity to our block
fun addEntity(entity: GameEntity<EntityType>) {
currentEntities.add(entity)
updateContent()
}
// And also for removing one
fun removeEntity(entity: GameEntity<EntityType>) {
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
}
}
} }

View file

@ -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 <T : EntityType> newGameEntityOfType(
type: T,
init: EntityBuilder<T, GameContext>.() -> Unit
) = newEntityOfType(type, init)
// We define our factory as an object since well 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)
}
}

View file

@ -10,4 +10,7 @@ object GameColors {
val FLOOR_FOREGROUND = TileColor.fromString("#75715E") val FLOOR_FOREGROUND = TileColor.fromString("#75715E")
val FLOOR_BACKGROUND = TileColor.fromString("#1e2320") val FLOOR_BACKGROUND = TileColor.fromString("#1e2320")
// Player Color?
val ACCENT_COLOR = TileColor.fromString("#FFCD22")
} }

View file

@ -1,5 +1,6 @@
package group.ouroboros.potrogue.builders 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_BACKGROUND
import group.ouroboros.potrogue.builders.GameColors.FLOOR_FOREGROUND import group.ouroboros.potrogue.builders.GameColors.FLOOR_FOREGROUND
import group.ouroboros.potrogue.builders.GameColors.WALL_BACKGROUND import group.ouroboros.potrogue.builders.GameColors.WALL_BACKGROUND
@ -28,4 +29,11 @@ object GameTileRepository {
.withBackgroundColor(WALL_BACKGROUND) .withBackgroundColor(WALL_BACKGROUND)
.buildCharacterTile() .buildCharacterTile()
//Player Tile
val PLAYER = Tile.newBuilder()
.withCharacter('☺')
.withBackgroundColor(FLOOR_BACKGROUND)
.withForegroundColor(ACCENT_COLOR)
.buildCharacterTile()
} }

View file

@ -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<EntityType>
typealias GameEntity<T> = Entity<T, GameContext>
typealias GameMessage = Message<GameContext>
// 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 <T : Attribute> AnyGameEntity.tryToFindAttribute(klass: KClass<T>): T = findAttribute(klass).orElseThrow {
NoSuchElementException("Entity '$this' has no property with type '${klass.simpleName}'.")
}

View file

@ -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<EntityType>,
val previousPosition: Position3D
) : GameMessage

View file

@ -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<EntityType>,
val position: Position3D
) : GameMessage

View file

@ -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<GameContext, MoveCamera>(MoveCamera::class) {
override suspend fun receive(message: MoveCamera): Response {
val (context, source, previousPosition) = message
val world = context.world
// The players position on the screen can be calculated by subtracting the Worlds visibleOffset from the players 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 Entitys 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
}
}

View file

@ -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<GameContext>() {
override suspend fun update(entity: Entity<EntityType, GameContext>, context: GameContext): Boolean {
// We destructure our context object so its properties are easy to access.
// Destructuring is positional, so here _ means that we dont 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
}
}

View file

@ -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, whats 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<GameContext, MoveTo>(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 well 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
}
}

View file

@ -2,10 +2,10 @@ package group.ouroboros.potrogue.view
import group.ouroboros.potrogue.GameConfig import group.ouroboros.potrogue.GameConfig
import group.ouroboros.potrogue.GameConfig.LOG_AREA_HEIGHT 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.GameConfig.WINDOW_WIDTH
import group.ouroboros.potrogue.builders.GameTileRepository import group.ouroboros.potrogue.builders.GameTileRepository
import group.ouroboros.potrogue.world.Game import group.ouroboros.potrogue.world.Game
import group.ouroboros.potrogue.world.GameBuilder
import org.hexworks.cobalt.databinding.api.extension.toProperty import org.hexworks.cobalt.databinding.api.extension.toProperty
import org.hexworks.zircon.api.ComponentDecorations.box import org.hexworks.zircon.api.ComponentDecorations.box
import org.hexworks.zircon.api.Components 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.grid.TileGrid
import org.hexworks.zircon.api.view.base.BaseView import org.hexworks.zircon.api.view.base.BaseView
import org.hexworks.zircon.internal.game.impl.GameAreaComponentRenderer 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 { init {
//Create Sidebar //Create Sidebar
val sidebar = Components.panel() 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) 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
}
} }
} }

View file

@ -1,21 +1,29 @@
package group.ouroboros.potrogue.world package group.ouroboros.potrogue.world
import group.ouroboros.potrogue.GameConfig.GAME_AREA_SIZE import group.ouroboros.potrogue.attributes.types.Player
import group.ouroboros.potrogue.GameConfig.WORLD_SIZE import group.ouroboros.potrogue.extensions.GameEntity
import group.ouroboros.potrogue.builders.WorldBuilder
import org.hexworks.zircon.api.data.Size3D
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 wont 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<Player>
) {
companion object { companion object {
fun create( fun create(
worldSize: Size3D = WORLD_SIZE, player: GameEntity<Player>,
visibleSize: Size3D = GAME_AREA_SIZE world: World
) = Game( ) = Game(
WorldBuilder(worldSize) world = world,
.makeCaves() player = player
.build(visibleSize)
) )
} }
} }

View file

@ -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<Player> {
// We create the player entity here since were 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()
}
}

View file

@ -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<Player>
) : Context

View file

@ -1,11 +1,20 @@
package group.ouroboros.potrogue.world package group.ouroboros.potrogue.world
import group.ouroboros.potrogue.blocks.GameBlock 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.builder.game.GameAreaBuilder
import org.hexworks.zircon.api.data.Position3D import org.hexworks.zircon.api.data.Position3D
import org.hexworks.zircon.api.data.Size3D import org.hexworks.zircon.api.data.Size3D
import org.hexworks.zircon.api.data.Tile import org.hexworks.zircon.api.data.Tile
import org.hexworks.zircon.api.game.GameArea import org.hexworks.zircon.api.game.GameArea
import org.hexworks.zircon.api.screen.Screen
import org.hexworks.zircon.api.uievent.UIEvent
class World ( 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. // 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) .withActualSize(actualSize)
.build() { .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 were keeping it simple.
private val engine: TurnBasedEngine<GameContext> = Engine.create()
init { init {
startingBlocks.forEach { (pos, block) -> 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? Well see soon enough wen we implement the WorldBuilder! // a World takes a Map of GameBlocks, so we need to add them to the GameArea. Where these blocks come from? Well see soon enough wen we implement the WorldBuilder!
setBlockAt(pos, block) 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<EntityType, GameContext>, 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<EntityType>,
offset: Position3D = Position3D.create(0, 0, 0),
size: Size3D = actualSize
): Boolean {
return findEmptyLocationWithin(offset, size).fold(
// If we didnt 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<Position3D> {
var position = Maybe.empty<Position3D>()
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 well be using it to display dialogs and similar things
screen = screen,
// Well inspect the UIEvent to determine what the user wants to do (like moving around).
// Were 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<EntityType>, 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<GameBlock>, newBlock: Maybe<GameBlock>) =
oldBlock.isPresent && newBlock.isPresent
} }