05Next queue — previewing upcoming pieces on a mini canvas
A bag that shuffles all seven pieces per refill keeps the queue unpredictable yet unbiased. The mini canvas reuses the same fillRect drawing, sharing code with the main board.
Best forPreview panels, queue UIs
fillRect

<main class="stage">
<div class="nq" id="nq">
<p class="nq__hint">Seven-piece bag shuffle, never obvious</p>
<div class="nq__row">
<div class="nq__field" id="field" role="application" aria-label="Next preview" tabindex="0">
<canvas class="nq__cv" id="cv"></canvas>
</div>
<div class="nq__side">
<b class="nq__next">Next</b>
<div class="nq__queue" id="queue"></div>
<p class="nq__bag" id="bag">Bag 0 / 7</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 넥스트 큐(05) — 움직임은 전부 캔버스 rAF (077 방식). CSS 애니메이션 0개.
// 무대는 다시 잉크 — 종이 다음 순환.
$color: $subject-sky;
@include stage($stage-ink);
.nq {
// 파스텔 팔레트 — 01 과 같은 h 187/47/282/135/355/222/27, 채도 .34
--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)};
--glow: #{$stage-yellow};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 430px;
}
.nq__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.nq__row { display: flex; gap: $sp-2; align-items: stretch; }
.nq__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(140px, 40vw, 192px);
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;
}
.nq__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.nq__cv { display: block; width: 100%; height: 100%; }
.nq__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.nq__next {
font-weight: 800; font-size: clamp(13px, 3.4vw, 16px); line-height: 1;
color: $stage-yellow; letter-spacing: .04em;
}
.nq__queue {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: $sp-1;
margin-top: $sp-1;
}
.nq__mini {
display: block; width: 100%; aspect-ratio: 1;
border-radius: $r-sm;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
}
.nq__bag {
margin: $sp-1 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5;
color: rgba($subject-cream, .82); font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.nq { gap: $sp-1; }
.nq__field { width: 88px; height: 112px; }
.nq__queue { gap: $sp-1; }
}
// reduce — 정적 한 장 (큐 다섯 칸이 다 보인다)
@media (prefers-reduced-motion: reduce) {
.nq.is-still .nq__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);
}
.nq {
--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);
--glow: #ffd23f;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 430px;
}
.nq__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.nq__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.nq__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(140px, 40vw, 192px);
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;
}
.nq__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.nq__cv {
display: block;
width: 100%;
height: 100%;
}
.nq__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.nq__next {
font-weight: 800;
font-size: clamp(13px, 3.4vw, 16px);
line-height: 1;
color: #ffd23f;
letter-spacing: 0.04em;
}
.nq__queue {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 4px;
margin-top: 4px;
}
.nq__mini {
display: block;
width: 100%;
aspect-ratio: 1;
border-radius: 8px;
background: rgba(255, 247, 230, 0.05);
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.12);
}
.nq__bag {
margin: 4px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(255, 247, 230, 0.82);
font-variant-numeric: tabular-nums;
}
@media (max-width: 340px) {
.nq {
gap: 4px;
}
.nq__field {
width: 88px;
height: 112px;
}
.nq__queue {
gap: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.nq.is-still .nq__field {
cursor: default !important;
}
}// 넥스트 큐 — 일곱 조각을 한 세트로 섞어 가방에 채우고 앞의 다섯을 미니 캔버스에
// 그린다. 가방이 비면 다시 섞는다 — 편향 없이 다음이 예측만 되고 뻔하지는 않다.
// 조각이 하나 소비될 때 큐가 한 칸씩 올라가고, 가방이 다시 섞이는 순간 표시가 바뀐다.
(function () {
var root = document.getElementById('nq');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var queueEl = document.getElementById('queue');
var bagEl = document.getElementById('bag');
if (!root || !field || !cv || !queueEl || !bagEl) return;
var ctx = cv.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 GLOW = v('--glow');
var COLS = 8, ROWS = 14;
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 NQ = 5; // 미리 보는 조각 수
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(3305);
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 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;
}
function shapeRot(t, r) {
var c = SHAPES[t].map(function (p) { return p.slice(); }), n = BOX[t];
for (var i = 0; i < r; i++) c = rotCW(c, n);
return c;
}
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 f = 0;
for (var x = 0; x < COLS; x++) if (g2[y][x]) f += 1;
if (f === 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;
}
// 시작 쓰레기 — 아래 4lines, 구멍 1cells
var grid = emptyGrid();
for (var gy0 = ROWS - 4; gy0 < ROWS; gy0++) {
var h0 = Math.floor(rnd() * COLS);
for (var gx0 = 0; gx0 < COLS; gx0++) grid[gy0][gx0] = gx0 === h0 ? 0 : TYPES[Math.floor(rnd() * 7)];
}
// Bag — 일곱 조각을 한 세트로 섞는다
var bag = [], bagUsed = 0, bagCount = 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;
}
bagUsed = 0; bagCount += 1;
}
refill();
function nextType() { if (!bag.length) refill(); bagUsed += 1; return bag.pop(); }
// 큐 — 앞의 NQ 개
var queue = [];
function fillQueue() { while (queue.length < NQ + 2) queue.push(nextType()); }
fillQueue();
// 미니 캔버스 — 큐 슬롯마다 하나
var slots = [];
for (var s = 0; s < NQ; s++) {
var mini = document.createElement('canvas');
mini.className = 'nq__mini';
queueEl.appendChild(mini);
slots.push({ el: mini, ctx: mini.getContext('2d') });
}
var piece = null, popped = 0;
function spawn() {
var t = queue.shift();
fillQueue();
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: -0.4, plan: plan };
popped += 1;
}
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 {
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) { grid.splice(y, 1); grid.unshift(new Array(COLS).fill(0)); }
}
}
// 판이 얇아지면 쓰레기 보충
var filled = 0;
for (var y2 = 0; y2 < ROWS; y2++) for (var x2 = 0; x2 < COLS; x2++) if (grid[y2][x2]) filled += 1;
if (filled < COLS * 2) {
for (var gy = ROWS - 3; gy < ROWS; gy++) {
var h = Math.floor(rnd() * COLS);
for (var gx = 0; gx < COLS; gx++) grid[gy][gx] = gx === h ? 0 : TYPES[Math.floor(rnd() * 7)];
}
}
piece = null;
}
var W = 0, H = 0, dpr = 1, cell = 8, ox = 0, oy = 0;
var miniS = 44;
function fit() {
W = field.clientWidth; H = field.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);
cell = Math.floor(Math.min(H / ROWS, W / COLS));
ox = Math.floor((W - cell * COLS) / 2); oy = Math.floor((H - cell * ROWS) / 2);
miniS = slots[0].el.clientWidth || 44;
for (var i = 0; i < slots.length; i++) {
var m = slots[i].el, w = m.clientWidth || 44, h = m.clientHeight || 44;
m.width = Math.round(w * dpr); m.height = Math.round(h * dpr);
slots[i].ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
slots[i].w = w; slots[i].h = h;
}
}
function paintCell(gx, gy, t, alpha) {
var r = Math.max(2, cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(ox + gx * cell + 1, oy + gy * cell + 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 drawMini(slot, t, alpha) {
var c = slot.ctx, w = slot.w, h = slot.h;
c.clearRect(0, 0, w, h);
if (!t) return;
var cells = SHAPES[t];
// 조각을 슬롯 중앙에 맞춘다
var minX = 9, maxX = -9, minY = 9, maxY = -9;
for (var i = 0; i < cells.length; i++) {
minX = Math.min(minX, cells[i][0]); maxX = Math.max(maxX, cells[i][0]);
minY = Math.min(minY, cells[i][1]); maxY = Math.max(maxY, cells[i][1]);
}
var pw = maxX - minX + 1, ph = maxY - minY + 1;
var mc = Math.min(w / 4.4, h / 4.4);
var mx = (w - pw * mc) / 2, my = (h - ph * mc) / 2;
c.globalAlpha = alpha == null ? 1 : alpha;
for (var k = 0; k < cells.length; k++) {
// cells 밑면 — fillRect 로 깔고 그 위에 테두리 사각을 얹는다 (메인 보드와 같은 순서)
c.fillStyle = EDGE[t];
c.globalAlpha = (alpha == null ? 1 : alpha) * 0.25;
c.fillRect(mx + (cells[k][0] - minX) * mc, my + (cells[k][1] - minY) * mc, mc, mc);
c.globalAlpha = alpha == null ? 1 : alpha;
c.beginPath();
c.roundRect(mx + (cells[k][0] - minX) * mc + 1, my + (cells[k][1] - minY) * mc + 1, mc - 2, mc - 2, Math.max(2, mc * 0.22));
c.fillStyle = FILL[t]; c.fill();
c.lineWidth = Math.max(1.5, mc * 0.13);
c.strokeStyle = EDGE[t]; c.stroke();
}
c.globalAlpha = 1;
}
var last = 0, raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var popT = 0; // 큐 이동 보간 (초)
function draw() {
ctx.clearRect(0, 0, W, H);
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();
for (var gy = 0; gy < ROWS; gy++) for (var gx = 0; gx < COLS; gx++) if (grid[gy][gx]) paintCell(gx, gy, grid[gy][gx]);
if (piece) {
for (var i = 0; i < piece.cells.length; i++) {
var cy = piece.y + piece.cells[i][1];
if (cy >= 0) paintCell(Math.round(piece.x + piece.cells[i][0]), Math.round(cy), piece.t);
}
}
}
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (piece) {
var dx = piece.plan.c - piece.x;
piece.x += Math.max(-13 * dt, Math.min(13 * dt, dx));
if (piece.r !== piece.plan.r && Math.abs(dx) < 0.6) {
var rc = rotCW(piece.cells, BOX[piece.t]);
if (!hits(grid, rc, Math.floor(piece.y), Math.round(piece.x))) { piece.cells = rc; piece.r = (piece.r + 1) % 4; }
else piece.r = piece.plan.r;
}
var target = landingRow(grid, piece.cells, Math.round(piece.x));
var aligned = Math.abs(dx) < 0.05 && piece.r === piece.plan.r;
var ny = piece.y + (aligned ? 40 : 5.5) * dt;
piece.y = ny >= target ? target : ny;
if (piece.y === target) lock();
} else {
spawn();
}
popT = Math.max(0, popT - dt);
draw();
// 큐 — 소비 직후 슬롯이 위로 올라오며 사그라든다
for (var s2 = 0; s2 < slots.length; s2++) {
var a = 1 - 0.10 * s2;
drawMini(slots[s2], queue[s2], a * (s2 === 0 && popT > 0 ? 0.35 + 0.65 * (1 - popT / 0.2) : 1));
}
bagEl.textContent = 'Bag ' + (TYPES.length - bagUsed % TYPES.length || TYPES.length) + ' / 7';
raf = requestAnimationFrame(step);
}
field.addEventListener('pointerdown', function () {
if (piece) {
piece.x = piece.plan.c; piece.r = piece.plan.r;
var c2 = shapeRot(piece.t, piece.plan.r);
piece.cells = c2;
piece.y = landingRow(grid, piece.cells, Math.round(piece.x));
lock();
popT = 0.2;
}
});
window.addEventListener('resize', function () { fit(); if (reduce) { draw(); for (var i = 0; i < slots.length; i++) drawMini(slots[i], queue[i], 1 - 0.10 * i); } });
fit();
if (reduce) {
draw();
for (var r2 = 0; r2 < slots.length; r2++) drawMini(slots[r2], queue[r2], 1 - 0.10 * r2);
bagEl.textContent = 'Bag 3 / 7';
root.classList.add('is-still');
} else {
last = performance.now();
raf = requestAnimationFrame(step);
}
})();// TetrisNextQueue — vanilla 판(run/330/demo/05-tetris-next-queue/index.html) 의 React 포트.
// 일곱 조각을 한 세트로 섞어 가방에 채우고 앞의 다섯을 미니 캔버스에 그린다.
// 가방이 비면 다시 섞는다 — 편향 없이 다음이 예측만 되고 뻔하지는 않다.
// 조각이 하나 소비될 때 큐가 한 칸씩 올라가고(0.2초 보간), 가방이 다시 섞이는 순간 표시가 바뀐다.
// 정렬 전 낙하 5.5칸/s · 정렬 후 착지 40칸/s clamp · 미끄러짐 13칸/s. 값·수치는 vanilla 와 같다.
// SHAPES 원본은 오염시키지 않는다 — 낙하 판은 slice, 회전 판은 map(slice) 사본.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 큐 다섯 칸이 다 보이는 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TetrisNextQueue.module.scss";
export type TetrisNextQueueProps = {
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 = 8, ROWS = 14;
const TYPES = ["I", "O", "T", "S", "Z", "J", "L"];
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 NQ = 5; // 미리 보는 조각 수
const FALL_ALIGNED = 40, DRIFT = 5.5, SLIDE = 13; // 정렬 후 착지 40칸/s · 정렬 전 5.5칸/s · 미끄러짐 13칸/s
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 HINT = "가방 7개 섞기 — 다음이 뻔하지 않게";
const ARIA_FIELD = "다음 미리 보기";
const L_NEXT = "다음";
const BAG_WORD = "가방";
const BAG_STILL = "가방 3 / 7";
// 시드 고정 난수 — 같은 판이 반복된다
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;
};
}
function rotCW(cells: number[][], n: number): number[][] {
return cells.map((c) => [c[1], n - 1 - c[0]]);
}
function hits(grid: string[][], 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: string[][], cells: number[][], col: number): number {
let row = -4;
while (!hits(grid, cells, row + 1, col)) row += 1;
return row;
}
function shapeRot(t: string, r: number): number[][] {
let c = SHAPES[t].map((p) => p.slice());
const n = BOX[t];
for (let i = 0; i < r; i++) c = rotCW(c, n);
return c;
}
type Grid = string[][];
type Piece = { t: string; r: number; cells: number[][]; x: number; y: number; plan: { r: number; c: number } };
// 미니 캔버스 슬롯 — 큐 칸마다 하나
type Slot = { el: HTMLCanvasElement | null; ctx: CanvasRenderingContext2D | null; w: number; h: number };
export default function TetrisNextQueue({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: TetrisNextQueueProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const queueRef = useRef<HTMLDivElement | null>(null);
const bagRef = useRef<HTMLParagraphElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, dpr: 1, cell: 8, ox: 0, oy: 0,
grid: [] as Grid, bag: [] as string[], bagUsed: 0, bagCount: 0,
queue: [] as string[], piece: null as Piece | null, popped: 0, popT: 0,
slots: [] as Slot[]
}) as Box<{
W: number; H: number; dpr: number; cell: number; ox: number; oy: number;
grid: Grid; bag: string[]; bagUsed: number; bagCount: number;
queue: string[]; piece: Piece | null; popped: number; popT: number;
slots: Slot[];
}>;
const rndRef = useRef(mulberry32(3305)) 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 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 f = 0;
for (let x = 0; x < COLS; x++) if (g2[y][x]) f += 1;
if (f === 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;
}
st.bagUsed = 0; st.bagCount += 1;
}
function nextType(): string {
const st = stRef.current;
if (!st.bag.length) refill();
st.bagUsed += 1;
return st.bag.pop() as string;
}
function fillQueue(): void {
const st = stRef.current;
while (st.queue.length < NQ + 2) st.queue.push(nextType());
}
function spawn(): void {
const st = stRef.current;
const t = st.queue.shift() as string;
fillQueue();
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: -0.4, plan: plan };
st.popped += 1;
}
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 {
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) { st.grid.splice(y, 1); st.grid.unshift(new Array(COLS).fill("")); }
}
}
// 판이 얇아지면 쓰레기 보충
let filled = 0;
for (let y2 = 0; y2 < ROWS; y2++) for (let x2 = 0; x2 < COLS; x2++) if (st.grid[y2][x2]) filled += 1;
if (filled < COLS * 2) {
for (let gy = ROWS - 3; gy < ROWS; gy++) {
const h = Math.floor(rndRef.current() * COLS);
for (let gx = 0; gx < COLS; gx++) st.grid[gy][gx] = gx === h ? "" : TYPES[Math.floor(rndRef.current() * 7)];
}
}
st.piece = null;
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
st.W = field.clientWidth; st.H = field.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);
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;
for (let i = 0; i < st.slots.length; i++) {
const m = st.slots[i].el;
if (!m) continue;
const c = m.getContext("2d");
if (!c) continue;
const w = m.clientWidth || 44, h = m.clientHeight || 44;
m.width = Math.round(w * st.dpr); m.height = Math.round(h * st.dpr);
c.setTransform(st.dpr, 0, 0, st.dpr, 0, 0);
st.slots[i].ctx = c;
st.slots[i].w = w; st.slots[i].h = h;
}
}
function paintCell(gx: number, gy: number, t: string, alpha?: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const r = Math.max(2, st.cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(st.ox + gx * st.cell + 1, st.oy + gy * st.cell + 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 drawMini(slot: Slot, t: string | undefined, alpha: number): void {
const c = slot.ctx;
const w = slot.w, h = slot.h;
if (!c) return;
c.clearRect(0, 0, w, h);
if (!t) return;
const cells = SHAPES[t];
// 조각을 슬롯 중앙에 맞춘다
let minX = 9, maxX = -9, minY = 9, maxY = -9;
for (let i = 0; i < cells.length; i++) {
minX = Math.min(minX, cells[i][0]); maxX = Math.max(maxX, cells[i][0]);
minY = Math.min(minY, cells[i][1]); maxY = Math.max(maxY, cells[i][1]);
}
const pw = maxX - minX + 1, ph = maxY - minY + 1;
const mc = Math.min(w / 4.4, h / 4.4);
const mx = (w - pw * mc) / 2, my = (h - ph * mc) / 2;
for (let k = 0; k < cells.length; k++) {
// 칸 밑면 — fillRect 로 깔고 그 위에 테두리 사각을 얹는다 (메인 보드와 같은 순서)
c.fillStyle = EDGE[t];
c.globalAlpha = alpha * 0.25;
c.fillRect(mx + (cells[k][0] - minX) * mc, my + (cells[k][1] - minY) * mc, mc, mc);
c.globalAlpha = alpha;
c.beginPath();
c.roundRect(mx + (cells[k][0] - minX) * mc + 1, my + (cells[k][1] - minY) * mc + 1, mc - 2, mc - 2, Math.max(2, mc * 0.22));
c.fillStyle = FILL[t]; c.fill();
c.lineWidth = Math.max(1.5, mc * 0.13);
c.strokeStyle = EDGE[t]; c.stroke();
}
c.globalAlpha = 1;
}
function draw(): 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]);
if (st.piece) {
for (let i = 0; i < st.piece.cells.length; i++) {
const cy = st.piece.y + st.piece.cells[i][1];
if (cy >= 0) paintCell(Math.round(st.piece.x + st.piece.cells[i][0]), Math.round(cy), st.piece.t);
}
}
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000;
lastRef.current = now;
if (st.piece) {
const p = st.piece;
const dx = p.plan.c - p.x;
p.x += Math.max(-SLIDE * dt, Math.min(SLIDE * dt, dx));
if (p.r !== p.plan.r && Math.abs(dx) < 0.6) {
const rc = rotCW(p.cells, BOX[p.t]);
if (!hits(st.grid, rc, Math.floor(p.y), Math.round(p.x))) { p.cells = rc; p.r = (p.r + 1) % 4; }
else p.r = p.plan.r;
}
const target = landingRow(st.grid, p.cells, Math.round(p.x));
const aligned = Math.abs(dx) < 0.05 && p.r === p.plan.r;
const ny = p.y + (aligned ? FALL_ALIGNED : DRIFT) * dt;
p.y = ny >= target ? target : ny;
if (p.y === target) lock();
} else {
spawn();
}
st.popT = Math.max(0, st.popT - dt);
draw();
// 큐 — 소비 직후 슬롯이 위로 올라오며 사그라든다
for (let s2 = 0; s2 < st.slots.length; s2++) {
const a = 1 - 0.10 * s2;
drawMini(st.slots[s2], st.queue[s2], a * (s2 === 0 && st.popT > 0 ? 0.35 + 0.65 * (1 - st.popT / 0.2) : 1));
}
const bagOut = bagRef.current;
if (bagOut) bagOut.textContent = BAG_WORD + " " + (TYPES.length - st.bagUsed % TYPES.length || TYPES.length) + " / 7";
rafRef.current = requestAnimationFrame(step);
}
// 누르면 하드드롭 — 큐가 바로 한 칸 올라온다
function onPointerDown(): void {
const st = stRef.current;
const p = st.piece;
if (p) {
p.x = p.plan.c; p.r = p.plan.r;
p.cells = shapeRot(p.t, p.plan.r);
p.y = landingRow(st.grid, p.cells, Math.round(p.x));
lock();
st.popT = 0.2;
if (reduced()) {
draw();
for (let i = 0; i < st.slots.length; i++) drawMini(st.slots[i], st.queue[i], 1 - 0.10 * i);
}
}
}
useEffect(() => {
const st = stRef.current;
st.grid = emptyGrid();
// 시작 쓰레기 — 아래 4줄, 구멍 1칸
for (let gy0 = ROWS - 4; gy0 < ROWS; gy0++) {
const h0 = Math.floor(rndRef.current() * COLS);
for (let gx0 = 0; gx0 < COLS; gx0++) st.grid[gy0][gx0] = gx0 === h0 ? "" : TYPES[Math.floor(rndRef.current() * 7)];
}
refill();
fillQueue();
// 미니 캔버스 — 큐 슬롯마다 하나 (DOM 에서 만들어 ref 로 보관)
const queueEl = queueRef.current;
if (queueEl) {
for (let s = 0; s < NQ; s++) {
const mini = document.createElement("canvas");
mini.className = styles.mini;
queueEl.appendChild(mini);
st.slots.push({ el: mini, ctx: mini.getContext("2d"), w: 44, h: 44 });
}
}
fit();
function onResize(): void {
fit();
if (reduced()) {
draw();
for (let i = 0; i < st.slots.length; i++) drawMini(st.slots[i], st.queue[i], 1 - 0.10 * i);
}
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 정적 한 장 — 큐 다섯 칸이 다 보인다
draw();
for (let r2 = 0; r2 < st.slots.length; r2++) drawMini(st.slots[r2], st.queue[r2], 1 - 0.10 * r2);
const bagOut = bagRef.current;
if (bagOut) bagOut.textContent = BAG_STILL;
setStill(true);
} else {
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
if (queueEl) queueEl.textContent = "";
st.slots = [];
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const rootCls = still ? styles.nq + " " + styles.isStill : styles.nq;
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.next}>{L_NEXT}</b>
<div className={styles.queue} ref={queueRef}></div>
<p className={styles.bag} ref={bagRef}>가방 0 / 7</p>
</div>
</div>
</div>
</main>
);
}05 Next queue — previewing upcoming pieces on a mini canvas — Measured area 16.403% · 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
If you like this effect - similar demos
game08 Elimination mode — the wheel spins on without its winnersSpin Wheel: 9 Canvas Roulettes, Flick to Stop
game06 A Board Inside a Board Where the Cell You Pick Chooses the Next Board and the View Springs In to ItCSS Tic Tac Toe: 9 Boards That Count Ahead
game09 A Mini Pinball Table With Flippers, Bumpers and a ScoreboardJavaScript Pinball Game: 9 Physics Scenes