Files
tetrig/TETRIG-최종명세서.md
tkrmagid c99a867ba7 Lock TETRIG name for store launch and monetization
이름 재검토 트리거 제거: §2·§10·README 상표 섹션을 '스토어 출시·수익화까지 TETRIG 확정'으로 갱신 (2026-08-21 사용자 결정).
2026-08-21 22:55:25 +09:00

529 lines
39 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# TETRIG (테트리그) — 최종 구현 명세서 v3.0
> 이 문서는 **AI(Claude)에게 구현을 맡기기 위한 핸드오프 문서**다. 지금까지 사용자와 확정한 모든 설계 결정·수치·에셋이 담겨 있으며, 이 문서 하나로 프로젝트 전체를 구현할 수 있다.
---
## 0. 이 문서를 받은 AI에게 — 작업 지침 (가장 먼저 읽을 것)
**무엇을 만드는가**: TETR.IO 스타일의 모바일 실시간 대전 블록 스태커 웹 게임 **"테트리그 (TETRIG)"**. 솔로 3모드 + 인원 무제한 커스텀 룸(FFA) + Glicko-2 랭크 리그전.
**산출물 2개**:
1. **클라이언트** — 단일 HTML 파일 (HTML+CSS+JS, Canvas 2D, 외부 에셋 0개, 사운드는 Web Audio 신스 생성)
2. **서버** — Cloudflare Workers 프로젝트 (wrangler 표준 구조: Durable Objects 2종 + D1 + Cron)
**작업 규칙**:
- §15 로드맵의 **Phase 순서대로** 진행하고, 각 Phase 완료 시 사용자 확인 후 다음으로 넘어간다.
- 각 Phase 산출물 = 실행 가능한 파일 + 코어 로직의 jsdom(또는 node) 단위 테스트 통과 결과.
- **이 문서의 결정사항은 이미 확정된 것이다. 재논의·재제안하지 말고 그대로 구현하라.** 문서에 값이 명시된 것은 그 값을 쓰고, 없는 것만 질문하라.
- 앱 이름 "테트리그"는 사용자가 상표 리스크를 인지하고 확정한 결정이다. **다시 경고하지 말 것.** 단, §2의 완화책(BRAND 상수 등)은 반드시 구현할 것.
- 모든 응답과 코드 내 사용자 노출 텍스트는 **한국어** (게임 용어는 SPRINT, HOLD 등 영문 허용).
- 튜닝 대상 수치(중력, TR 커브, 콤보 테이블 등)는 코드 상단에 **상수로 분리**해 나중에 조정하기 쉽게 한다.
- **이 문서와 함께 첨부되는 파일 — 반드시 함께 확인할 것**: `tetrig-ux-mockup.png`(9화면 UX 목업, **UI 구현의 시각 기준 — 가장 먼저 열어볼 것**), `tetrig-logo.svg`·`tetrig-icon.svg`(실사용 로고 에셋, 부록 A에 동일 소스 수록), `tetrig-logo.png`·`tetrig-icon.png`(로고 렌더 미리보기). §12의 좌표 명세는 목업을 수치로 보조하는 자료다 — 시각 판단은 목업 이미지를 따른다.
**절대 금지 (IP)**:
- 사용자 노출 텍스트(UI·스토어 문구·게임 내 판정 텍스트)에 "Tetris/테트리스" 단어 사용 금지 — 4줄 클리어는 **"QUAD"**
- 테트리스 관련 음악(Korobeiniki 등) 사용 금지 — 사운드는 100% Web Audio 신스
- 표준 가이드라인 피스 색상 1:1 복제 금지 — §13의 자체 팔레트 사용
- TETR.IO의 에셋·명칭(TETRA LEAGUE 등) 복제 금지
---
## 1. 아키텍처 개요
```
[모바일 브라우저] [Cloudflare]
클라이언트 (단일 HTML, Canvas) ⇄ WSS ⇄ Workers
├ 게임 엔진 (SRS+, 판정, 렌더) ├ MatchmakerDO (리그 매칭 큐, 싱글턴)
├ 솔로 모드 3종 ├ GameRoomDO (경기방 — 1:1 리그 & N인 커스텀 공용)
└ 멀티/커스텀/프로필/리더보드 UI ├ D1 (계정·레이팅·전적·리더보드)
└ Cron (RD 감쇠, 시즌 처리)
```
- 클라이언트는 Cloudflare Pages(또는 Workers 정적 자산)로 호스팅. 주소는 무료 서브도메인(`*.pages.dev`)으로 시작.
- 서버는 무료 티어로 운영 가능 (DO는 SQLite 백엔드, WebSocket Hibernation 사용. WS 수신 메시지 20개=1요청 집계).
- 로컬 개발: `wrangler dev` + 브라우저 탭 여러 개로 다인전 테스트.
## 2. IP 리스크 방침
**앱 이름: 테트리그 (TETRIG) — 스토어 출시·수익화까지 최종 확정.** "TETR-" 접두어의 상표 유사성 리스크를 사용자가 인지한 뒤, 개인 배포는 물론 **스토어 출시와 수익화(광고 게시)까지 이 이름으로 진행**하기로 확정했다(2026-08-21). 이름 재검토 트리거는 없다. 아래 완화책은 리브랜딩 여지 확보가 아니라 코드 위생·자체 제작 원칙으로서 그대로 구현할 것:
1. **BRAND 상수**: 앱 이름·로고 문구를 코드 전체에서 상수 1곳(`const BRAND = "TETRIG"` / `BRAND_KO = "테트리그"`)으로 관리 — 유지보수·표기 일관성을 위한 단일 소스
2. **단계별 운용**: 개인·지인 배포 → 스토어 출시 → 수익화 모두 테트리그 이름으로 진행 (이름 재검토 없음, 확정)
3. **이름 외 모든 표현은 자체 제작**: 모드명(SPRINT/TIME ATTACK/ENDLESS/리그전), "QUAD" 용어, §13 자체 팔레트·자체 로고·자체 신스 사운드
## 3. 화면 구조 & 내비게이션
```
테트리그 메인
├── 솔로 ──(바텀시트)──▶ SPRINT / TIME ATTACK / ENDLESS
├── 멀티 ──▶ 리그전 (매칭 큐 → 1:1 → 결과)
└── 커스텀 ──▶ 방 만들기(코드 발급→로비) / 참가하기(코드 입력→로비)
하단 탭바: 홈 · 리더보드 · 프로필 · 설정 (전 메뉴 화면 공통)
```
전체 화면 목록 (상세 좌표는 §12): ① 메인 ② 솔로 인게임 ③ 리그전 1:1 ④ 커스텀 FFA ⑤ 커스텀 로비 ⑥ 리그 결과 ⑦ 프로필(게스트→로그인) ⑧ 솔로 모드 선택 시트 ⑨ 일반전(커스텀) 결과. 추가로 리더보드·설정 화면은 동일 디자인 언어로 구성.
## 4. 게임 코어 규칙 (전 모드 공통)
- **보드**: 10열 × 40행(내부), 하단 20행만 표시. 스폰은 21~22행(숨김 영역 하단). 스폰 위치가 이미 막혀 있으면 톱아웃.
- **랜덤**: 7-bag (7종 피스를 가방 단위로 셔플).
- **넥스트**: 5개 표시. **홀드**: 1회/피스, 스왑 후 해당 피스 락다운까지 재사용 불가.
- **고스트 피스**: 항상 표시 (설정에서 끔 가능).
- **락 딜레이**: 500ms. 이동/회전 성공 시 리셋, **최대 15회**(초과 시 즉시 락).
- **중력 초기값** (튜닝 상수): 리그/커스텀 1000ms/칸 고정, SPRINT 800ms 고정, TIME ATTACK·ENDLESS는 레벨제 — 10라인=1레벨, 낙하간격(초) = `0.8^(level-1)` 근사, 하한 50ms.
- **소프트드롭 속도** = 중력 × SDF.
### 4.1 회전 시스템 — SRS + 180°
좌표 규약: 킥 오프셋 (dx, dy), **dy 양수 = 위쪽**. 회전 상태 0=스폰, 1=CW, 2=180, 3=CCW. 각 킥을 순서대로 시도해 처음 충돌 없는 위치 적용, 전부 실패 시 회전 취소.
**JLSTZ 킥테이블 (표준 SRS)**
| 전이 | 킥 순서 |
|---|---|
| 0→1 | (0,0) (-1,0) (-1,1) (0,-2) (-1,-2) |
| 1→0 | (0,0) (1,0) (1,-1) (0,2) (1,2) |
| 1→2 | (0,0) (1,0) (1,-1) (0,2) (1,2) |
| 2→1 | (0,0) (-1,0) (-1,1) (0,-2) (-1,-2) |
| 2→3 | (0,0) (1,0) (1,1) (0,-2) (1,-2) |
| 3→2 | (0,0) (-1,0) (-1,-1) (0,2) (-1,2) |
| 3→0 | (0,0) (-1,0) (-1,-1) (0,2) (-1,2) |
| 0→3 | (0,0) (1,0) (1,1) (0,-2) (1,-2) |
**I 킥테이블 (표준 SRS)**
| 전이 | 킥 순서 |
|---|---|
| 0→1 | (0,0) (-2,0) (1,0) (-2,-1) (1,2) |
| 1→0 | (0,0) (2,0) (-1,0) (2,1) (-1,-2) |
| 1→2 | (0,0) (-1,0) (2,0) (-1,2) (2,-1) |
| 2→1 | (0,0) (1,0) (-2,0) (1,-2) (-2,1) |
| 2→3 | (0,0) (2,0) (-1,0) (2,1) (-1,-2) |
| 3→2 | (0,0) (-2,0) (1,0) (-2,-1) (1,2) |
| 3→0 | (0,0) (1,0) (-2,0) (1,-2) (-2,1) |
| 0→3 | (0,0) (-1,0) (2,0) (-1,2) (2,-1) |
**180° 회전 킥 (SRS+ 계열)** — JLSTZ:
| 전이 | 킥 순서 |
|---|---|
| 0→2 | (0,0) (0,1) (1,1) (-1,1) (1,0) (-1,0) |
| 2→0 | (0,0) (0,-1) (-1,-1) (1,-1) (-1,0) (1,0) |
| 1→3 | (0,0) (1,0) (1,2) (1,1) (0,2) (0,1) |
| 3→1 | (0,0) (-1,0) (-1,2) (-1,1) (0,2) (0,1) |
I 피스 180°는 (0,0)만. O 피스는 회전해도 변화 없음(킥 불필요).
※ 킥테이블·T-spin 판정은 반드시 단위 테스트로 검증할 것.
### 4.2 판정 정의
- **T-spin**: T 피스가 **마지막 조작이 회전**으로 락다운 + T 중심 기준 4모서리 중 3칸 이상 채움(보드 밖도 채움 취급). 전면(포인팅 방향) 2모서리가 모두 채워졌으면 **T-spin**, 아니면 **T-spin mini**.
- **B2B (Back-to-Back)**: Quad·T-spin 계열 클리어가 다른 클리어(Single~Triple 일반 클리어) 없이 연속되면 유지. 판정 팝업에 B2B ×n 표시.
- **콤보**: 연속 피스가 각각 라인 클리어하면 +1씩 누적.
- **All Clear (퍼펙트 클리어)**: 클리어 후 보드가 완전히 빔.
- 판정 팝업 텍스트 예: `QUAD!`, `T-SPIN DOUBLE!`, `B2B ×2`, `ALL CLEAR!`, `3 COMBO`
## 5. 솔로 모드 3종 + 점수
| 모드 | 규칙 | 기록 |
|---|---|---|
| SPRINT | 40줄 클리어 최속 | 시간 (ms) |
| TIME ATTACK | 2분 점수 어택, 레벨 가속 | 점수 |
| ENDLESS | 무한, 게임오버 없음(톱아웃 시 보드만 리셋, 점수 유지) | 누적 점수·라인 |
**점수표 (×레벨, ENDLESS/TIME ATTACK 적용. SPRINT는 시간만 기록)**
- Single 100 / Double 300 / Triple 500 / Quad 800
- T-spin: Mini 100 / Mini Single 200 / Single 800 / Double 1200 / Triple 1600
- B2B 대상 클리어: ×1.5
- 콤보: +50 × 콤보수 × 레벨
- All Clear 보너스: Single 800 / Double 1200 / Triple 1800 / Quad 2000 (B2B Quad AC 3200)
- 소프트드롭 +1/칸, 하드드롭 +2/칸
## 6. 대전 규칙 (리그전·커스텀 공통)
**가비지 전송 테이블**
| 클리어 | 전송 줄 |
|---|---|
| Single | 0 |
| Double / Triple / Quad | 1 / 2 / 4 |
| T-spin Mini / Mini Single | 0 / 1 |
| T-spin Single / Double / Triple | 2 / 4 / 6 |
| B2B 보너스 | +1 |
| All Clear | +5 (B2B 카운트 유지) |
| 콤보 보너스 (콤보 2부터) | [1,1,2,2,3,3,4,4,4,5] 이후 5 고정 (초기값) |
- **상쇄(counter)**: 대기 중인 수신 가비지에서 내 공격량을 먼저 차감, 남은 만큼만 실제 전송.
- **투입**: 수신 가비지는 게이지에 누적 표시 → 다음 피스 락다운 시 하단 삽입. 구멍 열은 랜덤(70% 확률로 직전과 같은 열 유지 — 초기값).
- **가비지 한 번에 최대 삽입**: 8줄/회 (초과분은 대기 유지).
- **장기전 방지**: 라운드 90초 경과 후 30초마다 가비지 배율 +25% (커스텀에선 호스트가 끔 가능).
- **톱아웃** = 라운드 패배. FFA는 최후 생존자가 라운드 승리.
## 7. 커스텀 룸 — 인원 무제한 FFA
- **방 만들기**: 6자리 영숫자 코드 발급(혼동 문자 I/1/O/0 제외) → 로비. **참가하기**: 코드 입력.
- **최대 인원 제한 없음.** 2명 = 1:1, 3명+ = FFA(라운드마다 최후 생존자 승).
- 라운드 무한 반복 + 승수 누적. 호스트가 목표 승수 설정: **FT1 / FT3 / FT5** (기본 무제한 반복).
- **호스트 설정**: 가비지 배율 0.5×~2.0×, 목표 승수, 시드 모드(개인 bag 기본 / 동일 bag 공정전), 장기전 배율 on/off.
- **타겟팅**: 기본 랜덤(8초마다 자동 변경) + 상대 미니보드 탭으로 수동 지정. KO 피드 표시(`A → B 격추!`).
- KO된 플레이어는 라운드 끝까지 관전. **도중 입장 가능**(다음 라운드부터 참전). 호스트 퇴장 시 다음 입장자에게 위임.
- 레이팅 미반영(언랭크). 전적에 "커스텀" 표기. 결과창에서 **리매치 투표**(과반 동의 시 즉시 재시작).
## 8. 리그전 — 레이팅·랭크·매치메이킹
### 8.1 Glicko-2 & TR
- **Glicko-2** 전체 구현 (rating R, deviation RD, volatility σ). 초기값 R=1500, RD=350, σ=0.06, τ=0.5.
- **경기 승패로만 갱신** (라운드 무관). 경기 종료 시 서버에서 양측 동시 갱신.
- **표시용 TR (0~25,000)**: `TR = round(25000 / (1 + 10^((1600 (R RD)) / 300)))` — 상수 1600/300은 튜닝 대상(Phase 6에서 분포 보고 조정).
- **배치 10경기** 완료 전 TR 비공개("배치 n/10" 표시). **RD < 100일 때만 랭크 표시.**
- RD 하한 58. 7일 미접속 시 하루 +1씩 증가(최대 350) — Cron으로 일일 처리.
### 8.2 랭크 18단계 (초기 고정 TR 컷 — 운영 데이터로 조정)
```
D 0 · D+ 800 · C- 1,400 · C 2,000 · C+ 2,600 · B- 3,300 · B 4,000 · B+ 4,800
A- 5,700 · A 6,700 · A+ 7,800 · S- 9,200 · S 10,800 · S+ 12,400 · SS 14,200
U 16,500 · X 19,500 · X+ 22,500
```
(각 값 이상이면 해당 랭크. 인구가 충분해지면 퍼센타일 방식으로 전환.) 랭크 뱃지는 자체 아트, 승급/강등 연출 포함.
### 8.3 경기 형식 & 매치메이킹
- 형식: 두 플레이어 중 **높은 쪽 랭크** 기준 — A+ 이하 **FT3**, S- 이상 **FT5**. (FT7 없음 — 최대 FT5 확정)
- MatchmakerDO(싱글턴) 큐. 매칭 조건: 두 플레이어의 **글리코 R ± 2×RD 구간이 겹치면** 성사.
- 대기 30초마다 자기 구간을 ×1.2씩 확장. 120초+ 무매칭 시 "커스텀 룸에서 친구와 플레이" 안내 노출(큐 유지).
- 게스트는 리그전 참가 불가 (§9).
## 9. 계정 · 온보딩 (게스트 우선)
### 온보딩
- 첫 실행 시 가입 없이 즉시 플레이. **랜덤 게스트명 자동 부여**: `형용사+명사+숫자3` 조합 (예: "재빠른미노482"). 단어 풀은 각 20개 내외로 코드에 정의. [게스트] 뱃지 표시.
- 게스트 신원: 기기 로컬 ID(localStorage UUID) + 서버 발급 게스트 토큰.
- 게스트 가능: 솔로 전 모드, 커스텀 생성/참가. **불가**: 리그전, 리더보드 등록, 닉 변경.
### 로그인 (프로필 화면)
1. **Google 로그인**: Google Identity Services(웹 OAuth). 클라가 받은 ID 토큰을 서버로 전송 → Worker가 Google JWKS로 서명 검증 → `google_sub`로 계정 매칭/생성. ※ Play Games가 아닌 구글 계정 로그인(웹 배포 특성). 사전 준비물: Google Cloud Console OAuth 클라이언트 ID (사용자에게 발급 절차 안내할 것).
2. **이메일+비밀번호**: PBKDF2(WebCrypto, 반복 ≥100k, salt 개별) 해시. v1은 비밀번호 재설정 미지원(문구로 안내), v2에서 메일 인프라와 함께.
- **로그인 시 게스트 기록 자동 이전**: 솔로 베스트·커스텀 전적을 계정으로 병합(더 좋은 기록 우선).
- 세션: HMAC 서명 토큰(만료 30일), 자동 로그인.
### 닉네임 정책
- 로그인 유저만 변경 가능. **UNIQUE 보장**(D1), 2~12자(한글/영문/숫자/_), 실시간 중복 검사 API.
- 랜덤 게스트명 패턴은 시스템 예약(사칭 방지). 변경 쿨다운 30일.
## 10. 광고 & 수익화
- **원칙: 플레이 중 화면(②③④)에는 광고 0** — 조작 공간 보호 + 우발 클릭 방지(애드센스 정책 위반 리스크 회피).
- **하단 배너 (320×50/적응형)**: ① 메인(탭바 위), ⑤ 커스텀 로비. 자리는 §12 좌표 참고.
- **결과창 네이티브 카드 (300×250급)**: ⑥ 리그 결과, ⑨ 일반전 결과, 솔로 결과 — 모든 결과 화면 공통 슬롯.
- **전면(interstitial)**: 결과 확인 → 다음 행동 사이에만. 3판당 1회, 세션 첫 판 제외.
- **보상형**: v2.
- 구현: **AdSense 배너 + H5 Games Ads(Ad Placement API)**. 개발 중에는 광고 위치에 플레이스홀더 박스(점선 테두리 + "AD" 라벨)를 렌더하고, 실제 광고 코드는 배포 단계에서 사용자 애드센스 계정 ID로 끼운다.
- 광고 게시 = 수익화 단계. 앱 이름은 테트리그로 확정되어 있으므로 이름 재검토 트리거는 없다 (§2 참고).
## 11. 조작 — 확정 배치 (390×844 기준 좌표)
**기본: 가상 버튼.** 조작 존 y612~812. 멀티터치 지원(이동 누른 채 회전 가능). touchstart 즉시 반응(클릭 지연 제거), 햅틱(vibrate 10ms) 옵션.
| 버튼 | x | y | w | h | 동작 |
|---|---|---|---|---|---|
| HOLD | 16 | 612 | 144 | 56 | 홀드 |
| ↺ 좌회전 | 16 | 676 | 68 | 136 | CCW |
| ↻ 우회전 | 92 | 676 | 68 | 136 | CW |
| 180° | 168 | 612 | 54 | 56 | 180 회전 |
| 하드드롭 | 168 | 676 | 54 | 136 | 즉시 낙하+락 (마젠타 강조) |
| ◀ | 230 | 612 | 68 | 128 | 좌 이동 (홀드 시 DAS) |
| ▶ | 306 | 612 | 68 | 128 | 우 이동 (홀드 시 DAS) |
| ▼ 소프트 | 230 | 748 | 144 | 64 | 소프트드롭 |
- **핸들링 초기값**(설정 슬라이더): DAS 133ms(80~300), ARR 10ms(0~83, 0=즉시), SDF 20×(5~40).
- **옵션: 스와이프 모드**(설정 전환): 좌우 드래그=이동(거리=칸), 아래 빠른 플릭=하드, 아래 느린 드래그=소프트, 우측 탭=CW/좌측 탭=CCW, 홀드는 HOLD 박스 탭.
- 버튼 크기/투명도 3프리셋(소/중/대) 제공.
## 12. 화면별 상세 명세 (뷰포트 390×844, 세로 고정)
공통: **테마 이원화** — 메뉴·로비·결과 화면(①⑤⑥⑦⑧⑨)은 BONE(#EDE7DA) 라이트 테마, 인게임(②③④)은 INK(#17161B) 다크 테마. 팔레트·타이포·컴포넌트 규칙은 §13 디자인 시스템 v2를 따른다. 모든 메뉴 화면 하단 탭바(y764~844): 홈·리더보드·프로필·설정 (활성 탭 = SAFETY 오렌지 블록 + 잉크 보더).
**① 메인**: 상단 TETRIG 워드마크(24,46) + 우측 닉네임·랭크 칩. 시즌 바(24,76,342,40): "SEASON n · D-xx" + TR. 카드 3장(24,y,342,148 / y=132,300,468): 솔로(민트 좌측바)·멀티/리그전(마젠타)·커스텀(바이올렛), 각각 타이틀+설명+우측 쉐브론. **배너 광고(24,700,342,52)**. 탭바.
**② 솔로 인게임**: 일시정지(16,16,40,36). 상단 중앙: 모드명/시간(대)/LINES·PPS. 보드(20,92, 셀 25px → 250×500). 우측 열(292~374): HOLD 박스(82×56) → NEXT 라벨 → 넥스트 5개(각 82×54). 판정 팝업은 보드 중앙 상단에 페이드 텍스트. 조작 존 §11.
**③ 리그전 1:1**: 상단: 좌 내 칩 / 중앙 "2 : 1"+"리그전·FT3" / 우 상대 칩. 내 보드(16,86, 셀 22 → 220×440). **가비지 게이지**(244,86,10,440): 수신 대기량을 아래부터 마젠타로 채움 + 수치. 우측 열(262~374): 상대 실시간 미니보드(셀 10 → 100×200, 닉·랭크 라벨) → 내 HOLD(100×50) → NEXT 3개. 조작 존 동일. 라운드 종료 시 중앙 오버레이(승/패 + 스코어), 경기 종료 시 ⑥으로.
**④ 커스텀 FFA**: 상단 좌 "ALIVE 9/24", 우 타겟 칩("타겟: 랜덤/수동") + "미니보드 탭=타겟" 힌트. 그 아래 KO 피드 2줄. 내 보드(16,92, 셀 20 → 200×400). 우측 그리드(x 236/308, y 96/248/400): 상대 미니보드 2열(각 66×132, 닉 라벨, 타겟은 마젠타 테두리+조준 배지, KO는 회색+KO 표시), 마지막 타일 "+n명". 보드 아래 가로 스트립(y552): HOLD(70×52)+NEXT 5(각 52×52). 조작 존 동일.
**⑤ 커스텀 로비**: 헤더 "커스텀 룸"+뒤로. 코드 카드(20,70,350,124): "초대 코드" 라벨 + 코드 6자 대형(자간 넓게, 탭=복사) + [복사] 버튼 + "인원 제한 없음 · 현재 n명". 규칙 카드(20,210,350,58): 칩 3개(FTn·가비지 x.x×·bag 모드) + 호스트만 [변경]. 참가자 리스트(20,300~, 행 350×48·간격 8): 랭크칩+HOST칩+닉+준비상태(●/○). **배너 광고(20, 리스트 아래, 350×52)**. 하단 힌트 + [게임 시작](20,744,350,66, 마젠타 — 호스트) / 게스트는 [준비] 토글.
**⑥ 리그 결과**: "VICTORY"(민트)/"DEFEAT"(#FF6B6B) 대형 + "2:1 · 리그전 FT3". 레이팅 카드(30,220,330,160): TR 이전→이후 + 증감(민트/코럴) + 랭크칩 + 다음 랭크 게이지+%. 스탯 카드(30,398,330,86): APM/PPS/VS 3열. 상대 행(30,502,330,54): "vs 닉 · 랭크" + [전적 보기]. **광고 네이티브 카드(30,566,330,108)**. [다시 매칭](30,688,330,62, 마젠타) [메인으로](30,762,330,54).
**⑦ 프로필 (게스트 상태)**: 신원 카드(20,70,350,110): 아바타(블록 문양)+게스트명+[게스트]칩+"기록이 이 기기에만 임시 저장 중". 기록 카드(20,196,350,84): SPRINT/TIME ATTACK/플레이수 3열. 로그인 카드(20,300,350,262, 바이올렛 테두리): "로그인하고 이름을 가져가세요" + 혜택 3줄(고유 닉네임 / 기록·레이팅 영구 보존 / 리그전 참가) + **[Google로 계속하기]**(흰 배경 40,446,310,52) + **[이메일로 계속하기]**(40,508,310,44) + "로그인 시 게스트 기록이 계정으로 이전됩니다". 로그인 후에는 이 카드가 닉네임+[이름 변경]+로그아웃으로 대체. 탭바(프로필 활성).
**⑧ 솔로 모드 선택**: 메인 위 딤 오버레이 + 바텀시트(y404~844, 상단 핸들바): "솔로" 타이틀 + 행 3개(24,y,342,96 / y=486,594,702): SPRINT(민트바)·TIME ATTACK(앰버바)·ENDLESS(바이올렛바), 각각 설명+내 베스트+쉐브론.
**⑨ 일반전(커스텀) 결과**: "DEFEAT"(#FF6B6B) 또는 "VICTORY" + "n위/m명 · 커스텀 FFA"(1:1이면 스코어). 스탯 카드(30,220,330,86): KO/APM/PPS. **광고 카드(30,330,330,190, 300×250급)**. 순위 요약 행(30,540,330,54). [리매치 투표 n/m](30,688,330,62, 바이올렛) [로비로](30,762,330,54).
## 13. 비주얼 & 브랜드 — 디자인 시스템 v2 (NEOBRUTAL)
**방법론**: 사람 디자이너들이 실제로 쓰는 네오브루탈리즘 컴포넌트 체계(ekmas/neobrutalism-components 계열 규칙: 2px 잉크 보더, 블러 없는 하드 오프셋 섀도, 고대비 플랫 컬러, 최소 라운딩)를 기반으로, 이 게임 고유의 시그니처 **"해저드 시스템"**(가비지=위험에서 따온 45° 스트라이프 + 챔퍼컷 모서리)을 결합했다. **다크 네이비 배경·네온 글로우·그라디언트·블러 섀도·과도한 라운딩은 전면 금지** — AI 생성 티가 나는 디폴트이므로 사용하지 않는다.
### 팔레트
| 이름 | HEX | 용도 |
|---|---|---|
| BONE | #EDE7DA | 라이트 배경·밝은 패널 |
| PAPER | #E1D9C7 | 보조 패널 |
| INK | #17161B | 텍스트·보더·하드 섀도·인게임 배경 |
| SAFETY | #FF4E11 | 주 액션·공격·랭크 강조 |
| COBALT | #2B3FE0 | 리그·정보·보조 액션 |
| DWELL / DPNL / DGRID | #101015 / #1F1F26 / #26262E | 보드 웰 / 다크 패널 / 그리드 |
| SUB / DSUB | #6E6A5E / #8D8A80 | 보조 텍스트 (라이트/다크) |
### 피스 7색 (v2 확정 — 프린트 톤)
| I | O | T | S | Z | J | L | 가비지 |
|---|---|---|---|---|---|---|---|
| #3EC1B6 | #F5C531 | #A76BF2 | #7FCC4C | #F25C4C | #4C7DF2 | #F2913D | #46464F |
### 타이포 (3역할 — 구현 시 웹폰트)
- **디스플레이**: 각진 헤비 산세리프 — 'Black Han Sans'(구글 폰트, 무료) 권장 + **skewX(-8°) 이탤릭 처리**. 큰 숫자·VICTORY/DEFEAT·모드명·판정 팝업에만 절제 사용
- **본문**: 'Pretendard' 또는 시스템 산세리프
- **유틸/모노**: 'Nanum Gothic Coding' 또는 'IBM Plex Mono' — 초대 코드·TR·APM/PPS·라벨·시즌 표기
### 컴포넌트 규칙
- **패널**: 잉크 2~2.5px 보더 + **하드 오프셋 섀도(4~6px, 블러 0, 단색 잉크)** + **우하단 챔퍼컷 14px**
- **버튼**: 주 액션 = SAFETY 채움 + 잉크 텍스트·보더·섀도 / 보조 = BONE·PAPER 채움. 라운딩은 0~10px만
- **태그·랭크 뱃지**: 사각 스티커 + 잉크 보더. 랭크 뱃지는 **-3~-4° 틸트** (스티커 감성)
- **해저드 스트라이프 용법** (남용 금지 — 위험·공격·광고·난투에만): 가비지 게이지(다크: SAFETY×INK 스트라이프), 광고 슬롯 프레임(라이트: INK×BONE), 커스텀 카드 표식, DEFEAT 테이프
- **인게임 다크 패널**: #1F1F26 채움 + BONE 1.5~2px 보더, 섀도 없음(플랫 콘솔 느낌)
- **판정 팝업**: 디스플레이체 skew + SAFETY 밑줄 바 (글로우 금지)
- 연출도 플랫하게: 라인 클리어 파티클 = 사각 파편, 셰이크는 유지. 사운드 방향(Web Audio 신스)은 동일
### 로고 (부록 A v2)
- **워드마크**: BONE 바탕 + 잉크 셀 레터폼 + SAFETY 하드 오프셋 섀도. 첫 T 상단에 오렌지 T-미노(잉크 보더), **마지막 G의 모서리 셀 하나가 11° 틀어져 튀어나온 디테일**(의도된 불완전성 = 사람 손맛), 하단 해저드 테이프 + 모노 캡션 "RANKED STACK BATTLE"
- **앱 아이콘**: BONE 스퀘어클 + 잉크 T-미노(-7° 틸트) + SAFETY 하드 섀도 + 하단 해저드 밴드 + 잉크 링 보더
## 14. 서버 상세
### 14.1 구성
- **GameRoomDO** (경기당 1개, 리그·커스텀 공용): WS 허브, 가비지 원장·타겟 라우팅, 라운드/FT 관리, 시드 배포, 재접속 유예(15초, 리그는 초과 시 몰수), 리매치 투표, 결과를 D1에 기록 후 리그면 레이팅 갱신 호출.
- **MatchmakerDO** (싱글턴): 리그 큐, §8.3 규칙으로 페어링 → GameRoomDO 생성·양측 이관.
- **D1**: 아래 스키마. **Cron**: 일 1회 RD 감쇠, 시즌 마감 처리.
- N인 방 부하: 보드 스냅샷은 피스 락 시 + 플레이어당 초당 2회 상한, 방 인원이 클수록 주기 자동 완화(관전자·타겟 우선 전달). WebSocket Hibernation 사용.
- 최소 안티치트: PPS 상한(5.0), 피스당 공격량 상한, 라운드 이벤트 로그 보관(v2 정밀 검증 대비).
### 14.2 D1 스키마 초안 (구현 시 조정 가능)
```sql
CREATE TABLE users(
id TEXT PRIMARY KEY, nickname TEXT UNIQUE NOT NULL,
is_guest INTEGER NOT NULL DEFAULT 1,
email TEXT UNIQUE, google_sub TEXT UNIQUE, pw_hash TEXT, pw_salt TEXT,
created_at INTEGER, nickname_changed_at INTEGER
);
CREATE TABLE ratings(
user_id TEXT PRIMARY KEY REFERENCES users(id),
season INTEGER NOT NULL, glicko REAL, rd REAL, vol REAL,
tr INTEGER, games INTEGER DEFAULT 0, wins INTEGER DEFAULT 0, last_played INTEGER
);
CREATE TABLE matches(
id TEXT PRIMARY KEY, season INTEGER, mode TEXT, -- 'league' | 'custom'
finished_at INTEGER, room_size INTEGER,
p1 TEXT, p2 TEXT, score1 INTEGER, score2 INTEGER, -- 커스텀 FFA는 p1=본인, score1=순위
tr_delta1 INTEGER, tr_delta2 INTEGER
);
CREATE TABLE solo_records(
user_id TEXT, mode TEXT, best INTEGER, updated_at INTEGER,
PRIMARY KEY(user_id, mode)
);
```
### 14.3 WebSocket 프로토콜 (JSON, 초안)
```
클라 → 서버: {t:"join", room, token} · {t:"ready"} · {t:"attack", lines, kind}
{t:"board", grid}(압축 비트필드) · {t:"topout"} · {t:"target", id}
{t:"rematch", vote} · {t:"leave"}
서버 → 클라: {t:"lobby", players[], rules} · {t:"seed", bag, mode}
{t:"round_start", n} · {t:"garbage", from, lines}
{t:"board", id, grid} · {t:"ko", who, by} · {t:"round_end", winner, score}
{t:"match_end", result, trDelta?} · {t:"rematch_state", votes}
```
가비지 계산은 클라 보고 기반이되 **서버가 원장 관리**(상쇄·라우팅·상한 검증)로 desync 방지.
## 15. 개발 로드맵 (이 순서로 진행)
| 단계 | 내용 | 완료 기준 |
|---|---|---|
| Phase 1 | 코어 엔진: 보드·피스·SRS+180 킥·7-bag·홀드·고스트·락딜레이·클리어 | 키보드로 플레이 가능 + 킥/판정 단위 테스트 통과 |
| Phase 2 | 판정·점수 + 솔로 3모드 + 솔로 결과 화면(광고 슬롯 포함) | 3모드 정상 플레이·기록 저장 |
| Phase 3 | §11 가상 버튼 + 스와이프 + 핸들링 설정 + §12 메뉴 화면들 | 모바일 브라우저 실기 조작 확인 |
| Phase 4 | 서버 뼈대: 게스트 토큰·계정(구글/이메일)·기록 이전·GameRoomDO·WS·가비지 원장 + 커스텀 1:1 | 탭 2개로 코드 방 1:1 대전 성사 |
| Phase 5 | 커스텀 FFA 확장: 다인 타겟팅·미니뷰 그리드·KO 관전·도중 입장·리매치 투표 | 탭 4개+ FFA 라운드 정상 진행 |
| Phase 6 | MatchmakerDO + Glicko-2 + TR/랭크 + 리더보드·프로필·전적 + 리그 결과 화면 | 매칭→경기→TR 반영 전체 사이클 |
| Phase 7 | 연출·사운드·시즌·광고 슬롯 연결·재접속/부하 시나리오 점검·폴리시 | v1 배포 (wrangler deploy + Pages) |
## 16. 스토어/랜딩 문구 (확정 카피)
- **태그라인**: "쌓아라, 부숴라, 올라가라." (예비: "손끝에서 터지는 실시간 블록 대전" / "리그는 열렸다. 랭크로 증명해라.")
- **짧은 설명**: 실시간 1:1 리그전부터 인원 무제한 커스텀 난투까지 — 모바일에 최적화된 대전 블록 스태커
- **전체 설명 구성**: 인트로(모바일 재설계·핸들링·랭크) → ■리그전(Glicko-2·18랭크·배치10·시즌) → ■커스텀(6자리 코드·인원 무제한·규칙 설정) → ■솔로(SPRINT/TIME ATTACK/ENDLESS) → ■손맛(SRS+·7-bag·T-spin·B2B·핸들링) → ■기록(리더보드·APM·PPS) → 클로징("배치 10경기를 끝내보세요")
- **영문 소개**: TETRIG — a real-time competitive block stacker built for mobile. Ranked 1v1 league with 18 tiers, unlimited-size custom rooms with invite codes, and solo modes with full handling customization.
- **ASO 키워드**: 블록 대전, 실시간 대전, 퍼즐 배틀, 스태커, 랭크 게임, 블록 퍼즐, 1:1 대결. ⚠ 설명·키워드에 "테트리스/Tetris" 직접 사용 금지.
## 17. 미확정 사항 (구현을 막지 않음 — 필요 시점에 사용자에게 확인)
1. 도메인: `tetrig.pages.dev` 무료로 시작. 커스텀 도메인(tetrig.com 등)은 사용자가 추후 결정
2. 시즌 길이 (제안 기본값: 2개월)
3. Google OAuth 클라이언트 ID (Phase 4 시작 시 사용자에게 발급 안내)
4. 애드센스 계정 ID (Phase 7 광고 연결 시)
---
## 부록 A. 로고 SVG 원본 소스 (그대로 파일로 저장해 사용)
### A-1. tetrig-logo.svg (가로형 워드마크)
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1322 448" width="1322" height="448">
<defs><pattern id="hz" width="18" height="18" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="18" height="18" fill="#EDE7DA"/><rect width="9" height="18" fill="#17161B"/></pattern></defs>
<rect width="1322" height="448" fill="#EDE7DA"/>
<rect x="87" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="139" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="191" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="139" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="139" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="139" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="139" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="132" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="132" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="132" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="80" y="80" width="46" height="46" rx="3" fill="#FF4E11" stroke="#17161B" stroke-width="3"/>
<rect x="132" y="80" width="46" height="46" rx="3" fill="#FF4E11" stroke="#17161B" stroke-width="3"/>
<rect x="184" y="80" width="46" height="46" rx="3" fill="#FF4E11" stroke="#17161B" stroke-width="3"/>
<rect x="132" y="132" width="46" height="46" rx="3" fill="#FF4E11" stroke="#17161B" stroke-width="3"/>
<rect x="279" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="331" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="383" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="279" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="279" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="331" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="383" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="279" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="279" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="331" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="383" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="272" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="324" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="376" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="272" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="272" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="324" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="376" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="272" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="272" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="324" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="376" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="471" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="523" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="575" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="523" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="523" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="523" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="523" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="464" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="516" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="568" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="516" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="516" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="516" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="516" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="663" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="715" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="663" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="767" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="663" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="715" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="663" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="767" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="663" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="767" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="656" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="708" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="656" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="760" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="656" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="708" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="656" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="760" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="656" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="760" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="855" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="907" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="959" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="907" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="907" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="907" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="855" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="907" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="959" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="848" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="900" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="952" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="900" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="900" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="900" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="848" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="900" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="952" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1047" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1099" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1151" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1203" y="87" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1047" y="139" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1047" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1151" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1203" y="191" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1047" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1203" y="243" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1047" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1099" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1151" y="295" width="46" height="46" rx="3" fill="#FF4E11" />
<rect x="1040" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1092" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1144" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1196" y="80" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1040" y="132" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1040" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1144" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1196" y="184" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1040" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1196" y="236" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1040" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1092" y="288" width="46" height="46" rx="3" fill="#17161B" />
<rect x="1144" y="288" width="46" height="46" rx="3" fill="#17161B" />
<g transform="rotate(11 1229.0 319.0)"><rect x="1209" y="297" width="46" height="46" rx="3" fill="#FF4E11" stroke="#17161B" stroke-width="3"/></g>
<rect x="80" y="350" width="216" height="12" fill="url(#hz)" stroke="#17161B" stroke-width="2"/>
<text x="312" y="361" font-family="NanumGothicCoding, monospace" font-size="15" font-weight="700" fill="#17161B" letter-spacing="2">RANKED STACK BATTLE</text>
</svg>
```
### A-2. tetrig-icon.svg (앱 아이콘 512×512)
```svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<defs><pattern id="hz" width="18" height="18" patternUnits="userSpaceOnUse" patternTransform="rotate(45)">
<rect width="18" height="18" fill="#EDE7DA"/><rect width="9" height="18" fill="#17161B"/></pattern></defs>
<rect width="512" height="512" rx="96" fill="#EDE7DA"/>
<clipPath id="cp"><rect width="512" height="512" rx="96"/></clipPath>
<g clip-path="url(#cp)">
<rect x="0" y="428" width="512" height="84" fill="url(#hz)"/>
<rect x="0" y="424" width="512" height="5" fill="#17161B"/>
<g transform="rotate(-7 250.0 261.0)">
<rect x="92.0" y="162" width="104" height="104" rx="6" fill="#FF4E11" />
<rect x="210.0" y="162" width="104" height="104" rx="6" fill="#FF4E11" />
<rect x="328.0" y="162" width="104" height="104" rx="6" fill="#FF4E11" />
<rect x="210.0" y="280" width="104" height="104" rx="6" fill="#FF4E11" />
<rect x="80.0" y="150" width="104" height="104" rx="6" fill="#17161B" />
<rect x="198.0" y="150" width="104" height="104" rx="6" fill="#17161B" />
<rect x="316.0" y="150" width="104" height="104" rx="6" fill="#17161B" />
<rect x="198.0" y="268" width="104" height="104" rx="6" fill="#17161B" />
</g>
</g>
<rect x="7" y="7" width="498" height="498" rx="90" fill="none" stroke="#17161B" stroke-width="10"/>
</svg>
```
## 부록 B. UX 목업 파일 (첨부됨)
- 이 문서와 함께 **`tetrig-ux-mockup.png`** (9화면 시트, 1390×2944)가 첨부된다. **UI·레이아웃·색·버튼 배치는 이 목업을 시각 기준으로 구현**하고, §11~§13의 좌표·팔레트 수치를 함께 사용해 정확도를 맞춘다.
- 목업의 화면 번호(①~⑨)는 §12의 화면 명세 번호와 1:1 대응한다.
- 로고는 첨부된 `tetrig-logo.svg` / `tetrig-icon.svg`를 그대로 사용한다 (부록 A에 동일 소스).