안드로이드 스튜디오에서 빈 프로젝트를 만들고, 코틀린과 Jetpack Compose로 인베이더를 붙이는 순서

규칙은 Qt로 만든 인베이더 엔진과 같다. 그 엔진을 설명하는 글은 「인베이더 게임으로 배우는 휴리스틱과 AI 브리지」다. 이 글은 그 플레이 부분만 폰에서 돌리는 순서다. 휴리스틱과 VLM 브리지는 그 글에 그대로 둔다.

판이 돌아가는 방식

세계는 가로 480, 세로 640이다. 화면은 그 비율을 유지한 채 폰에 맞춘다. 적은 11열 6줄, 모두 66마리다. 한 마리를 맞히면 10점이다. 맨 아래 줄만 탄을 쏜다. 적이 줄면 걸음이 빨라진다. 66마리일 때 10틱, 36마리 이하이면 8틱, 18마리 이하이면 6틱마다 6픽셀을 움직인다. 벽에 닿으면 방향을 바꾸고 14픽셀 내려온다. 플레이어 머리까지 내려오거나 적의 탄에 맞으면 그 판은 끝이다.

플레이어 탄은 한 번에 5발까지다. 위를 지나가는 분홍 비행체를 플레이어 탄으로 맞히면 100점에서 299점 사이가 더해지고, 동시에 쏠 수 있는 탄이 2발 늘어난다. 적의 탄이 그 비행체를 맞히면 30점만 오르고 탄 수는 그대로다. 서로 다른 편의 탄이 부딪히면 둘 다 사라진다.

방어막은 여섯 채다. 지붕과 굴뚝, 문과 창이 뚫린 집 모양이다. 탄이 남은 픽셀에 맞으면 그 자리의 반지름 7픽셀이 지워지고 탄은 사라진다. 이미 빈 구멍으로는 탄이 지나간다. 적이 방어막에 겹치면 위쪽부터 한 틱에 네 줄까지 지워진다.

시작과 웨이브를 클리어한 뒤에는 10초를 센다. 그 동안에는 좌우로만 움직이고 발사는 되지 않는다. 적이 모두 사라지면 점수와 탄 상한은 유지한 채 적과 방어막만 다시 놓인다.

원 엔진은 한 틱에 키를 하나만 받는다. 폰에서는 이동 버튼을 누른 채로 발사할 수 있다. 틱은 48밀리초다.

iPhone용 같은 게임은 「Xcode로 인베이더 만드는 방법」에 있다.

프로젝트를 만든다

안드로이드 스튜디오는 developer.android.com/studio 에서 받아 설치한다. File → New → New Project에서 Phone and Tablet의 Empty Activity를 고른다. 템플릿이 넣어 둔 코틀린과 Jetpack Compose만 쓰고, 다른 라이브러리는 더하지 않는다.

항목값
NameInvader
Package namekr.thinktree.invader
LanguageKotlin
Minimum SDKAPI 26. 마법사 기본값이 더 높으면 그 값을 둔다
Build configuration languageKotlin DSL

패키지 이름을 다르게 적었다면, 아래 파일의 첫 줄 package만 그 이름으로 맞춘다.

첫 화면을 게임으로 바꾼다

app/src/main/java/kr/thinktree/invader/MainActivity.kt에서 Greeting이 있던 자리에 InvaderScreen을 둔다. 테마 함수는 프로젝트 이름을 따른다. Invader이면 InvaderTheme다.

setContent {
    InvaderTheme {
        Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
            InvaderScreen(modifier = Modifier.padding(innerPadding))
        }
    }
}

Greeting과 Preview는 지운다. app/src/main/AndroidManifest.xml의 activity에는 다음 한 줄을 넣어 판이 가로로 돌며 다시 만들어지지 않게 한다.

android:screenOrientation="portrait"

게임 파일을 추가한다

패키지 폴더에서 New → Kotlin Class/File로 InvaderScreen을 만들고 전체를 아래로 바꾼다.

package kr.thinktree.invader

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.waitForUpOrCancellation
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
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.sp
import kotlin.math.min
import kotlin.random.Random
import kotlinx.coroutines.delay

private const val WORLD_W = 480
private const val WORLD_H = 640
private const val HUD_H = 54
private const val PLAY_TOP = HUD_H
private const val PLAY_BOTTOM = WORLD_H - 16
private const val TICK_MS = 48L
private const val INTERMISSION_TICKS = (10 * 1000 + 47) / 48

