Kotlin Multiplatform 네이티브 앱으로 전환 (Android + iOS 공용 코드)

TWA 래퍼를 걷어내고 Compose Multiplatform 으로 다시 만들었다. 엔진·UI·모드
전부 commonMain 한 벌이라 Android 와 iOS 가 같은 코드를 쓴다.

- engine/: 웹 JS 엔진을 Kotlin 으로 이식 (SRS+180 킥, 7-bag, T-spin, B2B,
  콤보, All Clear, 점수·레벨). commonTest 28개 통과
- game/: 모드 규칙 + 중력/락딜레이/DAS/ARR 타이밍
- ui/: 네오브루탈 컴포넌트, 메인/솔로시트/인게임/결과/설정
- ui/Responsive.kt: 고정 해상도를 버리고 가용 크기에서 셀 크기·배율 계산.
  폰 세로, 플립 초세로, 플립 커버(소형), 폴드 펼침, 태블릿/아이패드 가로 대응
- androidMain: resizeableActivity + configChanges 로 접기/펴기·회전 시 재시작 없음
- iosMain + iosApp/: MainViewController + SwiftUI 껍데기 + XcodeGen 설정
  (실제 iOS 빌드는 macOS + Xcode 필요)
- scripts/release.sh: 버전 상승 → 테스트 → 서명 빌드 → 태그 → Gitea 릴리스 자동화
This commit is contained in:
2026-09-23 10:37:17 +09:00
parent 0839705b97
commit cbd0f62319
87 changed files with 2706 additions and 783 deletions

View File

