안드로이드 스튜디오에서 적이 길을 따라오고, 길 옆에 포탑을 세워 막는 디펜스 게임을 만드는 순서
블록을 맞추는 게임은 「안드로이드 스튜디오로 테트리스 만드는 방법」에 있다. 이 글은 같은 도구로 디펜스 게임을 만드는 순서다. 템플릿이 넣어 둔 코틀린과 Jetpack Compose만 쓰고, 다른 라이브러리는 더하지 않는다.
판이 돌아가는 방식
가로 8칸, 세로 12칸이다. 길은 왼쪽 위에서 구불구불 내려와 오른쪽 아래에서 끝난다. 적은 그 칸만 걷는다.
포탑은 길이 아닌 빈칸에만 세울 수 있다. 하나 값은 30이고, 시작할 때 금은 90이라 세 대까지 미리 놓을 수 있다. 사거리는 칸 중심에서 2.6칸이고, 0.5초마다 생명 4를 깎는다. 적을 쓰러뜨리면 금 12가 돌아온다.
웨이브를 시작하면 적이 간격을 두고 입장한다. 웨이브가 오를수록 수가 늘고, 생명과 속도도 오른다. 적이 마지막 칸에 닿으면 생명 1이 빠지고 그 적은 사라진다. 시작 생명은 10이다.
프로젝트를 만든다
안드로이드 스튜디오는 developer.android.com/studio 에서 받는다. File → New → New Project에서 Phone and Tablet의 Empty Activity를 고른다.
| 항목 | 값 |
|---|---|
| Name | Gilmok |
| Package name | kr.thinktree.gilmok |
| Language | Kotlin |
| Minimum SDK | API 26. 마법사 기본값이 더 높으면 그 값을 둔다 |
| Build configuration language | Kotlin DSL |
패키지를 다르게 적었다면, 아래 파일들의 첫 줄 `package`만 그 이름으로 맞춘다.
첫 화면을 게임으로 바꾼다
`app/src/main/java/kr/thinktree/gilmok/MainActivity.kt`에서 Greeting이 있던 자리에 DefenseScreen을 둔다. 테마 함수는 프로젝트 이름을 따른다. Gilmok이면 GilmokTheme다.
setContent {
GilmokTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
DefenseScreen(modifier = Modifier.padding(innerPadding))
}
}
}Greeting과 Preview는 지운다. `app/src/main/AndroidManifest.xml`의 activity에는 다음 한 줄을 넣어 판이 가로로 돌며 다시 만들어지지 않게 한다.
android:screenOrientation="portrait"게임 파일을 추가한다
패키지 폴더에서 New → Kotlin Class/File로 DefenseScreen을 만들고 전체를 아래로 바꾼다. 길 좌표, 적과 포탑의 계산, 화면 그리기가 이 파일 안에 있다.
package kr.thinktree.gilmok
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
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.hypot
import kotlinx.coroutines.delay
private const val COLS = 8
private const val ROWS = 12
private const val TOWER_COST = 30
private const val RANGE = 2.6f
private val path: List<Pair<Int, Int>> = buildPath(
listOf(
0 to 1, 6 to 1,
6 to 3, 1 to 3,
1 to 5, 6 to 5,
6 to 7, 1 to 7,
1 to 9, 7 to 9,
7 to 11,
),
)
private fun buildPath(points: List<Pair<Int, Int>>): List<Pair<Int, Int>> {
val built = mutableListOf(points.first())
for (index in 1 until points.size) {
val target = points[index]
var column = built.last().first
var row = built.last().second
val stepColumn = (target.first - column).coerceIn(-1, 1)
val stepRow = (target.second - row).coerceIn(-1, 1)
while (column != target.first || row != target.second) {
if (column != target.first) column += stepColumn else row += stepRow
built.add(column to row)
}
}
return built
}
private class Enemy(var along: Float, var hp: Int, val maxHp: Int) {
var alive = true
}
private class Tower(val column: Int, val row: Int, var cooldown: Float)
private class Shot(
val x1: Float,
val y1: Float,
val x2: Float,
val y2: Float,
var life: Float,
)
private class DefenseGame {
var gold = 90
var lives = 10
var wave = 0
var running = true
val towers = mutableListOf<Tower>()
val enemies = mutableListOf<Enemy>()
val shots = mutableListOf<Shot>()
fun place(column: Int, row: Int) {
if (!running || gold < TOWER_COST) return
if (column !in 0 until COLS || row !in 0 until ROWS) return
if (path.any { it.first == column && it.second == row }) return
if (towers.any { it.column == column && it.row == row }) return
gold -= TOWER_COST
towers.add(Tower(column, row, 0f))
}
fun launchWave() {
if (!running || enemies.any { it.alive }) return
wave += 1
val count = 5 + wave
val hp = 10 + wave * 6
repeat(count) { index ->
enemies.add(Enemy(along = -index * 0.95f, hp = hp, maxHp = hp))
}
}
fun update(dt: Float) {
if (!running) return
val speed = 1.35f + wave * 0.08f
for (enemy in enemies) {
if (!enemy.alive) continue
enemy.along += dt * speed
if (enemy.along >= path.lastIndex) {
enemy.alive = false
lives -= 1
}
}
for (shot in shots) shot.life -= dt
shots.removeAll { it.life <= 0f }
for (tower in towers) {
tower.cooldown -= dt
if (tower.cooldown > 0f) continue
val target = nearest(tower) ?: continue
val position = tileOf(target.along) ?: continue
target.hp -= 4
tower.cooldown = 0.5f
shots.add(
Shot(
tower.column + 0.5f,
tower.row + 0.5f,
position.first + 0.5f,
position.second + 0.5f,
0.12f,
),
)
if (target.hp <= 0) {
target.alive = false
gold += 12
}
}
enemies.removeAll { !it.alive }
if (lives <= 0) running = false
}
fun tileOf(along: Float): Pair<Float, Float>? {
if (along < 0f || along >= path.lastIndex) return null
val index = along.toInt().coerceAtMost(path.lastIndex - 1)
val fraction = along - index
val from = path[index]
val to = path[index + 1]
return (from.first + (to.first - from.first) * fraction) to
(from.second + (to.second - from.second) * fraction)
}
private fun nearest(tower: Tower): Enemy? {
var best: Enemy? = null
var bestDistance = RANGE
val originX = tower.column + 0.5f
val originY = tower.row + 0.5f
for (enemy in enemies) {
if (!enemy.alive) continue
val position = tileOf(enemy.along) ?: continue
val distance = hypot(position.first + 0.5f - originX, position.second + 0.5f - originY)
if (distance <= bestDistance) {
bestDistance = distance
best = enemy
}
}
return best
}
}
@Composable
fun DefenseScreen(modifier: Modifier = Modifier) {
var seed by remember { mutableStateOf(0) }
var frame by remember(seed) { mutableStateOf(0) }
val game = remember(seed) { DefenseGame() }
LaunchedEffect(seed) {
while (game.running) {
delay(16)
game.update(0.016f)
frame += 1
}
}
Column(
modifier
.fillMaxSize()
.background(Color(0xFF12151C))
.padding(12.dp),
) {
val shown = frame
Text(
text = "생명 ${game.lives} 금 ${game.gold} 웨이브 ${game.wave}",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
)
Spacer(Modifier.height(8.dp))
Canvas(
Modifier
.fillMaxWidth()
.aspectRatio(COLS / ROWS.toFloat())
.background(Color(0xFF1B212C))
.pointerInput(seed) {
detectTapGestures { offset ->
val cell = size.width / COLS
game.place((offset.x / cell).toInt(), (offset.y / cell).toInt())
frame += 1
}
},
) {
if (shown < 0) return@Canvas
val cell = size.width / COLS
fun center(x: Float, y: Float) = Offset(x * cell, y * cell)
for (row in 0 until ROWS) {
for (column in 0 until COLS) {
val onPath = path.any { it.first == column && it.second == row }
drawRect(
color = if (onPath) Color(0xFF3A4658) else Color.White.copy(alpha = 0.04f),
topLeft = Offset(column * cell, row * cell),
size = Size(cell - 2f, cell - 2f),
)
}
}
val entrance = path.first()
val base = path.last()
drawCircle(Color(0xFF86EFAC), cell * 0.28f, center(entrance.first + 0.5f, entrance.second + 0.5f))
drawCircle(Color(0xFFFDA4AF), cell * 0.28f, center(base.first + 0.5f, base.second + 0.5f))
for (tower in game.towers) {
drawCircle(
Color(0xFFFBBF24),
cell * 0.32f,
center(tower.column + 0.5f, tower.row + 0.5f),
)
}
for (enemy in game.enemies) {
val position = game.tileOf(enemy.along) ?: continue
val at = center(position.first + 0.5f, position.second + 0.5f)
drawCircle(Color(0xFFFB7185), cell * 0.26f, at)
val ratio = enemy.hp.toFloat() / enemy.maxHp
drawRect(Color.Black, at + Offset(-cell * 0.3f, -cell * 0.42f), Size(cell * 0.6f, 4f))
drawRect(Color(0xFF4ADE80), at + Offset(-cell * 0.3f, -cell * 0.42f), Size(cell * 0.6f * ratio, 4f))
}
for (shot in game.shots) {
drawLine(
Color.White,
center(shot.x1, shot.y1),
center(shot.x2, shot.y2),
strokeWidth = 3f,
)
}
}
Spacer(Modifier.height(8.dp))
Text(
if (game.running) "길이 아닌 칸을 누르면 포탑을 세웁니다. 한 대는 30입니다."
else "끝까지 들어온 적이 생명을 모두 깎았습니다.",
color = Color.White.copy(alpha = 0.8f),
fontSize = 14.sp,
)
Spacer(Modifier.height(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (game.running && game.enemies.isEmpty()) {
SmallButton("웨이브 ${game.wave + 1}") { game.launchWave() }
}
SmallButton("다시") { seed += 1 }
}
}
}
@Composable
private fun RowScope.SmallButton(label: String, onClick: () -> Unit) {
Button(onClick = onClick, modifier = Modifier.weight(1f)) { Text(label) }
}초록 원은 적이 들어오는 첫 칸이고, 분홍 원은 적이 빠져나가는 마지막 칸이다. 노란 원이 포탑, 붉은 원이 적이다. 적 머리 위의 막대는 남은 생명이다. 하얀 선은 포탑이 그 순간 맞힌 방향이고, 0.12초 뒤에 사라진다.
달러가 붙은 `${game.lives}` 같은 글자는 코틀린 문자열 템플릿이다. 붙여 넣을 때 지우지 않는다.
`buildPath`는 꺾이는 점만 받아, 그 사이를 한 칸씩 채운다. 가로로 먼저 맞춘 다음 세로로 맞추므로, 점에 적힌 두 좌표 중 하나는 바로 앞 점과 같아야 한다. 위에 적은 점은 그 조건을 지킨다.
실행
Tools → Device Manager에서 폰 이미지를 하나 만들고 Run을 누른다. 내 폰이면 개발자 옵션의 USB 디버깅을 켠 뒤 같은 버튼으로 설치한다.
처음에는 적이 없다. 분홍 원으로 가는 길 옆, 꺾이는 자리 근처에 포탑 세 대를 세운 다음 웨이브 1을 누른다. 적이 지나가기 전에 금이 모자라면 그 칸을 눌러도 포탑은 생기지 않는다. 길이 끝난 뒤에 다시를 누르면 금과 생명이 처음으로 돌아온다.
빌드가 실패하면 패키지 첫 줄과 폴더가 같은지 먼저 본다. DefenseScreen이 빨갛다면 MainActivity와 같은 디렉터리에 있는지 확인한다. 테마 이름이 빨갛다면 샘플의 GilmokTheme 대신 `ui/theme`에 있는 함수 이름을 쓴다.
다음에 나눌 수 있는 것
지금은 포탑이 한 종류다. 값을 더 받는 느린 포탑과, 싼 대신 사거리가 짧은 포탑을 나누면 어디 세울지가 선택이 된다. 이미 세운 포탑을 다시 눌러 절반 값에 팔게 할 수도 있다. 웨이브 사이가 너무 짧으면 `launchWave`를 바로 부르지 말고, 버튼으로만 다음 무리를 들이면 된다. 그 버튼은 이미 적이 없을 때만 나타난다.
댓글
0개아직 댓글이 없습니다.