Skip to content

Commit

Permalink
Merge branch 'feature/entity-component-system'
Browse files Browse the repository at this point in the history
  • Loading branch information
Zomis committed Feb 9, 2019
2 parents 7e9a874 + ccf8640 commit 4c9feef
Show file tree
Hide file tree
Showing 37 changed files with 1,541 additions and 241 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,12 @@ It is also possible that in the future I will with the event-based functionality

I wrote my own tool to generate documentation for how the client and server communicates. As I am sometimes not motivated to write test-cases and even less motivated to write documentation, I figured that I might as well generate the documentation from the test-cases. I have integrated a check in my build pipeline to make sure that the documentation is not outdated.

## Future possibilities
## Future possibilities and random notes

* As each listener has a description, it would be possible to check if there is a `System.getProperty`
for a cleaned version of that description and if it has a specific value then don't add the listener (automatic feature-toggling)
* Make more of the Server-code be shared between clients, such as the InviteSystem, GamesSystem, etc.
Then it would be possible to construct a Client in JavaScript and the server as well, and use more of the real server-code in JavaScript (to avoid making local games special cases)
* Use an Entity Component System approach in both the server-server code and in the games code
If ECS would be used throughout, then a WebSocketClient or CommunicationClient could just be added to the Player Entity
A Game could be the same Entity in both player-logic and server-logic. Would there be any downside to this?
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,11 @@ open class EventSystem {
return listen(description, ListenerPriority.NORMAL, clazz, condition, handler)
}

