안드로이드 스튜디오에서 빈 프로젝트를 만들고, 코틀린과 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만 쓰고, 다른 라이브러리는 더하지 않는다.
| 항목 | 값 |
|---|---|
| Name | Invader |
| Package name | kr.thinktree.invader |
| Language | Kotlin |
| Minimum SDK | API 26. 마법사 기본값이 더 높으면 그 값을 둔다 |
| Build configuration language | Kotlin 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로 둔다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다.
댓글
0개아직 댓글이 없습니다.