Created
July 21, 2026 02:43
-
-
Save samarthsubramanya/b0fd58cb5715bc53ae240421b9dbc7e1 to your computer and use it in GitHub Desktop.
TankDestroyer.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.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