Files
tetrig/index.html
tkrmagid c9463fe46b Phase 2+3: full solo app (3 modes, scoring, menus, settings, records)
- engine: 점수 시스템(§5) 추가 — scoreClear/levelForLines/gravityMsForLevel + 테스트(27개 통과).
- 클라이언트를 멀티스크린 PWA로 확장:
  · 메인 ①(라이트 네오브루탈, 3카드+시즌바+탭바+광고 슬롯)
  · 솔로 모드선택 시트 ⑧(SPRINT/TIME ATTACK/ENDLESS + 베스트 기록)
  · 인게임 ②(§11 터치, 모드별 중력/레벨/타이머/점수)
  · 솔로 결과 화면(기록 갱신·NEW RECORD·광고 슬롯)
  · 설정(DAS/ARR/SDF 슬라이더·고스트·햅틱, localStorage)
  · 멀티/커스텀/리더보드/프로필은 서버 단계까지 "준비 중"
- SPRINT(40줄 타임)·TIME ATTACK(2분 점수·레벨가속)·ENDLESS(무한, 톱아웃 시 보드만 리셋) 구현.
- 기록은 localStorage 저장. 헤드리스로 전 화면·플레이·점수·네비게이션 검증(에러 0).
2026-08-22 15:57:36 +09:00

