원자가 길을 따라 걸어가며 위쪽으로 쏘는 세로 스크롤 게임을 만들고, 그 결과물을 안드로이드와 맥에 올리는 순서

같은 도구로 만든 다른 게임은 「안드로이드 스튜디오로 테트리스 만드는 방법」과 「안드로이드 스튜디오로 디펜스 게임 만드는 방법」에 있다. iPhone용 같은 게임은 「Xcode로 Atom walk 만드는 방법」에 있다.

게임이 하는 일

화면 아래의 원자는 저절로 앞으로 걷는다. 길 표시가 아래로 흘러 세로로 올라가는 것처럼 보인다. 좌우와 조금의 위아래만 움직이고, 걷는 동안 총은 위쪽으로 계속 나간다. 위에서 내려오는 덩어리에 맞으면 생명이 줄고, 세 번이면 그 판은 끝이다. 맞힌 덩어리마다 10점이다.

프로젝트를 만든다

안드로이드 스튜디오를 설치한 뒤 kmp.jetbrains.com 에서 새 프로젝트를 받는다. 대상은 Android와 Desktop만 고른다. iOS와 웹은 끄면 구성이 단순하다. 프로젝트 이름은 AtomWalk, 패키지는 kr.thinktree.atomwalk 로 둔다. 받은 압축을 풀어 안드로이드 스튜디오로 연다.

2026년 마법사는 공유 모듈과 androidApp, desktopApp을 나누기도 하고, 예전처럼 composeApp 하나에 넣기도 한다. 어느 쪽이든 App.kt의 App 함수가 첫 화면이다. 그 함수 본문을 아래처럼 바꾼다. 패키지 선언은 파일이 이미 가진 것을 그대로 둔다.

@Composable
fun App() {
    AtomGame()
}

같은 패키지에 AtomGame.kt를 새로 만들고 아래 전체를 넣는다. package 줄만 App.kt와 같게 고친다.

package kr.thinktree.atomwalk

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.detectDragGestures
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.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.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.cos
import kotlin.math.hypot
import kotlin.math.sin
import kotlin.random.Random
import kotlinx.coroutines.delay

private class Mote(var x: Float, var y: Float, var hp: Int) {
    var alive = true
}

private class Bolt(var x: Float, var y: Float) {
    var alive = true
}

private class AtomWalk {
    var playerX = 0.5f
    var playerY = 0.78f
    var time = 0f
    var scroll = 0f
    var score = 0
    var lives = 3
    var running = true
    var holdLeft = false
    var holdRight = false
    var holdUp = false
    var holdDown = false
    val bolts = mutableListOf<Bolt>()
    val motes = mutableListOf<Mote>()
    private var fire = 0f
    private var spawn = 1.2f
    private var invuln = 0f

    fun update(dt: Float) {
        if (!running) return
        time += dt
        scroll += dt * 90f
        val step = dt * 0.55f
        if (holdLeft) playerX -= step
        if (holdRight) playerX += step
        if (holdUp) playerY -= step
        if (holdDown) playerY += step
        playerX = playerX.coerceIn(0.1f, 0.9f)
        playerY = playerY.coerceIn(0.62f, 0.9f)
        fire -= dt
        if (fire <= 0f) {
            fire = 0.16f
            bolts.add(Bolt(playerX, playerY - 0.06f))
        }
        spawn -= dt
        val gap = (0.85f - score * 0.004f).coerceAtLeast(0.32f)
        if (spawn <= 0f) {
            spawn = gap
            motes.add(Mote(Random.nextFloat() * 0.76f + 0.12f, -0.08f, if (score > 80) 2 else 1))
        }
        for (bolt in bolts) bolt.y -= dt * 0.95f
        for (mote in motes) mote.y += dt * (0.2f + score.coerceAtMost(150) * 0.0012f)
        for (bolt in bolts) {
            if (!bolt.alive) continue
            val mote = motes.firstOrNull { it.alive && hypot(it.x - bolt.x, it.y - bolt.y) < 0.05f }
            if (mote != null) {
                bolt.alive = false
                mote.hp -= 1
                if (mote.hp <= 0) {
                    mote.alive = false
                    score += 10
                }
            }
        }
        if (invuln > 0f) invuln -= dt
        for (mote in motes) {
            if (!mote.alive || invuln > 0f) continue
            if (hypot(mote.x - playerX, mote.y - playerY) < 0.07f) {
                mote.alive = false
                lives -= 1
                invuln = 1.1f
            }
        }
        bolts.removeAll { !it.alive || it.y < -0.1f }
        motes.removeAll { !it.alive || it.y > 1.08f }
        if (lives <= 0) running = false
    }

    fun hidden(): Boolean = invuln > 0f && (time * 12f).toInt() % 2 == 0
}