private enum class Phase { Playing, Intermission, Lost }

private class Box(var x: Int, var y: Int, val w: Int, val h: Int) {
    fun right() = x + w
    fun bottom() = y + h
    fun centerX() = x + w / 2
    fun centerY() = y + h / 2
    fun intersects(o: Box) =
        x < o.right() && right() > o.x && y < o.bottom() && bottom() > o.y
}

private class Shot(val box: Box, val vy: Int, val fromPlayer: Boolean)

private class Bunker(val x: Int, val y: Int) {
    val w = 58
    val h = 42
    val solid = Array(h) { BooleanArray(w) }

    init {
        carve()
    }

    fun rect() = Box(x, y, w, h)

    fun hits(shot: Box): Boolean {
        val area = overlap(rect(), shot) ?: return false
        for (yy in area.y until area.bottom()) {
            for (xx in area.x until area.right()) {
                if (solid[yy - y][xx - x]) return true
            }
        }
        return false
    }

    fun bite(shot: Box) {
        val area = overlap(rect(), shot) ?: return
        var px = area.centerX()
        var py = area.centerY()
        for (yy in area.y until area.bottom()) {
            for (xx in area.x until area.right()) {
                if (solid[yy - y][xx - x]) {
                    px = xx
                    py = yy
                }
            }
        }
        val radius = 7
        for (yy in py - radius..py + radius) {
            for (xx in px - radius..px + radius) {
                val dx = xx - px
                val dy = yy - py
                if (dx * dx + dy * dy <= radius * radius) erase(xx, yy)
            }
        }
    }

    fun chew(alien: Box) {
        val area = overlap(rect(), alien) ?: return
        var rows = 0
        var yy = area.y
        while (yy < area.bottom() && rows < 4) {
            var any = false
            for (xx in area.x until area.right()) {
                if (erase(xx, yy)) any = true
            }
            if (any) rows++
            yy++
        }
    }

    private fun erase(xx: Int, yy: Int): Boolean {
        val lx = xx - x
        val ly = yy - y
        if (ly !in 0 until h || lx !in 0 until w || !solid[ly][lx]) return false
        solid[ly][lx] = false
        return true
    }

    private fun carve() {
        fill(w - 15, 0, 8, 16, true)
        for (i in 0 until 10) fill(i * 2, i * 2, w - 4 * i, 2, true)
        fill(0, 20, w, h - 20, true)
        fill(w / 2 - 7, h - 17, 14, 17, false)
        fill(9, 27, 10, 9, false)
        fill(w - 19, 27, 10, 9, false)
    }

    private fun fill(left: Int, top: Int, width: Int, height: Int, on: Boolean) {
        for (yy in top until top + height) {
            if (yy !in 0 until h) continue
            for (xx in left until left + width) {
                if (xx in 0 until w) solid[yy][xx] = on
            }
        }
    }
}

private class Input {
    var left = false
    var right = false
    var fire = false
}

private class InvaderGame {
    var phase = Phase.Intermission
    var tick = 0
    var score = 0
    var maxBullets = 5
    var intermissionLeft = INTERMISSION_TICKS
    val player = Box(WORLD_W / 2 - 17, PLAY_BOTTOM - 14, 34, 14)
    val aliens = ArrayList<Box>()
    val bullets = ArrayList<Shot>()
    val bunkers = ArrayList<Bunker>()
    var alienDir = 1
    var alienStepEvery = 10
    var alienShootIn = 16
    var ufo: Box? = null
    var ufoVx = 3
    var ufoSpawnIn = 320 + Random.nextInt(480)

    init {
        placeAliens()
        placeBunkers()
    }

    fun seconds(): Int {
        if (phase != Phase.Intermission || intermissionLeft <= 0) return 0
        return maxOf(1, (intermissionLeft * TICK_MS.toInt() + 999) / 1000)
    }

    fun tick(move: Int, fire: Boolean) {
        if (phase == Phase.Lost) return
        tick++
        if (phase == Phase.Intermission) {
            slide(move)
            if (intermissionLeft > 0) intermissionLeft--
            if (intermissionLeft <= 0) phase = Phase.Playing
            return
        }
        stepUfo()
        slide(move)
        if (fire) shoot()
        stepBullets()
        resolve()
        if (phase != Phase.Playing) return
        if (tick % alienStepEvery == 0) {
            alienStepEvery = when {
                aliens.size <= 18 -> 6
                aliens.size <= 36 -> 8
                else -> 10
            }
            stepAliens()
            if (lostByTouch()) return
        }
        alienShoot()
        resolve()
        for (alien in aliens) for (bunker in bunkers) bunker.chew(alien)
        lostByTouch()
    }

