02Seven pieces and rotation — shapes that turn by matrix multiply
Each piece is a coordinate array; applying the (x,y)→(y,−x) rotation turns it 90 degrees at a time. When it meets a wall, a small post-rotation kick preserves the wrist-feel players expect.
Best forPuzzle games, rotation controls
keydownfillRect

<main class="stage">
<div class="rt" id="rt">
<p class="rt__hint">Matrix rotation (x,y) to (y,-x)</p>
<div class="rt__row">
<div class="rt__field" id="field" role="application" aria-label="Rotation lab" tabindex="0">
<canvas class="rt__cv" id="cv"></canvas>
</div>
<div class="rt__side">
<b class="rt__deg" id="deg" aria-live="polite">0 deg</b>
<span class="rt__kick" id="kick">Wall kick 1 cell</span>
<b class="rt__count" id="count">0</b>
<span class="rt__unit">rotations</span>
<p class="rt__tip">Press left or right arrow to spin it yourself</p>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 일곱 조각과 회전(02) — 움직임은 전부 캔버스 rAF (077 방식). CSS 애니메이션 0개.
// 칸 일곱 빛깔은 01 과 같은 파스텔 팔레트(채도 .34). 무대는 노랑 — 어두운 무대 다음 순환.
$color: $subject-ink;
@include stage($stage-yellow);
.rt {
// 파스텔 팔레트 — 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-ink, .10)};
--arc: #{$subject-ink};
--kickc: #{$stage-orange};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 420px;
}
.rt__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.rt__row { display: flex; gap: $sp-2; align-items: stretch; }
.rt__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(180px, 52vw, 236px);
height: clamp(180px, 52vw, 236px);
border-radius: $r-card;
background: rgba($subject-ink, .05);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer; touch-action: manipulation;
}
.rt__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rt__cv { display: block; width: 100%; height: 100%; }
.rt__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.rt__deg {
font-weight: 800; font-size: clamp(22px, 6vw, 32px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.rt__kick {
font-weight: 700; font-size: clamp(10px, 2.6vw, 12px);
color: $stage-orange; opacity: 0; transition: opacity .2s $ease-out;
}
.rt__count {
margin-top: $sp-2; font-weight: 800; font-size: clamp(20px, 5vw, 28px); line-height: 1;
color: $subject-ink; font-variant-numeric: tabular-nums;
}
.rt__unit { font-weight: 600; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.4; color: rgba($subject-ink, .82); }
.rt__tip { margin: $sp-2 0 0; font-size: clamp(9px, 2.4vw, 11px); line-height: 1.5; color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.rt { gap: $sp-1; }
.rt__field { width: 132px; height: 132px; }
.rt__deg { font-size: 18px; }
.rt__count { font-size: 16px; margin-top: $sp-1; }
}
// reduce — 루프가 안 돈다. 정적 한 장(네 방향 라벨)만 남는다.
@media (prefers-reduced-motion: reduce) {
.rt.is-still .rt__field { cursor: default !important; }
.rt.is-still .rt__kick { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rt {
--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);
--arc: #17141a;
--kickc: #ff4d1f;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 420px;
}
.rt__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.rt__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.rt__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(180px, 52vw, 236px);
height: clamp(180px, 52vw, 236px);
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
touch-action: manipulation;
}
.rt__field:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.rt__cv {
display: block;
width: 100%;
height: 100%;
}
.rt__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.rt__deg {
font-weight: 800;
font-size: clamp(22px, 6vw, 32px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.rt__kick {
font-weight: 700;
font-size: clamp(10px, 2.6vw, 12px);
color: #ff4d1f;
opacity: 0;
transition: opacity 0.2s ease-out;
}
.rt__count {
margin-top: 8px;
font-weight: 800;
font-size: clamp(20px, 5vw, 28px);
line-height: 1;
color: #17141a;
font-variant-numeric: tabular-nums;
}
.rt__unit {
font-weight: 600;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.4;
color: rgba(23, 20, 26, 0.82);
}
.rt__tip {
margin: 8px 0 0;
font-size: clamp(9px, 2.4vw, 11px);
line-height: 1.5;
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.rt {
gap: 4px;
}
.rt__field {
width: 132px;
height: 132px;
}
.rt__deg {
font-size: 18px;
}
.rt__count {
font-size: 16px;
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rt.is-still .rt__field {
cursor: default !important;
}
.rt.is-still .rt__kick {
opacity: 1;
}
}// 일곱 조각과 회전 — 조각을 좌표 배열로 두고 90도마다 (x,y)→(y,−x) 를 적용한다.
// 화면에는 그 사이를 메운다: 셀 좌표를 상자 중심 기준 극좌표로 바꿔 회전 행렬
// R(θ) = [cos −sin / sin cos] 를 매 프레임 곱해 셀이 원호를 그리며 돌게 한다.
// 돌린 결과가 상자(=벽) 밖으로 나가면 x 를 1cells 밀어 되돌린다 — 벽 킥.
(function () {
var root = document.getElementById('rt');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var degEl = document.getElementById('deg');
var kickEl = document.getElementById('kick');
var countEl = document.getElementById('count');
if (!root || !field || !cv || !degEl || !kickEl || !countEl) 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 ARC = v('--arc');
var KICKC = v('--kickc');
var BOXN = 6; // 6×6 상자 — 회전 무대이자 벽
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 DEGS = ['0 deg', '90 deg', '180 deg', '270 deg'];
// 회전 — (x,y) → (y,−x). n×n 상자 안으로 되돌리려면 y 에 n−1 을 더한다.
// 이 식과 중심 기준 90 deg 회전 행렬은 같은 결과를 낸다 — 아래 보간이 그 증명이다
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 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(3302);
// 상태 — 조각 · 회전 단계 · 보간 진행도 · 벽 킥
var ti = 0; // TYPES 첨자
var cells = SHAPES.T.slice(); // 현재 셀 좌표 (회전 적용된 판)
var baseN = 3; // 셀 좌표의 상자 크기 (BOX[T])
var offX = 1, offY = 2; // 6×6 상자 안 배치 오프셋
var theta = 0; // 보간 각도 (라디안)
var spin = 0; // 회전 수
var kickFlash = 0; // 킥 표시 남는 시간 (초)
var kickCount = 0;
var HOLD = 0.18, TURN = 0.55; // 정지 시간 · 회전 보간 시간 (초)
var phase = 'hold', phaseT = 0;
var from = null; // 회전 시작 시 셀 사본
var trail = []; // 잔상 {x, y, a} 상자 좌표 (연속)
function nextPiece() {
ti = (ti + 1) % TYPES.length;
if (ti === 0) { // 한 바퀴 돌면 시작 위치를 시드로 다시 섞는다
var j = Math.floor(rnd() * 4) + 1;
offX = j; offY = Math.floor(rnd() * 2) + 1;
} else { offX = 1 + Math.floor(rnd() * 2); offY = 1 + Math.floor(rnd() * 2); }
cells = SHAPES[TYPES[ti]].slice();
baseN = BOX[TYPES[ti]];
theta = 0;
}
function startTurn() {
from = cells.map(function (c) { return c.slice(); });
phase = 'turn'; phaseT = 0; theta = 0;
}
function finishTurn() {
var n = baseN;
var rotated = rotCW(cells, n);
// 벽 킥 — 돌린 결과가 상자 밖이면 1칸씩 밀어 되돌린다
var minX = 9, maxX = -9;
for (var i = 0; i < rotated.length; i++) {
var x = rotated[i][0] + offX;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
}
var kick = 0;
if (minX < 0) kick = -minX;
if (maxX > BOXN - 1) kick = BOXN - 1 - maxX;
if (kick !== 0) { offX += kick; kickFlash = 0.9; kickCount += 1; kickEl.textContent = 'Wall kick 1 cell'; }
cells = rotated;
theta = 0;
spin += 1;
degEl.textContent = DEGS[spin % 4];
countEl.textContent = String(spin);
phase = 'hold'; phaseT = 0;
if (spin % 8 === 0) nextPiece();
}
var W = 0, H = 0, dpr = 1, cell = 8, ox = 0, oy = 0;
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, W) / BOXN);
ox = Math.floor((W - cell * BOXN) / 2); oy = Math.floor((H - cell * BOXN) / 2);
}
// 보간 위치 — 셀을 상자 중심 기준 극좌표로 돌린다. R(θ) 행렬 곱 그 자체
function cellPos(c, th) {
var n = baseN, cx = (n - 1) / 2, cy = (n - 1) / 2;
var dx = c[0] - cx, dy = c[1] - cy;
var cos = Math.cos(th), sin = Math.sin(th);
return [cx + dx * cos - dy * sin + offX, cy + dx * sin + dy * cos + offY];
}
function paint(px, py, t, alpha) {
var r = Math.max(3, cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(ox + px * cell + 1.5, oy + py * cell + 1.5, cell - 3, cell - 3, r);
ctx.fillStyle = FILL[t]; ctx.fill();
ctx.lineWidth = Math.max(1.5, cell * 0.13);
ctx.strokeStyle = EDGE[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
var last = 0, raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function drawStatic() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
ctx.beginPath();
for (var x = 0; x <= BOXN; x++) { ctx.moveTo(ox + x * cell + .5, oy); ctx.lineTo(ox + x * cell + .5, oy + BOXN * cell); }
for (var y = 0; y <= BOXN; y++) { ctx.moveTo(ox, oy + y * cell + .5); ctx.lineTo(ox + BOXN * cell, oy + y * cell + .5); }
ctx.stroke();
for (var i = 0; i < cells.length; i++) paint(cells[i][0] + offX, cells[i][1] + offY, TYPES[ti]);
degEl.textContent = DEGS[spin % 4];
}
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
phaseT += dt;
if (kickFlash > 0) kickFlash -= dt;
var th = 0;
if (phase === 'hold') {
if (phaseT >= HOLD) startTurn();
} else {
var p = Math.min(1, phaseT / TURN);
var ease = 1 - Math.pow(1 - p, 3); // ease-out — 손목 감각
th = ease * Math.PI / 2;
if (p >= 1) finishTurn();
}
// 그리기
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
ctx.beginPath();
for (var x = 0; x <= BOXN; x++) { ctx.moveTo(ox + x * cell + .5, oy); ctx.lineTo(ox + x * cell + .5, oy + BOXN * cell); }
for (var y = 0; y <= BOXN; y++) { ctx.moveTo(ox, oy + y * cell + .5); ctx.lineTo(ox + BOXN * cell, oy + y * cell + .5); }
ctx.stroke();
// 회전 궤적 — 셀마다 이번 회전의 원호를 옅게 먼저 그린다
if (phase === 'turn' && from) {
ctx.strokeStyle = ARC; ctx.lineWidth = 2;
for (var i = 0; i < from.length; i++) {
var p0 = cellPos(from[i], 0), p1 = cellPos(from[i], Math.PI / 2);
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(ox + (p0[0] + .5) * cell, oy + (p0[1] + .5) * cell);
ctx.arc(ox + (offX + (baseN - 1) / 2 + .5) * cell, oy + (offY + (baseN - 1) / 2 + .5) * cell,
Math.hypot(p0[0] - offX - (baseN - 1) / 2, p0[1] - offY - (baseN - 1) / 2) * cell,
Math.atan2(p0[1] - offY - (baseN - 1) / 2, p0[0] - offX - (baseN - 1) / 2),
Math.atan2(p1[1] - offY - (baseN - 1) / 2, p1[0] - offX - (baseN - 1) / 2), false);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 잔상 — 최근 0.8초의 셀 위치를 사그라들게 깔아 회전의 지나간 자리를 남긴다
var cur = [];
for (var c = 0; c < cells.length; c++) {
var pos = (phase === 'turn' && from) ? cellPos(from[c], th) : [cells[c][0] + offX, cells[c][1] + offY];
cur.push(pos);
}
for (var t2 = 0; t2 < trail.length; t2++) {
var tr = trail[t2];
tr.a -= dt / 0.8;
if (tr.a <= 0) continue;
ctx.globalAlpha = tr.a * 0.60;
ctx.fillStyle = FILL[TYPES[ti]];
ctx.beginPath();
ctx.roundRect(ox + tr.x * cell + 3, oy + tr.y * cell + 3, cell - 6, cell - 6, Math.max(2, cell * 0.2));
ctx.fill();
}
ctx.globalAlpha = 1;
trail = trail.filter(function (tr2) { return tr2.a > 0; });
for (var k = 0; k < cur.length; k++) trail.push({ x: cur[k][0], y: cur[k][1], a: 0.8 });
// 벽 킥 화살표 — 킥이 일어난 직후 0.9초
if (kickFlash > 0) {
ctx.globalAlpha = Math.min(1, kickFlash / 0.9);
ctx.fillStyle = KICKC;
var bx = ox + (offX - 0.9) * cell, by = oy + (offY + baseN + 0.3) * cell;
if (by > H - cell * 0.8) by = H - cell * 0.8;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx + cell * 0.7, by - cell * 0.35); ctx.lineTo(bx + cell * 0.7, by + cell * 0.35);
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
kickEl.style.opacity = '1';
} else {
kickEl.style.opacity = '0';
}
// 회전 중심 — 상자 한가운데 작은 사각 마커. (x,y)→(y,−x) 의 기준점이다
ctx.fillStyle = KICKC;
ctx.globalAlpha = 0.8;
var cx3 = ox + (offX + (baseN - 1) / 2 + .5) * cell, cy3 = oy + (offY + (baseN - 1) / 2 + .5) * cell;
ctx.fillRect(cx3 - cell * 0.12, cy3 - cell * 0.12, cell * 0.24, cell * 0.24);
ctx.globalAlpha = 1;
// 조각 — 보간 각도로 그린다 (정지 때는 θ=0)
for (var m = 0; m < cur.length; m++) paint(cur[m][0], cur[m][1], TYPES[ti]);
raf = requestAnimationFrame(step);
}
// 키로 직접 회전 — keydown (← →)
document.addEventListener('keydown', function (e) {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
if (phase !== 'hold') return;
e.preventDefault();
startTurn();
});
field.addEventListener('pointerdown', function () {
if (phase === 'hold') startTurn();
});
window.addEventListener('resize', function () { fit(); if (reduce) drawStatic(); });
fit();
if (reduce) {
// 정지 한 장 — 네 방향을 작은 글자로만 보인다
drawStatic();
root.classList.add('is-still');
} else {
last = performance.now();
raf = requestAnimationFrame(step);
}
})();// TetrisPieceRotation — vanilla 판(run/330/demo/02-tetris-piece-rotation/index.html) 의 React 포트.
// 조각을 좌표 배열로 두고 90도마다 (x,y)→(y,−x) 를 적용한다. 화면에는 그 사이를 메운다:
// 셀 좌표를 상자 중심 기준 극좌표로 바꿔 회전 행렬 R(θ) = [cos −sin / sin cos] 를 매 프레임 곱해
// 셀이 원호를 그리며 돌게 한다. 돌린 결과가 상자(=벽) 밖으로 나가면 x 를 1칸 밀어 되돌린다 — 벽 킥.
// 정지 0.18초(HOLD) · 회전 보간 0.55초(TURN). 값·수치는 vanilla 와 같다.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 정적 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TetrisPieceRotation.module.scss";
export type TetrisPieceRotationProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type KeyEvt = { key: string; preventDefault(): void };
const BOXN = 6; // 6×6 상자 — 회전 무대이자 벽
const TYPES = ["I", "O", "T", "S", "Z", "J", "L"];
// SHAPES 는 원본을 오염시키지 않는다 — 현재 판은 slice 사본, 회전 시작 사본은 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 DEGS = ["0도", "90도", "180도", "270도"];
const HOLD = 0.18, TURN = 0.55; // 정지 시간 · 회전 보간 시간 (초)
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 ARC = "#17141a";
const KICKC = "#ff4d1f";
const HINT = "행렬 회전 x,y → y,−x";
const ARIA_FIELD = "회전 실험판";
const KICK_LABEL = "벽 킥 1칸";
const SPIN_UNIT = "번 회전";
const TIP = "← → 키로 직접 돌려 보세요";
// 시드 고정 난수 — 같은 판이 반복된다
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). n×n 상자 안으로 되돌리려면 y 에 n−1 을 더한다.
// 이 식과 중심 기준 90도 회전 행렬은 같은 결과를 낸다 — 아래 보간이 그 증명이다
function rotCW(cells: number[][], n: number): number[][] {
return cells.map((c) => [c[1], n - 1 - c[0]]);
}
type Trail = { x: number; y: number; a: number };
export default function TetrisPieceRotation({
duration = "2s",
easing = "ease-out",
color = "#17141a"
}: TetrisPieceRotationProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const degRef = useRef<HTMLBRElement | null>(null);
const kickRef = useRef<HTMLSpanElement | null>(null);
const countRef = useRef<HTMLBRElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, dpr: 1, cell: 8, ox: 0, oy: 0,
ti: 0, cells: SHAPES.T.slice() as number[][], baseN: 3, offX: 1, offY: 2,
theta: 0, spin: 0, kickFlash: 0, kickCount: 0,
phase: "hold" as "hold" | "turn", phaseT: 0,
from: null as number[][] | null, trail: [] as Trail[]
}) as Box<{
W: number; H: number; dpr: number; cell: number; ox: number; oy: number;
ti: number; cells: number[][]; baseN: number; offX: number; offY: number;
theta: number; spin: number; kickFlash: number; kickCount: number;
phase: "hold" | "turn"; phaseT: number;
from: number[][] | null; trail: Trail[];
}>;
const rndRef = useRef(mulberry32(3302)) as Box<() => number>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [kickOn, setKickOn] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function nextPiece(): void {
const st = stRef.current;
st.ti = (st.ti + 1) % TYPES.length;
if (st.ti === 0) { // 한 바퀴 돌면 시작 위치를 시드로 다시 섞는다
const j = Math.floor(rndRef.current() * 4) + 1;
st.offX = j; st.offY = Math.floor(rndRef.current() * 2) + 1;
} else { st.offX = 1 + Math.floor(rndRef.current() * 2); st.offY = 1 + Math.floor(rndRef.current() * 2); }
st.cells = SHAPES[TYPES[st.ti]].slice();
st.baseN = BOX[TYPES[st.ti]];
st.theta = 0;
}
function startTurn(): void {
const st = stRef.current;
st.from = st.cells.map((c) => c.slice());
st.phase = "turn"; st.phaseT = 0; st.theta = 0;
}
function finishTurn(): void {
const st = stRef.current;
const n = st.baseN;
const rotated = rotCW(st.cells, n);
// 벽 킥 — 돌린 결과가 상자 밖이면 1칸씩 밀어 되돌린다
let minX = 9, maxX = -9;
for (let i = 0; i < rotated.length; i++) {
const x = rotated[i][0] + st.offX;
if (x < minX) minX = x;
if (x > maxX) maxX = x;
}
let kick = 0;
if (minX < 0) kick = -minX;
if (maxX > BOXN - 1) kick = BOXN - 1 - maxX;
if (kick !== 0) {
st.offX += kick; st.kickFlash = 0.9; st.kickCount += 1;
setKickOn(true);
}
st.cells = rotated;
st.theta = 0;
st.spin += 1;
const deg = degRef.current, cnt = countRef.current;
if (deg) deg.textContent = DEGS[st.spin % 4];
if (cnt) cnt.textContent = String(st.spin);
st.phase = "hold"; st.phaseT = 0;
if (st.spin % 8 === 0) nextPiece();
}
// 고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, st.W) / BOXN);
st.ox = Math.floor((st.W - st.cell * BOXN) / 2);
st.oy = Math.floor((st.H - st.cell * BOXN) / 2);
ctxRef.current = ctx;
}
// 보간 위치 — 셀을 상자 중심 기준 극좌표로 돌린다. R(θ) 행렬 곱 그 자체
function cellPos(c: number[], th: number): number[] {
const st = stRef.current;
const n = st.baseN, cx = (n - 1) / 2, cy = (n - 1) / 2;
const dx = c[0] - cx, dy = c[1] - cy;
const cos = Math.cos(th), sin = Math.sin(th);
return [cx + dx * cos - dy * sin + st.offX, cy + dx * sin + dy * cos + st.offY];
}
function paint(px: number, py: number, t: string, alpha?: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const r = Math.max(3, st.cell * 0.22);
ctx.globalAlpha = alpha == null ? 1 : alpha;
ctx.beginPath();
ctx.roundRect(st.ox + px * st.cell + 1.5, st.oy + py * st.cell + 1.5, st.cell - 3, st.cell - 3, r);
ctx.fillStyle = FILL[t]; ctx.fill();
ctx.lineWidth = Math.max(1.5, st.cell * 0.13);
ctx.strokeStyle = EDGE[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
function drawStatic(): 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 <= BOXN; x++) { ctx.moveTo(st.ox + x * st.cell + 0.5, st.oy); ctx.lineTo(st.ox + x * st.cell + 0.5, st.oy + BOXN * st.cell); }
for (let y = 0; y <= BOXN; y++) { ctx.moveTo(st.ox, st.oy + y * st.cell + 0.5); ctx.lineTo(st.ox + BOXN * st.cell, st.oy + y * st.cell + 0.5); }
ctx.stroke();
for (let i = 0; i < st.cells.length; i++) paint(st.cells[i][0] + st.offX, st.cells[i][1] + st.offY, TYPES[st.ti]);
const deg = degRef.current;
if (deg) deg.textContent = DEGS[st.spin % 4];
}
function step(now: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const dt = Math.min(64, now - lastRef.current) / 1000;
lastRef.current = now;
st.phaseT += dt;
if (st.kickFlash > 0) {
st.kickFlash -= dt;
if (st.kickFlash <= 0) setKickOn(false);
}
let th = 0;
if (st.phase === "hold") {
if (st.phaseT >= HOLD) startTurn();
} else {
const p = Math.min(1, st.phaseT / TURN);
const ease = 1 - Math.pow(1 - p, 3); // ease-out — 손목 감각
th = ease * Math.PI / 2;
if (p >= 1) finishTurn();
}
// 그리기
ctx.clearRect(0, 0, st.W, st.H);
ctx.strokeStyle = LINE; ctx.lineWidth = 1;
ctx.beginPath();
for (let x = 0; x <= BOXN; x++) { ctx.moveTo(st.ox + x * st.cell + 0.5, st.oy); ctx.lineTo(st.ox + x * st.cell + 0.5, st.oy + BOXN * st.cell); }
for (let y = 0; y <= BOXN; y++) { ctx.moveTo(st.ox, st.oy + y * st.cell + 0.5); ctx.lineTo(st.ox + BOXN * st.cell, st.oy + y * st.cell + 0.5); }
ctx.stroke();
// 회전 궤적 — 셀마다 이번 회전의 원호를 옅게 먼저 그린다
if (st.phase === "turn" && st.from) {
ctx.strokeStyle = ARC; ctx.lineWidth = 2;
for (let i = 0; i < st.from.length; i++) {
const p0 = cellPos(st.from[i], 0), p1 = cellPos(st.from[i], Math.PI / 2);
ctx.globalAlpha = 0.5;
ctx.beginPath();
ctx.moveTo(st.ox + (p0[0] + 0.5) * st.cell, st.oy + (p0[1] + 0.5) * st.cell);
ctx.arc(st.ox + (st.offX + (st.baseN - 1) / 2 + 0.5) * st.cell, st.oy + (st.offY + (st.baseN - 1) / 2 + 0.5) * st.cell,
Math.hypot(p0[0] - st.offX - (st.baseN - 1) / 2, p0[1] - st.offY - (st.baseN - 1) / 2) * st.cell,
Math.atan2(p0[1] - st.offY - (st.baseN - 1) / 2, p0[0] - st.offX - (st.baseN - 1) / 2),
Math.atan2(p1[1] - st.offY - (st.baseN - 1) / 2, p1[0] - st.offX - (st.baseN - 1) / 2), false);
ctx.stroke();
}
ctx.globalAlpha = 1;
}
// 잔상 — 최근 0.8초의 셀 위치를 사그라들게 깔아 회전의 지나간 자리를 남긴다
const cur: number[][] = [];
for (let c = 0; c < st.cells.length; c++) {
const pos = (st.phase === "turn" && st.from) ? cellPos(st.from[c], th) : [st.cells[c][0] + st.offX, st.cells[c][1] + st.offY];
cur.push(pos);
}
for (let t2 = 0; t2 < st.trail.length; t2++) {
const tr = st.trail[t2];
tr.a -= dt / 0.8;
if (tr.a <= 0) continue;
ctx.globalAlpha = tr.a * 0.60;
ctx.fillStyle = FILL[TYPES[st.ti]];
ctx.beginPath();
ctx.roundRect(st.ox + tr.x * st.cell + 3, st.oy + tr.y * st.cell + 3, st.cell - 6, st.cell - 6, Math.max(2, st.cell * 0.2));
ctx.fill();
}
ctx.globalAlpha = 1;
st.trail = st.trail.filter((tr2) => tr2.a > 0);
for (let k = 0; k < cur.length; k++) st.trail.push({ x: cur[k][0], y: cur[k][1], a: 0.8 });
// 벽 킥 화살표 — 킥이 일어난 직후 0.9초
if (st.kickFlash > 0) {
ctx.globalAlpha = Math.min(1, st.kickFlash / 0.9);
ctx.fillStyle = KICKC;
let bx = st.ox + (st.offX - 0.9) * st.cell, by = st.oy + (st.offY + st.baseN + 0.3) * st.cell;
if (by > st.H - st.cell * 0.8) by = st.H - st.cell * 0.8;
ctx.beginPath();
ctx.moveTo(bx, by); ctx.lineTo(bx + st.cell * 0.7, by - st.cell * 0.35); ctx.lineTo(bx + st.cell * 0.7, by + st.cell * 0.35);
ctx.closePath(); ctx.fill();
ctx.globalAlpha = 1;
}
// 회전 중심 — 상자 한가운데 작은 사각 마커. (x,y)→(y,−x) 의 기준점이다
ctx.fillStyle = KICKC;
ctx.globalAlpha = 0.8;
const cx3 = st.ox + (st.offX + (st.baseN - 1) / 2 + 0.5) * st.cell, cy3 = st.oy + (st.offY + (st.baseN - 1) / 2 + 0.5) * st.cell;
ctx.fillRect(cx3 - st.cell * 0.12, cy3 - st.cell * 0.12, st.cell * 0.24, st.cell * 0.24);
ctx.globalAlpha = 1;
// 조각 — 보간 각도로 그린다 (정지 때는 θ=0)
for (let m = 0; m < cur.length; m++) paint(cur[m][0], cur[m][1], TYPES[st.ti]);
rafRef.current = requestAnimationFrame(step);
}
// 키로 직접 회전 — keydown (← →)
useEffect(() => {
function onKey(e: KeyboardEvent): void {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
const st = stRef.current;
if (st.phase !== "hold") return;
e.preventDefault();
startTurn();
if (reduced()) drawStatic();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
function onPointerDown(): void {
const st = stRef.current;
if (st.phase === "hold") {
startTurn();
if (reduced()) drawStatic();
}
}
useEffect(() => {
fit();
function onResize(): void {
fit();
if (reduced()) drawStatic();
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 네 방향을 작은 글자로만 보인다
drawStatic();
setStill(true);
} else {
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.rt + " " + styles.isStill : styles.rt;
const kickCls = kickOn ? styles.kick + " " + styles.kickOn : styles.kick;
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.deg} ref={degRef} aria-live="polite">0도</b>
<span className={kickCls} ref={kickRef}>{KICK_LABEL}</span>
<b className={styles.count} ref={countRef}>0</b>
<span className={styles.unit}>{SPIN_UNIT}</span>
<p className={styles.tip}>{TIP}</p>
</div>
</div>
</div>
</main>
);
}02 Seven pieces and rotation — shapes that turn by matrix multiply — Measured area 8.513% · 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


