03라인 클리어 — 꽉 찬 줄을 찾아 지우고 무너뜨리기
매 프레임 보드를 한 줄씩 훑어 빈 칸이 0개인 줄만 골라 splice 로 자르고 위를 내립니다. 빈 줄 검사는 8줄 테트리스까지 같은 코드로 돕니다.
쓰임매치 게임, 격자 정리 애니메이션
fillRectrequestAnimationFrame

<main class="stage">
<div class="lc" id="lc">
<p class="lc__hint">빈 칸 0개인 줄만 지운다</p>
<div class="lc__row">
<div class="lc__field" id="field" role="application" aria-label="라인 클리어" tabindex="0">
<canvas class="lc__cv" id="cv"></canvas>
</div>
<div class="lc__side">
<b class="lc__count" id="count" aria-live="polite">0</b>
<span class="lc__unit">줄 지움</span>
<div class="lc__bars" id="bars" role="img" aria-label="줄마다 찬 칸 수"><canvas id="bcv"></canvas></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 라인 클리어(03) — 움직임은 전부 캔버스 rAF (077 방식). CSS 애니메이션 0개.
// 무대는 주황 — 노랑 다음 순환. 어두운 글자.
$color: $subject-ink;
@include stage($stage-orange);
.lc {
// 파스텔 팔레트 — 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-ink, .10)};
--flash: #{$stage-yellow};
--bar: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.lc__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.lc__row { display: flex; gap: $sp-2; align-items: stretch; }
.lc__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(160px, 46vw, 224px);
height: clamp(216px, 62vw, 236px);
border-radius: $r-card;
background: rgba($subject-ink, .06);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer; touch-action: manipulation;
}
.lc__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.lc__cv { display: block; width: 100%; height: 100%; }
.lc__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.lc__count {
font-weight: 800; font-size: clamp(20px, 5vw, 28px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.lc__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.lc__bars {
position: relative; flex: 1; min-height: 0;
margin-top: $sp-2;
border-radius: $r-sm;
background: rgba($subject-ink, .05);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .14);
overflow: hidden;
}
.lc__bars canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.lc { gap: $sp-1; }
.lc__field { width: 96px; height: 120px; }
.lc__count { font-size: 16px; }
.lc__bars { margin-top: $sp-1; }
}
// reduce — 정적 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.lc.is-still .lc__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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.lc {
--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(23, 20, 26, 0.1);
--flash: #ffd23f;
--bar: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.lc__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.lc__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.lc__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(160px, 46vw, 224px);
height: clamp(216px, 62vw, 236px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.06);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
touch-action: manipulation;
}
.lc__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.lc__cv {
display: block;
width: 100%;
height: 100%;
}
.lc__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.lc__count {
font-weight: 800;
font-size: clamp(20px, 5vw, 28px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.lc__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.lc__bars {
position: relative;
flex: 1;
min-height: 0;
margin-top: 8px;
border-radius: 8px;
background: rgba(23, 20, 26, 0.05);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.14);
overflow: hidden;
}
.lc__bars canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.lc {
gap: 4px;
}
.lc__field {
width: 96px;
height: 120px;
}
.lc__count {
font-size: 16px;
}
.lc__bars {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.lc.is-still .lc__field {
cursor: default !important;
}
}// 라인 클리어 — 매 프레임 (1) 조각을 내리고 (2) 잠근 뒤 줄마다 빈 칸 수를 세어
// 0개인 줄만 골라 (3) 0.18초 섬광 뒤 splice 로 자르고 (4) 위 칸 전체를 한 칸씩
// 떨어뜨린다. 붕괴는 그 자체가 계산이다 — 배열이 한 칸씩 밀리며 화면 절반이 바뀐다.
(function () {
var root = document.getElementById('lc');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var bars = document.getElementById('bars');
var bcv = document.getElementById('bcv');
var countEl = document.getElementById('count');
if (!root || !field || !cv || !bars || !bcv || !countEl) return;
var ctx = cv.getContext('2d');
var ctx2 = bcv.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('--bar');
var COLS = 10, ROWS = 16;
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 };
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(3303);
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 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;
}
// 시작 쓰레기 — 아래 6줄, 줄마다 구멍 1칸. 클리어가 곧바로 일어나는 높이
var grid = emptyGrid();
for (var gy = ROWS - 6; gy < ROWS; gy++) {
var h1 = Math.floor(rnd() * COLS);
for (var gx = 0; gx < COLS; gx++) grid[gy][gx] = gx === h1 ? 0 : TYPES[Math.floor(rnd() * 7)];
}
var bag = [];
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;
}
}
refill();
function nextType() { if (!bag.length) refill(); return bag.pop(); }
// 상태
var piece = null; // {t, cells, x, y}
var flashRows = [], flashT = 0;
var dropY = 0; // 붕괴 애니메이션 — 위 칸이 내려오는 중인 픽셀 오프셋(칸)
var cleared = 0;
var FLASH_S = 0.18, DROP_S = 0.12; // 섬광 시간 · 붕괴 시간 (초)
var restT = 0;
function spawn() {
var t = nextType();
piece = { t: t, cells: SHAPES[t].slice(), x: 2 + Math.floor(rnd() * (COLS - 4)), y: -0.6, drop: 16 };
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 {
// 줄 검사 — 빈 칸 0개인 줄만
flashRows = [];
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) flashRows.push(y);
}
if (flashRows.length) { flashT = FLASH_S; }
}
piece = null;
}
function spliceRows() {
flashRows.sort(function (a, b) { return a - b; });
cleared += flashRows.length;
countEl.textContent = String(cleared);
for (var i = 0; i < flashRows.length; i++) {
grid.splice(flashRows[i], 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 * 3) {
for (var gy2 = ROWS - 4; gy2 < ROWS; gy2++) {
var h1 = Math.floor(rnd() * COLS);
for (var gx2 = 0; gx2 < COLS; gx2++) grid[gy2][gx2] = gx2 === h1 ? 0 : TYPES[Math.floor(rnd() * 7)];
}
}
flashRows = [];
dropY = 1; // 붕괴 시각 — 위 칸이 한 칸 내려오는 동안
}
var W = 0, H = 0, BW = 0, BH = 0, dpr = 1, cell = 8, ox = 0, oy = 0;
function fit() {
W = field.clientWidth; H = field.clientHeight;
BW = bars.clientWidth; BH = bars.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);
bcv.width = Math.round(BW * dpr); bcv.height = Math.round(BH * 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 paintCell(px, py, t, alpha) {
var r = Math.max(2, cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(px + 1, py + 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;
}
var last = 0, raf = 0, waitT = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function draw(fallOff) {
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();
// 쌓인 칸 — 붕괴 진행 중이면 잘린 줄 위쪽을 fallOff 칸만큼 내려 그린다
for (var gy = 0; gy < ROWS; gy++) {
var isFlash = flashRows.indexOf(gy) >= 0;
var fy = gy;
if (dropY > 0 && !isFlash) {
// 잘린 줄 개수만큼 위 칸이 내려온다 — 간단히 전체를 fallOff 만큼
fy = gy + fallOff;
}
for (var gx = 0; gx < COLS; gx++) {
if (!grid[gy][gx]) continue;
var py = oy + fy * cell;
if (py < oy - cell) continue;
paintCell(ox + gx * cell, Math.min(py, oy + (ROWS - 1) * cell), grid[gy][gx]);
}
}
// 섬광 — 찢어지며 밝아진다
if (flashT > 0) {
ctx.globalAlpha = Math.min(1, flashT / FLASH_S) * 0.9;
for (var f = 0; f < flashRows.length; f++) {
ctx.fillStyle = FLASH;
ctx.fillRect(ox, oy + flashRows[f] * cell, cell * COLS, cell);
}
ctx.globalAlpha = 1;
}
// 낙하 조각
if (piece) {
for (var i = 0; i < piece.cells.length; i++) {
var cy = piece.y + piece.cells[i][1];
if (cy >= 0) paintCell(ox + Math.round(piece.x + piece.cells[i][0]) * cell, oy + Math.round(cy) * cell, piece.t);
}
}
}
function drawBars() {
if (!BW || !BH) return;
ctx2.clearRect(0, 0, BW, BH);
var rh = BH / ROWS;
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) continue;
ctx2.fillStyle = BAR;
ctx2.globalAlpha = n === COLS ? 1 : 0.35 + 0.5 * (n / COLS);
var bw2 = (n / COLS) * BW;
ctx2.fillRect(0, y * rh + 1, bw2, rh - 2);
}
ctx2.globalAlpha = 1;
}
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (flashT > 0) {
flashT -= dt;
if (flashT <= 0) spliceRows();
} else if (dropY > 0) {
dropY = Math.max(0, dropY - dt / DROP_S);
if (dropY === 0) waitT = 0.05;
} else if (piece) {
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;
lock();
}
} else {
waitT -= dt;
if (waitT <= 0) spawn();
}
draw(dropY > 0 ? dropY : 0);
drawBars();
raf = requestAnimationFrame(step);
}
field.addEventListener('pointerdown', function () {
if (piece) { // 누르면 즉시 착지 — 클리어 검사까지 한 번에
while (!hits(grid, piece.cells, Math.floor(piece.y) + 1, Math.round(piece.x))) piece.y += 1;
lock();
}
});
window.addEventListener('resize', function () { fit(); if (reduce) { draw(0); drawBars(); } });
fit();
if (reduce) {
// 정지 한 장 — 클리어 직전 상태(한 줄만 구멍 1칸)를 보인다
for (var x3 = 0; x3 < COLS; x3++) grid[ROWS - 3][x3] = TYPES[(x3 + 3) % 7];
grid[ROWS - 3][2] = 0;
draw(0); drawBars();
root.classList.add('is-still');
} else {
spawn();
last = performance.now();
raf = requestAnimationFrame(step);
}
})();// TetrisLineClear — vanilla 판(run/330/demo/03-tetris-line-clear/index.html) 의 React 포트.
// 매 프레임 (1) 조각을 내리고 (2) 잠근 뒤 줄마다 빈 칸 수를 세어 0개인 줄만 골라
// (3) 0.18초 섬광 뒤 splice 로 자르고 (4) 위 칸 전체를 한 칸씩 떨어뜨린다(0.12초).
// 붕괴는 그 자체가 계산이다 — 배열이 한 칸씩 밀리며 화면 절반이 바뀐다.
// SHAPES 원본은 오염시키지 않는다 — 낙하 판은 slice 사본. 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 클리어 직전 상태의 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TetrisLineClear.module.scss";
export type TetrisLineClearProps = {
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 = 16;
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 FLASH_S = 0.18, DROP_S = 0.12; // 섬광 시간 · 붕괴 시간 (초)
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(23,20,26,.10)";
const FLASH = "#ffd23f";
const BAR = "#17141a";
const HINT = "빈 칸 0개인 줄만 지운다";
const ARIA_FIELD = "라인 클리어";
const ARIA_BARS = "줄마다 찬 칸 수";
const 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;
};
}
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;
}
type Grid = string[][];
type Piece = { t: string; cells: number[][]; x: number; y: number; drop: number };
export default function TetrisLineClear({
duration = "2s",
easing = "ease-out",
color = "#17141a"
}: TetrisLineClearProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const barsRef = useRef<HTMLDivElement | null>(null);
const bcvRef = useRef<HTMLCanvasElement | null>(null);
const countRef = useRef<HTMLBRElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const ctx2Ref = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, BW: 0, BH: 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, dropY: 0, cleared: 0, waitT: 0
}) as Box<{
W: number; H: number; BW: number; BH: number; dpr: number; cell: number; ox: number; oy: number;
grid: Grid; bag: string[]; piece: Piece | null;
flashRows: number[]; flashT: number; dropY: number; cleared: number; waitT: number;
}>;
const rndRef = useRef(mulberry32(3303)) 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;
}
// 시작 쓰레기 — 아래 6줄, 줄마다 구멍 1칸. 클리어가 곧바로 일어나는 높이
function garbage(): void {
const st = stRef.current;
for (let gy = ROWS - 6; gy < ROWS; gy++) {
const h1 = Math.floor(rndRef.current() * COLS);
for (let gx = 0; gx < COLS; gx++) st.grid[gy][gx] = gx === h1 ? "" : TYPES[Math.floor(rndRef.current() * 7)];
}
}
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 spawn(): void {
const st = stRef.current;
const t = nextType();
st.piece = { t: t, cells: SHAPES[t].slice(), x: 2 + Math.floor(rndRef.current() * (COLS - 4)), y: -0.6, drop: 16 };
}
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 {
// 줄 검사 — 빈 칸 0개인 줄만
st.flashRows = [];
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.flashRows.push(y);
}
if (st.flashRows.length) { st.flashT = FLASH_S; }
}
st.piece = null;
}
function spliceRows(): void {
const st = stRef.current;
st.flashRows.sort((a, b) => a - b);
st.cleared += st.flashRows.length;
const out = countRef.current;
if (out) out.textContent = String(st.cleared);
for (let i = 0; i < st.flashRows.length; i++) {
st.grid.splice(st.flashRows[i], 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 * 3) garbage();
st.flashRows = [];
st.dropY = 1; // 붕괴 시각 — 위 칸이 한 칸 내려오는 동안
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current, bars = barsRef.current, bcv = bcvRef.current;
if (!field || !cv || !bars || !bcv) return;
const ctx = cv.getContext("2d"), ctx2 = bcv.getContext("2d");
if (!ctx || !ctx2) return;
st.W = field.clientWidth; st.H = field.clientHeight;
st.BW = bars.clientWidth; st.BH = bars.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);
bcv.width = Math.round(st.BW * st.dpr); bcv.height = Math.round(st.BH * 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(px: number, py: 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(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(fallOff: 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();
// 쌓인 칸 — 붕괴 진행 중이면 잘린 줄 위쪽을 fallOff 칸만큼 내려 그린다
for (let gy = 0; gy < ROWS; gy++) {
const isFlash = st.flashRows.indexOf(gy) >= 0;
let fy = gy;
if (st.dropY > 0 && !isFlash) {
// 잘린 줄 개수만큼 위 칸이 내려온다 — 간단히 전체를 fallOff 만큼
fy = gy + fallOff;
}
for (let gx = 0; gx < COLS; gx++) {
if (!st.grid[gy][gx]) continue;
const py = st.oy + fy * st.cell;
if (py < st.oy - st.cell) continue;
paintCell(st.ox + gx * st.cell, Math.min(py, st.oy + (ROWS - 1) * st.cell), st.grid[gy][gx]);
}
}
// 섬광 — 찢어지며 밝아진다
if (st.flashT > 0) {
ctx.globalAlpha = Math.min(1, st.flashT / FLASH_S) * 0.9;
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;
}
// 낙하 조각
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(st.ox + Math.round(st.piece.x + st.piece.cells[i][0]) * st.cell, st.oy + Math.round(cy) * st.cell, st.piece.t);
}
}
}
function drawBars(): void {
const st = stRef.current;
const ctx2 = ctx2Ref.current;
if (!ctx2 || !st.BW || !st.BH) return;
ctx2.clearRect(0, 0, st.BW, st.BH);
const rh = st.BH / ROWS;
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) continue;
ctx2.fillStyle = BAR;
ctx2.globalAlpha = n === COLS ? 1 : 0.35 + 0.5 * (n / COLS);
const bw2 = (n / COLS) * st.BW;
ctx2.fillRect(0, y * rh + 1, bw2, rh - 2);
}
ctx2.globalAlpha = 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) spliceRows();
} else if (st.dropY > 0) {
st.dropY = Math.max(0, st.dropY - dt / DROP_S);
if (st.dropY === 0) st.waitT = 0.05;
} else if (st.piece) {
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;
lock();
}
} else {
st.waitT -= dt;
if (st.waitT <= 0) spawn();
}
draw(st.dropY > 0 ? st.dropY : 0);
drawBars();
rafRef.current = requestAnimationFrame(step);
}
// 누르면 즉시 착지 — 클리어 검사까지 한 번에
function onPointerDown(): void {
const st = stRef.current;
if (st.piece) {
while (!hits(st.grid, st.piece.cells, Math.floor(st.piece.y) + 1, Math.round(st.piece.x))) st.piece.y += 1;
lock();
if (reduced()) { draw(0); drawBars(); }
}
}
useEffect(() => {
const st = stRef.current;
st.grid = emptyGrid();
garbage();
refill();
fit();
function onResize(): void {
fit();
if (reduced()) { draw(0); drawBars(); }
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 클리어 직전 상태(한 줄만 구멍 1칸)를 보인다
for (let x3 = 0; x3 < COLS; x3++) st.grid[ROWS - 3][x3] = TYPES[(x3 + 3) % 7];
st.grid[ROWS - 3][2] = "";
draw(0); drawBars();
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.lc + " " + styles.isStill : styles.lc;
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}>{UNIT}</span>
<div className={styles.bars} ref={barsRef} role="img" aria-label={ARIA_BARS}>
<canvas ref={bcvRef}></canvas>
</div>
</div>
</div>
</div>
</main>
);
}03 라인 클리어 — 꽉 찬 줄을 찾아 지우고 무너뜨리기 — 측정 누적면적 24.708% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · HTML 테트리스 코드 — 캔버스로 조립하는 9단계
이 데모가 속한 글
HTML 테트리스 코드 — 캔버스로 조립하는 9단계블록이 내려오고 쌓이고 한 줄이 지워지는 걸 회전 행렬과 충돌 검사로 계산해 그립니다. 아홉 단계로 조립하는 테트리스 — 라이브러리 없이 캔버스 한 장, 코드는 비밀번호 zip으로 드립니다. 이 글은 html 테트리 이 글의 데모 전부 보기