open fun <E : Any> execute(event: E) {
open fun <E : Any> execute(event: E): E {
logger.info("Execute: $event")
val kclass = event::class as KClass<Any>
listeners[kclass]?.execute(event)
return event
}

fun with(registrator: EventRegistrator): EventSystem {
Expand Down
36 changes: 36 additions & 0 deletions games-core/src/main/kotlin/net/zomis/games/Features.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package net.zomis.games

import klogging.KLoggers
import net.zomis.core.events.EventSystem
import kotlin.reflect.KClass

// TODO: Is it possible to use a FeatureKey<T> and pass that to dependent features? Then use this key to fetch with some typesafety
// TODO: Maybe even FeatureKey<T, R> to know what the feature was added *to* as well?
typealias Feature = (Features, EventSystem) -> Unit

/**
* Acts as both a data storage and a way to plugin new event listeners.
*/
class Features(val events: EventSystem?) {

private val logger = KLoggers.logger(this)
val data = mutableSetOf<Any>()

operator fun <T: Any> get(clazz: KClass<T>): T {
val value = data.find({ clazz.isInstance(it) })
return if (value != null) value as T else
throw NullPointerException("No such component: $clazz on $this. Available features are ${data.map { it::class }}")
}

fun <T: Any> addData(dataToAdd: T): T {
logger.info("$this adding data $dataToAdd")
data.add(dataToAdd)
return dataToAdd
}

fun add(feature: Feature): Features {
feature(this, events!!)
return this
}

}
60 changes: 57 additions & 3 deletions games-core/src/main/kotlin/net/zomis/games/core/Components.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,71 @@ open class DynamicComponent: Component()
open class LogicComponent: Component()
class Targetable: LogicComponent()
class Actionable: LogicComponent()
data class ActionEvent(val actionable: Entity, val initiatedBy: Entity)
data class ActionAllowedCheck(val actionable: Entity, val initiatedBy: Entity, var denyReason: String? = null) {
fun deny(reason: String) {
this.denyReason = reason
}

val allowed: Boolean
get() = this.denyReason == null
}
data class ActionEvent(val actionable: Entity, val initiatedBy: Entity, var denyReason: String? = null) {
fun deny(reason: String) {
this.denyReason = reason
}
}

//import kotlin.reflect.KClass
//data class LimitedVisibility(val componentClass: KClass<*>, val sees: (Entity) -> Any): LogicComponent()
data class EntityComponent<out T: Component>(val entity: Entity, val component: T)


data class Tile(val x: Int, val y: Int): FixedComponent()
data class Container2D(val container: List<List<Entity>>): ContainerComponent()

data class Player(val index: Int, var winner: Boolean?, var resultPosition: Int?): DataComponent()
enum class WinStatus { WIN, LOSS, DRAW }
data class Player(val index: Int, var result: WinStatus?, var resultPosition: Int?): DataComponent() {
val eliminated: Boolean
get() = result != null
}
data class OwnedByPlayer(var owner: Player?): DataComponent()
data class Players(val players: List<Entity>): ContainerComponent()
data class Players(val players: List<Entity>): ContainerComponent() {
fun eliminate(index: Int, result: WinStatus, position: Int): Players {
players[index].updateComponent(Player::class) {
it.result = result
it.resultPosition = position
}
return this
}

fun getResultPosition(result: WinStatus): Int {
// if no one else has been eliminated, the player is at 1st place. Because the player itself has not been eliminated, it should get increased below.
var playerResultPosition = players.size + 1
if (result != WinStatus.LOSS) {
playerResultPosition = 0
}

var posTaken: Boolean
do {
playerResultPosition += if (result == WinStatus.LOSS) -1 else +1
posTaken = this.players.asSequence().map { it.component(Player::class) }.any { it.eliminated && it.resultPosition == playerResultPosition }
} while (posTaken)
return playerResultPosition
}

fun eliminate(index: Int, result: WinStatus): Players {
return this.eliminate(index, result, getResultPosition(result))
}

fun eliminateRemaining(result: WinStatus): Players {
val position = getResultPosition(result)
players.forEachIndexed {index, player ->
if (!player.component(Player::class).eliminated) {
eliminate(index, result, position)
}
}
return this
}
}
data class PlayerTurn(var currentPlayer: Player): DataComponent()

28 changes: 26 additions & 2 deletions games-core/src/main/kotlin/net/zomis/games/core/Entity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,22 @@ package net.zomis.games.core

import kotlin.reflect.KClass

class Entity {
data class UpdateEntityEvent(val entity: Entity, val componentClass: KClass<*>, val value: Component)

open class Entity(val world: World, val id: String) {

fun <T: Component> component(clazz: KClass<T>): T {
return components.find({ clazz.isInstance(it) })!! as T
val value = components.find({ clazz.isInstance(it) })
return if (value != null) value as T else
throw NullPointerException("No such component: $clazz on $this. Available components are ${components.map { it::class }}")
}

fun <T: Component> componentOrNull(clazz: KClass<T>): T? {
return components.find({ clazz.isInstance(it) }) as T?
}

fun components(): Set<Component> {
return components
}

fun add(component: Component): Entity {
Expand All @@ -14,4 +27,15 @@ class Entity {

private val components: MutableSet<Component> = mutableSetOf()

override fun toString(): String {
return "[Entity $id]"
}

fun <T: Component> updateComponent(component: KClass<T>, perform: (T) -> Unit): T {
val value = component(component)
perform.invoke(value)
world.execute(UpdateEntityEvent(this, component, value))
return value
}

}
17 changes: 0 additions & 17 deletions games-core/src/main/kotlin/net/zomis/games/core/Game.kt

This file was deleted.

36 changes: 36 additions & 0 deletions games-core/src/main/kotlin/net/zomis/games/core/World.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package net.zomis.games.core

import net.zomis.core.events.EventRegistrator
import net.zomis.core.events.EventSystem

typealias GameSystem = EventRegistrator

class World(val events: EventSystem = EventSystem()) {

private val entitiesById = mutableMapOf<String, Entity>()
val core: Entity = createEntity()
private var id = 0

fun createEntity(): Entity {
val result = Entity(this, (id++).toString())
entitiesById[result.id] = result
return result
}

fun entities(): Sequence<Entity> {
return entitiesById.values.asSequence()
}

fun entityById(id: String): Entity? {
return entitiesById[id]
}

fun system(system: GameSystem) {
events.with(system)
}

fun <E: Any> execute(event: E): E {
return this.events.execute(event)
}

}
129 changes: 115 additions & 14 deletions games-core/src/main/kotlin/net/zomis/games/ecs/UTTT.kt
Original file line number Diff line number Diff line change
@@ -1,41 +1,142 @@
package net.zomis.games.ecs

import klogging.KLoggers
import net.zomis.core.events.EventSystem
import net.zomis.games.core.*

data class ActiveBoard(var active: Tile?): DataComponent()
data class Parent(val parent: Entity): FixedComponent()

private val Entity.activeBoard: Tile?
get() = component(ActiveBoard::class).active

class UTTT {

fun setup(): Game {
val game = Game()
private val logger = KLoggers.logger(this)

fun setup(): World {
val game = World()

val players = (0..1).map { Player(it, null, null) }.map {
return@map Entity().add(it)
return@map game.createEntity().add(it)
}
game.core.add(Players(players))
game.core.add(PlayerTurn(players[0].component(Player::class)))

val boards = (0..2).map {x ->
(0..2).map {y -> Entity().add(Tile(x, y)).add(OwnedByPlayer(null)).add(createTiles()) }
val boards = (0..2).map {y ->
(0..2).map {x ->
val area = game.createEntity()
area.add(Tile(x, y)).add(OwnedByPlayer(null)).add(createTiles(area))
area
}
}

game.core.add(Container2D(boards))
game.core.add(Players(players))
.add(OwnedByPlayer(null))
.add(PlayerTurn(players[0].component(Player::class)))
.add(Container2D(boards))
.add(ActiveBoard(null))
game.system(this::actionableClick)

return game
}

fun actionableClick(p1: EventSystem) {
p1.listen("click", ActionEvent::class, {true}, {
fun actionableClick(events: EventSystem) {
events.listen("click allowed check", ActionAllowedCheck::class, {true}, {
val core = it.actionable.world.core
val tile = it.actionable
val ownedByPlayer = tile.component(OwnedByPlayer::class)
val played = tile.component(OwnedByPlayer::class).owner != null
val activeTile = core.component(ActiveBoard::class).active
if (ownedByPlayer.owner != null) {
return@listen it.deny("Move not allowed because already owned: $tile")
}
val parent = tile.component(Parent::class).parent.component(Tile::class)
if (activeTile != null && parent != activeTile) {
return@listen it.deny("Wrong parent. Active is $activeTile but parent is $parent")
}

val currentPlayer = core.component(PlayerTurn::class).currentPlayer
val correctPlayer = it.initiatedBy.component(Player::class) == currentPlayer
val allowed = !played && correctPlayer
if (!allowed) {
return@listen it.deny("Move not allowed: played $played correctPlayer $correctPlayer")
}
})

events.listen("click", ActionEvent::class, {
true
}, {
val core = it.actionable.world.core
it.actionable.updateComponent(OwnedByPlayer::class) {component ->
component.owner = it.initiatedBy.component(Player::class)
}

val destination = it.actionable.component(Tile::class)
val players = it.actionable.world.core.component(Players::class).players
core.updateComponent(PlayerTurn::class) {playerTurn ->
playerTurn.currentPlayer = players[(playerTurn.currentPlayer.index + 1) % players.size].component(Player::class)
}
core.updateComponent(ActiveBoard::class) {activeBoard ->
val target = it.actionable.world.core.component(Container2D::class).container[destination.y][destination.x]
activeBoard.active = if (target.component(OwnedByPlayer::class).owner != null) null else destination
}
checkWinner(it.actionable.component(Parent::class).parent)
val winner = checkWinner(it.actionable.world.core)
if (winner != null) {
core.component(Players::class).eliminate(winner.index, WinStatus.WIN).eliminateRemaining(WinStatus.LOSS)
}
})
}

private fun createTiles(): Component {
private fun checkWinner(entity: Entity): Player? {
val grid = entity.component(Container2D::class).container

val range = (0..2)

for (xx in range) {
val horizontal = checkWins(range.map { grid[it][xx].component(OwnedByPlayer::class).owner })
if (horizontal != null) {
return setWinner(entity, horizontal)
}

val vertical = checkWins(range.map { grid[xx][it].component(OwnedByPlayer::class).owner })
if (vertical != null) {
return setWinner(entity, vertical)
}
}

val diagonalOne = checkWins(listOf(grid[0][0], grid[1][1], grid[2][2]).map { it.component(OwnedByPlayer::class).owner })
if (diagonalOne != null) {
return setWinner(entity, diagonalOne)
}
val diagonalTwo = checkWins(listOf(grid[0][2], grid[1][1], grid[2][0]).map { it.component(OwnedByPlayer::class).owner })
if (diagonalTwo != null) {
return setWinner(entity, diagonalTwo)
}
return null
}

private fun setWinner(entity: Entity, player: Player): Player? {
return entity.updateComponent(OwnedByPlayer::class) {
it.owner = player
}.owner
}

private fun checkWins(map: Iterable<Player?>): Player? {
val distinct = map.distinct()
if (distinct.size != 1 || distinct.firstOrNull() == null) {
return null
}
return map.first()
}

private fun createTiles(parent: Entity): Component {
return Container2D((0..2).map {y ->
(0..2).map {x ->
Entity()
parent.world.createEntity()
.add(Tile(x, y))
.add(OwnedByPlayer(null))
.add(Actionable())}
.add(Parent(parent))
.add(Actionable())
}
})
}

Expand Down

0 comments on commit 4c9feef

Please sign in to comment.