    private fun slide(move: Int) {
        player.x += move * 7
        if (player.x < 8) player.x = 8
        if (player.right() > WORLD_W - 8) player.x = WORLD_W - 8 - player.w
    }

    private fun shoot() {
        if (bullets.count { it.fromPlayer } >= maxBullets) return
        bullets.add(Shot(Box(player.centerX() - 2, player.y - 12, 4, 10), -12, true))
    }

    private fun stepBullets() {
        val next = ArrayList<Shot>()
        for (shot in bullets) {
            shot.box.y += shot.vy
            if (shot.box.bottom() < PLAY_TOP || shot.box.y > PLAY_BOTTOM) continue
            next.add(shot)
        }
        bullets.clear()
        bullets.addAll(next)
    }

    private fun stepAliens() {
        if (aliens.isEmpty()) return
        val hitWall = aliens.any {
            val nx = it.x + alienDir * 6
            nx < 8 || nx + it.w > WORLD_W - 8
        }
        if (hitWall) {
            alienDir = -alienDir
            for (alien in aliens) alien.y += 14
        } else {
            for (alien in aliens) alien.x += alienDir * 6
        }
    }

    private fun alienShoot() {
        if (aliens.isEmpty()) return
        if (--alienShootIn > 0) return
        alienShootIn = 10 + Random.nextInt(18)
        val bottoms = aliens.filter { alien ->
            aliens.none { other -> other.x == alien.x && other.y > alien.y }
        }
        val pick = (bottoms.ifEmpty { aliens }).random()
        bullets.add(Shot(Box(pick.centerX() - 2, pick.bottom() + 2, 4, 10), 10, false))
    }

    private fun stepUfo() {
        val flying = ufo
        if (flying != null) {
            flying.x += ufoVx
            if (flying.right() < -14 || flying.x > WORLD_W + 14) ufo = null
            return
        }
        if (--ufoSpawnIn > 0) return
        ufoSpawnIn = 360 + Random.nextInt(560)
        ufoVx = if (Random.nextInt(2) == 1) 3 else -3
        ufo = if (ufoVx > 0) Box(-52, PLAY_TOP + 5, 42, 20) else Box(WORLD_W + 10, PLAY_TOP + 5, 42, 20)
    }

    private fun resolve() {
        val dead = BooleanArray(bullets.size)
        for (i in bullets.indices) {
            for (j in i + 1 until bullets.size) {
                if (dead[i] || dead[j] || bullets[i].fromPlayer == bullets[j].fromPlayer) continue
                if (bullets[i].box.intersects(bullets[j].box)) {
                    dead[i] = true
                    dead[j] = true
                }
            }
        }
        val kept = ArrayList<Shot>()
        for (i in bullets.indices) {
            if (dead[i]) continue
            val shot = bullets[i]
            val bunker = bunkers.firstOrNull { it.hits(shot.box) }
            if (bunker != null) {
                bunker.bite(shot.box)
                continue
            }
            if (shot.fromPlayer) {
                val alien = aliens.indexOfFirst { it.intersects(shot.box) }
                if (alien >= 0) {
                    aliens.removeAt(alien)
                    score += 10
                    continue
                }
                val flying = ufo
                if (flying != null && flying.intersects(shot.box)) {
                    score += 100 + Random.nextInt(200)
                    maxBullets += 2
                    ufo = null
                    continue
                }
            } else {
                val flying = ufo
                if (flying != null && flying.intersects(shot.box)) {
                    score += 30
                    ufo = null
                    continue
                }
                if (shot.box.intersects(player)) {
                    phase = Phase.Lost
                    continue
                }
            }
            kept.add(shot)
        }
        bullets.clear()
        bullets.addAll(kept)
        if (phase == Phase.Playing && aliens.isEmpty()) nextWave()
    }

    private fun lostByTouch(): Boolean {
        if (phase != Phase.Playing) return true
        if (aliens.any { it.bottom() >= player.y - 2 }) {
            phase = Phase.Lost
            return true
        }
        return false
    }

