diff options
| author | Elizabeth Hunt <me@liz.coffee> | 2025-10-23 21:59:37 -0700 |
|---|---|---|
| committer | Elizabeth Hunt <me@liz.coffee> | 2025-10-24 20:00:58 -0700 |
| commit | 64f825465de9fa30c4dfe2707067efdb96110db8 (patch) | |
| tree | 5241385e316e2f4ceede5018603103d71be75202 /composeApp/src/commonMain/kotlin/coffee/liz/ecs | |
| download | abstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.tar.gz abstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.zip | |
Init
Diffstat (limited to 'composeApp/src/commonMain/kotlin/coffee/liz/ecs')
7 files changed, 459 insertions, 0 deletions
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt new file mode 100644 index 0000000..c64a863 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt @@ -0,0 +1,7 @@ +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 new file mode 100644 index 0000000..d314065 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt @@ -0,0 +1,127 @@ +package coffee.liz.ecs + +import kotlin.reflect.KClass + +/** + * World implementation that executes systems in dependency order using a DAG. + */ +class DAGWorld(systems: List<System>) : World { + 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) + } + + override fun createEntity(): Entity { + val entity = Entity(nextEntityId++) + entities.add(entity) + return entity + } + + override fun destroyEntity(entity: Entity) { + // Remove from all component caches + entity.componentTypes().forEach { componentType -> + componentCache[componentType]?.remove(entity) + } + entities.remove(entity) + } + + 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 -> + entity.hasAll(*componentTypes) + }.toSet() + } + + override fun update(deltaTime: Float) { + // Update component cache based on current entity state + updateComponentCache() + + // Execute systems in dependency order + systemExecutionOrder.forEach { system -> + system.update(this, deltaTime) + } + } + + /** + * Rebuild the component cache based on current entity components. + * Call this after components have been added/removed from entities. + */ + private fun updateComponentCache() { + componentCache.clear() + entities.forEach { entity -> + entity.componentTypes().forEach { componentType -> + componentCache.getOrPut(componentType) { mutableSetOf() }.add(entity) + } + } + } + + /** + * Build a topologically sorted execution order from system dependencies. + * Uses Kahn's algorithm for topological sorting. + */ + private fun buildExecutionOrder(systems: List<System>): List<System> { + 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>>>() + + // Initialize graph + systems.forEach { system -> + val systemClass = system::class + inDegree[systemClass] = 0 + adjacencyList[systemClass] = mutableSetOf() + } + + // Build dependency graph (reversed - dependencies point to dependents) + systems.forEach { system -> + system.dependencies.forEach { dependency -> + if (systemMap.containsKey(dependency)) { + adjacencyList[dependency]!!.add(system::class) + inDegree[system::class] = inDegree[system::class]!! + 1 + } + } + } + + // Kahn's algorithm + val queue = ArrayDeque<KClass<out System>>() + val result = mutableListOf<System>() + + // Add all systems with no dependencies to queue + inDegree.forEach { (systemClass, degree) -> + if (degree == 0) { + queue.add(systemClass) + } + } + + while (queue.isNotEmpty()) { + val currentClass = queue.removeFirst() + result.add(systemMap[currentClass]!!) + + // Process dependents + adjacencyList[currentClass]?.forEach { dependent -> + inDegree[dependent] = inDegree[dependent]!! - 1 + if (inDegree[dependent] == 0) { + queue.add(dependent) + } + } + } + + // Check for cycles + if (result.size != systems.size) { + throw IllegalArgumentException("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 new file mode 100644 index 0000000..2ceac47 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt @@ -0,0 +1,70 @@ +package coffee.liz.ecs + +import kotlin.reflect.KClass + +/** + * An entity is a unique identifier with a collection of components. + */ +class Entity(val id: Int) { + private val components = mutableMapOf<KClass<out Component>, Component>() + + /** + * Add a component to this entity. + */ + fun <T : Component> add(component: T): Entity { + components[component::class] = component + return this + } + + /** + * Remove a component from this entity. + */ + fun <T : Component> remove(type: KClass<T>): Entity { + components.remove(type) + return this + } + + /** + * Get a component by type. + */ + @Suppress("UNCHECKED_CAST") + fun <T : Component> get(type: KClass<T>): T? { + return components[type] as? T + } + + /** + * Check if entity has a 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. + */ + fun componentTypes(): Set<KClass<out Component>> { + return 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)" +} + +// 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/System.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt new file mode 100644 index 0000000..0f8ef2a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt @@ -0,0 +1,22 @@ +package coffee.liz.ecs + +import kotlin.reflect.KClass + +/** + * Systems contain the logic that operates on entities with specific components. + */ +interface System { + /** + * Systems that must run before this system in the update loop. + * Used to construct a dependency DAG. + */ + 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 + */ + fun update(world: World, deltaTime: Float) +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt new file mode 100644 index 0000000..fd3b6df --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt @@ -0,0 +1,42 @@ +package coffee.liz.ecs + +import kotlin.jvm.JvmName +import kotlin.reflect.KClass + +/** + * The World manages entities and systems. + */ +interface World { + /** + * Create a new entity with a unique ID. + */ + fun createEntity(): Entity + + /** + * Destroy an entity and remove it from all caches. + */ + fun destroyEntity(entity: Entity) + + /** + * Get all entities that have all the specified component types. + */ + fun query(vararg componentTypes: KClass<out Component>): Set<Entity> + + /** + * Update all systems in dependency order. + * @param deltaTime Time elapsed since last update in seconds + */ + fun update(deltaTime: Float) +} + +// 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 new file mode 100644 index 0000000..4289eec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt @@ -0,0 +1,114 @@ +package coffee.liz.ecs.animation + +import coffee.liz.ecs.Component +import kotlin.jvm.JvmInline + +/** + * Loop modes for animation playback. + */ +enum class LoopMode { + /** Play once and stop on last frame */ + ONCE, + /** Repeat from beginning */ + LOOP, + /** Play forward, then backward, repeat */ + PING_PONG +} + +/** + * Type-safe wrapper for animation names. + */ +@JvmInline +value class AnimationName(val value: String) + +/** + * Type-safe wrapper for frame names. + */ +@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. + */ +data class AnimationClip( + val frameNames: List<FrameName>, + val frameDuration: Float, // seconds per frame + 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 + +/** + * Component for animation playback state. + * Contains animation clips and current playback state. + */ +data class Animator( + val clips: Map<AnimationName, AnimationClip>, + var currentClip: AnimationName, + var frameIndex: Int = 0, + var elapsed: Float = 0f, + var playing: Boolean = true, + var direction: Int = 1 // 1 for forward, -1 for backward (used in PING_PONG) +) : 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 + } + } + + /** + * Pause the current animation. + */ + fun pause() { + playing = false + } + + /** + * Resume the current animation. + */ + fun resume() { + playing = true + } + + /** + * Get the current frame name being displayed. + */ + fun getCurrentFrameName(): FrameName? { + 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 diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt new file mode 100644 index 0000000..7ae405e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt @@ -0,0 +1,77 @@ +package coffee.liz.ecs.animation + +import coffee.liz.ecs.System +import coffee.liz.ecs.World + +/** + * 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) + */ +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 + if (!animator.playing) return@forEach + + // Get the current animation clip + val clip = animator.clips[animator.currentClip] ?: return@forEach + + // Accumulate elapsed time + animator.elapsed += deltaTime + + // Advance frames while we have enough elapsed time + while (animator.elapsed >= clip.frameDuration) { + animator.elapsed -= clip.frameDuration + advanceFrame(animator, clip) + } + } + } + + /** + * Advances the animation to the next frame based on loop mode. + */ + private fun advanceFrame(animator: Animator, clip: AnimationClip) { + animator.frameIndex += animator.direction + + 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 + } + } + + 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 + if (animator.frameIndex >= clip.frameNames.size) { + // Hit the end, reverse direction + animator.frameIndex = (clip.frameNames.size - 2).coerceAtLeast(0) + animator.direction = -1 + } else if (animator.frameIndex < 0) { + // Hit the beginning, reverse direction + animator.frameIndex = 1.coerceAtMost(clip.frameNames.size - 1) + animator.direction = 1 + } + } + } + } +} |
