diff --git a/README.md b/README.md index 5cea3bc..de9bb21 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ TETR.IO 스타일의 **모바일 실시간 대전 블록 스태커** 웹 게임. 이 문서는 앱 제작을 위한 **최종 빌드 진입점(entry point)** 이다. 모든 설계 결정·수치·에셋의 **정본(source of truth)은 [`TETRIG-최종명세서.md`](./TETRIG-최종명세서.md)** 이며, 구현 시 반드시 명세서와 UX 목업을 함께 열어 작업한다. +> **클라이언트 변경 (2026-08-22 사용자 결정)**: 플레이스토어 출시를 위해 클라이언트를 **네이티브 Android (Kotlin + Jetpack Compose)** 로 제작한다. 소스는 [`android/`](./android). 코어 게임 로직은 UI와 분리된 순수 Kotlin 모듈 [`android/engine`](./android/engine) 에 있고 JUnit 으로 검증한다(SRS 킥테이블·7-bag·T-spin 판정·점수·좌표 등 §4·§13 데이터는 그대로 이식됨). 서버 계획(Cloudflare Workers + D1 + WebSocket)은 변경 없이 유지된다. 초기 웹(HTML) 프로토타입(`src/`, `index.html`)은 검증된 **동작 레퍼런스**로 남겨둔다. + --- ## 무엇을 만드는가 diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..bb2de31 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,8 @@ +.gradle/ +build/ +*/build/ +local.properties +.idea/ +*.iml +captures/ +.cxx/ diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..749d414 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + kotlin("jvm") version "2.0.21" apply false +} diff --git a/android/engine/build.gradle.kts b/android/engine/build.gradle.kts new file mode 100644 index 0000000..93a15b4 --- /dev/null +++ b/android/engine/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") +} + +dependencies { + testImplementation("junit:junit:4.13.2") +} + +tasks.test { + useJUnit() + testLogging { events("passed", "failed", "skipped") } +} diff --git a/android/engine/src/main/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt b/android/engine/src/main/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt new file mode 100644 index 0000000..29e68ed --- /dev/null +++ b/android/engine/src/main/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt @@ -0,0 +1,352 @@ +package kr.tkrmagid.tetrig.engine + +/** + * TETRIG 코어 엔진 (Kotlin 이식) — Phase 1. + * 순수 로직만: Android/렌더/타이머 의존 없음. UI 모듈이 타이밍을 구동한다. + * + * 좌표 규약 (명세서 §4.1): + * - 보드 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 맨 아래. + * - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용. + * - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW. + * + * (웹 레퍼런스 src/engine.js 와 동일 알고리즘·데이터를 Kotlin 으로 이식) + */ + +/** 튜닝 상수 (조정 대상은 여기 한 곳). */ +object Config { + const val BOARD_W = 10 + const val BOARD_H = 40 // 내부 높이 (하단 20행만 표시) + const val VISIBLE_H = 20 + const val NEXT_COUNT = 5 + const val LOCK_DELAY_MS = 500 + const val MAX_LOCK_RESETS = 15 + const val DEFAULT_GRAVITY_MS = 800 // Phase 1 기본 (SPRINT) + const val DAS_MS = 133 + const val ARR_MS = 10 + const val SDF = 20 +} + +val PIECE_TYPES = charArrayOf('I', 'O', 'T', 'S', 'Z', 'J', 'L') + +/** 피스 색 (명세서 §13 v2 확정). 'G' = 가비지. */ +val PIECE_COLORS: Map = mapOf( + 'I' to "#3EC1B6", 'O' to "#F5C531", 'T' to "#A76BF2", 'S' to "#7FCC4C", + 'Z' to "#F25C4C", 'J' to "#4C7DF2", 'L' to "#F2913D", 'G' to "#46464F", +) + +// ── 피스 스폰 셀 (박스-로컬, y 위쪽 증가) + 박스 크기 ── +private data class Spawn(val box: Int, val cells: Array) + +private val SPAWN: Map = mapOf( + 'I' to Spawn(4, arrayOf(intArrayOf(0, 2), intArrayOf(1, 2), intArrayOf(2, 2), intArrayOf(3, 2))), + 'O' to Spawn(2, arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(0, 1), intArrayOf(1, 1))), + 'T' to Spawn(3, arrayOf(intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1), intArrayOf(1, 2))), + 'S' to Spawn(3, arrayOf(intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(1, 2), intArrayOf(2, 2))), + 'Z' to Spawn(3, arrayOf(intArrayOf(0, 2), intArrayOf(1, 2), intArrayOf(1, 1), intArrayOf(2, 1))), + 'J' to Spawn(3, arrayOf(intArrayOf(0, 2), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1))), + 'L' to Spawn(3, arrayOf(intArrayOf(2, 2), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1))), +) + +/** 스폰 시 박스 원점의 보드 위치 (px, py). 셀은 (px+cx, py+cy). */ +private val SPAWN_POS: Map = mapOf( + 'I' to intArrayOf(3, 19), 'O' to intArrayOf(4, 21), 'T' to intArrayOf(3, 20), + 'S' to intArrayOf(3, 20), 'Z' to intArrayOf(3, 20), 'J' to intArrayOf(3, 20), 'L' to intArrayOf(3, 20), +) + +/** CW 회전: 박스 b 에서 (x,y) -> (y, (b-1)-x). */ +private fun rotateCW(cells: Array, b: Int): Array = + Array(cells.size) { i -> intArrayOf(cells[i][1], (b - 1) - cells[i][0]) } + +private fun buildStates(type: Char): Array> { + val sp = SPAWN.getValue(type) + if (type == 'O') return arrayOf(sp.cells, sp.cells, sp.cells, sp.cells) + val s0 = sp.cells + val s1 = rotateCW(s0, sp.box) + val s2 = rotateCW(s1, sp.box) + val s3 = rotateCW(s2, sp.box) + return arrayOf(s0, s1, s2, s3) +} + +/** STATES[type][state] = 셀 목록. */ +val STATES: Map>> = PIECE_TYPES.associateWith { buildStates(it) } + +// ── 킥테이블 (명세서 §4.1) ── +private val KICKS_JLSTZ: Map> = mapOf( + "0>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 1), intArrayOf(0, -2), intArrayOf(-1, -2)), + "1>0" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, -1), intArrayOf(0, 2), intArrayOf(1, 2)), + "1>2" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, -1), intArrayOf(0, 2), intArrayOf(1, 2)), + "2>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 1), intArrayOf(0, -2), intArrayOf(-1, -2)), + "2>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 1), intArrayOf(0, -2), intArrayOf(1, -2)), + "3>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, -1), intArrayOf(0, 2), intArrayOf(-1, 2)), + "3>0" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, -1), intArrayOf(0, 2), intArrayOf(-1, 2)), + "0>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 1), intArrayOf(0, -2), intArrayOf(1, -2)), + // 180° (SRS+) + "0>2" to arrayOf(intArrayOf(0, 0), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(-1, 1), intArrayOf(1, 0), intArrayOf(-1, 0)), + "2>0" to arrayOf(intArrayOf(0, 0), intArrayOf(0, -1), intArrayOf(-1, -1), intArrayOf(1, -1), intArrayOf(-1, 0), intArrayOf(1, 0)), + "1>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 2), intArrayOf(1, 1), intArrayOf(0, 2), intArrayOf(0, 1)), + "3>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 2), intArrayOf(-1, 1), intArrayOf(0, 2), intArrayOf(0, 1)), +) + +private val KICKS_I: Map> = mapOf( + "0>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-2, 0), intArrayOf(1, 0), intArrayOf(-2, -1), intArrayOf(1, 2)), + "1>0" to arrayOf(intArrayOf(0, 0), intArrayOf(2, 0), intArrayOf(-1, 0), intArrayOf(2, 1), intArrayOf(-1, -2)), + "1>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(2, 0), intArrayOf(-1, 2), intArrayOf(2, -1)), + "2>1" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(-2, 0), intArrayOf(1, -2), intArrayOf(-2, 1)), + "2>3" to arrayOf(intArrayOf(0, 0), intArrayOf(2, 0), intArrayOf(-1, 0), intArrayOf(2, 1), intArrayOf(-1, -2)), + "3>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-2, 0), intArrayOf(1, 0), intArrayOf(-2, -1), intArrayOf(1, 2)), + "3>0" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(-2, 0), intArrayOf(1, -2), intArrayOf(-2, 1)), + "0>3" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(2, 0), intArrayOf(-1, 2), intArrayOf(2, -1)), + // I 180°는 (0,0)만 + "0>2" to arrayOf(intArrayOf(0, 0)), "2>0" to arrayOf(intArrayOf(0, 0)), + "1>3" to arrayOf(intArrayOf(0, 0)), "3>1" to arrayOf(intArrayOf(0, 0)), +) + +private val ORIGIN = arrayOf(intArrayOf(0, 0)) + +fun kickTable(type: Char, from: Int, to: Int): Array { + val key = "$from>$to" + return when (type) { + 'O' -> ORIGIN + 'I' -> KICKS_I[key] ?: ORIGIN + else -> KICKS_JLSTZ[key] ?: ORIGIN + } +} + +/** 시드 가능 난수 (mulberry32) — JS 판과 동일 알고리즘. */ +fun makeRng(seed: Int): () -> Double { + var a = seed + return { + a += 0x6D2B79F5 + var t = (a xor (a ushr 15)) * (1 or a) + t = (t + ((t xor (t ushr 7)) * (61 or t))) xor t + ((t xor (t ushr 14)).toLong() and 0xFFFFFFFFL).toDouble() / 4294967296.0 + } +} + +/** 7-bag. */ +class SevenBag(private val rng: () -> Double = { Math.random() }) { + private val q = ArrayDeque() + init { refill(); refill() } + private fun refill() { + val bag = PIECE_TYPES.toMutableList() + for (i in bag.indices.reversed()) { + val j = (rng() * (i + 1)).toInt() + val tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp + } + q.addAll(bag) + } + fun next(): Char { + if (q.size <= Config.NEXT_COUNT + 1) refill() + return q.removeFirst() + } + fun peek(n: Int = Config.NEXT_COUNT): List { + while (q.size < n) refill() + return q.take(n) + } +} + +// ── 보드 유틸 ── +typealias Board = Array> + +fun createBoard(w: Int = Config.BOARD_W, h: Int = Config.BOARD_H): Board = + Array(h) { arrayOfNulls(w) } + +fun pieceCells(type: Char, state: Int, px: Int, py: Int): Array { + val cells = STATES.getValue(type)[state] + return Array(cells.size) { i -> intArrayOf(px + cells[i][0], py + cells[i][1]) } +} + +/** x 범위 밖 / 바닥 아래(y<0) / 점유 = 충돌. y >= H (버퍼 위)는 허용. */ +fun collides(board: Board, cells: Array): Boolean { + val w = board[0].size; val h = board.size + for (c in cells) { + val x = c[0]; val y = c[1] + if (x < 0 || x >= w || y < 0) return true + if (y < h && board[y][x] != null) return true + } + return false +} + +/** 가득 찬 행 제거 후 위에서 빈 행 보충. 제거된 행 수 반환. */ +fun clearLines(board: Board): Int { + val w = board[0].size; val h = board.size + val rows = (0 until h).filter { y -> board[y].all { it != null } } + if (rows.isEmpty()) return 0 + val rowSet = rows.toHashSet() + val kept = ArrayList>((0 until h).filter { it !in rowSet }.map { board[it] }) + while (kept.size < h) kept.add(arrayOfNulls(w)) + for (y in 0 until h) board[y] = kept[y] + return rows.size +} + +fun isBoardEmpty(board: Board): Boolean = board.all { row -> row.all { it == null } } + +/** T-spin 판정 (명세서 §4.2). "none" | "mini" | "tspin". */ +fun detectTSpin(board: Board, type: Char, state: Int, px: Int, py: Int, lastMoveRotation: Boolean): String { + if (type != 'T' || !lastMoveRotation) return "none" + val w = board[0].size; val h = board.size + val cx = px + 1; val cy = py + 1 + fun filled(dx: Int, dy: Int): Boolean { + val x = cx + dx; val y = cy + dy + if (x < 0 || x >= w || y < 0) return true // 벽/바닥 = 채움 + if (y >= h) return false // 버퍼 위 = 빔 + return board[y][x] != null + } + val tl = filled(-1, 1); val tr = filled(1, 1); val bl = filled(-1, -1); val br = filled(1, -1) + val count = listOf(tl, tr, bl, br).count { it } + if (count < 3) return "none" + val front = when (state) { + 0 -> Pair(tl, tr) + 1 -> Pair(tr, br) + 2 -> Pair(bl, br) + else -> Pair(tl, bl) + } + return if (front.first && front.second) "tspin" else "mini" +} + +/** B2B 대상("어려운" 클리어): Quad 또는 라인 동반 T-spin 계열. */ +fun isDifficult(lines: Int, tspin: String): Boolean = + lines == 4 || (tspin != "none" && lines >= 1) + +data class LockResult( + val type: Char, val lines: Int, val tspin: String, val allClear: Boolean, + val combo: Int, val b2b: Int, val difficult: Boolean, +) + +class Active(var type: Char, var state: Int, var x: Int, var y: Int) + +/** 게임 상태 (UI 가 타이밍을 구동). */ +class TetrigGame(seed: Int = 1) { + var board: Board = createBoard() + private set + private val bag = SevenBag(makeRng(seed)) + var hold: Char? = null + private set + var holdUsed = false + private set + var over = false + private set + var lastMoveRotation = false + var lockResets = 0 + var combo = -1 + private set + var b2b = -1 + private set + var lastClear: LockResult? = null + private set + lateinit var active: Active + private set + + init { spawn() } + + fun cells(a: Active = active): Array = pieceCells(a.type, a.state, a.x, a.y) + + fun spawn(type: Char = bag.next()): Boolean { + val pos = SPAWN_POS.getValue(type) + active = Active(type, 0, pos[0], pos[1]) + lastMoveRotation = false + lockResets = 0 + if (collides(board, cells())) { over = true; return false } + return true + } + + fun peekNext(n: Int = Config.NEXT_COUNT): List = bag.peek(n) + + fun canFall(): Boolean { + val c = cells().map { intArrayOf(it[0], it[1] - 1) }.toTypedArray() + return !collides(board, c) + } + + fun resetLockDelay(): Boolean { + if (lockResets >= Config.MAX_LOCK_RESETS) return false + lockResets++ + return true + } + + fun move(dx: Int): Boolean { + if (over) return false + val c = pieceCells(active.type, active.state, active.x + dx, active.y) + if (collides(board, c)) return false + active.x += dx + lastMoveRotation = false + return true + } + + fun softDrop(): Boolean { + if (over) return false + val c = pieceCells(active.type, active.state, active.x, active.y - 1) + if (collides(board, c)) return false + active.y -= 1 + lastMoveRotation = false + return true + } + + /** dir: "cw" | "ccw" | "180". */ + fun rotate(dir: String): Boolean { + if (over) return false + val a = active + val to = when (dir) { + "cw" -> (a.state + 1) % 4 + "ccw" -> (a.state + 3) % 4 + else -> (a.state + 2) % 4 + } + for (k in kickTable(a.type, a.state, to)) { + val nx = a.x + k[0]; val ny = a.y + k[1] + if (!collides(board, pieceCells(a.type, to, nx, ny))) { + a.state = to; a.x = nx; a.y = ny + lastMoveRotation = true + return true + } + } + return false + } + + fun ghostCells(): Array { + val a = active + var dy = 0 + while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - dy - 1))) dy++ + return pieceCells(a.type, a.state, a.x, a.y - dy) + } + + fun hardDrop(): LockResult? { + if (over) return null + val a = active + var d = 0 + while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - d - 1))) d++ + a.y -= d + return lock() + } + + fun holdSwap(): Boolean { + if (over || holdUsed) return false + val cur = active.type + if (hold == null) { + hold = cur + spawn() + } else { + val swap = hold!! + hold = cur + spawn(swap) + } + holdUsed = true + return true + } + + fun lock(): LockResult { + val a = active + val tspin = detectTSpin(board, a.type, a.state, a.x, a.y, lastMoveRotation) + for (c in cells()) { + if (c[1] in board.indices) board[c[1]][c[0]] = a.type + } + val cleared = clearLines(board) + val allClear = cleared > 0 && isBoardEmpty(board) + combo = if (cleared > 0) combo + 1 else -1 + val diff = isDifficult(cleared, tspin) + if (cleared > 0) b2b = if (diff) b2b + 1 else -1 + val result = LockResult(a.type, cleared, tspin, allClear, combo, b2b, diff) + lastClear = result + holdUsed = false + spawn() + return result + } +} diff --git a/android/engine/src/test/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt b/android/engine/src/test/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt new file mode 100644 index 0000000..60e9cf0 --- /dev/null +++ b/android/engine/src/test/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt @@ -0,0 +1,204 @@ +package kr.tkrmagid.tetrig.engine + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EngineTest { + + private fun cellSet(cells: Array): Set = + cells.map { "${it[0]},${it[1]}" }.toSet() + + // ── 피스 상태/회전 ── + @Test fun tSpawnCells() { + assertEquals(setOf("0,1", "1,1", "2,1", "1,2"), cellSet(STATES.getValue('T')[0])) + } + + @Test fun tCwState() { + // 오른쪽-포인팅: 세로줄 x=1 + (2,1) + assertEquals(setOf("1,0", "1,1", "1,2", "2,1"), cellSet(STATES.getValue('T')[1])) + } + + @Test fun iCwState() { + assertEquals(setOf("2,0", "2,1", "2,2", "2,3"), cellSet(STATES.getValue('I')[1])) + } + + @Test fun oNoRotation() { + val s0 = cellSet(STATES.getValue('O')[0]) + assertEquals(s0, cellSet(STATES.getValue('O')[1])) + assertEquals(s0, cellSet(STATES.getValue('O')[3])) + } + + @Test fun allPiecesHaveFourStates() { + for (t in PIECE_TYPES) assertEquals(4, STATES.getValue(t).size) + } + + // ── 킥테이블 ── + @Test fun kickTablesMatchSpec() { + assertEquals("0,0 -1,0 -1,1 0,-2 -1,-2", kickStr(kickTable('T', 0, 1))) + assertEquals("0,0 -2,0 1,0 -2,-1 1,2", kickStr(kickTable('I', 0, 1))) + assertEquals("0,0 0,1 1,1 -1,1 1,0 -1,0", kickStr(kickTable('T', 0, 2))) // 180 + assertEquals("0,0", kickStr(kickTable('I', 0, 2))) // I 180 + assertEquals("0,0", kickStr(kickTable('O', 0, 1))) + } + + private fun kickStr(k: Array) = k.joinToString(" ") { "${it[0]},${it[1]}" } + + @Test fun wallKickUsesOffsetWhenBlocked() { + val g = TetrigGame(5) + // 엔진의 board 를 비우고, active 를 직접 배치할 수 없으니 collides 로 시나리오만 확인 후 + // 실제 회전 킥은 별도 보드로 검증 + val board = createBoard() + // (0,0) 회전(state1) 셀 중 (5,5)를 막고, (-1,0) 킥으로 성사되는지 검증 + board[5][5] = 'X' + // state1 셀 at (4,5): pieceCells('T',1,4,5) + val blocked = collides(board, pieceCells('T', 1, 4, 5)) + assertTrue(blocked) + val kicked = collides(board, pieceCells('T', 1, 3, 5)) // (-1,0) + assertFalse(kicked) + } + + // ── 7-bag ── + @Test fun sevenBagContainsAllEachRound() { + val bag = SevenBag(makeRng(42)) + repeat(20) { + val seven = (0 until 7).map { bag.next() }.sorted() + assertEquals(PIECE_TYPES.toList().sorted(), seven) + } + } + + @Test fun peekMatchesConsumeOrder() { + val bag = SevenBag(makeRng(7)) + val peek = bag.peek(5) + assertEquals(5, peek.size) + for (i in 0 until 5) assertEquals(peek[i], bag.next()) + } + + @Test fun sameSeedSameSequence() { + val a = SevenBag(makeRng(123)); val b = SevenBag(makeRng(123)) + repeat(30) { assertEquals(a.next(), b.next()) } + } + + // ── 충돌/클리어 ── + @Test fun collidesWallsFloorOccupied() { + val bd = createBoard() + assertTrue(collides(bd, arrayOf(intArrayOf(-1, 0)))) + assertTrue(collides(bd, arrayOf(intArrayOf(10, 0)))) + assertTrue(collides(bd, arrayOf(intArrayOf(0, -1)))) + assertFalse(collides(bd, arrayOf(intArrayOf(5, 45)))) + bd[3][4] = 'T' + assertTrue(collides(bd, arrayOf(intArrayOf(4, 3)))) + } + + @Test fun clearLinesRemovesFullAndDrops() { + val bd = createBoard() + for (x in 0 until 10) bd[0][x] = 'I' + bd[1][0] = 'T' + assertEquals(1, clearLines(bd)) + assertEquals('T', bd[0][0]) + for (x in 1 until 10) assertEquals(null, bd[0][x]) + } + + @Test fun boardEmpty() { + val bd = createBoard() + assertTrue(isBoardEmpty(bd)) + bd[0][0] = 'I' + assertFalse(isBoardEmpty(bd)) + } + + // ── T-spin 판정 ── + @Test fun tspinFull() { + val bd = createBoard(10, 6) + bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X' + assertEquals("tspin", detectTSpin(bd, 'T', 2, 4, 1, true)) + } + + @Test fun tspinMini() { + val bd = createBoard(10, 6) + bd[1][4] = 'X'; bd[3][4] = 'X'; bd[3][6] = 'X' + assertEquals("mini", detectTSpin(bd, 'T', 2, 4, 1, true)) + } + + @Test fun tspinNoneTooFewCorners() { + val bd = createBoard(10, 6) + bd[1][4] = 'X' + assertEquals("none", detectTSpin(bd, 'T', 2, 4, 1, true)) + } + + @Test fun tspinNoneWhenNotRotation() { + val bd = createBoard(10, 6) + bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X' + assertEquals("none", detectTSpin(bd, 'T', 2, 4, 1, false)) + } + + @Test fun tspinFloorCountsAsFilled() { + val bd = createBoard(10, 6) + assertEquals("none", detectTSpin(bd, 'T', 2, 4, -1, true)) // 전면 2 + 뒤 0 = 2 + bd[1][4] = 'X' + assertEquals("tspin", detectTSpin(bd, 'T', 2, 4, -1, true)) // 3 + 전면 완전 + } + + @Test fun difficultRule() { + assertTrue(isDifficult(4, "none")) + assertTrue(isDifficult(2, "tspin")) + assertTrue(isDifficult(1, "mini")) + assertFalse(isDifficult(3, "none")) + assertFalse(isDifficult(0, "tspin")) + } + + // ── 게임 흐름 ── + @Test fun lockResetCappedAt15() { + val g = TetrigGame(1) + var ok = 0 + repeat(25) { if (g.resetLockDelay()) ok++ } + assertEquals(Config.MAX_LOCK_RESETS, ok) + } + + @Test fun spawnMoveHardDropCycle() { + val g = TetrigGame(3) + assertFalse(g.over) + val before = g.active.type + val res = g.hardDrop()!! + assertEquals(before, res.type) + assertNotNull(g.active) + assertFalse(g.holdUsed) + } + + @Test fun holdOncePerPiece() { + val g = TetrigGame(9) + val first = g.active.type + assertTrue(g.holdSwap()) + assertEquals(first, g.hold) + assertFalse(g.holdSwap()) + g.hardDrop() + assertFalse(g.holdUsed) + assertTrue(g.holdSwap()) + } + + @Test fun comboAndB2b() { + val g = TetrigGame(2) + fun quad(): LockResult { + for (y in 0 until 4) for (x in 1 until 10) g.board[y][x] = 'X' + // I 세로(state1)를 col0에 정렬: x=-2 → 셀 x=2-2=0 + val a = g.active + a.type = 'I'; a.state = 1; a.x = -2; a.y = 0 + g.lastMoveRotation = false + return g.hardDrop()!! + } + val r1 = quad() + assertEquals(4, r1.lines) + assertEquals(0, r1.b2b) + val r2 = quad() + assertEquals(4, r2.lines) + assertEquals(1, r2.b2b) + } + + @Test fun topOut() { + val g = TetrigGame(1) + for (y in 18 until 24) for (x in 0 until 10) g.board[y][x] = 'X' + assertFalse(g.spawn('T')) + assertTrue(g.over) + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..b430c7c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx1536m +org.gradle.caching=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..e2847c8 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 0000000..4ab1901 --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + } +} +rootProject.name = "tetrig" +include(":engine") +// :app (Jetpack Compose UI) 모듈은 UI 단계에서 추가한다.