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 | |
| download | abstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.tar.gz abstraction-engine-kt-64f825465de9fa30c4dfe2707067efdb96110db8.zip | |
Init
Diffstat (limited to 'composeApp/src/commonMain')
20 files changed, 1126 insertions, 0 deletions
diff --git a/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml new file mode 100644 index 0000000..1ffc948 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/drawable/compose-multiplatform.xml @@ -0,0 +1,44 @@ +<vector + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="450dp" + android:height="450dp" + android:viewportWidth="64" + android:viewportHeight="64"> + <path + android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z" + android:fillColor="#6075f2"/> + <path + android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z" + android:fillColor="#6b57ff"/> + <path + android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z"> + <aapt:attr name="android:fillColor"> + <gradient + android:centerX="23.131" + android:centerY="18.441" + android:gradientRadius="42.132" + android:type="radial"> + <item android:offset="0" android:color="#FF5383EC"/> + <item android:offset="0.867" android:color="#FF7F52FF"/> + </gradient> + </aapt:attr> + </path> + <path + android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z"> + <aapt:attr name="android:fillColor"> + <gradient + android:startX="44.172" + android:startY="4.377" + android:endX="17.973" + android:endY="34.035" + android:type="linear"> + <item android:offset="0" android:color="#FF33C3FF"/> + <item android:offset="0.878" android:color="#FF5383EC"/> + </gradient> + </aapt:attr> + </path> + <path + android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z" + android:fillColor="#000000"/> +</vector>
\ No newline at end of file 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 + } + } + } + } +} |
