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 릴리스 자동화
9
.gitignore
vendored
@@ -1,15 +1,6 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
_*.png
|
_*.png
|
||||||
_*.mjs
|
_*.mjs
|
||||||
_verify.mjs
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist/
|
dist/
|
||||||
live_*.png
|
live_*.png
|
||||||
android/build/
|
|
||||||
android/.gradle/
|
|
||||||
android/app/build/
|
|
||||||
android/local.properties
|
|
||||||
android/*.apk
|
|
||||||
android/*.aab
|
|
||||||
android/*.idsig
|
|
||||||
android/*.keystore
|
|
||||||
|
|||||||
89
README.md
@@ -97,29 +97,84 @@ wrangler deploy # 배포
|
|||||||
|
|
||||||
## 배포
|
## 배포
|
||||||
|
|
||||||
웹(Cloudflare Pages) — `dist/` 에 `index.html`, `manifest.webmanifest`, `sw.js`,
|
### 네이티브 앱 (Kotlin Multiplatform + Compose Multiplatform)
|
||||||
아이콘 3종, `.well-known/assetlinks.json` 을 담아 올린다.
|
|
||||||
|
`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
|
```bash
|
||||||
node build.js
|
node build.js
|
||||||
wrangler pages deploy dist --project-name tetrig --branch main
|
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 에셋·명칭 복제 금지.
|
- **IP 금지**: 사용자 노출 텍스트에 "Tetris/테트리스" 금지, 테트리스 음악(Korobeiniki 등) 금지, 표준 가이드라인 색상 1:1 복제 금지, TETR.IO 에셋·명칭 복제 금지.
|
||||||
|
|||||||
@@ -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'
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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();
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
package kr.tkrmagid.tetrig;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public class DelegationService extends
|
|
||||||
com.google.androidbrowserhelper.trusted.DelegationService {
|
|
||||||
@Override
|
|
||||||
public void onCreate() {
|
|
||||||
super.onCreate();
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 174 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 276 KiB |
@@ -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>
|
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 8.0 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -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"}]}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
<shortcuts xmlns:android='http://schemas.android.com/apk/res/android' />
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
BIN
android/gradle/wrapper/gradle-wrapper.jar
vendored
@@ -1 +0,0 @@
|
|||||||
ea4a002f690d4240040c3d431c1203eca46cc9a5
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
include ':app'
|
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
109
app/composeApp/build.gradle.kts
Normal 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)
|
||||||
|
}
|
||||||
36
app/composeApp/src/androidMain/AndroidManifest.xml
Normal 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>
|
||||||
@@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
After Width: | Height: | Size: 38 KiB |
@@ -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>
|
||||||
@@ -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>
|
||||||
BIN
app/composeApp/src/androidMain/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
BIN
app/composeApp/src/androidMain/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
BIN
app/composeApp/src/androidMain/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
BIN
app/composeApp/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 20 KiB |
@@ -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>
|
||||||
5
app/composeApp/src/androidMain/res/values/colors.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ink">#17161B</color>
|
||||||
|
<color name="bone">#EDE7DA</color>
|
||||||
|
</resources>
|
||||||
4
app/composeApp/src/androidMain/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">TETRIG</string>
|
||||||
|
</resources>
|
||||||
8
app/composeApp/src/androidMain/res/values/themes.xml
Normal 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>
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() }
|
||||||
@@ -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
@@ -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
|
||||||
17
app/gradle/libs.versions.toml
Normal 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
@@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.5-bin.zip
|
||||||
zipStorePath=wrapper/dists
|
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
|
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
5
android/gradlew → app/gradlew
vendored
@@ -15,6 +15,8 @@
|
|||||||
# See the License for the specific language governing permissions and
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# limitations under the License.
|
||||||
#
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
##############################################################################
|
##############################################################################
|
||||||
#
|
#
|
||||||
@@ -84,7 +86,8 @@ done
|
|||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
APP_BASE_NAME=${0##*/}
|
||||||
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
# 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.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
2
android/gradlew.bat → app/gradlew.bat
vendored
@@ -13,6 +13,8 @@
|
|||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
19
app/iosApp/iosApp/ContentView.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/iosApp/iosApp/iOSApp.swift
Normal 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
@@ -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
|
||||||
5
app/keystore.properties.sample
Normal 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
@@ -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
@@ -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"
|
||||||