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).
This commit is contained in:
tkrmagid
2026-08-22 15:57:36 +09:00
parent a8ff780248
commit c9463fe46b
4 changed files with 893 additions and 338 deletions

View File

@@ -15,108 +15,273 @@
<style>
/* 명세서 §13 팔레트 */
:root{
--ink:#17161B; --bone:#EDE7DA; --dwell:#101015; --dpnl:#1F1F26; --dgrid:#26262E;
--safety:#FF4E11; --cobalt:#2B3FE0; --dsub:#8D8A80;
--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:var(--ink); }
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(--bone);
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; }
/* 390x844 고정 캔버스를 화면에 맞춰 스케일 */
#app{ position:relative; width:390px; height:844px; background:var(--ink);
transform-origin:center center; }
.disp{ font-weight:900; font-style:italic; } /* 디스플레이체 대용 (skew) */
#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:14px; text-align:center; }
#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; }
/* ── 우측 HOLD / NEXT ── */
#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%); }
/* ── 하단 가상 버튼 (§11 확정 좌표) ── */
.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; }
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{ 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 .line{ font-size:16px; color:var(--bone); letter-spacing:1px; }
.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;
.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">
<!-- 상단 -->
<div id="pause"><span></span><span></span></div>
<div id="top">
<div id="mode" class="disp">SPRINT</div>
<div id="time" class="disp">0.0</div>
<div id="sub">LINES <b id="s-lines">0</b>/40 · PPS <b id="s-pps">0.00</b></div>
<!-- ① 메인 -->
<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>
<!-- 보드 -->
<canvas id="board"></canvas>
<div id="popup"><div class="big disp" id="pop-big"></div><div class="sm disp" id="pop-sm"></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>
<!-- 우측 HOLD/NEXT -->
<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>
<!-- 솔로 결과 -->
<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>
<!-- 하단 가상 버튼 (§11) -->
<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>
<!-- 설정 -->
<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 class="overlay" id="ov-pause">
<div class="title disp">PAUSE</div>
<button id="ov-resume">계속하기</button>
<button id="ov-restart1" class="ghost">다시하기</button>
</div>
<div class="overlay" id="ov-result">
<div class="title disp" id="res-title">FINISH</div>
<div class="line" id="res-line"></div>
<button id="ov-restart2">다시하기</button>
</div>
</div>
<script type="module">
// TETRIG — 코어 엔진 (Phase 1)
@@ -327,6 +492,33 @@ 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 } = {}) {
@@ -476,173 +668,236 @@ class TetrigGame {
}
// ── 브랜드 상수 (명세서 §2 — 코드 1곳에서 관리) ──
// ── 브랜드 상수 (명세서 §2) ──
const BRAND = "TETRIG", BRAND_KO = "테트리그";
document.title = BRAND;
// ── 화면 스케일 (390x844 → 기기 화면 맞춤) ──
// ── PWA ──
if ('serviceWorker' in navigator) addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(()=>{}));
// ── 화면 스케일 ──
const app = document.getElementById('app');
function fit(){
const s = Math.min(innerWidth/390, innerHeight/844);
app.style.transform = `scale(${s})`;
}
const fit = () => { app.style.transform = `scale(${Math.min(innerWidth/390, innerHeight/844)})`; };
addEventListener('resize', fit); fit();
// ── PWA: 서비스 워커 등록 (홈화면 설치 + 오프라인) ──
if ('serviceWorker' in navigator) {
addEventListener('load', () => navigator.serviceWorker.register('sw.js').catch(()=>{}));
// ── 설정 (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; // 250 x 500
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');
// ── SPRINT 모드 상태 (Phase 2 시작) ──
const MODE = { name:'SPRINT', goalLines:40, gravityMs:CONFIG.DEFAULT_GRAVITY_MS };
let g, startTime, piecesLocked, totalLines, softHeld, paused, finished;
// ── 모드 정의 (명세서 §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 reset(){
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; softHeld = false; paused = false; finished = false;
dropAcc = 0; lockTimer = 0; grounded = false; lastY = g.active.y;
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');
document.getElementById('ov-result').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;
}
// 플랫 (§13): 단색 채움 + INK 하드 보더, 그라디언트/베벨 없음
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]);} }
for(const[x,y]of g.ghostCells()){ if(y<VIS){const[sx,sy]=boardXY(x,y);drawCell(bctx,sx,sy,null,true);} }
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);
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;
document.getElementById('time').textContent = elapsed.toFixed(1);
document.getElementById('s-lines').textContent = totalLines;
document.getElementById('s-pps').textContent = (piecesLocked/Math.max(elapsed,.001)).toFixed(2);
}
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;
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>`;
}
// ── 판정 팝업 (명세서 §4.2) ──
// ── 판정 팝업 ──
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.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 sub=[];
if(res.b2b>=1) sub.push('B2B ×'+res.b2b);
if(res.combo>=1) sub.push((res.combo+1)+' COMBO');
popSm.textContent=sub.join(' ');
popup.style.opacity=1; popupTimer=900;
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 lockNow(res){
if(!res) return;
piecesLocked++; totalLines+=res.lines; showPopup(res);
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) finish(true);
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;
document.getElementById('res-title').textContent = topout? 'TOP OUT':'FINISH';
document.getElementById('res-line').innerHTML =
topout? `${totalLines} LINES · ${elapsed.toFixed(1)}s`
: `${MODE.name} ${MODE.goalLines}줄 · <b>${elapsed.toFixed(2)}s</b> · PPS ${(piecesLocked/elapsed).toFixed(2)}`;
document.getElementById('ov-result').classList.add('show');
}
function togglePause(){
if(finished) return;
paused=!paused;
document.getElementById('ov-pause').classList.toggle('show',paused);
if(!paused){ last=performance.now(); } // 시간 점프 방지
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');
}
// ── 터치/포인터 입력 (§11 가상 버튼, 멀티터치) ──
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(navigator.vibrate) navigator.vibrate(8); });
const up=e=>{ el.classList.remove('act'); onUp&&onUp(); };
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);
}
function guard(fn){ return ()=>{ if(!paused&&!finished&&!g.over) fn(); }; }
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(()=>lockNow(g.hardDrop())));
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=reset;
document.getElementById('ov-restart2').onclick=reset;
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(['arrowleft','arrowright','arrowup','arrowdown',' '].includes(k)) e.preventDefault();
if(finished){ if(k==='r')reset(); return; }
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) return;
if(e.repeat) 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;
@@ -651,8 +906,7 @@ addEventListener('keydown',e=>{
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 ' ': lockNow(g.hardDrop()); break;
case 'r': reset(); break;
case ' ': hardDrop(); break;
}
});
addEventListener('keyup',e=>{
@@ -662,34 +916,41 @@ addEventListener('keyup',e=>{
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;
if(!paused && !finished && !g.over){
// DAS/ARR
if(dasDir!==0){
dasTimer+=dt;
if(dasTimer>=CONFIG.DAS_MS){
if(CONFIG.ARR_MS<=0){ while(g.move(dasDir)){} tryLockReset(); }
else { arrTimer+=dt; const step=Math.floor(arrTimer/CONFIG.ARR_MS); if(step>0){ arrTimer-=step*CONFIG.ARR_MS; for(let i=0;i<step;i++) doMove(dasDir); } }
}
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(MODE.gravityMs/CONFIG.SDF,8) : MODE.gravityMs;
const eff = softHeld ? Math.max(gravityNow()/SET.sdf,8) : gravityNow();
dropAcc+=dt;
while(dropAcc>=eff){ dropAcc-=eff; if(g.canFall()) g.softDrop(); }
// 착지 + 락 딜레이
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) lockNow(g.lock()); }
else lockTimer=0;
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; }
}
render();
if(inGame) render();
requestAnimationFrame(loop);
}
reset();
show('main');
requestAnimationFrame(loop);
</script>
</body>