안드로이드 스튜디오에서 빈 프로젝트를 만들고, 코틀린과 Jetpack Compose로 블록 게임을 붙이는 순서

조작과 줄 삭제 규칙은 「테트리스 게임 하는 방법」과 같다. 이 글은 그 게임을 안드로이드 스튜디오 프로젝트로 만드는 순서만 다룬다. iPhone용 같은 게임은 「Xcode로 테트리스 만드는 방법」에 있다.

프로젝트를 만든다

안드로이드 스튜디오는 developer.android.com/studio 에서 받아 설치한다. 처음 켜면 SDK 다운로드가 끝날 때까지 기다린다.

File → New → New Project에서 Phone and Tablet의 Empty Activity를 고른다. 이 템플릿은 코틀린과 Jetpack Compose를 이미 붙여 둔다. 따로 라이브러리를 추가하지 않는다.

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

Finish를 누르면 Gradle이 의존성을 받는다. 첫 동기화는 시간이 걸린다. 아래에 오류가 없어질 때까지 기다린 다음 파일을 고친다.

패키지 이름을 다르게 적었다면, 아래 두 파일의 첫 줄 `package`만 그 이름으로 맞춘다. 폴더 경로와 패키지 선언이 다르면 빌드가 실패한다.

첫 화면을 게임으로 바꾼다

`app/src/main/java/kr/thinktree/hanjul/MainActivity.kt`를 연다. `setContent` 안에서 Greeting을 부르던 자리를 TetrisScreen으로 바꾼다. 테마 함수 이름은 프로젝트 이름을 따른다. HanJul이면 HanJulTheme다.

setContent {
    HanJulTheme {
        Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
            TetrisScreen(modifier = Modifier.padding(innerPadding))
        }
    }
}

같은 파일 아래의 Greeting 함수와 Preview는 지운다. 쓰지 않는 import도 에디터가 회색으로 표시하면 지운다.

폰을 돌릴 때 보드가 다시 만들어지지 않게 세로로 고정한다. `app/src/main/AndroidManifest.xml`의 activity 태그에 한 줄을 넣는다.

android:screenOrientation="portrait"

게임 파일을 추가한다

패키지 폴더를 오른쪽 클릭하고 New → Kotlin Class/File에서 TetrisScreen을 만든다. 파일 전체를 아래로 바꾼다.

이 파일은 세 가지를 한다. 10×20 보드와 일곱 가지 블록을 기억하고, 0.7초마다 한 칸씩 내린 뒤, Canvas에 칸을 그린다. 한 번에 지운 줄 수에 따라 점수는 100, 300, 500, 800에 레벨을 곱한다. 열 줄을 지울 때마다 레벨이 오르고 떨어지는 간격이 줄어든다.

package kr.thinktree.hanjul

import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.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.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp

private val shapes: List<List<List<Pair<Int, Int>>>> = listOf(
    listOf(
        listOf(0 to 1, 1 to 1, 2 to 1, 3 to 1),
        listOf(2 to 0, 2 to 1, 2 to 2, 2 to 3),
        listOf(0 to 2, 1 to 2, 2 to 2, 3 to 2),
        listOf(1 to 0, 1 to 1, 1 to 2, 1 to 3),
    ),
    List(4) { listOf(1 to 0, 2 to 0, 1 to 1, 2 to 1) },
    listOf(
        listOf(1 to 0, 0 to 1, 1 to 1, 2 to 1),
        listOf(1 to 0, 1 to 1, 2 to 1, 1 to 2),
        listOf(0 to 1, 1 to 1, 2 to 1, 1 to 2),
        listOf(1 to 0, 0 to 1, 1 to 1, 1 to 2),
    ),
    listOf(
        listOf(1 to 0, 2 to 0, 0 to 1, 1 to 1),
        listOf(1 to 0, 1 to 1, 2 to 1, 2 to 2),
        listOf(1 to 1, 2 to 1, 0 to 2, 1 to 2),
        listOf(0 to 0, 0 to 1, 1 to 1, 1 to 2),
    ),
    listOf(
        listOf(0 to 0, 1 to 0, 1 to 1, 2 to 1),
        listOf(2 to 0, 1 to 1, 2 to 1, 1 to 2),
        listOf(0 to 1, 1 to 1, 1 to 2, 2 to 2),
        listOf(1 to 0, 0 to 1, 1 to 1, 0 to 2),
    ),
    listOf(
        listOf(0 to 0, 0 to 1, 1 to 1, 2 to 1),
        listOf(1 to 0, 2 to 0, 1 to 1, 1 to 2),
        listOf(0 to 1, 1 to 1, 2 to 1, 2 to 2),
        listOf(1 to 0, 1 to 1, 0 to 2, 1 to 2),
    ),
    listOf(
        listOf(2 to 0, 0 to 1, 1 to 1, 2 to 1),
        listOf(1 to 0, 1 to 1, 1 to 2, 2 to 2),
        listOf(0 to 1, 1 to 1, 2 to 1, 0 to 2),
        listOf(0 to 0, 1 to 0, 1 to 1, 1 to 2),
    ),
)

