summaryrefslogtreecommitdiff
path: root/composeApp/src/commonMain/kotlin/coffee/liz
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-10-26 21:38:22 -0700
committerElizabeth Hunt <me@liz.coffee>2025-10-26 21:39:58 -0700
commita8e5e723b7e1891c9b352261a3ee4c3d3563e8cf (patch)
tree853df79c877d37d7e5d25f52b301aedcc3d5db55 /composeApp/src/commonMain/kotlin/coffee/liz
parent395aa7d1c312e495517701be11c21425d9a5838e (diff)
downloadabstraction-engine-kt-main.tar.gz
abstraction-engine-kt-main.zip
Checkpoint twoHEADmain
Diffstat (limited to 'composeApp/src/commonMain/kotlin/coffee/liz')
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt2
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt3
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt25
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt4
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt4
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt9
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt15
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt29
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt18
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt3
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt6
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt29
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt57
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt5
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt35
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt14
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Components.kt8
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/Grid.kt54
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/grid/GridSystem.kt17
-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.kt19
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt95
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt25
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt14
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt25
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsTick.kt10
27 files changed, 403 insertions, 136 deletions
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<String, ImageBitmap>()
- 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<KClass<out System>> = 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<Outside>(systems: List<System<Outside>>) : World<Outside> {
+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<Outside>> = 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<Outside>(systems: List<System<Outside>>) : World<Outside> {
override fun query(vararg componentTypes: KClass<out Component>): Set<Entity> {
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<Outside>(systems: List<System<Outside>>) : World<Outside> {
}
}
- /**
- * Build a topologically sorted execution order from system dependencies.
- */
private fun buildExecutionOrder(systems: List<System<Outside>>): List<System<Outside>> {
if (systems.isEmpty()) return emptyList()
@@ -88,7 +87,6 @@ class DAGWorld<Outside>(systems: List<System<Outside>>) : World<Outside> {
val queue = ArrayDeque<KClass<out System<Outside>>>()
val result = mutableListOf<System<Outside>>()
- // Add all systems with no dependencies to queue
queue.addAll(inDegree.filterValues { it == 0 }.keys)
while (queue.isNotEmpty()) {
@@ -104,7 +102,6 @@ class DAGWorld<Outside>(systems: List<System<Outside>>) : World<Outside> {
}
}
- // 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<KClass<out Component>, Component>()
/**
@@ -28,29 +30,25 @@ class Entity(val id: Int) {
* 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 = components[type] as T
/**
* Has [Component]
*/
- fun <T : Component> has(type: KClass<T>): Boolean {
- return components.containsKey(type)
- }
+ fun <T : Component> has(type: KClass<T>): Boolean = components.containsKey(type)
/**
* [Component]s of this entity.
*/
- fun componentTypes(): Set<KClass<out Component>> {
- return components.keys.toSet()
- }
+ fun componentTypes(): Set<KClass<out Component>> = 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<Outside> {
}
abstract fun render(world: World<Outside>)
+
abstract fun outsideState(): Outside
fun loop(now: Long) {
@@ -22,4 +23,4 @@ abstract class GameLoop<Outside> {
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<Outside> {
* @param world [World]
* @param deltaTime [Duration] since last update
*/
- fun update(world: World<Outside>, state: Outside, deltaTime: Duration)
+ 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
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<Outside>(
protected val tickRate: Duration,
- private var lastTick: Duration = Duration.ZERO
-): System<Outside> {
- abstract fun update(world: World<Outside>, state: Outside, ticks: Int)
+ 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) {
+ 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
+ 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<Rect>> = 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<Outside> {
/**
* 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<FrameName>,
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<FrameName, Rect>
+ val frames: Map<FrameName, Rect>,
) : 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<MutableSet<Entity>>> =
+ MutableList(dimensions.y.toInt()) {
+ MutableList(dimensions.x.toInt()) { mutableSetOf() }
+ },
+) {
+ fun at(position: Vec2): MutableSet<Entity> {
+ 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<Entity>) {
+ 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<Outside>(
+ private val grid: Grid
+): System<Outside> {
+ override val dependencies = setOf(PhysicsSystem::class)
+
+ override fun update(world: World<Outside>, 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<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
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<String>,
+)
+
+interface InputProvider {
+ val inputState: InputState
+}
+
class InputSystem<Outside : InputProvider>(
- pollRate: Duration
+ pollRate: Duration,
) : TickedSystem<Outside>(pollRate) {
private val activeInputs = mutableMapOf<String, Int>()
- override fun update(world: World<Outside>, state: Outside, ticks: Int) {
+ override fun update(
+ world: World<Outside>,
+ state: Outside,
+ ticks: Int,
+ ) {
state.inputState.activeInputs.forEach {
activeInputs[it] = (activeInputs[it] ?: 0) + ticks
}
@@ -20,4 +31,4 @@ class InputSystem<Outside : InputProvider>(
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<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)
+ private val collisionResolver: CollisionResolver<Outside>,
+ private val subgridDimension: Vec2 = Vec2(50f, 50f)
+) : PhysicsTick<Outside> {
+ override fun runTick(world: World<Outside>) {
+ 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<Outside>, potentialCollisions: Collection<Entity>) {
+ 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<Outside>, 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<Rect> {
+ 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<Rect>.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<TGroup> {
+ val collisionGroup: TGroup
+ val collideInto: Set<TGroup>
+}
/**
* @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
+data class Colliding<T : Enum<*>>(
+ val hitboxes: Collection<Rect>,
+ val group: CollisionGroup<T>,
+ 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<Outside> {
- fun runTick(world: World<Outside>) {
+internal class IntegrationTick<Outside> : PhysicsTick<Outside> {
+ override 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)
- )
+ vec2 = velocity.vec2.plus(acceleration.vec2),
+ ),
)
}
@@ -19,9 +19,9 @@ internal class IntegrationTick<Outside> {
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<Outside> {
- fun resolveCollision(world: World<Outside>, a: Entity, b: Entity)
+ fun resolveCollision(
+ world: World<Outside>,
+ a: Entity,
+ b: Entity,
+ )
}
abstract class PhysicsSystem<Outside>(
- physicsTickRate: Duration
-): TickedSystem<Outside>(physicsTickRate), CollisionResolver<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)
+ abstract override fun resolveCollision(
+ world: World<Outside>,
+ a: Entity,
+ b: Entity,
+ )
- override fun update(world: World<Outside>, state: Outside, ticks: Int) {
+ 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
+}
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<Outside> {
+ fun runTick(world: World<Outside>)
+}