From 395aa7d1c312e495517701be11c21425d9a5838e Mon Sep 17 00:00:00 2001 From: Elizabeth Hunt Date: Sun, 26 Oct 2025 17:25:13 -0700 Subject: Checkpoint --- .../liz/abstractionengine/AbstractionEngine.kt | 5 - .../kotlin/coffee/liz/abstractionengine/App.kt | 28 ----- .../coffee/liz/abstractionengine/Greeting.kt | 9 -- .../coffee/liz/abstractionengine/Platform.kt | 2 +- .../kotlin/coffee/liz/abstractionengine/app/App.kt | 24 ++++ .../coffee/liz/abstractionengine/app/ui/Theme.kt | 32 +++++ .../abstractionengine/game/AbstractionEngine.kt | 5 + .../coffee/liz/abstractionengine/game/Game.kt | 37 +----- .../liz/abstractionengine/game/RenderSystem.kt | 70 ----------- .../coffee/liz/abstractionengine/game/Renderer.kt | 65 +++++++++++ .../liz/abstractionengine/ui/ArcadeButton.kt | 52 --------- .../liz/abstractionengine/ui/ArcadeControls.kt | 130 --------------------- .../kotlin/coffee/liz/abstractionengine/ui/DPad.kt | 105 ----------------- .../liz/abstractionengine/ui/PCBBackground.kt | 85 -------------- .../coffee/liz/abstractionengine/ui/Theme.kt | 32 ----- .../commonMain/kotlin/coffee/liz/ecs/Component.kt | 4 - .../commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt | 61 ++++------ .../src/commonMain/kotlin/coffee/liz/ecs/Entity.kt | 28 ++--- .../commonMain/kotlin/coffee/liz/ecs/GameLoop.kt | 25 ++++ .../src/commonMain/kotlin/coffee/liz/ecs/System.kt | 17 +-- .../kotlin/coffee/liz/ecs/TickedSystem.kt | 17 +++ .../src/commonMain/kotlin/coffee/liz/ecs/Vec.kt | 33 ++++++ .../src/commonMain/kotlin/coffee/liz/ecs/World.kt | 32 ++--- .../liz/ecs/animation/AnimationComponents.kt | 67 +++++------ .../coffee/liz/ecs/animation/AnimationSystem.kt | 41 +++---- .../kotlin/coffee/liz/ecs/input/Controlled.kt | 7 ++ .../kotlin/coffee/liz/ecs/input/InputProvider.kt | 7 ++ .../kotlin/coffee/liz/ecs/input/InputSystem.kt | 23 ++++ .../kotlin/coffee/liz/ecs/physics/CollisionTick.kt | 28 +++++ .../kotlin/coffee/liz/ecs/physics/Components.kt | 15 +++ .../coffee/liz/ecs/physics/IntegrationTick.kt | 27 +++++ .../kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt | 26 +++++ 32 files changed, 432 insertions(+), 707 deletions(-) delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt delete mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt create mode 100644 composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt (limited to 'composeApp/src/commonMain/kotlin/coffee') diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt deleted file mode 100644 index d557fd8..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/AbstractionEngine.kt +++ /dev/null @@ -1,5 +0,0 @@ -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 deleted file mode 100644 index a69e711..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/App.kt +++ /dev/null @@ -1,28 +0,0 @@ -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 deleted file mode 100644 index 6f23320..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Greeting.kt +++ /dev/null @@ -1,9 +0,0 @@ -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 index 91faf62..17ca317 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/Platform.kt @@ -1,7 +1,7 @@ package coffee.liz.abstractionengine interface Platform { - val name: String + val needsTouchscreenControls: Boolean } expect fun getPlatform(): Platform \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt new file mode 100644 index 0000000..50e72f0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/App.kt @@ -0,0 +1,24 @@ +package coffee.liz.abstractionengine.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import coffee.liz.abstractionengine.app.ui.GameBoyTheme +import coffee.liz.abstractionengine.getPlatform +import org.jetbrains.compose.ui.tooling.preview.Preview + +@Composable +@Preview +fun App() { + + MaterialTheme(colorScheme = GameBoyTheme) { + Box(modifier = Modifier.fillMaxSize()) { + if (getPlatform().needsTouchscreenControls) { + Text("NEEDS TOUCHSCREEN CONTROLS") + } + } + } +} \ 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 new file mode 100644 index 0000000..be6c3ad --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/app/ui/Theme.kt @@ -0,0 +1,32 @@ +package coffee.liz.abstractionengine.app.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/abstractionengine/game/AbstractionEngine.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt new file mode 100644 index 0000000..18349c3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/AbstractionEngine.kt @@ -0,0 +1,5 @@ +package coffee.liz.abstractionengine.game + +class AbstractionEngine { + +} \ 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 index 9490f83..0c3a007 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Game.kt @@ -6,64 +6,35 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import coffee.liz.ecs.World import kotlinx.coroutines.isActive +import kotlin.time.Duration.Companion.nanoseconds /** - * 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 + * Manages and paints the [World]. */ @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 + var lastUpdate: Long? = null 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, + renderSystem: Renderer, backgroundColor: Color = Color.Transparent, modifier: Modifier = Modifier ) { diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt deleted file mode 100644 index 1d22ae6..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/RenderSystem.kt +++ /dev/null @@ -1,70 +0,0 @@ -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> = 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/game/Renderer.kt b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt new file mode 100644 index 0000000..57511f3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/game/Renderer.kt @@ -0,0 +1,65 @@ +package coffee.liz.abstractionengine.game + +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 +import kotlin.time.Duration + +/** + * 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 Renderer( + private val imageCache: ImageCache +) : System { + + override val dependencies: Set> = setOf(AnimationSystem::class) + + override fun update(world: World, duration: Duration) { + // 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 deleted file mode 100644 index 9d50192..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeButton.kt +++ /dev/null @@ -1,52 +0,0 @@ -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 deleted file mode 100644 index f5b4839..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/ArcadeControls.kt +++ /dev/null @@ -1,130 +0,0 @@ -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(null) } - var lastAction by remember { mutableStateOf(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 deleted file mode 100644 index 52f5866..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/DPad.kt +++ /dev/null @@ -1,105 +0,0 @@ -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 deleted file mode 100644 index be35a1a..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/PCBBackground.kt +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index 661cb09..0000000 --- a/composeApp/src/commonMain/kotlin/coffee/liz/abstractionengine/ui/Theme.kt +++ /dev/null @@ -1,32 +0,0 @@ -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 index c64a863..cfb01b1 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Component.kt @@ -1,7 +1,3 @@ package coffee.liz.ecs -/** - * Marker interface for all components. - * Components are pure data containers with no behavior. - */ interface Component diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt index d314065..77cb30b 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/DAGWorld.kt @@ -1,28 +1,27 @@ package coffee.liz.ecs +import kotlin.concurrent.atomics.AtomicInt +import kotlin.concurrent.atomics.ExperimentalAtomicApi +import kotlin.concurrent.atomics.incrementAndFetch import kotlin.reflect.KClass +import kotlin.time.Duration /** - * World implementation that executes systems in dependency order using a DAG. + * [World] that updates in [System.dependencies] DAG order. */ -class DAGWorld(systems: List) : World { +@OptIn(ExperimentalAtomicApi::class) +class DAGWorld(systems: List>) : World { private val entities = mutableSetOf() private val componentCache = mutableMapOf, MutableSet>() - private val systemExecutionOrder: List - private var nextEntityId = 0 - - init { - systemExecutionOrder = buildExecutionOrder(systems) - } + private val systemExecutionOrder: List> = buildExecutionOrder(systems) + private val nextEntityId = AtomicInt(0) override fun createEntity(): Entity { - val entity = Entity(nextEntityId++) - entities.add(entity) - return entity + return Entity(nextEntityId.incrementAndFetch()) + .also { entities.add(it) } } - override fun destroyEntity(entity: Entity) { - // Remove from all component caches + override fun removeEntity(entity: Entity) { entity.componentTypes().forEach { componentType -> componentCache[componentType]?.remove(entity) } @@ -38,25 +37,18 @@ class DAGWorld(systems: List) : World { // Filter to entities that have all component types return candidates.filter { entity -> - entity.hasAll(*componentTypes) + componentTypes.all { entity.has(it) } }.toSet() } - override fun update(deltaTime: Float) { - // Update component cache based on current entity state - updateComponentCache() - - // Execute systems in dependency order + override fun update(state: Outside, deltaTime: Duration) { + refreshComponentCache() systemExecutionOrder.forEach { system -> - system.update(this, deltaTime) + system.update(this, state, deltaTime) } } - /** - * Rebuild the component cache based on current entity components. - * Call this after components have been added/removed from entities. - */ - private fun updateComponentCache() { + private fun refreshComponentCache() { componentCache.clear() entities.forEach { entity -> entity.componentTypes().forEach { componentType -> @@ -67,14 +59,13 @@ class DAGWorld(systems: List) : World { /** * Build a topologically sorted execution order from system dependencies. - * Uses Kahn's algorithm for topological sorting. */ - private fun buildExecutionOrder(systems: List): List { + private fun buildExecutionOrder(systems: List>): List> { if (systems.isEmpty()) return emptyList() val systemMap = systems.associateBy { it::class } - val inDegree = mutableMapOf, Int>() - val adjacencyList = mutableMapOf, MutableSet>>() + val inDegree = mutableMapOf>, Int>() + val adjacencyList = mutableMapOf>, MutableSet>>>() // Initialize graph systems.forEach { system -> @@ -94,15 +85,11 @@ class DAGWorld(systems: List) : World { } // Kahn's algorithm - val queue = ArrayDeque>() - val result = mutableListOf() + val queue = ArrayDeque>>() + val result = mutableListOf>() // Add all systems with no dependencies to queue - inDegree.forEach { (systemClass, degree) -> - if (degree == 0) { - queue.add(systemClass) - } - } + queue.addAll(inDegree.filterValues { it == 0 }.keys) while (queue.isNotEmpty()) { val currentClass = queue.removeFirst() @@ -119,7 +106,7 @@ class DAGWorld(systems: List) : World { // Check for cycles if (result.size != systems.size) { - throw IllegalArgumentException("Circular dependency detected in systems") + error("Circular dependency detected in systems") } return result diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt index 2ceac47..fcc39fb 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Entity.kt @@ -9,7 +9,7 @@ class Entity(val id: Int) { private val components = mutableMapOf, Component>() /** - * Add a component to this entity. + * Add [Component]. */ fun add(component: T): Entity { components[component::class] = component @@ -17,7 +17,7 @@ class Entity(val id: Int) { } /** - * Remove a component from this entity. + * Remove [Component]. */ fun remove(type: KClass): Entity { components.remove(type) @@ -25,29 +25,22 @@ class Entity(val id: Int) { } /** - * Get a component by type. + * Get [Component] */ @Suppress("UNCHECKED_CAST") - fun get(type: KClass): T? { - return components[type] as? T + fun get(type: KClass): T { + return components[type] as T } /** - * Check if entity has a component. + * Has [Component] */ fun has(type: KClass): Boolean { return components.containsKey(type) } /** - * Check if entity has all specified components. - */ - fun hasAll(vararg types: KClass): Boolean { - return types.all { components.containsKey(it) } - } - - /** - * Get all component types on this entity. + * [Component]s of this entity. */ fun componentTypes(): Set> { return components.keys.toSet() @@ -58,13 +51,6 @@ class Entity(val id: Int) { if (other !is Entity) return false return id == other.id } - override fun hashCode(): Int = id - override fun toString(): String = "Entity($id)" } - -// Convenience extensions for cleaner syntax -inline fun Entity.get(): T? = get(T::class) -inline fun Entity.has(): Boolean = has(T::class) -inline fun Entity.remove(): Entity = remove(T::class) diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt new file mode 100644 index 0000000..713586d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/GameLoop.kt @@ -0,0 +1,25 @@ +package coffee.liz.ecs + +import kotlin.time.Duration.Companion.nanoseconds + +abstract class GameLoop { + private var world: World? = null + private var lastUpdate: Long? = null + + fun load(world: World) { + this.world = world + this.lastUpdate = null + } + + abstract fun render(world: World) + abstract fun outsideState(): Outside + + fun loop(now: Long) { + val world = this.world ?: error("World not loaded") + val outsideState = outsideState() + val dt = (now - (lastUpdate ?: now)).nanoseconds + world.update(outsideState, dt) + render(world) + lastUpdate = now + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt index 0f8ef2a..e763c9c 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/System.kt @@ -1,22 +1,23 @@ package coffee.liz.ecs import kotlin.reflect.KClass +import kotlin.time.Duration /** - * Systems contain the logic that operates on entities with specific components. + * System is a component that updates the world state. + * @param Outside is the state of the stuff outside the world (input, etc.). */ -interface System { +interface System { /** * Systems that must run before this system in the update loop. - * Used to construct a dependency DAG. */ - val dependencies: Set> + val dependencies: Set>> get() = emptySet() /** - * Update this system. Called once per frame. - * @param world The world containing entities and systems - * @param deltaTime Time elapsed since last update in seconds + * Integrate this system over [deltaTime]. + * @param world [World] + * @param deltaTime [Duration] since last update */ - fun update(world: World, deltaTime: Float) + fun update(world: World, state: Outside, deltaTime: Duration) } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt new file mode 100644 index 0000000..f512ca7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/TickedSystem.kt @@ -0,0 +1,17 @@ +package coffee.liz.ecs + +import kotlin.time.Duration + +abstract class TickedSystem( + protected val tickRate: Duration, + private var lastTick: Duration = Duration.ZERO +): System { + abstract fun update(world: World, state: Outside, ticks: Int) + + override fun update(world: World, state: Outside, deltaTime: Duration) { + val ticks = ((deltaTime - lastTick) / tickRate).toInt() + lastTick = if (ticks == 0) (lastTick + deltaTime).also { + update(world, state, ticks) + } else Duration.ZERO + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt new file mode 100644 index 0000000..0b771e4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/Vec.kt @@ -0,0 +1,33 @@ +package coffee.liz.ecs + +/** + * Rectangle in cartesian space. + */ +data class Rect( + val topLeft: Vec2, + val dimensions: Vec2 +) { + fun overlaps(other: Rect): Boolean { + val xOverlap = topLeft.x <= other.topLeft.x + other.dimensions.x && + topLeft.x + dimensions.x >= other.topLeft.x + val yOverlap = topLeft.y <= other.topLeft.y + other.dimensions.y && + topLeft.y + dimensions.y >= other.topLeft.y + return xOverlap && yOverlap + } +} + +/** + * Cartesian point. + */ +data class Vec2( + val x: Float, + val y: Float +) { + fun plus(other: Vec2): Vec2 = Vec2(x + other.x, y + other.y) + fun minus(other: Vec2): Vec2 = Vec2(x - other.x, y - other.y) + fun times(scalar: Float): Vec2 = Vec2(x * scalar, y * scalar) + fun div(scalar: Float): Vec2 = Vec2(x / scalar, y / scalar) + fun distanceTo(other: Vec2): Float = (this.minus(other)).length() + fun normalize(): Vec2 = this.div(length()) + fun length(): Float = kotlin.math.sqrt(x * x + y * y) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt index fd3b6df..452517f 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/World.kt @@ -1,42 +1,30 @@ package coffee.liz.ecs -import kotlin.jvm.JvmName import kotlin.reflect.KClass +import kotlin.time.Duration /** - * The World manages entities and systems. + * [World] is the state of the world. + * @param Outside is the state of the stuff outside the world (input, etc.). */ -interface World { +interface World { /** - * Create a new entity with a unique ID. + * Create unique [Entity] in the [World]. */ fun createEntity(): Entity /** - * Destroy an entity and remove it from all caches. + * Remove [entity] from [World]. */ - fun destroyEntity(entity: Entity) + fun removeEntity(entity: Entity) /** - * Get all entities that have all the specified component types. + * Get entities with a [Component] type. */ fun query(vararg componentTypes: KClass): Set /** - * Update all systems in dependency order. - * @param deltaTime Time elapsed since last update in seconds + * Integrate the [World]. */ - fun update(deltaTime: Float) + fun update(state: Outside, deltaTime: Duration) } - -// Convenience extension for queries -@JvmName("query1") -inline fun World.query(): Set = query(T::class) - -@JvmName("query2") -inline fun World.query(): Set = - query(T1::class, T2::class) - -@JvmName("query3") -inline fun World.query(): Set = - query(T1::class, T2::class, T3::class) diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt index 4289eec..cb3708f 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationComponents.kt @@ -1,85 +1,77 @@ package coffee.liz.ecs.animation import coffee.liz.ecs.Component +import coffee.liz.ecs.Rect import kotlin.jvm.JvmInline /** * Loop modes for animation playback. */ enum class LoopMode { - /** Play once and stop on last frame */ + /** Play once **/ ONCE, - /** Repeat from beginning */ + /** Repeat **/ LOOP, - /** Play forward, then backward, repeat */ + /** Play forward, then backward, repeat **/ PING_PONG } /** - * Type-safe wrapper for animation names. + * Name of an animation clip. */ @JvmInline value class AnimationName(val value: String) /** - * Type-safe wrapper for frame names. + * Name of a frame. */ @JvmInline value class FrameName(val value: String) -/** - * Represents a rectangle region in a sprite sheet. - */ -data class Rect( - val x: Int, - val y: Int, - val width: Int, - val height: Int -) /** - * Defines a single animation clip with frames and playback settings. + * Animation Clip details. */ data class AnimationClip( val frameNames: List, - val frameDuration: Float, // seconds per frame + val frameTicks: Int, val loopMode: LoopMode = LoopMode.LOOP ) /** * Component containing sprite sheet data - the image and frame definitions. - * This is immutable data that can be shared across entities. */ data class SpriteSheet( val imagePath: String, val frames: Map ) : Component +enum class AnimationDirection(val step: Int) { + /** Play in forward direction **/ + FORWARD(step = 1), + /** Play in reverse direction **/ + BACKWARD(step = -1); +} /** - * Component for animation playback state. - * Contains animation clips and current playback state. + * Current state of an animation. */ data class Animator( val clips: Map, var currentClip: AnimationName, var frameIndex: Int = 0, - var elapsed: Float = 0f, + var elapsedTicks: Int = 0, var playing: Boolean = true, - var direction: Int = 1 // 1 for forward, -1 for backward (used in PING_PONG) + var direction: AnimationDirection = AnimationDirection.FORWARD ) : Component { - /** * Play a specific animation clip by name. - * Resets playback state when switching clips. */ fun play(clipName: AnimationName, restart: Boolean = true) { - if (currentClip != clipName || restart) { - currentClip = clipName - frameIndex = 0 - elapsed = 0f - direction = 1 - playing = true - } + clipName.takeIf { currentClip != it || restart } + .run { + currentClip = clipName + reset() + } } /** @@ -103,12 +95,11 @@ data class Animator( val clip = clips[currentClip] ?: return null return clip.frameNames.getOrNull(frameIndex) } -} -/** - * Component for entity position in 2D space. - */ -data class Position( - var x: Float, - var y: Float -) : Component + private fun reset() { + frameIndex = 0 + elapsedTicks = 0 + direction = AnimationDirection.FORWARD + playing = true + } +} diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt index 7ae405e..7968250 100644 --- a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/animation/AnimationSystem.kt @@ -1,35 +1,27 @@ package coffee.liz.ecs.animation -import coffee.liz.ecs.System +import coffee.liz.ecs.TickedSystem import coffee.liz.ecs.World +import kotlin.time.Duration /** - * System that updates animation playback state for all entities with an Animator component. - * - * This system: - * - Updates elapsed time for playing animations - * - Advances frame indices based on frame duration - * - Handles loop modes (ONCE, LOOP, PING_PONG) - * - Stops animations when they complete (for ONCE mode) + * Updates animation playback state for all [Animator] components in [World]. */ -class AnimationSystem : System { - override fun update(world: World, deltaTime: Float) { - // Query all entities that have both Animator and SpriteSheet - world.query(Animator::class, SpriteSheet::class).forEach { entity -> - val animator = entity.get(Animator::class) ?: return@forEach - - // Skip if animation is not playing +class AnimationSystem(animationTickRate: Duration) : TickedSystem(animationTickRate) { + override fun update(world: World, ticks: Int) { + world.query(Animator::class).forEach { entity -> + val animator = entity.get(Animator::class) if (!animator.playing) return@forEach // Get the current animation clip val clip = animator.clips[animator.currentClip] ?: return@forEach // Accumulate elapsed time - animator.elapsed += deltaTime + animator.elapsedTicks += ticks // Advance frames while we have enough elapsed time - while (animator.elapsed >= clip.frameDuration) { - animator.elapsed -= clip.frameDuration + while (animator.elapsedTicks >= clip.frameTicks) { + animator.elapsedTicks -= clip.frameTicks advanceFrame(animator, clip) } } @@ -39,11 +31,10 @@ class AnimationSystem : System { * Advances the animation to the next frame based on loop mode. */ private fun advanceFrame(animator: Animator, clip: AnimationClip) { - animator.frameIndex += animator.direction + animator.frameIndex += animator.direction.step when (clip.loopMode) { LoopMode.ONCE -> { - // Play once and stop on last frame if (animator.frameIndex >= clip.frameNames.size) { animator.frameIndex = clip.frameNames.size - 1 animator.playing = false @@ -51,25 +42,21 @@ class AnimationSystem : System { } LoopMode.LOOP -> { - // Loop back to beginning if (animator.frameIndex >= clip.frameNames.size) { animator.frameIndex = 0 } else if (animator.frameIndex < 0) { - // Handle negative wrap (shouldn't happen in LOOP but be safe) animator.frameIndex = clip.frameNames.size - 1 } } LoopMode.PING_PONG -> { - // Bounce back and forth + // Go to (endFrame - 1) or (beginFrame + 1) ping ponging if (animator.frameIndex >= clip.frameNames.size) { - // Hit the end, reverse direction animator.frameIndex = (clip.frameNames.size - 2).coerceAtLeast(0) - animator.direction = -1 + animator.direction = AnimationDirection.BACKWARD } else if (animator.frameIndex < 0) { - // Hit the beginning, reverse direction animator.frameIndex = 1.coerceAtMost(clip.frameNames.size - 1) - animator.direction = 1 + animator.direction = AnimationDirection.FORWARD } } } diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt new file mode 100644 index 0000000..8c54bfe --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/Controlled.kt @@ -0,0 +1,7 @@ +package coffee.liz.ecs.input + +import coffee.liz.ecs.Component + +interface Controlled: Component { + fun handleInput(inputState: InputState) +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt new file mode 100644 index 0000000..5735665 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputProvider.kt @@ -0,0 +1,7 @@ +package coffee.liz.ecs.input + +data class InputState(val activeInputs: Set) + +interface InputProvider { + val inputState: InputState +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt new file mode 100644 index 0000000..6dc8256 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/input/InputSystem.kt @@ -0,0 +1,23 @@ +package coffee.liz.ecs.input + +import coffee.liz.ecs.System +import coffee.liz.ecs.TickedSystem +import coffee.liz.ecs.World +import kotlin.time.Duration + +class InputSystem( + pollRate: Duration +) : TickedSystem(pollRate) { + private val activeInputs = mutableMapOf() + + override fun update(world: World, state: Outside, ticks: Int) { + state.inputState.activeInputs.forEach { + activeInputs[it] = (activeInputs[it] ?: 0) + ticks + } + activeInputs.keys.retainAll(state.inputState.activeInputs) + + world.query(Controlled::class).forEach { + it.get(Controlled::class).handleInput(activeInputs.toMap()) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt new file mode 100644 index 0000000..bc34ac1 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/CollisionTick.kt @@ -0,0 +1,28 @@ +package coffee.liz.ecs.physics + +import coffee.liz.ecs.Rect +import coffee.liz.ecs.World + +internal class CollisionTick( + private val collisionResolver: CollisionResolver +) { + fun runTick(world: World) { + // Eh, fast enough for now. Don't need to do any fancy collision detection. There's always later to improve if + // it's that bad. + world.query(Collidable::class, Position::class).forEach { a -> + world.query(Collidable::class, Position::class).forEach { b -> + val aHitBoxes = a.get(Position::class).let { pos -> a.get(Collidable::class).hitboxes.map { + Rect(pos.vec2, it.dimensions) + }} + val bHitBoxes = b.get(Position::class).let { pos -> b.get(Collidable::class).hitboxes.map { + Rect(pos.vec2, it.dimensions) + }} + + val collisionDetected = aHitBoxes.any { a -> bHitBoxes.any { b -> a.overlaps(b) } } + if (collisionDetected) { + collisionResolver.resolveCollision(world, a, b) + } + } + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt new file mode 100644 index 0000000..ffae10b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/Components.kt @@ -0,0 +1,15 @@ +package coffee.liz.ecs.physics + +import coffee.liz.ecs.Component +import coffee.liz.ecs.Rect +import coffee.liz.ecs.Vec2 + +data class Position(val vec2: Vec2): Component +data class Velocity(val vec2: Vec2) : Component +data class Acceleration(val vec2: Vec2) : Component + +/** + * @param hitboxes a collection of hitboxes to check collisions against relative to [Rect.topLeft] as the top left of + * [Position]. + */ +data class Collidable(val hitboxes: Collection): Component diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt new file mode 100644 index 0000000..03af835 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/IntegrationTick.kt @@ -0,0 +1,27 @@ +package coffee.liz.ecs.physics + +import coffee.liz.ecs.World + +internal class IntegrationTick { + fun runTick(world: World) { + world.query(Velocity::class, Acceleration::class).forEach { entity -> + val velocity = entity.get(Velocity::class) + val acceleration = entity.get(Acceleration::class) + entity.add( + Velocity( + vec2 = velocity.vec2.plus(acceleration.vec2) + ) + ) + } + + world.query(Position::class, Velocity::class).forEach { entity -> + val position = entity.get(Position::class) + val velocity = entity.get(Acceleration::class) + entity.add( + Position( + vec2 = position.vec2.plus(velocity.vec2) + ) + ) + } + } +} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt new file mode 100644 index 0000000..703e614 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/coffee/liz/ecs/physics/PhysicsSystem.kt @@ -0,0 +1,26 @@ +package coffee.liz.ecs.physics + +import coffee.liz.ecs.Entity +import coffee.liz.ecs.TickedSystem +import coffee.liz.ecs.World +import kotlin.time.Duration + +internal fun interface CollisionResolver { + fun resolveCollision(world: World, a: Entity, b: Entity) +} + +abstract class PhysicsSystem( + physicsTickRate: Duration +): TickedSystem(physicsTickRate), CollisionResolver { + private val integrationTick = IntegrationTick() + private val collisionTick = CollisionTick(this) + + abstract override fun resolveCollision(world: World, a: Entity, b: Entity) + + override fun update(world: World, state: Outside, ticks: Int) { + for (tick in 1..ticks) { + collisionTick.runTick(world) + integrationTick.runTick(world) + } + } +} \ No newline at end of file -- cgit v1.2.3-70-g09d2