9 Commits
v2.0.0 ... main

Author SHA1 Message Date
845969297e release: v2.0.4 (versionCode 6) 2026-09-29 14:41:29 +09:00
5464b53f3f 조작부 아랫줄을 하드 드롭·홀드·소프트 드롭 3등분으로
아랫줄이 좌(액션)/우(이동) 두 컬럼에 걸쳐 있어 하드 드롭·홀드는 각각
화면의 28.7%, 소프트 드롭은 42.6% 를 차지했다. 정확히 1/3 씩 주려면 그
줄이 전체 폭을 써야 하므로, ControlPad 바깥을 Row → Column 으로 바꿔
윗줄(회전|이동)과 아랫줄을 각각 통짜 Row 로 깔았다. 윗줄 비율은 그대로다.

왼손 배치에서는 아랫줄도 좌우를 뒤집어 소프트 드롭이 왼쪽으로 간다.

검증(1080px 폭 실측): 아랫줄 버튼 폭 326/327/327px 로 균등. 윗줄은
↺199 ↻200 180°133 ◀202 ▶202 로 이전과 동일. 새 위치에서 홀드 탭 시
패널 유채색 0 → 5474, 하드 드롭 탭 시 넥스트 47584px 갱신,
소프트 드롭 길게 누름 시 낙하 속도 152 → 1137 px/s, 좌우 이동은
한 번에 정확히 한 칸(76px). 가로 화면은 구조가 달라 영향 없음(확인함).
엔진 테스트 28개 통과. 스토어 스크린샷 2장 교체.
2026-09-29 14:41:23 +09:00
43c8ccd6d2 release: v2.0.3 (versionCode 5) 2026-09-29 13:22:55 +09:00
f8c6bfab49 조작부: 하드 드롭을 반으로 줄이고 옆에 홀드 배치, 회전 버튼 확대
아랫줄을 하드 드롭 + HOLD 로 정확히 반씩 나눴다(실측 각 276px).
윗줄에서 HOLD 가 빠져 생긴 한 칸은 ↺·↻ 가 나눠 가졌다. 180° 는 폭을
유지해야 해서 4등분 중 1칸(weight 1)을 그대로 두고 회전 두 개에 1.5 씩
줬다 — 실측으로 180° 는 127 → 132px(간격 하나가 줄어든 만큼), ↺·↻ 는
126 → 198px 이 됐다.

검증: 새 위치에서 HOLD 탭 시 홀드 패널 유채색 0 → 5476, 하드 드롭 탭 시
보드 43377px·넥스트 47948px 갱신, 회전 3종 모두 탭 직후 보드 변화 확인.
엔진 테스트 28개 통과. 바뀐 조작부가 찍힌 스토어 스크린샷 2장 교체.
2026-09-29 13:22:50 +09:00
5e674fa686 release: v2.0.2 (versionCode 4) 2026-09-29 13:10:39 +09:00
fe13a61c09 조작 버튼 재배치 + 홀드/넥스트 패널이 갱신되지 않던 버그 수정
세로 화면 조작부를 요청대로 바꿨다. 회전(↺ ↻ 180°)과 HOLD 를 윗줄로 올리고,
하드 드롭은 맞은편 소프트 드롭과 같은 자리(맨 아래 가로로 긴 낮은 버튼)로
내렸다. 한 줄에 버튼이 4개가 되어 액션 컬럼 폭을 1.1 → 1.35 로 늘렸다.
1.1 로는 버튼이 세로로 길쭉해져 엄지로 누르기 불편했다.

그 과정에서 HOLD 를 눌러도 패널이 그대로인 것을 발견했다. game.hold 와
game.peekNext() 는 Compose 가 관찰하지 못하는 평범한 엔진 필드인데,
BoardCanvas 와 달리 패널 쪽은 state.version 을 구독하지 않아 게임 시작
시점의 넥스트 큐가 끝까지 멈춰 있었다. 넥스트 미리보기는 이 장르의 핵심이라
출시 전에 잡아야 하는 버그다. 패널을 SidePanels 로 분리해 version 을
구독시켰다 — 재구성 범위를 패널 서브트리로 좁혀 매 프레임
BoxWithConstraints 측정이 다시 돌지 않게 했다.

검증: 에뮬레이터에서 HOLD 탭 후 패널 유채색 픽셀 0 → 5474, 넥스트 패널은
하드 드롭 뒤 49370 픽셀 변화(수정 전에는 보드가 116848 픽셀 바뀌는 동안
넥스트는 0 픽셀이었다). 회전 3종·하드 드롭·소프트 드롭 모두 동작 확인.
세로/가로 양쪽 확인. 엔진 테스트 28개 통과.

바뀐 조작부가 찍힌 스토어 스크린샷 3장도 다시 촬영했다.
2026-09-29 13:10:31 +09:00
da0f355b98 release.sh: Gitea 푸시에 토큰 인증 붙이기
자격증명 헬퍼가 없어 'could not read Username' 으로 푸시가 실패했다.
토큰을 URL 에 실어 보낸다.
2026-09-24 16:29:46 +09:00
74ac78aaaa release: v2.0.1 (versionCode 3) 2026-09-24 16:29:06 +09:00
26bf54fb43 Google Play 단독 출시로 정리 + 스토어 제출 자산 준비
iOS 타깃을 제거했다. Apple Developer Program 연 $99 와 맥이 필요한데
아이폰은 기존 웹 PWA 로 이미 커버되므로 비용 대비 실익이 없다고 판단했다.
commonMain 중심 구조는 그대로라 iosTarget 만 다시 넣으면 복구된다.

