Created
July 12, 2026 21:45
-
-
Save samarthsubramanya/e851b65f85ec5e41bf9eb64dda81fa38 to your computer and use it in GitHub Desktop.
CrossyRoadsCompose.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| package com.thingsenz.composeui | |
| import androidx.compose.animation.core.animateFloatAsState | |
| import androidx.compose.animation.core.spring | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.background | |
| import androidx.compose.foundation.border | |
| import androidx.compose.foundation.clickable | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.Row | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.WindowInsets | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.fillMaxWidth | |
| import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.safeDrawing | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.foundation.layout.width | |
| import androidx.compose.foundation.layout.windowInsetsPadding | |
| import androidx.compose.foundation.shape.CircleShape | |
| import androidx.compose.foundation.shape.RoundedCornerShape | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.runtime.LaunchedEffect | |
| import androidx.compose.runtime.getValue | |
| import androidx.compose.runtime.mutableStateOf | |
| import androidx.compose.runtime.remember | |
| import androidx.compose.runtime.setValue | |
| import androidx.compose.runtime.withFrameNanos | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.draw.clip | |
| import androidx.compose.ui.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.StrokeCap | |
| import androidx.compose.ui.graphics.drawscope.DrawScope | |
| import androidx.compose.ui.graphics.drawscope.rotate | |
| import androidx.compose.ui.text.font.FontWeight | |
| import androidx.compose.ui.unit.dp | |
| import androidx.compose.ui.unit.sp | |
| import kotlinx.coroutines.isActive | |
| import kotlin.math.roundToInt | |
| import kotlin.random.Random | |
| private const val COLS = 7 | |
| private const val VISIBLE_AHEAD = 20 | |
| private const val SAFE_START_ROWS = 2 | |
| private enum class LaneType { GRASS, ROAD, RIVER, TRACKS } | |
| private enum class Facing { UP, DOWN, LEFT, RIGHT } | |
| private data class Obstacle(val base: Float, val length: Int) | |
| private class Lane( | |
| val row: Int, | |
| val type: LaneType, | |
| val dir: Int, | |
| val speed: Float, | |
| val obstacles: List<Obstacle>, | |
| val treeCols: Set<Int>, | |
| ) { | |
| var offset: Float = 0f | |
| var warnStart: Float = 0f | |
| var trainStart: Float = 0f | |
| var trainEnd: Float = 0f | |
| var cyclePeriod: Float = 0f | |
| var trainLenCells: Float = 0f | |
| } | |
| private class GameEngine { | |
| var playerCol by mutableStateOf(COLS / 2) | |
| private set | |
| var playerRow by mutableStateOf(0) | |
| private set | |
| var score by mutableStateOf(0) | |
| private set | |
| var bestScore by mutableStateOf(0) | |
| private set | |
| var isGameOver by mutableStateOf(false) | |
| private set | |
| var hasStarted by mutableStateOf(false) | |
| private set | |
| var riverDrift by mutableStateOf(0f) | |
| private set | |
| var facing by mutableStateOf(Facing.UP) | |
| private set | |
| var frameTick by mutableStateOf(0L) | |
| private set | |
| private val lanes = ArrayList<Lane>() | |
| private var isOnRiver = false | |
| private val rng = Random(System.nanoTime()) | |
| init { | |
| resetInternal() | |
| } | |
| fun reset() { | |
| bestScore = maxOf(bestScore, score) | |
| resetInternal() | |
| } | |
| private fun resetInternal() { | |
| lanes.clear() | |
| playerCol = COLS / 2 | |
| playerRow = 0 | |
| score = 0 | |
| isGameOver = false | |
| hasStarted = false | |
| isOnRiver = false | |
| riverDrift = 0f | |
| ensureLanes(VISIBLE_AHEAD) | |
| } | |
| fun peekLane(row: Int): Lane { | |
| ensureLanes(row) | |
| return lanes[row] | |
| } | |
| private fun ensureLanes(uptoRow: Int) { | |
| while (lanes.size <= uptoRow) { | |
| val row = lanes.size | |
| val prev1 = lanes.getOrNull(row - 1)?.type | |
| val prev2 = lanes.getOrNull(row - 2)?.type | |
| lanes.add(generateLane(row, prev1, prev2)) | |
| } | |
| } | |
| private fun pickLaneType(prev1: LaneType?, prev2: LaneType?): LaneType { | |
| var type = when (rng.nextInt(100)) { | |
| in 0 until 32 -> LaneType.GRASS | |
| in 32 until 60 -> LaneType.ROAD | |
| in 60 until 82 -> LaneType.RIVER | |
| else -> LaneType.TRACKS | |
| } | |
| if (type != LaneType.GRASS && type == prev1 && type == prev2) type = LaneType.GRASS | |
| return type | |
| } | |
| private fun randFloat(): Float = rng.nextDouble().toFloat() | |
| private fun genObstacles(minLen: Int, maxLen: Int): List<Obstacle> { | |
| val count = rng.nextInt(1, 3) | |
| val result = ArrayList<Obstacle>(count) | |
| var cursor = randFloat() * COLS | |
| repeat(count) { | |
| val len = rng.nextInt(minLen, maxLen + 1) | |
| result.add(Obstacle(((cursor % COLS) + COLS) % COLS, len)) | |
| cursor += len + 1.6f + randFloat() * 1.4f | |
| } | |
| return result | |
| } | |
| private fun generateLane(row: Int, prev1: LaneType?, prev2: LaneType?): Lane { | |
| if (row < SAFE_START_ROWS) return Lane(row, LaneType.GRASS, 0, 0f, emptyList(), emptySet()) | |
| return when (val type = pickLaneType(prev1, prev2)) { | |
| LaneType.GRASS -> { | |
| val treeCols = mutableSetOf<Int>() | |
| repeat(rng.nextInt(0, 3)) { treeCols.add(rng.nextInt(0, COLS)) } | |
| Lane(row, type, 0, 0f, emptyList(), treeCols) | |
| } | |
| LaneType.ROAD -> { | |
| val dir = if (rng.nextBoolean()) 1 else -1 | |
| val speed = (1.1f + randFloat() * 1.6f + row * 0.006f).coerceAtMost(3.4f) | |
| Lane(row, type, dir, speed, genObstacles(1, 2), emptySet()) | |
| } | |
| LaneType.RIVER -> { | |
| val dir = if (rng.nextBoolean()) 1 else -1 | |
| val speed = 0.7f + randFloat() * 1.1f | |
| Lane(row, type, dir, speed, genObstacles(2, 3), emptySet()) | |
| } | |
| LaneType.TRACKS -> { | |
| val dir = if (rng.nextBoolean()) 1 else -1 | |
| val safeDur = 2.6f + randFloat() * 2.4f | |
| val warnDur = 1.0f + randFloat() * 0.6f | |
| val trainSweepDur = 0.55f + randFloat() * 0.25f | |
| Lane(row, type, dir, 0f, emptyList(), emptySet()).also { l -> | |
| l.warnStart = safeDur | |
| l.trainStart = safeDur + warnDur | |
| l.trainEnd = l.trainStart + trainSweepDur | |
| l.cyclePeriod = l.trainEnd | |
| l.trainLenCells = COLS + 2f | |
| } | |
| } | |
| } | |
| } | |
| private fun rel(col: Int, obstacle: Obstacle, laneOffset: Float): Float { | |
| val w = ((obstacle.base + laneOffset) % COLS + COLS) % COLS | |
| return ((col - w) % COLS + COLS) % COLS | |
| } | |
| private fun isOccupied(lane: Lane, col: Int): Boolean = | |
| lane.obstacles.any { rel(col, it, lane.offset) < it.length } | |
| fun trainSweepBase(lane: Lane): Float? { | |
| if (lane.cyclePeriod <= 0f) return null | |
| val cycleT = lane.offset % lane.cyclePeriod | |
| if (cycleT < lane.trainStart || cycleT >= lane.trainEnd) return null | |
| val progress = ((cycleT - lane.trainStart) / (lane.trainEnd - lane.trainStart)).coerceIn(0f, 1f) | |
| val len = lane.trainLenCells | |
| return if (lane.dir >= 0) -len + progress * (COLS + len) else COLS - progress * (COLS + len) | |
| } | |
| fun isTrainWarning(lane: Lane): Boolean { | |
| if (lane.cyclePeriod <= 0f) return false | |
| val cycleT = lane.offset % lane.cyclePeriod | |
| return cycleT >= lane.warnStart && cycleT < lane.trainStart | |
| } | |
| private fun isTrainActive(lane: Lane, col: Int): Boolean { | |
| val base = trainSweepBase(lane) ?: return false | |
| return col >= base && col < base + lane.trainLenCells | |
| } | |
| fun move(dCol: Int, dRow: Int) { | |
| if (isGameOver) return | |
| hasStarted = true | |
| facing = when { | |
| dRow > 0 -> Facing.UP | |
| dRow < 0 -> Facing.DOWN | |
| dCol > 0 -> Facing.RIGHT | |
| dCol < 0 -> Facing.LEFT | |
| else -> facing | |
| } | |
| if (isOnRiver) { | |
| playerCol = (playerCol + riverDrift.roundToInt()).coerceIn(0, COLS - 1) | |
| riverDrift = 0f | |
| isOnRiver = false | |
| } | |
| val newCol = (playerCol + dCol).coerceIn(0, COLS - 1) | |
| val newRow = (playerRow + dRow).coerceAtLeast(0) | |
| if (newCol == playerCol && newRow == playerRow) return | |
| ensureLanes(newRow + VISIBLE_AHEAD) | |
| val lane = lanes[newRow] | |
| if (lane.type == LaneType.GRASS && lane.treeCols.contains(newCol)) return | |
| playerCol = newCol | |
| playerRow = newRow | |
| if (newRow > score) score = newRow | |
| when (lane.type) { | |
| LaneType.ROAD -> if (isOccupied(lane, newCol)) gameOver() | |
| LaneType.RIVER -> { | |
| if (!isOccupied(lane, newCol)) { | |
| gameOver() | |
| } else { | |
| isOnRiver = true | |
| riverDrift = 0f | |
| } | |
| } | |
| LaneType.TRACKS -> if (isTrainActive(lane, newCol)) gameOver() | |
| LaneType.GRASS -> {} | |
| } | |
| } | |
| fun tick(dt: Float) { | |
| if (isGameOver) return | |
| ensureLanes(playerRow + VISIBLE_AHEAD) | |
| val lo = (playerRow - 3).coerceAtLeast(0) | |
| val hi = playerRow + VISIBLE_AHEAD | |
| for (r in lo..hi) { | |
| val lane = lanes[r] | |
| when (lane.type) { | |
| LaneType.ROAD, LaneType.RIVER -> lane.offset += lane.dir * lane.speed * dt | |
| LaneType.TRACKS -> lane.offset += dt | |
| LaneType.GRASS -> {} | |
| } | |
| } | |
| val lane = lanes[playerRow] | |
| when (lane.type) { | |
| LaneType.ROAD -> if (isOccupied(lane, playerCol)) gameOver() | |
| LaneType.RIVER -> if (isOnRiver) { | |
| riverDrift += lane.dir * lane.speed * dt | |
| val effective = playerCol + riverDrift | |
| if (effective < -0.5f || effective > COLS - 0.5f) gameOver() | |
| } | |
| LaneType.TRACKS -> if (isTrainActive(lane, playerCol)) gameOver() | |
| LaneType.GRASS -> {} | |
| } | |
| frameTick++ | |
| } | |
| private fun gameOver() { | |
| isGameOver = true | |
| bestScore = maxOf(bestScore, score) | |
| } | |
| } | |
| @Composable | |
| fun CrossyRoadGame(modifier: Modifier = Modifier) { | |
| val engine = remember { GameEngine() } | |
| Column( | |
| modifier = modifier | |
| .fillMaxSize() | |
| .background(Color(0xFFF2F2F5)) | |
| .windowInsetsPadding(WindowInsets.safeDrawing) | |
| .padding(20.dp), | |
| ) { | |
| Row( | |
| modifier = Modifier.fillMaxWidth(), | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| Text( | |
| text = "CROSSY", | |
| color = Color(0xFF2B2B31), | |
| fontSize = 20.sp, | |
| fontWeight = FontWeight.Black, | |
| ) | |
| Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| repeat(3) { | |
| Box( | |
| Modifier | |
| .size(6.dp) | |
| .clip(CircleShape) | |
| .background(Color(0xFFCFCFD6)), | |
| ) | |
| } | |
| } | |
| } | |
| Spacer(Modifier.height(16.dp)) | |
| ScreenBezel(engine, modifier = Modifier.weight(1f).fillMaxWidth()) | |
| Spacer(Modifier.height(24.dp)) | |
| Controls(engine, modifier = Modifier.fillMaxWidth()) | |
| } | |
| } | |
| @Composable | |
| private fun ScreenBezel(engine: GameEngine, modifier: Modifier = Modifier) { | |
| Box( | |
| modifier = modifier | |
| .clip(RoundedCornerShape(24.dp)) | |
| .background(Color(0xFF232228)) | |
| .padding(14.dp), | |
| ) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .clip(RoundedCornerShape(10.dp)) | |
| .background(Color(0xFF7CC576)) | |
| .border(2.dp, Color(0xFF17171B), RoundedCornerShape(10.dp)), | |
| ) { | |
| GameCanvas(engine, modifier = Modifier.fillMaxSize()) | |
| Text( | |
| text = "SCORE ${engine.score}", | |
| color = Color.White, | |
| fontSize = 14.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier | |
| .align(Alignment.TopStart) | |
| .padding(8.dp) | |
| .clip(RoundedCornerShape(6.dp)) | |
| .background(Color(0x66000000)) | |
| .padding(horizontal = 8.dp, vertical = 4.dp), | |
| ) | |
| if (!engine.hasStarted && !engine.isGameOver) { | |
| Text( | |
| text = "TAP ▲ TO HOP", | |
| color = Color.White, | |
| fontSize = 13.sp, | |
| fontWeight = FontWeight.Bold, | |
| modifier = Modifier | |
| .align(Alignment.BottomCenter) | |
| .padding(bottom = 10.dp) | |
| .clip(RoundedCornerShape(6.dp)) | |
| .background(Color(0x66000000)) | |
| .padding(horizontal = 10.dp, vertical = 4.dp), | |
| ) | |
| } | |
| if (engine.isGameOver) { | |
| Box( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .background(Color(0xAA000000)), | |
| contentAlignment = Alignment.Center, | |
| ) { | |
| Column(horizontalAlignment = Alignment.CenterHorizontally) { | |
| Text("GAME OVER", color = Color.White, fontSize = 22.sp, fontWeight = FontWeight.Black) | |
| Spacer(Modifier.height(6.dp)) | |
| Text("Score ${engine.score} · Best ${engine.bestScore}", color = Color(0xFFDDDDDD), fontSize = 14.sp) | |
| Spacer(Modifier.height(14.dp)) | |
| Box( | |
| modifier = Modifier | |
| .clip(RoundedCornerShape(10.dp)) | |
| .background(Color(0xFFE8533F)) | |
| .clickable { engine.reset() } | |
| .padding(horizontal = 20.dp, vertical = 10.dp), | |
| ) { | |
| Text("RESTART", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun GameCanvas(engine: GameEngine, modifier: Modifier = Modifier) { | |
| LaunchedEffect(engine) { | |
| var lastNanos = 0L | |
| while (isActive) { | |
| val nanos = withFrameNanos { it } | |
| if (lastNanos != 0L) { | |
| val dt = ((nanos - lastNanos) / 1_000_000_000f).coerceAtMost(0.05f) | |
| engine.tick(dt) | |
| } | |
| lastNanos = nanos | |
| } | |
| } | |
| val cameraRow by animateFloatAsState( | |
| targetValue = engine.playerRow.toFloat(), | |
| animationSpec = spring(dampingRatio = 0.62f, stiffness = 260f), | |
| label = "cameraRow", | |
| ) | |
| val animCol by animateFloatAsState( | |
| targetValue = engine.playerCol.toFloat(), | |
| animationSpec = spring(dampingRatio = 0.62f, stiffness = 320f), | |
| label = "playerCol", | |
| ) | |
| val hopBounce by animateFloatAsState( | |
| targetValue = engine.playerRow.toFloat(), | |
| animationSpec = spring(dampingRatio = 0.35f, stiffness = 380f), | |
| label = "hop", | |
| ) | |
| Canvas(modifier = modifier) { | |
| engine.frameTick | |
| val cellPx = size.width / COLS | |
| val anchorY = size.height * 0.68f | |
| val rowsOnScreen = (size.height / cellPx).roundToInt() + 4 | |
| val startRow = (kotlin.math.floor(cameraRow).toInt() - 3).coerceAtLeast(0) | |
| val endRow = startRow + rowsOnScreen + 4 | |
| drawRect(color = Color(0xFF7CC576), size = size) | |
| for (r in startRow..endRow) { | |
| val lane = engine.peekLane(r) | |
| val centerY = anchorY - (r - cameraRow) * cellPx | |
| val top = centerY - cellPx / 2f | |
| if (top > size.height || top + cellPx < 0f) continue | |
| drawLane(engine, lane, r, top, cellPx, size.width) | |
| } | |
| val hopLift = (kotlin.math.abs(engine.playerRow - hopBounce)).coerceIn(0f, 1f) * cellPx * 0.35f | |
| val playerCenterX = (animCol + 0.5f + engine.riverDrift) * cellPx | |
| val playerCenterY = anchorY - (engine.playerRow - cameraRow) * cellPx - hopLift | |
| drawPlayer(Offset(playerCenterX, playerCenterY), cellPx, engine.facing) | |
| } | |
| } | |
| private fun DrawScope.drawLane( | |
| engine: GameEngine, | |
| lane: Lane, | |
| row: Int, | |
| top: Float, | |
| cellPx: Float, | |
| widthPx: Float, | |
| ) { | |
| val baseColor = when (lane.type) { | |
| LaneType.GRASS -> if (row % 2 == 0) Color(0xFF7CC576) else Color(0xFF88CD82) | |
| LaneType.ROAD -> Color(0xFF454550) | |
| LaneType.RIVER -> if (row % 2 == 0) Color(0xFF4FA8E0) else Color(0xFF57AEE6) | |
| LaneType.TRACKS -> Color(0xFF8A7A64) | |
| } | |
| drawRect(color = baseColor, topLeft = Offset(0f, top), size = Size(widthPx, cellPx)) | |
| when (lane.type) { | |
| LaneType.ROAD -> { | |
| val dashW = cellPx * 0.35f | |
| var dx = 0f | |
| while (dx < widthPx) { | |
| drawLine( | |
| color = Color(0x55FFFFFF), | |
| start = Offset(dx, top + cellPx / 2f), | |
| end = Offset((dx + dashW).coerceAtMost(widthPx), top + cellPx / 2f), | |
| strokeWidth = 3f, | |
| cap = StrokeCap.Round, | |
| ) | |
| dx += dashW * 2.2f | |
| } | |
| for (o in lane.obstacles) drawCarWrapped(o, lane.offset, lane.dir, top, cellPx, widthPx) | |
| } | |
| LaneType.RIVER -> { | |
| drawLine( | |
| color = Color(0x33FFFFFF), | |
| start = Offset(0f, top + cellPx * 0.3f), | |
| end = Offset(widthPx, top + cellPx * 0.3f), | |
| strokeWidth = 2f, | |
| ) | |
| for (o in lane.obstacles) drawLogWrapped(o, lane.offset, top, cellPx, widthPx) | |
| } | |
| LaneType.TRACKS -> drawTracks(engine, lane, top, cellPx, widthPx) | |
| LaneType.GRASS -> { | |
| for (c in lane.treeCols) drawTree(c, top, cellPx) | |
| } | |
| } | |
| } | |
| private fun DrawScope.drawTracks(engine: GameEngine, lane: Lane, top: Float, cellPx: Float, widthPx: Float) { | |
| var tx = cellPx * 0.15f | |
| while (tx < widthPx) { | |
| drawRect( | |
| color = Color(0xFF5B4632), | |
| topLeft = Offset(tx, top + cellPx * 0.14f), | |
| size = Size(cellPx * 0.26f, cellPx * 0.72f), | |
| ) | |
| tx += cellPx * 0.85f | |
| } | |
| drawLine(Color(0xFFCFCFD6), Offset(0f, top + cellPx * 0.32f), Offset(widthPx, top + cellPx * 0.32f), strokeWidth = 4f) | |
| drawLine(Color(0xFFCFCFD6), Offset(0f, top + cellPx * 0.68f), Offset(widthPx, top + cellPx * 0.68f), strokeWidth = 4f) | |
| if (engine.isTrainWarning(lane)) { | |
| val blink = ((lane.offset * 6f).toInt() % 2 == 0) | |
| if (blink) { | |
| drawCircle(Color(0xFFE8533F), radius = cellPx * 0.12f, center = Offset(cellPx * 0.28f, top + cellPx * 0.5f)) | |
| drawCircle(Color(0xFFE8533F), radius = cellPx * 0.12f, center = Offset(widthPx - cellPx * 0.28f, top + cellPx * 0.5f)) | |
| } | |
| } | |
| engine.trainSweepBase(lane)?.let { base -> drawTrain(base, lane.trainLenCells, top, cellPx) } | |
| } | |
| private fun DrawScope.drawTrain(base: Float, lengthCells: Float, top: Float, cellPx: Float) { | |
| val x = base * cellPx | |
| val pad = cellPx * 0.06f | |
| drawRoundRect( | |
| color = Color(0xFF2B2B31), | |
| topLeft = Offset(x + pad, top + pad), | |
| size = Size(lengthCells * cellPx - pad * 2, cellPx - pad * 2), | |
| cornerRadius = CornerRadius(cellPx * 0.18f, cellPx * 0.18f), | |
| ) | |
| var wx = x + cellPx * 0.35f | |
| val winW = cellPx * 0.5f | |
| val winRight = x + lengthCells * cellPx - cellPx * 0.35f | |
| while (wx < winRight) { | |
| drawRoundRect( | |
| color = Color(0xCCFFE79A), | |
| topLeft = Offset(wx, top + cellPx * 0.28f), | |
| size = Size(winW.coerceAtMost(winRight - wx), cellPx * 0.34f), | |
| cornerRadius = CornerRadius(cellPx * 0.08f, cellPx * 0.08f), | |
| ) | |
| wx += cellPx * 0.9f | |
| } | |
| drawRect( | |
| color = Color(0xFFE8533F), | |
| topLeft = Offset(x + pad, top + cellPx * 0.78f), | |
| size = Size(lengthCells * cellPx - pad * 2, cellPx * 0.1f), | |
| ) | |
| } | |
| private fun DrawScope.drawCarWrapped( | |
| obstacle: Obstacle, | |
| laneOffset: Float, | |
| dir: Int, | |
| top: Float, | |
| cellPx: Float, | |
| widthPx: Float, | |
| ) { | |
| val w = ((obstacle.base + laneOffset) % COLS + COLS) % COLS | |
| for (copy in -1..1) { | |
| val startCol = w + copy * COLS | |
| val x = startCol * cellPx | |
| if (x + obstacle.length * cellPx < 0f || x > widthPx) continue | |
| val bodyColor = carColorFor(obstacle) | |
| val pad = cellPx * 0.12f | |
| drawRoundRect( | |
| color = bodyColor, | |
| topLeft = Offset(x + pad, top + pad), | |
| size = Size(obstacle.length * cellPx - pad * 2, cellPx - pad * 2), | |
| cornerRadius = CornerRadius(cellPx * 0.25f, cellPx * 0.25f), | |
| ) | |
| val windowW = (obstacle.length * cellPx - pad * 2) * 0.4f | |
| val windowX = if (dir > 0) x + obstacle.length * cellPx - pad - windowW - pad else x + pad * 2 | |
| drawRoundRect( | |
| color = Color(0xCCDCEEFF), | |
| topLeft = Offset(windowX, top + pad * 1.6f), | |
| size = Size(windowW, cellPx - pad * 3.2f), | |
| cornerRadius = CornerRadius(cellPx * 0.15f, cellPx * 0.15f), | |
| ) | |
| } | |
| } | |
| private fun carColorFor(obstacle: Obstacle): Color { | |
| val palette = listOf( | |
| Color(0xFFE8533F), Color(0xFFF2A93C), Color(0xFF3E7CE0), | |
| Color(0xFF8E5CD9), Color(0xFF2FB380), | |
| ) | |
| val idx = (obstacle.base * 97).toInt().let { if (it < 0) -it else it } % palette.size | |
| return palette[idx] | |
| } | |
| private fun DrawScope.drawLogWrapped( | |
| obstacle: Obstacle, | |
| laneOffset: Float, | |
| top: Float, | |
| cellPx: Float, | |
| widthPx: Float, | |
| ) { | |
| val w = ((obstacle.base + laneOffset) % COLS + COLS) % COLS | |
| for (copy in -1..1) { | |
| val startCol = w + copy * COLS | |
| val x = startCol * cellPx | |
| if (x + obstacle.length * cellPx < 0f || x > widthPx) continue | |
| val pad = cellPx * 0.1f | |
| drawRoundRect( | |
| color = Color(0xFF9C6B3E), | |
| topLeft = Offset(x + pad, top + pad * 1.5f), | |
| size = Size(obstacle.length * cellPx - pad * 2, cellPx - pad * 3f), | |
| cornerRadius = CornerRadius(cellPx * 0.3f, cellPx * 0.3f), | |
| ) | |
| var lx = x + pad * 2 | |
| while (lx < x + obstacle.length * cellPx - pad * 2) { | |
| drawLine( | |
| color = Color(0x33452A15), | |
| start = Offset(lx, top + pad * 2), | |
| end = Offset(lx, top + cellPx - pad * 2), | |
| strokeWidth = 2f, | |
| ) | |
| lx += cellPx * 0.5f | |
| } | |
| } | |
| } | |
| private fun DrawScope.drawTree(col: Int, top: Float, cellPx: Float) { | |
| val cx = (col + 0.5f) * cellPx | |
| drawRect( | |
| color = Color(0xFF6B4A2A), | |
| topLeft = Offset(cx - cellPx * 0.06f, top + cellPx * 0.55f), | |
| size = Size(cellPx * 0.12f, cellPx * 0.4f), | |
| ) | |
| drawCircle( | |
| color = Color(0xFF2F8F4E), | |
| radius = cellPx * 0.34f, | |
| center = Offset(cx, top + cellPx * 0.42f), | |
| ) | |
| drawCircle( | |
| color = Color(0xFF3EA860), | |
| radius = cellPx * 0.22f, | |
| center = Offset(cx - cellPx * 0.12f, top + cellPx * 0.32f), | |
| ) | |
| } | |
| private fun DrawScope.drawPlayer(center: Offset, cellPx: Float, facing: Facing) { | |
| val r = cellPx * 0.32f | |
| drawCircle(color = Color(0x33000000), radius = r * 0.9f, center = Offset(center.x, center.y + r * 0.8f)) | |
| val degrees = when (facing) { | |
| Facing.DOWN -> 0f | |
| Facing.RIGHT -> 270f | |
| Facing.UP -> 180f | |
| Facing.LEFT -> 90f | |
| } | |
| rotate(degrees = degrees, pivot = center) { | |
| // body | |
| drawCircle(color = Color.White, radius = r, center = center) | |
| // comb | |
| drawCircle(color = Color(0xFFE8533F), radius = r * 0.22f, center = Offset(center.x, center.y - r * 0.95f)) | |
| // beak | |
| drawCircle(color = Color(0xFFF2A93C), radius = r * 0.24f, center = Offset(center.x, center.y + r * 0.05f)) | |
| // eyes | |
| drawCircle(color = Color(0xFF232228), radius = r * 0.12f, center = Offset(center.x - r * 0.32f, center.y - r * 0.2f)) | |
| drawCircle(color = Color(0xFF232228), radius = r * 0.12f, center = Offset(center.x + r * 0.32f, center.y - r * 0.2f)) | |
| } | |
| } | |
| @Composable | |
| private fun Controls(engine: GameEngine, modifier: Modifier = Modifier) { | |
| Row( | |
| modifier = modifier, | |
| horizontalArrangement = Arrangement.SpaceBetween, | |
| verticalAlignment = Alignment.CenterVertically, | |
| ) { | |
| DPad(onMove = { dc, dr -> engine.move(dc, dr) }) | |
| Box( | |
| modifier = Modifier | |
| .size(44.dp) | |
| .clip(CircleShape) | |
| .background(Color(0xFF3A3A42)) | |
| .clickable { engine.reset() }, | |
| contentAlignment = Alignment.Center, | |
| ) { | |
| Text("⟳", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Bold) | |
| } | |
| } | |
| } | |
| @Composable | |
| private fun DPad(onMove: (Int, Int) -> Unit) { | |
| val btn = 56.dp | |
| Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) { | |
| DPadButton("▲", btn) { onMove(0, 1) } | |
| Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { | |
| DPadButton("◀", btn) { onMove(-1, 0) } | |
| Spacer(Modifier.width(btn)) | |
| DPadButton("▶", btn) { onMove(1, 0) } | |
| } | |
| DPadButton("▼", btn) { onMove(0, -1) } | |
| } | |
| } | |
| @Composable | |
| private fun DPadButton(symbol: String, size: androidx.compose.ui.unit.Dp, onClick: () -> Unit) { | |
| Box( | |
| modifier = Modifier | |
| .size(size) | |
| .clip(RoundedCornerShape(14.dp)) | |
| .background(Color(0xFF3A3A42)) | |
| .border(1.dp, Color(0xFF17171B), RoundedCornerShape(14.dp)) | |
| .clickable { onClick() }, | |
| contentAlignment = Alignment.Center, | |
| ) { | |
| Text(symbol, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment