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

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

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

9
.gitignore vendored
View File

@@ -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

View File

@@ -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 에셋·명칭 복제 금지.

View File

@@ -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'
}

View File

@@ -1,200 +0,0 @@
<!--
Copyright 2019 Google Inc. All Rights Reserved.
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.
-->
<!-- The "package" attribute is rewritten by the Gradle build with the value of applicationId.
It is still required here, as it is used to derive paths, for instance when referring
to an Activity by ".MyActivity" instead of the full name. If more Activities are added to the
application, the package attribute will need to reflect the correct path in order to use
the abbreviated format. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="kr.tkrmagid.tetrig">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:name="Application"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/appName"
android:manageSpaceActivity="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:supportsRtl="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar">
<meta-data
android:name="asset_statements"
android:resource="@string/assetStatements" />
<meta-data
android:name="web_manifest_url"
android:value="@string/webManifestUrl" />
<meta-data
android:name="twa_generator"
android:value="@string/generatorApp" />
<activity android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:exported="false"
android:enabled="true"
android:excludeFromRecents="true">
<meta-data
android:name="android.support.customtabs.trusted.MANAGE_SPACE_URL"
android:value="@string/launchUrl" />
<intent-filter>
<action android:name="android.intent.action.APPLICATION_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity android:name="LauncherActivity"
android:alwaysRetainTaskState="true"
android:label="@string/launcherName"
android:exported="true">
<meta-data android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="@string/launchUrl" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/colorPrimary" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
android:resource="@color/colorPrimaryDark" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/navigationColor" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
android:resource="@color/navigationColorDark" />
<meta-data
android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR"
android:resource="@color/navigationDividerColor" />
<meta-data
android:name="androix.browser.trusted.NAVIGATION_BAR_DIVIDER_COLOR_DARK"
android:resource="@color/navigationDividerColorDark" />
<meta-data android:name="android.support.customtabs.trusted.SPLASH_IMAGE_DRAWABLE"
android:resource="@drawable/splash"/>
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_BACKGROUND_COLOR"
android:resource="@color/backgroundColor"/>
<meta-data android:name="android.support.customtabs.trusted.SPLASH_SCREEN_FADE_OUT_DURATION"
android:value="@integer/splashScreenFadeOutDuration"/>
<meta-data android:name="android.support.customtabs.trusted.FILE_PROVIDER_AUTHORITY"
android:value="@string/providerAuthority"/>
<meta-data android:name="android.app.shortcuts" android:resource="@xml/shortcuts" />
<meta-data android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
android:value="@string/fallbackType" />
<meta-data android:name="android.support.customtabs.trusted.SCREEN_ORIENTATION"
android:value="@string/orientation"/>
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https"
android:host="@string/hostName"
/>
</intent-filter>
</activity>
<activity android:name="com.google.androidbrowserhelper.trusted.FocusActivity" />
<activity android:name="com.google.androidbrowserhelper.trusted.WebViewFallbackActivity"
android:configChanges="orientation|screenSize" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/providerAuthority"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/filepaths" />
</provider>
<service
android:name=".DelegationService"
android:enabled="@bool/enableNotification"
android:exported="@bool/enableNotification">
<meta-data
android:name="android.support.customtabs.trusted.SMALL_ICON"
android:resource="@drawable/ic_notification_icon" />
<intent-filter>
<action android:name="android.support.customtabs.trusted.TRUSTED_WEB_ACTIVITY_SERVICE"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</service>
<activity android:name="com.google.androidbrowserhelper.trusted.NotificationPermissionRequestActivity" />
</application>
</manifest>

View File

@@ -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();
}
}

View File

@@ -1,14 +0,0 @@
package kr.tkrmagid.tetrig;
public class DelegationService extends
com.google.androidbrowserhelper.trusted.DelegationService {
@Override
public void onCreate() {
super.onCreate();
}
}

View File

@@ -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;
}
}

View File

@@ -1,25 +0,0 @@
<!--
Copyright 2020 Google Inc. All Rights Reserved.
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.
-->
<inset xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:inset="2dp">
<aapt:attr name="android:drawable">
<shape android:shape="oval">
<solid android:color="@color/shortcut_background" />
<size android:width="44dp" android:height="44dp" />
</shape>
</aapt:attr>
</inset>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

View File

@@ -1,33 +0,0 @@
<!--
Copyright 2019 Google Inc. All Rights Reserved.
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.
-->
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<background>
<layer-list>
<item android:drawable="@android:color/white" />
<!--
The paddings below give the icons a similar proportion as the
icon generated by WebAPKs.
-->
<item android:drawable="@mipmap/ic_maskable"
android:top="8.5dp"
android:right="8.5dp"
android:left="8.5dp"
android:bottom="8.5dp" />
</layer-list>
</background>
<foreground android:drawable="@android:color/transparent" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -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"}]}

View File

@@ -1,18 +0,0 @@
<!--
Copyright 2020 Google Inc. All Rights Reserved.
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.
-->
<resources>
<color name="shortcut_background">#F5F5F5</color>
</resources>

View File

@@ -1,42 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2021 Google Inc. All Rights Reserved.
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.
-->
<resources>
<!--
The "fullscreen" and "fullscreen-sticky" TWA manifest values correspond to "immersive" and "sticky-immersive"
AndroidManifest values.
-->
<!--
This variable below expresses the relationship between the app and the site,
as documented in the TWA documentation at
https://developers.google.com/web/updates/2017/10/using-twa#set_up_digital_asset_links_in_an_android_app
and is injected into the AndroidManifest.xml
-->
<string name="assetStatements">
[{
\"relation\": [\"delegate_permission/common.handle_all_urls\"],
\"target\": {
\"namespace\": \"web\",
\"site\": \"https://tetrig.pages.dev\"
}
}]
</string>
</resources>

View File

@@ -1,18 +0,0 @@
<!--
Copyright 2019 Google Inc. All Rights Reserved.
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.
-->
<paths>
<files-path path="twa_splash/" name="twa_splash" />
</paths>

View File

@@ -1 +0,0 @@
<shortcuts xmlns:android='http://schemas.android.com/apk/res/android' />

View File

@@ -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
}

View File

@@ -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

Binary file not shown.

View File

@@ -1 +0,0 @@
ea4a002f690d4240040c3d431c1203eca46cc9a5

View File

@@ -1 +0,0 @@
include ':app'

View File

@@ -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"
}

11
app/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
build/
.gradle/
local.properties
keystore.properties
.kotlin/
*.hprof
captures/
.idea/
xcuserdata/
*.xcodeproj/project.xcworkspace/
iosApp/iosApp.xcodeproj/

6
app/build.gradle.kts Normal file
View File

