From a8e5e723b7e1891c9b352261a3ee4c3d3563e8cf Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 26 Oct 2025 21:38:22 -0700 Subject: Checkpoint two --- .../coffee/liz/abstractionengine/Platform.kt | 2 +- .../kotlin/coffee/liz/abstractionengine/app/App.kt | 3 +- .../coffee/liz/abstractionengine/app/ui/Theme.kt | 25 +++--- .../abstractionengine/game/AbstractionEngine.kt | 4 +- .../coffee/liz/abstractionengine/game/Game.kt | 4 +- .../liz/abstractionengine/game/ImageCache.kt | 9 +- .../coffee/liz/abstractionengine/game/Renderer.kt | 15 ++-- .../commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt | 29 +++---- .../src/commonMain/kotlin/coffee/liz/ecs/Entity.kt | 18 ++-- .../commonMain/kotlin/coffee/liz/ecs/GameLoop.kt | 3 +- .../src/commonMain/kotlin/coffee/liz/ecs/System.kt | 6 +- .../kotlin/coffee/liz/ecs/TickedSystem.kt | 29 +++++-- .../src/commonMain/kotlin/coffee/liz/ecs/Vec.kt | 57 +++++++++++-- .../src/commonMain/kotlin/coffee/liz/ecs/World.kt | 5 +- .../liz/ecs/animation/AnimationComponents.kt | 35 +++++--- .../coffee/liz/ecs/animation/AnimationSystem.kt | 14 +++- .../kotlin/coffee/liz/ecs/grid/Components.kt | 8 ++ .../commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt | 54 ++++++++++++ .../kotlin/coffee/liz/ecs/grid/GridSystem.kt | 17 ++++ .../kotlin/coffee/liz/ecs/input/Controlled.kt | 7 +- .../kotlin/coffee/liz/ecs/input/InputProvider.kt | 7 -- .../kotlin/coffee/liz/ecs/input/InputSystem.kt | 19 ++++- .../kotlin/coffee/liz/ecs/physics/CollisionTick.kt | 95 ++++++++++++++++++---- .../kotlin/coffee/liz/ecs/physics/Components.kt | 25 +++++- .../coffee/liz/ecs/physics/IntegrationTick.kt | 14 ++-- .../kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt | 25 ++++-- .../kotlin/coffee/liz/ecs/physics/PhysicsTick.kt | 10 +++ 27 files changed, 403 insertions(+), 136 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Components.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/GridSystem.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsTick.kt (limited to 'composeApp/src/commonMain/kotlin/coffee') diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt index 17ca317..7c43f74 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt @@ -4,4 +4,4 @@ interface Platform { val needsTouchscreenControls: Boolean } -expect fun getPlatform(): Platform \ No newline at end of file +expect fun getPlatform(): Platform diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt index 50e72f0..5b8c967 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt @@ -13,7 +13,6 @@ import org.jetbrains.compose.ui.tooling.preview.Preview @Composable @Preview fun App() { - MaterialTheme(colorScheme = GameBoyTheme) { Box(modifier = Modifier.fillMaxSize()) { if (getPlatform().needsTouchscreenControls) { @@ -21,4 +20,4 @@ fun App() { } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt index be6c3ad..7f181a4 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt @@ -18,15 +18,16 @@ object GameBoyColors { val DPadLight = Color(0xFF6B7F82) } -val GameBoyTheme = darkColorScheme( - primary = GameBoyColors.MediumGreen, - onPrimary = GameBoyColors.DarkestGreen, - secondary = GameBoyColors.LightGreen, - onSecondary = GameBoyColors.DarkestGreen, - background = GameBoyColors.DarkestGreen, - onBackground = GameBoyColors.LightGreen, - surface = GameBoyColors.DarkGreen, - onSurface = GameBoyColors.LightGreen, - surfaceVariant = GameBoyColors.DPadGray, - outline = GameBoyColors.DarkestGreen, -) +val GameBoyTheme = + darkColorScheme( + primary = GameBoyColors.MediumGreen, + onPrimary = GameBoyColors.DarkestGreen, + secondary = GameBoyColors.LightGreen, + onSecondary = GameBoyColors.DarkestGreen, + background = GameBoyColors.DarkestGreen, + onBackground = GameBoyColors.LightGreen, + surface = GameBoyColors.DarkGreen, + onSurface = GameBoyColors.LightGreen, + surfaceVariant = GameBoyColors.DPadGray, + outline = GameBoyColors.DarkestGreen, + ) diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt index 18349c3..1f79dfc 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt @@ -1,5 +1,3 @@ package coffee.liz.abstractionengine.game -class AbstractionEngine { - -} \ No newline at end of file +class AbstractionEngine diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt index 0c3a007..20ea74c 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt @@ -14,7 +14,7 @@ import kotlin.time.Duration.Companion.nanoseconds @Composable fun Game( world: World, - content: @Composable () -> Unit + content: @Composable () -> Unit, ) { var frameCount by remember { mutableStateOf(0) } @@ -36,7 +36,7 @@ fun GameCanvas( world: World, renderSystem: Renderer, backgroundColor: Color = Color.Transparent, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, ) { Canvas(modifier = modifier) { // Clear background diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt index 0722535..1d64730 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt @@ -9,13 +9,14 @@ import androidx.compose.ui.graphics.ImageBitmap class ImageCache { private val images = mutableMapOf() - fun loadImage(path: String, image: ImageBitmap) { + fun loadImage( + path: String, + image: ImageBitmap, + ) { images[path] = image } - fun getImage(path: String): ImageBitmap? { - return images[path] - } + fun getImage(path: String): ImageBitmap? = images[path] fun clear() { images.clear() diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt index 57511f3..8e28944 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt @@ -21,12 +21,14 @@ import kotlin.time.Duration * Depends on AnimationSystem to ensure animations are updated before rendering. */ class Renderer( - private val imageCache: ImageCache + private val imageCache: ImageCache, ) : System { - override val dependencies: Set> = setOf(AnimationSystem::class) - override fun update(world: World, duration: Duration) { + override fun update( + world: World, + duration: Duration, + ) { // Rendering happens in render() method, not here } @@ -34,7 +36,10 @@ class Renderer( * Renders all entities with sprites to the given DrawScope. * This should be called from a Compose Canvas during the draw phase. */ - fun render(world: World, drawScope: DrawScope) { + fun render( + world: World, + drawScope: DrawScope, + ) { // Query all entities that can be rendered world.query(Position::class, SpriteSheet::class, Animator::class).forEach { entity -> val position = entity.get(Position::class) ?: return@forEach @@ -57,7 +62,7 @@ class Renderer( srcOffset = IntOffset(frameRect.x, frameRect.y), srcSize = IntSize(frameRect.width, frameRect.height), dstOffset = IntOffset(position.x.toInt(), position.y.toInt()), - dstSize = IntSize(frameRect.width, frameRect.height) + dstSize = IntSize(frameRect.width, frameRect.height), ) } } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt index 77cb30b..c6303d3 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt @@ -7,19 +7,20 @@ import kotlin.reflect.KClass import kotlin.time.Duration /** - * [World] that updates in [System.dependencies] DAG order. + * [World] that updates in [System.dependencies] topological order. */ @OptIn(ExperimentalAtomicApi::class) -class DAGWorld(systems: List>) : World { +class DAGWorld( + systems: List>, +) : World { private val entities = mutableSetOf() private val componentCache = mutableMapOf, MutableSet>() private val systemExecutionOrder: List> = buildExecutionOrder(systems) private val nextEntityId = AtomicInt(0) - override fun createEntity(): Entity { - return Entity(nextEntityId.incrementAndFetch()) + override fun createEntity(): Entity = + Entity(nextEntityId.incrementAndFetch()) .also { entities.add(it) } - } override fun removeEntity(entity: Entity) { entity.componentTypes().forEach { componentType -> @@ -31,17 +32,18 @@ class DAGWorld(systems: List>) : World { override fun query(vararg componentTypes: KClass): Set { if (componentTypes.isEmpty()) return entities.toSet() - // Start with entities that have the first component type val firstType = componentTypes[0] val candidates = componentCache[firstType] ?: return emptySet() - // Filter to entities that have all component types - return candidates.filter { entity -> - componentTypes.all { entity.has(it) } - }.toSet() + return candidates + .filter { entity -> componentTypes.all { entity.has(it) } } + .toSet() } - override fun update(state: Outside, deltaTime: Duration) { + override fun update( + state: Outside, + deltaTime: Duration, + ) { refreshComponentCache() systemExecutionOrder.forEach { system -> system.update(this, state, deltaTime) @@ -57,9 +59,6 @@ class DAGWorld(systems: List>) : World { } } - /** - * Build a topologically sorted execution order from system dependencies. - */ private fun buildExecutionOrder(systems: List>): List> { if (systems.isEmpty()) return emptyList() @@ -88,7 +87,6 @@ class DAGWorld(systems: List>) : World { val queue = ArrayDeque>>() val result = mutableListOf>() - // Add all systems with no dependencies to queue queue.addAll(inDegree.filterValues { it == 0 }.keys) while (queue.isNotEmpty()) { @@ -104,7 +102,6 @@ class DAGWorld(systems: List>) : World { } } - // Check for cycles if (result.size != systems.size) { error("Circular dependency detected in systems") } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt index fcc39fb..0ee604c 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt @@ -5,7 +5,9 @@ import kotlin.reflect.KClass /** * An entity is a unique identifier with a collection of components. */ -class Entity(val id: Int) { +class Entity( + val id: Int, +) { private val components = mutableMapOf, Component>() /** @@ -28,29 +30,25 @@ class Entity(val id: Int) { * Get [Component] */ @Suppress("UNCHECKED_CAST") - fun get(type: KClass): T { - return components[type] as T - } + fun get(type: KClass): T = components[type] as T /** * Has [Component] */ - fun has(type: KClass): Boolean { - return components.containsKey(type) - } + fun has(type: KClass): Boolean = components.containsKey(type) /** * [Component]s of this entity. */ - fun componentTypes(): Set> { - return components.keys.toSet() - } + fun componentTypes(): Set> = components.keys.toSet() override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Entity) return false return id == other.id } + override fun hashCode(): Int = id + override fun toString(): String = "Entity($id)" } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt index 713586d..638a316 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt @@ -12,6 +12,7 @@ abstract class GameLoop { } abstract fun render(world: World) + abstract fun outsideState(): Outside fun loop(now: Long) { @@ -22,4 +23,4 @@ abstract class GameLoop { render(world) lastUpdate = now } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt index e763c9c..0032925 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt @@ -19,5 +19,9 @@ interface System { * @param world [World] * @param deltaTime [Duration] since last update */ - fun update(world: World, state: Outside, deltaTime: Duration) + fun update( + world: World, + state: Outside, + deltaTime: Duration, + ) } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt index f512ca7..fa4566d 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt @@ -4,14 +4,27 @@ import kotlin.time.Duration abstract class TickedSystem( protected val tickRate: Duration, - private var lastTick: Duration = Duration.ZERO -): System { - abstract fun update(world: World, state: Outside, ticks: Int) + private var lastTick: Duration = Duration.ZERO, +) : System { + abstract fun update( + world: World, + state: Outside, + ticks: Int, + ) - override fun update(world: World, state: Outside, deltaTime: Duration) { + override fun update( + world: World, + state: Outside, + deltaTime: Duration, + ) { val ticks = ((deltaTime - lastTick) / tickRate).toInt() - lastTick = if (ticks == 0) (lastTick + deltaTime).also { - update(world, state, ticks) - } else Duration.ZERO + lastTick = + if (ticks == 0) { + (lastTick + deltaTime).also { + update(world, state, ticks) + } + } else { + Duration.ZERO + } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt index 0b771e4..8047f3e 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt @@ -1,19 +1,42 @@ package coffee.liz.ecs +import kotlin.math.sqrt + /** * Rectangle in cartesian space. */ data class Rect( val topLeft: Vec2, - val dimensions: Vec2 + val dimensions: Vec2, ) { + fun contains(point: Vec2): Boolean = + point.x >= topLeft.x && + point.x <= topLeft.x + dimensions.x && + point.y >= topLeft.y && + point.y <= topLeft.y + dimensions.y + fun overlaps(other: Rect): Boolean { - val xOverlap = topLeft.x <= other.topLeft.x + other.dimensions.x && + val xOverlap = + topLeft.x <= other.topLeft.x + other.dimensions.x && topLeft.x + dimensions.x >= other.topLeft.x - val yOverlap = topLeft.y <= other.topLeft.y + other.dimensions.y && + val yOverlap = + topLeft.y <= other.topLeft.y + other.dimensions.y && topLeft.y + dimensions.y >= other.topLeft.y return xOverlap && yOverlap } + + fun split(newDimensions: Vec2): List> = List( + (dimensions.y / newDimensions.y).toInt() + ) { y -> + List( + (dimensions.x / newDimensions.x).toInt() + ) { x -> + Rect( + topLeft = topLeft.plus(Vec2(x * newDimensions.x, y * newDimensions.y)), + dimensions = newDimensions, + ) + } + } } /** @@ -21,13 +44,29 @@ data class Rect( */ data class Vec2( val x: Float, - val y: Float + val y: Float, ) { fun plus(other: Vec2): Vec2 = Vec2(x + other.x, y + other.y) + fun minus(other: Vec2): Vec2 = Vec2(x - other.x, y - other.y) - fun times(scalar: Float): Vec2 = Vec2(x * scalar, y * scalar) - fun div(scalar: Float): Vec2 = Vec2(x / scalar, y / scalar) + + fun scale(x: Float, y: Float): Vec2 = Vec2(this.x * x, this.y * y) + fun distanceTo(other: Vec2): Float = (this.minus(other)).length() - fun normalize(): Vec2 = this.div(length()) - fun length(): Float = kotlin.math.sqrt(x * x + y * y) -} \ No newline at end of file + + fun normalize(): Vec2 = this.scale(1 / length(), 1 / length()) + + fun length(): Float = sqrt(x * x + y * y) +} + +/** + * Cardinal directions. + */ +enum class CardinalDirection( + val direction: Vec2, +) { + NORTH(Vec2(0f, -1f)), + SOUTH(Vec2(0f, 1f)), + WEST(Vec2(-1f, 0f)), + EAST(Vec2(1f, 0f)), +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt index 452517f..f49e79f 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt @@ -26,5 +26,8 @@ interface World { /** * Integrate the [World]. */ - fun update(state: Outside, deltaTime: Duration) + fun update( + state: Outside, + deltaTime: Duration, + ) } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt index cb3708f..5a846fb 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt @@ -10,24 +10,29 @@ import kotlin.jvm.JvmInline enum class LoopMode { /** Play once **/ ONCE, + /** Repeat **/ LOOP, + /** Play forward, then backward, repeat **/ - PING_PONG + PING_PONG, } /** * Name of an animation clip. */ @JvmInline -value class AnimationName(val value: String) +value class AnimationName( + val value: String, +) /** * Name of a frame. */ @JvmInline -value class FrameName(val value: String) - +value class FrameName( + val value: String, +) /** * Animation Clip details. @@ -35,7 +40,7 @@ value class FrameName(val value: String) data class AnimationClip( val frameNames: List, val frameTicks: Int, - val loopMode: LoopMode = LoopMode.LOOP + val loopMode: LoopMode = LoopMode.LOOP, ) /** @@ -43,15 +48,19 @@ data class AnimationClip( */ data class SpriteSheet( val imagePath: String, - val frames: Map + val frames: Map, ) : Component -enum class AnimationDirection(val step: Int) { +enum class AnimationDirection( + val step: Int, +) { /** Play in forward direction **/ FORWARD(step = 1), + /** Play in reverse direction **/ - BACKWARD(step = -1); + BACKWARD(step = -1), } + /** * Current state of an animation. */ @@ -61,13 +70,17 @@ data class Animator( var frameIndex: Int = 0, var elapsedTicks: Int = 0, var playing: Boolean = true, - var direction: AnimationDirection = AnimationDirection.FORWARD + var direction: AnimationDirection = AnimationDirection.FORWARD, ) : Component { /** * Play a specific animation clip by name. */ - fun play(clipName: AnimationName, restart: Boolean = true) { - clipName.takeIf { currentClip != it || restart } + fun play( + clipName: AnimationName, + restart: Boolean = true, + ) { + clipName + .takeIf { currentClip != it || restart } .run { currentClip = clipName reset() diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt index 7968250..db64ac6 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt @@ -7,8 +7,13 @@ import kotlin.time.Duration /** * Updates animation playback state for all [Animator] components in [World]. */ -class AnimationSystem(animationTickRate: Duration) : TickedSystem(animationTickRate) { - override fun update(world: World, ticks: Int) { +class AnimationSystem( + animationTickRate: Duration, +) : TickedSystem(animationTickRate) { + override fun update( + world: World, + ticks: Int, + ) { world.query(Animator::class).forEach { entity -> val animator = entity.get(Animator::class) if (!animator.playing) return@forEach @@ -30,7 +35,10 @@ class AnimationSystem(animationTickRate: Duration) : TickedSystem(animationTickR /** * Advances the animation to the next frame based on loop mode. */ - private fun advanceFrame(animator: Animator, clip: AnimationClip) { + private fun advanceFrame( + animator: Animator, + clip: AnimationClip, + ) { animator.frameIndex += animator.direction.step when (clip.loopMode) { diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Components.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Components.kt new file mode 100644 index 0000000..b559aa7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Components.kt @@ -0,0 +1,8 @@ +package coffee.liz.ecs.grid + +import coffee.liz.ecs.Component +import coffee.liz.ecs.Vec2 + +data class GridPosition( + val position: Vec2, +) : Component diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt new file mode 100644 index 0000000..f4eef43 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt @@ -0,0 +1,54 @@ +package coffee.liz.ecs.grid + +import coffee.liz.ecs.Entity +import coffee.liz.ecs.Rect +import coffee.liz.ecs.Vec2 + +data class Grid( + val dimensions: Vec2, + val grid: MutableList>> = + MutableList(dimensions.y.toInt()) { + MutableList(dimensions.x.toInt()) { mutableSetOf() } + }, +) { + fun at(position: Vec2): MutableSet { + if (!Rect(Vec2(0f, 0f), dimensions).contains(position)) { + throw IllegalArgumentException() + } + return grid[position.y.toInt()][position.x.toInt()] + } + + fun add(entity: Entity) { + if (!entity.has(GridPosition::class)) { + throw IllegalArgumentException("Entity must have a GridPosition component to be added to the grid") + } + val position = entity.get(GridPosition::class).position + at(position).add(entity) + } + + fun reassignCellEntities(entities: Collection) { + val remainingEntities = entities.filter { it.has(GridPosition::class) }.toMutableSet() + for (y in grid.indices) { + for (x in grid[y].indices) { + val vec = Vec2(x.toFloat(), y.toFloat()) + val entities = at(vec) + entities.retainAll( + entities + .filter { + if (!it.has(GridPosition::class)) { + return@filter false + } + val currentPosition = it.get(GridPosition::class).position + (vec == currentPosition).also { stillInCell -> + if (!stillInCell) { + at(currentPosition).add(it) + } + } + }.toSet(), + ) + remainingEntities.removeAll(entities) + } + } + remainingEntities.forEach { add(it) } + } +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/GridSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/GridSystem.kt new file mode 100644 index 0000000..50d784d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/GridSystem.kt @@ -0,0 +1,17 @@ +package coffee.liz.ecs.grid + +import coffee.liz.ecs.System +import coffee.liz.ecs.World +import coffee.liz.ecs.physics.PhysicsSystem +import kotlin.reflect.KClass +import kotlin.time.Duration + +class GridSystem( + private val grid: Grid +): System { + override val dependencies = setOf(PhysicsSystem::class) + + override fun update(world: World, state: Outside, deltaTime: Duration) { + + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt index 8c54bfe..ddda2d6 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt @@ -2,6 +2,9 @@ package coffee.liz.ecs.input import coffee.liz.ecs.Component -interface Controlled: Component { +/** + * Processes the [InputState]. + */ +fun interface Controlled : Component { fun handleInput(inputState: InputState) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt deleted file mode 100644 index 5735665..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt +++ /dev/null @@ -1,7 +0,0 @@ -package coffee.liz.ecs.input - -data class InputState(val activeInputs: Set) - -interface InputProvider { - val inputState: InputState -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt index 6dc8256..f80c1ad 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt @@ -1,16 +1,27 @@ package coffee.liz.ecs.input -import coffee.liz.ecs.System import coffee.liz.ecs.TickedSystem import coffee.liz.ecs.World import kotlin.time.Duration +data class InputState( + val activeInputs: Set, +) + +interface InputProvider { + val inputState: InputState +} + class InputSystem( - pollRate: Duration + pollRate: Duration, ) : TickedSystem(pollRate) { private val activeInputs = mutableMapOf() - override fun update(world: World, state: Outside, ticks: Int) { + override fun update( + world: World, + state: Outside, + ticks: Int, + ) { state.inputState.activeInputs.forEach { activeInputs[it] = (activeInputs[it] ?: 0) + ticks } @@ -20,4 +31,4 @@ class InputSystem( it.get(Controlled::class).handleInput(activeInputs.toMap()) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt index bc34ac1..d7d4dda 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt @@ -1,28 +1,89 @@ package coffee.liz.ecs.physics +import coffee.liz.ecs.Entity import coffee.liz.ecs.Rect +import coffee.liz.ecs.Vec2 import coffee.liz.ecs.World +import kotlin.math.min internal class CollisionTick( - private val collisionResolver: CollisionResolver -) { - fun runTick(world: World) { - // Eh, fast enough for now. Don't need to do any fancy collision detection. There's always later to improve if - // it's that bad. - world.query(Collidable::class, Position::class).forEach { a -> - world.query(Collidable::class, Position::class).forEach { b -> - val aHitBoxes = a.get(Position::class).let { pos -> a.get(Collidable::class).hitboxes.map { - Rect(pos.vec2, it.dimensions) - }} - val bHitBoxes = b.get(Position::class).let { pos -> b.get(Collidable::class).hitboxes.map { - Rect(pos.vec2, it.dimensions) - }} - - val collisionDetected = aHitBoxes.any { a -> bHitBoxes.any { b -> a.overlaps(b) } } - if (collisionDetected) { - collisionResolver.resolveCollision(world, a, b) + private val collisionResolver: CollisionResolver, + private val subgridDimension: Vec2 = Vec2(50f, 50f) +) : PhysicsTick { + override fun runTick(world: World) { + val subgridEntities = world.collidingBounds().split(subgridDimension).map { subgrid -> + subgrid.map { cell -> + world.query(Colliding::class, Position::class).filter { entity -> + entity.positionalHitBoxes().any { cell.overlaps(it) } } } } + + for (y in subgridEntities.indices) { + for (x in subgridEntities[y].indices) { + resolveCollisions(world, subgridEntities[y][x]) + } + } + } + + private fun resolveCollisions(world: World, potentialCollisions: Collection) { + potentialCollisions.forEach { from -> + potentialCollisions.filter { to -> + val fromColliding = from.get(Colliding::class) + val toColliding = to.get(Colliding::class) + + val canCollide = fromColliding.group.collideInto.contains(toColliding.group.collisionGroup) + if (!canCollide) { + return@filter false + } + + val fromHitBoxes = from.positionalHitBoxes() + val toHitBoxes = to.positionalHitBoxes() + + fromHitBoxes.any { a -> toHitBoxes.any { b -> a.overlaps(b) } } + }.forEach { collision -> propagateCollision(world, from, collision)} + } + } + + private fun propagateCollision(world: World, from: Entity, to: Entity) { + collisionResolver.resolveCollision(world, from, to) + + val fromVelocity = from.takeIf { it.has(Velocity::class) }?.get(Velocity::class) ?: return + + val toVelocity = to.takeIf { it.has(Velocity::class) }?.get(Velocity::class) + ?: Velocity(Vec2(0f, 0f)) + val toColliding = to.get(Colliding::class) + + if (toColliding.propagateMovement) { + to.add(Velocity(toVelocity.velocity.plus(fromVelocity.velocity))) + } + } +} + +private fun Entity.positionalHitBoxes(): Collection { + val pos = get(Position::class) + val hitboxes = get(Colliding::class).hitboxes + return hitboxes.map { Rect(pos.position.plus(it.topLeft), it.dimensions) } +} + +fun World<*>.collidingBounds(): Rect { + return query(Position::class, Colliding::class).map { entity -> + val topLeft = entity.get(Position::class).position + val dimensions = entity.get(Colliding::class).hitboxes.merge().dimensions + Rect(topLeft, dimensions) + }.merge() +} + +fun Collection.merge(): Rect { + return reduce { a, b -> + val newTopLeft = Vec2( + min(a.topLeft.x, b.topLeft.x), + min(a.topLeft.y, b.topLeft.y) + ) + val dimensions = Vec2( + maxOf(a.topLeft.x + a.dimensions.x, b.topLeft.x + b.dimensions.x) - newTopLeft.x, + maxOf(a.topLeft.y + a.dimensions.y, b.topLeft.y + b.dimensions.y) - newTopLeft.y + ) + Rect(newTopLeft, dimensions) } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt index ffae10b..0b4818d 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt @@ -4,12 +4,29 @@ import coffee.liz.ecs.Component import coffee.liz.ecs.Rect import coffee.liz.ecs.Vec2 -data class Position(val vec2: Vec2): Component -data class Velocity(val vec2: Vec2) : Component -data class Acceleration(val vec2: Vec2) : Component +data class Position( + val position: Vec2, +) : Component + +data class Velocity( + val velocity: Vec2, +) : Component + +data class Acceleration( + val acceleration: Vec2, +) : Component + +interface CollisionGroup { + val collisionGroup: TGroup + val collideInto: Set +} /** * @param hitboxes a collection of hitboxes to check collisions against relative to [Rect.topLeft] as the top left of * [Position]. */ -data class Collidable(val hitboxes: Collection): Component +data class Colliding>( + val hitboxes: Collection, + val group: CollisionGroup, + val propagateMovement: Boolean = true, +) : Component diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt index 03af835..c3484ae 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt @@ -2,15 +2,15 @@ package coffee.liz.ecs.physics import coffee.liz.ecs.World -internal class IntegrationTick { - fun runTick(world: World) { +internal class IntegrationTick : PhysicsTick { + override fun runTick(world: World) { world.query(Velocity::class, Acceleration::class).forEach { entity -> val velocity = entity.get(Velocity::class) val acceleration = entity.get(Acceleration::class) entity.add( Velocity( - vec2 = velocity.vec2.plus(acceleration.vec2) - ) + vec2 = velocity.vec2.plus(acceleration.vec2), + ), ) } @@ -19,9 +19,9 @@ internal class IntegrationTick { val velocity = entity.get(Acceleration::class) entity.add( Position( - vec2 = position.vec2.plus(velocity.vec2) - ) + vec2 = position.vec2.plus(velocity.vec2), + ), ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt index 703e614..ec9c71b 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt @@ -6,21 +6,34 @@ import coffee.liz.ecs.World import kotlin.time.Duration internal fun interface CollisionResolver { - fun resolveCollision(world: World, a: Entity, b: Entity) + fun resolveCollision( + world: World, + a: Entity, + b: Entity, + ) } abstract class PhysicsSystem( - physicsTickRate: Duration -): TickedSystem(physicsTickRate), CollisionResolver { + physicsTickRate: Duration, +) : TickedSystem(physicsTickRate), + CollisionResolver { private val integrationTick = IntegrationTick() private val collisionTick = CollisionTick(this) - abstract override fun resolveCollision(world: World, a: Entity, b: Entity) + abstract override fun resolveCollision( + world: World, + a: Entity, + b: Entity, + ) - override fun update(world: World, state: Outside, ticks: Int) { + override fun update( + world: World, + state: Outside, + ticks: Int, + ) { for (tick in 1..ticks) { collisionTick.runTick(world) integrationTick.runTick(world) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsTick.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsTick.kt new file mode 100644 index 0000000..79a95c0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsTick.kt @@ -0,0 +1,10 @@ +package coffee.liz.ecs.physics + +import coffee.liz.ecs.World + +/** + * Runs a physics tick in [World]. + */ +fun interface PhysicsTick { + fun runTick(world: World) +} -- cgit v1.2.3-70-g09d2