@Composable
fun AtomGame() {
    var seed by remember { mutableStateOf(0) }
    var frame by remember(seed) { mutableStateOf(0) }
    val game = remember(seed) { AtomWalk() }
    val focus = remember { FocusRequester() }

    LaunchedEffect(seed) {
        runCatching { focus.requestFocus() }
        while (game.running) {
            delay(16)
            game.update(0.016f)
            frame += 1
        }
    }

    Column(
        Modifier
            .fillMaxSize()
            .background(Color(0xFF0B1020))
            .focusRequester(focus)
            .focusable()
            .onKeyEvent { event ->
                val down = event.type == KeyEventType.KeyDown
                val up = event.type == KeyEventType.KeyUp
                if (!down && !up) return@onKeyEvent false
                val pressed = down
                when (event.key) {
                    Key.DirectionLeft, Key.A -> game.holdLeft = pressed
                    Key.DirectionRight, Key.D -> game.holdRight = pressed
                    Key.DirectionUp, Key.W -> game.holdUp = pressed
                    Key.DirectionDown, Key.S -> game.holdDown = pressed
                    else -> return@onKeyEvent false
                }
                true
            }
            .padding(12.dp),
    ) {
        val shown = frame
        Text(
            "점수 ${game.score}   생명 ${game.lives}",
            color = Color.White,
            fontSize = 18.sp,
            fontWeight = FontWeight.Bold,
        )
        Spacer(Modifier.height(8.dp))
        Canvas(
            Modifier
                .fillMaxWidth()
                .weight(1f)
                .background(Color(0xFF12182B))
                .pointerInput(seed) {
                    detectDragGestures { change, drag ->
                        change.consume()
                        game.playerX = (game.playerX + drag.x / size.width).coerceIn(0.1f, 0.9f)
                        game.playerY = (game.playerY + drag.y / size.height).coerceIn(0.62f, 0.9f)
                    }
                },
        ) {
            if (shown < 0) return@Canvas
            val stripe = 48f
            var mark = -stripe + (game.scroll % stripe)
            while (mark < size.height) {
                drawRect(
                    Color.White.copy(alpha = 0.08f),
                    Offset(size.width * 0.47f, mark),
                    Size(size.width * 0.06f, stripe * 0.4f),
                )
                mark += stripe
            }
            fun at(x: Float, y: Float) = Offset(x * size.width, y * size.height)
            for (mote in game.motes) {
                drawCircle(Color(0xFFFB7185), size.minDimension * 0.035f, at(mote.x, mote.y))
            }
            for (bolt in game.bolts) {
                drawCircle(Color(0xFFFDE68A), size.minDimension * 0.012f, at(bolt.x, bolt.y))
            }
            if (!game.hidden()) {
                drawWalker(at(game.playerX, game.playerY), size.minDimension * 0.055f, game.time)
            }
        }
        Spacer(Modifier.height(8.dp))
        Text(
            if (game.running) "드래그하거나 방향키로 피하고, 총은 걷는 동안 알아서 나갑니다."
            else "세 번 맞았습니다.",
            color = Color.White.copy(alpha = 0.8f),
            fontSize = 14.sp,
        )
        Spacer(Modifier.height(8.dp))
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            SmallButton("다시") { seed += 1 }
        }
    }
}

private fun androidx.compose.ui.graphics.drawscope.DrawScope.drawWalker(center: Offset, radius: Float, time: Float) {
    val bob = sin(time * 10f) * radius * 0.12f
    val swing = sin(time * 10f) * radius * 0.45f
    val body = center + Offset(0f, bob)
    drawLine(Color(0xFFE2E8F0), body + Offset(-radius * 0.25f, radius * 0.55f), body + Offset(-radius * 0.55f + swing, radius * 1.25f), 4f)
    drawLine(Color(0xFFE2E8F0), body + Offset(radius * 0.25f, radius * 0.55f), body + Offset(radius * 0.55f - swing, radius * 1.25f), 4f)
    drawLine(Color(0xFF94A3B8), body + Offset(radius * 0.15f, -radius * 0.05f), body + Offset(radius * 0.15f, -radius * 1.15f), 5f)
    drawCircle(Color(0xFF38BDF8), radius * 0.55f, body)
    drawCircle(Color(0xFFE0F2FE), radius * 0.2f, body)
    val angle = time * 3.2f
    drawCircle(Color(0xFFFDE68A), radius * 0.12f, body + Offset(cos(angle) * radius, sin(angle) * radius * 0.42f))
    drawCircle(Color(0xFFFDBA74), radius * 0.1f, body + Offset(cos(angle + 3.1f) * radius * 0.85f, sin(angle + 3.1f) * radius))
}