    private fun nextWave() {
        placeAliens()
        placeBunkers()
        bullets.clear()
        ufo = null
        ufoSpawnIn = 320 + Random.nextInt(480)
        alienDir = 1
        alienStepEvery = 10
        alienShootIn = 16
        phase = Phase.Intermission
        intermissionLeft = INTERMISSION_TICKS
    }

    private fun placeAliens() {
        aliens.clear()
        for (row in 0 until 6) {
            for (col in 0 until 11) {
                aliens.add(Box(40 + col * 34, PLAY_TOP + 22 + row * 26, 22, 16))
            }
        }
    }

    private fun placeBunkers() {
        bunkers.clear()
        val maskW = 58
        val pad = 18
        val gap = (WORLD_W - pad * 2 - 6 * maskW) / 5
        val y = PLAY_BOTTOM - 110
        for (i in 0 until 6) bunkers.add(Bunker(pad + i * (maskW + gap), y))
    }
}

private fun overlap(a: Box, b: Box): Box? {
    val x = maxOf(a.x, b.x)
    val y = maxOf(a.y, b.y)
    val right = minOf(a.right(), b.right())
    val bottom = minOf(a.bottom(), b.bottom())
    if (right <= x || bottom <= y) return null
    return Box(x, y, right - x, bottom - y)
}

@Composable
fun InvaderScreen(modifier: Modifier = Modifier) {
    var seed by remember { mutableIntStateOf(0) }
    var frame by remember(seed) { mutableIntStateOf(0) }
    val game = remember(seed) { InvaderGame() }
    val input = remember { Input() }
    val focus = remember { FocusRequester() }

    LaunchedEffect(seed) {
        runCatching { focus.requestFocus() }
        val current = game
        while (current.phase != Phase.Lost) {
            delay(TICK_MS)
            val move = if (input.left) -1 else if (input.right) 1 else 0
            current.tick(move, input.fire)
            frame++
        }
    }

    Column(
        modifier
            .fillMaxSize()
            .background(Color(0xFF0A0C1C))
            .focusRequester(focus)
            .focusable()
            .onKeyEvent { event ->
                val down = event.type == KeyEventType.KeyDown
                val up = event.type == KeyEventType.KeyUp
                if (!down && !up) return@onKeyEvent false
                when (event.key) {
                    Key.DirectionLeft, Key.A -> input.left = down
                    Key.DirectionRight, Key.D -> input.right = down
                    Key.Spacebar, Key.DirectionUp, Key.W -> input.fire = down
                    else -> return@onKeyEvent false
                }
                true
            }
            .padding(12.dp),
    ) {
        val shown = frame
        Text(
            "점수 ${game.score}   탄 ${game.maxBullets}",
            color = Color.White,
            fontSize = 18.sp,
            fontWeight = FontWeight.Bold,
        )
        Box(Modifier.fillMaxWidth().weight(1f).padding(vertical = 8.dp)) {
            Canvas(Modifier.fillMaxSize()) {
                if (shown < 0) return@Canvas
                val scale = min(size.width / WORLD_W, size.height / WORLD_H)
                val ox = (size.width - WORLD_W * scale) / 2f
                val oy = (size.height - WORLD_H * scale) / 2f
                fun px(v: Int) = ox + v * scale
                fun py(v: Int) = oy + v * scale
                drawRect(Color(0xFF101426), Offset(ox, oy), Size(WORLD_W * scale, WORLD_H * scale))
                drawLine(Color(0xFF28325A), Offset(ox, py(HUD_H)), Offset(ox + WORLD_W * scale, py(HUD_H)), 1f)
                for (bunker in game.bunkers) {
                    for (y in 0 until bunker.h) {
                        var x = 0
                        while (x < bunker.w) {
                            if (!bunker.solid[y][x]) {
                                x++
                                continue
                            }
                            val start = x
                            while (x < bunker.w && bunker.solid[y][x]) x++
                            val color = if (y < 16 && start >= bunker.w - 15) Color(0xFF60606E)
                            else if (y < 20) Color(0xFFBC4E44)
                            else Color(0xFF3AA852)
                            drawRect(color, Offset(px(bunker.x + start), py(bunker.y + y)), Size((x - start) * scale, scale))
                        }
                    }
                }
                val phase = (game.tick / 8) % 2
                for (alien in game.aliens) {
                    drawRect(Color(0xFF4EDC7D), Offset(px(alien.x), py(alien.y)), Size(alien.w * scale, alien.h * scale))
                    drawRect(Color(0xFF143C23), Offset(px(alien.x + 4), py(alien.y + 3)), Size(5 * scale, 5 * scale))
                    drawRect(Color(0xFF143C23), Offset(px(alien.right() - 8), py(alien.y + 3)), Size(5 * scale, 5 * scale))
                    val skew = if (phase == 0) 0 else 3
                    drawRect(Color(0xFF268248), Offset(px(alien.x + 2 + skew), py(alien.bottom() - 4)), Size(6 * scale, 4 * scale))
                    drawRect(Color(0xFF268248), Offset(px(alien.right() - 7 - skew), py(alien.bottom() - 4)), Size(6 * scale, 4 * scale))
                }
                game.ufo?.let { ufo ->
                    drawOval(Color(0xFFAA3764), Offset(px(ufo.x), py(ufo.y)), Size(ufo.w * scale, ufo.h * scale))
                }
                for (shot in game.bullets) {
                    val color = if (shot.fromPlayer) Color(0xFFFFE650) else Color(0xFF78C8FF)
                    drawRect(color, Offset(px(shot.box.x), py(shot.box.y)), Size(shot.box.w * scale, shot.box.h * scale))
                }
                drawRect(Color(0xFFB4B4DC), Offset(px(game.player.centerX() - 4), py(game.player.y - 6)), Size(8 * scale, 6 * scale))
                drawRect(Color(0xFFDCDCF0), Offset(px(game.player.x), py(game.player.y)), Size(game.player.w * scale, game.player.h * scale))
            }
            if (game.phase == Phase.Intermission && game.seconds() > 0) {
                Text(
                    "${game.seconds()}",
                    color = Color(0xFFFFEB3C),
                    fontSize = 96.sp,
                    fontWeight = FontWeight.Bold,
                    modifier = Modifier.align(Alignment.Center),
                )
            }
        }
        if (game.phase == Phase.Lost) {
            Text("적이 닿았거나 탄에 맞았습니다.", color = Color.White)
        }
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            HoldButton("왼쪽", Modifier.weight(1f)) { input.left = it }
            HoldButton("발사", Modifier.weight(1f)) { input.fire = it }
            HoldButton("오른쪽", Modifier.weight(1f)) { input.right = it }
        }
        Button(
            onClick = {
                input.left = false
                input.right = false
                input.fire = false
                seed++
            },
            modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
        ) { Text("다시") }
    }
}

