01The board grid — drawing 10×20 cells from an array
One 2D array holds the entire board state. Each cell is a fillRect and the whole game lives on a single canvas — everything else stacks on top of this.
Best forGame boards, grid UIs
fillRectrequestAnimationFrame

<main class="stage">
<div class="tg" id="tg">
<p class="tg__hint">10 x 20 cells, per-frame collision checks</p>
<div class="tg__row">
<div class="tg__field" id="field" role="application" aria-label="Board grid lab" tabindex="0">
<canvas class="tg__cv" id="cv"></canvas>
</div>
<div class="tg__side">
<b class="tg__count" id="count" aria-live="polite">0</b>
<span class="tg__unit">cells drawn</span>
<p class="tg__scan">array <b id="scan">0</b><span class="tg__scanUnit">th cell</span></p>
<div class="tg__prof" id="prof" role="img" aria-label="Column height graph"><canvas id="pcv"></canvas></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 보드 격자(01) — 움직임은 전부 캔버스 rAF 루프 안에 있다(077 방식). @keyframes 를 두지
// 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍는다.
// 칸 일곱 빛깔은 파스텔 팔레트(채도 .34) — 면은 파스텔, 테두리는 같은 색조의 어두운 판(글자 대비와 같은 원리).
$duration: $dur-loop;
$easing: $ease-out;
$color: $subject-sky;
@include stage($stage-ink);
.tg {
// 파스텔 팔레트 — h 187/47/282/135/355/222/27, 채도 .34 명도 .74 (면) / 명도 .46 (테두리)
--c-i: #a6ced3; --d-i: #429da9;
--c-o: #d3c9a6; --d-o: #a99342;
--c-t: #c6a6d3; --d-t: #8a42a9;
--c-s: #a6d3b1; --d-s: #42a95b;
--c-z: #d3a6aa; --d-z: #a9424a;
--c-j: #a6b4d3; --d-j: #4261a9;
--c-l: #d3baa6; --d-l: #a97042;
--grid-line: #{rgba($subject-cream, .08)};
--flash: #{$stage-yellow};
--prof-bar: #{$subject-sky};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 400px;
}
.tg__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.tg__row { display: flex; gap: $sp-2; align-items: stretch; }
.tg__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(168px, 46vw, 236px);
height: clamp(208px, 60vw, 236px);
border-radius: $r-card;
background: rgba($subject-cream, .04);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .14);
cursor: pointer; touch-action: manipulation;
}
.tg__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.tg__cv { display: block; width: 100%; height: 100%; }
.tg__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.tg__count {
font-weight: 800; font-size: clamp(20px, 6vw, 30px); line-height: 1;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.tg__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-cream, .82); }
.tg__scan { margin: $sp-2 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-cream, .82); }
.tg__scan b { font-weight: 700; color: $stage-yellow; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.tg__scanUnit { color: rgba($subject-cream, .72); }
.tg__prof {
position: relative; flex: 1; min-height: 0;
margin-top: $sp-2;
border-radius: $r-sm;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
overflow: hidden;
}
.tg__prof canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.tg { gap: $sp-1; }
.tg__field { width: 96px; height: 120px; }
.tg__count { font-size: 16px; }
.tg__scan { margin-top: 0; font-size: 8px; }
.tg__prof { margin-top: $sp-1; }
}
// reduce — 루프가 안 돈다. 정적 한 장(스캔 테두리 + 쌓인 칸)이 남는다.
@media (prefers-reduced-motion: reduce) {
.tg.is-still .tg__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tg {
--c-i: #a6ced3;
--d-i: #429da9;
--c-o: #d3c9a6;
--d-o: #a99342;
--c-t: #c6a6d3;
--d-t: #8a42a9;
--c-s: #a6d3b1;
--d-s: #42a95b;
--c-z: #d3a6aa;
--d-z: #a9424a;
--c-j: #a6b4d3;
--d-j: #4261a9;
--c-l: #d3baa6;
--d-l: #a97042;
--grid-line: rgba(255, 247, 230, 0.08);
--flash: #ffd23f;
--prof-bar: #6ec8ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 400px;
}
.tg__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.tg__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.tg__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(168px, 46vw, 236px);
height: clamp(208px, 60vw, 236px);
border-radius: 16px;
background: rgba(255, 247, 230, 0.04);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.14);
cursor: pointer;
touch-action: manipulation;
}
.tg__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.tg__cv {
display: block;
width: 100%;
height: 100%;
}
.tg__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.tg__count {
font-weight: 800;
font-size: clamp(20px, 6vw, 30px);
line-height: 1;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.tg__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(255, 247, 230, 0.82);
}
.tg__scan {
margin: 8px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
}
.tg__scan b {
font-weight: 700;
color: #ffd23f;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.tg__scanUnit {
color: rgba(255, 247, 230, 0.72);
}
.tg__prof {
position: relative;
flex: 1;
min-height: 0;
margin-top: 8px;
border-radius: 8px;
background: rgba(255, 247, 230, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.12);
overflow: hidden;
}
.tg__prof canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.tg {
gap: 4px;
}
.tg__field {
width: 96px;
height: 120px;
}
.tg__count {
font-size: 16px;
}
.tg__scan {
margin-top: 0;
font-size: 8px;
}
.tg__prof {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.tg.is-still .tg__field {
cursor: default !important;
}
}// Board 격자 — 2차원 array grid[20][10] 이 보드의 전 상태다. 매 프레임
// (1) 낙하 조각을 중력 delta 만큼 내리고 (2) 모든 셀 좌표를 충돌 검사하고 (3) fillRect 로 칸을 그린다.
// 조각 배치는 1수 탐색 — 회전 4 × 열 10 조합의 착지 결과를 격자에 놓아보고
// (완성 lines - 구멍 - 높이) 로 점수를 매겨 최적을 고른다. 시드 고정(mulberry32)이라 같은 판이 반복된다.
(function () {
var root = document.getElementById('tg');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var prof = document.getElementById('prof');
var pcv = document.getElementById('pcv');
var countEl = document.getElementById('count');
var scanEl = document.getElementById('scan');
if (!root || !field || !cv || !prof || !pcv || !countEl || !scanEl) return;
var ctx = cv.getContext('2d');
var ctx2 = pcv.getContext('2d');
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
var FILL = { I: v('--c-i'), O: v('--c-o'), T: v('--c-t'), S: v('--c-s'), Z: v('--c-z'), J: v('--c-j'), L: v('--c-l') };
var EDGE = { I: v('--d-i'), O: v('--d-o'), T: v('--d-t'), S: v('--d-s'), Z: v('--d-z'), J: v('--d-j'), L: v('--d-l') };
var LINE = v('--grid-line');
var FLASH = v('--flash');
var BAR = v('--prof-bar');
var COLS = 10, ROWS = 20, CELLS = COLS * ROWS;
var TYPES = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
var SHAPES = {
I: [[0, 1], [1, 1], [2, 1], [3, 1]],
O: [[1, 1], [2, 1], [1, 2], [2, 2]],
T: [[1, 0], [0, 1], [1, 1], [2, 1]],
S: [[1, 0], [2, 0], [0, 1], [1, 1]],
Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
J: [[0, 0], [0, 1], [1, 1], [2, 1]],
L: [[2, 0], [0, 1], [1, 1], [2, 1]]
};
var BOX = { I: 4, O: 4, T: 3, S: 3, Z: 3, J: 3, L: 3 };
var KICKS = [0, -1, 1, -2, 2];
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
var t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rnd = mulberry32(3301);
// 회전 — (x,y) → (y,−x). 행렬 곱 90도와 같은 식이고, y 에 n−1 을 더해 n×n 상자 안으로 되돌린다
function rotCW(cells, n) {
var out = [];
for (var i = 0; i < cells.length; i++) out.push([cells[i][1], n - 1 - cells[i][0]]);
return out;
}
function shapeRot(t, r) {
var c = SHAPES[t], n = BOX[t];
for (var i = 0; i < r; i++) c = rotCW(c, n);
return c;
}
// 충돌 검사 — 조각 셀 전부를 격자·벽과 견준다. 이 하나가 벽 뚫림을 막는다
function hits(grid, cells, row, col) {
for (var i = 0; i < cells.length; i++) {
var x = col + cells[i][0], y = row + cells[i][1];
if (x < 0 || x >= COLS || y >= ROWS) return true;
if (y >= 0 && grid[y][x]) return true;
}
return false;
}
function landingRow(grid, cells, col) {
var row = -4;
while (!hits(grid, cells, row + 1, col)) row += 1;
return row;
}
function emptyGrid() {
var g = [];
for (var y = 0; y < ROWS; y++) { g.push([]); for (var x = 0; x < COLS; x++) g[y].push(0); }
return g;
}
function cloneGrid(g) {
var o = [];
for (var y = 0; y < ROWS; y++) o.push(g[y].slice());
return o;
}
// 1수 탐색 — 착지 결과의 구멍·총 높이·완성 줄로 Score. 깊게 놓을수록 좋다
function choose(grid, t) {
var best = null;
for (var r = 0; r < 4; r++) {
var cells = shapeRot(t, r);
for (var c = -2; c < COLS; c++) {
if (hits(grid, cells, 0, c)) continue;
var row = landingRow(grid, cells, c);
if (row < 0) continue;
var g2 = cloneGrid(grid);
for (var i = 0; i < cells.length; i++) g2[row + cells[i][1]][c + cells[i][0]] = t;
var full = 0, holes = 0, agg = 0;
for (var y = 0; y < ROWS; y++) {
var filled = 0;
for (var x = 0; x < COLS; x++) if (g2[y][x]) filled += 1;
if (filled === COLS) full += 1;
}
for (var x2 = 0; x2 < COLS; x2++) {
var seen = false;
for (var y2 = 0; y2 < ROWS; y2++) {
if (g2[y2][x2]) { seen = true; agg += ROWS - y2; }
else if (seen) holes += 1;
}
}
var score = full * 120 - holes * 90 - agg * 2 + row;
if (!best || score > best.score) best = { r: r, c: c, score: score };
}
}
return best;
}
var grid = emptyGrid(), bag = [], piece = null;
var flashRows = [], flashT = 0, restT = 0;
function refill() {
bag = TYPES.slice();
for (var i = bag.length - 1; i > 0; i--) {
var j = Math.floor(rnd() * (i + 1)), tmp = bag[i]; bag[i] = bag[j]; bag[j] = tmp;
}
}
function nextType() { if (!bag.length) refill(); return bag.pop(); }
function filledCount() {
var n = 0;
for (var y = 0; y < ROWS; y++) for (var x = 0; x < COLS; x++) if (grid[y][x]) n += 1;
return n;
}
function spawn() {
var t = nextType();
var plan = choose(grid, t);
if (!plan) { grid = emptyGrid(); plan = choose(grid, t); }
piece = { t: t, r: 0, cells: SHAPES[t].slice(), x: 3, y: -2.2, plan: plan, drop: 4.6, slide: 9.5 };
restT = 0;
}
function lock() {
var rx = Math.round(piece.x), ry = Math.floor(piece.y), dead = false;
for (var i = 0; i < piece.cells.length; i++) {
var gx = rx + piece.cells[i][0], gy = ry + piece.cells[i][1];
if (gy < 0) { dead = true; break; }
grid[gy][gx] = piece.t;
}
if (dead) { grid = emptyGrid(); }
else {
var full = [];
for (var y = 0; y < ROWS; y++) {
var n = 0;
for (var x = 0; x < COLS; x++) if (grid[y][x]) n += 1;
if (n === COLS) full.push(y);
}
if (full.length) { flashRows = full; flashT = 0.30; }
}
countEl.textContent = String(filledCount());
spawn();
}
function spliceFull() {
flashRows.sort(function (a, b) { return a - b; });
for (var i = 0; i < flashRows.length; i++) {
grid.splice(flashRows[i], 1);
grid.unshift(new Array(COLS).fill(0));
}
flashRows = [];
countEl.textContent = String(filledCount());
}
function tryRotate() {
var rc = rotCW(piece.cells, BOX[piece.t]);
var px = Math.round(piece.x), py = Math.floor(piece.y);
for (var k = 0; k < KICKS.length; k++) {
if (!hits(grid, rc, py, px + KICKS[k])) {
piece.cells = rc; piece.x = px + KICKS[k]; piece.r = (piece.r + 1) % 4;
return true;
}
}
return false;
}
var W = 0, H = 0, PW = 0, PH = 0, dpr = 1, cell = 8, ox = 0, oy = 0, colSeen = new Array(COLS).fill(-9);
function fit() {
W = field.clientWidth; H = field.clientHeight;
PW = prof.clientWidth; PH = prof.clientHeight;
dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
pcv.width = Math.round(PW * dpr); pcv.height = Math.round(PH * dpr);
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
cell = Math.floor(Math.min(H / ROWS, W / COLS));
ox = Math.floor((W - cell * COLS) / 2); oy = Math.floor((H - cell * ROWS) / 2);
}
function cellRect(gx, gy) { return [ox + gx * cell, oy + gy * cell]; }
function paintCell(gx, gy, t, alpha) {
var p = cellRect(gx, gy), r = Math.max(2, cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(p[0] + 1, p[1] + 1, cell - 2, cell - 2, r);
ctx.fillStyle = FILL[t]; ctx.fill();
ctx.lineWidth = Math.max(1, cell * 0.14);
ctx.strokeStyle = EDGE[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
function draw(time) {
ctx.clearRect(0, 0, W, H);
// 격자 — 배열의 cells 수 그대로
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
ctx.beginPath();
for (var x = 0; x <= COLS; x++) { ctx.moveTo(ox + x * cell + .5, oy); ctx.lineTo(ox + x * cell + .5, oy + ROWS * cell); }
for (var y = 0; y <= ROWS; y++) { ctx.moveTo(ox, oy + y * cell + .5); ctx.lineTo(ox + COLS * cell, oy + y * cell + .5); }
ctx.stroke();
// 쌓인 cells
for (var gy = 0; gy < ROWS; gy++) for (var gx = 0; gx < COLS; gx++) if (grid[gy][gx]) paintCell(gx, gy, grid[gy][gx]);
// Clear 섬광 — splice 전 0.3초
if (flashT > 0) {
ctx.globalAlpha = Math.min(1, flashT / 0.30) * 0.85;
for (var f = 0; f < flashRows.length; f++) {
ctx.fillStyle = FLASH;
ctx.fillRect(ox, oy + flashRows[f] * cell, cell * COLS, cell);
}
ctx.globalAlpha = 1;
}
// 낙하 조각 (y<0 위쪽은 자른다)
if (piece) {
var fx = piece.x, fy = piece.y;
for (var i = 0; i < piece.cells.length; i++) {
var cx2 = piece.cells[i][0], cy2 = piece.cells[i][1];
if (fy + cy2 >= 0) paintCell(Math.round(fx + cx2), Math.round(fy + cy2), piece.t);
}
}
// array 스캔 — 200칸을 초당 120칸씩 훑는다(1.7초에 Board 한 바퀴). 검사가 지나간
// 열은 0.5초에 걸쳐 서서히 사그라는 잔상을 남긴다 — "살펴본 자리"가 눈에 쌓인다
var idx = Math.floor(time * 0.12) % CELLS;
var sx = idx % COLS, sy = Math.floor(idx / COLS);
colSeen[sx] = time;
ctx.fillStyle = FLASH;
for (var c2 = 0; c2 < COLS; c2++) {
var age = (time - colSeen[c2]) / 1000;
if (age < 0) age += 1e9; // 스캔을 아직 안 지나간 열
if (age > 0.6) continue;
var a = age < 0 ? 0 : 0.42 * (1 - age / 0.6);
if (a <= 0.02) continue;
ctx.globalAlpha = a;
ctx.fillRect(ox + c2 * cell, oy, cell, ROWS * cell);
}
ctx.globalAlpha = 1;
var sp = cellRect(sx, sy);
ctx.strokeStyle = FLASH; ctx.lineWidth = 2;
ctx.strokeRect(sp[0] + .5, sp[1] + .5, cell - 1, cell - 1);
scanEl.textContent = String(idx);
}
// Column height graph — 같은 배열을 다른 각도로 읽는다. 열마다 쌓인 cells 수를 세어
// 막대로 세운다. 조각이 놓이고 줄이 지워질 때마다 막대 전체가 다시 계산된다
function drawProfile(time) {
if (!PW || !PH) return;
ctx2.clearRect(0, 0, PW, PH);
var bw = PW / COLS;
for (var x = 0; x < COLS; x++) {
var h = 0;
for (var y = 0; y < ROWS; y++) if (grid[y][x]) h += 1;
if (!h) continue;
var bh = (h / ROWS) * PH;
ctx2.fillStyle = BAR;
ctx2.globalAlpha = 0.55 + 0.45 * (h / ROWS);
ctx2.fillRect(x * bw + 1, PH - bh, bw - 2, bh);
}
ctx2.globalAlpha = 1;
// 스캔이 지금 머문 열 표시
var idx2 = Math.floor(time * 0.12) % CELLS;
ctx2.strokeStyle = FLASH; ctx2.lineWidth = 2;
ctx2.strokeRect((idx2 % COLS) * bw + .5, .5, bw - 1, PH - 1);
}
var last = 0, raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (flashT > 0) { flashT -= dt; if (flashT <= 0) spliceFull(); }
if (piece) {
var plan = piece.plan;
var dx = plan.c - piece.x;
piece.x += Math.max(-piece.slide * dt, Math.min(piece.slide * dt, dx));
if (piece.r !== plan.r && Math.abs(plan.c - piece.x) < 0.6) tryRotate();
piece.y += piece.drop * dt;
var rx = Math.round(piece.x), ry = Math.floor(piece.y) + 1;
if (hits(grid, piece.cells, ry, rx)) {
piece.y = ry - 1;
restT += dt;
var aligned = piece.r === plan.r && rx === plan.c;
if (aligned || restT > 1.4) { piece.x = plan.c; lock(); }
} else restT = 0;
}
draw(now);
drawProfile(now);
raf = requestAnimationFrame(step);
}
field.addEventListener('pointerdown', function () {
// 눌러 보기 — 현재 조각을 착지점까지 한 번에 내린다 (고스트 계산과 같은 식)
if (piece) {
piece.x = piece.plan.c;
while (piece.r !== piece.plan.r && tryRotate()) { /* 회전을 계획까지 맞춘다 */ }
piece.y = landingRow(grid, piece.cells, Math.round(piece.x));
lock();
}
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(0); });
// 시작 쓰레기 — 아래 네 줄을 시드 난수로 미리 채운다. 줄마다 구멍 한 cells(Clear 직전 상태)
for (var gy0 = ROWS - 4; gy0 < ROWS; gy0++) {
var hole0 = Math.floor(rnd() * COLS);
for (var gx0 = 0; gx0 < COLS; gx0++) grid[gy0][gx0] = gx0 === hole0 ? 0 : TYPES[Math.floor(rnd() * 7)];
}
countEl.textContent = String(filledCount());
fit();
if (reduce) {
// 정지 한 장 — 스캔 지점과 쌓인 칸이 다 보이는 시각. 조각 여섯을 미리 놓는다
for (var i = 0; i < 6; i++) { spawn(); lock(); }
draw(320);
drawProfile(320);
root.classList.add('is-still');
} else {
spawn();
last = performance.now();
raf = requestAnimationFrame(step);
}
})();// TetrisBoardGrid — vanilla 판(run/330/demo/01-tetris-board-grid/index.html) 의 React 포트.
// grid[20][10] 2차원 배열이 보드의 전 상태다. 매 프레임 (1) 낙하 조각을 중력 delta 만큼
// 내리고 (2) 모든 셀 좌표를 충돌 검사하고 (3) fillRect 로 칸을 그린다. 조각 배치는 1수 탐색 —
// 회전 4 × 열 10 조합의 착지 결과를 격자에 놓아보고 (완성 줄 − 구멍 − 높이) 로 점수를 매겨
// 최적을 고른다. 시드 고정(mulberry32)이라 같은 판이 반복된다. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 스캔 지점과 쌓인 칸이 다 보이는 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TetrisBoardGrid.module.scss";
export type TetrisBoardGridProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
const COLS = 10, ROWS = 20, CELLS = COLS * ROWS;
const TYPES = ["I", "O", "T", "S", "Z", "J", "L"];
// SHAPES 는 원본을 오염시키지 않는다 — 회전·낙하 판은 map/slice 로 사본을 만든다
const SHAPES: Record<string, number[][]> = {
I: [[0, 1], [1, 1], [2, 1], [3, 1]],
O: [[1, 1], [2, 1], [1, 2], [2, 2]],
T: [[1, 0], [0, 1], [1, 1], [2, 1]],
S: [[1, 0], [2, 0], [0, 1], [1, 1]],
Z: [[0, 0], [1, 0], [1, 1], [2, 1]],
J: [[0, 0], [0, 1], [1, 1], [2, 1]],
L: [[2, 0], [0, 1], [1, 1], [2, 1]]
};
const BOX: Record<string, number> = { I: 4, O: 4, T: 3, S: 3, Z: 3, J: 3, L: 3 };
const KICKS = [0, -1, 1, -2, 2];
const HOLD_S = 1.4; // 착지 후 계획과 어긋난 채 쉴 수 있는 최대 시간(초)
const FILL: Record<string, string> = {
I: "#a6ced3", O: "#d3c9a6", T: "#c6a6d3", S: "#a6d3b1", Z: "#d3a6aa", J: "#a6b4d3", L: "#d3baa6"
};
const EDGE: Record<string, string> = {
I: "#429da9", O: "#a99342", T: "#8a42a9", S: "#42a95b", Z: "#a9424a", J: "#4261a9", L: "#a97042"
};
const LINE = "rgba(255,247,230,.08)";
const FLASH = "#ffd23f";
const BAR = "#6ec8ff";
const HINT = "10 × 20 칸 · 매 프레임 충돌 검사";
const ARIA_FIELD = "보드 격자 실험판";
const ARIA_PROF = "열 높이 그래프";
const CELLS_UNIT = "칸을 그렸습니다";
const ARRAY_WORD = "배열";
const SCAN_UNIT = "번째 칸";
// 시드 고정 난수 — 같은 판이 반복된다
function mulberry32(a: number): () => number {
return function (): number {
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;
};
}
// 회전 — (x,y) → (y,−x). 행렬 곱 90도와 같은 식이고, y 에 n−1 을 더해 n×n 상자 안으로 되돌린다
function rotCW(cells: number[][], n: number): number[][] {
return cells.map((c) => [c[1], n - 1 - c[0]]);
}
function shapeRot(t: string, r: number): number[][] {
let c = SHAPES[t];
for (let i = 0; i < r; i++) c = rotCW(c, BOX[t]);
return c;
}
type Grid = string[][];
type Piece = { t: string; r: number; cells: number[][]; x: number; y: number; plan: { r: number; c: number }; drop: number; slide: number };
export default function TetrisBoardGrid({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: TetrisBoardGridProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const profRef = useRef<HTMLDivElement | null>(null);
const pcvRef = useRef<HTMLCanvasElement | null>(null);
const countRef = useRef<HTMLBRElement | null>(null);
const scanRef = useRef<HTMLBRElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const ctx2Ref = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, PW: 0, PH: 0, dpr: 1, cell: 8, ox: 0, oy: 0,
grid: [] as Grid, bag: [] as string[], piece: null as Piece | null,
flashRows: [] as number[], flashT: 0, restT: 0,
colSeen: new Array<number>(COLS).fill(-9)
}) as Box<{
W: number; H: number; PW: number; PH: number; dpr: number; cell: number; ox: number; oy: number;
grid: Grid; bag: string[]; piece: Piece | null;
flashRows: number[]; flashT: number; restT: number; colSeen: number[];
}>;
const rndRef = useRef(mulberry32(3301)) as Box<() => number>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function emptyGrid(): Grid {
const g: Grid = [];
for (let y = 0; y < ROWS; y++) { g.push([]); for (let x = 0; x < COLS; x++) g[y].push(""); }
return g;
}
function cloneGrid(g: Grid): Grid {
return g.map((row) => row.slice());
}
// 충돌 검사 — 조각 셀 전부를 격자·벽과 견준다. 이 하나가 벽 뚫림을 막는다
function hits(grid: Grid, cells: number[][], row: number, col: number): boolean {
for (let i = 0; i < cells.length; i++) {
const x = col + cells[i][0], y = row + cells[i][1];
if (x < 0 || x >= COLS || y >= ROWS) return true;
if (y >= 0 && grid[y][x]) return true;
}
return false;
}
function landingRow(grid: Grid, cells: number[][], col: number): number {
let row = -4;
while (!hits(grid, cells, row + 1, col)) row += 1;
return row;
}
// 1수 탐색 — 착지 결과의 구멍·총 높이·완성 줄로 점수. 깊게 놓을수록 좋다
function choose(grid: Grid, t: string): { r: number; c: number } | null {
let best: { r: number; c: number; score: number } | null = null;
for (let r = 0; r < 4; r++) {
const cells = shapeRot(t, r);
for (let c = -2; c < COLS; c++) {
if (hits(grid, cells, 0, c)) continue;
const row = landingRow(grid, cells, c);
if (row < 0) continue;
const g2 = cloneGrid(grid);
for (let i = 0; i < cells.length; i++) g2[row + cells[i][1]][c + cells[i][0]] = t;
let full = 0, holes = 0, agg = 0;
for (let y = 0; y < ROWS; y++) {
let filled = 0;
for (let x = 0; x < COLS; x++) if (g2[y][x]) filled += 1;
if (filled === COLS) full += 1;
}
for (let x2 = 0; x2 < COLS; x2++) {
let seen = false;
for (let y2 = 0; y2 < ROWS; y2++) {
if (g2[y2][x2]) { seen = true; agg += ROWS - y2; }
else if (seen) holes += 1;
}
}
const score = full * 120 - holes * 90 - agg * 2 + row;
if (!best || score > best.score) best = { r: r, c: c, score: score };
}
}
return best;
}
function refill(): void {
const st = stRef.current;
st.bag = TYPES.slice();
for (let i = st.bag.length - 1; i > 0; i--) {
const j = Math.floor(rndRef.current() * (i + 1));
const tmp = st.bag[i]; st.bag[i] = st.bag[j]; st.bag[j] = tmp;
}
}
function nextType(): string {
const st = stRef.current;
if (!st.bag.length) refill();
return st.bag.pop() as string;
}
function filledCount(): number {
const st = stRef.current;
let n = 0;
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if (st.grid[y][x]) n += 1;
return n;
}
function spawn(): void {
const st = stRef.current;
const t = nextType();
let plan = choose(st.grid, t);
if (!plan) { st.grid = emptyGrid(); plan = choose(st.grid, t) as { r: number; c: number }; }
st.piece = { t: t, r: 0, cells: SHAPES[t].slice(), x: 3, y: -2.2, plan: plan, drop: 4.6, slide: 9.5 };
st.restT = 0;
}
function lock(): void {
const st = stRef.current;
const p = st.piece;
if (!p) return;
const rx = Math.round(p.x), ry = Math.floor(p.y);
let dead = false;
for (let i = 0; i < p.cells.length; i++) {
const gx = rx + p.cells[i][0], gy = ry + p.cells[i][1];
if (gy < 0) { dead = true; break; }
st.grid[gy][gx] = p.t;
}
if (dead) { st.grid = emptyGrid(); }
else {
const full: number[] = [];
for (let y = 0; y < ROWS; y++) {
let n = 0;
for (let x = 0; x < COLS; x++) if (st.grid[y][x]) n += 1;
if (n === COLS) full.push(y);
}
if (full.length) { st.flashRows = full; st.flashT = 0.30; }
}
const out = countRef.current;
if (out) out.textContent = String(filledCount());
spawn();
}
function spliceFull(): void {
const st = stRef.current;
st.flashRows.sort((a, b) => a - b);
for (let i = 0; i < st.flashRows.length; i++) {
st.grid.splice(st.flashRows[i], 1);
st.grid.unshift(new Array(COLS).fill(""));
}
st.flashRows = [];
const out = countRef.current;
if (out) out.textContent = String(filledCount());
}
function tryRotate(): boolean {
const st = stRef.current;
const p = st.piece;
if (!p) return false;
const rc = rotCW(p.cells, BOX[p.t]);
const px = Math.round(p.x), py = Math.floor(p.y);
for (let k = 0; k < KICKS.length; k++) {
if (!hits(st.grid, rc, py, px + KICKS[k])) {
p.cells = rc; p.x = px + KICKS[k]; p.r = (p.r + 1) % 4;
return true;
}
}
return false;
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current, prof = profRef.current, pcv = pcvRef.current;
if (!field || !cv || !prof || !pcv) return;
const ctx = cv.getContext("2d"), ctx2 = pcv.getContext("2d");
if (!ctx || !ctx2) return;
st.W = field.clientWidth; st.H = field.clientHeight;
st.PW = prof.clientWidth; st.PH = prof.clientHeight;
st.dpr = Math.min(2, window.devicePixelRatio || 1);
cv.width = Math.round(st.W * st.dpr); cv.height = Math.round(st.H * st.dpr);
ctx.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
pcv.width = Math.round(st.PW * st.dpr); pcv.height = Math.round(st.PH * st.dpr);
ctx2.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
st.cell = Math.floor(Math.min(st.H / ROWS, st.W / COLS));
st.ox = Math.floor((st.W - st.cell * COLS) / 2);
st.oy = Math.floor((st.H - st.cell * ROWS) / 2);
ctxRef.current = ctx;
ctx2Ref.current = ctx2;
}
function paintCell(gx: number, gy: number, t: string, alpha?: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const px = st.ox + gx * st.cell, py = st.oy + gy * st.cell;
const r = Math.max(2, st.cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(px + 1, py + 1, st.cell - 2, st.cell - 2, r);
ctx.fillStyle = FILL[t]; ctx.fill();
ctx.lineWidth = Math.max(1, st.cell * 0.14);
ctx.strokeStyle = EDGE[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
function draw(time: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
ctx.clearRect(0, 0, st.W, st.H);
// 격자 — 배열의 칸 수 그대로
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= COLS; x++) { ctx.moveTo(st.ox + x * st.cell + 0.5, st.oy); ctx.lineTo(st.ox + x * st.cell + 0.5, st.oy + ROWS * st.cell); }
for (let y = 0; y <= ROWS; y++) { ctx.moveTo(st.ox, st.oy + y * st.cell + 0.5); ctx.lineTo(st.ox + COLS * st.cell, st.oy + y * st.cell + 0.5); }
ctx.stroke();
// 쌓인 칸
for (let gy = 0; gy < ROWS; gy++) for (let gx = 0; gx < COLS; gx++) if (st.grid[gy][gx]) paintCell(gx, gy, st.grid[gy][gx]);
// 클리어 섬광 — splice 전 0.3초
if (st.flashT > 0) {
ctx.globalAlpha = Math.min(1, st.flashT / 0.30) * 0.85;
for (let f = 0; f < st.flashRows.length; f++) {
ctx.fillStyle = FLASH;
ctx.fillRect(st.ox, st.oy + st.flashRows[f] * st.cell, st.cell * COLS, st.cell);
}
ctx.globalAlpha = 1;
}
// 낙하 조각 (y<0 위쪽은 자른다)
if (st.piece) {
const fx = st.piece.x, fy = st.piece.y;
for (let i = 0; i < st.piece.cells.length; i++) {
const cx2 = st.piece.cells[i][0], cy2 = st.piece.cells[i][1];
if (fy + cy2 >= 0) paintCell(Math.round(fx + cx2), Math.round(fy + cy2), st.piece.t);
}
}
// 배열 스캔 — 200칸을 초당 120칸씩 훑는다(1.7초에 보드 한 바퀴). 검사가 지나간
// 열은 0.5초에 걸쳐 서서히 사그라는 잔상을 남긴다 — "살펴본 자리"가 눈에 쌓인다
const idx = Math.floor(time * 0.12) % CELLS;
const sx = idx % COLS, sy = Math.floor(idx / COLS);
st.colSeen[sx] = time;
ctx.fillStyle = FLASH;
for (let c2 = 0; c2 < COLS; c2++) {
let age = (time - st.colSeen[c2]) / 1000;
if (age < 0) age += 1e9; // 스캔을 아직 안 지나간 열
if (age > 0.6) continue;
const a = age < 0 ? 0 : 0.42 * (1 - age / 0.6);
if (a <= 0.02) continue;
ctx.globalAlpha = a;
ctx.fillRect(st.ox + c2 * st.cell, st.oy, st.cell, ROWS * st.cell);
}
ctx.globalAlpha = 1;
const sp0 = st.ox + sx * st.cell, sp1 = st.oy + sy * st.cell;
ctx.strokeStyle = FLASH; ctx.lineWidth = 2;
ctx.strokeRect(sp0 + 0.5, sp1 + 0.5, st.cell - 1, st.cell - 1);
const out = scanRef.current;
if (out) out.textContent = String(idx);
}
// 열 높이 그래프 — 같은 배열을 다른 각도로 읽는다. 열마다 쌓인 칸 수를 세어
// 막대로 세운다. 조각이 놓이고 줄이 지워질 때마다 막대 전체가 다시 계산된다
function drawProfile(time: number): void {
const st = stRef.current;
const ctx2 = ctx2Ref.current;
if (!ctx2 || !st.PW || !st.PH) return;
ctx2.clearRect(0, 0, st.PW, st.PH);
const bw = st.PW / COLS;
for (let x = 0; x < COLS; x++) {
let h = 0;
for (let y = 0; y < ROWS; y++) if (st.grid[y][x]) h += 1;
if (!h) continue;
const bh = (h / ROWS) * st.PH;
ctx2.fillStyle = BAR;
ctx2.globalAlpha = 0.55 + 0.45 * (h / ROWS);
ctx2.fillRect(x * bw + 1, st.PH - bh, bw - 2, bh);
}
ctx2.globalAlpha = 1;
// 스캔이 지금 머문 열 표시
const idx2 = Math.floor(time * 0.12) % CELLS;
ctx2.strokeStyle = FLASH; ctx2.lineWidth = 2;
ctx2.strokeRect((idx2 % COLS) * bw + 0.5, 0.5, bw - 1, st.PH - 1);
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000;
lastRef.current = now;
if (st.flashT > 0) { st.flashT -= dt; if (st.flashT <= 0) spliceFull(); }
if (st.piece) {
const plan = st.piece.plan;
const dx = plan.c - st.piece.x;
st.piece.x += Math.max(-st.piece.slide * dt, Math.min(st.piece.slide * dt, dx));
if (st.piece.r !== plan.r && Math.abs(plan.c - st.piece.x) < 0.6) tryRotate();
st.piece.y += st.piece.drop * dt;
const rx = Math.round(st.piece.x), ry = Math.floor(st.piece.y) + 1;
if (hits(st.grid, st.piece.cells, ry, rx)) {
st.piece.y = ry - 1;
st.restT += dt;
const aligned = st.piece.r === plan.r && rx === plan.c;
if (aligned || st.restT > HOLD_S) { st.piece.x = plan.c; lock(); }
} else st.restT = 0;
}
draw(now);
drawProfile(now);
rafRef.current = requestAnimationFrame(step);
}
// 눌러 보기 — 현재 조각을 착지점까지 한 번에 내린다 (고스트 계산과 같은 식)
function onPointerDown(): void {
const st = stRef.current;
if (st.piece) {
st.piece.x = st.piece.plan.c;
while (st.piece.r !== st.piece.plan.r && tryRotate()) { /* 회전을 계획까지 맞춘다 */ }
st.piece.y = landingRow(st.grid, st.piece.cells, Math.round(st.piece.x));
lock();
if (reduced()) { draw(320); drawProfile(320); }
}
}
useEffect(() => {
const st = stRef.current;
// 시작 쓰레기 — 아래 네 줄을 시드 난수로 미리 채운다. 줄마다 구멍 한 칸(클리어 직전 상태)
st.grid = emptyGrid();
for (let gy = ROWS - 4; gy < ROWS; gy++) {
const hole = Math.floor(rndRef.current() * COLS);
for (let gx = 0; gx < COLS; gx++) st.grid[gy][gx] = gx === hole ? "" : TYPES[Math.floor(rndRef.current() * 7)];
}
fit();
const out = countRef.current;
if (out) out.textContent = String(filledCount());
function onResize(): void {
fit();
if (reduced()) { draw(320); drawProfile(320); }
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 스캔 지점과 쌓인 칸이 다 보이는 시각. 조각 여섯을 미리 놓는다
for (let i = 0; i < 6; i++) { spawn(); lock(); }
draw(320);
drawProfile(320);
setStill(true);
} else {
spawn();
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rootCls = still ? styles.tg + " " + styles.isStill : styles.tg;
return (
<main className={styles.stage}>
<div className={rootCls} style={vars}>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row}>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={ARIA_FIELD}
tabIndex={0}
onPointerDown={onPointerDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.side}>
<b className={styles.count} ref={countRef} aria-live="polite">0</b>
<span className={styles.unit}>{CELLS_UNIT}</span>
<p className={styles.scan}>{ARRAY_WORD} <b ref={scanRef}>0</b><span className={styles.scanUnit}>{SCAN_UNIT}</span></p>
<div className={styles.prof} ref={profRef} role="img" aria-label={ARIA_PROF}>
<canvas ref={pcvRef}></canvas>
</div>
</div>
</div>
</div>
</main>
);
}01 The board grid — drawing 10×20 cells from an array — Measured area 13.931% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Tetris JavaScript: Build It in 9 Canvas Steps
The post this demo lives in
Tetris JavaScript: Build It in 9 Canvas StepsFalling blocks, stacking, line clears — computed from rotation matrices and collision checks. A Tetris built in nine ste See all demos in this post