@Composable
private fun RowScope.SmallButton(label: String, onClick: () -> Unit) {
    Button(onClick = onClick, modifier = Modifier.weight(1f)) { Text(label) }
}

달러가 붙은 `${game.score}`는 코틀린 문자열 템플릿이다. 붙여 넣을 때 그대로 둔다.

파란 핵과 그 주위를 도는 점이 주인공이다. 다리는 걸음에 맞춰 흔들리고, 오른쪽 선은 위쪽을 향한 총이다. 길 한가운데의 흰 조각이 아래로 움직여 앞으로 걷는 느낌을 만든다. 붉은 원은 적이고, 노란 점은 탄이다.

조작하는 일
드래그발 딛는 위치를 바꿈. 폰과 맥 모두
방향키, W A S D맥에서 이동. 화면을 한 번 클릭해 포커스를 둔다
다시점수와 생명을 처음으로 되돌림

안드로이드에서는 실행 버튼으로 에뮬레이터나 USB 디버깅을 켠 폰을 고른다. 맥에서는 데스크톱 모듈의 run 태스크를 실행한다. 모듈 이름이 composeApp이면 `./gradlew :composeApp:run`, desktopApp이면 `./gradlew :desktopApp:run` 이다.

안드로이드에 올린다

올리는 파일은 APK가 아니라 Android App Bundle이다. 안드로이드 스튜디오에서 Build → Generate Signed App Bundle / APK → Android App Bundle을 고른다. 키 저장소를 새로 만들면 암호와 별칭을 적고, 그 파일은 저장소 밖에 보관한다. 잃어버리면 같은 패키지로 업데이트를 올릴 수 없다.

Play Console에서 앱을 만들 때 패키지 이름은 androidApp 또는 composeApp의 applicationId와 같아야 한다. 처음에는 내부 테스트 트랙에 번들을 올려 기기에 깔아 본다. 공개 전에 스토어 목록, 스크린샷, 콘텐츠 등급, 데이터 안전 설정을 채운다. 이 게임은 계정과 네트워크가 없으므로 수집하는 데이터는 없다. versionCode는 올릴 때마다 1씩 올린다.

스토어 이름과 아이콘에는 기존 만화 주인공의 얼굴과 이름을 쓰지 않는다. 화면에 있는 것은 핵과 전자로 그린 원자다. 앱 이름은 아톰워크처럼 이 게임의 이름으로 둔다.

맥에 올린다

데스크톱 꾸러미는 그 운영체제에서만 만들어진다. dmg는 맥에서 `packageDmg` 태스크로 뽑는다. 리눅스나 윈도우에서 이 태스크를 실행하면 건너뛴다. JDK는 17 이상이어야 jpackage가 동작한다.

개발 중에는 서명 설정을 넣지 않는다. 값이 비어 있으면 실행 자체도 구성 단계에서 실패할 수 있다. 배포할 때만 데스크톱 모듈의 `macOS { }` 안에 다음을 넣는다. 인증서 이름은 키체인 접근에 있는 Developer ID Application과 글자까지 같아야 한다.

macOS {
    bundleID = "kr.thinktree.atomwalk"
    signing {
        sign.set(true)
        identity.set("Developer ID Application: 본인 이름 (TEAMID)")
    }
    notarization {
        appleID.set("개발자에 등록한 Apple ID")
        password.set("@keychain:AC_PASSWORD")
        teamID.set("TEAMID")
    }
}

`AC_PASSWORD`는 앱 암호를 키체인에 넣어 둔 이름이다. 암호를 소스에 적거나 저장소에 커밋하지 않는다. 그다음 맥에서 packageDmg를 실행하고, 이어서 notarizeDmg를 실행한다. 공증이 끝나지 않은 앱은 받은 사람에게 손상된 파일로 보인다. 결과 dmg는 그 모듈의 `build/compose/binaries` 아래에 생긴다.

맥 앱스토어는 dmg와 인증서가 다르다. `appStore = true`로 바꾸고, 서명 신원은 3rd Party Mac Developer Application을 쓴다. 번들 아이디와 같은 Mac App Store 프로비저닝 프로파일을 모듈에 연결한 뒤 packagePkg로 패키지를 만든다. 그 파일을 Transporter로 앱 스토어 커넥트에 올린다. 플러그인이 자바 런타임에 필요한 권한을 넣으므로, 권한 파일을 따로 만들기 전에는 기본값을 유지한다. 스크린샷, 개인정보, 연령 등급은 안드로이드와 같이 채운다.

스토어 설명에는 세로로 걸으며 위쪽으로 쏘는 아케이드라고 적으면 된다. 만화 속 로봇 소년의 이름이나 복장은 넣지 않는다.