Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

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

Select an option

Save samarthsubramanya/b0fd58cb5715bc53ae240421b9dbc7e1 to your computer and use it in GitHub Desktop.
TankDestroyer.kt
package com.thingsenz.composeui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
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.Path
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.isActive
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.min
import kotlin.random.Random
private const val GRID = 13
private const val TANK_MARGIN = 0.08f
private const val PLAYER_SPEED = 3.0f
private const val ENEMY_SPEED_BASE = 1.9f
private const val ENEMY_SPEED_MAX = 3.0f
private const val BULLET_SPEED = 7.0f
private const val PLAYER_FIRE_COOLDOWN = 0.45f
private const val EXPLOSION_LIFE = 0.35f
private const val RESPAWN_DELAY = 1.4f
private const val INVULN_DURATION = 1.6f
private enum class Direction(val dx: Float, val dy: Float) {
UP(0f, -1f), DOWN(0f, 1f), LEFT(-1f, 0f), RIGHT(1f, 0f)
}
private enum class CellType { EMPTY, BRICK, STEEL, BASE }
private class Tank(var x: Float, var y: Float, var dir: Direction, val isPlayer: Boolean, var speed: Float) {
var alive = true
var fireCooldown = 0f
var aiTimer = 0f
var invuln = 0f
var bulletAlive = false
}
private class Bullet(var x: Float, var y: Float, val dir: Direction, val owner: Tank)
private class Explosion(val x: Float, val y: Float, var age: Float = 0f)
/** Plain state-holder driving the Battle-City-style game loop and grid logic. */
private class TankEngine {
var score by mutableStateOf(0)
private set
var wave by mutableStateOf(1)
private set
var lives by mutableStateOf(3)
private set
var isGameOver by mutableStateOf(false)
private set
var gameOverReason by mutableStateOf("")
private set
var waveBanner by mutableStateOf<String?>(null)
private set
var frameTick by mutableStateOf(0L)
private set
var fireHeld = false
private lateinit var grid: Array<Array<CellType>>
private var player = Tank(1f, 1f, Direction.UP, true, PLAYER_SPEED)
private val enemies = ArrayList<Tank>()
private val bullets = ArrayList<Bullet>()
private val explosions = ArrayList<Explosion>()
private val held = BooleanArray(4)
private var enemiesRemainingToSpawn = 0
private var spawnTimer = 0f
private var waveBannerTimer = 0f
private var respawnTimer = 0f
private val rng = Random(System.nanoTime())
private val spawnPoints = listOf(1 to 1, GRID / 2 to 1, (GRID - 2) to 1)
private val playerSpawnCol = 2
private val playerSpawnRow = GRID - 2
init {
startNewGame()
}
fun setHeld(dir: Direction, isHeld: Boolean) {
held[dir.ordinal] = isHeld
}
fun reset() = startNewGame()
fun cellAt(row: Int, col: Int): CellType = grid[row][col]
fun bulletsView(): List<Bullet> = bullets
fun explosionsView(): List<Explosion> = explosions
fun enemiesView(): List<Tank> = enemies
fun playerView(): Tank = player
private fun randFloat(): Float = rng.nextDouble().toFloat()
private fun startNewGame() {
score = 0
wave = 1
lives = 3
isGameOver = false
gameOverReason = ""
respawnTimer = 0f
bullets.clear()
explosions.clear()
enemies.clear()
generateMap()
spawnPlayer()
startWave()
}
private fun startWave() {
enemiesRemainingToSpawn = 3 + wave * 2
spawnTimer = 0.5f
waveBanner = "WAVE $wave"
waveBannerTimer = 1.6f
}
private fun generateMap() {
grid = Array(GRID) { r ->
Array(GRID) { c ->
if (r == 0 || r == GRID - 1 || c == 0 || c == GRID - 1) CellType.STEEL else CellType.EMPTY
}
}
val bc = GRID / 2
for (dc in -1..1) {
grid[GRID - 2][bc + dc] = if (dc == 0) CellType.BASE else CellType.BRICK
grid[GRID - 3][bc + dc] = CellType.BRICK
}
for (r in 1 until GRID - 1) {
for (c in 1 until GRID - 1) {
if (grid[r][c] != CellType.EMPTY) continue
if (isNearSpawn(c, r) || isNearPlayerSpawn(c, r)) continue
val roll = randFloat()
if (roll < 0.16f) grid[r][c] = CellType.BRICK else if (roll < 0.20f) grid[r][c] = CellType.STEEL
}
}
clearArea(playerSpawnCol, playerSpawnRow)
for (sp in spawnPoints) clearArea(sp.first, sp.second)
}
private fun clearArea(col: Int, row: Int) {
for (dr in -1..1) {
for (dc in -1..1) {
val r = row + dr
val c = col + dc
if (r in 1 until GRID - 1 && c in 1 until GRID - 1) grid[r][c] = CellType.EMPTY
}
}
}
private fun isNearSpawn(col: Int, row: Int) =
spawnPoints.any { abs(it.first - col) <= 1 && abs(it.second - row) <= 1 }
private fun isNearPlayerSpawn(col: Int, row: Int) =
abs(playerSpawnCol - col) <= 1 && abs(playerSpawnRow - row) <= 1
private fun spawnPlayer() {
player = Tank(playerSpawnCol.toFloat(), playerSpawnRow.toFloat(), Direction.UP, true, PLAYER_SPEED)
player.invuln = INVULN_DURATION
}
fun tick(dt: Float) {
if (isGameOver) return
if (waveBannerTimer > 0f) {
waveBannerTimer -= dt
if (waveBannerTimer <= 0f) waveBanner = null
}
if (respawnTimer > 0f) {
respawnTimer -= dt
if (respawnTimer <= 0f) spawnPlayer()
} else if (player.alive) {
updatePlayer(dt)
}
updateSpawning(dt)
for (e in enemies) if (e.alive) updateEnemyAi(e, dt)
updateBullets(dt)
val expIt = explosions.iterator()
while (expIt.hasNext()) {
val e = expIt.next()
e.age += dt
if (e.age > EXPLOSION_LIFE) expIt.remove()
}
enemies.removeAll { !it.alive }
if (enemiesRemainingToSpawn <= 0 && enemies.isEmpty() && respawnTimer <= 0f && waveBannerTimer <= 0f) {
wave++
generateMap()
startWave()
}
frameTick++
}
private fun updatePlayer(dt: Float) {
if (player.invuln > 0f) player.invuln -= dt
player.fireCooldown -= dt
val dir = currentHeldDirection()
if (dir != null) {
player.dir = dir
tryMove(player, dir, dt)
}
if (fireHeld && !player.bulletAlive && player.fireCooldown <= 0f) {
fireBullet(player)
player.fireCooldown = PLAYER_FIRE_COOLDOWN
}
}
private fun currentHeldDirection(): Direction? {
for (d in Direction.values()) if (held[d.ordinal]) return d
return null
}
private fun tryMove(tank: Tank, dir: Direction, dt: Float): Boolean {
val nx = tank.x + dir.dx * tank.speed * dt
val ny = tank.y + dir.dy * tank.speed * dt
if (isBlocked(nx, ny, tank)) return false
tank.x = nx
tank.y = ny
return true
}
private fun isBlocked(x: Float, y: Float, self: Tank): Boolean {
val m = TANK_MARGIN
val corners = arrayOf(x + m to y + m, x + 1 - m to y + m, x + m to y + 1 - m, x + 1 - m to y + 1 - m)
for ((cx, cy) in corners) {
if (isSolidCell(floor(cx).toInt(), floor(cy).toInt())) return true
}
val others = if (self.isPlayer) {
enemies.filter { it.alive }
} else {
enemies.filter { it.alive && it !== self } + (if (player.alive) listOf(player) else emptyList())
}
return others.any { aabbOverlap(x, y, it.x, it.y) }
}
private fun aabbOverlap(x1: Float, y1: Float, x2: Float, y2: Float): Boolean {
val m = TANK_MARGIN
return x1 + 1 - m > x2 + m && x2 + 1 - m > x1 + m && y1 + 1 - m > y2 + m && y2 + 1 - m > y1 + m
}
private fun isSolidCell(col: Int, row: Int): Boolean {
if (col < 0 || col >= GRID || row < 0 || row >= GRID) return true
val t = grid[row][col]
return t == CellType.BRICK || t == CellType.STEEL || t == CellType.BASE
}
private fun fireBullet(tank: Tank) {
val bx = tank.x + 0.5f + tank.dir.dx * 0.5f
val by = tank.y + 0.5f + tank.dir.dy * 0.5f
bullets.add(Bullet(bx, by, tank.dir, tank))
tank.bulletAlive = true
}
private fun updateBullets(dt: Float) {
val it = bullets.iterator()
while (it.hasNext()) {
val b = it.next()
b.x += b.dir.dx * BULLET_SPEED * dt
b.y += b.dir.dy * BULLET_SPEED * dt
val col = floor(b.x).toInt()
val row = floor(b.y).toInt()
if (col !in 0 until GRID || row !in 0 until GRID) {
b.owner.bulletAlive = false
it.remove()
continue
}
val cell = grid[row][col]
var consumed = false
if (cell == CellType.BRICK) {
grid[row][col] = CellType.EMPTY
explosions.add(Explosion(col + 0.5f, row + 0.5f))
consumed = true
} else if (cell == CellType.STEEL) {
explosions.add(Explosion(col + 0.5f, row + 0.5f))
consumed = true
} else if (cell == CellType.BASE) {
explosions.add(Explosion(col + 0.5f, row + 0.5f))
if (!b.owner.isPlayer) gameOver("BASE DESTROYED")
consumed = true
}
if (!consumed) {
if (b.owner.isPlayer) {
val hitEnemy = enemies.firstOrNull { it.alive && pointInTank(b.x, b.y, it) }
if (hitEnemy != null) {
hitEnemy.alive = false
explosions.add(Explosion(hitEnemy.x + 0.5f, hitEnemy.y + 0.5f))
score += 10
consumed = true
}
} else if (player.alive && player.invuln <= 0f && pointInTank(b.x, b.y, player)) {
explosions.add(Explosion(player.x + 0.5f, player.y + 0.5f))
killPlayer()
consumed = true
}
}
if (consumed) {
b.owner.bulletAlive = false
it.remove()
}
}
}
private fun pointInTank(x: Float, y: Float, tank: Tank): Boolean {
val m = TANK_MARGIN
return x >= tank.x + m && x <= tank.x + 1 - m && y >= tank.y + m && y <= tank.y + 1 - m
}
private fun killPlayer() {
player.alive = false
if (lives > 1) {
lives--
respawnTimer = RESPAWN_DELAY
} else {
lives = 0
gameOver("TANK DESTROYED")
}
}
private fun gameOver(reason: String) {
isGameOver = true
gameOverReason = reason
}
private fun updateSpawning(dt: Float) {
if (enemiesRemainingToSpawn <= 0) return
val maxConcurrent = (3 + wave / 3).coerceAtMost(5)
if (enemies.size >= maxConcurrent) return
spawnTimer -= dt
if (spawnTimer <= 0f) {
val sp = spawnPoints[rng.nextInt(spawnPoints.size)]
val fx = sp.first.toFloat()
val fy = sp.second.toFloat()
if (!isBlockedSpawn(fx, fy)) {
val speed = (ENEMY_SPEED_BASE + (wave - 1) * 0.1f).coerceAtMost(ENEMY_SPEED_MAX)
enemies.add(Tank(fx, fy, Direction.DOWN, false, speed))
enemiesRemainingToSpawn--
}
spawnTimer = 1.0f + randFloat() * 1.2f
}
}
private fun isBlockedSpawn(x: Float, y: Float): Boolean {
if (player.alive && aabbOverlap(x, y, player.x, player.y)) return true
return enemies.any { it.alive && aabbOverlap(x, y, it.x, it.y) }
}
private fun updateEnemyAi(e: Tank, dt: Float) {
e.aiTimer -= dt
e.fireCooldown -= dt
if (e.aiTimer <= 0f) {
e.dir = pickEnemyDirection(e)
e.aiTimer = 0.7f + randFloat() * 1.3f
}
if (!tryMove(e, e.dir, dt)) {
e.dir = pickEnemyDirection(e)
e.aiTimer = 0.5f + randFloat() * 1f
}
if (e.fireCooldown <= 0f) {
if (!e.bulletAlive) fireBullet(e)
e.fireCooldown = 1.0f + randFloat() * 1.8f
}
}
private fun pickEnemyDirection(e: Tank): Direction {
if (rng.nextInt(100) < 55) {
val dx = player.x - e.x
val dy = player.y - e.y
return if (abs(dx) > abs(dy)) {
if (dx > 0) Direction.RIGHT else Direction.LEFT
} else {
if (dy > 0) Direction.DOWN else Direction.UP
}
}
return Direction.values()[rng.nextInt(4)]
}
}
/** Full-screen white handheld arcade console that renders the Battle-City-style tank game. */
@Composable
fun TankDestroyerGame(modifier: Modifier = Modifier, onExit: (() -> Unit)? = null) {
val engine = remember { TankEngine() }
Column(
modifier = modifier
.fillMaxSize()
.background(Color(0xFFF2F2F5))
.windowInsetsPadding(WindowInsets.safeDrawing)
.padding(20.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
if (onExit != null) {
Text(
text = "◀ MENU",
color = Color(0xFF2B2B31),
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.clickable { onExit() },
)
} else {
Spacer(Modifier.width(1.dp))
}
Text(
text = "TANKS",
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))
TankScreenBezel(engine, modifier = Modifier.weight(1f).fillMaxWidth())
Spacer(Modifier.height(24.dp))
TankControls(engine, modifier = Modifier.fillMaxWidth())
}
}
@Composable
private fun TankScreenBezel(engine: TankEngine, 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(0xFF2E2A22))
.border(2.dp, Color(0xFF17171B), RoundedCornerShape(10.dp)),
) {
TankCanvas(engine, modifier = Modifier.fillMaxSize())
Text(
text = "SCORE ${engine.score} WAVE ${engine.wave} ♥${engine.lives}",
color = Color.White,
fontSize = 13.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),
)
val banner = engine.waveBanner
if (banner != null && !engine.isGameOver) {
Text(
text = banner,
color = Color.White,
fontSize = 22.sp,
fontWeight = FontWeight.Black,
modifier = Modifier
.align(Alignment.Center)
.clip(RoundedCornerShape(8.dp))
.background(Color(0x88000000))
.padding(horizontal = 16.dp, vertical = 8.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(engine.gameOverReason, color = Color(0xFFE8533F), fontSize = 13.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text("Score ${engine.score} · Wave ${engine.wave}", 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 TankCanvas(engine: TankEngine, 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
}
}
Canvas(modifier = modifier) {
// Read frameTick inside the draw phase (not hoisted to composition) so this block
// re-runs on every engine tick even once nothing else in the composable is animating.
engine.frameTick
val cell = min(size.width, size.height) / GRID
val offX = (size.width - cell * GRID) / 2f
val offY = (size.height - cell * GRID) / 2f
drawRect(color = Color(0xFF2E2A22), size = size)
for (row in 0 until GRID) {
for (col in 0 until GRID) {
val type = engine.cellAt(row, col)
if (type == CellType.EMPTY) continue
drawCell(type, offX + col * cell, offY + row * cell, cell)
}
}
for (b in engine.bulletsView()) {
drawCircle(
color = Color(0xFFFFE79A),
radius = cell * 0.09f,
center = Offset(offX + b.x * cell, offY + b.y * cell),
)
}
for (ex in engine.explosionsView()) {
val t = (ex.age / EXPLOSION_LIFE).coerceIn(0f, 1f)
drawCircle(
color = Color(0xFFF2A93C).copy(alpha = 1f - t),
radius = cell * (0.15f + t * 0.35f),
center = Offset(offX + ex.x * cell, offY + ex.y * cell),
)
}
for (e in engine.enemiesView()) drawTank(e, offX, offY, cell, isPlayer = false)
val player = engine.playerView()
if (player.alive) drawTank(player, offX, offY, cell, isPlayer = true)
}
}
private fun DrawScope.drawCell(type: CellType, x: Float, y: Float, cell: Float) {
when (type) {
CellType.BRICK -> {
drawRect(Color(0xFF9C5B3E), topLeft = Offset(x, y), size = Size(cell, cell))
val half = cell / 2f
drawLine(Color(0xFF6B3A26), Offset(x, y + half), Offset(x + cell, y + half), strokeWidth = 2f)
drawLine(Color(0xFF6B3A26), Offset(x + half, y), Offset(x + half, y + half), strokeWidth = 2f)
drawLine(Color(0xFF6B3A26), Offset(x + half / 2, y + half), Offset(x + half / 2, y + cell), strokeWidth = 2f)
drawLine(
Color(0xFF6B3A26),
Offset(x + half + half / 2, y + half),
Offset(x + half + half / 2, y + cell),
strokeWidth = 2f,
)
}
CellType.STEEL -> {
drawRect(Color(0xFFAFAFB8), topLeft = Offset(x, y), size = Size(cell, cell))
drawRect(
Color(0xFF7A7A82),
topLeft = Offset(x + cell * 0.12f, y + cell * 0.12f),
size = Size(cell * 0.76f, cell * 0.76f),
)
for (dx in listOf(0.2f, 0.8f)) {
for (dy in listOf(0.2f, 0.8f)) {
drawCircle(Color(0xFFD8D8DE), radius = cell * 0.06f, center = Offset(x + cell * dx, y + cell * dy))
}
}
}
CellType.BASE -> {
drawRect(Color(0xFF1B1B1F), topLeft = Offset(x, y), size = Size(cell, cell))
val path = Path().apply {
moveTo(x + cell * 0.5f, y + cell * 0.12f)
lineTo(x + cell * 0.85f, y + cell * 0.85f)
lineTo(x + cell * 0.15f, y + cell * 0.85f)
close()
}
drawPath(path, color = Color(0xFFF2C94C))
}
CellType.EMPTY -> {}
}
}
private fun DrawScope.drawTank(tank: Tank, offX: Float, offY: Float, cell: Float, isPlayer: Boolean) {
if (!tank.alive) return
if (tank.invuln > 0f && ((tank.invuln * 8).toInt() % 2 == 0)) return
val cx = offX + (tank.x + 0.5f) * cell
val cy = offY + (tank.y + 0.5f) * cell
val bodyColor = if (isPlayer) Color(0xFF3E7CE0) else Color(0xFFB4533F)
val degrees = when (tank.dir) {
Direction.UP -> 0f
Direction.RIGHT -> 90f
Direction.DOWN -> 180f
Direction.LEFT -> 270f
}
val half = cell * 0.42f
rotate(degrees = degrees, pivot = Offset(cx, cy)) {
drawRoundRect(
color = Color(0xFF232228),
topLeft = Offset(cx - half, cy - half),
size = Size(half * 2, half * 2),
cornerRadius = CornerRadius(cell * 0.08f, cell * 0.08f),
)
drawRoundRect(
color = bodyColor,
topLeft = Offset(cx - half * 0.75f, cy - half * 0.75f),
size = Size(half * 1.5f, half * 1.5f),
cornerRadius = CornerRadius(cell * 0.1f, cell * 0.1f),
)
drawRect(
color = Color(0xFF232228),
topLeft = Offset(cx - cell * 0.06f, cy - half * 1.15f),
size = Size(cell * 0.12f, half * 0.9f),
)
drawCircle(color = bodyColor.copy(alpha = 0.9f), radius = half * 0.42f, center = Offset(cx, cy))
}
}
@Composable
private fun TankControls(engine: TankEngine, modifier: Modifier = Modifier) {
Row(
modifier = modifier,
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Bottom,
) {
TankDPad(engine)
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(10.dp)) {
FireButton(engine)
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(Color(0xFF3A3A42))
.clickable { engine.reset() },
contentAlignment = Alignment.Center,
) {
Text("⟳", color = Color.White, fontSize = 15.sp, fontWeight = FontWeight.Bold)
}
}
}
}
@Composable
private fun TankDPad(engine: TankEngine) {
val btn = 56.dp
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(6.dp)) {
HoldButton("▲", btn, { engine.setHeld(Direction.UP, true) }, { engine.setHeld(Direction.UP, false) })
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
HoldButton("◀", btn, { engine.setHeld(Direction.LEFT, true) }, { engine.setHeld(Direction.LEFT, false) })
Spacer(Modifier.width(btn))
HoldButton("▶", btn, { engine.setHeld(Direction.RIGHT, true) }, { engine.setHeld(Direction.RIGHT, false) })
}
HoldButton("▼", btn, { engine.setHeld(Direction.DOWN, true) }, { engine.setHeld(Direction.DOWN, false) })
}
}
@Composable
private fun HoldButton(symbol: String, size: Dp, onDown: () -> Unit, onUp: () -> Unit) {
Box(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(14.dp))
.background(Color(0xFF3A3A42))
.border(1.dp, Color(0xFF17171B), RoundedCornerShape(14.dp))
.pointerInput(Unit) {
detectTapGestures(onPress = {
onDown()
tryAwaitRelease()
onUp()
})
},
contentAlignment = Alignment.Center,
) {
Text(symbol, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
@Composable
private fun FireButton(engine: TankEngine) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(Color(0xFFE8533F))
.border(2.dp, Color(0xFF8F2E1F), CircleShape)
.pointerInput(Unit) {
detectTapGestures(onPress = {
engine.fireHeld = true
tryAwaitRelease()
engine.fireHeld = false
})
},
contentAlignment = Alignment.Center,
) {
Text("FIRE", color = Color.White, fontWeight = FontWeight.Black, fontSize = 13.sp)
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment