summaryrefslogtreecommitdiff
path: root/composeApp/src/commonMain/kotlin/coffee/liz/ecs
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-10-26 17:25:13 -0700
committerElizabeth Hunt <me@liz.coffee>2025-10-26 17:25:13 -0700
commit395aa7d1c312e495517701be11c21425d9a5838e (patch)
tree4ad184b082838c56149cc1d1efe191cfd3d0679b /composeApp/src/commonMain/kotlin/coffee/liz/ecs
parent64f825465de9fa30c4dfe2707067efdb96110db8 (diff)
downloadabstraction-engine-kt-395aa7d1c312e495517701be11c21425d9a5838e.tar.gz
abstraction-engine-kt-395aa7d1c312e495517701be11c21425d9a5838e.zip
Checkpoint
Diffstat (limited to 'composeApp/src/commonMain/kotlin/coffee/liz/ecs')
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt4
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt61
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt28
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt25
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt17
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt17
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt33
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt32
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt67
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt41
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt7
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt7
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt23
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt28
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt15
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt27
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt26
17 files changed, 301 insertions, 157 deletions
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt
index c64a863..cfb01b1 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt
@@ -1,7 +1,3 @@
package coffee.liz.ecs
-/**
- * Marker interface for all components.
- * Components are pure data containers with no behavior.
- */
interface Component
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt
index d314065..77cb30b 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt
@@ -1,28 +1,27 @@
package coffee.liz.ecs
+import kotlin.concurrent.atomics.AtomicInt
+import kotlin.concurrent.atomics.ExperimentalAtomicApi
+import kotlin.concurrent.atomics.incrementAndFetch
import kotlin.reflect.KClass
+import kotlin.time.Duration
/**
- * World implementation that executes systems in dependency order using a DAG.
+ * [World] that updates in [System.dependencies] DAG order.
*/
-class DAGWorld(systems: List<System>) : World {
+@OptIn(ExperimentalAtomicApi::class)
+class DAGWorld<Outside>(systems: List<System<Outside>>) : World<Outside> {
private val entities = mutableSetOf<Entity>()
private val componentCache = mutableMapOf<KClass<out Component>, MutableSet<Entity>>()
- private val systemExecutionOrder: List<System>
- private var nextEntityId = 0
-
- init {
- systemExecutionOrder = buildExecutionOrder(systems)
- }
+ private val systemExecutionOrder: List<System<Outside>> = buildExecutionOrder(systems)
+ private val nextEntityId = AtomicInt(0)
override fun createEntity(): Entity {
- val entity = Entity(nextEntityId++)
- entities.add(entity)
- return entity
+ return Entity(nextEntityId.incrementAndFetch())
+ .also { entities.add(it) }
}
- override fun destroyEntity(entity: Entity) {
- // Remove from all component caches
+ override fun removeEntity(entity: Entity) {
entity.componentTypes().forEach { componentType ->
componentCache[componentType]?.remove(entity)
}
@@ -38,25 +37,18 @@ class DAGWorld(systems: List<System>) : World {
// Filter to entities that have all component types
return candidates.filter { entity ->
- entity.hasAll(*componentTypes)
+ componentTypes.all { entity.has(it) }
}.toSet()
}
- override fun update(deltaTime: Float) {
- // Update component cache based on current entity state
- updateComponentCache()
-
- // Execute systems in dependency order
+ override fun update(state: Outside, deltaTime: Duration) {
+ refreshComponentCache()
systemExecutionOrder.forEach { system ->
- system.update(this, deltaTime)
+ system.update(this, state, deltaTime)
}
}
- /**
- * Rebuild the component cache based on current entity components.
- * Call this after components have been added/removed from entities.
- */
- private fun updateComponentCache() {
+ private fun refreshComponentCache() {
componentCache.clear()
entities.forEach { entity ->
entity.componentTypes().forEach { componentType ->
@@ -67,14 +59,13 @@ class DAGWorld(systems: List<System>) : World {
/**
* Build a topologically sorted execution order from system dependencies.
- * Uses Kahn's algorithm for topological sorting.
*/
- private fun buildExecutionOrder(systems: List<System>): List<System> {
+ private fun buildExecutionOrder(systems: List<System<Outside>>): List<System<Outside>> {
if (systems.isEmpty()) return emptyList()
val systemMap = systems.associateBy { it::class }
- val inDegree = mutableMapOf<KClass<out System>, Int>()
- val adjacencyList = mutableMapOf<KClass<out System>, MutableSet<KClass<out System>>>()
+ val inDegree = mutableMapOf<KClass<out System<Outside>>, Int>()
+ val adjacencyList = mutableMapOf<KClass<out System<Outside>>, MutableSet<KClass<out System<Outside>>>>()
// Initialize graph
systems.forEach { system ->
@@ -94,15 +85,11 @@ class DAGWorld(systems: List<System>) : World {
}
// Kahn's algorithm
- val queue = ArrayDeque<KClass<out System>>()
- val result = mutableListOf<System>()
+ val queue = ArrayDeque<KClass<out System<Outside>>>()
+ val result = mutableListOf<System<Outside>>()
// Add all systems with no dependencies to queue
- inDegree.forEach { (systemClass, degree) ->
- if (degree == 0) {
- queue.add(systemClass)
- }
- }
+ queue.addAll(inDegree.filterValues { it == 0 }.keys)
while (queue.isNotEmpty()) {
val currentClass = queue.removeFirst()
@@ -119,7 +106,7 @@ class DAGWorld(systems: List<System>) : World {
// Check for cycles
if (result.size != systems.size) {
- throw IllegalArgumentException("Circular dependency detected in systems")
+ error("Circular dependency detected in systems")
}
return result
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt
index 2ceac47..fcc39fb 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt
@@ -9,7 +9,7 @@ class Entity(val id: Int) {
private val components = mutableMapOf<KClass<out Component>, Component>()
/**
- * Add a component to this entity.
+ * Add [Component].
*/
fun <T : Component> add(component: T): Entity {
components[component::class] = component
@@ -17,7 +17,7 @@ class Entity(val id: Int) {
}
/**
- * Remove a component from this entity.
+ * Remove [Component].
*/
fun <T : Component> remove(type: KClass<T>): Entity {
components.remove(type)
@@ -25,29 +25,22 @@ class Entity(val id: Int) {
}
/**
- * Get a component by type.
+ * Get [Component]
*/
@Suppress("UNCHECKED_CAST")
- fun <T : Component> get(type: KClass<T>): T? {
- return components[type] as? T
+ fun <T : Component> get(type: KClass<T>): T {
+ return components[type] as T
}
/**
- * Check if entity has a component.
+ * Has [Component]
*/
fun <T : Component> has(type: KClass<T>): Boolean {
return components.containsKey(type)
}
/**
- * Check if entity has all specified components.
- */
- fun hasAll(vararg types: KClass<out Component>): Boolean {
- return types.all { components.containsKey(it) }
- }
-
- /**
- * Get all component types on this entity.
+ * [Component]s of this entity.
*/
fun componentTypes(): Set<KClass<out Component>> {
return components.keys.toSet()
@@ -58,13 +51,6 @@ class Entity(val id: Int) {
if (other !is Entity) return false
return id == other.id
}
-
override fun hashCode(): Int = id
-
override fun toString(): String = "Entity($id)"
}
-
-// Convenience extensions for cleaner syntax
-inline fun <reified T : Component> Entity.get(): T? = get(T::class)
-inline fun <reified T : Component> Entity.has(): Boolean = has(T::class)
-inline fun <reified T : Component> Entity.remove(): Entity = remove(T::class)
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt
new file mode 100644
index 0000000..713586d
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt
@@ -0,0 +1,25 @@
+package coffee.liz.ecs
+
+import kotlin.time.Duration.Companion.nanoseconds
+
+abstract class GameLoop<Outside> {
+ private var world: World<Outside>? = null
+ private var lastUpdate: Long? = null
+
+ fun load(world: World<Outside>) {
+ this.world = world
+ this.lastUpdate = null
+ }
+
+ abstract fun render(world: World<Outside>)
+ abstract fun outsideState(): Outside
+
+ fun loop(now: Long) {
+ val world = this.world ?: error("World not loaded")
+ val outsideState = outsideState()
+ val dt = (now - (lastUpdate ?: now)).nanoseconds
+ world.update(outsideState, dt)
+ 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 0f8ef2a..e763c9c 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt
@@ -1,22 +1,23 @@
package coffee.liz.ecs
import kotlin.reflect.KClass
+import kotlin.time.Duration
/**
- * Systems contain the logic that operates on entities with specific components.
+ * System is a component that updates the world state.
+ * @param Outside is the state of the stuff outside the world (input, etc.).
*/
-interface System {
+interface System<Outside> {
/**
* Systems that must run before this system in the update loop.
- * Used to construct a dependency DAG.
*/
- val dependencies: Set<KClass<out System>>
+ val dependencies: Set<KClass<out System<*>>>
get() = emptySet()
/**
- * Update this system. Called once per frame.
- * @param world The world containing entities and systems
- * @param deltaTime Time elapsed since last update in seconds
+ * Integrate this system over [deltaTime].
+ * @param world [World]
+ * @param deltaTime [Duration] since last update
*/
- fun update(world: World, deltaTime: Float)
+ fun update(world: World<Outside>, 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
new file mode 100644
index 0000000..f512ca7
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt
@@ -0,0 +1,17 @@
+package coffee.liz.ecs
+
+import kotlin.time.Duration
+
+abstract class TickedSystem<Outside>(
+ protected val tickRate: Duration,
+ private var lastTick: Duration = Duration.ZERO
+): System<Outside> {
+ abstract fun update(world: World<Outside>, state: Outside, ticks: Int)
+
+ override fun update(world: World<Outside>, state: Outside, deltaTime: Duration) {
+ val ticks = ((deltaTime - lastTick) / tickRate).toInt()
+ 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
new file mode 100644
index 0000000..0b771e4
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt
@@ -0,0 +1,33 @@
+package coffee.liz.ecs
+
+/**
+ * Rectangle in cartesian space.
+ */
+data class Rect(
+ val topLeft: Vec2,
+ val dimensions: Vec2
+) {
+ fun overlaps(other: Rect): Boolean {
+ 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 &&
+ topLeft.y + dimensions.y >= other.topLeft.y
+ return xOverlap && yOverlap
+ }
+}
+
+/**
+ * Cartesian point.
+ */
+data class Vec2(
+ val x: 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 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
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt
index fd3b6df..452517f 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt
@@ -1,42 +1,30 @@
package coffee.liz.ecs
-import kotlin.jvm.JvmName
import kotlin.reflect.KClass
+import kotlin.time.Duration
/**
- * The World manages entities and systems.
+ * [World] is the state of the world.
+ * @param Outside is the state of the stuff outside the world (input, etc.).
*/
-interface World {
+interface World<Outside> {
/**
- * Create a new entity with a unique ID.
+ * Create unique [Entity] in the [World].
*/
fun createEntity(): Entity
/**
- * Destroy an entity and remove it from all caches.
+ * Remove [entity] from [World].
*/
- fun destroyEntity(entity: Entity)
+ fun removeEntity(entity: Entity)
/**
- * Get all entities that have all the specified component types.
+ * Get entities with a [Component] type.
*/
fun query(vararg componentTypes: KClass<out Component>): Set<Entity>
/**
- * Update all systems in dependency order.
- * @param deltaTime Time elapsed since last update in seconds
+ * Integrate the [World].
*/
- fun update(deltaTime: Float)
+ fun update(state: Outside, deltaTime: Duration)
}
-
-// Convenience extension for queries
-@JvmName("query1")
-inline fun <reified T : Component> World.query(): Set<Entity> = query(T::class)
-
-@JvmName("query2")
-inline fun <reified T1 : Component, reified T2 : Component> World.query(): Set<Entity> =
- query(T1::class, T2::class)
-
-@JvmName("query3")
-inline fun <reified T1 : Component, reified T2 : Component, reified T3 : Component> World.query(): Set<Entity> =
- query(T1::class, T2::class, T3::class)
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 4289eec..cb3708f 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt
@@ -1,85 +1,77 @@
package coffee.liz.ecs.animation
import coffee.liz.ecs.Component
+import coffee.liz.ecs.Rect
import kotlin.jvm.JvmInline
/**
* Loop modes for animation playback.
*/
enum class LoopMode {
- /** Play once and stop on last frame */
+ /** Play once **/
ONCE,
- /** Repeat from beginning */
+ /** Repeat **/
LOOP,
- /** Play forward, then backward, repeat */
+ /** Play forward, then backward, repeat **/
PING_PONG
}
/**
- * Type-safe wrapper for animation names.
+ * Name of an animation clip.
*/
@JvmInline
value class AnimationName(val value: String)
/**
- * Type-safe wrapper for frame names.
+ * Name of a frame.
*/
@JvmInline
value class FrameName(val value: String)
-/**
- * Represents a rectangle region in a sprite sheet.
- */
-data class Rect(
- val x: Int,
- val y: Int,
- val width: Int,
- val height: Int
-)
/**
- * Defines a single animation clip with frames and playback settings.
+ * Animation Clip details.
*/
data class AnimationClip(
val frameNames: List<FrameName>,
- val frameDuration: Float, // seconds per frame
+ val frameTicks: Int,
val loopMode: LoopMode = LoopMode.LOOP
)
/**
* Component containing sprite sheet data - the image and frame definitions.
- * This is immutable data that can be shared across entities.
*/
data class SpriteSheet(
val imagePath: String,
val frames: Map<FrameName, Rect>
) : Component
+enum class AnimationDirection(val step: Int) {
+ /** Play in forward direction **/
+ FORWARD(step = 1),
+ /** Play in reverse direction **/
+ BACKWARD(step = -1);
+}
/**
- * Component for animation playback state.
- * Contains animation clips and current playback state.
+ * Current state of an animation.
*/
data class Animator(
val clips: Map<AnimationName, AnimationClip>,
var currentClip: AnimationName,
var frameIndex: Int = 0,
- var elapsed: Float = 0f,
+ var elapsedTicks: Int = 0,
var playing: Boolean = true,
- var direction: Int = 1 // 1 for forward, -1 for backward (used in PING_PONG)
+ var direction: AnimationDirection = AnimationDirection.FORWARD
) : Component {
-
/**
* Play a specific animation clip by name.
- * Resets playback state when switching clips.
*/
fun play(clipName: AnimationName, restart: Boolean = true) {
- if (currentClip != clipName || restart) {
- currentClip = clipName
- frameIndex = 0
- elapsed = 0f
- direction = 1
- playing = true
- }
+ clipName.takeIf { currentClip != it || restart }
+ .run {
+ currentClip = clipName
+ reset()
+ }
}
/**
@@ -103,12 +95,11 @@ data class Animator(
val clip = clips[currentClip] ?: return null
return clip.frameNames.getOrNull(frameIndex)
}
-}
-/**
- * Component for entity position in 2D space.
- */
-data class Position(
- var x: Float,
- var y: Float
-) : Component
+ private fun reset() {
+ frameIndex = 0
+ elapsedTicks = 0
+ direction = AnimationDirection.FORWARD
+ playing = true
+ }
+}
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 7ae405e..7968250 100644
--- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt
@@ -1,35 +1,27 @@
package coffee.liz.ecs.animation
-import coffee.liz.ecs.System
+import coffee.liz.ecs.TickedSystem
import coffee.liz.ecs.World
+import kotlin.time.Duration
/**
- * System that updates animation playback state for all entities with an Animator component.
- *
- * This system:
- * - Updates elapsed time for playing animations
- * - Advances frame indices based on frame duration
- * - Handles loop modes (ONCE, LOOP, PING_PONG)
- * - Stops animations when they complete (for ONCE mode)
+ * Updates animation playback state for all [Animator] components in [World].
*/
-class AnimationSystem : System {
- override fun update(world: World, deltaTime: Float) {
- // Query all entities that have both Animator and SpriteSheet
- world.query(Animator::class, SpriteSheet::class).forEach { entity ->
- val animator = entity.get(Animator::class) ?: return@forEach
-
- // Skip if animation is not playing
+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
// Get the current animation clip
val clip = animator.clips[animator.currentClip] ?: return@forEach
// Accumulate elapsed time
- animator.elapsed += deltaTime
+ animator.elapsedTicks += ticks
// Advance frames while we have enough elapsed time
- while (animator.elapsed >= clip.frameDuration) {
- animator.elapsed -= clip.frameDuration
+ while (animator.elapsedTicks >= clip.frameTicks) {
+ animator.elapsedTicks -= clip.frameTicks
advanceFrame(animator, clip)
}
}
@@ -39,11 +31,10 @@ class AnimationSystem : System {
* Advances the animation to the next frame based on loop mode.
*/
private fun advanceFrame(animator: Animator, clip: AnimationClip) {
- animator.frameIndex += animator.direction
+ animator.frameIndex += animator.direction.step
when (clip.loopMode) {
LoopMode.ONCE -> {
- // Play once and stop on last frame
if (animator.frameIndex >= clip.frameNames.size) {
animator.frameIndex = clip.frameNames.size - 1
animator.playing = false
@@ -51,25 +42,21 @@ class AnimationSystem : System {
}
LoopMode.LOOP -> {
- // Loop back to beginning
if (animator.frameIndex >= clip.frameNames.size) {
animator.frameIndex = 0
} else if (animator.frameIndex < 0) {
- // Handle negative wrap (shouldn't happen in LOOP but be safe)
animator.frameIndex = clip.frameNames.size - 1
}
}
LoopMode.PING_PONG -> {
- // Bounce back and forth
+ // Go to (endFrame - 1) or (beginFrame + 1) ping ponging
if (animator.frameIndex >= clip.frameNames.size) {
- // Hit the end, reverse direction
animator.frameIndex = (clip.frameNames.size - 2).coerceAtLeast(0)
- animator.direction = -1
+ animator.direction = AnimationDirection.BACKWARD
} else if (animator.frameIndex < 0) {
- // Hit the beginning, reverse direction
animator.frameIndex = 1.coerceAtMost(clip.frameNames.size - 1)
- animator.direction = 1
+ animator.direction = AnimationDirection.FORWARD
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt
new file mode 100644
index 0000000..8c54bfe
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt
@@ -0,0 +1,7 @@
+package coffee.liz.ecs.input
+
+import coffee.liz.ecs.Component
+
+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
new file mode 100644
index 0000000..5735665
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt
@@ -0,0 +1,7 @@
+package coffee.liz.ecs.input
+
+data class InputState(val activeInputs: Set<String>)
+
+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
new file mode 100644
index 0000000..6dc8256
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt
@@ -0,0 +1,23 @@
+package coffee.liz.ecs.input
+
+import coffee.liz.ecs.System
+import coffee.liz.ecs.TickedSystem
+import coffee.liz.ecs.World
+import kotlin.time.Duration
+
+class InputSystem<Outside : InputProvider>(
+ pollRate: Duration
+) : TickedSystem<Outside>(pollRate) {
+ private val activeInputs = mutableMapOf<String, Int>()
+
+ override fun update(world: World<Outside>, state: Outside, ticks: Int) {
+ state.inputState.activeInputs.forEach {
+ activeInputs[it] = (activeInputs[it] ?: 0) + ticks
+ }
+ activeInputs.keys.retainAll(state.inputState.activeInputs)
+
+ world.query(Controlled::class).forEach {
+ 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
new file mode 100644
index 0000000..bc34ac1
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt
@@ -0,0 +1,28 @@
+package coffee.liz.ecs.physics
+
+import coffee.liz.ecs.Rect
+import coffee.liz.ecs.World
+
+internal class CollisionTick<Outside>(
+ private val collisionResolver: CollisionResolver<Outside>
+) {
+ fun runTick(world: World<Outside>) {
+ // 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)
+ }
+ }
+ }
+ }
+} \ 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
new file mode 100644
index 0000000..ffae10b
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt
@@ -0,0 +1,15 @@
+package coffee.liz.ecs.physics
+
+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
+
+/**
+ * @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<Rect>): 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
new file mode 100644
index 0000000..03af835
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt
@@ -0,0 +1,27 @@
+package coffee.liz.ecs.physics
+
+import coffee.liz.ecs.World
+
+internal class IntegrationTick<Outside> {
+ fun runTick(world: World<Outside>) {
+ 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)
+ )
+ )
+ }
+
+ world.query(Position::class, Velocity::class).forEach { entity ->
+ val position = entity.get(Position::class)
+ val velocity = entity.get(Acceleration::class)
+ entity.add(
+ Position(
+ 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
new file mode 100644
index 0000000..703e614
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt
@@ -0,0 +1,26 @@
+package coffee.liz.ecs.physics
+
+import coffee.liz.ecs.Entity
+import coffee.liz.ecs.TickedSystem
+import coffee.liz.ecs.World
+import kotlin.time.Duration
+
+internal fun interface CollisionResolver<Outside> {
+ fun resolveCollision(world: World<Outside>, a: Entity, b: Entity)
+}
+
+abstract class PhysicsSystem<Outside>(
+ physicsTickRate: Duration
+): TickedSystem<Outside>(physicsTickRate), CollisionResolver<Outside> {
+ private val integrationTick = IntegrationTick<Outside>()
+ private val collisionTick = CollisionTick(this)
+
+ abstract override fun resolveCollision(world: World<Outside>, a: Entity, b: Entity)
+
+ override fun update(world: World<Outside>, state: Outside, ticks: Int) {
+ for (tick in 1..ticks) {
+ collisionTick.runTick(world)
+ integrationTick.runTick(world)
+ }
+ }
+} \ No newline at end of file