- src/engine.js: 순수 코어 로직 (보드 10x40, 피스 7종, SRS+180 킥테이블, T-spin/mini 판정, 콤보/B2B, 7-bag, 홀드, 고스트, 락딜레이 15회 캡) - test/engine.test.js: 24개 단위 테스트 (킥테이블·T-spin 판정·7-bag·클리어 등) 전부 통과 - src/index.template.html + build.js: 엔진을 인라인한 단일 index.html 생성 (외부 에셋 0개). 키보드 플레이 가능 (DAS/ARR, 하드/소프트드롭, 판정 팝업)
613 lines
22 KiB
HTML
613 lines
22 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
|
<title>TETRIG</title>
|
|
<style>
|
|
: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; }
|
|
html,body{ height:100%; }
|
|
body{ background:var(--ink); color:var(--bone); font-family:'Nanum Gothic Coding',ui-monospace,monospace;
|
|
display:flex; align-items:center; justify-content:center; min-height:100vh; touch-action:none;
|
|
-webkit-user-select:none; user-select:none; }
|
|
#wrap{ display:flex; gap:14px; align-items:flex-start; padding:12px; }
|
|
.panel{ background:var(--dpnl); border:2px solid var(--bone); }
|
|
#hud{ display:flex; flex-direction:column; gap:10px; width:120px; }
|
|
#board-wrap{ position:relative; }
|
|
canvas{ display:block; image-rendering:pixelaser; }
|
|
.lbl{ font-size:11px; color:var(--dsub); letter-spacing:2px; padding:4px 6px; }
|
|
.box{ padding:6px; }
|
|
.stat{ font-size:13px; padding:4px 8px; display:flex; justify-content:space-between; }
|
|
.stat b{ color:var(--safety); font-variant-numeric:tabular-nums; }
|
|
#popup{ position:absolute; left:0; right:0; top:24%; text-align:center; pointer-events:none;
|
|
font-weight:900; font-style:italic; color:var(--bone); text-shadow:2px 2px 0 var(--ink);
|
|
opacity:0; transition:opacity .12s; font-size:22px; }
|
|
#popup .u{ display:inline-block; border-bottom:5px solid var(--safety); padding-bottom:2px; }
|
|
#over{ position:absolute; inset:0; background:rgba(23,22,27,.86); display:none;
|
|
flex-direction:column; align-items:center; justify-content:center; gap:10px; }
|
|
#over b{ font-size:34px; font-style:italic; color:var(--safety); }
|
|
#over button{ font:inherit; background:var(--safety); color:var(--ink); border:2px solid var(--bone);
|
|
padding:8px 18px; cursor:pointer; }
|
|
#help{ margin-top:8px; font-size:10px; color:var(--dsub); line-height:1.7; }
|
|
#title{ font-weight:900; font-style:italic; letter-spacing:1px; font-size:20px; }
|
|
#title small{ color:var(--dsub); font-weight:400; font-style:normal; font-size:10px; letter-spacing:2px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="wrap">
|
|
<div id="hud">
|
|
<div id="title">TETRIG<br><small>PHASE 1 · CORE</small></div>
|
|
<div class="panel"><div class="lbl">HOLD</div><canvas id="hold" width="108" height="72"></canvas></div>
|
|
<div class="panel">
|
|
<div class="stat"><span>TIME</span><b id="s-time">0.0</b></div>
|
|
<div class="stat"><span>LINES</span><b id="s-lines">0</b></div>
|
|
<div class="stat"><span>PPS</span><b id="s-pps">0.00</b></div>
|
|
</div>
|
|
<div id="help">
|
|
← → 이동<br>↓ 소프트드롭<br>Space 하드드롭<br>↑ / X 우회전<br>Z / Ctrl 좌회전<br>A 180°<br>C / Shift 홀드<br>R 재시작
|
|
</div>
|
|
</div>
|
|
<div id="board-wrap" class="panel">
|
|
<canvas id="board"></canvas>
|
|
<div id="popup"></div>
|
|
<div id="over"><b>TOP OUT</b><button id="restart">다시하기 (R)</button></div>
|
|
</div>
|
|
<div id="hud">
|
|
<div class="panel"><div class="lbl">NEXT</div><canvas id="next" width="108" height="360"></canvas></div>
|
|
</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;
|
|
|
|
const CELL = 26;
|
|
const 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 over = document.getElementById('over');
|
|
|
|
let g, startTime, piecesLocked, gravityMs, softHeld;
|
|
|
|
function reset(){
|
|
g = new TetrigGame({ seed: (Math.random()*1e9)|0 });
|
|
startTime = performance.now();
|
|
piecesLocked = 0;
|
|
gravityMs = CONFIG.DEFAULT_GRAVITY_MS;
|
|
softHeld = false;
|
|
dropAcc = 0; lockTimer = 0; grounded = false; lastY = g.active.y;
|
|
over.style.display = 'none';
|
|
}
|
|
|
|
// ── 렌더링 ──
|
|
function drawCell(ctx, sx, sy, color, ghost){
|
|
if (ghost){
|
|
ctx.fillStyle = 'rgba(237,231,218,.14)';
|
|
ctx.fillRect(sx+1, sy+1, CELL-2, CELL-2);
|
|
ctx.strokeStyle = 'rgba(237,231,218,.5)';
|
|
ctx.lineWidth = 2; ctx.strokeRect(sx+1.5, sy+1.5, CELL-3, CELL-3);
|
|
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);
|
|
}
|
|
function boardXY(x, y){ return [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]); } }
|
|
|
|
// NEXT
|
|
nctx.clearRect(0,0,108,360);
|
|
g.peekNext(5).forEach((t,i)=> drawMini(nctx, t, 6, 6 + i*72));
|
|
// HOLD
|
|
hctx.clearRect(0,0,108,72);
|
|
if (g.hold) drawMini(hctx, g.hold, 6, 6, g.holdUsed);
|
|
|
|
// 스탯
|
|
const elapsed = (performance.now()-startTime)/1000;
|
|
document.getElementById('s-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, ox, oy, dim){
|
|
const c = 16;
|
|
const cells = STATES[type][0];
|
|
const xs = cells.map(a=>a[0]), ys = cells.map(a=>a[1]);
|
|
const minx = Math.min(...xs), maxy = Math.max(...ys);
|
|
ctx.globalAlpha = dim ? 0.35 : 1;
|
|
for (const [cx,cy] of cells){
|
|
const sx = ox + (cx-minx)*c, sy = oy + (maxy-cy)*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 popupTimer = 0;
|
|
function showPopup(res){
|
|
const parts = [];
|
|
if (res.tspin === 'tspin') parts.push('T-SPIN');
|
|
else if (res.tspin === 'mini') parts.push('T-SPIN MINI');
|
|
if (res.lines === 4) parts.push('QUAD');
|
|
else if (res.tspin !== 'none'){
|
|
parts.push(['','SINGLE','DOUBLE','TRIPLE'][res.lines] || '');
|
|
} else {
|
|
parts.push(['', '', 'DOUBLE','TRIPLE'][res.lines] || '');
|
|
}
|
|
let txt = parts.filter(Boolean).join(' ');
|
|
if (res.b2b >= 1) txt = 'B2B ' + txt;
|
|
if (res.combo >= 1) txt += (txt?' ':'') + (res.combo+1) + ' COMBO';
|
|
if (res.allClear) txt = 'ALL CLEAR!';
|
|
if (!txt) return;
|
|
popup.innerHTML = '<span class="u">'+txt+'</span>';
|
|
popup.style.opacity = 1; popupTimer = 900;
|
|
}
|
|
|
|
// ── 입력 (DAS/ARR) ──
|
|
let dropAcc=0, lockTimer=0, grounded=false, lastY=0, totalLines=0;
|
|
const keys = {}; let dasTimer=0, arrTimer=0, dasDir=0;
|
|
|
|
function tryLockReset(){ if (grounded && g.resetLockDelay()) lockTimer=0; }
|
|
|
|
function doMove(dir){ if (g.move(dir)) { tryLockReset(); } }
|
|
function doRotate(kind){ if (g.rotate(kind)) { tryLockReset(); } }
|
|
|
|
addEventListener('keydown', e=>{
|
|
if (e.repeat) return;
|
|
const k = e.key.toLowerCase();
|
|
if (['arrowleft','arrowright','arrowdown','arrowup',' '].includes(e.key.toLowerCase()) || e.key===' ') e.preventDefault();
|
|
if (g.over){ if (k==='r') reset(); return; }
|
|
switch(k){
|
|
case 'arrowleft': keys.left=true; dasDir=-1; dasTimer=0; doMove(-1); break;
|
|
case 'arrowright': keys.right=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'){ keys.left=false; if(dasDir===-1){ dasDir=keys.right?1:0; } }
|
|
if (k==='arrowright'){ keys.right=false; if(dasDir===1){ dasDir=keys.left?-1:0; } }
|
|
if (k==='arrowdown') softHeld=false;
|
|
});
|
|
|
|
function lockNow(res){
|
|
if (!res) return;
|
|
piecesLocked++;
|
|
totalLines += res.lines;
|
|
showPopup(res);
|
|
dropAcc=0; lockTimer=0; grounded=!g.canFall(); lastY=g.active.y;
|
|
if (g.over) over.style.display='flex';
|
|
}
|
|
|
|
// ── 메인 루프 ──
|
|
let last = performance.now();
|
|
function loop(now){
|
|
const dt = now - last; last = now;
|
|
if (!g.over){
|
|
// DAS/ARR
|
|
if (dasDir!==0){
|
|
dasTimer += dt;
|
|
if (dasTimer >= CONFIG.DAS_MS){
|
|
arrTimer += dt;
|
|
const step = CONFIG.ARR_MS<=0 ? 999 : Math.floor(arrTimer/CONFIG.ARR_MS);
|
|
if (CONFIG.ARR_MS<=0){ while(g.move(dasDir)){} tryLockReset(); }
|
|
else 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(gravityMs/CONFIG.SDF, 8) : 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);
|
|
}
|
|
|
|
document.getElementById('restart').onclick = reset;
|
|
reset();
|
|
requestAnimationFrame(loop);
|
|
</script>
|
|
</body>
|
|
</html>
|