@@ -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
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,404 @@
package kr.tkrmagid.tetrig.engine
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.random.Random
/**
* TETRIG 코어 엔진 — 플랫폼 독립(commonMain). Android/iOS 공용.
*
* 좌표 규약 (명세서 §4.1):
* - 보드 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 맨 아래.
* - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용.
* - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW.
*/
object Config {
const val BOARD_W = 10
const val BOARD_H = 40 // 내부 높이 (하단 20행만 표시)
const val VISIBLE_H = 20
const val NEXT_COUNT = 5
const val LOCK_DELAY_MS = 500
const val MAX_LOCK_RESETS = 15
const val DEFAULT_GRAVITY_MS = 800
const val DAS_MS = 133
const val ARR_MS = 10
const val SDF = 20
}
val PIECE_TYPES = charArrayOf('I', 'O', 'T', 'S', 'Z', 'J', 'L')
/** 피스 색 ARGB (명세서 §13). 'G' = 가비지. */
val PIECE_COLORS: Map<Char, Long> = mapOf(
'I' to 0xFF3EC1B6, 'O' to 0xFFF5C531, 'T' to 0xFFA76BF2, 'S' to 0xFF7FCC4C,
'Z' to 0xFFF25C4C, 'J' to 0xFF4C7DF2, 'L' to 0xFFF2913D, 'G' to 0xFF46464F,
)
private class Spawn(val box: Int, val cells: Array<IntArray>)
private val SPAWN: Map<Char, Spawn> = mapOf(
'I' to Spawn(4, arrayOf(intArrayOf(0, 2), intArrayOf(1, 2), intArrayOf(2, 2), intArrayOf(3, 2))),
'O' to Spawn(2, arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(0, 1), intArrayOf(1, 1))),
'T' to Spawn(3, arrayOf(intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1), intArrayOf(1, 2))),
'S' to Spawn(3, arrayOf(intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(1, 2), intArrayOf(2, 2))),
'Z' to Spawn(3, arrayOf(intArrayOf(0, 2), intArrayOf(1, 2), intArrayOf(1, 1), intArrayOf(2, 1))),
'J' to Spawn(3, arrayOf(intArrayOf(0, 2), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1))),
'L' to Spawn(3, arrayOf(intArrayOf(2, 2), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(2, 1))),
)
/** 스폰 시 박스 원점의 보드 위치 (px, py). 셀은 (px+cx, py+cy). */
private val SPAWN_POS: Map<Char, IntArray> = mapOf(
'I' to intArrayOf(3, 19), 'O' to intArrayOf(4, 21), 'T' to intArrayOf(3, 20),
'S' to intArrayOf(3, 20), 'Z' to intArrayOf(3, 20), 'J' to intArrayOf(3, 20), 'L' to intArrayOf(3, 20),
)
/** CW 회전: 박스 b 에서 (x,y) -> (y, (b-1)-x). */
private fun rotateCW(cells: Array<IntArray>, b: Int): Array<IntArray> =
Array(cells.size) { i -> intArrayOf(cells[i][1], (b - 1) - cells[i][0]) }
private fun buildStates(type: Char): Array<Array<IntArray>> {
val sp = SPAWN.getValue(type)
if (type == 'O') return arrayOf(sp.cells, sp.cells, sp.cells, sp.cells)
val s0 = sp.cells
val s1 = rotateCW(s0, sp.box)
val s2 = rotateCW(s1, sp.box)
val s3 = rotateCW(s2, sp.box)
return arrayOf(s0, s1, s2, s3)
}
/** STATES[type][state] = 셀 목록. */
val STATES: Map<Char, Array<Array<IntArray>>> = PIECE_TYPES.associateWith { buildStates(it) }
// ── 킥테이블 (명세서 §4.1) ──
private val KICKS_JLSTZ: Map<String, Array<IntArray>> = mapOf(
"0>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 1), intArrayOf(0, -2), intArrayOf(-1, -2)),
"1>0" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, -1), intArrayOf(0, 2), intArrayOf(1, 2)),
"1>2" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, -1), intArrayOf(0, 2), intArrayOf(1, 2)),
"2>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 1), intArrayOf(0, -2), intArrayOf(-1, -2)),
"2>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 1), intArrayOf(0, -2), intArrayOf(1, -2)),
"3>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, -1), intArrayOf(0, 2), intArrayOf(-1, 2)),
"3>0" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, -1), intArrayOf(0, 2), intArrayOf(-1, 2)),
"0>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 1), intArrayOf(0, -2), intArrayOf(1, -2)),
// 180° (SRS+)
"0>2" to arrayOf(intArrayOf(0, 0), intArrayOf(0, 1), intArrayOf(1, 1), intArrayOf(-1, 1), intArrayOf(1, 0), intArrayOf(-1, 0)),
"2>0" to arrayOf(intArrayOf(0, 0), intArrayOf(0, -1), intArrayOf(-1, -1), intArrayOf(1, -1), intArrayOf(-1, 0), intArrayOf(1, 0)),
"1>3" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(1, 2), intArrayOf(1, 1), intArrayOf(0, 2), intArrayOf(0, 1)),
"3>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(-1, 2), intArrayOf(-1, 1), intArrayOf(0, 2), intArrayOf(0, 1)),
)
private val KICKS_I: Map<String, Array<IntArray>> = mapOf(
"0>1" to arrayOf(intArrayOf(0, 0), intArrayOf(-2, 0), intArrayOf(1, 0), intArrayOf(-2, -1), intArrayOf(1, 2)),
"1>0" to arrayOf(intArrayOf(0, 0), intArrayOf(2, 0), intArrayOf(-1, 0), intArrayOf(2, 1), intArrayOf(-1, -2)),
"1>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(2, 0), intArrayOf(-1, 2), intArrayOf(2, -1)),
"2>1" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(-2, 0), intArrayOf(1, -2), intArrayOf(-2, 1)),
"2>3" to arrayOf(intArrayOf(0, 0), intArrayOf(2, 0), intArrayOf(-1, 0), intArrayOf(2, 1), intArrayOf(-1, -2)),
"3>2" to arrayOf(intArrayOf(0, 0), intArrayOf(-2, 0), intArrayOf(1, 0), intArrayOf(-2, -1), intArrayOf(1, 2)),
"3>0" to arrayOf(intArrayOf(0, 0), intArrayOf(1, 0), intArrayOf(-2, 0), intArrayOf(1, -2), intArrayOf(-2, 1)),
"0>3" to arrayOf(intArrayOf(0, 0), intArrayOf(-1, 0), intArrayOf(2, 0), intArrayOf(-1, 2), intArrayOf(2, -1)),
// I 180°는 (0,0)만
"0>2" to arrayOf(intArrayOf(0, 0)), "2>0" to arrayOf(intArrayOf(0, 0)),
"1>3" to arrayOf(intArrayOf(0, 0)), "3>1" to arrayOf(intArrayOf(0, 0)),
)
private val ORIGIN = arrayOf(intArrayOf(0, 0))
fun kickTable(type: Char, from: Int, to: Int): Array<IntArray> {
val key = "$from>$to"
return when (type) {
'O' -> ORIGIN
'I' -> KICKS_I[key] ?: ORIGIN
else -> KICKS_JLSTZ[key] ?: ORIGIN
}
}
/** 시드 가능 난수 (mulberry32) — 웹/JS 판과 동일 수열. */
fun makeRng(seed: Int): () -> Double {
var a = seed
return {
a += 0x6D2B79F5
var t = (a xor (a ushr 15)) * (1 or a)
t = (t + ((t xor (t ushr 7)) * (61 or t))) xor t
((t xor (t ushr 14)).toLong() and 0xFFFFFFFFL).toDouble() / 4294967296.0
}
}
/** 7-bag. */
class SevenBag(private val rng: () -> Double = { Random.nextDouble() }) {
private val q = ArrayDeque<Char>()
init {
refill(); refill()
}
private fun refill() {
val bag = PIECE_TYPES.toMutableList()
for (i in bag.indices.reversed()) {
val j = (rng() * (i + 1)).toInt()
val tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp
}
q.addAll(bag)
}
fun next(): Char {
if (q.size <= Config.NEXT_COUNT + 1) refill()
return q.removeFirst()
}
fun peek(n: Int = Config.NEXT_COUNT): List<Char> {
while (q.size < n) refill()
return q.take(n)
}
}
typealias Board = Array<Array<Char?>>
fun createBoard(w: Int = Config.BOARD_W, h: Int = Config.BOARD_H): Board =
Array(h) { arrayOfNulls(w) }
fun pieceCells(type: Char, state: Int, px: Int, py: Int): Array<IntArray> {
val cells = STATES.getValue(type)[state]
return Array(cells.size) { i -> intArrayOf(px + cells[i][0], py + cells[i][1]) }
}
/** x 범위 밖 / 바닥 아래(y<0) / 점유 = 충돌. y >= H (버퍼 위)는 허용. */
fun collides(board: Board, cells: Array<IntArray>): Boolean {
val w = board[0].size
val h = board.size
for (c in cells) {
val x = c[0]
val y = c[1]
if (x < 0 || x >= w || y < 0) return true
if (y < h && board[y][x] != null) return true
}
return false
}
/** 가득 찬 행 제거 후 위에서 빈 행 보충. 제거된 행 수 반환. */
fun clearLines(board: Board): Int {
val w = board[0].size
val h = board.size
val rows = (0 until h).filter { y -> board[y].all { it != null } }
if (rows.isEmpty()) return 0
val rowSet = rows.toHashSet()
val kept = ArrayList<Array<Char?>>((0 until h).filter { it !in rowSet }.map { board[it] })
while (kept.size < h) kept.add(arrayOfNulls(w))
for (y in 0 until h) board[y] = kept[y]
return rows.size
}
fun isBoardEmpty(board: Board): Boolean = board.all { row -> row.all { it == null } }
/** T-spin 판정 (명세서 §4.2). "none" | "mini" | "tspin". */
fun detectTSpin(board: Board, type: Char, state: Int, px: Int, py: Int, lastMoveRotation: Boolean): String {
if (type != 'T' || !lastMoveRotation) return "none"
val w = board[0].size
val h = board.size
val cx = px + 1
val cy = py + 1
fun filled(dx: Int, dy: Int): Boolean {
val x = cx + dx
val y = cy + dy
if (x < 0 || x >= w || y < 0) return true // 벽/바닥 = 채움
if (y >= h) return false // 버퍼 위 = 빔
return board[y][x] != null
}
val tl = filled(-1, 1)
val tr = filled(1, 1)
val bl = filled(-1, -1)
val br = filled(1, -1)
val count = listOf(tl, tr, bl, br).count { it }
if (count < 3) return "none"
val front = when (state) {
0 -> tl to tr
1 -> tr to br
2 -> bl to br
else -> tl to bl
}
return if (front.first && front.second) "tspin" else "mini"
}
/** B2B 대상("어려운" 클리어): Quad 또는 라인 동반 T-spin 계열. */
fun isDifficult(lines: Int, tspin: String): Boolean =
lines == 4 || (tspin != "none" && lines >= 1)
// ── 점수·레벨 (명세서 §5) ──
/** 레벨: 10라인 = 1레벨. */
fun levelForLines(lines: Int): Int = lines / 10 + 1
/** 낙하간격(ms) = 0.8^(level-1) × 1000, 하한 50ms. */
fun gravityMsForLevel(level: Int): Int =
maxOf((0.8.pow(level - 1) * 1000).roundToInt(), 50)
/** 라인 클리어 1회 점수 (×레벨 적용). */
fun scoreClear(res: LockResult, level: Int = 1): Int {
val lines = res.lines
var base = when {
res.tspin == "tspin" -> intArrayOf(0, 800, 1200, 1600).getOrElse(lines) { 0 }
res.tspin == "mini" -> if (lines >= 1) 200 else 100
else -> intArrayOf(0, 100, 300, 500, 800).getOrElse(lines) { 0 }
}
if (res.b2b >= 1 && isDifficult(lines, res.tspin)) base = (base * 1.5).roundToInt()
var score = base * level
if (lines > 0 && res.combo >= 1) score += 50 * res.combo * level
if (res.allClear) {
var ac = intArrayOf(0, 800, 1200, 1800, 2000).getOrElse(lines) { 0 }
if (lines == 4 && res.b2b >= 1) ac = 3200
score += ac * level
}
return score
}
data class LockResult(
val type: Char,
val lines: Int,
val tspin: String,
val allClear: Boolean,
val combo: Int,
val b2b: Int,
val difficult: Boolean,
)
class Active(var type: Char, var state: Int, var x: Int, var y: Int)
/** 게임 상태. UI 가 타이밍을 구동한다. */
class TetrigGame(seed: Int = 1) {
var board: Board = createBoard()
private set
private val bag = SevenBag(makeRng(seed))
var hold: Char? = null
private set
var holdUsed = false
private set
var over = false
private set
var lastMoveRotation = false
var lockResets = 0
var combo = -1
private set
var b2b = -1
private set
var lastClear: LockResult? = null
private set
lateinit var active: Active
private set
init {
spawn()
}
fun cells(a: Active = active): Array<IntArray> = pieceCells(a.type, a.state, a.x, a.y)
fun spawn(type: Char = bag.next()): Boolean {
val pos = SPAWN_POS.getValue(type)
active = Active(type, 0, pos[0], pos[1])
lastMoveRotation = false
lockResets = 0
if (collides(board, cells())) {
over = true
return false
}
return true
}
fun peekNext(n: Int = Config.NEXT_COUNT): List<Char> = bag.peek(n)
fun canFall(): Boolean {
val c = cells().map { intArrayOf(it[0], it[1] - 1) }.toTypedArray()
return !collides(board, c)
}
fun resetLockDelay(): Boolean {
if (lockResets >= Config.MAX_LOCK_RESETS) return false
lockResets++
return true
}
fun move(dx: Int): Boolean {
if (over) return false
val c = pieceCells(active.type, active.state, active.x + dx, active.y)
if (collides(board, c)) return false
active.x += dx
lastMoveRotation = false
return true
}
fun softDrop(): Boolean {
if (over) return false
val c = pieceCells(active.type, active.state, active.x, active.y - 1)
if (collides(board, c)) return false
active.y -= 1
lastMoveRotation = false
return true
}
/** dir: "cw" | "ccw" | "180". */
fun rotate(dir: String): Boolean {
if (over) return false
val a = active
val to = when (dir) {
"cw" -> (a.state + 1) % 4
"ccw" -> (a.state + 3) % 4
else -> (a.state + 2) % 4
}
for (k in kickTable(a.type, a.state, to)) {
val nx = a.x + k[0]
val ny = a.y + k[1]
if (!collides(board, pieceCells(a.type, to, nx, ny))) {
a.state = to; a.x = nx; a.y = ny
lastMoveRotation = true
return true
}
}
return false
}
fun ghostCells(): Array<IntArray> {
val a = active
var dy = 0
while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - dy - 1))) dy++
return pieceCells(a.type, a.state, a.x, a.y - dy)
}
/** 하드드롭. 떨어진 칸 수와 락 결과를 반환한다. */
fun hardDrop(): Pair<Int, LockResult>? {
if (over) return null
val a = active
var d = 0
while (!collides(board, pieceCells(a.type, a.state, a.x, a.y - d - 1))) d++
a.y -= d
return d to lock()
}
fun holdSwap(): Boolean {
if (over || holdUsed) return false
val cur = active.type
if (hold == null) {
hold = cur
spawn()
} else {
val swap = hold!!
hold = cur
spawn(swap)
}
holdUsed = true
return true
}
fun lock(): LockResult {
val a = active
val tspin = detectTSpin(board, a.type, a.state, a.x, a.y, lastMoveRotation)
for (c in cells()) {
if (c[1] in board.indices) board[c[1]][c[0]] = a.type
}
val cleared = clearLines(board)
val allClear = cleared > 0 && isBoardEmpty(board)
combo = if (cleared > 0) combo + 1 else -1
val diff = isDifficult(cleared, tspin)
if (cleared > 0) b2b = if (diff) b2b + 1 else -1
val result = LockResult(a.type, cleared, tspin, allClear, combo, b2b, diff)
lastClear = result
holdUsed = false
spawn()
return result
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,268 @@
package kr.tkrmagid.tetrig.engine
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
import kotlin.test.assertTrue
class EngineTest {
private fun cellSet(cells: Array<IntArray>): Set<String> =
cells.map { "${it[0]},${it[1]}" }.toSet()
private fun kickStr(k: Array<IntArray>) = k.joinToString(" ") { "${it[0]},${it[1]}" }
// ── 피스 상태/회전 ──
@Test
fun tSpawnCells() {
assertEquals(setOf("0,1", "1,1", "2,1", "1,2"), cellSet(STATES.getValue('T')[0]))
}
@Test
fun tCwState() {
assertEquals(setOf("1,0", "1,1", "1,2", "2,1"), cellSet(STATES.getValue('T')[1]))
}
@Test
fun iCwState() {
assertEquals(setOf("2,0", "2,1", "2,2", "2,3"), cellSet(STATES.getValue('I')[1]))
}
@Test
fun oNoRotation() {
val s0 = cellSet(STATES.getValue('O')[0])
assertEquals(s0, cellSet(STATES.getValue('O')[1]))
assertEquals(s0, cellSet(STATES.getValue('O')[3]))
}
@Test
fun allPiecesHaveFourStates() {
for (t in PIECE_TYPES) assertEquals(4, STATES.getValue(t).size)
}
// ── 킥테이블 ──
@Test
fun kickTablesMatchSpec() {
assertEquals("0,0 -1,0 -1,1 0,-2 -1,-2", kickStr(kickTable('T', 0, 1)))
assertEquals("0,0 -2,0 1,0 -2,-1 1,2", kickStr(kickTable('I', 0, 1)))
assertEquals("0,0 0,1 1,1 -1,1 1,0 -1,0", kickStr(kickTable('T', 0, 2)))
assertEquals("0,0", kickStr(kickTable('I', 0, 2)))
assertEquals("0,0", kickStr(kickTable('O', 0, 1)))
}
@Test
fun wallKickUsesOffsetWhenBlocked() {
val board = createBoard()
board[5][5] = 'X'
assertTrue(collides(board, pieceCells('T', 1, 4, 5)))
assertFalse(collides(board, pieceCells('T', 1, 3, 5)))
}
@Test
fun rotateAppliesKickOffset() {
val g = TetrigGame(11)
// 왼쪽 벽에 T를 붙이고 CCW 회전 → 킥으로 성사되어야 한다.
val a = g.active
a.type = 'T'; a.state = 0; a.x = -1; a.y = 5
assertTrue(g.rotate("cw"))
assertTrue(g.lastMoveRotation)
}
// ── 7-bag ──
@Test
fun sevenBagContainsAllEachRound() {
val bag = SevenBag(makeRng(42))
repeat(20) {
val seven = (0 until 7).map { bag.next() }.sorted()
assertEquals(PIECE_TYPES.toList().sorted(), seven)
}
}
@Test
fun peekMatchesConsumeOrder() {
val bag = SevenBag(makeRng(7))
val peek = bag.peek(5)
assertEquals(5, peek.size)
for (i in 0 until 5) assertEquals(peek[i], bag.next())
}
@Test
fun sameSeedSameSequence() {
val a = SevenBag(makeRng(123))
val b = SevenBag(makeRng(123))
repeat(30) { assertEquals(a.next(), b.next()) }
}
// ── 충돌/클리어 ──
@Test
fun collidesWallsFloorOccupied() {
val bd = createBoard()
assertTrue(collides(bd, arrayOf(intArrayOf(-1, 0))))
assertTrue(collides(bd, arrayOf(intArrayOf(10, 0))))
assertTrue(collides(bd, arrayOf(intArrayOf(0, -1))))
assertFalse(collides(bd, arrayOf(intArrayOf(5, 45))))
bd[3][4] = 'T'
assertTrue(collides(bd, arrayOf(intArrayOf(4, 3))))
}
@Test
fun clearLinesRemovesFullAndDrops() {
val bd = createBoard()
for (x in 0 until 10) bd[0][x] = 'I'
bd[1][0] = 'T'
assertEquals(1, clearLines(bd))
assertEquals('T', bd[0][0])
for (x in 1 until 10) assertEquals(null, bd[0][x])
}
@Test
fun boardEmpty() {
val bd = createBoard()
assertTrue(isBoardEmpty(bd))
bd[0][0] = 'I'
assertFalse(isBoardEmpty(bd))
}
// ── T-spin 판정 ──
@Test
fun tspinFull() {
val bd = createBoard(10, 6)
bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X'
assertEquals("tspin", detectTSpin(bd, 'T', 2, 4, 1, true))
}
@Test
fun tspinMini() {
val bd = createBoard(10, 6)
bd[1][4] = 'X'; bd[3][4] = 'X'; bd[3][6] = 'X'
assertEquals("mini", detectTSpin(bd, 'T', 2, 4, 1, true))
}
@Test
fun tspinNoneTooFewCorners() {
val bd = createBoard(10, 6)
bd[1][4] = 'X'
assertEquals("none", detectTSpin(bd, 'T', 2, 4, 1, true))
}
@Test
fun tspinNoneWhenNotRotation() {
val bd = createBoard(10, 6)
bd[1][4] = 'X'; bd[1][6] = 'X'; bd[3][4] = 'X'
assertEquals("none", detectTSpin(bd, 'T', 2, 4, 1, false))
}
@Test
fun tspinFloorCountsAsFilled() {
val bd = createBoard(10, 6)
assertEquals("none", detectTSpin(bd, 'T', 2, 4, -1, true))
bd[1][4] = 'X'
assertEquals("tspin", detectTSpin(bd, 'T', 2, 4, -1, true))
}
@Test
fun difficultRule() {
assertTrue(isDifficult(4, "none"))
assertTrue(isDifficult(2, "tspin"))
assertTrue(isDifficult(1, "mini"))
assertFalse(isDifficult(3, "none"))
assertFalse(isDifficult(0, "tspin"))
}
// ── 게임 흐름 ──
@Test
fun lockResetCappedAt15() {
val g = TetrigGame(1)
var ok = 0
repeat(25) { if (g.resetLockDelay()) ok++ }
assertEquals(Config.MAX_LOCK_RESETS, ok)
}
@Test
fun spawnMoveHardDropCycle() {
val g = TetrigGame(3)
assertFalse(g.over)
val before = g.active.type
val (dropped, res) = g.hardDrop()!!
assertEquals(before, res.type)
assertTrue(dropped > 0)
assertNotNull(g.active)
assertFalse(g.holdUsed)
}
@Test
fun holdOncePerPiece() {
val g = TetrigGame(9)
val first = g.active.type
assertTrue(g.holdSwap())
assertEquals(first, g.hold)
assertFalse(g.holdSwap())
g.hardDrop()
assertFalse(g.holdUsed)
assertTrue(g.holdSwap())
}
@Test
fun comboAndB2b() {
val g = TetrigGame(2)
fun quad(): LockResult {
for (y in 0 until 4) for (x in 1 until 10) g.board[y][x] = 'X'
val a = g.active
a.type = 'I'; a.state = 1; a.x = -2; a.y = 0
g.lastMoveRotation = false
return g.hardDrop()!!.second
}
val r1 = quad()
assertEquals(4, r1.lines)
assertEquals(0, r1.b2b)
val r2 = quad()
assertEquals(4, r2.lines)
assertEquals(1, r2.b2b)
}
@Test
fun topOut() {
val g = TetrigGame(1)
for (y in 18 until 24) for (x in 0 until 10) g.board[y][x] = 'X'
assertFalse(g.spawn('T'))
assertTrue(g.over)
}
// ── 점수·레벨 (명세서 §5) ──
private fun mk(
lines: Int,
tspin: String = "none",
allClear: Boolean = false,
combo: Int = -1,
b2b: Int = -1,
) = LockResult('T', lines, tspin, allClear, combo, b2b, isDifficult(lines, tspin))
@Test
fun scoreBasicClears() {
assertEquals(100, scoreClear(mk(1), 1))
assertEquals(300, scoreClear(mk(2), 1))
assertEquals(800, scoreClear(mk(4), 1))
assertEquals(300, scoreClear(mk(1), 3))
}
@Test
fun scoreTspinB2bComboAllClear() {
assertEquals(1200, scoreClear(mk(2, tspin = "tspin"), 1))
assertEquals(1200, scoreClear(mk(4, b2b = 1), 1))
assertEquals(150, scoreClear(mk(1, combo = 1), 1))
assertEquals(2800, scoreClear(mk(4, allClear = true), 1))
assertEquals(4400, scoreClear(mk(4, allClear = true, b2b = 1), 1))
}
@Test
fun levelAndGravity() {
assertEquals(1, levelForLines(0))
assertEquals(1, levelForLines(9))
assertEquals(2, levelForLines(10))
assertEquals(5, levelForLines(42))
assertEquals(1000, gravityMsForLevel(1))
assertEquals(800, gravityMsForLevel(2))
assertEquals(50, gravityMsForLevel(30))
}
}

View File

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

View File

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

14
app/gradle.properties Normal file
View File

@@ -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

View File

@@ -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" }

BIN
app/gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -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

View File

@@ -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

View File

@@ -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 ##########################################################################

View File

@@ -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)
}
}

View File

@@ -0,0 +1,11 @@
import SwiftUI
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.ignoresSafeArea(.all)
}
}
}

53
app/iosApp/project.yml Normal file
View File

@@ -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

View File

@@ -0,0 +1,5 @@
# 복사해서 keystore.properties 로 쓴다 (git 에 올리지 않는다).
storeFile=/absolute/path/to/tetrig.keystore
storePassword=CHANGE_ME
keyAlias=tetrig
keyPassword=CHANGE_ME

30
app/settings.gradle.kts Normal file
View File

@@ -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")

77
scripts/release.sh Executable file
View File

@@ -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"