private val palette = listOf(
    Color(0xFF3EC8E8),
    Color(0xFFFFC44D),
    Color(0xFFC084FC),
    Color(0xFF4ADE80),
    Color(0xFFFB7185),
    Color(0xFF60A5FA),
    Color(0xFFFB923C),
)

private data class Piece(val kind: Int, val rot: Int, val x: Int, val y: Int)

private class TetrisGame {
    val cols = 10
    val rows = 20
    val board = Array(rows) { IntArray(cols) }
    var piece = Piece(0, 0, 3, 0)
    var score = 0
    var lines = 0
    var running = true
    private val bag = ArrayDeque<Int>()

    init { spawn() }

    val level: Int get() = 1 + lines / 10
    val interval: Long get() = (700L - (level - 1) * 45L).coerceAtLeast(120L)

    fun left() = slide(-1)
    fun right() = slide(1)
    fun soft() {
        if (!running) return
        if (!slideDown()) lock()
    }

    fun hard() {
        if (!running) return
        var distance = 0
        while (slideDown()) distance++
        score += distance * 2
        lock()
    }

    fun rotate() {
        if (!running) return
        val next = (piece.rot + 1) % 4
        val kicks = listOf(0 to 0, -1 to 0, 1 to 0, -2 to 0, 2 to 0, 0 to -1)
        for ((dx, dy) in kicks) {
            val moved = piece.copy(x = piece.x + dx, y = piece.y + dy, rot = next)
            if (fits(moved)) {
                piece = moved
                return
            }
        }
    }

    fun gravity() {
        if (running && !slideDown()) lock()
    }

    fun ghost(): Piece {
        var dropped = piece
        while (fits(dropped.copy(y = dropped.y + 1))) dropped = dropped.copy(y = dropped.y + 1)
        return dropped
    }

    fun blocks(target: Piece) =
        shapes[target.kind][target.rot].map { it.first + target.x to it.second + target.y }

    private fun slide(dx: Int): Boolean {
        if (!running) return false
        val moved = piece.copy(x = piece.x + dx)
        if (!fits(moved)) return false
        piece = moved
        return true
    }

    private fun slideDown(): Boolean {
        val moved = piece.copy(y = piece.y + 1)
        if (!fits(moved)) return false
        piece = moved
        return true
    }

    private fun lock() {
        for ((x, y) in blocks(piece)) {
            if (y in 0 until rows && x in 0 until cols) board[y][x] = piece.kind + 1
        }
        val cleared = clearLines()
        if (cleared > 0) score += listOf(0, 100, 300, 500, 800)[cleared] * level
        lines += cleared
        spawn()
    }

    private fun clearLines(): Int {
        var cleared = 0
        var y = rows - 1
        while (y >= 0) {
            if (board[y].all { it != 0 }) {
                for (row in y downTo 1) board[row] = board[row - 1].copyOf()
                board[0] = IntArray(cols)
                cleared++
            } else {
                y--
            }
        }
        return cleared
    }

    private fun spawn() {
        piece = Piece(nextKind(), 0, 3, 0)
        if (!fits(piece)) running = false
    }

    private fun nextKind(): Int {
        if (bag.isEmpty()) bag.addAll((0 until 7).shuffled())
        return bag.removeFirst()
    }

    private fun fits(target: Piece) = blocks(target).all { (x, y) ->
        x in 0 until cols && y < rows && (y < 0 || board[y][x] == 0)
    }
}

