diff --git a/.gitignore b/.gitignore
index b7344cb..f01889c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,15 +1,6 @@
node_modules/
_*.png
_*.mjs
-_verify.mjs
.DS_Store
dist/
live_*.png
-android/build/
-android/.gradle/
-android/app/build/
-android/local.properties
-android/*.apk
-android/*.aab
-android/*.idsig
-android/*.keystore
diff --git a/README.md b/README.md
index 1782385..24ecf31 100644
--- a/README.md
+++ b/README.md
@@ -97,29 +97,84 @@ wrangler deploy # 배포
## 배포
-웹(Cloudflare Pages) — `dist/` 에 `index.html`, `manifest.webmanifest`, `sw.js`,
-아이콘 3종, `.well-known/assetlinks.json` 을 담아 올린다.
+### 네이티브 앱 (Kotlin Multiplatform + Compose Multiplatform)
+
+`app/` 이 실제 출시 대상이다. 엔진과 UI 전부 `commonMain` 한 벌이고
+Android / iOS 가 그걸 공유한다.
+
+```
+app/composeApp/src/
+ commonMain/ 엔진(engine/) · 모드·타이밍(game/) · UI(ui/) · 저장소(data/)
+ commonTest/ 엔진 테스트 (Android/iOS 양쪽에서 동일하게 돈다)
+ androidMain/ MainActivity, 매니페스트, 아이콘, SharedPreferences 구현
+ iosMain/ MainViewController, NSUserDefaults 구현
+app/iosApp/ SwiftUI 껍데기 + XcodeGen 설정
+```
+
+안드로이드 빌드 (이 저장소에서 바로 가능):
+
+```bash
+export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
+export ANDROID_HOME=$HOME/android-sdk
+cd app
+cp keystore.properties.sample keystore.properties # 서명 키 경로/비번 입력
+./gradlew :composeApp:testDebugUnitTest :composeApp:assembleRelease :composeApp:bundleRelease
+# → composeApp/build/outputs/apk/release/composeApp-release.apk
+# → composeApp/build/outputs/bundle/release/composeApp-release.aab
+```
+
+iOS 빌드 (**macOS + Xcode 필요**. 리눅스에서는 코드만 컴파일 검증되고 실제
+프레임워크 빌드는 되지 않는다):
+
+```bash
+brew install xcodegen
+cd app/iosApp && xcodegen generate && open iosApp.xcodeproj
+# Xcode 에서 실행하면 prebuild 스크립트가
+# ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode 를 돌려
+# ComposeApp.framework 를 만들어 링크한다.
+```
+
+App Store 출시에는 Apple Developer Program 등록(연 $99)과 맥이 필요하다.
+
+### 기기 호환
+
+고정 해상도를 전제하지 않고 `BoxWithConstraints` 로 실제 가용 크기에서
+셀 크기·UI 배율을 계산한다 (`ui/Responsive.kt`).
+
+| 폼팩터 | 동작 |
+| --- | --- |
+| 일반 폰 세로 | 보드 위 + 조작 아래 |
+| 갤럭시 Z 플립 본화면(초세로) | 보드를 세로로 더 키움 |
+| 갤럭시 Z 플립 커버(소형) | 축소 배율 + 넥스트 3개로 감축 |
+| 갤럭시 Z 폴드 펼침(정사각형에 가까움) | 넓은 폭 레이아웃, 카드 가로 배치 |
+| 태블릿 / 아이패드 가로 | 보드 가운데, 조작을 좌우 엄지 영역으로 분리 |
+| 아이폰 | 동일 코드. 세이프에어리어 처리 |
+
+접었다 펴기·회전은 액티비티 재시작 없이 처리한다
+(`configChanges` + `resizeableActivity=true`).
+
+### 버전 관리
+
+- `app/gradle.properties` 의 `appVersionName`(semver) / `appVersionCode`(단조 증가)가 원본
+- 빌드 시 `-PappVersionName` `-PappVersionCode` 로 덮어쓸 수 있다
+- 릴리스는 스크립트 한 방:
+
+```bash
+scripts/release.sh patch # 또는 minor / major
+```
+
+버전 상승 → 테스트 → 서명 빌드 → 커밋 → `vX.Y.Z` 태그 → Gitea push →
+릴리스 생성 + APK/AAB 업로드까지 자동으로 한다.
+
+### 웹 (Cloudflare Pages)
+
+`index.html` 기반 PWA 는 그대로 유지한다 (아이폰 미설치 사용자용 / 데모).
```bash
node build.js
wrangler pages deploy dist --project-name tetrig --branch main
```
-안드로이드(Play Store) — `android/` 는 위 사이트를 감싸는 TWA(Trusted Web Activity)
-프로젝트다. 패키지는 `kr.tkrmagid.tetrig`. Bubblewrap 으로 빌드한다.
-
-```bash
-export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 # Bubblewrap 은 JDK 17 고정
-export BUBBLEWRAP_KEYSTORE_PASSWORD=... BUBBLEWRAP_KEY_PASSWORD=...
-cd android && bubblewrap build --skipPwaValidation
-# → app-release-signed.apk (테스트 설치용), app-release-bundle.aab (Play 업로드용)
-```
-
-서명 키는 저장소에 넣지 않는다. 키를 바꾸면 `.well-known/assetlinks.json` 의
-SHA-256 지문도 같이 갱신해야 TWA 가 주소창 없이 전체화면으로 뜬다.
-Play App Signing 을 쓰면 Play Console 이 발급한 앱 서명 키 지문도
-assetlinks 에 함께 넣어야 한다.
-
## 구현 가드레일 (반드시 지킬 것)
- **IP 금지**: 사용자 노출 텍스트에 "Tetris/테트리스" 금지, 테트리스 음악(Korobeiniki 등) 금지, 표준 가이드라인 색상 1:1 복제 금지, TETR.IO 에셋·명칭 복제 금지.
diff --git a/android/app/build.gradle b/android/app/build.gradle
deleted file mode 100644
index dc3ea5f..0000000
--- a/android/app/build.gradle
+++ /dev/null
@@ -1,213 +0,0 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * 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
- *
- * http://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.
- */
-
-import groovy.xml.MarkupBuilder
-
-plugins {
- id 'com.android.application'
-}
-
-def twaManifest = [
- applicationId: 'kr.tkrmagid.tetrig',
- hostName: 'tetrig.pages.dev', // The domain being opened in the TWA.
- launchUrl: '/', // The start path for the TWA. Must be relative to the domain.
- name: 'TETRIG', // The application name.
- launcherName: 'TETRIG', // The name shown on the Android Launcher.
- themeColor: '#17161B', // The color used for the status bar.
- themeColorDark: '#000000', // The color used for the dark status bar.
- navigationColor: '#000000', // The color used for the navigation bar.
- navigationColorDark: '#000000', // The color used for the dark navbar.
- navigationDividerColor: '#000000', // The navbar divider color.
- navigationDividerColorDark: '#000000', // The dark navbar divider color.
- backgroundColor: '#17161B', // The color used for the splash screen background.
- enableNotifications: true, // Set to true to enable notification delegation.
- // Every shortcut must include the following fields:
- // - name: String that will show up in the shortcut.
- // - short_name: Shorter string used if |name| is too long.
- // - url: Absolute path of the URL to launch the app with (e.g '/create').
- // - icon: Name of the resource in the drawable folder to use as an icon.
- shortcuts: [],
- // The duration of fade out animation in milliseconds to be played when removing splash screen.
- splashScreenFadeOutDuration: 300,
- generatorApp: 'bubblewrap-cli', // Application that generated the Android Project
- // The fallback strategy for when Trusted Web Activity is not available. Possible values are
- // 'customtabs' and 'webview'.
- fallbackType: 'customtabs',
- enableSiteSettingsShortcut: 'true',
- orientation: 'portrait',
-]
-
-android {
- compileSdkVersion 36
- namespace "kr.tkrmagid.tetrig"
- defaultConfig {
- applicationId "kr.tkrmagid.tetrig"
- minSdkVersion 21
- targetSdkVersion 36
- versionCode 1
- versionName "1.0.0"
-
- // The name for the application
- resValue "string", "appName", twaManifest.name
-
- // The name for the application on the Android Launcher
- resValue "string", "launcherName", twaManifest.launcherName
-
- // The URL that will be used when launching the TWA from the Android Launcher
- def launchUrl = "https://" + twaManifest.hostName + twaManifest.launchUrl
- resValue "string", "launchUrl", launchUrl
-
-
-
-
- // The URL the Web Manifest for the Progressive Web App that the TWA points to. This
- // is used by Chrome OS and Meta Quest to open the Web version of the PWA instead of
- // the TWA, as it will probably give a better user experience for non-mobile devices.
- resValue "string", "webManifestUrl", 'https://tetrig.pages.dev/manifest.webmanifest'
-
-
-
- // This is used by Meta Quest.
- resValue "string", "fullScopeUrl", 'https://tetrig.pages.dev/'
-
-
-
-
- // The hostname is used when building the intent-filter, so the TWA is able to
- // handle Intents to open host url of the application.
- resValue "string", "hostName", twaManifest.hostName
-
- // This attribute sets the status bar color for the TWA. It can be either set here or in
- // `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the status bar color defaults to #FFFFFF - white.
- resValue "color", "colorPrimary", twaManifest.themeColor
-
- // This attribute sets the dark status bar color for the TWA. It can be either set here or in
- // `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the status bar color defaults to #000000 - white.
- resValue "color", "colorPrimaryDark", twaManifest.themeColorDark
-
- // This attribute sets the navigation bar color for the TWA. It can be either set here or
- // in `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the navigation bar color defaults to #FFFFFF - white.
- resValue "color", "navigationColor", twaManifest.navigationColor
-
- // This attribute sets the dark navigation bar color for the TWA. It can be either set here
- // or in `res/values/colors.xml`. Setting in both places is an error and the app will not
- // compile. If not set, the navigation bar color defaults to #000000 - black.
- resValue "color", "navigationColorDark", twaManifest.navigationColorDark
-
- // This attribute sets the navbar divider color for the TWA. It can be either
- // set here or in `res/values/colors.xml`. Setting in both places is an error and the app
- // will not compile. If not set, the divider color defaults to #00000000 - transparent.
- resValue "color", "navigationDividerColor", twaManifest.navigationDividerColor
-
- // This attribute sets the dark navbar divider color for the TWA. It can be either
- // set here or in `res/values/colors.xml`. Setting in both places is an error and the
- //app will not compile. If not set, the divider color defaults to #000000 - black.
- resValue "color", "navigationDividerColorDark", twaManifest.navigationDividerColorDark
-
- // Sets the color for the background used for the splash screen when launching the
- // Trusted Web Activity.
- resValue "color", "backgroundColor", twaManifest.backgroundColor
-
- // Defines a provider authority for the Splash Screen
- resValue "string", "providerAuthority", twaManifest.applicationId + '.fileprovider'
-
- // The enableNotification resource is used to enable or disable the
- // TrustedWebActivityService, by changing the android:enabled and android:exported
- // attributes
- resValue "bool", "enableNotification", twaManifest.enableNotifications.toString()
-
- twaManifest.shortcuts.eachWithIndex { shortcut, index ->
- resValue "string", "shortcut_name_$index", "$shortcut.name"
- resValue "string", "shortcut_short_name_$index", "$shortcut.short_name"
- }
-
- // The splashScreenFadeOutDuration resource is used to set the duration of fade out animation in milliseconds
- // to be played when removing splash screen. The default is 0 (no animation).
- resValue "integer", "splashScreenFadeOutDuration", twaManifest.splashScreenFadeOutDuration.toString()
-
- resValue "string", "generatorApp", twaManifest.generatorApp
-
- resValue "string", "fallbackType", twaManifest.fallbackType
-
- resValue "bool", "enableSiteSettingsShortcut", twaManifest.enableSiteSettingsShortcut
- resValue "string", "orientation", twaManifest.orientation
-
-
- }
- buildTypes {
- release {
- minifyEnabled true
- }
- }
- compileOptions {
- sourceCompatibility JavaVersion.VERSION_1_8
- targetCompatibility JavaVersion.VERSION_1_8
- }
- lintOptions {
- checkReleaseBuilds false
- }
-}
-
-task generateShorcutsFile {
- assert twaManifest.shortcuts.size() < 5, "You can have at most 4 shortcuts."
- twaManifest.shortcuts.eachWithIndex { s, i ->
- assert s.name != null, 'Missing `name` in shortcut #' + i
- assert s.short_name != null, 'Missing `short_name` in shortcut #' + i
- assert s.url != null, 'Missing `icon` in shortcut #' + i
- assert s.icon != null, 'Missing `url` in shortcut #' + i
- }
-
- def shortcutsFile = new File("$projectDir/src/main/res/xml", "shortcuts.xml")
-
- def xmlWriter = new StringWriter()
- def xmlMarkup = new MarkupBuilder(new IndentPrinter(xmlWriter, " ", true))
-
- xmlMarkup
- .'shortcuts'('xmlns:android': 'http://schemas.android.com/apk/res/android') {
- twaManifest.shortcuts.eachWithIndex { s, i ->
- 'shortcut'(
- 'android:shortcutId': 'shortcut' + i,
- 'android:enabled': 'true',
- 'android:icon': '@drawable/' + s.icon,
- 'android:shortcutShortLabel': '@string/shortcut_short_name_' + i,
- 'android:shortcutLongLabel': '@string/shortcut_name_' + i) {
- 'intent'(
- 'android:action': 'android.intent.action.MAIN',
- 'android:targetPackage': twaManifest.applicationId,
- 'android:targetClass': twaManifest.applicationId + '.LauncherActivity',
- 'android:data': s.url)
- 'categories'('android:name': 'android.intent.category.LAUNCHER')
- }
- }
- }
- shortcutsFile.text = xmlWriter.toString() + '\n'
-}
-
-preBuild.dependsOn(generateShorcutsFile)
-
-repositories {
-
-}
-
-dependencies {
- implementation fileTree(include: ['*.jar'], dir: 'libs')
-
- implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.6.2'
-
-}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
deleted file mode 100644
index 04a7711..0000000
--- a/android/app/src/main/AndroidManifest.xml
+++ /dev/null
@@ -1,200 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/java/kr/tkrmagid/tetrig/Application.java b/android/app/src/main/java/kr/tkrmagid/tetrig/Application.java
deleted file mode 100644
index b060915..0000000
--- a/android/app/src/main/java/kr/tkrmagid/tetrig/Application.java
+++ /dev/null
@@ -1,29 +0,0 @@
-/*
- * Copyright 2020 Google Inc.
- *
- * 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
- *
- * http://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.
- */
-package kr.tkrmagid.tetrig;
-
-
-
-public class Application extends android.app.Application {
-
-
-
- @Override
- public void onCreate() {
- super.onCreate();
-
- }
-}
diff --git a/android/app/src/main/java/kr/tkrmagid/tetrig/DelegationService.java b/android/app/src/main/java/kr/tkrmagid/tetrig/DelegationService.java
deleted file mode 100644
index 72303f8..0000000
--- a/android/app/src/main/java/kr/tkrmagid/tetrig/DelegationService.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package kr.tkrmagid.tetrig;
-
-
-
-public class DelegationService extends
- com.google.androidbrowserhelper.trusted.DelegationService {
- @Override
- public void onCreate() {
- super.onCreate();
-
-
- }
-}
-
diff --git a/android/app/src/main/java/kr/tkrmagid/tetrig/LauncherActivity.java b/android/app/src/main/java/kr/tkrmagid/tetrig/LauncherActivity.java
deleted file mode 100644
index 2d7ceed..0000000
--- a/android/app/src/main/java/kr/tkrmagid/tetrig/LauncherActivity.java
+++ /dev/null
@@ -1,54 +0,0 @@
-/*
- * Copyright 2020 Google Inc.
- *
- * 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
- *
- * http://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.
- */
-package kr.tkrmagid.tetrig;
-
-import android.content.pm.ActivityInfo;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Bundle;
-
-
-
-public class LauncherActivity
- extends com.google.androidbrowserhelper.trusted.LauncherActivity {
-
-
-
-
- @Override
- protected void onCreate(Bundle savedInstanceState) {
- super.onCreate(savedInstanceState);
- // Setting an orientation crashes the app due to the transparent background on Android 8.0
- // Oreo and below. We only set the orientation on Oreo and above. This only affects the
- // splash screen and Chrome will still respect the orientation.
- // See https://github.com/GoogleChromeLabs/bubblewrap/issues/496 for details.
- if (Build.VERSION.SDK_INT > Build.VERSION_CODES.O) {
- setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT);
- } else {
- setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED);
- }
- }
-
- @Override
- protected Uri getLaunchingUrl() {
- // Get the original launch Url.
- Uri uri = super.getLaunchingUrl();
-
-
-
- return uri;
- }
-}
diff --git a/android/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml b/android/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml
deleted file mode 100644
index d53c148..0000000
--- a/android/app/src/main/res/drawable-anydpi/shortcut_legacy_background.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/drawable-hdpi/ic_notification_icon.png b/android/app/src/main/res/drawable-hdpi/ic_notification_icon.png
deleted file mode 100644
index 6c24d0c..0000000
Binary files a/android/app/src/main/res/drawable-hdpi/ic_notification_icon.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-hdpi/splash.png b/android/app/src/main/res/drawable-hdpi/splash.png
deleted file mode 100644
index afc8fc4..0000000
Binary files a/android/app/src/main/res/drawable-hdpi/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-mdpi/ic_notification_icon.png b/android/app/src/main/res/drawable-mdpi/ic_notification_icon.png
deleted file mode 100644
index b724614..0000000
Binary files a/android/app/src/main/res/drawable-mdpi/ic_notification_icon.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-mdpi/splash.png b/android/app/src/main/res/drawable-mdpi/splash.png
deleted file mode 100644
index 1dfadc3..0000000
Binary files a/android/app/src/main/res/drawable-mdpi/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xhdpi/ic_notification_icon.png b/android/app/src/main/res/drawable-xhdpi/ic_notification_icon.png
deleted file mode 100644
index 6b74f2c..0000000
Binary files a/android/app/src/main/res/drawable-xhdpi/ic_notification_icon.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xhdpi/splash.png b/android/app/src/main/res/drawable-xhdpi/splash.png
deleted file mode 100644
index 7608f28..0000000
Binary files a/android/app/src/main/res/drawable-xhdpi/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png b/android/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png
deleted file mode 100644
index 013a7d1..0000000
Binary files a/android/app/src/main/res/drawable-xxhdpi/ic_notification_icon.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xxhdpi/splash.png b/android/app/src/main/res/drawable-xxhdpi/splash.png
deleted file mode 100644
index 0b0dce4..0000000
Binary files a/android/app/src/main/res/drawable-xxhdpi/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png b/android/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png
deleted file mode 100644
index 8d71436..0000000
Binary files a/android/app/src/main/res/drawable-xxxhdpi/ic_notification_icon.png and /dev/null differ
diff --git a/android/app/src/main/res/drawable-xxxhdpi/splash.png b/android/app/src/main/res/drawable-xxxhdpi/splash.png
deleted file mode 100644
index 1c3482c..0000000
Binary files a/android/app/src/main/res/drawable-xxxhdpi/splash.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index 0480797..0000000
--- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,33 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index 013a7d1..0000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-hdpi/ic_maskable.png b/android/app/src/main/res/mipmap-hdpi/ic_maskable.png
deleted file mode 100644
index 3b7301f..0000000
Binary files a/android/app/src/main/res/mipmap-hdpi/ic_maskable.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index 6b74f2c..0000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-mdpi/ic_maskable.png b/android/app/src/main/res/mipmap-mdpi/ic_maskable.png
deleted file mode 100644
index 7adb207..0000000
Binary files a/android/app/src/main/res/mipmap-mdpi/ic_maskable.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 8d71436..0000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_maskable.png b/android/app/src/main/res/mipmap-xhdpi/ic_maskable.png
deleted file mode 100644
index c130d97..0000000
Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_maskable.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index ca5fcaf..0000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_maskable.png b/android/app/src/main/res/mipmap-xxhdpi/ic_maskable.png
deleted file mode 100644
index 1ae8ad2..0000000
Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_maskable.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index ea26270..0000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png
deleted file mode 100644
index fd9be59..0000000
Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_maskable.png and /dev/null differ
diff --git a/android/app/src/main/res/raw/web_app_manifest.json b/android/app/src/main/res/raw/web_app_manifest.json
deleted file mode 100644
index 8c73d35..0000000
--- a/android/app/src/main/res/raw/web_app_manifest.json
+++ /dev/null
@@ -1 +0,0 @@
-{"name":"TETRIG","short_name":"TETRIG","description":"실시간 대전 블록 스태커 — 솔로·커스텀·랭크 리그전","lang":"ko","start_url":"/index.html","scope":"./","display":"standalone","orientation":"portrait","background_color":"#17161B","theme_color":"#17161B","icons":[{"src":"icon-192.png","sizes":"192x192","type":"image/png"},{"src":"icon-512.png","sizes":"512x512","type":"image/png"},{"src":"icon-512.png","sizes":"512x512","type":"image/png","purpose":"maskable"}]}
\ No newline at end of file
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
deleted file mode 100644
index e66222d..0000000
--- a/android/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
- #F5F5F5
-
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
deleted file mode 100644
index 642eb69..0000000
--- a/android/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,42 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- [{
- \"relation\": [\"delegate_permission/common.handle_all_urls\"],
- \"target\": {
- \"namespace\": \"web\",
- \"site\": \"https://tetrig.pages.dev\"
- }
- }]
-
-
-
diff --git a/android/app/src/main/res/xml/filepaths.xml b/android/app/src/main/res/xml/filepaths.xml
deleted file mode 100644
index a543485..0000000
--- a/android/app/src/main/res/xml/filepaths.xml
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml
deleted file mode 100644
index 5effdb1..0000000
--- a/android/app/src/main/res/xml/shortcuts.xml
+++ /dev/null
@@ -1 +0,0 @@
-
diff --git a/android/build.gradle b/android/build.gradle
deleted file mode 100644
index dc22923..0000000
--- a/android/build.gradle
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * Copyright 2019 Google Inc.
- *
- * 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
- *
- * http://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.
- */
-
-// Top-level build file where you can add configuration options common to all sub-projects/modules.
-
-buildscript {
-
- repositories {
- google()
- mavenCentral()
- }
- dependencies {
- classpath 'com.android.tools.build:gradle:8.9.1'
-
- // NOTE: Do not place your application dependencies here; they belong
- // in the individual module build.gradle files
- }
-}
-
-allprojects {
- repositories {
- google()
- mavenCentral()
- }
-}
-
-task clean(type: Delete) {
- delete rootProject.buildDir
-}
diff --git a/android/gradle.properties b/android/gradle.properties
deleted file mode 100644
index 784a1d4..0000000
--- a/android/gradle.properties
+++ /dev/null
@@ -1,14 +0,0 @@
-# Project-wide Gradle settings.
-# IDE (e.g. Android Studio) users:
-# Gradle settings configured through the IDE *will override*
-# any settings specified in this file.
-# For more details on how to configure your build environment visit
-# http://www.gradle.org/docs/current/userguide/build_environment.html
-# Specifies the JVM arguments used for the daemon process.
-# The setting is particularly useful for tweaking memory settings.
-org.gradle.jvmargs=-Xmx1536m
-# When configured, Gradle will run in incubating parallel mode.
-# This option should only be used with decoupled projects. More details, visit
-# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
-# org.gradle.parallel=true
-android.useAndroidX=true
diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar
deleted file mode 100644
index 5c2d1cf..0000000
Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ
diff --git a/android/manifest-checksum.txt b/android/manifest-checksum.txt
deleted file mode 100644
index 7c0a74e..0000000
--- a/android/manifest-checksum.txt
+++ /dev/null
@@ -1 +0,0 @@
-ea4a002f690d4240040c3d431c1203eca46cc9a5
\ No newline at end of file
diff --git a/android/settings.gradle b/android/settings.gradle
deleted file mode 100644
index e7b4def..0000000
--- a/android/settings.gradle
+++ /dev/null
@@ -1 +0,0 @@
-include ':app'
diff --git a/android/twa-manifest.json b/android/twa-manifest.json
deleted file mode 100644
index 13acbec..0000000
--- a/android/twa-manifest.json
+++ /dev/null
@@ -1,47 +0,0 @@
-{
- "packageId": "kr.tkrmagid.tetrig",
- "host": "tetrig.pages.dev",
- "name": "TETRIG",
- "launcherName": "TETRIG",
- "display": "standalone",
- "themeColor": "#17161B",
- "themeColorDark": "#000000",
- "navigationColor": "#000000",
- "navigationColorDark": "#000000",
- "navigationDividerColor": "#000000",
- "navigationDividerColorDark": "#000000",
- "backgroundColor": "#17161B",
- "enableNotifications": true,
- "startUrl": "/",
- "iconUrl": "https://tetrig.pages.dev/icon-512.png",
- "maskableIconUrl": "https://tetrig.pages.dev/icon-512.png",
- "splashScreenFadeOutDuration": 300,
- "signingKey": {
- "path": "/home/claude/.config/ejclaw/tetrig.keystore",
- "alias": "tetrig"
- },
- "appVersionName": "1.0.0",
- "appVersionCode": 1,
- "shortcuts": [],
- "generatorApp": "bubblewrap-cli",
- "webManifestUrl": "https://tetrig.pages.dev/manifest.webmanifest",
- "fallbackType": "customtabs",
- "features": {},
- "alphaDependencies": {
- "enabled": false
- },
- "enableSiteSettingsShortcut": true,
- "isChromeOSOnly": false,
- "isMetaQuest": false,
- "fullScopeUrl": "https://tetrig.pages.dev/",
- "minSdkVersion": 21,
- "orientation": "portrait",
- "fingerprints": [],
- "additionalTrustedOrigins": [],
- "retainedBundles": [],
- "protocolHandlers": [],
- "fileHandlers": [],
- "launchHandlerClientMode": "",
- "displayOverride": [],
- "appVersion": "1.0.0"
-}
\ No newline at end of file
diff --git a/app/.gitignore b/app/.gitignore
new file mode 100644
index 0000000..e1b3c22
--- /dev/null
+++ b/app/.gitignore
@@ -0,0 +1,11 @@
+build/
+.gradle/
+local.properties
+keystore.properties
+.kotlin/
+*.hprof
+captures/
+.idea/
+xcuserdata/
+*.xcodeproj/project.xcworkspace/
+iosApp/iosApp.xcodeproj/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..3ddc4cd
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,6 @@
+plugins {
+ alias(libs.plugins.androidApplication) apply false
+ alias(libs.plugins.kotlinMultiplatform) apply false
+ alias(libs.plugins.composeMultiplatform) apply false
+ alias(libs.plugins.composeCompiler) apply false
+}
diff --git a/app/composeApp/build.gradle.kts b/app/composeApp/build.gradle.kts
new file mode 100644
index 0000000..a02d085
--- /dev/null
+++ b/app/composeApp/build.gradle.kts
@@ -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)
+}
diff --git a/app/composeApp/src/androidMain/AndroidManifest.xml b/app/composeApp/src/androidMain/AndroidManifest.xml
new file mode 100644
index 0000000..5e44231
--- /dev/null
+++ b/app/composeApp/src/androidMain/AndroidManifest.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/MainActivity.kt b/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/MainActivity.kt
new file mode 100644
index 0000000..17f694d
--- /dev/null
+++ b/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/MainActivity.kt
@@ -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() }
+ }
+}
diff --git a/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/data/Store.android.kt b/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/data/Store.android.kt
new file mode 100644
index 0000000..f5b9457
--- /dev/null
+++ b/app/composeApp/src/androidMain/kotlin/kr/tkrmagid/tetrig/data/Store.android.kt
@@ -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()
diff --git a/app/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.png b/app/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.png
new file mode 100644
index 0000000..df6d225
Binary files /dev/null and b/app/composeApp/src/androidMain/res/drawable/ic_launcher_foreground.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..9fd5c0f
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..9fd5c0f
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 0000000..90d2e26
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..f0d0662
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 0000000..6760f55
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a08e51e
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 0000000..9c9be67
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..933bacc
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000..0d195e6
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..195aa84
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 0000000..99b209e
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..d24dc7c
Binary files /dev/null and b/app/composeApp/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/composeApp/src/androidMain/res/values-night/themes.xml b/app/composeApp/src/androidMain/res/values-night/themes.xml
new file mode 100644
index 0000000..64e3752
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/values-night/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/composeApp/src/androidMain/res/values/colors.xml b/app/composeApp/src/androidMain/res/values/colors.xml
new file mode 100644
index 0000000..e6e099b
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/values/colors.xml
@@ -0,0 +1,5 @@
+
+
+ #17161B
+ #EDE7DA
+
diff --git a/app/composeApp/src/androidMain/res/values/strings.xml b/app/composeApp/src/androidMain/res/values/strings.xml
new file mode 100644
index 0000000..228ae3c
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+
+ TETRIG
+
diff --git a/app/composeApp/src/androidMain/res/values/themes.xml b/app/composeApp/src/androidMain/res/values/themes.xml
new file mode 100644
index 0000000..dabcda8
--- /dev/null
+++ b/app/composeApp/src/androidMain/res/values/themes.xml
@@ -0,0 +1,8 @@
+
+
+
+
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/data/Store.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/data/Store.kt
new file mode 100644
index 0000000..64e0c56
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/data/Store.kt
@@ -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()
+
+ 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())
+ }
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt
new file mode 100644
index 0000000..cb26ba5
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/engine/Engine.kt
@@ -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 = 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)
+
+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 = { Random.nextDouble() }) {
+ 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 -> 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 = 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(): Pair? {
+ 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
+ }
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/game/GameState.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/game/GameState.kt
new file mode 100644
index 0000000..cf44f2e
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/game/GameState.kt
@@ -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(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 -> ""
+ }
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/App.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/App.kt
new file mode 100644
index 0000000..30ef571
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/App.kt
@@ -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.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)
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Components.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Components.kt
new file mode 100644
index 0000000..4445df0
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Components.kt
@@ -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,
+ )
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/GameScreen.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/GameScreen.kt
new file mode 100644
index 0000000..2d75c14
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/GameScreen.kt
@@ -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? {
+ 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, 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()
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/MainScreen.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/MainScreen.kt
new file mode 100644
index 0000000..b7cc2b6
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/MainScreen.kt
@@ -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))
+ }
+ }
+ }
+ }
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Responsive.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Responsive.kt
new file mode 100644
index 0000000..60eba43
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Responsive.kt
@@ -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,
+ )
+}
diff --git a/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Theme.kt b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Theme.kt
new file mode 100644
index 0000000..3670e65
--- /dev/null
+++ b/app/composeApp/src/commonMain/kotlin/kr/tkrmagid/tetrig/ui/Theme.kt
@@ -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)
+}
diff --git a/app/composeApp/src/commonTest/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt b/app/composeApp/src/commonTest/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt
new file mode 100644
index 0000000..fda1a8b
--- /dev/null
+++ b/app/composeApp/src/commonTest/kotlin/kr/tkrmagid/tetrig/engine/EngineTest.kt
@@ -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): Set =
+ cells.map { "${it[0]},${it[1]}" }.toSet()
+
+ private fun kickStr(k: Array) = 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))
+ }
+}
diff --git a/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/MainViewController.kt b/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/MainViewController.kt
new file mode 100644
index 0000000..ecb18b9
--- /dev/null
+++ b/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/MainViewController.kt
@@ -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() }
diff --git a/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/data/Store.ios.kt b/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/data/Store.ios.kt
new file mode 100644
index 0000000..6c36489
--- /dev/null
+++ b/app/composeApp/src/iosMain/kotlin/kr/tkrmagid/tetrig/data/Store.ios.kt
@@ -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()
diff --git a/app/gradle.properties b/app/gradle.properties
new file mode 100644
index 0000000..78a8dc6
--- /dev/null
+++ b/app/gradle.properties
@@ -0,0 +1,14 @@
+org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1g
+org.gradle.caching=true
+org.gradle.configuration-cache=false
+org.gradle.parallel=true
+
+android.useAndroidX=true
+android.nonTransitiveRClass=true
+
+kotlin.code.style=official
+kotlin.daemon.jvmargs=-Xmx2g
+
+# 버전은 CI/로컬에서 -PappVersionName -PappVersionCode 로 덮어쓸 수 있다.
+appVersionName=2.0.0
+appVersionCode=2
diff --git a/app/gradle/libs.versions.toml b/app/gradle/libs.versions.toml
new file mode 100644
index 0000000..a8ee687
--- /dev/null
+++ b/app/gradle/libs.versions.toml
@@ -0,0 +1,17 @@
+[versions]
+agp = "8.13.2"
+kotlin = "2.2.20"
+compose = "1.10.3"
+androidx-activityCompose = "1.11.0"
+androidx-lifecycle = "2.9.4"
+
+[libraries]
+androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activityCompose" }
+androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
+kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
+
+[plugins]
+androidApplication = { id = "com.android.application", version.ref = "agp" }
+kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose" }
+composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..a4b76b9
Binary files /dev/null and b/app/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties
similarity index 93%
rename from android/gradle/wrapper/gradle-wrapper.properties
rename to app/gradle/wrapper/gradle-wrapper.properties
index 719e736..4f5eb9d 100644
--- a/android/gradle/wrapper/gradle-wrapper.properties
+++ b/app/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-zipStoreBase=GRADLE_USER_HOME
-zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
networkTimeout=10000
validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/android/gradlew b/app/gradlew
similarity index 98%
rename from android/gradlew
rename to app/gradlew
index b740cf1..f5feea6 100755
--- a/android/gradlew
+++ b/app/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -84,7 +86,8 @@ done
# 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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+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
diff --git a/android/gradlew.bat b/app/gradlew.bat
similarity index 95%
rename from android/gradlew.bat
rename to app/gradlew.bat
index 7101f8e..9b42019 100644
--- a/android/gradlew.bat
+++ b/app/gradlew.bat
@@ -13,6 +13,8 @@
@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 ##########################################################################
diff --git a/app/iosApp/iosApp/ContentView.swift b/app/iosApp/iosApp/ContentView.swift
new file mode 100644
index 0000000..89799d5
--- /dev/null
+++ b/app/iosApp/iosApp/ContentView.swift
@@ -0,0 +1,19 @@
+import SwiftUI
+import UIKit
+import ComposeApp
+
+struct ComposeView: UIViewControllerRepresentable {
+ func makeUIViewController(context: Context) -> UIViewController {
+ // Kotlin 쪽 MainViewController() — commonMain 의 App() 을 그대로 띄운다.
+ MainViewControllerKt.MainViewController()
+ }
+
+ func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
+}
+
+struct ContentView: View {
+ var body: some View {
+ ComposeView()
+ .ignoresSafeArea(.keyboard)
+ }
+}
diff --git a/app/iosApp/iosApp/iOSApp.swift b/app/iosApp/iosApp/iOSApp.swift
new file mode 100644
index 0000000..215dc02
--- /dev/null
+++ b/app/iosApp/iosApp/iOSApp.swift
@@ -0,0 +1,11 @@
+import SwiftUI
+
+@main
+struct iOSApp: App {
+ var body: some Scene {
+ WindowGroup {
+ ContentView()
+ .ignoresSafeArea(.all)
+ }
+ }
+}
diff --git a/app/iosApp/project.yml b/app/iosApp/project.yml
new file mode 100644
index 0000000..dd3458c
--- /dev/null
+++ b/app/iosApp/project.yml
@@ -0,0 +1,53 @@
+# XcodeGen 설정 — macOS 에서 `xcodegen generate` 로 iosApp.xcodeproj 를 만든다.
+# brew install xcodegen
+# cd app/iosApp && xcodegen generate && open iosApp.xcodeproj
+# Gradle 이 만든 ComposeApp.framework 를 링크한다.
+name: iosApp
+options:
+ bundleIdPrefix: kr.tkrmagid
+ deploymentTarget:
+ iOS: "15.0"
+
+settings:
+ base:
+ PRODUCT_BUNDLE_IDENTIFIER: kr.tkrmagid.tetrig
+ MARKETING_VERSION: "2.0.0"
+ CURRENT_PROJECT_VERSION: "2"
+ SWIFT_VERSION: "5.0"
+ TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
+ ENABLE_USER_SCRIPT_SANDBOXING: NO
+
+targets:
+ iosApp:
+ type: application
+ platform: iOS
+ sources:
+ - path: iosApp
+ info:
+ path: iosApp/Info.plist
+ properties:
+ CFBundleDisplayName: TETRIG
+ UILaunchScreen: {}
+ UISupportedInterfaceOrientations:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ UISupportedInterfaceOrientations~ipad:
+ - UIInterfaceOrientationPortrait
+ - UIInterfaceOrientationPortraitUpsideDown
+ - UIInterfaceOrientationLandscapeLeft
+ - UIInterfaceOrientationLandscapeRight
+ UIRequiresFullScreen: false
+ settings:
+ base:
+ FRAMEWORK_SEARCH_PATHS:
+ - "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
+ OTHER_LDFLAGS:
+ - "-framework"
+ - "ComposeApp"
+ preBuildScripts:
+ - name: Build Compose framework
+ script: |
+ cd "$SRCROOT/../"
+ ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
+ basedOnDependencyAnalysis: false
diff --git a/app/keystore.properties.sample b/app/keystore.properties.sample
new file mode 100644
index 0000000..9f9d9a3
--- /dev/null
+++ b/app/keystore.properties.sample
@@ -0,0 +1,5 @@
+# 복사해서 keystore.properties 로 쓴다 (git 에 올리지 않는다).
+storeFile=/absolute/path/to/tetrig.keystore
+storePassword=CHANGE_ME
+keyAlias=tetrig
+keyPassword=CHANGE_ME
diff --git a/app/settings.gradle.kts b/app/settings.gradle.kts
new file mode 100644
index 0000000..11f6bb6
--- /dev/null
+++ b/app/settings.gradle.kts
@@ -0,0 +1,30 @@
+rootProject.name = "TETRIG"
+
+pluginManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositories {
+ google {
+ mavenContent {
+ includeGroupAndSubgroups("androidx")
+ includeGroupAndSubgroups("com.android")
+ includeGroupAndSubgroups("com.google")
+ }
+ }
+ mavenCentral()
+ }
+}
+
+include(":composeApp")
diff --git a/scripts/release.sh b/scripts/release.sh
new file mode 100755
index 0000000..09e3966
--- /dev/null
+++ b/scripts/release.sh
@@ -0,0 +1,77 @@
+#!/usr/bin/env bash
+# TETRIG 릴리스 스크립트
+#
+# scripts/release.sh [major|minor|patch] # 기본 patch
+#
+# 하는 일:
+# 1) app/gradle.properties 의 appVersionName 을 semver 로 올리고 appVersionCode 를 +1
+# 2) 테스트 → 릴리스 APK/AAB 빌드 (keystore.properties 로 서명)
+# 3) 버전 커밋 + vX.Y.Z 태그 + push
+# 4) Gitea 릴리스 생성 후 APK/AAB 업로드
+#
+# 필요 환경:
+# JAVA_HOME (JDK 17+), ANDROID_HOME, app/keystore.properties
+# GITEA_TOKEN (없으면 ~/.config/ejclaw/secrets.json 의 git.tkrmagid.kr 토큰 사용)
+set -euo pipefail
+
+BUMP="${1:-patch}"
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+PROPS="$ROOT/app/gradle.properties"
+GITEA_HOST="https://git.tkrmagid.kr"
+REPO="tkrmagid/tetrig"
+
+[ -f "$ROOT/app/keystore.properties" ] || { echo "app/keystore.properties 가 없습니다 (서명 불가)"; exit 1; }
+
+cur_name=$(grep -E '^appVersionName=' "$PROPS" | cut -d= -f2)
+cur_code=$(grep -E '^appVersionCode=' "$PROPS" | cut -d= -f2)
+IFS=. read -r MA MI PA <<< "$cur_name"
+case "$BUMP" in
+ major) MA=$((MA+1)); MI=0; PA=0 ;;
+ minor) MI=$((MI+1)); PA=0 ;;
+ patch) PA=$((PA+1)) ;;
+ *) echo "usage: $0 [major|minor|patch]"; exit 1 ;;
+esac
+new_name="$MA.$MI.$PA"
+new_code=$((cur_code+1))
+echo "버전: $cur_name($cur_code) → $new_name($new_code)"
+
+sed -i "s/^appVersionName=.*/appVersionName=$new_name/" "$PROPS"
+sed -i "s/^appVersionCode=.*/appVersionCode=$new_code/" "$PROPS"
+
+cd "$ROOT/app"
+./gradlew :composeApp:testDebugUnitTest :composeApp:assembleRelease :composeApp:bundleRelease \
+ -PappVersionName="$new_name" -PappVersionCode="$new_code"
+
+OUT="$ROOT/app/composeApp/build/outputs"
+APK="$OUT/apk/release/composeApp-release.apk"
+AAB="$OUT/bundle/release/composeApp-release.aab"
+[ -f "$APK" ] || { echo "APK 빌드 실패"; exit 1; }
+
+DIST="$ROOT/app/build/dist"
+mkdir -p "$DIST"
+cp "$APK" "$DIST/TETRIG-v$new_name.apk"
+cp "$AAB" "$DIST/TETRIG-v$new_name.aab"
+
+cd "$ROOT"
+git add app/gradle.properties
+git commit -m "release: v$new_name (versionCode $new_code)"
+git tag -a "v$new_name" -m "TETRIG v$new_name"
+
+TOKEN="${GITEA_TOKEN:-$(node -e 'console.log(require(process.env.HOME+"/.config/ejclaw/secrets.json").credentials["git.tkrmagid.kr"].token)' 2>/dev/null || true)}"
+[ -n "$TOKEN" ] || { echo "GITEA_TOKEN 이 없습니다"; exit 1; }
+
+git push "$GITEA_HOST/$REPO.git" HEAD:main --tags
+
+echo "Gitea 릴리스 생성..."
+REL_ID=$(curl -sS -X POST "$GITEA_HOST/api/v1/repos/$REPO/releases" \
+ -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"v$new_name\",\"name\":\"TETRIG v$new_name\",\"body\":\"versionCode $new_code\\n\\n안드로이드: TETRIG-v$new_name.apk 를 내려받아 설치하세요.\\nPlay Console 업로드: TETRIG-v$new_name.aab\",\"draft\":false,\"prerelease\":false}" \
+ | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d);if(!j.id){console.error(d);process.exit(1)}console.log(j.id)})')
+
+for f in "TETRIG-v$new_name.apk" "TETRIG-v$new_name.aab"; do
+ echo " 업로드: $f"
+ curl -sS -X POST "$GITEA_HOST/api/v1/repos/$REPO/releases/$REL_ID/assets?name=$f" \
+ -H "Authorization: token $TOKEN" -F "attachment=@$DIST/$f" > /dev/null
+done
+
+echo "완료: $GITEA_HOST/$REPO/releases/tag/v$new_name"