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 미니 피스 수직 센터링 수정
This commit is contained in:
428
index.html
428
index.html
@@ -2,60 +2,115 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<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>
|
||||
: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; }
|
||||
/* 명세서 §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="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 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>
|
||||
<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>
|
||||
|
||||
<!-- 보드 -->
|
||||
<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 id="hud">
|
||||
<div class="panel"><div class="lbl">NEXT</div><canvas id="next" width="108" height="360"></canvas></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">
|
||||
@@ -420,127 +475,167 @@ class TetrigGame {
|
||||
const BRAND = "TETRIG", BRAND_KO = "테트리그";
|
||||
document.title = BRAND;
|
||||
|
||||
const CELL = 26;
|
||||
const VIS = CONFIG.VISIBLE_H, W = CONFIG.BOARD_W;
|
||||
// ── 화면 스케일 (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;
|
||||
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 over = document.getElementById('over');
|
||||
const popBig = document.getElementById('pop-big'), popSm = document.getElementById('pop-sm');
|
||||
|
||||
let g, startTime, piecesLocked, gravityMs, softHeld;
|
||||
// ── 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 });
|
||||
g = new TetrigGame({ seed:(Math.random()*1e9)|0 });
|
||||
startTime = performance.now();
|
||||
piecesLocked = 0;
|
||||
gravityMs = CONFIG.DEFAULT_GRAVITY_MS;
|
||||
softHeld = false;
|
||||
piecesLocked = 0; totalLines = 0; softHeld = false; paused = false; finished = false;
|
||||
dropAcc = 0; lockTimer = 0; grounded = false; lastY = g.active.y;
|
||||
over.style.display = 'none';
|
||||
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){
|
||||
function drawCell(ctx, sx, sy, color, ghost, cell=CELL){
|
||||
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);
|
||||
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);
|
||||
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]; }
|
||||
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]); } }
|
||||
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);
|
||||
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('s-time').textContent = elapsed.toFixed(1);
|
||||
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, 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);
|
||||
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;
|
||||
ctx.globalAlpha=1;
|
||||
}
|
||||
|
||||
// ── 판정 팝업 ──
|
||||
let popupTimer = 0;
|
||||
// ── 판정 팝업 (명세서 §4.2) ──
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 입력 (DAS/ARR) ──
|
||||
let dropAcc=0, lockTimer=0, grounded=false, lastY=0, totalLines=0;
|
||||
const keys = {}; let dasTimer=0, arrTimer=0, dasDir=0;
|
||||
// ── 게임 진행 ──
|
||||
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 tryLockReset(){ if (grounded && g.resetLockDelay()) lockTimer=0; }
|
||||
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(); } // 시간 점프 방지
|
||||
}
|
||||
|
||||
function doMove(dir){ if (g.move(dir)) { tryLockReset(); } }
|
||||
function doRotate(kind){ if (g.rotate(kind)) { tryLockReset(); } }
|
||||
// ── 터치/포인터 입력 (§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(); }; }
|
||||
|
||||
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; }
|
||||
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': keys.left=true; dasDir=-1; dasTimer=0; doMove(-1); break;
|
||||
case 'arrowright': keys.right=true; dasDir=1; dasTimer=0; doMove(1); break;
|
||||
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;
|
||||
@@ -550,61 +645,40 @@ addEventListener('keydown', e=>{
|
||||
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;
|
||||
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;
|
||||
});
|
||||
|
||||
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();
|
||||
let last=performance.now();
|
||||
function loop(now){
|
||||
const dt = now - last; last = now;
|
||||
if (!g.over){
|
||||
const dt=now-last; last=now;
|
||||
if(!paused && !finished && !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); }
|
||||
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(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();
|
||||
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);
|
||||
}
|
||||
|
||||
document.getElementById('restart').onclick = reset;
|
||||
reset();
|
||||
requestAnimationFrame(loop);
|
||||
</script>
|
||||
|
||||
@@ -2,60 +2,115 @@
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<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>
|
||||
: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; }
|
||||
/* 명세서 §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="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 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>
|
||||
<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>
|
||||
|
||||
<!-- 보드 -->
|
||||
<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 id="hud">
|
||||
<div class="panel"><div class="lbl">NEXT</div><canvas id="next" width="108" height="360"></canvas></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">
|
||||
@@ -65,127 +120,167 @@
|
||||
const BRAND = "TETRIG", BRAND_KO = "테트리그";
|
||||
document.title = BRAND;
|
||||
|
||||
const CELL = 26;
|
||||
const VIS = CONFIG.VISIBLE_H, W = CONFIG.BOARD_W;
|
||||
// ── 화면 스케일 (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;
|
||||
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 over = document.getElementById('over');
|
||||
const popBig = document.getElementById('pop-big'), popSm = document.getElementById('pop-sm');
|
||||
|
||||
let g, startTime, piecesLocked, gravityMs, softHeld;
|
||||
// ── 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 });
|
||||
g = new TetrigGame({ seed:(Math.random()*1e9)|0 });
|
||||
startTime = performance.now();
|
||||
piecesLocked = 0;
|
||||
gravityMs = CONFIG.DEFAULT_GRAVITY_MS;
|
||||
softHeld = false;
|
||||
piecesLocked = 0; totalLines = 0; softHeld = false; paused = false; finished = false;
|
||||
dropAcc = 0; lockTimer = 0; grounded = false; lastY = g.active.y;
|
||||
over.style.display = 'none';
|
||||
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){
|
||||
function drawCell(ctx, sx, sy, color, ghost, cell=CELL){
|
||||
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);
|
||||
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);
|
||||
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]; }
|
||||
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]); } }
|
||||
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);
|
||||
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('s-time').textContent = elapsed.toFixed(1);
|
||||
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, 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);
|
||||
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;
|
||||
ctx.globalAlpha=1;
|
||||
}
|
||||
|
||||
// ── 판정 팝업 ──
|
||||
let popupTimer = 0;
|
||||
// ── 판정 팝업 (명세서 §4.2) ──
|
||||
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;
|
||||
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;
|
||||
}
|
||||
|
||||
// ── 입력 (DAS/ARR) ──
|
||||
let dropAcc=0, lockTimer=0, grounded=false, lastY=0, totalLines=0;
|
||||
const keys = {}; let dasTimer=0, arrTimer=0, dasDir=0;
|
||||
// ── 게임 진행 ──
|
||||
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 tryLockReset(){ if (grounded && g.resetLockDelay()) lockTimer=0; }
|
||||
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(); } // 시간 점프 방지
|
||||
}
|
||||
|
||||
function doMove(dir){ if (g.move(dir)) { tryLockReset(); } }
|
||||
function doRotate(kind){ if (g.rotate(kind)) { tryLockReset(); } }
|
||||
// ── 터치/포인터 입력 (§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(); }; }
|
||||
|
||||
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; }
|
||||
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': keys.left=true; dasDir=-1; dasTimer=0; doMove(-1); break;
|
||||
case 'arrowright': keys.right=true; dasDir=1; dasTimer=0; doMove(1); break;
|
||||
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;
|
||||
@@ -195,61 +290,40 @@ addEventListener('keydown', e=>{
|
||||
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;
|
||||
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;
|
||||
});
|
||||
|
||||
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();
|
||||
let last=performance.now();
|
||||
function loop(now){
|
||||
const dt = now - last; last = now;
|
||||
if (!g.over){
|
||||
const dt=now-last; last=now;
|
||||
if(!paused && !finished && !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); }
|
||||
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(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();
|
||||
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);
|
||||
}
|
||||
|
||||
document.getElementById('restart').onclick = reset;
|
||||
reset();
|
||||
requestAnimationFrame(loop);
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user