09スキンとハイスコア — パステルパレットで仕上げる自分のテトリス
7ピースそれぞれにパステルパレットの色を割り当てて背景とのコントラストを整え、ハイスコアはlocalStorageに残して次の訪問に続きます。完成品をそのまま持ち帰る最終ステップ。
使いどころ完成ゲーム、テーマスイッチャー
fillRectlocalStorage

<main class="stage">
<div class="sk" id="sk">
<p class="sk__hint">ブロックパズル完成版 — スキンを選んでみてください</p>
<div class="sk__row">
<div class="sk__field" id="field" role="application" aria-label="ブロックパズル完成版" tabindex="0">
<canvas class="sk__cv" id="cv"></canvas>
</div>
<div class="sk__side">
<b class="sk__title">スキン</b>
<div class="sk__skins" id="skins"></div>
<div class="sk__stats">
<span class="sk__sLabel">スコア</span>
<b class="sk__score" id="score" aria-live="polite">0</b>
<span class="sk__sLabel">ハイスコア</span>
<b class="sk__best" id="best">0</b>
</div>
<p class="sk__gameover" id="go">ゲームオーバー</p>
<div class="sk__hist" id="hist" role="img" aria-label="直近のスコア推移"><canvas id="hcv"></canvas></div>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 스킨과 최고점(09) — 움직임은 전부 캔버스 rAF (077 방식). CSS 애니메이션 0개.
// 무대는 잉크(3번째) — 마지막 항목.
$color: $subject-sky;
@include stage($stage-ink);
.sk {
// 스킨 1 파스텔 — 기본. h 187/47/282/135/355/222/27, 채도 .34 (면 .74 / 테두리 .46)
--pastel-f-i: #a6ced3; --pastel-e-i: #429da9;
--pastel-f-o: #d3c9a6; --pastel-e-o: #a99342;
--pastel-f-t: #c6a6d3; --pastel-e-t: #8a42a9;
--pastel-f-s: #a6d3b1; --pastel-e-s: #42a95b;
--pastel-f-z: #d3a6aa; --pastel-e-z: #a9424a;
--pastel-f-j: #a6b4d3; --pastel-e-j: #4261a9;
--pastel-f-l: #d3baa6; --pastel-e-l: #a97042;
--pastel-line: #{rgba($subject-cream, .08)};
--pastel-flash: #{$stage-yellow};
// 스킨 2 야간 — 어두운 면에 밝은 테두리 (파스텔의 명도 반전)
--night-f-i: #2e4a4f; --night-e-i: #8fd0da;
--night-f-o: #4f482e; --night-e-o: #dcc98f;
--night-f-t: #472e4f; --night-e-t: #d0a6df;
--night-f-s: #2e4f3a; --night-e-s: #98e0af;
--night-f-z: #4f2e33; --night-e-z: #e0a6ae;
--night-f-j: #2e384f; --night-e-j: #a6bae0;
--night-f-l: #4f3c2e; --night-e-l: #e0c0a6;
--night-line: #{rgba($subject-cream, .06)};
--night-flash: #{$subject-sky};
// 스킨 3 모노 — 한 색조의 단계 (밝기만 다른 잉크 계열)
--mono-f-i: #b9c2cc; --mono-e-i: #5d6a78;
--mono-f-o: #a3aeb9; --mono-e-o: #4d5a68;
--mono-f-t: #cdd5dd; --mono-e-t: #707d8b;
--mono-f-s: #96a2ad; --mono-e-s: #414d5a;
--mono-f-z: #b0bac5; --mono-e-z: #57646f;
--mono-f-j: #8b97a3; --mono-e-j: #38434f;
--mono-f-l: #c2cad3; --mono-e-l: #67737f;
--mono-line: #{rgba($subject-cream, .10)};
--mono-flash: #{$subject-cream};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 440px;
}
.sk__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-cream;
}
.sk__row { display: flex; gap: $sp-2; align-items: stretch; }
.sk__field {
position: relative; overflow: hidden;
flex: 0 0 auto;
width: clamp(150px, 42vw, 208px);
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;
}
.sk__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.sk__cv { display: block; width: 100%; height: 100%; }
.sk__side {
flex: 1; min-width: 0;
display: flex; flex-direction: column; gap: $sp-1;
padding-top: $sp-1;
}
.sk__title {
font-weight: 800; font-size: clamp(11px, 2.8vw, 13px); line-height: 1;
color: $stage-yellow; letter-spacing: .04em;
}
.sk__skins { display: flex; gap: $sp-1; margin-top: $sp-1; }
.sk__skinBtn {
flex: 1;
appearance: none; border: 0;
padding: $sp-1 $sp-2;
border-radius: $r-control;
background: rgba($subject-cream, .08);
color: rgba($subject-cream, .82);
font: 600 clamp(9px, 2.4vw, 11px)/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba($subject-cream, .18);
}
.sk__skinBtn[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
box-shadow: inset 0 0 0 1px $subject-cream;
}
.sk__stats { margin-top: $sp-2; display: flex; flex-direction: column; gap: 0; }
.sk__sLabel { font-weight: 700; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-cream, .72); }
.sk__score {
font-weight: 800; font-size: clamp(20px, 5vw, 28px); line-height: 1.1;
color: $subject-cream; font-variant-numeric: tabular-nums;
}
.sk__best {
font-weight: 700; font-size: clamp(14px, 3.6vw, 18px); line-height: 1.2;
color: $stage-yellow; font-variant-numeric: tabular-nums;
}
.sk__gameover {
margin: $sp-1 0 0; font-weight: 800; font-size: clamp(10px, 2.6vw, 12px);
color: $stage-orange; visibility: hidden;
}
.sk__gameover.is-on { visibility: visible; }
.sk__hist {
position: relative; flex: 1; min-height: 0;
margin-top: $sp-1;
border-radius: $r-sm;
background: rgba($subject-cream, .05);
box-shadow: inset 0 0 0 1px rgba($subject-cream, .12);
overflow: hidden;
}
.sk__hist canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; }
@media (max-width: 340px) {
.sk { gap: $sp-1; }
.sk__field { width: 92px; height: 116px; }
.sk__score { font-size: 16px; }
.sk__best { font-size: 12px; }
.sk__stats { margin-top: $sp-1; }
}
// reduce — 정지 한 장 (스킨·점수·고스트가 보인다)
@media (prefers-reduced-motion: reduce) {
.sk.is-still .sk__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);
}
.sk {
--pastel-f-i: #a6ced3;
--pastel-e-i: #429da9;
--pastel-f-o: #d3c9a6;
--pastel-e-o: #a99342;
--pastel-f-t: #c6a6d3;
--pastel-e-t: #8a42a9;
--pastel-f-s: #a6d3b1;
--pastel-e-s: #42a95b;
--pastel-f-z: #d3a6aa;
--pastel-e-z: #a9424a;
--pastel-f-j: #a6b4d3;
--pastel-e-j: #4261a9;
--pastel-f-l: #d3baa6;
--pastel-e-l: #a97042;
--pastel-line: rgba(255, 247, 230, 0.08);
--pastel-flash: #ffd23f;
--night-f-i: #2e4a4f;
--night-e-i: #8fd0da;
--night-f-o: #4f482e;
--night-e-o: #dcc98f;
--night-f-t: #472e4f;
--night-e-t: #d0a6df;
--night-f-s: #2e4f3a;
--night-e-s: #98e0af;
--night-f-z: #4f2e33;
--night-e-z: #e0a6ae;
--night-f-j: #2e384f;
--night-e-j: #a6bae0;
--night-f-l: #4f3c2e;
--night-e-l: #e0c0a6;
--night-line: rgba(255, 247, 230, 0.06);
--night-flash: #6ec8ff;
--mono-f-i: #b9c2cc;
--mono-e-i: #5d6a78;
--mono-f-o: #a3aeb9;
--mono-e-o: #4d5a68;
--mono-f-t: #cdd5dd;
--mono-e-t: #707d8b;
--mono-f-s: #96a2ad;
--mono-e-s: #414d5a;
--mono-f-z: #b0bac5;
--mono-e-z: #57646f;
--mono-f-j: #8b97a3;
--mono-e-j: #38434f;
--mono-f-l: #c2cad3;
--mono-e-l: #67737f;
--mono-line: rgba(255, 247, 230, 0.1);
--mono-flash: #fff7e6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 440px;
}
.sk__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #fff7e6;
}
.sk__row {
display: flex;
gap: 8px;
align-items: stretch;
}
.sk__field {
position: relative;
overflow: hidden;
flex: 0 0 auto;
width: clamp(150px, 42vw, 208px);
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;
}
.sk__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.sk__cv {
display: block;
width: 100%;
height: 100%;
}
.sk__side {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 4px;
}
.sk__title {
font-weight: 800;
font-size: clamp(11px, 2.8vw, 13px);
line-height: 1;
color: #ffd23f;
letter-spacing: 0.04em;
}
.sk__skins {
display: flex;
gap: 4px;
margin-top: 4px;
}
.sk__skinBtn {
flex: 1;
appearance: none;
border: 0;
padding: 4px 8px;
border-radius: 12px;
background: rgba(255, 247, 230, 0.08);
color: rgba(255, 247, 230, 0.82);
font: 600 clamp(9px, 2.4vw, 11px)/1.4 -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
cursor: pointer;
box-shadow: inset 0 0 0 1px rgba(255, 247, 230, 0.18);
}
.sk__skinBtn[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
box-shadow: inset 0 0 0 1px #fff7e6;
}
.sk__stats {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 0;
}
.sk__sLabel {
font-weight: 700;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(255, 247, 230, 0.72);
}
.sk__score {
font-weight: 800;
font-size: clamp(20px, 5vw, 28px);
line-height: 1.1;
color: #fff7e6;
font-variant-numeric: tabular-nums;
}
.sk__best {
font-weight: 700;
font-size: clamp(14px, 3.6vw, 18px);
line-height: 1.2;
color: #ffd23f;
font-variant-numeric: tabular-nums;
}
.sk__gameover {
margin: 4px 0 0;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 12px);
color: #ff4d1f;
visibility: hidden;
}
.sk__gameover.is-on {
visibility: visible;
}
.sk__hist {
position: relative;
flex: 1;
min-height: 0;
margin-top: 4px;
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;
}
.sk__hist canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
@media (max-width: 340px) {
.sk {
gap: 4px;
}
.sk__field {
width: 92px;
height: 116px;
}
.sk__score {
font-size: 16px;
}
.sk__best {
font-size: 12px;
}
.sk__stats {
margin-top: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.sk.is-still .sk__field {
cursor: default !important;
}
}// 스킨과 ハイスコア — 지금까지 여덟 단계를 전부 합친 완성판. 일곱 조각의 빛깔 배정은
// スキン(세 가지 팔레트)으로 바꿀 수 있고, 최고점은 localStorage 에 남아 次 방문에
// 이어진다. ボード·회전·충돌·クリア·スコア·홀드는 앞 단계의 코드가 그대로 돈다.
(function () {
var root = document.getElementById('sk');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var skinsEl = document.getElementById('skins');
var scoreEl = document.getElementById('score');
var bestEl = document.getElementById('best');
var goEl = document.getElementById('go');
var hist = document.getElementById('hist');
var hcv = document.getElementById('hcv');
if (!root || !field || !cv || !skinsEl || !scoreEl || !bestEl || !goEl || !hist || !hcv) return;
var ctx = cv.getContext('2d');
var hctx = hcv.getContext('2d');
// スキン — CSS 변수로 정의된 세 팔레트 (パステル 기본 · ナイト · モノ)
var SKINS = ['pastel', 'night', 'mono'];
var SKIN_NAMES = { pastel: 'パステル', night: 'ナイト', mono: 'モノ' };
var skinIdx = 0;
var css = getComputedStyle(root);
function v(name) { return css.getPropertyValue(name).trim(); }
function readSkin(s) {
// 스킨별 변수 — --p-{スキン}-{종류}
var f = {}, e = {};
var types = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
for (var i = 0; i < types.length; i++) {
f[types[i]] = v('--' + s + '-f-' + types[i].toLowerCase());
e[types[i]] = v('--' + s + '-e-' + types[i].toLowerCase());
}
return { fill: f, edge: e, line: v('--' + s + '-line'), flash: v('--' + s + '-flash') };
}
var skin = null;
function applySkin() { skin = readSkin(SKINS[skinIdx]); }
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 };
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(3309);
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;
}
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)];
}
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(); }
// スコア·ハイスコア — localStorage
var BEST_KEY = 'p330-09-best';
var SKIN_KEY = 'p330-09-skin';
var score = 0, best = 0;
try {
best = parseInt(localStorage.getItem(BEST_KEY) || '0', 10) || 0;
var sv = parseInt(localStorage.getItem(SKIN_KEY) || '0', 10) || 0;
if (sv >= 0 && sv < SKINS.length) skinIdx = sv;
} catch (e) { /* 저장 실패는 무시 */ }
bestEl.textContent = String(best);
function saveBest() {
if (score > best) {
best = score;
bestEl.textContent = String(best);
try { localStorage.setItem(BEST_KEY, String(best)); } catch (e) { /* 무시 */ }
}
}
function saveSkin() {
try { localStorage.setItem(SKIN_KEY, String(skinIdx)); } catch (e) { /* 무시 */ }
}
var piece = null, flashRows = [], flashT = 0, over = false;
var scoreHistory = [];
function spawn() {
var t = nextType();
var plan = choose(grid, t);
if (!plan) { over = true; goEl.classList.add('is-on'); return; }
piece = { t: t, r: 0, cells: SHAPES[t].slice(), x: 3, y: -0.4, plan: plan };
}
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(); piece = null; return; } // 판이 넘으면 리셋하고 계속 — 데모는 멈추지 않는다
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) {
score += 100 * flashRows.length * flashRows.length;
scoreEl.textContent = String(score);
scoreHistory.push(score);
if (scoreHistory.length > 30) scoreHistory.shift();
saveBest();
flashT = 0.16;
}
piece = null;
}
function spliceRows() {
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 = [];
var filled = 0;
for (var y = 0; y < ROWS; y++) for (var x = 0; x < COLS; x++) if (grid[y][x]) 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)];
}
}
}
function restart() {
grid = emptyGrid();
score = 0; over = false;
scoreEl.textContent = '0';
goEl.classList.remove('is-on');
spawn();
}
// スキン 버튼
var skinBtns = [];
for (var sb = 0; sb < SKINS.length; sb++) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'sk__skinBtn';
b.textContent = SKIN_NAMES[SKINS[sb]];
b.setAttribute('aria-pressed', sb === skinIdx ? 'true' : 'false');
(function (idx) {
b.addEventListener('click', function () {
skinIdx = idx;
for (var k = 0; k < skinBtns.length; k++) skinBtns[k].setAttribute('aria-pressed', k === idx ? 'true' : 'false');
applySkin();
saveSkin();
});
})(sb);
skinsEl.appendChild(b);
skinBtns.push(b);
}
applySkin();
var W = 0, H = 0, HW2 = 0, HH2 = 0, dpr = 1, cell = 8, ox = 0, oy = 0;
function fit() {
W = field.clientWidth; H = field.clientHeight;
HW2 = hist.clientWidth; HH2 = hist.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);
hcv.width = Math.round(HW2 * dpr); hcv.height = Math.round(HH2 * dpr);
hctx.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(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 = skin.fill[t]; ctx.fill();
ctx.lineWidth = Math.max(1, cell * 0.14);
ctx.strokeStyle = skin.edge[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
var last = 0, raf = 0;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function draw() {
ctx.clearRect(0, 0, W, H);
ctx.strokeStyle = skin.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 (flashT > 0) {
ctx.globalAlpha = Math.min(1, flashT / 0.16) * 0.85;
ctx.fillStyle = skin.flash;
for (var f = 0; f < flashRows.length; f++) ctx.fillRect(ox, oy + flashRows[f] * cell, cell * COLS, cell);
ctx.globalAlpha = 1;
}
if (piece) {
// 고스트
var px = Math.round(piece.x);
var lr = landingRow(grid, piece.cells, px);
ctx.setLineDash([4, 3]);
ctx.lineWidth = 2;
ctx.strokeStyle = skin.edge[piece.t];
for (var g = 0; g < piece.cells.length; g++) {
var gyy = lr + piece.cells[g][1];
if (gyy >= 0 && gyy !== Math.floor(piece.y) + piece.cells[g][1]) {
ctx.beginPath();
ctx.roundRect(ox + (px + piece.cells[g][0]) * cell + 2.5, oy + gyy * cell + 2.5, cell - 5, cell - 5, Math.max(2, cell * 0.22));
ctx.stroke();
}
}
ctx.setLineDash([]);
for (var i = 0; i < piece.cells.length; i++) {
var cy = piece.y + piece.cells[i][1];
if (cy >= 0) paintCell(px + piece.cells[i][0], Math.round(cy), piece.t);
}
}
}
function drawHist() {
if (!HW2 || !HH2) return;
hctx.clearRect(0, 0, HW2, HH2);
if (scoreHistory.length < 2) return;
var max = scoreHistory[scoreHistory.length - 1] || 1;
var bw = HW2 / (scoreHistory.length - 1);
hctx.beginPath();
for (var i = 0; i < scoreHistory.length; i++) {
var yy = HH2 - (scoreHistory[i] / max) * (HH2 - 2) - 1;
if (i === 0) hctx.moveTo(0, yy); else hctx.lineTo(i * bw, yy);
}
hctx.strokeStyle = skin.flash;
hctx.lineWidth = 2;
hctx.stroke();
}
function step(now) {
var dt = Math.min(64, now - last) / 1000; last = now;
if (over) { draw(); drawHist(); raf = requestAnimationFrame(step); return; }
if (flashT > 0) {
flashT -= dt;
if (flashT <= 0) spliceRows();
} else if (!piece) {
spawn();
} else {
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]);
var px3 = Math.round(piece.x);
for (var k = 0; k < KICKS.length && hits(grid, rc, Math.floor(piece.y), px3 + KICKS[k]); k++) { /* 킥 탐색 */ }
if (k < KICKS.length) { piece.cells = rc; piece.x = px3 + KICKS[k]; 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 ? 34 : 5) * dt;
piece.y = ny >= target ? target : ny;
if (piece.y === target) lock();
}
draw();
drawHist();
raf = requestAnimationFrame(step);
}
field.addEventListener('pointerdown', function () {
if (over) { restart(); return; }
if (piece) {
piece.x = piece.plan.c;
piece.cells = shapeRot(piece.t, piece.plan.r);
piece.r = piece.plan.r;
piece.y = landingRow(grid, piece.cells, Math.round(piece.x));
lock();
}
});
window.addEventListener('resize', function () { fit(); if (reduce) { draw(); drawHist(); } });
fit();
if (reduce) {
// 정지 한 장 — スキン·スコア·고스트가 다 보이는 상태
score = 1900; scoreEl.textContent = String(score);
scoreHistory = [100, 500, 500, 1400, 1900];
piece = { t: 'L', r: 0, cells: SHAPES.L.slice(), x: 4, y: 4.0, plan: { r: 0, c: 4 } };
draw(); drawHist();
root.classList.add('is-still');
} else {
spawn();
last = performance.now();
raf = requestAnimationFrame(step);
}
})();// TetrisPortraitSkin — vanilla 판(run/330/demo/09-tetris-portrait-skin/index.html) 의 React 포트.
// 지금까지 여덟 단계를 전부 합친 완성판. 일곱 조각의 빛깔 배정은 스킨(세 가지 팔레트)으로
// 바꿀 수 있고, 최고점은 localStorage 에 남아 다음 방문에 이어진다.
// 보드·회전·충돌·클리어·점수(100×줄²)·고스트는 앞 단계의 코드가 그대로 돈다.
// 정렬 전 5칸/s · 정렬 후 34칸/s · 미끄러짐 13칸/s · 클리어 섬광 0.16초 · 벽 킥 0,±1,±2.
// 값·수치는 vanilla 와 같다. SHAPES 원본은 오염시키지 않는다 — 회전 판은 map(slice) 사본.
// 움직임은 전부 캔버스 rAF 루프가 그리므로 CSS 애니메이션이 없다.
// reduce 에서는 루프를 돌리지 않고 스킨·점수·고스트가 다 보이는 한 장만 남긴다(.isStill).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TetrisPortraitSkin.module.scss";
export type TetrisPortraitSkinProps = {
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 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 FLASH_S = 0.16; // 클리어 섬광 (초)
const FALL_ALIGNED = 34, DRIFT = 5, SLIDE = 13; // 정렬 후 34칸/s · 정렬 전 5칸/s · 미끄러짐 13칸/s
// 스킨 — 세 팔레트 (파스텔 기본 · 야간 · 모노). 값은 vanilla CSS 변수와 동일
type Skin = { fill: Record<string, string>; edge: Record<string, string>; line: string; flash: string };
const SKINS: Skin[] = [
{
fill: { I: "#a6ced3", O: "#d3c9a6", T: "#c6a6d3", S: "#a6d3b1", Z: "#d3a6aa", J: "#a6b4d3", L: "#d3baa6" },
edge: { I: "#429da9", O: "#a99342", T: "#8a42a9", S: "#42a95b", Z: "#a9424a", J: "#4261a9", L: "#a97042" },
line: "rgba(255,247,230,.08)", flash: "#ffd23f"
},
{
fill: { I: "#2e4a4f", O: "#4f482e", T: "#472e4f", S: "#2e4f3a", Z: "#4f2e33", J: "#2e384f", L: "#4f3c2e" },
edge: { I: "#8fd0da", O: "#dcc98f", T: "#d0a6df", S: "#98e0af", Z: "#e0a6ae", J: "#a6bae0", L: "#e0c0a6" },
line: "rgba(255,247,230,.06)", flash: "#6ec8ff"
},
{
fill: { I: "#b9c2cc", O: "#a3aeb9", T: "#cdd5dd", S: "#96a2ad", Z: "#b0bac5", J: "#8b97a3", L: "#c2cad3" },
edge: { I: "#5d6a78", O: "#4d5a68", T: "#707d8b", S: "#414d5a", Z: "#57646f", J: "#38434f", L: "#67737f" },
line: "rgba(255,247,230,.10)", flash: "#fff7e6"
}
];
const SKIN_NAMES = ["파스텔", "야간", "모노"];
const HINT = "블록 퍼즐 완성판 — 스킨을 골라 보세요";
const ARIA_FIELD = "블록 퍼즐 완성판";
const ARIA_HIST = "최근 점수 흐름";
const L_SKIN = "스킨";
const L_SCORE = "점수";
const L_BEST = "최고점";
const GAMEOVER = "게임 끝";
// 시드 고정 난수 — 같은 판이 반복된다
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 } };
export default function TetrisPortraitSkin({
duration = "2s",
easing = "ease-out",
color = "#6ec8ff"
}: TetrisPortraitSkinProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const scoreRef = useRef<HTMLElement | null>(null);
const bestRef = useRef<HTMLElement | null>(null);
const histRef = useRef<HTMLDivElement | null>(null);
const hcvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const hctxRef = useRef<CanvasRenderingContext2D | null>(null);
const stRef = useRef({
W: 0, H: 0, HW: 0, HH: 0, dpr: 1, cell: 8, ox: 0, oy: 0,
grid: [] as Grid, bag: [] as string[], piece: null as Piece | null,
skinIdx: 0, score: 0, best: 0, over: false,
flashRows: [] as number[], flashT: 0, scoreHistory: [] as number[]
}) as Box<{
W: number; H: number; HW: number; HH: number; dpr: number; cell: number; ox: number; oy: number;
grid: Grid; bag: string[]; piece: Piece | null;
skinIdx: number; score: number; best: number; over: boolean;
flashRows: number[]; flashT: number; scoreHistory: number[];
}>;
const rndRef = useRef(mulberry32(3309)) as Box<() => number>;
const lastRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [still, setStill] = useState(false);
const [skinIdx, setSkinIdx] = useState(0);
const [gameover, setGameover] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function skin(): Skin {
return SKINS[stRef.current.skinIdx];
}
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;
}
}
function nextType(): string {
const st = stRef.current;
if (!st.bag.length) refill();
return st.bag.pop() as string;
}
// 점수·최고점 — localStorage (저장 실패는 무시)
const BEST_KEY = "p330-09-best";
const SKIN_KEY = "p330-09-skin";
function saveBest(): void {
const st = stRef.current;
if (st.score > st.best) {
st.best = st.score;
const out = bestRef.current;
if (out) out.textContent = String(st.best);
try { localStorage.setItem(BEST_KEY, String(st.best)); } catch (e) { /* 무시 */ }
}
}
function saveSkin(): void {
try { localStorage.setItem(SKIN_KEY, String(stRef.current.skinIdx)); } catch (e) { /* 무시 */ }
}
function spawn(): void {
const st = stRef.current;
const t = nextType();
const plan = choose(st.grid, t);
if (!plan) { st.over = true; setGameover(true); return; }
st.piece = { t: t, r: 0, cells: SHAPES[t].slice(), x: 3, y: -0.4, plan: plan };
}
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(); st.piece = null; return; } // 판이 넘으면 리셋하고 계속 — 데모는 멈추지 않는다
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.score += 100 * st.flashRows.length * st.flashRows.length;
const out = scoreRef.current;
if (out) out.textContent = String(st.score);
st.scoreHistory.push(st.score);
if (st.scoreHistory.length > 30) st.scoreHistory.shift();
saveBest();
st.flashT = FLASH_S;
}
st.piece = null;
}
function spliceRows(): 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 = [];
let filled = 0;
for (let y = 0; y < ROWS; y++) for (let x = 0; x < COLS; x++) if (st.grid[y][x]) 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)];
}
}
}
function restart(): void {
const st = stRef.current;
st.grid = emptyGrid();
st.score = 0; st.over = false;
const out = scoreRef.current;
if (out) out.textContent = "0";
setGameover(false);
spawn();
}
// 고DPI 대응 — 배킹 스토어를 devicePixelRatio 배로 키우고 좌표계는 CSS 픽셀 그대로 쓴다
function fit(): void {
const st = stRef.current;
const field = fieldRef.current, cv = cvRef.current, hist = histRef.current, hcv = hcvRef.current;
if (!field || !cv || !hist || !hcv) return;
const ctx = cv.getContext("2d"), hctx = hcv.getContext("2d");
if (!ctx || !hctx) return;
st.W = field.clientWidth; st.H = field.clientHeight;
st.HW = hist.clientWidth; st.HH = hist.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);
hcv.width = Math.round(st.HW * st.dpr); hcv.height = Math.round(st.HH * st.dpr);
hctx.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;
hctxRef.current = hctx;
}
function paintCell(gx: number, gy: number, t: string, alpha?: number): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const sk = skin();
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 = sk.fill[t]; ctx.fill();
ctx.lineWidth = Math.max(1, st.cell * 0.14);
ctx.strokeStyle = sk.edge[t]; ctx.stroke();
ctx.globalAlpha = 1;
}
function draw(): void {
const st = stRef.current;
const ctx = ctxRef.current;
if (!ctx) return;
const sk = skin();
ctx.clearRect(0, 0, st.W, st.H);
ctx.strokeStyle = sk.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.flashT > 0) {
ctx.globalAlpha = Math.min(1, st.flashT / FLASH_S) * 0.85;
ctx.fillStyle = sk.flash;
for (let f = 0; f < st.flashRows.length; f++) ctx.fillRect(st.ox, st.oy + st.flashRows[f] * st.cell, st.cell * COLS, st.cell);
ctx.globalAlpha = 1;
}
if (st.piece) {
const p = st.piece;
// 고스트 — 착지점 점선 윤곽
const px = Math.round(p.x);
const lr = landingRow(st.grid, p.cells, px);
ctx.setLineDash([4, 3]);
ctx.lineWidth = 2;
ctx.strokeStyle = sk.edge[p.t];
for (let g = 0; g < p.cells.length; g++) {
const gyy = lr + p.cells[g][1];
if (gyy >= 0 && gyy !== Math.floor(p.y) + p.cells[g][1]) {
ctx.beginPath();
ctx.roundRect(st.ox + (px + p.cells[g][0]) * st.cell + 2.5, st.oy + gyy * st.cell + 2.5, st.cell - 5, st.cell - 5, Math.max(2, st.cell * 0.22));
ctx.stroke();
}
}
ctx.setLineDash([]);
for (let i = 0; i < p.cells.length; i++) {
const cy = p.y + p.cells[i][1];
if (cy >= 0) paintCell(px + p.cells[i][0], Math.round(cy), p.t);
}
}
}
function drawHist(): void {
const st = stRef.current;
const hctx = hctxRef.current;
if (!hctx || !st.HW || !st.HH) return;
hctx.clearRect(0, 0, st.HW, st.HH);
if (st.scoreHistory.length < 2) return;
const max = st.scoreHistory[st.scoreHistory.length - 1] || 1;
const bw = st.HW / (st.scoreHistory.length - 1);
hctx.beginPath();
for (let i = 0; i < st.scoreHistory.length; i++) {
const yy = st.HH - (st.scoreHistory[i] / max) * (st.HH - 2) - 1;
if (i === 0) hctx.moveTo(0, yy); else hctx.lineTo(i * bw, yy);
}
hctx.strokeStyle = skin().flash;
hctx.lineWidth = 2;
hctx.stroke();
}
function step(now: number): void {
const st = stRef.current;
const dt = Math.min(64, now - lastRef.current) / 1000;
lastRef.current = now;
if (!st.over) {
if (st.flashT > 0) {
st.flashT -= dt;
if (st.flashT <= 0) spliceRows();
} else if (!st.piece) {
spawn();
} else {
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]);
const px3 = Math.round(p.x);
let k = 0;
for (; k < KICKS.length && hits(st.grid, rc, Math.floor(p.y), px3 + KICKS[k]); k++) { /* 킥 탐색 */ }
if (k < KICKS.length) { p.cells = rc; p.x = px3 + KICKS[k]; 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();
}
}
draw();
drawHist();
rafRef.current = requestAnimationFrame(step);
}
// 누르면 — 끝났으면 다시 시작, 아니면 하드드롭
function onPointerDown(): void {
const st = stRef.current;
if (st.over) { restart(); return; }
if (st.piece) {
const p = st.piece;
p.x = p.plan.c;
p.cells = shapeRot(p.t, p.plan.r);
p.r = p.plan.r;
p.y = landingRow(st.grid, p.cells, Math.round(p.x));
lock();
if (reduced()) { draw(); drawHist(); }
} else if (reduced()) { draw(); drawHist(); }
}
// 스킨 버튼 — 팔레트를 바꾸고 선택을 저장한다
function onSkin(idx: number): void {
stRef.current.skinIdx = idx;
setSkinIdx(idx);
saveSkin();
if (reduced()) { draw(); drawHist(); }
else { draw(); drawHist(); }
}
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();
// 저장된 최고점·스킨 불러오기
try {
st.best = parseInt(localStorage.getItem(BEST_KEY) || "0", 10) || 0;
const sv = parseInt(localStorage.getItem(SKIN_KEY) || "0", 10) || 0;
if (sv >= 0 && sv < SKINS.length) st.skinIdx = sv;
} catch (e) { /* 저장 실패는 무시 */ }
const bestOut = bestRef.current;
if (bestOut) bestOut.textContent = String(st.best);
setSkinIdx(st.skinIdx);
fit();
function onResize(): void {
fit();
if (reduced()) { draw(); drawHist(); }
}
window.addEventListener("resize", onResize);
if (reduced()) {
// 정지 한 장 — 스킨·점수·고스트가 다 보이는 상태
st.score = 1900;
const scOut = scoreRef.current;
if (scOut) scOut.textContent = String(st.score);
st.scoreHistory = [100, 500, 500, 1400, 1900];
st.piece = { t: "L", r: 0, cells: SHAPES.L.slice(), x: 4, y: 4.0, plan: { r: 0, c: 4 } };
draw(); drawHist();
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.sk + " " + styles.isStill : styles.sk;
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.title}>{L_SKIN}</b>
<div className={styles.skins}>
{SKIN_NAMES.map((name, i) => (
<button
key={name}
type="button"
className={styles.skinBtn}
aria-pressed={i === skinIdx ? "true" : "false"}
onClick={function () { onSkin(i); }}
>{name}</button>
))}
</div>
<div className={styles.stats}>
<span className={styles.sLabel}>{L_SCORE}</span>
<b className={styles.score} ref={scoreRef} aria-live="polite">0</b>
<span className={styles.sLabel}>{L_BEST}</span>
<b className={styles.best} ref={bestRef}>0</b>
</div>
<p className={gameover ? styles.gameover + " " + styles.isOn : styles.gameover}>{GAMEOVER}</p>
<div className={styles.hist} ref={histRef} role="img" aria-label={ARIA_HIST}>
<canvas ref={hcvRef}></canvas>
</div>
</div>
</div>
</div>
</main>
);
}09 スキンとハイスコア — パステルパレットで仕上げる自分のテトリス — 計測 累積面積 12.078% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · テトリス HTML コード — キャンバスで組む9ステップ
このデモが属する記事
テトリス HTML コード — キャンバスで組む9ステップ1枚のキャンバスに落ち物パズルを組むテトリス html の実装は、盤面・回転・消去・スコアの4つの計算に分解できます。ブロックが落ちて積まれ、一行消えるまでを回転行列と衝突判定で計算して描きます。9ステップで組み立てるテトリス — ライブラ この記事のデモをすべて見る