@@ -0,0 +1,109 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
}
// ── 버전 관리 ────────────────────────────────────────────────────
// 기본값은 gradle.properties. CI/릴리스 스크립트는
// ./gradlew assembleRelease -PappVersionName=1.2.0 -PappVersionCode=7
// 처럼 덮어쓴다.
val appVersionName: String = (findProperty("appVersionName") as String?) ?: "0.0.1"
val appVersionCode: Int = ((findProperty("appVersionCode") as String?) ?: "1").toInt()
kotlin {
androidTarget {
@OptIn(ExperimentalKotlinGradlePluginApi::class)
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
// iOS 타깃 — 실제 컴파일은 macOS + Xcode 에서만 가능하다.
// 리눅스에서는 설정만 되고 빌드 태스크를 실행하지 않는다.
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
sourceSets {
commonMain.dependencies {
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(compose.components.resources)
implementation(libs.androidx.lifecycle.viewmodel)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
androidMain.dependencies {
implementation(compose.preview)
implementation(libs.androidx.activity.compose)
}
}
}
android {
namespace = "kr.tkrmagid.tetrig"
compileSdk = 36
defaultConfig {
applicationId = "kr.tkrmagid.tetrig"
minSdk = 24
targetSdk = 36
versionCode = appVersionCode
versionName = appVersionName
}
sourceSets["main"].manifest.srcFile("src/androidMain/AndroidManifest.xml")
sourceSets["main"].res.srcDirs("src/androidMain/res")
// keystore.properties 가 있으면 릴리스 서명을 붙인다. 없으면 unsigned.
val keystorePropsFile = rootProject.file("keystore.properties")
val keystoreProps = Properties().apply {
if (keystorePropsFile.exists()) keystorePropsFile.inputStream().use { load(it) }
}
if (keystoreProps.isNotEmpty()) {
signingConfigs {
create("release") {
storeFile = file(keystoreProps.getProperty("storeFile"))
storePassword = keystoreProps.getProperty("storePassword")
keyAlias = keystoreProps.getProperty("keyAlias")
keyPassword = keystoreProps.getProperty("keyPassword")
}
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
if (keystoreProps.isNotEmpty()) {
signingConfig = signingConfigs.getByName("release")
}
}
getByName("debug") {
applicationIdSuffix = ".debug"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
packaging {
resources.excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
dependencies {
debugImplementation(compose.uiTooling)
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 폴드/플립/태블릿 대응: 화면 크기가 자유롭게 바뀌어도 액티비티를 재시작하지 않는다. -->
<supports-screens
android:anyDensity="true"
android:largeScreens="true"
android:normalScreens="true"
android:resizeable="true"
android:smallScreens="true"
android:xlargeScreens="true" />
<application
android:name=".TetrigApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Tetrig">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden|density|uiMode|layoutDirection|navigation"
android:exported="true"
android:launchMode="singleTop"
android:resizeableActivity="true"
android:theme="@style/Theme.Tetrig"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,25 @@
package kr.tkrmagid.tetrig
import android.app.Application
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import kr.tkrmagid.tetrig.data.AndroidStoreHolder
import kr.tkrmagid.tetrig.ui.App
class TetrigApp : Application() {
override fun onCreate() {
super.onCreate()
AndroidStoreHolder.init(this)
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
AndroidStoreHolder.init(this)
setContent { App() }
}
}

View File

@@ -0,0 +1,27 @@
package kr.tkrmagid.tetrig.data
import android.annotation.SuppressLint
import android.content.Context
import android.content.SharedPreferences
@SuppressLint("StaticFieldLeak")
internal object AndroidStoreHolder {
var prefs: SharedPreferences? = null
fun init(context: Context) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences("tetrig", Context.MODE_PRIVATE)
}
}
}
private class AndroidStore : Store {
override fun getString(key: String, default: String): String =
AndroidStoreHolder.prefs?.getString(key, default) ?: default
override fun putString(key: String, value: String) {
AndroidStoreHolder.prefs?.edit()?.putString(key, value)?.apply()
}
}
actual fun platformStore(): Store = AndroidStore()

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ink" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ink" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tetrig" parent="android:Theme.Material.NoActionBar">
<item name="android:windowBackground">@color/ink</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ink">#17161B</color>
<color name="bone">#EDE7DA</color>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TETRIG</string>
</resources>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tetrig" parent="android:Theme.Material.Light.NoActionBar">
<item name="android:windowBackground">@color/bone</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
</resources>

View File

@@ -0,0 +1,45 @@
package kr.tkrmagid.tetrig.data
import androidx.compose.runtime.mutableStateMapOf
/** 플랫폼 키-값 저장소 (Android SharedPreferences / iOS NSUserDefaults). */
interface Store {
fun getString(key: String, default: String): String
fun putString(key: String, value: String)
}
expect fun platformStore(): Store
/** 기록과 설정. 화면에서 바로 관찰할 수 있게 Compose 상태로 미러링한다. */
object Prefs {
private val store by lazy { platformStore() }
private val cache = mutableStateMapOf<String, String>()
private fun read(key: String, default: String): String =
cache.getOrPut(key) { store.getString(key, default) }
private fun write(key: String, value: String) {
cache[key] = value
store.putString(key, value)
}
// ── 설정 ──
fun flag(key: String, default: Boolean): Boolean = read("flag.$key", if (default) "1" else "0") == "1"
fun setFlag(key: String, value: Boolean) = write("flag.$key", if (value) "1" else "0")
// ── 기록 ──
/** SPRINT: 최소 시간(ms). 미달성이면 0. */
fun bestSprintMs(): Long = read("rec.sprint", "0").toLongOrNull() ?: 0L
/** TIME ATTACK / ENDLESS: 최고 점수. */
fun bestScore(modeId: String): Int = read("rec.score.$modeId", "0").toIntOrNull() ?: 0
fun submitSprint(ms: Long) {
val cur = bestSprintMs()
if (cur == 0L || ms < cur) write("rec.sprint", ms.toString())
}
fun submitScore(modeId: String, score: Int) {
if (score > bestScore(modeId)) write("rec.score.$modeId", score.toString())
}
}

View File

@@ -0,0 +1,404 @@
package kr.tkrmagid.tetrig.engine
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.random.Random
/**
* TETRIG 코어 엔진 — 플랫폼 독립(commonMain). Android/iOS 공용.
*
* 좌표 규약 (명세서 §4.1):
* - 보드 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 맨 아래.
* - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용.
* - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW.
*/
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
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')
/** 피스 색 ARGB (명세서 §13). 'G' = 가비지. */
val PIECE_COLORS: Map<Char, Long> = mapOf(
'I' to 0xFF3EC1B6, 'O' to 0xFFF5C531, 'T' to 0xFFA76BF2, 'S' to 0xFF7FCC4C,
'Z' to 0xFFF25C4C, 'J' to 0xFF4C7DF2, 'L' to 0xFFF2913D, 'G' to 0xFF46464F,
)
private class Spawn(val box: Int, val cells: Array<IntArray>)
private val SPAWN: Map<Char, Spawn> = 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<Char, IntArray> = 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<IntArray>, b: Int): Array<IntArray> =
Array(cells.size) { i -> intArrayOf(cells[i][1], (b - 1) - cells[i][0]) }
private fun buildStates(type: Char): Array<Array<IntArray>> {
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<Char, Array<Array<IntArray>>> = PIECE_TYPES.associateWith { buildStates(it) }
// ── 킥테이블 (명세서 §4.1) ──
private val KICKS_JLSTZ: Map<String, Array<IntArray>> = 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<String, Array<IntArray>> = 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<IntArray> {
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 = { Random.nextDouble() }) {
private val q = ArrayDeque<Char>()
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<Char> {
while (q.size < n) refill()
return q.take(n)
}
}
typealias Board = Array<Array<Char?>>
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<IntArray> {
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<IntArray>): 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<Array<Char?>>((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 -> tl to tr
1 -> tr to br
2 -> bl to br
else -> tl to 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)
// ── 점수·레벨 (명세서 §5) ──
/** 레벨: 10라인 = 1레벨. */
fun levelForLines(lines: Int): Int = lines / 10 + 1
/** 낙하간격(ms) = 0.8^(level-1) × 1000, 하한 50ms. */
fun gravityMsForLevel(level: Int): Int =
maxOf((0.8.pow(level - 1) * 1000).roundToInt(), 50)
/** 라인 클리어 1회 점수 (×레벨 적용). */
fun scoreClear(res: LockResult, level: Int = 1): Int {
val lines = res.lines
var base = when {
res.tspin == "tspin" -> intArrayOf(0, 800, 1200, 1600).getOrElse(lines) { 0 }
res.tspin == "mini" -> if (lines >= 1) 200 else 100
else -> intArrayOf(0, 100, 300, 500, 800).getOrElse(lines) { 0 }
}
if (res.b2b >= 1 && isDifficult(lines, res.tspin)) base = (base * 1.5).roundToInt()
var score = base * level
if (lines > 0 && res.combo >= 1) score += 50 * res.combo * level
if (res.allClear) {
var ac = intArrayOf(0, 800, 1200, 1800, 2000).getOrElse(lines) { 0 }
if (lines == 4 && res.b2b >= 1) ac = 3200
score += ac * level
}
return score
}
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<IntArray> = 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<Char> = 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<IntArray> {
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(): Pair<Int, 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 d to 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
}
}

View File

@@ -0,0 +1,236 @@
package kr.tkrmagid.tetrig.game
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import kr.tkrmagid.tetrig.engine.Config
import kr.tkrmagid.tetrig.engine.LockResult
import kr.tkrmagid.tetrig.engine.TetrigGame
import kr.tkrmagid.tetrig.engine.gravityMsForLevel
import kr.tkrmagid.tetrig.engine.levelForLines
import kr.tkrmagid.tetrig.engine.scoreClear
enum class Mode(val id: String, val title: String, val desc: String) {
Sprint("sprint", "SPRINT", "40줄 최속 클리어"),
TimeAttack("time", "TIME ATTACK", "2분 점수 어택 · 레벨 가속"),
Endless("endless", "ENDLESS", "무한 · 톱아웃해도 계속"),
}
const val SPRINT_GOAL_LINES = 40
const val TIME_ATTACK_MS = 120_000L
/**
* 모드 규칙 + 타이밍(중력/락딜레이/DAS/ARR)을 담당하는 상태 홀더.
* 엔진은 순수 로직이고, 시간은 전부 여기서 구동한다.
*/
class GameState(val mode: Mode, seed: Int) {
var game = TetrigGame(seed)
private set
var score by mutableIntStateOf(0)
private set
var lines by mutableIntStateOf(0)
private set
var level by mutableIntStateOf(1)
private set
var elapsedMs by mutableStateOf(0L)
private set
var piecesPlaced by mutableIntStateOf(0)
private set
var finished by mutableStateOf(false)
private set
var paused by mutableStateOf(false)
var lastEvent by mutableStateOf<String?>(null)
private set
/** UI 리컴포지션 트리거. 엔진 내부는 가변이라 버전 번호로 갱신을 알린다. */
var version by mutableIntStateOf(0)
private set
private var gravityAcc = 0L
private var lockAcc = 0L
private var dasAcc = 0L
private var arrAcc = 0L
private var softAcc = 0L
private var eventAcc = 0L
var heldDir = 0 // -1 왼쪽, 1 오른쪽, 0 없음
var softHeld = false
val remainingMs: Long get() = (TIME_ATTACK_MS - elapsedMs).coerceAtLeast(0L)
val pps: Double get() = if (elapsedMs <= 0) 0.0 else piecesPlaced * 1000.0 / elapsedMs
private fun bump() {
version++
}
private fun gravityMs(): Int = when (mode) {
Mode.Sprint -> Config.DEFAULT_GRAVITY_MS
else -> gravityMsForLevel(level)
}
fun tick(dtMs: Long) {
if (finished || paused) return
elapsedMs += dtMs
if (mode == Mode.TimeAttack && elapsedMs >= TIME_ATTACK_MS) {
elapsedMs = TIME_ATTACK_MS
finished = true
bump()
return
}
lastEvent?.let {
eventAcc += dtMs
if (eventAcc > 1200) {
lastEvent = null
eventAcc = 0
}
}
// DAS / ARR
if (heldDir != 0) {
dasAcc += dtMs
if (dasAcc >= Config.DAS_MS) {
arrAcc += dtMs
while (arrAcc >= Config.ARR_MS) {
arrAcc -= Config.ARR_MS
if (game.move(heldDir)) onMoveSucceeded() else break
}
}
}
// 소프트드롭
if (softHeld) {
softAcc += dtMs * Config.SDF
val step = gravityMs()
while (softAcc >= step) {
softAcc -= step
if (game.softDrop()) {
score += 1
onMoveSucceeded()
} else break
}
}
// 중력
if (game.canFall()) {
gravityAcc += dtMs
val step = gravityMs()
while (gravityAcc >= step) {
gravityAcc -= step
if (!game.softDrop()) break
}
lockAcc = 0
bump()
} else {
lockAcc += dtMs
if (lockAcc >= Config.LOCK_DELAY_MS) {
applyLock(game.lock())
}
}
}
private fun onMoveSucceeded() {
if (!game.canFall()) {
if (game.resetLockDelay()) lockAcc = 0
}
bump()
}
fun press(dir: Int) {
if (finished || paused) return
heldDir = dir
dasAcc = 0
arrAcc = 0
if (game.move(dir)) onMoveSucceeded()
}
fun release(dir: Int) {
if (heldDir == dir) heldDir = 0
}
fun setSoft(down: Boolean) {
softHeld = down
if (!down) softAcc = 0
}
fun rotate(dir: String) {
if (finished || paused) return
if (game.rotate(dir)) onMoveSucceeded()
}
fun hold() {
if (finished || paused) return
if (game.holdSwap()) {
gravityAcc = 0
lockAcc = 0
bump()
}
}
fun hardDrop() {
if (finished || paused) return
val r = game.hardDrop() ?: return
score += r.first * 2
applyLock(r.second)
}
private fun applyLock(res: LockResult) {
piecesPlaced++
gravityAcc = 0
lockAcc = 0
if (res.lines > 0) {
score += scoreClear(res, level)
lines += res.lines
level = levelForLines(lines)
lastEvent = describe(res)
eventAcc = 0
}
when (mode) {
Mode.Sprint -> if (lines >= SPRINT_GOAL_LINES) finished = true
Mode.TimeAttack -> Unit
Mode.Endless -> Unit
}
if (game.over) {
if (mode == Mode.Endless) {
// 톱아웃해도 계속: 보드를 비우고 이어간다.
val keptScore = score
val keptLines = lines
game = TetrigGame((elapsedMs.toInt() xor score) or 1)
score = keptScore
lines = keptLines
lastEvent = "TOP OUT · 계속"
eventAcc = 0
} else {
finished = true
}
}
bump()
}
private fun describe(res: LockResult): String {
val name = when {
res.tspin == "tspin" -> "T-SPIN " + countName(res.lines)
res.tspin == "mini" -> "T-SPIN MINI"
res.lines == 4 -> "QUAD"
else -> countName(res.lines)
}
val parts = mutableListOf(name)
if (res.b2b >= 1) parts += "B2B×${res.b2b + 1}"
if (res.combo >= 1) parts += "COMBO ${res.combo}"
if (res.allClear) parts += "ALL CLEAR"
return parts.joinToString(" · ")
}
private fun countName(n: Int) = when (n) {
1 -> "SINGLE"
2 -> "DOUBLE"
3 -> "TRIPLE"
4 -> "QUAD"
else -> ""
}
}

View File

@@ -0,0 +1,75 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier
import kr.tkrmagid.tetrig.game.Mode
val LocalLayout = staticCompositionLocalOf { layoutFor(androidx.compose.ui.unit.Dp(390f), androidx.compose.ui.unit.Dp(844f)) }
sealed interface Screen {
data object Main : Screen
data object Settings : Screen
data class Soon(val title: String) : Screen
data class Game(val mode: Mode, val seed: Int) : Screen
data class Result(val mode: Mode, val score: Int, val lines: Int, val timeMs: Long, val pps: Double) : Screen
}
@Composable
fun App() {
var screen by remember { mutableStateOf<Screen>(Screen.Main) }
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
.background(if (screen is Screen.Game) Palette.Ink else Palette.Bone)
) {
val layout = remember(maxWidth, maxHeight) { layoutFor(maxWidth, maxHeight) }
CompositionLocalProvider(LocalLayout provides layout) {
Box(Modifier.fillMaxSize().safeDrawingPadding()) {
when (val s = screen) {
is Screen.Main -> MainScreen(
onMode = { screen = Screen.Game(it, randomSeed()) },
onSettings = { screen = Screen.Settings },
onSoon = { screen = Screen.Soon(it) },
)
is Screen.Settings -> SettingsScreen(onBack = { screen = Screen.Main })
is Screen.Soon -> SoonScreen(s.title, onBack = { screen = Screen.Main })
is Screen.Game -> GameScreen(
mode = s.mode,
seed = s.seed,
onExit = { screen = Screen.Main },
onFinish = { score, lines, timeMs, pps ->
screen = Screen.Result(s.mode, score, lines, timeMs, pps)
},
)
is Screen.Result -> ResultScreen(
mode = s.mode,
score = s.score,
lines = s.lines,
timeMs = s.timeMs,
pps = s.pps,
onRetry = { screen = Screen.Game(s.mode, randomSeed()) },
onHome = { screen = Screen.Main },
)
}
}
}
}
}
private fun randomSeed(): Int = kotlin.random.Random.nextInt(1, Int.MAX_VALUE)

View File

@@ -0,0 +1,148 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.GenericShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
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.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
private fun pad2(v: Int): String = if (v < 10) "0$v" else "$v"
/** mm:ss.cc (1분 미만이면 ss.cc). */
fun formatMs(ms: Long): String {
val t = ms.coerceAtLeast(0)
val m = (t / 60_000L).toInt()
val s = ((t % 60_000L) / 1000L).toInt()
val cs = ((t % 1000L) / 10L).toInt()
return if (m > 0) "$m:${pad2(s)}.${pad2(cs)}" else "$s.${pad2(cs)}"
}
/** 우하단 모서리를 잘라낸 네오브루탈 패널 모양. */
fun cutCornerShape(cut: Dp) = GenericShape { size: Size, _ ->
val c = cut.value * 2.6f
moveTo(0f, 0f)
lineTo(size.width, 0f)
lineTo(size.width, size.height - c)
lineTo(size.width - c, size.height)
lineTo(0f, size.height)
close()
}
@Composable
fun NeoPanel(
modifier: Modifier = Modifier,
background: Color = Palette.Bone,
border: Color = Palette.Ink,
shadow: Boolean = true,
onClick: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
val shape = cutCornerShape(5.dp)
Box(
modifier = modifier
.then(
if (shadow) Modifier.drawBehind {
val o = 4.dp.toPx()
drawRect(border, topLeft = Offset(o, o), size = size)
} else Modifier
)
.clip(shape)
.background(background)
.border(2.dp, border, shape)
.then(
if (onClick != null) Modifier.pointerInput(onClick) {
detectTapGestures(onTap = { onClick() })
} else Modifier
),
content = content,
)
}
@Composable
fun Mono(
text: String,
modifier: Modifier = Modifier,
color: Color = Palette.Ink,
size: TextUnit = 13.sp,
weight: FontWeight = FontWeight.Normal,
italic: Boolean = false,
align: TextAlign = TextAlign.Start,
maxLines: Int = 1,
) {
Text(
text = text,
modifier = modifier,
color = color,
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = size,
fontWeight = weight,
fontStyle = if (italic) FontStyle.Italic else FontStyle.Normal,
textAlign = align,
),
maxLines = maxLines,
)
}
/** 제목용 — 굵은 이탤릭 (명세서 §13 disp 스타일). */
@Composable
fun Disp(
text: String,
modifier: Modifier = Modifier,
color: Color = Palette.Ink,
size: TextUnit = 24.sp,
) = Mono(text, modifier, color, size, FontWeight.Black, italic = true)
/** 누름 상태를 알려주는 조작 버튼. 게임 컨트롤은 전부 이걸 쓴다. */
@Composable
fun HoldButton(
modifier: Modifier = Modifier,
background: Color = Palette.DPanel,
borderColor: Color = Palette.Bone,
onPress: () -> Unit = {},
onRelease: () -> Unit = {},
onTap: (() -> Unit)? = null,
content: @Composable BoxScope.() -> Unit,
) {
val shape = cutCornerShape(4.dp)
Box(
modifier = modifier
.clip(shape)
.background(background)
.border(2.dp, borderColor, shape)
.pointerInput(onPress, onRelease, onTap) {
detectTapGestures(
onPress = {
onPress()
tryAwaitRelease()
onRelease()
},
onTap = { onTap?.invoke() },
)
}
.padding(2.dp),
contentAlignment = Alignment.Center,
content = content,
)
}

View File

@@ -0,0 +1,433 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
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.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kr.tkrmagid.tetrig.data.Prefs
import kr.tkrmagid.tetrig.engine.Config
import kr.tkrmagid.tetrig.game.GameState
import kr.tkrmagid.tetrig.game.Mode
import kr.tkrmagid.tetrig.game.SPRINT_GOAL_LINES
@Composable
fun GameScreen(
mode: Mode,
seed: Int,
onExit: () -> Unit,
onFinish: (score: Int, lines: Int, timeMs: Long, pps: Double) -> Unit,
) {
val state = remember(mode, seed) { GameState(mode, seed) }
val l = LocalLayout.current
val ghostOn = remember { Prefs.flag("ghost", true) }
val lefty = remember { Prefs.flag("lefty", false) }
val bigButtons = remember { Prefs.flag("bigButtons", false) }
// 프레임 구동 루프
LaunchedEffect(state) {
var last = 0L
while (true) {
withFrameNanos { now ->
if (last != 0L) {
val dt = ((now - last) / 1_000_000L).coerceAtMost(100L)
state.tick(dt)
}
last = now
}
if (state.finished) break
}
}
LaunchedEffect(state.finished) {
if (state.finished) {
when (mode) {
Mode.Sprint -> if (state.lines >= SPRINT_GOAL_LINES) Prefs.submitSprint(state.elapsedMs)
else -> Prefs.submitScore(mode.id, state.score)
}
onFinish(state.score, state.lines, state.elapsedMs, state.pps)
}
}
Column(Modifier.fillMaxSize().background(Palette.Ink)) {
Hud(state, onPause = { state.paused = !state.paused }, onExit = onExit)
if (l.isLandscape) {
// 가로 (태블릿·폴드 펼침·아이패드): 좌 조작 | 보드 | 우 조작
Row(Modifier.weight(1f).fillMaxWidth()) {
ControlColumn(state, left = !lefty, scale = l.uiScale * if (bigButtons) 1.2f else 1f, modifier = Modifier.weight(1f).fillMaxHeight())
Box(Modifier.weight(1.6f).fillMaxHeight(), contentAlignment = Alignment.Center) {
BoardWithPanels(state, ghostOn, l, stacked = false)
}
ControlColumn(state, left = lefty, scale = l.uiScale * if (bigButtons) 1.2f else 1f, modifier = Modifier.weight(1f).fillMaxHeight())
}
} else {
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
BoardWithPanels(state, ghostOn, l, stacked = true)
}
ControlPad(
state,
lefty = lefty,
scale = l.uiScale * if (bigButtons) 1.15f else 1f,
compactHeight = l.isTiny,
)
}
}
if (state.paused) {
PauseOverlay(onResume = { state.paused = false }, onExit = onExit)
}
}
// ── HUD ──────────────────────────────────────────────────────────
@Composable
private fun Hud(state: GameState, onPause: () -> Unit, onExit: () -> Unit) {
val s = LocalLayout.current.uiScale
Row(
Modifier.fillMaxWidth().padding(horizontal = (14 * s).dp, vertical = (8 * s).dp),
verticalAlignment = Alignment.CenterVertically,
) {
HoldButton(
modifier = Modifier.size((42 * s).dp),
background = Palette.DPanel,
borderColor = Palette.Bone,
onTap = onPause,
) {
Mono("II", color = Palette.Bone, size = (15 * s).sp, weight = FontWeight.Bold)
}
Spacer(Modifier.width(10.dp))
Column(Modifier.weight(1f), horizontalAlignment = Alignment.CenterHorizontally) {
Mono(state.mode.title, color = Palette.DSub, size = (12 * s).sp)
val headline = when (state.mode) {
Mode.Sprint -> formatMs(state.elapsedMs)
Mode.TimeAttack -> formatMs(state.remainingMs)
Mode.Endless -> state.score.toString()
}
Disp(headline, color = Palette.Bone, size = (30 * s).sp)
val sub = when (state.mode) {
Mode.Sprint -> "LINES ${state.lines}/$SPRINT_GOAL_LINES · PPS ${fmt2(state.pps)}"
Mode.TimeAttack -> "SCORE ${state.score} · LV ${state.level}"
Mode.Endless -> "LINES ${state.lines} · LV ${state.level}"
}
Mono(sub, color = Palette.DSub, size = (12 * s).sp)
}
Spacer(Modifier.width(10.dp))
HoldButton(
modifier = Modifier.size(width = (48 * s).dp, height = (42 * s).dp),
background = Palette.DPanel,
borderColor = Palette.Bone,
onTap = onExit,
) {
Mono("나가기", color = Palette.Bone, size = (10 * s).sp)
}
}
state.lastEvent?.let {
Mono(
it,
Modifier.fillMaxWidth(),
color = Palette.Safety,
size = (13 * LocalLayout.current.uiScale).sp,
weight = FontWeight.Bold,
align = TextAlign.Center,
)
}
}
// ── 보드 + HOLD/NEXT ──────────────────────────────────────────────
@Composable
private fun BoardWithPanels(state: GameState, ghostOn: Boolean, l: Layout, stacked: Boolean) {
val s = l.uiScale
BoxWithConstraints(Modifier.fillMaxSize().padding(horizontal = (10 * s).dp)) {
val panelW: Dp = if (l.isTiny) (46 * s).dp else (68 * s).dp
val availW = maxWidth - panelW - (10 * s).dp
val availH = maxHeight
val cell = minOf(availW.value / Config.BOARD_W, availH.value / Config.VISIBLE_H)
val boardW = (cell * Config.BOARD_W).dp
val boardH = (cell * Config.VISIBLE_H).dp
Row(
Modifier.align(Alignment.Center),
verticalAlignment = Alignment.Top,
horizontalArrangement = Arrangement.spacedBy((10 * s).dp),
) {
Box(Modifier.size(boardW, boardH)) {
BoardCanvas(state, ghostOn)
}
Column(Modifier.width(panelW)) {
SidePanel("HOLD", listOfNotNull(state.game.hold), s, 1, l.isTiny)
Spacer(Modifier.height((8 * s).dp))
SidePanel("NEXT", state.game.peekNext(if (l.isTiny) 3 else 5), s, if (l.isTiny) 3 else 5, l.isTiny)
}
}
}
}
@Composable
private fun BoardCanvas(state: GameState, ghostOn: Boolean) {
@Suppress("UNUSED_EXPRESSION")
state.version // 엔진 변경 시 재그리기
val game = state.game
Canvas(Modifier.fillMaxSize()) {
val cw = size.width / Config.BOARD_W
val ch = size.height / Config.VISIBLE_H
drawRect(Palette.Dwell)
// 격자
for (x in 0..Config.BOARD_W) {
drawLine(Palette.DGrid, Offset(x * cw, 0f), Offset(x * cw, size.height), 1f)
}
for (y in 0..Config.VISIBLE_H) {
drawLine(Palette.DGrid, Offset(0f, y * ch), Offset(size.width, y * ch), 1f)
}
fun cellRect(bx: Int, by: Int): Pair<Offset, Size>? {
if (by < 0 || by >= Config.VISIBLE_H) return null
val left = bx * cw
val top = (Config.VISIBLE_H - 1 - by) * ch
return Offset(left + 1f, top + 1f) to Size(cw - 2f, ch - 2f)
}
fun block(bx: Int, by: Int, color: Color) {
val r = cellRect(bx, by) ?: return
drawRect(color, topLeft = r.first, size = r.second)
}
// 쌓인 블록
for (y in 0 until Config.VISIBLE_H) {
for (x in 0 until Config.BOARD_W) {
game.board[y][x]?.let { block(x, y, pieceColor(it)) }
}
}
// 고스트
if (ghostOn && !game.over) {
for (c in game.ghostCells()) {
val r = cellRect(c[0], c[1]) ?: continue
drawRect(
color = Palette.DSub,
topLeft = r.first,
size = r.second,
style = Stroke(width = 2f),
)
}
}
// 현재 피스
if (!game.over) {
val t = game.active.type
for (c in game.cells()) block(c[0], c[1], pieceColor(t))
}
}
}
@Composable
private fun SidePanel(label: String, pieces: List<Char>, s: Float, slots: Int, tiny: Boolean) {
Column {
Mono(label, color = Palette.DSub, size = (11 * s).sp)
Spacer(Modifier.height(4.dp))
Box(
Modifier
.fillMaxWidth()
.height(((if (tiny) 34 else 44) * s * slots).dp)
.background(Palette.DPanel),
) {
Column(Modifier.fillMaxSize().padding(4.dp)) {
repeat(slots) { i ->
Box(Modifier.fillMaxWidth().weight(1f)) {
pieces.getOrNull(i)?.let { MiniPiece(it) }
}
}
}
}
}
}
@Composable
private fun MiniPiece(type: Char) {
Canvas(Modifier.fillMaxSize()) {
val cells = kr.tkrmagid.tetrig.engine.STATES.getValue(type)[0]
val xs = cells.map { it[0] }
val ys = cells.map { it[1] }
val minX = xs.min(); val maxX = xs.max()
val minY = ys.min(); val maxY = ys.max()
val w = maxX - minX + 1
val h = maxY - minY + 1
val c = minOf(size.width / (w + 0.6f), size.height / (h + 0.6f))
val ox = (size.width - c * w) / 2f
val oy = (size.height - c * h) / 2f
val color = pieceColor(type)
for (cell in cells) {
val gx = cell[0] - minX
val gy = maxY - cell[1]
drawRect(
color,
topLeft = Offset(ox + gx * c + 1f, oy + gy * c + 1f),
size = Size(c - 2f, c - 2f),
)
}
}
}
// ── 조작 ──────────────────────────────────────────────────────────
@Composable
private fun ControlPad(state: GameState, lefty: Boolean, scale: Float, compactHeight: Boolean) {
val h = ((if (compactHeight) 130 else 168) * scale).dp
Row(
Modifier
.fillMaxWidth()
.height(h)
.padding(horizontal = (10 * scale).dp, vertical = (6 * scale).dp),
horizontalArrangement = Arrangement.spacedBy((8 * scale).dp),
) {
val actions: @Composable (Modifier) -> Unit = { m ->
Column(m.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
CtrlButton("HOLD", Modifier.weight(2f).fillMaxHeight(), scale) { state.hold() }
CtrlButton("180°", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("180") }
}
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
CtrlButton("↺", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("ccw") }
CtrlButton("↻", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("cw") }
CtrlButton("하드\n드롭", Modifier.weight(1f).fillMaxHeight(), scale, accent = true) { state.hardDrop() }
}
}
}
val moves: @Composable (Modifier) -> Unit = { m ->
Column(m.fillMaxHeight(), verticalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
Row(Modifier.weight(1.4f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
HoldCtrl("◀", Modifier.weight(1f).fillMaxHeight(), scale, { state.press(-1) }, { state.release(-1) })
HoldCtrl("▶", Modifier.weight(1f).fillMaxHeight(), scale, { state.press(1) }, { state.release(1) })
}
HoldCtrl("▼ 소프트", Modifier.weight(1f).fillMaxWidth(), scale, { state.setSoft(true) }, { state.setSoft(false) })
}
}
if (lefty) {
moves(Modifier.weight(1f))
actions(Modifier.weight(1.1f))
} else {
actions(Modifier.weight(1.1f))
moves(Modifier.weight(1f))
}
}
}
@Composable
private fun ControlColumn(state: GameState, left: Boolean, scale: Float, modifier: Modifier) {
Column(
modifier.padding((8 * scale).dp),
verticalArrangement = Arrangement.spacedBy((8 * scale).dp),
) {
if (left) {
HoldCtrl("◀", Modifier.weight(1f).fillMaxWidth(), scale, { state.press(-1) }, { state.release(-1) })
HoldCtrl("▶", Modifier.weight(1f).fillMaxWidth(), scale, { state.press(1) }, { state.release(1) })
HoldCtrl("▼ 소프트", Modifier.weight(1f).fillMaxWidth(), scale, { state.setSoft(true) }, { state.setSoft(false) })
} else {
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
CtrlButton("↺", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("ccw") }
CtrlButton("↻", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("cw") }
}
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy((8 * scale).dp)) {
CtrlButton("HOLD", Modifier.weight(1f).fillMaxHeight(), scale) { state.hold() }
CtrlButton("180°", Modifier.weight(1f).fillMaxHeight(), scale) { state.rotate("180") }
}
CtrlButton("하드 드롭", Modifier.weight(1f).fillMaxWidth(), scale, accent = true) { state.hardDrop() }
}
}
}
@Composable
private fun CtrlButton(
label: String,
modifier: Modifier,
scale: Float,
accent: Boolean = false,
onTap: () -> Unit,
) {
HoldButton(
modifier = modifier,
background = if (accent) Palette.Safety else Palette.DPanel,
borderColor = Palette.Bone,
onTap = onTap,
) {
Mono(
label,
color = if (accent) Palette.Ink else Palette.Bone,
size = (14 * scale).sp,
weight = FontWeight.Bold,
align = TextAlign.Center,
maxLines = 2,
)
}
}
@Composable
private fun HoldCtrl(
label: String,
modifier: Modifier,
scale: Float,
onPress: () -> Unit,
onRelease: () -> Unit,
) {
HoldButton(
modifier = modifier,
background = Palette.DPanel,
borderColor = Palette.Bone,
onPress = onPress,
onRelease = onRelease,
) {
Mono(label, color = Palette.Bone, size = (18 * scale).sp, weight = FontWeight.Bold, align = TextAlign.Center)
}
}
@Composable
private fun PauseOverlay(onResume: () -> Unit, onExit: () -> Unit) {
val s = LocalLayout.current.uiScale
Box(
Modifier.fillMaxSize().background(Color(0xCC17161B)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Disp("일시정지", color = Palette.Bone, size = (26 * s).sp)
Spacer(Modifier.height((20 * s).dp))
NeoPanel(background = Palette.Safety, onClick = onResume) {
Mono("계속하기", Modifier.padding(horizontal = (30 * s).dp, vertical = (13 * s).dp), size = (14 * s).sp, weight = FontWeight.Bold)
}
Spacer(Modifier.height((12 * s).dp))
NeoPanel(onClick = onExit) {
Mono("나가기", Modifier.padding(horizontal = (30 * s).dp, vertical = (13 * s).dp), size = (14 * s).sp, weight = FontWeight.Bold)
}
}
}
}
private fun fmt2(v: Double): String {
val r = (v * 100).toInt() / 100.0
return r.toString()
}