@Composable
fun TetrisScreen(modifier: Modifier = Modifier) {
    var seed by remember { mutableStateOf(0) }
    var frame by remember(seed) { mutableStateOf(0) }
    val game = remember(seed) { TetrisGame() }
    val focus = remember { FocusRequester() }

    LaunchedEffect(seed) {
        runCatching { focus.requestFocus() }
        while (game.running) {
            kotlinx.coroutines.delay(game.interval)
            game.gravity()
            frame++
        }
    }

    fun redraw(action: () -> Unit) {
        action()
        frame++
    }

    Column(
        modifier
            .fillMaxSize()
            .background(Color(0xFF12151C))
            .focusRequester(focus)
            .focusable()
            .onKeyEvent { event ->
                if (event.type != KeyEventType.KeyDown) return@onKeyEvent false
                when (event.key) {
                    Key.DirectionLeft -> redraw { game.left() }
                    Key.DirectionRight -> redraw { game.right() }
                    Key.DirectionUp -> redraw { game.rotate() }
                    Key.DirectionDown -> redraw { game.soft() }
                    Key.Spacebar -> redraw { game.hard() }
                    else -> return@onKeyEvent false
                }
                true
            }
            .padding(12.dp),
    ) {
        val shown = frame
        Text(
            "점수 ${game.score}   줄 ${game.lines}   레벨 ${game.level}",
            color = Color.White,
            fontSize = 18.sp,
            fontWeight = FontWeight.Bold,
        )
        Spacer(Modifier.height(8.dp))
        Canvas(
            Modifier
                .fillMaxWidth()
                .aspectRatio(0.5f)
                .background(Color(0xFF1B212C)),
        ) {
            if (shown < 0) return@Canvas
            val cell = size.width / game.cols
            for (y in 0 until game.rows) {
                for (x in 0 until game.cols) {
                    val value = game.board[y][x]
                    drawRect(
                        color = if (value == 0) Color.White.copy(alpha = 0.04f) else palette[value - 1],
                        topLeft = Offset(x * cell, y * cell),
                        size = Size(cell - 2f, cell - 2f),
                    )
                }
            }
            val ghost = game.ghost()
            if (game.running && ghost.y != game.piece.y) {
                for ((x, y) in game.blocks(ghost)) {
                    if (y < 0) continue
                    drawRect(
                        palette[game.piece.kind].copy(alpha = 0.25f),
                        topLeft = Offset(x * cell, y * cell),
                        size = Size(cell - 2f, cell - 2f),
                    )
                }
            }
            if (game.running) {
                for ((x, y) in game.blocks(game.piece)) {
                    if (y < 0) continue
                    drawRect(
                        palette[game.piece.kind],
                        topLeft = Offset(x * cell, y * cell),
                        size = Size(cell - 2f, cell - 2f),
                    )
                }
            }
        }
        Spacer(Modifier.height(8.dp))
        if (!game.running) {
            Text("새 블록이 나올 자리가 없습니다.", color = Color.White)
            Spacer(Modifier.height(8.dp))
        }
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            SmallButton("왼쪽") { redraw { game.left() } }
            SmallButton("회전") { redraw { game.rotate() } }
            SmallButton("오른쪽") { redraw { game.right() } }
        }
        Spacer(Modifier.height(8.dp))
        Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            SmallButton("아래") { redraw { game.soft() } }
            SmallButton("바닥") { redraw { game.hard() } }
            SmallButton("다시") { seed++ }
        }
    }
}

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

O 블록의 모양은 네 회전이 같다. `List(4) { ... }`가 그 네 벌을 만든다. 나머지 블록은 회전마다 칸 좌표를 따로 적었다. 보드 값은 0이 빈칸이고, 블록이 고정되면 종류 번호에 1을 더한 값이 남는다.

점수 글자 안의 `${game.score}`처럼 달러 표기는 코틀린의 문자열 템플릿이다. 파일을 붙여 넣을 때 그대로 둔다.

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

Tools → Device Manager → Create Device에서 폰 하나를 고르고, 권장 시스템 이미지를 받은 뒤 Finish를 누른다. 위쪽 실행 버튼으로 그 기기를 고르면 빌드 후 앱이 켜진다.

내 폰에서 보려면 설정 → 휴대전화 정보에서 빌드 번호를 일곱 번 눌러 개발자 옵션을 켜고, USB 디버깅을 허용한 뒤 케이블로 연결한다. 기기 목록에 폰이 보이면 같은 Run 버튼으로 설치된다.

에뮬레이터에서는 키보드도 된다. 화면을 한 번 클릭해 포커스를 둔 다음 아래 키를 쓴다. 버튼과 같은 동작이다.

키하는 일
← →좌우로 한 칸
↑시계 방향 회전. 벽 옆이면 한두 칸을 밀어 본다
↓한 칸 내리기. 더 못 내리면 그 자리에 고정
Space바닥까지 붙인 뒤 바로 고정

다시 버튼은 판을 비우고 새 게임을 시작한다.

빌드가 실패하면 먼저 패키지 첫 줄과 폴더가 같은지 본다. TetrisScreen이 빨갛다면 MainActivity와 같은 디렉터리에 있는지 확인한다. 테마 이름이 빨갛다면 샘플의 HanJulTheme 대신 `ui/theme`에 있는 함수 이름을 쓴다.

스토어에 낼 때

디버그 실행만으로도 게임은 완성이다. Play에 올리려면 Build → Generate Signed App Bundle에서 업로드 키를 만들고 App Bundle을 뽑는다. 스토어 이름과 스크린샷에는 테트리스 상표를 쓰지 않고, 프로젝트 이름처럼 한줄이라는 자기 이름을 쓴다.