@Composable
private fun HoldButton(label: String, modifier: Modifier, onChange: (Boolean) -> Unit) {
    Box(
        modifier
            .height(48.dp)
            .background(Color.White.copy(alpha = 0.14f))
            .pointerInput(Unit) {
                awaitEachGesture {
                    awaitFirstDown()
                    onChange(true)
                    try {
                        waitForUpOrCancellation()
                    } finally {
                        onChange(false)
                    }
                }
            },
        contentAlignment = Alignment.Center,
    ) {
        Text(label, color = Color.White, fontSize = 16.sp)
    }
}

점수 글자 안의 달러와 중괄호는 코틀린 문자열 템플릿이다. 붙여 넣을 때 그대로 둔다.

조작하는 일
왼쪽, 오른쪽누르고 있는 동안 이동
발사누르고 있는 동안 탄을 쏨. 동시에 낼 수 있는 수까지만
다시점수와 판을 처음으로 되돌림
← →, A D에뮬레이터에서 이동. 화면을 한 번 눌러 포커스를 둔다
Space, ↑, W에뮬레이터에서 발사

에뮬레이터와 폰에서 실행한다

Tools → Device Manager에서 폰 하나를 고르고 Run을 누른다. 내 폰에서 보려면 개발자 옵션의 USB 디버깅을 켠 뒤 케이블로 연결한다. 기기 목록에 폰이 보이면 같은 Run으로 설치된다.

빌드가 실패하면 패키지 첫 줄과 폴더가 같은지 먼저 본다. 테마 이름이 빨갛다면 샘플의 InvaderTheme 대신 ui/theme에 있는 함수 이름을 쓴다.

스토어에 낼 때

디버그 실행만으로도 게임은 완성이다. Play에 올리려면 Build → Generate Signed App Bundle에서 업로드 키를 만들고 App Bundle을 뽑는다. 스토어 이름과 아이콘에는 상용 슈팅 게임의 로고와 이름을 쓰지 않는다. 앱 이름은 이 프로젝트처럼 Invader로 둔다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.