View File

@@ -0,0 +1,429 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
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.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
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.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kr.tkrmagid.tetrig.data.Prefs
import kr.tkrmagid.tetrig.game.Mode
@Composable
fun MainScreen(
onMode: (Mode) -> Unit,
onSettings: () -> Unit,
onSoon: (String) -> Unit,
) {
val l = LocalLayout.current
val s = l.uiScale
var showModes by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
Column(
Modifier
.weight(1f)
.fillMaxWidth()
.verticalScroll(rememberScrollState())
.padding(horizontal = (20 * s).dp, vertical = (16 * s).dp),
) {
Header(s)
Spacer(Modifier.height((14 * s).dp))
SeasonBar(s)
Spacer(Modifier.height((16 * s).dp))
if (l.widthClass != SizeClass.Compact && l.isLandscape) {
// 태블릿·폴드 가로: 카드 3개를 한 줄에
Row(horizontalArrangement = Arrangement.spacedBy((14 * s).dp)) {
Box(Modifier.weight(1f)) {
ModeCard("솔로", "SPRINT · TIME ATTACK · ENDLESS", Palette.Cobalt, s) { showModes = true }
}
Box(Modifier.weight(1f)) {
ModeCard("멀티 · 리그전", "실시간 1:1 랭크 매치 · 배치 10경기", Palette.Safety, s) { onSoon("멀티 · 리그전") }
}
Box(Modifier.weight(1f)) {
ModeCard("커스텀", "코드로 만들고 참가 · 인원 제한 없음", null, s) { onSoon("커스텀") }
}
}
} else {
Box(Modifier.widthIn(max = 720.dp).align(Alignment.CenterHorizontally)) {
Column {
ModeCard("솔로", "SPRINT · TIME ATTACK · ENDLESS", Palette.Cobalt, s) { showModes = true }
ModeCard("멀티 · 리그전", "실시간 1:1 랭크 매치 · 배치 10경기", Palette.Safety, s) { onSoon("멀티 · 리그전") }
ModeCard("커스텀", "코드로 만들고 참가 · 인원 제한 없음", null, s) { onSoon("커스텀") }
}
}
}
}
TabBar(
selected = 0,
scale = s,
onSelect = { i ->
when (i) {
1 -> onSoon("리더보드")
2 -> onSoon("프로필")
3 -> onSettings()
}
},
)
}
if (showModes) {
SoloSheet(scale = s, onDismiss = { showModes = false }, onPick = {
showModes = false
onMode(it)
})
}
}
}
@Composable
private fun Header(s: Float) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Column {
Row {
Mono("TET", color = Palette.Safety, size = (30 * s).sp, weight = FontWeight.Black, italic = true)
Mono("RIG", color = Palette.Ink, size = (30 * s).sp, weight = FontWeight.Black, italic = true)
}
Spacer(Modifier.height(4.dp))
Box(Modifier.width((120 * s).dp).height((8 * s).dp).background(Palette.Ink))
}
Spacer(Modifier.weight(1f))
NeoPanel(shadow = false, modifier = Modifier.padding(end = 6.dp)) {
Mono("게스트", Modifier.padding(horizontal = 8.dp, vertical = 3.dp), size = (12 * s).sp, weight = FontWeight.Bold)
}
NeoPanel(background = Palette.Safety, shadow = false) {
Mono("—", Modifier.padding(horizontal = 10.dp, vertical = 3.dp), size = (12 * s).sp, weight = FontWeight.Bold)
}
}
}
@Composable
private fun SeasonBar(s: Float) {
NeoPanel(modifier = Modifier.fillMaxWidth()) {
Row(
Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = (9 * s).dp),
verticalAlignment = Alignment.CenterVertically,
) {
Mono("SEASON 1 · D-42", size = (14 * s).sp)
Spacer(Modifier.weight(1f))
Mono("배치 0/10", color = Palette.Cobalt, size = (14 * s).sp, weight = FontWeight.Bold)
}
}
}
@Composable
private fun ModeCard(
title: String,
sub: String,
barColor: Color?,
scale: Float,
onClick: () -> Unit,
) {
Box(Modifier.fillMaxWidth().padding(bottom = (14 * scale).dp)) {
NeoPanel(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Row(Modifier.fillMaxWidth().height((130 * scale).dp)) {
if (barColor != null) {
Box(Modifier.width((14 * scale).dp).fillMaxHeight().background(barColor))
} else {
HazardBar(scale)
}
Column(
Modifier
.weight(1f)
.padding(start = (14 * scale).dp, top = (18 * scale).dp, end = 8.dp),
) {
Disp(title, size = (26 * scale).sp)
Spacer(Modifier.height(6.dp))
Mono(sub, color = Palette.Sub, size = (12 * scale).sp)
}
Mono(
"›",
Modifier.align(Alignment.CenterVertically).padding(end = 14.dp),
color = Palette.Sub,
size = (26 * scale).sp,
)
}
}
}
}
@Composable
private fun HazardBar(scale: Float) {
Column(Modifier.width((14 * scale).dp).fillMaxHeight()) {
repeat(14) { i ->
Box(Modifier.fillMaxWidth().weight(1f).background(if (i % 2 == 0) Palette.Ink else Palette.Bone))
}
}
}
@Composable
fun TabBar(selected: Int, scale: Float, onSelect: (Int) -> Unit) {
val items = listOf("홈", "리더보드", "프로필", "설정")
Row(
Modifier
.fillMaxWidth()
.height((74 * scale).dp)
.background(Palette.Bone),
) {
items.forEachIndexed { i, name ->
Column(
Modifier
.weight(1f)
.fillMaxHeight()
.pointerInput(i) { detectTapGestures { onSelect(i) } },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Box(
Modifier
.size(width = (26 * scale).dp, height = (if (i == selected) 15 else 9).dp)
.background(if (i == selected) Palette.Safety else Palette.Sub)
)
Spacer(Modifier.height(7.dp))
Mono(
name,
color = if (i == selected) Palette.Ink else Palette.Sub,
size = (11 * scale).sp,
weight = if (i == selected) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
}
@Composable
fun SoloSheet(scale: Float, onDismiss: () -> Unit, onPick: (Mode) -> Unit) {
Box(
Modifier
.fillMaxSize()
.background(Color(0x8017161B))
.pointerInput(Unit) { detectTapGestures { onDismiss() } },
) {
Box(
Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.background(Palette.Bone)
.pointerInput(Unit) { detectTapGestures { } },
) {
Column(
Modifier
.widthIn(max = 720.dp)
.align(Alignment.TopCenter)
.padding(horizontal = (20 * scale).dp, vertical = (16 * scale).dp),
) {
Box(
Modifier
.align(Alignment.CenterHorizontally)
.width(56.dp)
.height(6.dp)
.background(Palette.Sub)
)
Spacer(Modifier.height((14 * scale).dp))
Disp("솔로", size = (24 * scale).sp)
Spacer(Modifier.height((12 * scale).dp))
Mode.entries.forEach { m -> SoloRow(m, scale) { onPick(m) } }
Spacer(Modifier.height((6 * scale).dp))
}
}
}
}
@Composable
private fun SoloRow(mode: Mode, scale: Float, onClick: () -> Unit) {
val best = when (mode) {
Mode.Sprint -> Prefs.bestSprintMs().let { if (it == 0L) "기록 없음" else formatMs(it) }
else -> Prefs.bestScore(mode.id).let { if (it == 0) "기록 없음" else "$it" }
}
val bar = when (mode) {
Mode.Sprint -> Palette.Mint
Mode.TimeAttack -> Palette.Amber
Mode.Endless -> Palette.Violet
}
Box(Modifier.fillMaxWidth().padding(bottom = (12 * scale).dp)) {
NeoPanel(modifier = Modifier.fillMaxWidth(), onClick = onClick) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.width((12 * scale).dp).height((78 * scale).dp).background(bar))
Column(
Modifier
.weight(1f)
.padding(start = (14 * scale).dp, top = (14 * scale).dp, bottom = (14 * scale).dp),
) {
Disp(mode.title, size = (20 * scale).sp)
Spacer(Modifier.height(4.dp))
Mono(mode.desc, color = Palette.Sub, size = (11 * scale).sp)
}
Mono(best, size = (12 * scale).sp)
Mono("›", Modifier.padding(start = 10.dp, end = 12.dp), color = Palette.Sub, size = (22 * scale).sp)
}
}
}
}
@Composable
fun SoonScreen(title: String, onBack: () -> Unit) {
val s = LocalLayout.current.uiScale
Column(Modifier.fillMaxSize().padding((20 * s).dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Disp("준비 중", size = (24 * s).sp)
Spacer(Modifier.weight(1f))
NeoPanel(shadow = false, onClick = onBack) {
Mono("← 뒤로", Modifier.padding(horizontal = 12.dp, vertical = 6.dp), size = (13 * s).sp)
}
}
Spacer(Modifier.height((24 * s).dp))
NeoPanel(modifier = Modifier.fillMaxWidth()) {
Column(Modifier.padding((20 * s).dp)) {
Disp(title, size = (20 * s).sp)
Spacer(Modifier.height(10.dp))
Mono("아직 구현되지 않았습니다. 다음 업데이트에서 열립니다.", color = Palette.Sub, size = (12 * s).sp, maxLines = 3)
}
}
}
}
@Composable
fun ResultScreen(
mode: Mode,
score: Int,
lines: Int,
timeMs: Long,
pps: Double,
onRetry: () -> Unit,
onHome: () -> Unit,
) {
val s = LocalLayout.current.uiScale
Column(
Modifier.fillMaxSize().padding((20 * s).dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Disp("RESULT", size = (28 * s).sp)
Spacer(Modifier.height((6 * s).dp))
Mono(mode.title, color = Palette.Sub, size = (14 * s).sp)
Spacer(Modifier.height((18 * s).dp))
NeoPanel(modifier = Modifier.fillMaxWidth().widthIn(max = 560.dp)) {
Column(Modifier.padding((18 * s).dp)) {
StatRow("점수", "$score", s)
StatRow("라인", "$lines", s)
StatRow("시간", formatMs(timeMs), s)
StatRow("PPS", ((pps * 100).toInt() / 100.0).toString(), s)
}
}
Spacer(Modifier.height((18 * s).dp))
Row(Modifier.widthIn(max = 560.dp).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
NeoPanel(background = Palette.Safety, modifier = Modifier.weight(1f), onClick = onRetry) {
Mono(
"다시 하기",
Modifier.fillMaxWidth().padding(vertical = (14 * s).dp),
size = (14 * s).sp,
weight = FontWeight.Bold,
align = TextAlign.Center,
)
}
NeoPanel(modifier = Modifier.weight(1f), onClick = onHome) {
Mono(
"홈으로",
Modifier.fillMaxWidth().padding(vertical = (14 * s).dp),
size = (14 * s).sp,
weight = FontWeight.Bold,
align = TextAlign.Center,
)
}
}
}
}
@Composable
private fun StatRow(label: String, value: String, s: Float) {
Row(Modifier.fillMaxWidth().padding(vertical = (7 * s).dp), verticalAlignment = Alignment.CenterVertically) {
Mono(label, color = Palette.Sub, size = (13 * s).sp)
Spacer(Modifier.weight(1f))
Mono(value, size = (18 * s).sp, weight = FontWeight.Bold)
}
}
@Composable
fun SettingsScreen(onBack: () -> Unit) {
val s = LocalLayout.current.uiScale
var ghost by remember { mutableStateOf(Prefs.flag("ghost", true)) }
var lefty by remember { mutableStateOf(Prefs.flag("lefty", false)) }
var bigButtons by remember { mutableStateOf(Prefs.flag("bigButtons", false)) }
Column(Modifier.fillMaxSize()) {
Column(Modifier.weight(1f).verticalScroll(rememberScrollState()).padding((20 * s).dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Disp("설정", size = (24 * s).sp)
Spacer(Modifier.weight(1f))
NeoPanel(shadow = false, onClick = onBack) {
Mono("← 뒤로", Modifier.padding(horizontal = 12.dp, vertical = 6.dp), size = (13 * s).sp)
}
}
Spacer(Modifier.height((18 * s).dp))
Box(Modifier.widthIn(max = 720.dp)) {
Column {
Toggle("고스트 표시", "착지 위치 미리보기", ghost, s) {
ghost = it; Prefs.setFlag("ghost", it)
}
Toggle("왼손 배치", "조작 버튼 좌우 반전", lefty, s) {
lefty = it; Prefs.setFlag("lefty", it)
}
Toggle("큰 버튼", "태블릿·폴드에서 버튼 확대", bigButtons, s) {
bigButtons = it; Prefs.setFlag("bigButtons", it)
}
}
}
}
TabBar(selected = 3, scale = s, onSelect = { if (it == 0) onBack() })
}
}
@Composable
private fun Toggle(title: String, sub: String, value: Boolean, s: Float, onChange: (Boolean) -> Unit) {
Box(Modifier.fillMaxWidth().padding(bottom = (12 * s).dp)) {
NeoPanel(modifier = Modifier.fillMaxWidth(), onClick = { onChange(!value) }) {
Row(Modifier.fillMaxWidth().padding((16 * s).dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Mono(title, size = (15 * s).sp, weight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Mono(sub, color = Palette.Sub, size = (11 * s).sp)
}
Box(
Modifier
.size(width = (52 * s).dp, height = (28 * s).dp)
.background(if (value) Palette.Safety else Palette.Paper),
contentAlignment = if (value) Alignment.CenterEnd else Alignment.CenterStart,
) {
Box(Modifier.size((24 * s).dp).background(Palette.Ink))
}
}
}
}
}

View File

@@ -0,0 +1,58 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.runtime.Immutable
import androidx.compose.ui.unit.Dp
/**
* 기기 폼팩터 적응 규칙.
*
* 고정 해상도(390×844)를 전제하지 않고 실제 가용 크기에서 계산한다.
* 대상: 일반 폰, 갤럭시 Z 플립(21:9 이상 초세로 + 커버 화면), 갤럭시 Z 폴드(펼치면 거의 정사각),
* 아이폰(노치/다이나믹 아일랜드 포함), 갤럭시 탭 / 아이패드(대화면·가로).
*/
enum class SizeClass { Compact, Medium, Expanded }
@Immutable
data class Layout(
val width: Dp,
val height: Dp,
val widthClass: SizeClass,
val heightClass: SizeClass,
val isLandscape: Boolean,
) {
/** 폴드 펼침·태블릿처럼 보드 양옆에 패널을 둘 여유가 있는가. */
val sidePanels: Boolean get() = widthClass != SizeClass.Compact || isLandscape
/** 조작 버튼을 좌우 엄지 영역으로 나눌 것인가 (넓은 화면). */
val splitControls: Boolean get() = widthClass == SizeClass.Expanded || isLandscape
/** 플립 커버 화면처럼 아주 작은 화면. 최소 구성만 노출한다. */
val isTiny: Boolean get() = width.value < 340f || height.value < 380f
/** 초세로(플립 본화면 등) — 세로 여유가 많아 보드를 키울 수 있다. */
val isTallNarrow: Boolean get() = !isLandscape && height.value / width.value > 2.05f
/** 화면 크기에 따른 본문 스케일. 태블릿에서 UI가 작아 보이지 않게 한다. */
val uiScale: Float
get() = when {
isTiny -> 0.82f
widthClass == SizeClass.Expanded -> 1.35f
widthClass == SizeClass.Medium -> 1.15f
else -> 1f
}
}
fun layoutFor(width: Dp, height: Dp): Layout {
fun cls(v: Float) = when {
v < 600f -> SizeClass.Compact
v < 840f -> SizeClass.Medium
else -> SizeClass.Expanded
}
return Layout(
width = width,
height = height,
widthClass = cls(width.value),
heightClass = cls(height.value),
isLandscape = width.value > height.value * 1.1f,
)
}

View File

@@ -0,0 +1,31 @@
package kr.tkrmagid.tetrig.ui
import androidx.compose.ui.graphics.Color
/** 명세서 §13 NEOBRUTAL 팔레트. */
object Palette {
val Ink = Color(0xFF17161B)
val Bone = Color(0xFFEDE7DA)
val Paper = Color(0xFFE1D9C7)
val Dwell = Color(0xFF101015)
val DPanel = Color(0xFF1F1F26)
val DGrid = Color(0xFF26262E)
val Safety = Color(0xFFFF4E11)
val Cobalt = Color(0xFF2B3FE0)
val DSub = Color(0xFF8D8A80)
val Sub = Color(0xFF6E6A5E)
val Mint = Color(0xFF3EC1B6)
val Amber = Color(0xFFF5C531)
val Violet = Color(0xFFA76BF2)
}
fun pieceColor(type: Char): Color = when (type) {
'I' -> Palette.Mint
'O' -> Palette.Amber
'T' -> Palette.Violet
'S' -> Color(0xFF7FCC4C)
'Z' -> Color(0xFFF25C4C)
'J' -> Color(0xFF4C7DF2)
'L' -> Color(0xFFF2913D)
else -> Color(0xFF46464F)
}

View File

@@ -0,0 +1,268 @@
package kr.tkrmagid.tetrig.engine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class EngineTest {
private fun cellSet(cells: Array<IntArray>): Set<String> =
cells.map { "${it[0]},${it[1]}" }.toSet()
private fun kickStr(k: Array<IntArray>) = k.joinToString(" ") { "${it[0]},${it[1]}" }
// ── 피스 상태/회전 ──
@Test
fun tSpawnCells() {
assertEquals(setOf("0,1", "1,1", "2,1", "1,2"), cellSet(STATES.getValue('T')[0]))
}
@Test
fun tCwState() {
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)))
assertEquals("0,0", kickStr(kickTable('I', 0, 2)))
assertEquals("0,0", kickStr(kickTable('O', 0, 1)))
}
@Test
fun wallKickUsesOffsetWhenBlocked() {
val board = createBoard()
board[5][5] = 'X'
assertTrue(collides(board, pieceCells('T', 1, 4, 5)))
assertFalse(collides(board, pieceCells('T', 1, 3, 5)))
}
@Test
fun rotateAppliesKickOffset() {
val g = TetrigGame(11)
// 왼쪽 벽에 T를 붙이고 CCW 회전 → 킥으로 성사되어야 한다.
val a = g.active
a.type = 'T'; a.state = 0; a.x = -1; a.y = 5
assertTrue(g.rotate("cw"))
assertTrue(g.lastMoveRotation)
}
// ── 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))
bd[1][4] = 'X'
assertEquals("tspin", detectTSpin(bd, 'T', 2, 4, -1, true))
}
@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 (dropped, res) = g.hardDrop()!!
assertEquals(before, res.type)
assertTrue(dropped > 0)
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'
val a = g.active
a.type = 'I'; a.state = 1; a.x = -2; a.y = 0
g.lastMoveRotation = false
return g.hardDrop()!!.second
}
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)
}
// ── 점수·레벨 (명세서 §5) ──
private fun mk(
lines: Int,
tspin: String = "none",
allClear: Boolean = false,
combo: Int = -1,
b2b: Int = -1,
) = LockResult('T', lines, tspin, allClear, combo, b2b, isDifficult(lines, tspin))
@Test
fun scoreBasicClears() {
assertEquals(100, scoreClear(mk(1), 1))
assertEquals(300, scoreClear(mk(2), 1))
assertEquals(800, scoreClear(mk(4), 1))
assertEquals(300, scoreClear(mk(1), 3))
}
@Test
fun scoreTspinB2bComboAllClear() {
assertEquals(1200, scoreClear(mk(2, tspin = "tspin"), 1))
assertEquals(1200, scoreClear(mk(4, b2b = 1), 1))
assertEquals(150, scoreClear(mk(1, combo = 1), 1))
assertEquals(2800, scoreClear(mk(4, allClear = true), 1))
assertEquals(4400, scoreClear(mk(4, allClear = true, b2b = 1), 1))
}
@Test
fun levelAndGravity() {
assertEquals(1, levelForLines(0))
assertEquals(1, levelForLines(9))
assertEquals(2, levelForLines(10))
assertEquals(5, levelForLines(42))
assertEquals(1000, gravityMsForLevel(1))
assertEquals(800, gravityMsForLevel(2))
assertEquals(50, gravityMsForLevel(30))
}
}

View File

@@ -0,0 +1,7 @@
package kr.tkrmagid.tetrig
import androidx.compose.ui.window.ComposeUIViewController
import kr.tkrmagid.tetrig.ui.App
@Suppress("unused", "FunctionName")
fun MainViewController() = ComposeUIViewController { App() }

View File

@@ -0,0 +1,16 @@
package kr.tkrmagid.tetrig.data
import platform.Foundation.NSUserDefaults
private class IosStore : Store {
private val defaults = NSUserDefaults.standardUserDefaults
override fun getString(key: String, default: String): String =
defaults.stringForKey(key) ?: default
override fun putString(key: String, value: String) {
defaults.setObject(value, forKey = key)
}
}
actual fun platformStore(): Store = IosStore()