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"