멀티·리그전/커스텀 카드에 '준비 중' 배지를 붙였다. 구현되지 않은 모드가
스토어 스크린샷에서 동작하는 것처럼 보이면 Play 의 허위 표시 정책에 걸린다.

store/ 에 Play Console 제출 자산을 모았다: 등록정보 문구, 데이터 보안 설문
답안(릴리스 APK 에 INTERNET 권한이 없음을 aapt2 로 확인), 개인정보처리방침,
피처 그래픽 1024x500, 스토어 아이콘 512x512, 폰 4장 + 태블릿 3장 스크린샷.
2026-09-24 16:28:59 +09:00
25 changed files with 455 additions and 168 deletions

View File

@@ -6,7 +6,13 @@ TETR.IO 스타일의 **모바일 실시간 대전 블록 스태커** 웹 게임.
이 문서는 앱 제작을 위한 **최종 빌드 진입점(entry point)** 이다. 모든 설계 결정·수치·에셋의 **정본(source of truth)은 [`TETRIG-최종명세서.md`](./TETRIG-최종명세서.md)** 이며, 구현 시 반드시 명세서와 UX 목업을 함께 열어 작업한다.
> **비용 0 · 아이폰/안드로이드 모두 지원 (2026-08-22 확정)**: 명세서대로 **웹 게임 + PWA(홈화면 설치)** 로 간다. Cloudflare Pages(무료)에 배포하면 iPhone Safari·Android Chrome에서 그대로 돌아가고, "홈 화면에 추가"로 전체화면 앱처럼 실행된다(`manifest.webmanifest` + `sw.js` 오프라인 캐시 + 앱 아이콘). **맥·Xcode·앱스토어/구글플레이 등록비·Apple Developer 멤버십이 전혀 필요 없다.** (네이티브 iOS 앱만 유료·맥 필수라 웹/PWA 경로가 무료로 크로스플랫폼을 달성하는 유일한 방법이다.) 클라이언트 진입점은 [`index.html`](./index.html)(빌드 산출물), 소스는 [`src/`](./src), 서버 계획(Cloudflare Workers + D1 + WebSocket)은 명세서 그대로.
> **배포 경로 (2026-09-24 확정)**: 출시 대상은 **Google Play 안드로이드 네이티브 앱**
> (`app/`, Kotlin Multiplatform + Compose). 아이폰은 App Store 대신 **웹 PWA**
> (`https://tetrig.pages.dev`, "홈 화면에 추가")로 커버한다 — iOS 네이티브는
> Apple Developer Program 연 $99 + 맥이 필요해 접었다. 필요한 비용은 Play 개발자
> 등록비 $25(1회)뿐이고, 웹/서버는 Cloudflare 무료 티어로 간다. 웹 클라이언트
> 진입점은 [`index.html`](./index.html)(빌드 산출물), 소스는 [`src/`](./src),
> 서버 계획(Cloudflare Workers + D1 + WebSocket)은 명세서 그대로.
---
@@ -99,19 +105,20 @@ wrangler deploy # 배포
### 네이티브 앱 (Kotlin Multiplatform + Compose Multiplatform)
`app/` 이 실제 출시 대상이다. 엔진과 UI 전부 `commonMain` 한 벌이고
Android / iOS 가 그걸 공유한다.
`app/` 이 실제 출시 대상이고, **출시 플랫폼은 Google Play(안드로이드) 하나다.**
iOS 타깃은 2026-09-24 에 제거했다 — Apple Developer Program 연 $99 와 맥이
필요해 비용 대비 실익이 없다고 판단했다. 아이폰 사용자는 웹 PWA
(`https://tetrig.pages.dev`)로 커버한다. 엔진과 UI 전부 `commonMain` 한 벌이라,
나중에 iOS 가 필요해지면 `build.gradle.kts` 에 `iosTarget` 만 다시 넣으면 된다.
```
app/composeApp/src/
commonMain/ 엔진(engine/) · 모드·타이밍(game/) · UI(ui/) · 저장소(data/)
commonTest/ 엔진 테스트 (Android/iOS 양쪽에서 동일하게 돈다)
commonTest/ 엔진 테스트 28개
androidMain/ MainActivity, 매니페스트, 아이콘, SharedPreferences 구현
iosMain/ MainViewController, NSUserDefaults 구현
app/iosApp/ SwiftUI 껍데기 + XcodeGen 설정
```
안드로이드 빌드 (이 저장소에서 바로 가능):
안드로이드 빌드:
```bash
export JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
@@ -123,18 +130,22 @@ cp keystore.properties.sample keystore.properties # 서명 키 경로/비번
# → composeApp/build/outputs/bundle/release/composeApp-release.aab
```
iOS 빌드 (**macOS + Xcode 필요**. 리눅스에서는 코드만 컴파일 검증되고 실제
프레임워크 빌드는 되지 않는다):
### Play Console 제출 자산
```bash
brew install xcodegen
cd app/iosApp && xcodegen generate && open iosApp.xcodeproj
# Xcode 에서 실행하면 prebuild 스크립트가
# ./gradlew :composeApp:embedAndSignAppleFrameworkForXcode 를 돌려
# ComposeApp.framework 를 만들어 링크한다.
`store/` 에 스토어 등록에 필요한 것이 다 들어 있다.
```
store/listing.md 앱 이름·설명·데이터보안 설문 답안·콘텐츠 등급 가이드
store/privacy.html 개인정보처리방침 (https://tetrig.pages.dev/privacy 로 배포됨)
store/feature_graphic.png 1024x500 피처 그래픽
store/icon-512-play.png 512x512 스토어 아이콘 (알파 없는 정사각형)
store/shots/0*.png 휴대전화 스크린샷 1080x2400 4장
store/shots/tablet_*.png 태블릿 세로 1600x2560 2장 + 가로 2560x1600 1장
store/make_feature_graphic.py, store/make_play_icon.py 자산 재생성 스크립트
```
App Store 출시에는 Apple Developer Program 등록(연 $99)과 맥이 필요하다.
앱은 `INTERNET` 권한조차 요청하지 않는다(`aapt2 dump badging` 으로 확인).
그래서 Play 데이터 보안 설문은 전 항목 "수집 안 함"으로 끝난다.
### 기기 호환
@@ -147,8 +158,7 @@ App Store 출시에는 Apple Developer Program 등록(연 $99)과 맥이 필요
| 갤럭시 Z 플립 본화면(초세로) | 보드를 세로로 더 키움 |
| 갤럭시 Z 플립 커버(소형) | 축소 배율 + 넥스트 3개로 감축 |
| 갤럭시 Z 폴드 펼침(정사각형에 가까움) | 넓은 폭 레이아웃, 카드 가로 배치 |
| 태블릿 / 아이패드 가로 | 보드 가운데, 조작을 좌우 엄지 영역으로 분리 |
| 아이폰 | 동일 코드. 세이프에어리어 처리 |
| 태블릿 가로 | 보드 가운데, 조작을 좌우 엄지 영역으로 분리 |
접었다 펴기·회전은 액티비티 재시작 없이 처리한다
(`configChanges` + `resizeableActivity=true`).
@@ -168,7 +178,8 @@ scripts/release.sh patch # 또는 minor / major
### 웹 (Cloudflare Pages)
`index.html` 기반 PWA 는 그대로 유지한다 (아이폰 미설치 사용자용 / 데모).
`index.html` 기반 PWA 는 그대로 유지한다 (아이폰 사용자용 / 데모).
`dist/privacy.html` 은 개인정보처리방침이고 `/privacy` 로 서빙된다.
```bash
node build.js

View File

@@ -1,4 +1,3 @@
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
@@ -23,14 +22,9 @@ kotlin {
compilerOptions { jvmTarget.set(JvmTarget.JVM_17) }
}
// iOS 타깃 — 실제 컴파일은 macOS + Xcode 에서만 가능하다.
// 리눅스에서는 설정만 되고 빌드 태스크를 실행하지 않는다.
listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = "ComposeApp"
isStatic = true
}
}
// iOS 타깃은 뺐다 (2026-09-24 결정). Apple Developer Program 연 $99 + 맥이
// 필요해 비용 대비 실익이 없다고 판단. 아이폰은 웹 PWA(tetrig.pages.dev)로 커버한다.
// 구조는 commonMain 중심 그대로라, 나중에 iosTarget 만 다시 추가하면 된다.
sourceSets {
commonMain.dependencies {

View File

@@ -2,7 +2,7 @@ package kr.tkrmagid.tetrig.data
import androidx.compose.runtime.mutableStateMapOf
/** 플랫폼 키-값 저장소 (Android SharedPreferences / iOS NSUserDefaults). */
/** 플랫폼 키-값 저장소 (Android 는 SharedPreferences). 기기 밖으로 나가지 않는다. */
interface Store {
fun getString(key: String, default: String): String
fun putString(key: String, value: String)

View File

@@ -181,14 +181,33 @@ private fun BoardWithPanels(state: GameState, ghostOn: Boolean, l: Layout, stack
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)
SidePanels(state, s, l.isTiny)
}
}
}
}
/**
* HOLD / NEXT 패널.
*
* `game.hold` 와 `game.peekNext()` 는 Compose 가 관찰하지 못하는 평범한 엔진
* 필드라, 값이 바뀌어도 그것만으로는 재구성이 일어나지 않는다. BoardCanvas 와
* 똑같이 여기서 `state.version` 을 읽어 구독해야 한다. 이 구독이 없으면 게임이
* 시작될 때의 넥스트 큐가 끝까지 그대로 멈춰 있는다.
*
* 보드 전체가 아니라 패널만 따로 떼어 둔 이유는, 재구성 범위를 이 서브트리로
* 좁혀 매 프레임 BoxWithConstraints 측정까지 다시 돌지 않게 하기 위해서다.
*/
@Composable
private fun SidePanels(state: GameState, s: Float, tiny: Boolean) {
@Suppress("UNUSED_EXPRESSION")
state.version
val nextCount = if (tiny) 3 else 5
SidePanel("HOLD", listOfNotNull(state.game.hold), s, 1, tiny)
Spacer(Modifier.height((8 * s).dp))
SidePanel("NEXT", state.game.peekNext(nextCount), s, nextCount, tiny)
}
@Composable
private fun BoardCanvas(state: GameState, ghostOn: Boolean) {
@Suppress("UNUSED_EXPRESSION")
@@ -299,41 +318,59 @@ private fun MiniPiece(type: Char) {
@Composable
private fun ControlPad(state: GameState, lefty: Boolean, scale: Float, compactHeight: Boolean) {
val h = ((if (compactHeight) 130 else 168) * scale).dp
Row(
val gap = (8 * scale).dp
// 아랫줄 3개(하드 드롭·홀드·소프트 드롭)를 정확히 1/3 씩 주려면 그 줄이
// 화면 전체 폭을 차지해야 한다. 그래서 좌/우 컬럼으로 쪼개지 않고
// 바깥을 Column 으로 두어 윗줄·아랫줄을 각각 통짜 Row 로 깐다.
Column(
Modifier
.fillMaxWidth()
.height(h)
.padding(horizontal = (10 * scale).dp, vertical = (6 * scale).dp),
horizontalArrangement = Arrangement.spacedBy((8 * scale).dp),
verticalArrangement = Arrangement.spacedBy(gap),
) {
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() }
// ── 윗줄: 회전 | 좌우 이동 ──
Row(Modifier.weight(1.4f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
val rotates: @Composable (Modifier) -> Unit = { m ->
Row(m.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(gap)) {
// 180° 는 폭을 유지해야 해서 4등분 중 1칸을 그대로 두고,
// ↺·↻ 가 1.5 씩 받아 3/8 폭이 된다.
CtrlButton("↺", Modifier.weight(1.5f).fillMaxHeight(), scale) { state.rotate("ccw") }
CtrlButton("↻", Modifier.weight(1.5f).fillMaxHeight(), scale) { state.rotate("cw") }
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)) {
val moves: @Composable (Modifier) -> Unit = { m ->
Row(m.fillMaxHeight(), horizontalArrangement = Arrangement.spacedBy(gap)) {
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))
rotates(Modifier.weight(1.35f))
} else {
rotates(Modifier.weight(1.35f))
moves(Modifier.weight(1f))
}
}
if (lefty) {
moves(Modifier.weight(1f))
actions(Modifier.weight(1.1f))
} else {
actions(Modifier.weight(1.1f))
moves(Modifier.weight(1f))
// ── 아랫줄: 하드 드롭 · 홀드 · 소프트 드롭 3등분 ──
Row(Modifier.weight(1f).fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
val third = Modifier.weight(1f).fillMaxHeight()
val hardDrop: @Composable (Modifier) -> Unit = { m ->
CtrlButton("하드 드롭", m, scale, accent = true) { state.hardDrop() }
}
val hold: @Composable (Modifier) -> Unit = { m -> CtrlButton("HOLD", m, scale) { state.hold() } }
val soft: @Composable (Modifier) -> Unit = { m ->
HoldCtrl("▼ 소프트", m, scale, { state.setSoft(true) }, { state.setSoft(false) })
}
// 왼손 배치에서는 좌우를 뒤집는다 (소프트가 왼쪽 엄지 쪽으로).
if (lefty) {
soft(third); hold(third); hardDrop(third)
} else {
hardDrop(third); hold(third); soft(third)
}
}
}
}

View File

@@ -64,18 +64,18 @@ fun MainScreen(
ModeCard("솔로", "SPRINT · TIME ATTACK · ENDLESS", Palette.Cobalt, s) { showModes = true }
}
Box(Modifier.weight(1f)) {
ModeCard("멀티 · 리그전", "실시간 1:1 랭크 매치 · 배치 10경기", Palette.Safety, s) { onSoon("멀티 · 리그전") }
ModeCard("멀티 · 리그전", "실시간 1:1 랭크 매치 · 배치 10경기", Palette.Safety, s, soon = true) { onSoon("멀티 · 리그전") }
}
Box(Modifier.weight(1f)) {
ModeCard("커스텀", "코드로 만들고 참가 · 인원 제한 없음", null, s) { onSoon("커스텀") }
ModeCard("커스텀", "코드로 만들고 참가 · 인원 제한 없음", null, s, soon = true) { 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("커스텀") }
ModeCard("멀티 · 리그전", "실시간 1:1 랭크 매치 · 배치 10경기", Palette.Safety, s, soon = true) { onSoon("멀티 · 리그전") }
ModeCard("커스텀", "코드로 만들고 참가 · 인원 제한 없음", null, s, soon = true) { onSoon("커스텀") }
}
}
}
@@ -144,6 +144,9 @@ private fun ModeCard(
sub: String,
barColor: Color?,
scale: Float,
// 아직 구현되지 않은 모드는 카드에 '준비 중' 배지를 달아 둔다. 스토어 스크린샷에
// 없는 기능이 있는 것처럼 보이면 Play 정책(허위 표시) 위반이 된다.
soon: Boolean = false,
onClick: () -> Unit,
) {
Box(Modifier.fillMaxWidth().padding(bottom = (14 * scale).dp)) {
@@ -159,7 +162,19 @@ private fun ModeCard(
.weight(1f)
.padding(start = (14 * scale).dp, top = (18 * scale).dp, end = 8.dp),
) {
Disp(title, size = (26 * scale).sp)
Row(verticalAlignment = Alignment.CenterVertically) {
Disp(title, size = (26 * scale).sp)
if (soon) {
Spacer(Modifier.width((8 * scale).dp))
Box(
Modifier
.background(Palette.Sub)
.padding(horizontal = (7 * scale).dp, vertical = (2 * scale).dp),
) {
Mono("준비 중", color = Palette.Bone, size = (11 * scale).sp, weight = FontWeight.Bold)
}
}
}
Spacer(Modifier.height(6.dp))
Mono(sub, color = Palette.Sub, size = (12 * scale).sp)
}

View File

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

View File

@@ -1,16 +0,0 @@
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()

View File

@@ -10,5 +10,5 @@ kotlin.code.style=official
kotlin.daemon.jvmargs=-Xmx2g
# 버전은 CI/로컬에서 -PappVersionName -PappVersionCode 로 덮어쓸 수 있다.
appVersionName=2.0.0
appVersionCode=2
appVersionName=2.0.4
appVersionCode=6

View File

@@ -1,19 +0,0 @@
import SwiftUI
import UIKit
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
// Kotlin 쪽 MainViewController() — commonMain 의 App() 을 그대로 띄운다.
MainViewControllerKt.MainViewController()
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard)
}
}

View File

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

View File

@@ -1,53 +0,0 @@
# XcodeGen 설정 — macOS 에서 `xcodegen generate` 로 iosApp.xcodeproj 를 만든다.
# brew install xcodegen
# cd app/iosApp && xcodegen generate && open iosApp.xcodeproj
# Gradle 이 만든 ComposeApp.framework 를 링크한다.
name: iosApp
options:
bundleIdPrefix: kr.tkrmagid
deploymentTarget:
iOS: "15.0"
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: kr.tkrmagid.tetrig
MARKETING_VERSION: "2.0.0"
CURRENT_PROJECT_VERSION: "2"
SWIFT_VERSION: "5.0"
TARGETED_DEVICE_FAMILY: "1,2" # iPhone + iPad
ENABLE_USER_SCRIPT_SANDBOXING: NO
targets:
iosApp:
type: application
platform: iOS
sources:
- path: iosApp
info:
path: iosApp/Info.plist
properties:
CFBundleDisplayName: TETRIG
UILaunchScreen: {}
UISupportedInterfaceOrientations:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UISupportedInterfaceOrientations~ipad:
- UIInterfaceOrientationPortrait
- UIInterfaceOrientationPortraitUpsideDown
- UIInterfaceOrientationLandscapeLeft
- UIInterfaceOrientationLandscapeRight
UIRequiresFullScreen: false
settings:
base:
FRAMEWORK_SEARCH_PATHS:
- "$(SRCROOT)/../composeApp/build/xcode-frameworks/$(CONFIGURATION)/$(SDK_NAME)"
OTHER_LDFLAGS:
- "-framework"
- "ComposeApp"
preBuildScripts:
- name: Build Compose framework
script: |
cd "$SRCROOT/../"
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
basedOnDependencyAnalysis: false

View File

@@ -60,7 +60,9 @@ 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
# 토큰을 URL 에 넣어 푸시한다. 그냥 호스트만 주면 자격증명 헬퍼가 없어
# "could not read Username" 으로 죽는다. URL 은 로그에 남지 않게 변수로만 쓴다.
git push "https://oauth2:${TOKEN}@${GITEA_HOST#https://}/$REPO.git" HEAD:main --tags
echo "Gitea 릴리스 생성..."
REL_ID=$(curl -sS -X POST "$GITEA_HOST/api/v1/repos/$REPO/releases" \

BIN
store/feature_graphic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

BIN
store/icon-512-play.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

102
store/listing.md Normal file
View File

@@ -0,0 +1,102 @@
# TETRIG — Play Console 스토어 등록 정보
Play Console → 스토어 설정 / 기본 스토어 등록정보에 그대로 붙여 넣는 값이다.
길이 제한은 Play 기준(앱 이름 30자, 간단한 설명 80자, 자세한 설명 4000자)을 지켰다.
---
## 앱 이름 (30자 이내)
```
TETRIG
```
## 간단한 설명 (80자 이내) — 현재 31자
```
스프린트·타임어택·엔들리스. 광고 없는 순수 블록 퍼즐.
```
## 자세한 설명 (4000자 이내)
```
TETRIG은 군더더기를 걷어낸 블록 스태킹 퍼즐입니다. 로그인도, 광고도, 결제도 없습니다. 앱을 열면 바로 게임이 시작됩니다.
■ 세 가지 솔로 모드
· SPRINT — 40줄을 얼마나 빨리 지우는지 겨루는 기록 경기입니다.
· TIME ATTACK — 2분 동안 최대 점수를 뽑아내는 모드입니다. 시간이 갈수록 레벨이 올라갑니다.
· ENDLESS — 제한이 없습니다. 탑아웃해도 이어서 계속 쌓을 수 있어 연습에 좋습니다.
■ 제대로 된 조작감
· SRS+180 회전과 킥 테이블을 구현해, 익숙한 회전 보정이 그대로 통합니다.
· 7-bag 랜덤이라 같은 블록이 몰려 나오지 않습니다.
· 홀드, 고스트(착지 위치 미리보기), 하드 드롭, 소프트 드롭, 180도 회전을 모두 지원합니다.
· T-스핀, 백투백, 콤보, 퍼펙트 클리어를 판정해 점수에 반영합니다.
· DAS/ARR, 락 딜레이를 조정해 반응이 끊기지 않게 맞췄습니다.
■ 어떤 화면에서든 맞춰집니다
· 일반 스마트폰은 물론 갤럭시 폴드(접었을 때·펼쳤을 때), 플립, 태블릿까지 화면 크기에 따라 배치가 바뀝니다.
· 가로 화면에서는 조작 버튼이 좌우로 갈라져 양손 엄지에 닿습니다.
· 폴드를 펼치거나 화면을 돌려도 게임이 재시작되지 않고 그대로 이어집니다.
· 태블릿·폴드용 '큰 버튼' 옵션과 왼손잡이용 좌우 반전 옵션이 있습니다.
■ 개인정보를 수집하지 않습니다
· 인터넷 권한조차 요청하지 않습니다. 앱은 네트워크에 전혀 접속하지 않습니다.
· 기록과 설정은 기기 안에만 저장되고 어디로도 전송되지 않습니다.
· 회원가입, 광고, 추적, 분석 도구가 없습니다.
· 비행기 모드에서도 모든 기능이 동일하게 동작합니다.
■ 광고도 결제도 없습니다
전체 기능이 처음부터 열려 있습니다. 결제 유도, 에너지·하트 같은 플레이 제한, 보상형 광고가 없습니다.
개인정보처리방침: https://tetrig.pages.dev/privacy
TETRIG은 개인이 만든 앱입니다. Tetris Holding LLC 및 관련 회사와 아무 관련이 없습니다.
```
---
## 그래픽 자산
| 항목 | 파일 | 규격 | 상태 |
|---|---|---|---|
| 앱 아이콘 | `store/icon-512-play.png` | 512×512 PNG, 알파 없는 풀블리드 | 준비됨 |
| 그래픽 이미지(피처) | `store/feature_graphic.png` | 1024×500 PNG | 준비됨 |
| 휴대전화 스크린샷 | `store/shots/01_main.png` ~ `04_settings.png` | 1080×2400 | 4장 (Play 최소 2장) |
| 7·10인치 태블릿 | `store/shots/tablet_01_main.png`, `tablet_02_game.png` | 1600×2560 | 2장 |
| 태블릿 가로 | `store/shots/tablet_03_landscape.png` | 2560×1600 | 1장 |
피처 그래픽은 `python3 store/make_feature_graphic.py`, 앱 아이콘은
`python3 store/make_play_icon.py` 로 다시 생성할 수 있다. 앱 내부 런처 아이콘은
모서리가 둥근 `icon-512.png` 를 그대로 쓰고, 스토어 등록용만 정사각형 풀블리드다.
---
## 데이터 보안(Data safety) 설문 답안
릴리스 APK의 권한을 확인한 결과 `INTERNET` 권한이 없다(`kr.tkrmagid.tetrig.DYNAMIC_RECEIVER_NOT_EXPORTED_PERMISSION` 하나뿐). 그래서 모든 문항이 "아니요"로 끝난다.
- 앱이 필요한 사용자 데이터 유형을 수집하거나 공유하나요? → **아니요**
- 모든 사용자 데이터가 전송 중에 암호화되나요? → (수집 없음이므로 해당 없음)
- 사용자가 데이터 삭제를 요청할 수 있나요? → (수집 없음이므로 해당 없음)
- 앱에 광고가 포함되어 있나요? → **아니요**
- 인앱 구매 → **없음**
근거: 기록·설정은 안드로이드 SharedPreferences 로 기기 내부에만 저장되고 외부로 나가지 않는다.
## 콘텐츠 등급 설문
퍼즐 게임 카테고리로, 폭력·성적 내용·욕설·도박·약물 요소가 전부 없고 사용자 간 소통 기능도 없다. 전 문항 "아니요"로 답하면 전체이용가(ESRB Everyone / PEGI 3 / GRAC 전체이용가)가 나온다.
## 기타 Play Console 입력값
- 앱 또는 게임: **게임**
- 카테고리: **퍼즐**
- 태그: 퍼즐 / 캐주얼 / 아케이드
- 무료 또는 유료: **무료**
- 대상 연령: 전체
- 광고 포함: **아니요**
- 개인정보처리방침 URL: `https://tetrig.pages.dev/privacy`
- 이메일: `tkrmagid@gmail.com`
- 패키지명: `kr.tkrmagid.tetrig`
- 업로드 파일: `app/composeApp/build/outputs/bundle/release/composeApp-release.aab`

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Play Console 피처 그래픽(1024x500) 생성.
앱 팔레트(NEOBRUTAL)를 그대로 써서 로고 + 테트로미노 악센트를 배치한다.
가장자리는 스토어에서 잘릴 수 있으므로 핵심 요소는 가운데로 모았다.
python3 store/make_feature_graphic.py
"""
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "store" / "feature_graphic.png"
W, H = 1024, 500
BONE = (0xED, 0xE7, 0xDA)
INK = (0x17, 0x16, 0x1B)
SAFETY = (0xFF, 0x4E, 0x11)
SUB = (0x6E, 0x6A, 0x5E)
PIECES = {
"I": (0x3E, 0xC1, 0xB6),
"O": (0xF5, 0xC5, 0x31),
"T": (0xA7, 0x6B, 0xF2),
"S": (0x7F, 0xCC, 0x4C),
"Z": (0xF2, 0x5C, 0x4C),
"J": (0x4C, 0x7D, 0xF2),
"L": (0xF2, 0x91, 0x3D),
}
img = Image.new("RGB", (W, H), BONE)
d = ImageDraw.Draw(img)
# 은은한 격자
for x in range(0, W, 32):
d.line([(x, 0), (x, H)], fill=(0xE3, 0xDC, 0xCD), width=1)
for y in range(0, H, 32):
d.line([(0, y), (W, y)], fill=(0xE3, 0xDC, 0xCD), width=1)
def block(x, y, s, color):
"""네오브루탈 블록: 오렌지 그림자 + 잉크 테두리."""
d.rounded_rectangle([x + 5, y + 5, x + s + 5, y + s + 5], radius=6, fill=SAFETY)
d.rounded_rectangle([x, y, x + s, y + s], radius=6, fill=color, outline=INK, width=3)
# 오른쪽: 쌓인 스택 실루엣
S = 44
stack = [
(0, 0, "I"), (1, 0, "I"), (2, 0, "I"),
(1, 1, "T"), (2, 1, "L"),
(0, 2, "J"), (1, 2, "O"), (2, 2, "O"),
(0, 3, "S"), (1, 3, "Z"), (2, 3, "Z"),
]
bx, by = 778, 145
for cx, cy, p in stack:
block(bx + cx * (S + 6), by + cy * (S + 6), S, PIECES[p])
# 로고
logo = Image.open(ROOT / "tetrig-logo.png").convert("RGBA")
lw = 640
logo = logo.resize((lw, int(logo.height * lw / logo.width)), Image.LANCZOS)
img.paste(logo, (62, 92), logo)
def font(size, bold=True):
name = "DejaVuSansMono-Bold.ttf" if bold else "DejaVuSansMono.ttf"
return ImageFont.truetype(f"/usr/share/fonts/truetype/dejavu/{name}", size)
# 태그라인 바
tag = "SPRINT · TIME ATTACK · ENDLESS"
f = font(24)
tw = d.textlength(tag, font=f)
tx, ty = 80, 352
d.rectangle([tx + 6, ty + 6, tx + tw + 36, ty + 48], fill=SAFETY)
d.rectangle([tx, ty, tx + tw + 30, ty + 42], fill=INK)
d.text((tx + 15, ty + 9), tag, font=f, fill=BONE)
img.save(OUT)
print(OUT, img.size)

50
store/make_play_icon.py Normal file
View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""Play Console 앱 아이콘(512x512, 알파 없는 정사각형) 생성.
기존 icon-512.png 는 모서리가 투명한 라운드 아이콘이라 Play 스토어처럼
자체 마스킹을 적용하는 곳에서는 모서리가 비어 보인다. 여기서는 같은 디자인을
정사각형 풀블리드로 다시 그려 알파 없이 저장한다.
python3 store/make_play_icon.py
"""
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "store" / "icon-512-play.png"
S = 512
BONE = (0xED, 0xE7, 0xDA)
INK = (0x17, 0x16, 0x1B)
SAFETY = (0xFF, 0x4E, 0x11)
img = Image.new("RGB", (S, S), BONE)
d = ImageDraw.Draw(img)
# T 미노: 위 3칸 + 아래 가운데 1칸 (원본 아이콘과 동일한 배치)
CELL = 118
GAP = 10
OFF = 14 # 오렌지 그림자 오프셋
cells = [(0, 0), (1, 0), (2, 0), (1, 1)]
total_w = 3 * CELL + 2 * GAP
ox = (S - total_w) // 2
oy = 150
for cx, cy in cells:
x = ox + cx * (CELL + GAP)
y = oy + cy * (CELL + GAP)
d.rounded_rectangle([x + OFF, y + OFF, x + CELL + OFF, y + CELL + OFF],
radius=16, fill=SAFETY)
d.rounded_rectangle([x, y, x + CELL, y + CELL], radius=16, fill=INK)
# 하단 해저드 스트라이프
bar_top = S - 78
d.rectangle([0, bar_top, S, S], fill=BONE)
step = 34
for i in range(-S, S * 2, step):
d.polygon([(i, S), (i + step // 2, S), (i + step // 2 + 78, bar_top), (i + 78, bar_top)],
fill=INK)
d.line([(0, bar_top), (S, bar_top)], fill=INK, width=6)
img.save(OUT)
print(OUT, img.size, img.mode)

102
store/privacy.html Normal file
View File

@@ -0,0 +1,102 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TETRIG 개인정보처리방침 / Privacy Policy</title>
<style>
:root{ --ink:#17161B; --bone:#EDE7DA; --sub:#6E6A5E; --safety:#FF4E11; }
*{ box-sizing:border-box; margin:0; padding:0; }
body{ background:var(--bone); color:var(--ink); font-family:ui-monospace,'Menlo',monospace;
line-height:1.75; padding:32px 20px 80px; }
main{ max-width:760px; margin:0 auto; }
h1{ font-size:28px; font-style:italic; font-weight:900; margin-bottom:6px; }
h1 .o{ color:var(--safety); }
h2{ font-size:18px; margin:32px 0 10px; border-left:8px solid var(--ink); padding-left:10px; }
p,li{ font-size:14px; }
ul{ padding-left:22px; }
.meta{ color:var(--sub); font-size:12px; margin-bottom:24px; }
.panel{ border:2px solid var(--ink); box-shadow:4px 4px 0 var(--ink); background:#fff;
padding:16px 18px; margin:18px 0; }
hr{ border:0; border-top:2px solid var(--ink); margin:44px 0 28px; }
a{ color:var(--ink); }
</style>
</head>
<body>
<main>
<h1><span class="o">TET</span>RIG 개인정보처리방침</h1>
<p class="meta">최종 수정일: 2026-09-24 · 앱 패키지: kr.tkrmagid.tetrig</p>
<div class="panel">
<p><strong>요약: TETRIG은 개인정보를 일절 수집하지 않습니다.</strong> 앱에는 인터넷 접근 권한조차 없으며,
모든 데이터는 기기 안에만 저장됩니다.</p>
</div>
<h2>1. 수집하는 개인정보</h2>
<p>없습니다. TETRIG은 이름, 이메일, 전화번호, 위치, 연락처, 사진, 기기 식별자 등
어떤 개인정보도 수집·요청·전송하지 않습니다. 회원가입 절차가 없습니다.</p>
<h2>2. 기기에 저장되는 정보</h2>
<p>아래 항목만 기기 내부 저장소(Android SharedPreferences)에 저장되며 외부로 전송되지 않습니다.</p>
<ul>
<li>솔로 모드 최고 기록 (SPRINT 최단 시간, TIME ATTACK / ENDLESS 최고 점수)</li>
<li>게임 설정값 (고스트 표시, 왼손 배치, 큰 버튼)</li>
</ul>
<p>앱을 삭제하면 이 데이터도 함께 삭제됩니다.</p>
<h2>3. 권한</h2>
<p>TETRIG은 인터넷, 저장소, 위치, 카메라, 마이크, 연락처 등 어떤 런타임 권한도 요청하지 않습니다.</p>
<h2>4. 제3자 제공 · 광고 · 분석</h2>
<p>광고 SDK, 분석 SDK, 크래시 리포팅 도구를 일절 포함하지 않습니다.
따라서 제3자에게 제공되는 데이터도 없습니다.</p>
<h2>5. 아동 개인정보</h2>
<p>어떤 이용자로부터도 개인정보를 수집하지 않으므로, 만 14세 미만 아동의 개인정보 역시 수집하지 않습니다.</p>
<h2>6. 변경 고지</h2>
<p>향후 온라인 대전 기능이 추가되어 수집 항목이 생기면, 이 문서를 갱신하고
앱 업데이트 시점에 안내합니다.</p>
<h2>7. 문의</h2>
<p>개인정보 관련 문의: <a href="mailto:tkrmagid@gmail.com">tkrmagid@gmail.com</a></p>
<hr>
<h1><span class="o">TET</span>RIG Privacy Policy</h1>
<p class="meta">Last updated: 2026-09-24 · Package: kr.tkrmagid.tetrig</p>
<div class="panel">
<p><strong>Summary: TETRIG collects no personal data.</strong> The app does not even hold the
INTERNET permission. All data stays on your device.</p>
</div>
<h2>1. Data we collect</h2>
<p>None. TETRIG does not collect, request, or transmit any personal information such as name,
email, phone number, location, contacts, photos, or device identifiers. There is no account system.</p>
<h2>2. Data stored on your device</h2>
<ul>
<li>Solo mode personal bests (SPRINT time, TIME ATTACK / ENDLESS score)</li>
<li>Gameplay settings (ghost piece, left-handed layout, large buttons)</li>
</ul>
<p>Uninstalling the app removes this data.</p>
<h2>3. Permissions</h2>
<p>TETRIG requests no runtime permissions and has no internet access.</p>
<h2>4. Third parties, ads, analytics</h2>
<p>No advertising SDK, no analytics SDK, no crash reporting. No data is shared with anyone.</p>
<h2>5. Children</h2>
<p>Since no personal data is collected from any user, none is collected from children either.</p>
<h2>6. Changes</h2>
<p>If online multiplayer is added later and data collection begins, this document will be updated
and the change announced with the app update.</p>
<h2>7. Contact</h2>
<p><a href="mailto:tkrmagid@gmail.com">tkrmagid@gmail.com</a></p>
</main>
</body>
</html>

BIN
store/shots/01_main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

BIN
store/shots/02_modes.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
store/shots/03_game.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
store/shots/04_settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB