Files
tetrig/index.html
tkrmagid f026fc3d07 Rebuild in-game client as mobile (UX mockup 화면 ② + §11 터치 버튼)
- 390x844 세로 고정 레이아웃, 기기 화면에 맞춰 스케일
- 목업 ② 대로: 상단 pause/SPRINT/타임/LINES·PPS, 다크 웰 보드+고스트+판정팝업,
  우측 HOLD/NEXT×5, 하단 §11 확정 좌표 가상 버튼(하드드롭 SAFETY 오렌지, 챔퍼컷)
- 포인터/터치 멀티터치 입력 + DAS/ARR, 햅틱; 키보드는 데스크톱 폴백으로 유지
- Phase 2 시작: SPRINT 모드(40줄) + 일시정지/결과 오버레이 연결
- NEXT/HOLD 미니 피스 수직 센터링 수정
2026-08-22 11:21:18 +09:00

687 lines
28 KiB
HTML
Raw 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="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#17161B">
<title>TETRIG</title>
<style>
/* 명세서 §13 팔레트 */
:root{
--ink:#17161B; --bone:#EDE7DA; --dwell:#101015; --dpnl:#1F1F26; --dgrid:#26262E;
--safety:#FF4E11; --cobalt:#2B3FE0; --dsub:#8D8A80;
}
*{ box-sizing:border-box; margin:0; padding:0; -webkit-tap-highlight-color:transparent; }
html,body{ height:100%; width:100%; overflow:hidden; background:var(--ink); }
body{ display:flex; align-items:center; justify-content:center;
font-family:ui-monospace,'Nanum Gothic Coding','Menlo',monospace; color:var(--bone);
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) */
/* ── 상단 바 ── */
#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; }
#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 ── */
.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; }
.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 .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;
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); }
</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>
</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>
<!-- 우측 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>
<!-- 하단 가상 버튼 (§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>
<!-- 오버레이 -->
<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)
// 순수 로직만: 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);
}
// ── 게임 상태 (클라이언트가 타이밍을 구동) ─────────────────────────
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 — 코드 1곳에서 관리) ──
const BRAND = "TETRIG", BRAND_KO = "테트리그";
document.title = BRAND;
// ── 화면 스케일 (390x844 → 기기 화면 맞춤) ──
const app = document.getElementById('app');
function fit(){
const s = Math.min(innerWidth/390, innerHeight/844);
app.style.transform = `scale(${s})`;
}
addEventListener('resize', fit); fit();
// ── 렌더 대상 ──
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 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;
function reset(){
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;
document.getElementById('mode').textContent = MODE.name;
document.getElementById('ov-pause').classList.remove('show');
document.getElementById('ov-result').classList.remove('show');
}
// ── 렌더링 ──
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.fillStyle='rgba(23,22,27,.35)'; ctx.fillRect(sx,sy+cell-4,cell,4);
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 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);} }
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;
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;
}
// ── 판정 팝업 (명세서 §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.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;
}
// ── 게임 진행 ──
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);
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);
}
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(); } // 시간 점프 방지
}
// ── 터치/포인터 입력 (§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('pointerup',up); el.addEventListener('pointercancel',up); el.addEventListener('pointerleave',up);
}
function guard(fn){ return ()=>{ 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-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;
// ── 키보드 폴백 (데스크톱 개발용) ──
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(k==='escape'||k==='p'){ togglePause(); return; }
if(paused) return;
if(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 ' ': lockNow(g.hardDrop()); break;
case 'r': reset(); 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;
});
// ── 메인 루프 ──
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); } }
}
} else { dasTimer=0; arrTimer=0; }
// 중력
const eff = softHeld ? Math.max(MODE.gravityMs/CONFIG.SDF,8) : MODE.gravityMs;
dropAcc+=dt;
while(dropAcc>=eff){ dropAcc-=eff; if(g.canFall()) g.softDrop(); }
// 착지 + 락 딜레이
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(popupTimer>0){ popupTimer-=dt; if(popupTimer<=0) popup.style.opacity=0; }
}
render();
requestAnimationFrame(loop);
}
reset();
requestAnimationFrame(loop);
</script>
</body>
</html>