958 lines
47 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="TETRIG">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#17161B">
<link rel="manifest" href="manifest.webmanifest">
<link rel="apple-touch-icon" href="apple-touch-icon.png">
<link rel="icon" type="image/png" href="icon-192.png">
<title>TETRIG</title>
<style>
/* 명세서 §13 팔레트 */
:root{
--ink:#17161B; --bone:#EDE7DA; --paper:#E1D9C7; --dwell:#101015; --dpnl:#1F1F26; --dgrid:#26262E;
--safety:#FF4E11; --cobalt:#2B3FE0; --dsub:#8D8A80; --sub:#6E6A5E;
--mint:#3EC1B6; --amber:#F5C531; --violet:#A76BF2;
}
*{ box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
html,body{ height:100%; width:100%; overflow:hidden; background:#000; }
body{ display:flex; align-items:center; justify-content:center;
font-family:ui-monospace,'Nanum Gothic Coding','Menlo',monospace; color:var(--ink);
touch-action:none; -webkit-user-select:none; user-select:none; overscroll-behavior:none; }
#app{ position:relative; width:390px; height:844px; overflow:hidden; transform-origin:center center; }
.disp{ font-weight:900; font-style:italic; }
.mono{ font-variant-numeric:tabular-nums; }
/* 화면 전환 */
.screen{ position:absolute; inset:0; display:none; }
.screen.on{ display:block; }
.light{ background:var(--bone); color:var(--ink); }
.dark{ background:var(--ink); color:var(--bone); }
/* 네오브루탈 공통 */
.panel{ background:var(--bone); border:2px solid var(--ink);
box-shadow:4px 4px 0 var(--ink);
clip-path:polygon(0 0,100% 0,100% calc(100% - 14px),calc(100% - 14px) 100%,0 100%); }
.hazard{ background:repeating-linear-gradient(45deg,var(--ink) 0 9px,var(--bone) 9px 18px); }
.chip{ display:inline-block; border:2px solid var(--ink); background:var(--bone); padding:1px 7px;
font-size:12px; font-weight:700; }
.chip.safety{ background:var(--safety); color:var(--ink); }
.chevron{ font-size:26px; color:var(--sub); }
/* ── ① 메인 ── */
#wordmark{ position:absolute; left:24px; top:40px; font-size:30px; color:var(--ink); }
#wordmark .o{ color:var(--safety); }
#wordmark .tape{ position:absolute; left:2px; top:38px; width:120px; height:8px; border:1.5px solid var(--ink); }
#whoami{ position:absolute; right:24px; top:44px; display:flex; gap:6px; align-items:center; }
.season{ position:absolute; left:24px; top:96px; width:342px; height:40px; display:flex; align-items:center;
justify-content:space-between; padding:0 12px; }
.season .tr{ color:var(--cobalt); font-weight:700; }
.mcard{ position:absolute; left:24px; width:342px; height:148px; padding:18px 18px 0 26px; cursor:pointer; }
.mcard .bar{ position:absolute; left:0; top:0; bottom:14px; width:14px; }
.mcard h2{ font-size:26px; margin-bottom:6px; }
.mcard p{ font-size:12px; color:var(--sub); }
.mcard .chevron{ position:absolute; right:16px; top:56px; }
.adbanner{ position:absolute; left:24px; top:700px; width:342px; height:52px; display:flex; align-items:center;
gap:10px; padding:0 10px; border:2px dashed var(--ink); background:var(--paper); font-size:12px; color:var(--sub); }
.adbanner .adtag{ background:var(--ink); color:var(--bone); font-weight:700; padding:2px 6px; font-size:11px; }
/* 탭바 (메뉴 공통) */
.tabbar{ position:absolute; left:0; right:0; bottom:0; height:80px; border-top:2px solid var(--ink);
display:flex; background:var(--bone); }
.tabbar .tab{ flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:6px;
font-size:11px; color:var(--sub); cursor:pointer; }
.tabbar .tab .dot{ width:26px; height:10px; background:var(--sub); }
.tabbar .tab.act{ color:var(--ink); font-weight:700; }
.tabbar .tab.act .dot{ background:var(--safety); border:2px solid var(--ink); height:16px; }
/* ── ⑧ 모드 선택 시트 ── */
#dim{ position:absolute; inset:0; background:rgba(23,22,27,.5); display:none; z-index:8; }
#dim.on{ display:block; }
#sheet{ position:absolute; left:0; right:0; bottom:0; top:404px; background:var(--bone);
border-top:2px solid var(--ink); border-radius:0; z-index:9; display:none; padding:16px 24px; }
#sheet.on{ display:block; }
#sheet .handle{ width:56px; height:6px; background:var(--sub); margin:0 auto 14px; }
#sheet h2{ font-size:24px; margin-bottom:14px; }
.mrow{ position:relative; height:96px; margin-bottom:12px; padding:16px 16px 0 24px; cursor:pointer; }
.mrow .bar{ position:absolute; left:0; top:0; bottom:14px; width:12px; }
.mrow h3{ font-size:20px; }
.mrow p{ font-size:11px; color:var(--sub); margin-top:4px; }
.mrow .best{ position:absolute; right:44px; top:36px; font-size:12px; color:var(--ink); }
.mrow .chevron{ position:absolute; right:14px; top:30px; }
/* ── ② 인게임 (다크) ── */
#pause{ position:absolute; left:16px; top:16px; width:40px; height:36px; background:var(--dpnl);
border:2px solid var(--bone); display:flex; align-items:center; justify-content:center;
clip-path:polygon(0 0,100% 0,100% calc(100% - 9px),calc(100% - 9px) 100%,0 100%); }
#pause span{ width:4px; height:16px; background:var(--bone); margin:0 2px; }
#top{ position:absolute; left:70px; right:16px; top:12px; text-align:center; }
#mode{ font-size:12px; letter-spacing:4px; color:var(--dsub); }
#time{ font-size:34px; line-height:1.05; letter-spacing:1px; }
#sub{ font-size:12px; letter-spacing:1px; color:var(--dsub); margin-top:2px; }
#sub b{ color:var(--bone); }
#board{ position:absolute; left:20px; top:92px; }
#popup{ position:absolute; left:20px; width:250px; top:300px; text-align:center; pointer-events:none; opacity:0; transition:opacity .1s; }
#popup .big{ font-size:26px; color:var(--bone); text-shadow:2px 2px 0 var(--ink); border-bottom:5px solid var(--safety);
display:inline-block; padding:0 4px 2px; transform:skewX(-8deg); }
#popup .sm{ font-size:13px; letter-spacing:1px; margin-top:6px; color:var(--bone); }
.side-lbl{ position:absolute; font-size:11px; letter-spacing:3px; color:var(--dsub); }
.side-box{ position:absolute; background:var(--dpnl); border:2px solid var(--bone);
clip-path:polygon(0 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%); }
.btn{ position:absolute; background:var(--dpnl); border:2px solid var(--bone); color:var(--bone);
display:flex; align-items:center; justify-content:center; font-weight:700; font-size:15px; text-align:center; line-height:1.15;
clip-path:polygon(0 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%); transition:filter .05s; }
.btn.act{ filter:brightness(1.7); }
.btn.safety{ background:var(--safety); color:var(--ink); border-color:var(--bone); }
.btn .ar{ font-size:30px; line-height:1; }
.btn small{ font-size:12px; font-weight:700; }
/* 오버레이 (일시정지 / 결과는 별도 화면) */
.overlay{ position:absolute; inset:0; background:rgba(23,22,27,.9); display:none; flex-direction:column;
align-items:center; justify-content:center; gap:14px; z-index:5; }
.overlay.show{ display:flex; }
.overlay .title{ font-size:40px; color:var(--safety); }
.overlay button{ font:inherit; font-weight:700; background:var(--safety); color:var(--ink); border:2px solid var(--bone);
padding:12px 26px; cursor:pointer; font-size:16px; min-width:200px;
clip-path:polygon(0 0,100% 0,100% calc(100% - 10px),calc(100% - 10px) 100%,0 100%); }
.overlay button.ghost{ background:var(--dpnl); color:var(--bone); }
/* ── 결과 화면 (라이트) ── */
#scr-result{ padding:0 30px; }
#res-hero{ position:absolute; left:0; right:0; top:70px; text-align:center; }
#res-hero .big{ font-size:44px; color:var(--safety); }
#res-hero .modeline{ font-size:13px; color:var(--sub); margin-top:2px; }
#res-metric{ position:absolute; left:30px; right:30px; top:190px; text-align:center; padding:22px 0; }
#res-metric .k{ font-size:12px; color:var(--sub); letter-spacing:2px; }
#res-metric .v{ font-size:44px; margin-top:4px; }
#res-metric .best{ font-size:12px; color:var(--cobalt); margin-top:6px; font-weight:700; }
#res-stats{ position:absolute; left:30px; right:30px; top:340px; display:flex; padding:14px 0; }
#res-stats .c{ flex:1; text-align:center; }
#res-stats .c .k{ font-size:11px; color:var(--sub); }
#res-stats .c .v{ font-size:20px; font-weight:700; margin-top:2px; }
#res-ad{ position:absolute; left:30px; right:30px; top:452px; height:150px; border:2px dashed var(--ink);
background:var(--paper); display:flex; align-items:center; justify-content:center; gap:10px; color:var(--sub); font-size:12px; }
#res-ad .adtag{ background:var(--ink); color:var(--bone); font-weight:700; padding:2px 6px; }
.btn-lg{ position:absolute; left:30px; right:30px; height:60px; font:inherit; font-weight:700; font-size:17px;
border:2px solid var(--ink); background:var(--safety); color:var(--ink); cursor:pointer; box-shadow:4px 4px 0 var(--ink);
clip-path:polygon(0 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%); }
.btn-lg.ghost{ background:var(--bone); }
/* ── 설정 / 준비중 (라이트) ── */
.hdr{ position:absolute; left:24px; top:36px; font-size:24px; }
.back{ position:absolute; right:24px; top:40px; font-size:14px; border:2px solid var(--ink); background:var(--bone);
padding:4px 12px; cursor:pointer; }
.setrow{ position:absolute; left:24px; right:24px; padding:14px 16px; }
.setrow label{ font-size:14px; font-weight:700; }
.setrow .val{ float:right; color:var(--cobalt); font-weight:700; }
.setrow input[type=range]{ width:100%; margin-top:10px; accent-color:var(--safety); }
.toggle{ float:right; width:52px; height:28px; border:2px solid var(--ink); background:var(--bone); position:relative; cursor:pointer; }
.toggle.on{ background:var(--safety); }
.toggle .knob{ position:absolute; top:1px; left:1px; width:22px; height:22px; background:var(--ink); transition:left .1s; }
.toggle.on .knob{ left:27px; }
.soon-msg{ position:absolute; left:24px; right:24px; top:300px; text-align:center; color:var(--sub); font-size:15px; line-height:1.8; }
</style>
</head>
<body>
<div id="app">
<!-- ① 메인 -->
<section id="scr-main" class="screen light on">
<div id="wordmark" class="disp"><span class="o">TET</span>RIG<div class="tape hazard"></div></div>
<div id="whoami"><span class="chip" id="nick">게스트</span><span class="chip safety" id="rank"></span></div>
<div class="season panel mono"><span>SEASON 1 · D-42</span><span class="tr" id="tr">배치 0/10</span></div>
<div class="mcard panel" style="top:152px" data-go="modes">
<div class="bar" style="background:var(--cobalt)"></div><h2 class="disp">솔로</h2>
<p>SPRINT · TIME ATTACK · ENDLESS</p><div class="chevron"></div>
</div>
<div class="mcard panel" style="top:316px" data-go="soon" data-soon="멀티 · 리그전">
<div class="bar" style="background:var(--safety)"></div><h2 class="disp">멀티 · 리그전</h2>
<p>실시간 1:1 랭크 매치 · 배치 10경기</p><div class="chevron"></div>
</div>
<div class="mcard panel" style="top:480px" data-go="soon" data-soon="커스텀">
<div class="bar hazard"></div><h2 class="disp">커스텀</h2>
<p>코드로 만들고 참가 · 인원 제한 없음</p><div class="chevron"></div>
</div>
<div class="adbanner"><span class="adtag">AD</span>배너 광고 320×50 (배포 시 애드센스)</div>
<nav class="tabbar" data-tabs>
<div class="tab act" data-go="main"><div class="dot"></div></div>
<div class="tab" data-go="soon" data-soon="리더보드"><div class="dot"></div>리더보드</div>
<div class="tab" data-go="soon" data-soon="프로필"><div class="dot"></div>프로필</div>
<div class="tab" data-go="settings"><div class="dot"></div>설정</div>
</nav>
</section>
<!-- ⑧ 모드 선택 시트 -->
<div id="dim"></div>
<div id="sheet">
<div class="handle"></div>
<h2 class="disp">솔로</h2>
<div class="mrow panel" data-mode="sprint">
<div class="bar" style="background:var(--mint)"></div><h3 class="disp">SPRINT</h3>
<p>40줄 최속 클리어</p><div class="best mono" id="best-sprint"></div><div class="chevron"></div>
</div>
<div class="mrow panel" data-mode="time">
<div class="bar" style="background:var(--amber)"></div><h3 class="disp">TIME ATTACK</h3>
<p>2분 점수 어택 · 레벨 가속</p><div class="best mono" id="best-time"></div><div class="chevron"></div>
</div>
<div class="mrow panel" data-mode="endless">
<div class="bar" style="background:var(--violet)"></div><h3 class="disp">ENDLESS</h3>
<p>무한 · 톱아웃해도 계속</p><div class="best mono" id="best-endless"></div><div class="chevron"></div>
</div>
</div>
<!-- ② 인게임 -->
<section id="scr-game" class="screen dark">
<div id="pause"><span></span><span></span></div>
<div id="top">
<div id="mode" class="disp">SPRINT</div>
<div id="time" class="disp mono">0.0</div>
<div id="sub" class="mono">LINES <b id="s-lines">0</b> · PPS <b id="s-pps">0.00</b></div>
</div>
<canvas id="board"></canvas>
<div id="popup"><div class="big disp" id="pop-big"></div><div class="sm disp" id="pop-sm"></div></div>
<div class="side-lbl" style="left:292px;top:92px;">HOLD</div>
<canvas id="hold" class="side-box" style="left:292px;top:108px;" width="82" height="56"></canvas>
<div class="side-lbl" style="left:292px;top:176px;">NEXT</div>
<canvas id="next" class="side-box" style="left:292px;top:196px;" width="82" height="286"></canvas>
<div class="btn" id="b-hold" style="left:16px; top:612px; width:144px; height:56px;">HOLD</div>
<div class="btn" id="b-ccw" style="left:16px; top:676px; width:68px; height:136px;"><span class="ar"></span></div>
<div class="btn" id="b-cw" style="left:92px; top:676px; width:68px; height:136px;"><span class="ar"></span></div>
<div class="btn" id="b-180" style="left:168px;top:612px; width:54px; height:56px;">180°</div>
<div class="btn safety" id="b-hard" style="left:168px;top:676px; width:54px; height:136px;"><small>하드<br>드롭<br>⌄⌄</small></div>
<div class="btn" id="b-left" style="left:230px;top:612px; width:68px; height:128px;"><span class="ar"></span></div>
<div class="btn" id="b-right" style="left:306px;top:612px; width:68px; height:128px;"><span class="ar"></span></div>
<div class="btn" id="b-soft" style="left:230px;top:748px; width:144px; height:64px;"><small>▼ 소프트</small></div>
<div class="overlay" id="ov-pause">
<div class="title disp">PAUSE</div>
<button id="ov-resume">계속하기</button>
<button id="ov-restart1" class="ghost">다시하기</button>
<button id="ov-quit" class="ghost">메인으로</button>
</div>
</section>
<!-- 솔로 결과 -->
<section id="scr-result" class="screen light">
<div id="res-hero"><div class="big disp" id="res-title">FINISH</div><div class="modeline" id="res-mode"></div></div>
<div id="res-metric" class="panel"><div class="k" id="res-mk">TIME</div><div class="v disp mono" id="res-mv">0.00</div><div class="best" id="res-best"></div></div>
<div id="res-stats" class="panel">
<div class="c"><div class="k">LINES</div><div class="v mono" id="res-lines">0</div></div>
<div class="c"><div class="k">PPS</div><div class="v mono" id="res-pps">0.00</div></div>
<div class="c"><div class="k">LEVEL</div><div class="v mono" id="res-level">1</div></div>
</div>
<div id="res-ad"><span class="adtag">AD</span>네이티브 광고 300×250 (배포 시)</div>
<button class="btn-lg" id="res-retry" style="top:626px">다시하기</button>
<button class="btn-lg ghost" id="res-menu" style="top:700px">메인으로</button>
</section>
<!-- 설정 -->
<section id="scr-settings" class="screen light">
<div class="hdr disp">설정</div><div class="back" data-go="main">← 뒤로</div>
<div class="setrow panel" style="top:96px"><label>DAS</label><span class="val"><span id="v-das">133</span>ms</span>
<input type="range" id="s-das" min="80" max="300" step="1"></div>
<div class="setrow panel" style="top:186px"><label>ARR</label><span class="val"><span id="v-arr">10</span>ms</span>
<input type="range" id="s-arr" min="0" max="83" step="1"></div>
<div class="setrow panel" style="top:276px"><label>SDF (소프트드롭 배율)</label><span class="val"><span id="v-sdf">20</span>×</span>
<input type="range" id="s-sdf" min="5" max="40" step="1"></div>
<div class="setrow panel" style="top:366px; height:56px"><label>고스트 피스</label>
<div class="toggle on" id="s-ghost"><div class="knob"></div></div></div>
<div class="setrow panel" style="top:446px; height:56px"><label>햅틱 진동</label>
<div class="toggle on" id="s-haptic"><div class="knob"></div></div></div>
<nav class="tabbar" data-tabs>
<div class="tab" data-go="main"><div class="dot"></div></div>
<div class="tab" data-go="soon" data-soon="리더보드"><div class="dot"></div>리더보드</div>
<div class="tab" data-go="soon" data-soon="프로필"><div class="dot"></div>프로필</div>
<div class="tab act" data-go="settings"><div class="dot"></div>설정</div>
</nav>
</section>
<!-- 준비 중 (멀티/커스텀/리더보드/프로필) -->
<section id="scr-soon" class="screen light">
<div class="hdr disp" id="soon-title">준비 중</div><div class="back" data-go="main">← 뒤로</div>
<div class="soon-msg">이 기능은 서버 단계에서 열립니다.<br>실시간 멀티·리그전·리더보드·프로필은<br>Cloudflare 서버 연동 후 제공됩니다.</div>
<nav class="tabbar" data-tabs>
<div class="tab" data-go="main"><div class="dot"></div></div>
<div class="tab" data-go="soon" data-soon="리더보드"><div class="dot"></div>리더보드</div>
<div class="tab" data-go="soon" data-soon="프로필"><div class="dot"></div>프로필</div>
<div class="tab" data-go="settings"><div class="dot"></div>설정</div>
</nav>
</section>
</div>
<script type="module">
// TETRIG — 코어 엔진 (Phase 1)
// 순수 로직만: DOM/타이머 없음. 렌더링·입력·타이밍은 index.html(클라이언트)에서 처리한다.
//
// 좌표 규약 (명세서 §4.1 준수):
// - 보드 좌표 (x, y): x 오른쪽 증가, y 위쪽 증가. y=0 이 보드 맨 아래.
// - 킥 오프셋 (dx, dy): dy 양수 = 위쪽. 그대로 py += dy 로 적용된다.
// - 회전 상태: 0=스폰, 1=CW, 2=180, 3=CCW.
// ── 튜닝 상수 (조정 대상은 여기 한 곳에 모은다) ──────────────────
const CONFIG = {
BOARD_W: 10,
BOARD_H: 40, // 내부 높이 (하단 20행만 표시)
VISIBLE_H: 20,
NEXT_COUNT: 5, // 넥스트 표시 개수
LOCK_DELAY_MS: 500, // 락 딜레이
MAX_LOCK_RESETS: 15, // 이동/회전 성공 시 리셋, 최대 15회
DEFAULT_GRAVITY_MS: 800, // Phase 1 기본 낙하 간격 (SPRINT 값)
DAS_MS: 133,
ARR_MS: 10,
SDF: 20, // 소프트드롭 배율
};
const PIECE_TYPES = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
// 피스 색 (명세서 §13 v2 확정 — 프린트 톤)
const PIECE_COLORS = {
I: '#3EC1B6', O: '#F5C531', T: '#A76BF2', S: '#7FCC4C',
Z: '#F25C4C', J: '#4C7DF2', L: '#F2913D', G: '#46464F', // G = 가비지
};
// ── 피스 정의 (스폰 상태 셀 + 박스 크기) ──────────────────────────
// 박스-로컬 좌표, y 위쪽 증가. STATES 는 4회전 상태를 자동 생성한다.
const SPAWN = {
I: { box: 4, cells: [[0, 2], [1, 2], [2, 2], [3, 2]] },
O: { box: 2, cells: [[0, 0], [1, 0], [0, 1], [1, 1]] },
T: { box: 3, cells: [[0, 1], [1, 1], [2, 1], [1, 2]] },
S: { box: 3, cells: [[0, 1], [1, 1], [1, 2], [2, 2]] },
Z: { box: 3, cells: [[0, 2], [1, 2], [1, 1], [2, 1]] },
J: { box: 3, cells: [[0, 2], [0, 1], [1, 1], [2, 1]] },
L: { box: 3, cells: [[2, 2], [0, 1], [1, 1], [2, 1]] },
};
// 스폰 시 박스 원점의 보드 위치 (px, py). 셀은 (px+cx, py+cy).
const SPAWN_POS = {
I: [3, 19], O: [4, 21], T: [3, 20], S: [3, 20],
Z: [3, 20], J: [3, 20], L: [3, 20],
};
// CW 회전: 박스 크기 b 에서 (x,y) -> (y, (b-1)-x) (y 위쪽 증가 기준)
function rotateCW(cells, b) {
return cells.map(([x, y]) => [y, (b - 1) - x]);
}
function buildStates(type) {
const { box, cells } = SPAWN[type];
if (type === 'O') return [cells, cells, cells, cells]; // O는 회전 불변
const s0 = cells;
const s1 = rotateCW(s0, box);
const s2 = rotateCW(s1, box);
const s3 = rotateCW(s2, box);
return [s0, s1, s2, s3];
}
const STATES = Object.fromEntries(PIECE_TYPES.map(t => [t, buildStates(t)]));
// ── 킥테이블 (명세서 §4.1) ────────────────────────────────────────
// 키: "from>to". 값: [ [dx,dy], ... ] 순서대로 시도.
const KICKS_JLSTZ = {
'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]],
// 180° (SRS+)
'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]],
};
const KICKS_I = {
'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]],
// I 180°는 (0,0)만
'0>2': [[0, 0]], '2>0': [[0, 0]], '1>3': [[0, 0]], '3>1': [[0, 0]],
};
function kickTable(type, from, to) {
const key = `${from}>${to}`;
if (type === 'O') return [[0, 0]];
if (type === 'I') return KICKS_I[key] || [[0, 0]];
return KICKS_JLSTZ[key] || [[0, 0]];
}
// ── RNG (테스트를 위한 시드 가능 난수, mulberry32) ─────────────────
function makeRng(seed = 1) {
let a = seed >>> 0;
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ── 7-bag ─────────────────────────────────────────────────────────
class SevenBag {
constructor(rng = Math.random) {
this.rng = rng;
this.queue = [];
this._refill();
this._refill();
}
_refill() {
const bag = PIECE_TYPES.slice();
for (let i = bag.length - 1; i > 0; i--) {
const j = Math.floor(this.rng() * (i + 1));
[bag[i], bag[j]] = [bag[j], bag[i]];
}
this.queue.push(...bag);
}
next() {
if (this.queue.length <= CONFIG.NEXT_COUNT + 1) this._refill();
return this.queue.shift();
}
peek(n = CONFIG.NEXT_COUNT) {
while (this.queue.length < n) this._refill();
return this.queue.slice(0, n);
}
}
// ── 보드 유틸 ─────────────────────────────────────────────────────
function createBoard(w = CONFIG.BOARD_W, h = CONFIG.BOARD_H) {
return Array.from({ length: h }, () => new Array(w).fill(null));
}
// 절대 보드 셀 좌표 목록
function pieceCells(type, state, px, py) {
return STATES[type][state].map(([cx, cy]) => [px + cx, py + cy]);
}
// 충돌: x 범위 밖 / 바닥 아래(y<0) / 점유 셀 = 충돌. y >= H (버퍼 위)는 비어있음으로 허용.
function collides(board, cells) {
const W = board[0].length, H = board.length;
for (const [x, y] of cells) {
if (x < 0 || x >= W || y < 0) return true;
if (y < H && board[y][x]) return true;
}
return false;
}
// 라인 클리어: 가득 찬 행 제거 후 위에서 빈 행 채움. { cleared, rows } 반환.
function clearLines(board) {
const W = board[0].length, H = board.length;
const rows = [];
for (let y = 0; y < H; y++) {
if (board[y].every(c => c)) rows.push(y);
}
if (rows.length === 0) return { cleared: 0, rows };
const kept = board.filter((_, y) => !rows.includes(y));
while (kept.length < H) kept.push(new Array(W).fill(null));
for (let y = 0; y < H; y++) board[y] = kept[y];
return { cleared: rows.length, rows };
}
// 보드가 완전히 비었는가 (All Clear 판정)
function isBoardEmpty(board) {
return board.every(row => row.every(c => !c));
}
// ── T-spin 판정 (명세서 §4.2) ─────────────────────────────────────
// lastMoveRotation 이고 T 피스일 때만 호출. 반환: 'none' | 'mini' | 'tspin'
function detectTSpin(board, type, state, px, py, lastMoveRotation) {
if (type !== 'T' || !lastMoveRotation) return 'none';
const W = board[0].length, H = board.length;
const cx = px + 1, cy = py + 1; // T 중심 (박스 (1,1))
const filled = (dx, dy) => {
const x = cx + dx, y = cy + dy;
if (x < 0 || x >= W || y < 0) return true; // 벽/바닥은 채움 취급
if (y >= H) return false; // 버퍼 위는 빈 것
return !!board[y][x];
};
const TL = filled(-1, 1), TR = filled(1, 1), BL = filled(-1, -1), BR = filled(1, -1);
const count = TL + TR + BL + BR;
if (count < 3) return 'none';
// 전면(포인팅 방향) 2모서리
let front;
if (state === 0) front = [TL, TR];
else if (state === 1) front = [TR, BR];
else if (state === 2) front = [BL, BR];
else front = [TL, BL];
return (front[0] && front[1]) ? 'tspin' : 'mini';
}
// 클리어가 B2B 대상("어려운" 클리어)인가: Quad 또는 라인 동반 T-spin 계열
function isDifficult(lines, tspin) {
return (lines === 4) || (tspin !== 'none' && lines >= 1);
}
// ── 점수·레벨 (명세서 §5) ─────────────────────────────────────────
// 레벨: 10라인=1레벨. 낙하간격(초)=0.8^(level-1), 하한 50ms.
function levelForLines(lines) {
return Math.floor(lines / 10) + 1;
}
function gravityMsForLevel(level) {
return Math.max(Math.round(Math.pow(0.8, level - 1) * 1000), 50);
}
// 라인 클리어 1회 점수 (×레벨 적용). res = lock() 결과, level = 현재 레벨.
function scoreClear(res, level = 1) {
const { lines, tspin, allClear, combo, b2b } = res;
let base;
if (tspin === 'tspin') base = [0, 800, 1200, 1600][lines] || 0;
else if (tspin === 'mini') base = lines >= 1 ? 200 : 100; // Mini 100 / Mini Single 200
else base = [0, 100, 300, 500, 800][lines] || 0; // Single~Quad
if (b2b >= 1 && isDifficult(lines, tspin)) base = Math.round(base * 1.5); // B2B ×1.5
let score = base * level;
if (lines > 0 && combo >= 1) score += 50 * combo * level; // 콤보 +50×콤보수×레벨
if (allClear) {
let ac = [0, 800, 1200, 1800, 2000][lines] || 0; // All Clear 보너스
if (lines === 4 && b2b >= 1) ac = 3200; // B2B Quad AC
score += ac * level;
}
return score;
}
// ── 게임 상태 (클라이언트가 타이밍을 구동) ─────────────────────────
class TetrigGame {
constructor({ seed = 1 } = {}) {
this.board = createBoard();
this.bag = new SevenBag(makeRng(seed));
this.hold = null;
this.holdUsed = false;
this.over = false;
this.lastMoveRotation = false;
this.lockResets = 0;
// 통계 (Phase 2 점수 계산용 상태)
this.combo = -1;
this.b2b = -1;
this.lastClear = null;
this.active = null;
this.spawn();
}
cells(a = this.active) {
return pieceCells(a.type, a.state, a.x, a.y);
}
spawn(type = this.bag.next()) {
const [px, py] = SPAWN_POS[type];
this.active = { type, state: 0, x: px, y: py };
this.lastMoveRotation = false;
this.lockResets = 0;
if (collides(this.board, this.cells())) {
this.over = true; // 톱아웃
return false;
}
return true;
}
peekNext(n = CONFIG.NEXT_COUNT) { return this.bag.peek(n); }
canFall() {
const c = this.cells().map(([x, y]) => [x, y - 1]);
return !collides(this.board, c);
}
// 락 딜레이 리셋 (이동/회전 성공 시). 최대 횟수 초과 시 false.
resetLockDelay() {
if (this.lockResets >= CONFIG.MAX_LOCK_RESETS) return false;
this.lockResets++;
return true;
}
move(dx) {
if (this.over) return false;
const a = this.active;
const c = pieceCells(a.type, a.state, a.x + dx, a.y);
if (collides(this.board, c)) return false;
a.x += dx;
this.lastMoveRotation = false;
return true;
}
softDrop() {
if (this.over) return false;
const a = this.active;
const c = pieceCells(a.type, a.state, a.x, a.y - 1);
if (collides(this.board, c)) return false;
a.y -= 1;
this.lastMoveRotation = false;
return true;
}
// dir: 'cw' | 'ccw' | '180'
rotate(dir) {
if (this.over) return false;
const a = this.active;
const to = dir === 'cw' ? (a.state + 1) % 4
: dir === 'ccw' ? (a.state + 3) % 4
: (a.state + 2) % 4;
const kicks = kickTable(a.type, a.state, to);
for (const [dx, dy] of kicks) {
const nx = a.x + dx, ny = a.y + dy;
const c = pieceCells(a.type, to, nx, ny);
if (!collides(this.board, c)) {
a.state = to; a.x = nx; a.y = ny;
this.lastMoveRotation = true;
return true;
}
}
return false;
}
ghostCells() {
const a = this.active;
let dy = 0;
while (!collides(this.board, pieceCells(a.type, a.state, a.x, a.y - dy - 1))) dy++;
return pieceCells(a.type, a.state, a.x, a.y - dy);
}
hardDrop() {
if (this.over) return null;
const a = this.active;
let d = 0;
while (!collides(this.board, pieceCells(a.type, a.state, a.x, a.y - d - 1))) d++;
a.y -= d;
return this.lock();
}
holdSwap() {
if (this.over || this.holdUsed) return false;
const cur = this.active.type;
if (this.hold == null) {
this.hold = cur;
this.spawn();
} else {
const swap = this.hold;
this.hold = cur;
this.spawn(swap);
}
this.holdUsed = true;
return true;
}
// 현재 피스를 보드에 고정하고 클리어/판정/스폰까지 처리. 결과 객체 반환.
lock() {
const a = this.active;
const tspin = detectTSpin(this.board, a.type, a.state, a.x, a.y, this.lastMoveRotation);
for (const [x, y] of this.cells()) {
if (y >= 0 && y < this.board.length) this.board[y][x] = a.type;
}
const { cleared } = clearLines(this.board);
const allClear = cleared > 0 && isBoardEmpty(this.board);
// 콤보
this.combo = cleared > 0 ? this.combo + 1 : -1;
// B2B
const diff = isDifficult(cleared, tspin);
if (cleared > 0) {
this.b2b = diff ? this.b2b + 1 : -1;
}
const result = {
type: a.type, lines: cleared, tspin, allClear,
combo: this.combo, b2b: this.b2b, difficult: diff,
};
this.lastClear = result;
this.holdUsed = false;
this.spawn();
return result;
}
}
// ── 브랜드 상수 (명세서 §2) ──
const BRAND = "TETRIG", BRAND_KO = "테트리그";
document.title = BRAND;
// ── PWA ──
if ('serviceWorker' in navigator) addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(()=>{}));
// ── 화면 스케일 ──
const app = document.getElementById('app');
const fit = () => { app.style.transform = `scale(${Math.min(innerWidth/390, innerHeight/844)})`; };
addEventListener('resize', fit); fit();
// ── 설정 (localStorage) ──
const SET = Object.assign({ das:CONFIG.DAS_MS, arr:CONFIG.ARR_MS, sdf:CONFIG.SDF, ghost:true, haptic:true },
JSON.parse(localStorage.getItem('tetrig_set') || '{}'));
const saveSet = () => localStorage.setItem('tetrig_set', JSON.stringify(SET));
// ── 기록 (localStorage) ──
const REC = JSON.parse(localStorage.getItem('tetrig_rec') || '{}'); // {sprint(ms), time(score), endless(score)}
const saveRec = () => localStorage.setItem('tetrig_rec', JSON.stringify(REC));
const fmtTime = (s) => `${Math.floor(s/60)}:${(s%60).toFixed(2).padStart(5,'0')}`;
function refreshBests(){
document.getElementById('best-sprint').textContent = REC.sprint!=null ? fmtTime(REC.sprint/1000) : '기록 없음';
document.getElementById('best-time').textContent = REC.time!=null ? REC.time.toLocaleString()+'점' : '기록 없음';
document.getElementById('best-endless').textContent = REC.endless!=null ? REC.endless.toLocaleString()+'점' : '기록 없음';
}
// ── 화면 라우팅 ──
const screens = ['main','game','result','settings','soon'];
function show(id){ screens.forEach(s=>document.getElementById('scr-'+s).classList.toggle('on', s===id)); }
function openSheet(){ document.getElementById('dim').classList.add('on'); document.getElementById('sheet').classList.add('on'); }
function closeSheet(){ document.getElementById('dim').classList.remove('on'); document.getElementById('sheet').classList.remove('on'); }
document.getElementById('dim').onclick = closeSheet;
document.querySelectorAll('[data-go]').forEach(el=>{
el.addEventListener('click', ()=>{
const go = el.getAttribute('data-go');
if(go==='modes'){ refreshBests(); openSheet(); return; }
if(go==='soon'){ document.getElementById('soon-title').textContent = el.getAttribute('data-soon')||'준비 중'; show('soon'); return; }
show(go);
});
});
document.querySelectorAll('#sheet .mrow').forEach(el=>{
el.addEventListener('click', ()=>{ closeSheet(); startGame(el.getAttribute('data-mode')); });
});
// ── 렌더 대상 ──
const CELL = 25, VIS = CONFIG.VISIBLE_H, W = CONFIG.BOARD_W;
const board = document.getElementById('board'); board.width = W*CELL; board.height = VIS*CELL;
const bctx = board.getContext('2d');
const nctx = document.getElementById('next').getContext('2d');
const hctx = document.getElementById('hold').getContext('2d');
const popup = document.getElementById('popup');
const popBig = document.getElementById('pop-big'), popSm = document.getElementById('pop-sm');
// ── 모드 정의 (명세서 §5) ──
const MODES = {
sprint: { name:'SPRINT', goalLines:40, scored:false, dir:'up', levels:false, rec:'sprint' },
time: { name:'TIME ATTACK', duration:120, scored:true, dir:'down', levels:true, rec:'time' },
endless: { name:'ENDLESS', scored:true, dir:'up', levels:true, rec:'endless', endlessTopout:true },
};
let MODE, g, startTime, piecesLocked, totalLines, score, level, softHeld, paused, finished;
function gravityNow(){ return MODE.levels ? gravityMsForLevel(level) : CONFIG.DEFAULT_GRAVITY_MS; }
function startGame(key){
MODE = MODES[key];
g = new TetrigGame({ seed:(Math.random()*1e9)|0 });
startTime = performance.now();
piecesLocked=0; totalLines=0; score=0; level=1; softHeld=false; paused=false; finished=false;
dropAcc=0; lockTimer=0; grounded=false; lastY=g.active.y;
document.getElementById('mode').textContent = MODE.name;
document.getElementById('ov-pause').classList.remove('show');
last = performance.now();
show('game');
}
// ── 렌더링 ──
function drawCell(ctx, sx, sy, color, ghost, cell=CELL){
if(ghost){ ctx.fillStyle='rgba(237,231,218,.12)'; ctx.fillRect(sx+1,sy+1,cell-2,cell-2);
ctx.strokeStyle='rgba(237,231,218,.55)'; ctx.lineWidth=2; ctx.setLineDash([4,3]); ctx.strokeRect(sx+2,sy+2,cell-4,cell-4); ctx.setLineDash([]); return; }
ctx.fillStyle=color; ctx.fillRect(sx,sy,cell,cell);
ctx.strokeStyle='#17161B'; ctx.lineWidth=2; ctx.strokeRect(sx+1,sy+1,cell-2,cell-2);
}
const boardXY = (x,y)=>[x*CELL,(VIS-1-y)*CELL];
function drawMini(ctx,type,cx,cy,c,dim){
const cells=STATES[type][0], xs=cells.map(a=>a[0]),ys=cells.map(a=>a[1]);
const w=(Math.max(...xs)-Math.min(...xs)+1), h=(Math.max(...ys)-Math.min(...ys)+1);
const ox=cx-w*c/2-Math.min(...xs)*c, oy=cy-h*c/2+Math.max(...ys)*c;
ctx.globalAlpha=dim?0.35:1;
for(const[px,py]of cells){ const sx=ox+px*c, sy=oy-py*c;
ctx.fillStyle=PIECE_COLORS[type]; ctx.fillRect(sx,sy,c,c);
ctx.strokeStyle='#17161B'; ctx.lineWidth=1.5; ctx.strokeRect(sx+.75,sy+.75,c-1.5,c-1.5); }
ctx.globalAlpha=1;
}
function render(){
bctx.fillStyle='#101015'; bctx.fillRect(0,0,board.width,board.height);
bctx.strokeStyle='#26262E'; bctx.lineWidth=1;
for(let x=0;x<=W;x++){ bctx.beginPath();bctx.moveTo(x*CELL,0);bctx.lineTo(x*CELL,board.height);bctx.stroke(); }
for(let y=0;y<=VIS;y++){ bctx.beginPath();bctx.moveTo(0,y*CELL);bctx.lineTo(board.width,y*CELL);bctx.stroke(); }
for(let y=0;y<VIS;y++)for(let x=0;x<W;x++){ const t=g.board[y][x]; if(t){const[sx,sy]=boardXY(x,y);drawCell(bctx,sx,sy,PIECE_COLORS[t]);} }
if(SET.ghost) for(const[x,y]of g.ghostCells()){ if(y<VIS){const[sx,sy]=boardXY(x,y);drawCell(bctx,sx,sy,null,true);} }
for(const[x,y]of g.cells()){ if(y<VIS&&y>=0){const[sx,sy]=boardXY(x,y);drawCell(bctx,sx,sy,PIECE_COLORS[g.active.type]);} }
nctx.clearRect(0,0,82,286); g.peekNext(5).forEach((t,i)=>drawMini(nctx,t,41,29+i*57,15));
hctx.clearRect(0,0,82,56); if(g.hold) drawMini(hctx,g.hold,41,28,15,g.holdUsed);
const elapsed=(performance.now()-startTime)/1000;
let big;
if(MODE.dir==='down'){ const left=Math.max(MODE.duration-elapsed,0); big=fmtTime(left); }
else big=elapsed.toFixed(1);
document.getElementById('time').textContent = big;
const pps=(piecesLocked/Math.max(elapsed,.001)).toFixed(2);
document.getElementById('sub').innerHTML = MODE.scored
? `SCORE <b>${score.toLocaleString()}</b> · Lv <b>${level}</b> · <b>${pps}</b>pps`
: `LINES <b>${totalLines}/${MODE.goalLines}</b> · PPS <b>${pps}</b>`;
}
// ── 판정 팝업 ──
let popupTimer=0;
function showPopup(res){
let main='';
if(res.tspin==='tspin') main='T-SPIN'; else if(res.tspin==='mini') main='T-SPIN MINI';
if(res.lines===4) main=(main?main+' ':'')+'QUAD';
else if(res.tspin!=='none') main+=' '+(['','SINGLE','DOUBLE','TRIPLE'][res.lines]||'');
else main=['', '', 'DOUBLE','TRIPLE'][res.lines]||'';
if(res.allClear) main='ALL CLEAR!';
main=main.trim(); if(!main) return;
popBig.textContent=main+'!';
const s=[]; if(res.b2b>=1)s.push('B2B ×'+res.b2b); if(res.combo>=1)s.push((res.combo+1)+' COMBO');
popSm.textContent=s.join(' '); popup.style.opacity=1; popupTimer=900;
}
// ── 진행 ──
let dropAcc=0, lockTimer=0, grounded=false, lastY=0;
function tryLockReset(){ if(grounded && g.resetLockDelay()) lockTimer=0; }
function doMove(d){ if(g.move(d)) tryLockReset(); }
function doRotate(k){ if(g.rotate(k)) tryLockReset(); }
function afterLock(res){
piecesLocked++; totalLines+=res.lines;
if(MODE.scored){ score += scoreClear(res, level); }
if(MODE.levels){ level = levelForLines(totalLines); }
showPopup(res);
dropAcc=0; lockTimer=0; grounded=!g.canFall(); lastY=g.active.y;
if(MODE.goalLines && totalLines>=MODE.goalLines) return finish(false);
if(g.over){
if(MODE.endlessTopout){ // 보드만 리셋, 점수/라인 유지
g.board = createBoard(); g.over=false; g.hold=null; g.holdUsed=false; g.spawn();
grounded=!g.canFall(); lastY=g.active.y;
} else finish(true);
}
}
function finish(topout){
finished=true;
const elapsed=(performance.now()-startTime)/1000;
const pps=(piecesLocked/Math.max(elapsed,.001));
document.getElementById('res-title').textContent = topout?'TOP OUT':(MODE.dir==='down'?'TIME UP':'FINISH');
document.getElementById('res-title').style.color = topout?'#FF6B6B':'var(--safety)';
document.getElementById('res-mode').textContent = MODE.name + (topout?' · 종료':' · 완료');
// 주요 지표 + 기록 갱신
let newRec=false, key=MODE.rec;
if(MODE.scored){
document.getElementById('res-mk').textContent='SCORE';
document.getElementById('res-mv').textContent=score.toLocaleString();
if(REC[key]==null || score>REC[key]){ REC[key]=score; newRec=true; }
} else { // SPRINT: 시간(작을수록 좋음), 목표 도달 시에만 기록
document.getElementById('res-mk').textContent='TIME';
document.getElementById('res-mv').textContent=elapsed.toFixed(2)+'s';
if(!topout && (REC[key]==null || elapsed*1000<REC[key])){ REC[key]=elapsed*1000; newRec=true; }
}
saveRec();
document.getElementById('res-best').textContent = newRec ? 'NEW RECORD!' :
(MODE.scored ? (REC[key]!=null?'BEST '+REC[key].toLocaleString():'') : (REC[key]!=null?'BEST '+fmtTime(REC[key]/1000):''));
document.getElementById('res-lines').textContent=totalLines;
document.getElementById('res-pps').textContent=pps.toFixed(2);
document.getElementById('res-level').textContent=level;
show('result');
}
function togglePause(){ if(finished) return; paused=!paused; document.getElementById('ov-pause').classList.toggle('show',paused); if(!paused) last=performance.now(); }
function quitToMain(){
// ENDLESS는 나갈 때 점수 기록
if(MODE && MODE.endlessTopout && MODE.scored && (REC.endless==null || score>REC.endless)){ REC.endless=score; saveRec(); }
paused=false; finished=true; document.getElementById('ov-pause').classList.remove('show'); show('main');
}
// ── 입력 (터치 §11) ──
let btnLeft=false, btnRight=false, dasDir=0, dasTimer=0, arrTimer=0;
function bind(id,onDown,onUp){
const el=document.getElementById(id);
el.addEventListener('pointerdown',e=>{ e.preventDefault(); el.classList.add('act'); onDown&&onDown(); if(SET.haptic&&navigator.vibrate)navigator.vibrate(8); });
const up=()=>{ el.classList.remove('act'); onUp&&onUp(); };
el.addEventListener('pointerup',up); el.addEventListener('pointercancel',up); el.addEventListener('pointerleave',up);
}
const guard = fn => () => { if(!paused&&!finished&&!g.over) fn(); };
bind('b-left', guard(()=>{ btnLeft=true; dasDir=-1; dasTimer=0; doMove(-1); }), ()=>{ btnLeft=false; if(dasDir===-1)dasDir=btnRight?1:0; });
bind('b-right', guard(()=>{ btnRight=true; dasDir=1; dasTimer=0; doMove(1); }), ()=>{ btnRight=false; if(dasDir===1)dasDir=btnLeft?-1:0; });
bind('b-cw', guard(()=>doRotate('cw')));
bind('b-ccw', guard(()=>doRotate('ccw')));
bind('b-180', guard(()=>doRotate('180')));
bind('b-hold', guard(()=>{ g.holdSwap(); lockTimer=0; grounded=!g.canFall(); lastY=g.active.y; }));
bind('b-hard', guard(()=>hardDrop()));
bind('b-soft', guard(()=>{ softHeld=true; }), ()=>{ softHeld=false; });
bind('pause', ()=>togglePause());
document.getElementById('ov-resume').onclick=()=>togglePause();
document.getElementById('ov-restart1').onclick=()=>startGame(Object.keys(MODES).find(k=>MODES[k]===MODE));
document.getElementById('ov-quit').onclick=quitToMain;
document.getElementById('res-retry').onclick=()=>startGame(Object.keys(MODES).find(k=>MODES[k]===MODE));
document.getElementById('res-menu').onclick=()=>show('main');
// 하드드롭 (점수: +2/칸)
function hardDrop(){
const from=g.active.y;
const landing=Math.min(...g.ghostCells().map(c=>c[1]));
const dist=from-landing;
const res=g.hardDrop(); if(!res) return;
if(MODE.scored) score += 2*Math.max(dist,0);
afterLock(res);
}
// ── 키보드 폴백 ──
addEventListener('keydown',e=>{
const k=e.key.toLowerCase();
if(document.getElementById('scr-game').classList.contains('on')){
if(['arrowleft','arrowright','arrowup','arrowdown',' '].includes(k)) e.preventDefault();
}
if(finished){ if(k==='r' && MODE) startGame(Object.keys(MODES).find(x=>MODES[x]===MODE)); return; }
if(!g || !document.getElementById('scr-game').classList.contains('on')) return;
if(k==='escape'||k==='p'){ togglePause(); return; }
if(paused||e.repeat) return;
switch(k){
case 'arrowleft': btnLeft=true; dasDir=-1; dasTimer=0; doMove(-1); break;
case 'arrowright': btnRight=true; dasDir=1; dasTimer=0; doMove(1); break;
case 'arrowdown': softHeld=true; break;
case 'arrowup': case 'x': doRotate('cw'); break;
case 'z': case 'control': doRotate('ccw'); break;
case 'a': doRotate('180'); break;
case 'c': case 'shift': g.holdSwap(); lockTimer=0; grounded=!g.canFall(); lastY=g.active.y; break;
case ' ': hardDrop(); break;
}
});
addEventListener('keyup',e=>{
const k=e.key.toLowerCase();
if(k==='arrowleft'){ btnLeft=false; if(dasDir===-1)dasDir=btnRight?1:0; }
if(k==='arrowright'){ btnRight=false; if(dasDir===1)dasDir=btnLeft?-1:0; }
if(k==='arrowdown') softHeld=false;
});
// ── 설정 UI ──
function initSettings(){
const bindSlider=(id,key,vid,unit='')=>{ const el=document.getElementById(id); el.value=SET[key];
document.getElementById(vid).textContent=SET[key];
el.addEventListener('input',()=>{ SET[key]=+el.value; document.getElementById(vid).textContent=el.value; saveSet(); }); };
bindSlider('s-das','das','v-das'); bindSlider('s-arr','arr','v-arr'); bindSlider('s-sdf','sdf','v-sdf');
const bindToggle=(id,key)=>{ const el=document.getElementById(id); el.classList.toggle('on',SET[key]);
el.addEventListener('click',()=>{ SET[key]=!SET[key]; el.classList.toggle('on',SET[key]); saveSet(); }); };
bindToggle('s-ghost','ghost'); bindToggle('s-haptic','haptic');
}
initSettings(); refreshBests();
// ── 메인 루프 ──
let last=performance.now();
function loop(now){
const dt=now-last; last=now;
const inGame=document.getElementById('scr-game').classList.contains('on');
if(inGame && !paused && !finished && !g.over){
if(dasDir!==0){ dasTimer+=dt;
if(dasTimer>=SET.das){ if(SET.arr<=0){ while(g.move(dasDir)){} tryLockReset(); }
else { arrTimer+=dt; const step=Math.floor(arrTimer/SET.arr); if(step>0){ arrTimer-=step*SET.arr; for(let i=0;i<step;i++) doMove(dasDir); } } }
} else { dasTimer=0; arrTimer=0; }
const eff = softHeld ? Math.max(gravityNow()/SET.sdf,8) : gravityNow();
dropAcc+=dt;
while(dropAcc>=eff){ dropAcc-=eff; if(g.canFall()){ g.softDrop(); if(softHeld&&MODE.scored) score+=1; } }
if(g.active.y<lastY){ lastY=g.active.y; g.lockResets=0; lockTimer=0; }
grounded=!g.canFall();
if(grounded){ lockTimer+=dt; if(lockTimer>=CONFIG.LOCK_DELAY_MS) afterLock(g.lock()); } else lockTimer=0;
if(MODE.dir==='down'){ const left=MODE.duration-(now-startTime)/1000; if(left<=0) finish(false); }
if(popupTimer>0){ popupTimer-=dt; if(popupTimer<=0) popup.style.opacity=0; }
}
if(inGame) render();
requestAnimationFrame(loop);
}
show('main');
requestAnimationFrame(loop);
</script>
</body>
</html>