summaryrefslogtreecommitdiff
path: root/composeApp/src/commonMain/kotlin/coffee
diff options
context:
space:
mode:
authorElizabeth Hunt <me@liz.coffee>2025-10-23 21:59:37 -0700
committerElizabeth Hunt <me@liz.coffee>2025-10-24 20:00:58 -0700
commit64f825465de9fa30c4dfe2707067efdb96110db8 (patch)
tree5241385e316e2f4ceede5018603103d71be75202 /composeApp/src/commonMain/kotlin/coffee
downloadabstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.tar.gz
abstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.zip
Init
Diffstat (limited to 'composeApp/src/commonMain/kotlin/coffee')
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt5
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt28
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt9
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt7
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt77
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt23
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt70
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt52
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt130
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt105
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt85
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt32
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt7
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt127
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt70
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt22
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt42
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt114
-rw-r--r--composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt77
19 files changed, 1082 insertions, 0 deletions
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt
new file mode 100644
index 0000000..d557fd8
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt
@@ -0,0 +1,5 @@
+package coffee.liz.abstractionengine
+
+class AbstractionEngine {
+
+} \ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt
new file mode 100644
index 0000000..a69e711
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt
@@ -0,0 +1,28 @@
+package coffee.liz.abstractionengine
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import coffee.liz.abstractionengine.ui.ArcadeControls
+import coffee.liz.abstractionengine.ui.GameBoyTheme
+import org.jetbrains.compose.ui.tooling.preview.Preview
+
+@Composable
+@Preview
+fun App() {
+ MaterialTheme(colorScheme = GameBoyTheme) {
+ ArcadeControls(
+ onDirectionPressed = { direction ->
+ println("Direction pressed: $direction")
+ },
+ onActionA = {
+ println("Action A pressed!")
+ },
+ onActionB = {
+ println("Action B pressed!")
+ },
+ modifier = Modifier.fillMaxSize()
+ )
+ }
+} \ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt
new file mode 100644
index 0000000..6f23320
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt
@@ -0,0 +1,9 @@
+package coffee.liz.abstractionengine
+
+class Greeting {
+ private val platform = getPlatform()
+
+ fun greet(): String {
+ return "Hello, ${platform.name}!"
+ }
+} \ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt
new file mode 100644
index 0000000..91faf62
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt
@@ -0,0 +1,7 @@
+package coffee.liz.abstractionengine
+
+interface Platform {
+ val name: String
+}
+
+expect fun getPlatform(): Platform \ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt
new file mode 100644
index 0000000..9490f83
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt
@@ -0,0 +1,77 @@
+package coffee.liz.abstractionengine.game
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import coffee.liz.ecs.World
+import kotlinx.coroutines.isActive
+
+/**
+ * Manages the game loop for an ECS world.
+ * Syncs with Compose's frame rate (like requestAnimationFrame) and provides
+ * the world to child content for custom rendering/UI.
+ *
+ * @param world The ECS world containing entities and systems
+ * @param content Composable content that can use the world for rendering
+ */
+@Composable
+fun Game(
+ world: World,
+ content: @Composable () -> Unit
+) {
+ // Trigger recomposition when we need to redraw
+ var frameCount by remember { mutableStateOf(0) }
+
+ // Game loop - syncs with Compose's frame rate like requestAnimationFrame
+ LaunchedEffect(world) {
+ var lastFrameTimeNanos = 0L
+
+ while (isActive) {
+ // Wait for next frame (like requestAnimationFrame)
+ withFrameNanos { frameTimeNanos ->
+ val deltaTime = if (lastFrameTimeNanos != 0L) {
+ (frameTimeNanos - lastFrameTimeNanos) / 1_000_000_000f
+ } else {
+ 0f
+ }
+ lastFrameTimeNanos = frameTimeNanos
+
+ // Update ECS world (runs all systems)
+ if (deltaTime > 0f) {
+ world.update(deltaTime)
+ }
+
+ // Trigger recomposition to redraw
+ frameCount++
+ }
+ }
+ }
+
+ // Render content
+ content()
+}
+
+/**
+ * A Canvas that renders sprites from an ECS world using a RenderSystem.
+ *
+ * @param world The ECS world containing entities to render
+ * @param renderSystem The system responsible for rendering sprites
+ * @param backgroundColor Background color for the canvas
+ * @param modifier Modifier for the Canvas
+ */
+@Composable
+fun GameCanvas(
+ world: World,
+ renderSystem: RenderSystem,
+ backgroundColor: Color = Color.Transparent,
+ modifier: Modifier = Modifier
+) {
+ Canvas(modifier = modifier) {
+ // Clear background
+ drawRect(backgroundColor)
+
+ // Render all sprites
+ renderSystem.render(world, this)
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt
new file mode 100644
index 0000000..0722535
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/ImageCache.kt
@@ -0,0 +1,23 @@
+package coffee.liz.abstractionengine.game
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+/**
+ * Simple cache for loaded images.
+ * In a real game, you'd want more sophisticated resource management.
+ */
+class ImageCache {
+ private val images = mutableMapOf<String, ImageBitmap>()
+
+ fun loadImage(path: String, image: ImageBitmap) {
+ images[path] = image
+ }
+
+ fun getImage(path: String): ImageBitmap? {
+ return images[path]
+ }
+
+ fun clear() {
+ images.clear()
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt
new file mode 100644
index 0000000..1d22ae6
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt
@@ -0,0 +1,70 @@
+package coffee.liz.abstractionengine.game
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.IntSize
+import coffee.liz.ecs.System
+import coffee.liz.ecs.World
+import coffee.liz.ecs.animation.AnimationSystem
+import coffee.liz.ecs.animation.Animator
+import coffee.liz.ecs.animation.Position
+import coffee.liz.ecs.animation.SpriteSheet
+import kotlin.reflect.KClass
+
+/**
+ * System that renders sprites to a DrawScope.
+ *
+ * This system queries all entities with Position, SpriteSheet, and Animator components,
+ * then draws their current animation frame to the provided DrawScope.
+ *
+ * Depends on AnimationSystem to ensure animations are updated before rendering.
+ */
+class RenderSystem(
+ private val imageCache: ImageCache
+) : System {
+
+ override val dependencies: Set<KClass<out System>> = setOf(AnimationSystem::class)
+
+ /**
+ * The update method is required by the System interface, but rendering
+ * happens synchronously during Compose's draw phase via the render() method.
+ */
+ override fun update(world: World, deltaTime: Float) {
+ // Rendering happens in render() method, not here
+ }
+
+ /**
+ * 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) {
+ // 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
+ val spriteSheet = entity.get(SpriteSheet::class) ?: return@forEach
+ val animator = entity.get(Animator::class) ?: return@forEach
+
+ // Get the current frame name from the animator
+ val frameName = animator.getCurrentFrameName() ?: return@forEach
+
+ // Look up the frame rectangle in the sprite sheet
+ val frameRect = spriteSheet.frames[frameName] ?: return@forEach
+
+ // Get the image from cache
+ val image = imageCache.getImage(spriteSheet.imagePath) ?: return@forEach
+
+ // Draw the sprite
+ with(drawScope) {
+ drawImage(
+ image = image,
+ 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)
+ )
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt
new file mode 100644
index 0000000..9d50192
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt
@@ -0,0 +1,52 @@
+package coffee.liz.abstractionengine.ui
+
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+enum class ArcadeButtonColor {
+ RED, YELLOW
+}
+
+@Composable
+fun ArcadeButton(
+ label: String,
+ color: ArcadeButtonColor = ArcadeButtonColor.RED,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ val buttonColor = when (color) {
+ ArcadeButtonColor.RED -> GameBoyColors.ButtonRed
+ ArcadeButtonColor.YELLOW -> GameBoyColors.ButtonYellow
+ }
+
+ Button(
+ onClick = onClick,
+ modifier = modifier
+ .size(65.dp)
+ .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape),
+ shape = CircleShape,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = buttonColor,
+ contentColor = MaterialTheme.colorScheme.onPrimary
+ ),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(
+ text = label,
+ fontSize = 20.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt
new file mode 100644
index 0000000..f5b4839
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt
@@ -0,0 +1,130 @@
+package coffee.liz.abstractionengine.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Composable
+fun ArcadeControls(
+ onDirectionPressed: (Direction) -> Unit,
+ onActionA: () -> Unit,
+ onActionB: () -> Unit,
+ modifier: Modifier = Modifier,
+ gameContent: @Composable BoxScope.() -> Unit = {
+ // Default placeholder content
+ Column(
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Text(
+ text = "GAME AREA",
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ color = MaterialTheme.colorScheme.onPrimary
+ )
+ }
+ }
+) {
+ var lastDirection by remember { mutableStateOf<Direction?>(null) }
+ var lastAction by remember { mutableStateOf<String?>(null) }
+
+ Box(modifier = modifier.fillMaxSize()) {
+ // PCB background layer
+ PCBBackground()
+
+ // Transparent casing overlay
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(Color(0x33000000))
+ )
+
+ // Content
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ // Game area (square)
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.95f)
+ .aspectRatio(1f)
+ .background(
+ color = GameBoyColors.ScreenGreen.copy(alpha = 0.85f),
+ shape = RoundedCornerShape(8.dp)
+ )
+ .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(8.dp))
+ .padding(8.dp),
+ contentAlignment = Alignment.Center,
+ content = gameContent
+ )
+
+ // Control panel
+ Box(
+ modifier = Modifier
+ .fillMaxWidth(0.95f)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.7f),
+ shape = RoundedCornerShape(16.dp)
+ )
+ .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(16.dp))
+ .padding(horizontal = 16.dp, vertical = 16.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // D-Pad on the left
+ DPad(
+ onDirectionPressed = { direction ->
+ lastDirection = direction
+ lastAction = null
+ onDirectionPressed(direction)
+ }
+ )
+
+ // Action buttons on the right
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ ArcadeButton(
+ label = "B",
+ color = ArcadeButtonColor.YELLOW,
+ onClick = {
+ lastAction = "B"
+ lastDirection = null
+ onActionB()
+ }
+ )
+ ArcadeButton(
+ label = "A",
+ color = ArcadeButtonColor.RED,
+ onClick = {
+ lastAction = "A"
+ lastDirection = null
+ onActionA()
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt
new file mode 100644
index 0000000..52f5866
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt
@@ -0,0 +1,105 @@
+package coffee.liz.abstractionengine.ui
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+enum class Direction {
+ UP, DOWN, LEFT, RIGHT
+}
+
+@Composable
+fun DPad(
+ onDirectionPressed: (Direction) -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .size(140.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ shape = CircleShape
+ )
+ .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
+ ) {
+ // Up button
+ DirectionButton(
+ text = "▲",
+ onClick = { onDirectionPressed(Direction.UP) },
+ modifier = Modifier
+ .align(Alignment.TopCenter)
+ .offset(y = 20.dp)
+ )
+
+ // Down button
+ DirectionButton(
+ text = "▼",
+ onClick = { onDirectionPressed(Direction.DOWN) },
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .offset(y = (-20).dp)
+ )
+
+ // Left button
+ DirectionButton(
+ text = "◀",
+ onClick = { onDirectionPressed(Direction.LEFT) },
+ modifier = Modifier
+ .align(Alignment.CenterStart)
+ .offset(x = 20.dp)
+ )
+
+ // Right button
+ DirectionButton(
+ text = "▶",
+ onClick = { onDirectionPressed(Direction.RIGHT) },
+ modifier = Modifier
+ .align(Alignment.CenterEnd)
+ .offset(x = (-20).dp)
+ )
+
+ // Center circle
+ Box(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .size(38.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surface.copy(alpha = 0.5f),
+ shape = CircleShape
+ )
+ .border(1.dp, MaterialTheme.colorScheme.outline, CircleShape)
+ )
+ }
+}
+
+@Composable
+private fun DirectionButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier.size(38.dp),
+ shape = RoundedCornerShape(4.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
+ contentColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.3f)
+ ),
+ contentPadding = PaddingValues(0.dp)
+ ) {
+ Text(
+ text = text,
+ fontSize = 14.sp,
+ style = MaterialTheme.typography.headlineMedium
+ )
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt
new file mode 100644
index 0000000..be35a1a
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt
@@ -0,0 +1,85 @@
+package coffee.liz.abstractionengine.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.PathEffect
+
+@Composable
+fun PCBBackground(modifier: Modifier = Modifier) {
+ val pcbGreen = Color(0xFF0D5F0D)
+ val traceColor = Color(0xFF1A7F1A)
+ val padColor = Color(0xFFD4AF37)
+
+ Canvas(modifier = modifier.fillMaxSize().background(pcbGreen)) {
+ val width = size.width
+ val height = size.height
+
+ // Draw circuit traces (horizontal and vertical lines)
+ val pathEffect = PathEffect.dashPathEffect(floatArrayOf(10f, 5f), 0f)
+
+ // Horizontal traces
+ for (i in 0..15) {
+ val y = (i * height / 15)
+ drawLine(
+ color = traceColor,
+ start = Offset(0f, y),
+ end = Offset(width, y),
+ strokeWidth = 2f,
+ pathEffect = pathEffect
+ )
+ }
+
+ // Vertical traces
+ for (i in 0..10) {
+ val x = (i * width / 10)
+ drawLine(
+ color = traceColor,
+ start = Offset(x, 0f),
+ end = Offset(x, height),
+ strokeWidth = 2f,
+ pathEffect = pathEffect
+ )
+ }
+
+ // Draw pads/components (small circles at intersections)
+ for (i in 0..10 step 2) {
+ for (j in 0..15 step 3) {
+ val x = i * width / 10
+ val y = j * height / 15
+ drawCircle(
+ color = padColor,
+ radius = 4f,
+ center = Offset(x, y)
+ )
+ }
+ }
+
+ // Draw some resistor-like rectangles
+ for (i in 1..9 step 4) {
+ val x = i * width / 10
+ val y = height / 2
+ drawRect(
+ color = Color(0xFF2A4A2A),
+ topLeft = Offset(x - 15f, y - 8f),
+ size = androidx.compose.ui.geometry.Size(30f, 16f)
+ )
+ // Resistor contacts
+ drawCircle(
+ color = padColor,
+ radius = 3f,
+ center = Offset(x - 15f, y)
+ )
+ drawCircle(
+ color = padColor,
+ radius = 3f,
+ center = Offset(x + 15f, y)
+ )
+ }
+ }
+}
diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt
new file mode 100644
index 0000000..661cb09
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt
@@ -0,0 +1,32 @@
+package coffee.liz.abstractionengine.ui
+
+import androidx.compose.material3.darkColorScheme
+import androidx.compose.ui.graphics.Color
+
+// GameBoy-inspired color palette
+object GameBoyColors {
+ val DarkestGreen = Color(0xFF0F380F)
+ val DarkGreen = Color(0xFF306230)
+ val MediumGreen = Color(0xFF8BAC0F)
+ val LightGreen = Color(0xFF9BBC0F)
+ val ScreenGreen = Color(0xFF8BAC0F)
+
+ // Accent colors for buttons (still retro but with more variety)
+ val ButtonRed = Color(0xFFE76F51)
+ val ButtonYellow = Color(0xFFF4A261)
+ val DPadGray = Color(0xFF4A5759)
+ 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,
+)
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
+ }
+ }
+ }
+ }
+}