Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save samarthsubramanya/e851b65f85ec5e41bf9eb64dda81fa38 to your computer and use it in GitHub Desktop.

Select an option

Save samarthsubramanya/e851b65f85ec5e41bf9eb64dda81fa38 to your computer and use it in GitHub Desktop.
CrossyRoadsCompose.kt
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