09A Hero Scene Where the 2048 Board Plays Itself
The auto player of 07 picks a direction, draws a simulated swipe and judges it as in 03, then plays one move at a time; tiles slide with the stretch of 02, merge by the rule of 01, shift into the colors of 05 and throw the fragments of 06, and when the board locks up or a round of 16 moves is done it collapses as in 08 and starts over.
Best forHero section of a number learning app landing page
ROUND_MOVESSWIPE_MSMOVE_GAP_09

<main class="stage">
<div class="cf is-demo" id="cf">
<div class="cf__board" data-role="board" tabindex="0" role="application" aria-label="2048 board: click it, then use the arrow keys or swipe to move">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" aria-hidden="true"></canvas>
</div>
<p class="cf__sr">A 2048 board works out its next move and swipes, tiles stretch as they slide, merge, change color and throw fragments, and when it locks up it collapses and starts over</p>
<p class="cf__status" data-role="status" aria-live="off">Setting up the board</p>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $subject-cream;
}
// 판 상자 — 포커스를 받으면 안쪽 테두리로 보인다. 포커스가 있을 때만 쓸기가 페이지 스크롤 대신 판으로 간다
.cf__board {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
}
// 바깥 .cf 가 넘침을 자르므로 테두리는 안쪽으로 들인다
.cf__board:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.cf__board:focus {
touch-action: none;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 방금 둔 수(방향·합침·점수)를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 낭독용 장면 설명 한 줄 — 화면에서는 숨기고 낭독기에만 읽힌다(판 위 숫자는 캔버스라 aria-hidden)
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 10px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 정지 판 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { transform: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cf {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #fff7e6;
}
.cf__board {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
}
.cf__board:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.cf__board:focus {
touch-action: none;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
bottom: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.cf__sr {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function deg(rad) { return rad * 180 / Math.PI; }
function makeSpring(x) { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s, stiffness, damping, dt) {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s, x) { s.x = x; s.v = 0; s.to = x; }
function roughWidth(ch, px, weight) {
const wide = ch.charCodeAt(0) >= 0x1100;
const base = ch === ' ' ? 0.28 : (wide ? 0.95 : 0.6);
return px * base * (weight >= 700 ? 1.06 : 1);
}
// 글꼴이 다 온 뒤 잰 폭
let glyphWidth = roughWidth;
// 문자열 폭 = 글자 하나씩 잰 폭의 합
function textWidth(s, px, weight, m) {
let w = 0;
for (const ch of Array.from(s)) {
w += m(ch, px, weight);
}
return w;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 자동 시연이 수를 두는가 — 사람이 판에서 방향키·쓸기(04 는 백스페이스도)로 처음 손대면 꺼지고, 그 뒤로는 사람 수만 둔다
let autoOn = true;
// 파스텔 팔레트 — 크림 바탕 Up 라벤더·민트·버터·산호. 글자는 진한 쪽으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_DIM = [236, 228, 214];
const BOARD_BG = [214, 204, 190];
const CELL_BG = [232, 224, 212];
const INK = [44, 38, 58];
const INK_SOFT = [104, 96, 118];
const CREAM = [255, 251, 242];
const LAVENDER = [196, 182, 238];
const MINT = [164, 218, 196];
const BUTTER = [246, 224, 150];
const CORAL = [244, 170, 152];
const SKY = [170, 206, 238];
const PINK = [244, 190, 212];
const MINT_DEEP = [36, 116, 90];
const CORAL_DEEP = [178, 66, 52];
const VIOLET_DEEP = [84, 58, 160];
function mix(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 판 규칙 ──────────────────────────────────────────────────
// 판은 칸 16개. 칸 번호 = 행 × 4 + 열, 값 0 = empty
const SIZE = 4;
const CELLS = SIZE * SIZE;
// 방향 번호 — 0 Left · 1 Up · 2 Right · 3 Down
const DIR_LEFT = 0;
const DIR_UP = 1;
const DIR_RIGHT = 2;
const DIR_DOWN = 3;
const DIR_NAMES = ['Left', 'Up', 'Right', 'Down'];
// 방향마다 판을 시계 방향으로 몇 번 돌리면 그 방향의 벽이 왼쪽에 오는가 — 위는 세 번, 오른쪽은 두 번, 아래는 한 번
const TURNS = [0, 3, 2, 1];
// 새 타일이 4 일 확률 — 나머지는 2
const FOUR_CHANCE = 0.1;
// 칸 번호 표를 시계 방향으로 한 번 돌린다 — 새 (행 r, 열 c) 자리에 원래 (행 3 − c, 열 r) 칸이 온다
function rotateCW(g) {
const out = [];
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) {
out.push(g[(SIZE - 1 - c) * SIZE + r]);
}
}
return out;
}
// 방향 dir 로 밀 때 읽는 순서 — order[r × 4 + k] = 돌린 판의 행 r, Left 벽에서 k 번째 자리에 오는 원래 칸 번호
function pushOrder(dir) {
let g = [];
for (let i = 0; i < CELLS; i += 1) {
g.push(i);
}
for (let t = 0; t < TURNS[dir]; t += 1) {
g = rotateCW(g);
}
return g;
}
const ORDERS = [0, 1, 2, 3].map(pushOrder);
function compressLine(line, lock) {
const out = [];
for (const t of line) {
if (!t) {
continue;
}
const last = out.length ? out[out.length - 1] : null;
if (last && last.v === t.v && !(lock && last.merged)) {
last.v *= 2;
last.ids.push(t.id);
last.merged = true;
}
else {
out.push({ ids: [t.id], v: t.v, merged: false });
}
}
return out;
}
function gridOf(tiles) {
const g = [];
for (let i = 0; i < CELLS; i += 1) {
g.push(null);
}
for (const t of tiles) {
g[t.cell] = t;
}
return g;
}
function valuesOf(tiles) {
const v = [];
for (let i = 0; i < CELLS; i += 1) {
v.push(0);
}
for (const t of tiles) {
v[t.cell] = t.v;
}
return v;
}
function copyTiles(tiles) { return tiles.map(function (t) { return { id: t.id, v: t.v, cell: t.cell }; }); }
// 한 수 밀기 — 판을 돌려 그 방향을 왼쪽으로 만든 순서표로 네 줄을 읽고, 줄마다 compressLine 한 결과를 원래 칸 번호로 되돌려 적는다
function slideBoard(g, dir, lock) {
const cells = gridOf(g.tiles);
const order = ORDERS[dir];
const byId = {};
for (const t of g.tiles) {
byId[t.id] = t;
}
const slides = [];
const merges = [];
const after = [];
let gain = 0;
for (let r = 0; r < SIZE; r += 1) {
const line = [];
for (let k = 0; k < SIZE; k += 1) {
line.push(cells[order[r * SIZE + k]]);
}
compressLine(line, lock).forEach(function (o, slot) {
const to = order[r * SIZE + slot];
for (const id of o.ids) {
slides.push({ id: id, v: byId[id].v, from: byId[id].cell, to: to });
}
if (o.merged) {
const nid = g.nextId;
g.nextId += 1;
merges.push({ id: nid, v: o.v, cell: to, parents: o.ids.slice() });
after.push({ id: nid, v: o.v, cell: to });
gain += o.v;
}
else {
after.push({ id: o.ids[0], v: o.v, cell: to });
}
});
}
const moved = merges.length > 0 || slides.some(function (s) { return s.from !== s.to; });
return { dir: dir, moved: moved, slides: slides, merges: merges, gain: gain, before: copyTiles(g.tiles), after: after, spawn: null };
}
function emptyCells(tiles) {
const used = [];
for (let i = 0; i < CELLS; i += 1) {
used.push(false);
}
for (const t of tiles) {
used[t.cell] = true;
}
const out = [];
for (let i = 0; i < CELLS; i += 1) {
if (!used[i]) {
out.push(i);
}
}
return out;
}
// empty 하나를 시드 난수로 뽑아 2 또는 4 를 놓는다 — 4 는 FOUR_CHANCE 의 확률
function spawnTile(g) {
const empty = emptyCells(g.tiles);
if (!empty.length) {
return null;
}
const cell = empty[Math.floor(g.rng() * empty.length)];
const v = g.rng() < FOUR_CHANCE ? 4 : 2;
const t = { id: g.nextId, v: v, cell: cell };
g.nextId += 1;
g.tiles.push(t);
return t;
}
// 한 수 두기 — 움직였을 때만 판을 바꾸고 새 타일을 하나 돋운다. 안 움직이는 방향은 수가 아니다(새 타일도 없음)
function playMove(g, dir, lock) {
const mv = slideBoard(g, dir, lock);
if (!mv.moved) {
return mv;
}
g.tiles = mv.after.map(function (t) { return { id: t.id, v: t.v, cell: t.cell }; });
g.score += mv.gain;
mv.spawn = spawnTile(g);
return mv;
}
function makeGame(values, seed) {
const tiles = [];
let id = 1;
values.forEach(function (v, cell) {
if (v) {
tiles.push({ id: id, v: v, cell: cell });
id += 1;
}
});
return { tiles: tiles, nextId: id, score: 0, rng: makeRng(seed) };
}
function equalPairs(vals) {
const out = [];
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) {
const i = r * SIZE + c;
if (!vals[i]) {
continue;
}
if (c + 1 < SIZE && vals[i + 1] === vals[i]) {
out.push({ a: i, b: i + 1 });
}
if (r + 1 < SIZE && vals[i + SIZE] === vals[i]) {
out.push({ a: i, b: i + SIZE });
}
}
}
return out;
}
function countEmpty(vals) { let n = 0; for (const v of vals) {
if (!v) {
n += 1;
}
} return n; }
function isStuck(vals) { return countEmpty(vals) === 0 && equalPairs(vals).length === 0; }
function rowOf(cell) { return Math.floor(cell / SIZE); }
function colOf(cell) { return cell % SIZE; }
// ── 쓸기 방향 판정 ───────────────────────────────────────────
// 시작점·끝점 차(dx, dy, 화면 px — y 는 아래로)와 걸린 시간(ms)으로 네 부채꼴 중 하나를 고르거나 버린다
// 이 길이보다 짧은 궤적은 버린다 — 다만 FLICK_SPEED_PX_S 보다 빠르면 짧아도 받아들인다(빠른 튕김)
const SWIPE_MIN_PX = 24;
const FLICK_SPEED_PX_S = 450;
// 튕김이라도 이보다 짧으면 손가락이 떨린 것으로 본다
const FLICK_MIN_PX = 8;
// 부채꼴 경계(45°·135°·225°·315°) 양쪽 이 각도 안은 애매한 대각선 — 버린다
const DEAD_ZONE_DEG = 10;
const VERDICT_OK = 'ok';
const VERDICT_SHORT = 'short';
const VERDICT_DIAGONAL = 'diagonal';
const VERDICT_FLICK = 'flick';
// 화면 각(0° Right, 90° Down)을 부채꼴로 — 가장 가까운 90° 배수가 방향
const ANGLE_TO_DIR = [DIR_RIGHT, DIR_DOWN, DIR_LEFT, DIR_UP];
function judgeSwipe(dx, dy, ms) {
const length = Math.sqrt(dx * dx + dy * dy);
const speed = length / Math.max(1, ms) * 1000;
const angle = (deg(Math.atan2(dy, dx)) + 360) % 360;
const base = { dir: -1, angle: angle, length: length, speed: speed, reason: VERDICT_SHORT };
const flick = length >= FLICK_MIN_PX && speed >= FLICK_SPEED_PX_S;
if (length < SWIPE_MIN_PX && !flick) {
return base;
}
// 경계까지 남은 각 — 90° 마다 되풀이되는 자리에서 45° 와의 차
const toBorder = Math.abs((angle % 90) - 45);
if (toBorder < DEAD_ZONE_DEG) {
base.reason = VERDICT_DIAGONAL;
return base;
}
base.dir = ANGLE_TO_DIR[Math.round(angle / 90) % 4];
base.reason = length < SWIPE_MIN_PX ? VERDICT_FLICK : VERDICT_OK;
return base;
}
// ── 타일 색과 숫자 크기 ──────────────────────────────────────
// 단계 = log2(값) — 2 는 1단계, 2048 은 11단계, 8192 는 13단계
function stepOf(v) { return v > 0 ? Math.log2(v) : 0; }
// 파스텔 기준색과 그 색이 놓이는 단계 — 사이 단계는 oklab 에서 곧게 섞는다
const TILE_ANCHORS = [[250, 238, 218], [246, 224, 150], [244, 170, 152], [244, 190, 212], [196, 182, 238], [170, 206, 238], [164, 218, 196]];
const ANCHOR_STEPS = [1, 3, 5, 7, 9, 11, 13];
function toLinear(c) { const x = c / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }
function fromLinear(x) { const c = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055; return clamp(c * 255, 0, 255); }
// sRGB → oklab (지각 균등 색 공간 — 같은 거리만큼 섞으면 눈에도 비슷한 만큼 바뀐다)
function rgbToOklab(c) {
const r = toLinear(c[0]);
const g = toLinear(c[1]);
const b = toLinear(c[2]);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s];
}
function oklabToRgb(c) {
const l = Math.pow(c[0] + 0.3963377774 * c[1] + 0.2158037573 * c[2], 3);
const m = Math.pow(c[0] - 0.1055613458 * c[1] - 0.0638541728 * c[2], 3);
const s = Math.pow(c[0] - 0.0894841775 * c[1] - 1.2914855480 * c[2], 3);
return [fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)];
}
const ANCHOR_LABS = TILE_ANCHORS.map(rgbToOklab);
// 단계(소수 가능)의 색 — 양옆 기준 단계 사이 몫 k 로 oklab 세 값을 각각 곧게 섞는다(선형 보간). 끝 바깥은 끝 색
function tileColor(step) {
const last = ANCHOR_STEPS.length - 1;
if (step <= ANCHOR_STEPS[0]) {
return TILE_ANCHORS[0];
}
if (step >= ANCHOR_STEPS[last]) {
return TILE_ANCHORS[last];
}
let i = 0;
while (step > ANCHOR_STEPS[i + 1]) {
i += 1;
}
const k = (step - ANCHOR_STEPS[i]) / (ANCHOR_STEPS[i + 1] - ANCHOR_STEPS[i]);
const a = ANCHOR_LABS[i];
const b = ANCHOR_LABS[i + 1];
return oklabToRgb([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
// 비교용 — 같은 두 색을 sRGB 값 그대로 섞은 색
function tileColorRgb(step) {
const last = ANCHOR_STEPS.length - 1;
if (step <= ANCHOR_STEPS[0]) {
return TILE_ANCHORS[0];
}
if (step >= ANCHOR_STEPS[last]) {
return TILE_ANCHORS[last];
}
let i = 0;
while (step > ANCHOR_STEPS[i + 1]) {
i += 1;
}
const k = (step - ANCHOR_STEPS[i]) / (ANCHOR_STEPS[i + 1] - ANCHOR_STEPS[i]);
return mix(TILE_ANCHORS[i], TILE_ANCHORS[i + 1], k);
}
// 숫자 크기 fitted — 칸 폭 × DIGIT_PX_RATIO 로 시작해, 잰 숫자 폭이 칸 폭 × DIGIT_FILL 을 넘으면 그 비율만큼 줄인다
const DIGIT_PX_RATIO = 0.5;
const DIGIT_FILL = 0.8;
const DIGIT_WEIGHT = 800;
function fitDigits(text, cell, m) {
const px0 = cell * DIGIT_PX_RATIO;
const raw = textWidth(text, px0, DIGIT_WEIGHT, m);
const room = cell * DIGIT_FILL;
const scale = raw > room ? room / raw : 1;
return { px: px0 * scale, width: raw * scale, rawWidth: raw, scale: scale };
}
// 합친 타일 크기 스프링 — 크기 1 에서 POP_KICK(1/초)의 속도로 차올라 넘쳤다가 1 로 앉는다. 새 타일은 0 에서 자란다
const POP_STIFFNESS = 420;
const POP_DAMPING = 18;
const POP_KICK = 6;
// 새 타일은 미끄러짐이 다 끝난 뒤 이만큼 있다가 돋는다
const SPAWN_DELAY_S = 0.04;
// 되감기 때 새 타일이 오그라들어 사라지는 데 쓰는 시간
const SHRINK_S = 0.22;
// 숨는 시각이 없는 타일 — 아주 먼 뒤
const NO_HIDE = 1e9;
function makeSprite(t, show) {
const c = colOf(t.cell);
const r = rowOf(t.cell);
return { id: t.id, v: t.v, fx: c, fy: r, tx: c, ty: r, t0: 0, dur: 0, show: show, hide: NO_HIDE, pop: makeSpring(1), squash: makeSpring(0), axis: 0, landed: true, lockUntil: -1 };
}
function makeView(tiles, style) {
return { sprites: tiles.map(function (t) { return makeSprite(t, -1); }), style: style };
}
// 판 그대로 다시 놓기 — 움직임 줄이기·사람 수를 멈춘 화면에서 바로 보일 때
function settleView(view, tiles) {
view.sprites = tiles.map(function (t) { return makeSprite(t, -1); });
}
function slideTime(style, cells) { return cells > 0 ? style.base + style.perCell * (cells - 1) : 0; }
function findSprite(view, id) {
for (const s of view.sprites) {
if (s.id === id && s.hide >= NO_HIDE) {
return s;
}
}
return null;
}
function aimSprite(s, from, to, t, style) {
s.fx = colOf(from);
s.fy = rowOf(from);
s.tx = colOf(to);
s.ty = rowOf(to);
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
s.axis = s.fx !== s.tx ? 0 : 1;
s.t0 = t;
s.dur = slideTime(style, cells);
s.landed = cells === 0;
}
// 한 수를 화면 타일로 — 미끄러짐 시작 t. 합친 타일은 마지막 부모가 닿는 순간 나타나 스프링으로 부풀고, 새 타일은 그 뒤에 돋는다. 끝나는 시각을 돌려준다
function animateMove(view, mv, t, lockS) {
let end = t;
const arrive = {};
for (const sl of mv.slides) {
let s = findSprite(view, sl.id);
if (!s) {
s = makeSprite({ id: sl.id, v: sl.v, cell: sl.from }, -1);
view.sprites.push(s);
}
aimSprite(s, sl.from, sl.to, t, view.style);
arrive[sl.id] = t + s.dur;
end = Math.max(end, t + s.dur);
}
for (const m of mv.merges) {
let at = t;
for (const p of m.parents) {
at = Math.max(at, arrive[p]);
}
for (const p of m.parents) {
const s = findSprite(view, p);
if (s) {
s.hide = at;
}
}
const born = makeSprite({ id: m.id, v: m.v, cell: m.cell }, at);
born.pop.v = POP_KICK;
born.lockUntil = at + lockS;
view.sprites.push(born);
}
if (mv.spawn) {
const sp = makeSprite(mv.spawn, end + SPAWN_DELAY_S);
snapSpring(sp.pop, 0);
sp.pop.to = 1;
view.sprites.push(sp);
}
return end + SPAWN_DELAY_S;
}
// 되감기 — 기록을 거꾸로: 새 타일은 오그라들어 사라지고, 합친 타일은 그 자리에서 두 부모로 갈라져 각자 출발 칸으로 미끄러져 간다
function animateUndo(view, mv, t) {
let end = t;
if (mv.spawn) {
const s = findSprite(view, mv.spawn.id);
if (s) {
s.pop.to = 0;
s.hide = t + SHRINK_S;
}
end = t + SHRINK_S;
}
const merged = {};
for (const m of mv.merges) {
const s = findSprite(view, m.id);
if (s) {
s.hide = t;
}
for (const p of m.parents) {
merged[p] = true;
}
}
for (const sl of mv.slides) {
let s = merged[sl.id] ? null : findSprite(view, sl.id);
if (!s) {
s = makeSprite({ id: sl.id, v: sl.v, cell: sl.to }, t);
view.sprites.push(s);
}
aimSprite(s, sl.to, sl.from, t, view.style);
end = Math.max(end, t + s.dur);
}
return end;
}
// 진행 몫 u(0~1)의 자리 몫과, 그때 1초에 가는 칸 수
function slideAt(s, t, easeIn) {
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
if (s.dur <= 0 || t >= s.t0 + s.dur) {
return { x: s.tx, y: s.ty, speed: 0 };
}
const u = clamp((t - s.t0) / s.dur, 0, 1);
const k = easeIn ? u * u : smooth(u);
const dk = easeIn ? 2 * u : 6 * u * (1 - u);
return { x: lerp(s.fx, s.tx, k), y: lerp(s.fy, s.ty, k), speed: dk * cells / s.dur };
}
function stepView(view, t, dt) {
const out = [];
for (const s of view.sprites) {
if (t < s.show) {
continue;
}
stepSpring(s.pop, POP_STIFFNESS, POP_DAMPING, dt);
if (!s.landed && t >= s.t0 + s.dur) {
s.landed = true;
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
out.push({ s: s, speed: view.style.easeIn ? 2 * cells / s.dur : 0 });
}
}
view.sprites = view.sprites.filter(function (s) { return t < s.hide; });
return out;
}
function viewBusy(view, t) {
for (const s of view.sprites) {
if (t < s.t0 + s.dur || (s.show > t)) {
return true;
}
}
return false;
}
// ── 판 하나 = 논리 판 + 화면 타일 + 입력 대기열 ─────────────
// 미끄러지는 동안 들어온 입력은 대기열에 넣었다가 미끄러짐이 끝나면 둔다. 대기열이 꽉 차 있으면 그 입력은 버린다
const INPUT_QUEUE_MAX = 1;
function makePlayer(values, seed, style, lock, lockS) {
const g = makeGame(values, seed);
return { game: g, view: makeView(g.tiles, style), lock: lock, lockS: lockS, queue: [], queueMax: INPUT_QUEUE_MAX, busyUntil: -1, last: null, history: [], got: 0, played: 0, dropped: 0 };
}
// 지금 바로 한 수 — 안 움직이는 방향이면 판도 기록도 그대로
function doMove(p, dir, t) {
const mv = playMove(p.game, dir, p.lock);
p.last = mv;
if (!mv.moved) {
return mv;
}
p.busyUntil = animateMove(p.view, mv, t, p.lockS);
p.history.push(mv);
p.played += 1;
return mv;
}
// 입력 하나 — 미끄러지는 중이면 대기열에, 대기열이 꽉 찼으면 버린다. 둔 수가 있으면 돌려준다
function pushInput(p, dir, t) {
p.got += 1;
if (t < p.busyUntil) {
if (p.queue.length < p.queueMax) {
p.queue.push(dir);
}
else {
p.dropped += 1;
}
return null;
}
return doMove(p, dir, t);
}
// 미끄러짐이 끝났으면 대기열 맨 앞 입력을 둔다
function pumpQueue(p, t) {
if (t < p.busyUntil || !p.queue.length) {
return null;
}
const dir = p.queue.shift();
return dir === undefined ? null : doMove(p, dir, t);
}
function settlePlayer(p) {
settleView(p.view, p.game.tiles);
p.busyUntil = -1;
p.queue = [];
}
function maxTile(p) { let m = 0; for (const t of p.game.tiles) {
m = Math.max(m, t.v);
} return m; }
// 상태 줄 — 둔 방향 · 이번 수에 합친 횟수 · score
const STATUS_MOVE = '{d} · merges {m} · score {s}';
const STATUS_BLOCKED = '{d}: no tile can move';
function moveStatus(p, mv) {
if (!mv.moved) {
return STATUS_BLOCKED.replace('{d}', DIR_NAMES[mv.dir]);
}
return STATUS_MOVE.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(p.game.score));
}
// ── 기대값 탐색 ──────────────────────────────────────────────
// 판 평가 = empty 수 × EMPTY_WEIGHT + 한쪽으로 커지는 순서 × MONO_WEIGHT + 이웃 값의 매끄러움 × SMOOTH_WEIGHT. 값은 log2 단계로 본다
const EMPTY_WEIGHT = 2.7;
const MONO_WEIGHT = 1.0;
const SMOOTH_WEIGHT = 0.1;
// 몇 층까지 내다보는가 — 한 층 = 네 방향 밀기 + 빈칸마다 2·4 떨어짐
const SEARCH_DEPTH = 2;
// 평가한 끝판 수(잎) — 한 번 고를 때마다 0 에서 센다
let searchLeaves = 0;
function lineSteps(vals, cells) { return cells.map(function (i) { return stepOf(vals[i]); }); }
const LINES = (function () {
const out = [];
for (let r = 0; r < SIZE; r += 1) {
out.push([0, 1, 2, 3].map(function (c) { return r * SIZE + c; }));
}
for (let c = 0; c < SIZE; c += 1) {
out.push([0, 1, 2, 3].map(function (r) { return r * SIZE + c; }));
}
return out;
})();
// 한쪽으로 커지는 순서 — 줄마다 오르막 합과 내리막 합 중 작은 쪽을 벌점으로(한 방향으로만 커지면 0)
function monotonicity(vals) {
let pen = 0;
for (const ln of LINES) {
const s = lineSteps(vals, ln);
let up = 0;
let down = 0;
for (let i = 0; i + 1 < s.length; i += 1) {
if (s[i + 1] > s[i]) {
up += s[i + 1] - s[i];
}
else {
down += s[i] - s[i + 1];
}
}
pen += Math.min(up, down);
}
return -pen;
}
// 매끄러움 — 맞닿은 두 타일(empty 제외) 단계 차의 합을 벌점으로
function smoothness(vals) {
let pen = 0;
for (let i = 0; i < CELLS; i += 1) {
if (!vals[i]) {
continue;
}
if (colOf(i) + 1 < SIZE && vals[i + 1]) {
pen += Math.abs(stepOf(vals[i]) - stepOf(vals[i + 1]));
}
if (rowOf(i) + 1 < SIZE && vals[i + SIZE]) {
pen += Math.abs(stepOf(vals[i]) - stepOf(vals[i + SIZE]));
}
}
return -pen;
}
function evaluate(vals) {
searchLeaves += 1;
return countEmpty(vals) * EMPTY_WEIGHT + monotonicity(vals) * MONO_WEIGHT + smoothness(vals) * SMOOTH_WEIGHT;
}
// 값만으로 미는 판 — 타일 번호 없이, 같은 한 줄 압축(잠금 있음)
function slideValues(vals, dir) {
const order = ORDERS[dir];
const out = vals.slice();
let moved = false;
for (let r = 0; r < SIZE; r += 1) {
const line = [];
for (let k = 0; k < SIZE; k += 1) {
const v = vals[order[r * SIZE + k]];
if (v) {
line.push(v);
}
}
const packed = [];
let locked = false;
for (const v of line) {
if (packed.length && packed[packed.length - 1] === v && !locked) {
packed[packed.length - 1] = v * 2;
locked = true;
}
else {
packed.push(v);
locked = false;
}
}
for (let k = 0; k < SIZE; k += 1) {
const v = k < packed.length ? packed[k] : 0;
if (out[order[r * SIZE + k]] !== v) {
moved = true;
}
out[order[r * SIZE + k]] = v;
}
}
return { vals: out, moved: moved };
}
// 떨어질 차례 — 빈칸마다 2(확률 1 − FOUR_CHANCE)·4(FOUR_CHANCE)를 놓아 본 값의 평균. empty 하나하나는 같은 확률로 뽑힌다
function chanceNode(vals, depth) {
const empty = [];
for (let i = 0; i < CELLS; i += 1) {
if (!vals[i]) {
empty.push(i);
}
}
if (!empty.length) {
return depth > 1 ? moveNode(vals, depth - 1) : evaluate(vals);
}
let total = 0;
for (const cell of empty) {
for (const v of [2, 4]) {
const p = v === 2 ? 1 - FOUR_CHANCE : FOUR_CHANCE;
const next = vals.slice();
next[cell] = v;
total += p * (depth > 1 ? moveNode(next, depth - 1) : evaluate(next));
}
}
return total / empty.length;
}
// 내 차례 — 움직이는 방향 중 가장 높은 기대값. 어느 쪽도 못 밀면 그 판 평가 그대로
function moveNode(vals, depth) {
let best = -Infinity;
for (let d = 0; d < 4; d += 1) {
const s = slideValues(vals, d);
if (!s.moved) {
continue;
}
best = Math.max(best, chanceNode(s.vals, depth));
}
return best === -Infinity ? evaluate(vals) : best;
}
function chooseMove(vals, depth) {
searchLeaves = 0;
const scores = [];
let best = -1;
for (let d = 0; d < 4; d += 1) {
const s = slideValues(vals, d);
const v = s.moved ? chanceNode(s.vals, depth) : NaN;
scores.push(v);
if (!isNaN(v) && (best < 0 || v > scores[best])) {
best = d;
}
}
return { scores: scores, best: best, leaves: searchLeaves };
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const boardEl = root.querySelector('[data-role="board"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !boardEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const boardBox = boardEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 잰 폭은 100px 기준으로 한 번만 재 두고 크기에 비례해 쓴다. 글꼴이 새로 오면 통째로 비운다
const widthCache = {};
function measureWith(stack) {
return function (ch, px, weight) {
const key = stack + '|' + weight + '|' + ch;
let w = widthCache[key];
if (w === undefined) {
ctx.font = weight + ' 100px ' + stack;
w = ctx.measureText(ch).width;
widthCache[key] = w;
}
return w * px / 100;
};
}
function remeasure() {
for (const k of Object.keys(widthCache)) {
delete widthCache[k];
}
glyphWidth = measureWith(FONT_STACK);
relayout();
}
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(c) {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px, weight, stack) {
ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + stack;
}
function label(s, x, y, c, a, px, weight, align) {
setFont(px, weight, FONT_STACK);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x, y, w, h, r) {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x, y, r, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function line(x1, y1, x2, y2, width, c, a) {
if (a <= 0) {
return;
}
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s, x, y, bg, fg, a, px) {
setFont(px, 700, FONT_STACK);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus() {
if (!statusEl || simStatus === shownStatus) {
return;
}
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
function frameSize(f) { return SIZE * f.cell + (SIZE + 1) * f.gap; }
function cellCenter(f, col, row) {
return [f.x + f.gap + col * (f.cell + f.gap) + f.cell / 2, f.y + f.gap + row * (f.cell + f.gap) + f.cell / 2];
}
function drawBoardBack(f, a) {
const s = frameSize(f);
fillRound(f.x, f.y, s, s, f.cell * 0.16, BOARD_BG, a);
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) {
const p = cellCenter(f, c, r);
fillRound(p[0] - f.cell / 2, p[1] - f.cell / 2, f.cell, f.cell, f.cell * 0.12, CELL_BG, a);
}
}
}
// 타일 하나 — 가운데 (cx, cy), 한 변 size, 가로·세로 배율 sx·sy, 색 단계 step(소수면 두 색 사이). fit 이 거짓이면 숫자를 줄이지 않는다(넘치는 판)
function drawTileAt(v, cx, cy, size, sx, sy, a, step, fit) {
if (a <= 0.01 || sx <= 0.01 || sy <= 0.01) {
return;
}
const w = size * sx;
const h = size * sy;
fillRound(cx - w / 2, cy - h / 2, w, h, size * 0.12, tileColor(step), a);
const text = String(v);
const f = fitDigits(text, size, glyphWidth);
const px = fit ? f.px : size * DIGIT_PX_RATIO;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(sx, sy);
setFont(px, DIGIT_WEIGHT, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(INK, a);
ctx.fillText(text, 0, px * 0.04);
ctx.restore();
}
function noStretch(s, speed) { void s; void speed; return [1, 1]; }
function drawView(view, f, t, a, stretch) {
// 미끄러지는 타일을 나중에 — 서 있는 타일 위로 지나가게
const order = view.sprites.filter(function (s) { return t >= s.show; });
order.sort(function (p, q) { return (p.landed ? 0 : 1) - (q.landed ? 0 : 1); });
for (const s of order) {
const at = slideAt(s, t, view.style.easeIn);
const p = cellCenter(f, at.x, at.y);
const k = Math.max(0, s.pop.x);
const st = stretch(s, at.speed);
drawTileAt(s.v, p[0], p[1], f.cell, k * st[0], k * st[1], a, stepOf(s.v), true);
if (t < s.lockUntil) {
// 잠금 표시 — 이번 수에 합쳐진 타일. 같은 수 안에서는 다시 합치지 않는다
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, a * 0.9);
roundRectPath(p[0] - f.cell * 0.5 * k - 3, p[1] - f.cell * 0.5 * k - 3, f.cell * k + 6, f.cell * k + 6, f.cell * 0.16);
ctx.stroke();
}
}
}
// 09 — 첫 화면에서 스스로 두어 가는 2048 판. 시드 고정 중반 판에서 시작해 수마다
// ① chooseMove(07)로 방향을 고르고 ② 그 방향으로 흉내 낸 쓸기 궤적을 그려 judgeSwipe(03)로 판정한 뒤
// ③ 늘었다 줄며 미끄러지고(02) ④ 잠금 있는 한 줄 압축으로 합쳐(01) ⑤ 단계 색으로 그리고(05) ⑥ 조각을 튀기며 흔들린다(06).
// 판이 막히거나(empty 0 · 짝 0) ROUND_MOVES 수를 두면 타일이 떨어져 튄 뒤(08) 처음 판으로 돌아간다 — 탐색이 막힘을 잘 피해 한참 동안 막히지 않는다
const START_09 = [
2, 0, 4, 2,
8, 4, 16, 4,
16, 64, 32, 8,
128, 256, 64, 16
];
const SEED_09 = 909;
const SLIDE_BASE_S = 0.16;
const SLIDE_PER_CELL_S = 0.04;
const STYLE_09 = { base: SLIDE_BASE_S, perCell: SLIDE_PER_CELL_S, easeIn: true };
const STRETCH_PER_SPEED = 0.012;
const STRETCH_MAX = 1.4;
const LAND_KICK = 0.25;
const LAND_STIFFNESS = 600;
const LAND_DAMPING = 16;
const BURST_PER_STEP = 3;
const BURST_SPEED_PER_STEP = 34;
const BURST_GRAVITY = 520;
const BURST_DRAG = 2.4;
const BURST_LIFE_S = 0.8;
const BURST_LIFT = 60;
const SHAKE_PER_MERGE = 1.6;
const SHAKE_DECAY = 6;
const SHAKE_HZ = 16;
const FALL_GRAVITY = 1600;
const BOUNCE = 0.42;
const REST_SPEED = 70;
const COLLAPSE_SPREAD_S = 0.3;
const FALL_SPREAD_VX = 220;
// 수 사이 간격과 흉내 낸 쓸기 궤적(길이 px · 걸린 ms)
const FIRST_MOVE_09 = 0.2;
const MOVE_GAP_09 = 0.42;
const SWIPE_LEN_PX = 70;
const SWIPE_MS = 130;
// 방향 번호(Left·Up·Right·Down)의 화면 각 — 0° Right, 90° Down
const DIR_ANGLE_09 = [180, 270, 0, 90];
// 판이 막힌 뒤 무너지기까지, 다 떨어진 뒤 처음 판으로 돌아가기까지
const STUCK_HOLD_S = 0.35;
const FALLEN_HOLD_S = 0.5;
const SCORE_RATE = 10;
// 한 바퀴에 두는 수 — 이만큼 두면 막히지 않아도 무너뜨리고 처음 판으로
const ROUND_MOVES = 16;
const LOOP_S = 600;
// 판 자리 — 화면과 같은 값
const F09_X = 214;
const F09_Y = 16;
const F09_CELL = 52;
const F09_GAP = 6;
const FLOOR_09 = 290;
let hero = makePlayer(START_09, SEED_09, STYLE_09, true, 0.25);
let fxRng = makeRng(99);
let shards9 = [];
let bodies9 = [];
let pend9 = [];
let swipeAt = -1;
let swipeDir = -1;
let choice9 = null;
let nextAt09 = FIRST_MOVE_09;
let shakeAt9 = -10;
let shakeAmp9 = 0;
let scoreShown = 0;
let stuckAt9 = -1;
let fallAt9 = -1;
let moves9 = 0;
function reset() {
hero = makePlayer(START_09, SEED_09, STYLE_09, true, 0.25);
fxRng = makeRng(99);
shards9 = [];
bodies9 = [];
pend9 = [];
swipeAt = -1;
swipeDir = -1;
choice9 = null;
nextAt09 = sceneT + FIRST_MOVE_09;
shakeAt9 = -10;
shakeAmp9 = 0;
scoreShown = 0;
stuckAt9 = -1;
fallAt9 = -1;
moves9 = 0;
stuckReal9 = false;
}
function relayout() { }
function center09(col, row) {
return [F09_X + F09_GAP + col * (F09_CELL + F09_GAP) + F09_CELL / 2, F09_Y + F09_GAP + row * (F09_CELL + F09_GAP) + F09_CELL / 2];
}
function shake9(t) { return t < shakeAt9 ? 0 : shakeAmp9 * Math.exp(-SHAKE_DECAY * (t - shakeAt9)); }
function scales9(s, speed) {
const along = Math.min(STRETCH_MAX, 1 + STRETCH_PER_SPEED * speed) * Math.max(0.55, 1 + s.squash.x);
return s.axis === 0 ? [along, 1 / along] : [1 / along, along];
}
const STATUS_HERO = 'swipe {d} · merges {m} · score {s}';
const STATUS_FALL = 'empty 0 · pairs 0: collapsing after {n} moves';
const STATUS_ROUND = '{n} moves: round over, collapsing';
let stuckReal9 = false;
function afterMove9(mv) {
if (!mv || !mv.moved) {
return;
}
moves9 += 1;
simStatus = STATUS_HERO.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(hero.game.score));
let first = NO_HIDE;
for (const s of hero.view.sprites) {
for (const m of mv.merges) {
if (s.id === m.id) {
first = Math.min(first, s.show);
pend9.push({ at: s.show, cell: m.cell, v: m.v });
}
}
}
if (mv.merges.length) {
shakeAmp9 = shake9(first) + SHAKE_PER_MERGE * mv.merges.length;
shakeAt9 = first;
}
stuckReal9 = isStuck(valuesOf(hero.game.tiles));
if (stuckReal9 || moves9 >= ROUND_MOVES) {
stuckAt9 = hero.busyUntil + STUCK_HOLD_S;
}
}
function humanMove(dir) { if (stuckAt9 < 0) {
afterMove9(pushInput(hero, dir, sceneT));
} }
function onSwipe(v) { if (v.dir >= 0) {
humanMove(v.dir);
} }
function burst9(cell, v, t) {
const k = stepOf(v);
const c = center09(colOf(cell), rowOf(cell));
for (let i = 0; i < BURST_PER_STEP * k; i += 1) {
const a = fxRng() * TAU;
const sp = BURST_SPEED_PER_STEP * k * (0.55 + 0.45 * fxRng());
shards9.push({ x: c[0], y: c[1], vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - BURST_LIFT, born: t, rot: fxRng() * TAU, spin: (fxRng() - 0.5) * 14, size: 3 + 3 * fxRng(), c: tileColor(k) });
}
}
function startFall9() {
bodies9 = hero.game.tiles.map(function (t) {
const c = center09(colOf(t.cell), rowOf(t.cell));
return { v: t.v, x: c[0], y: c[1], vx: (fxRng() - 0.5) * FALL_SPREAD_VX, vy: 0, delay: fxRng() * COLLAPSE_SPREAD_S, resting: false, rot: 0, spin: (fxRng() - 0.5) * 3 };
});
}
function stepFall9(dt, since) {
let all = true;
for (const b of bodies9) {
if (b.resting) {
continue;
}
all = false;
if (since < b.delay) {
continue;
}
b.vy += FALL_GRAVITY * dt;
b.y += b.vy * dt;
b.x = clamp(b.x + b.vx * dt, F09_CELL / 2, W - F09_CELL / 2);
b.rot += b.spin * dt;
const floor = FLOOR_09 - F09_CELL / 2;
if (b.y >= floor) {
b.y = floor;
b.vy = -b.vy * BOUNCE;
b.vx *= 0.6;
b.spin *= 0.5;
if (Math.abs(b.vy) < REST_SPEED) {
b.vy = 0;
b.resting = true;
}
}
}
return all;
}
let fallenAt9 = -1;
function update(dt) {
if (stuckAt9 >= 0) {
if (fallAt9 < 0 && sceneT >= stuckAt9) {
fallAt9 = sceneT;
startFall9();
simStatus = (stuckReal9 ? STATUS_FALL : STATUS_ROUND).replace('{n}', String(moves9));
}
if (fallAt9 >= 0) {
if (stepFall9(dt, sceneT - fallAt9) && fallenAt9 < 0) {
fallenAt9 = sceneT;
}
if (fallenAt9 >= 0 && sceneT >= fallenAt9 + FALLEN_HOLD_S) {
fallenAt9 = -1;
reset();
}
}
}
else {
// 쓸기 흉내 — 고른 방향으로 궤적을 그리고, 다 그리면 judgeSwipe 로 판정해 받은 방향으로 둔다
if (autoOn && swipeAt < 0 && sceneT >= nextAt09) {
choice9 = chooseMove(valuesOf(hero.game.tiles), SEARCH_DEPTH);
if (choice9.best >= 0) {
swipeAt = sceneT;
swipeDir = choice9.best;
}
nextAt09 = sceneT + MOVE_GAP_09;
}
if (swipeAt >= 0 && sceneT >= swipeAt + SWIPE_MS / 1000) {
const a = DIR_ANGLE_09[swipeDir] * Math.PI / 180;
const v = judgeSwipe(Math.cos(a) * SWIPE_LEN_PX, Math.sin(a) * SWIPE_LEN_PX, SWIPE_MS);
swipeAt = -1;
if (v.dir >= 0) {
afterMove9(pushInput(hero, v.dir, sceneT));
}
}
afterMove9(pumpQueue(hero, sceneT));
}
for (const p of pend9.filter(function (q) { return sceneT >= q.at; })) {
burst9(p.cell, p.v, sceneT);
}
pend9 = pend9.filter(function (q) { return sceneT < q.at; });
const drag = Math.exp(-BURST_DRAG * dt);
for (const s of shards9) {
s.vy += BURST_GRAVITY * dt;
s.vx *= drag;
s.vy *= drag;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.rot += s.spin * dt;
}
shards9 = shards9.filter(function (s) { return sceneT - s.born < BURST_LIFE_S; });
scoreShown += (hero.game.score - scoreShown) * follow(SCORE_RATE, dt);
for (const l of stepView(hero.view, sceneT, dt)) {
l.s.squash.v = -LAND_KICK * l.speed;
}
for (const s of hero.view.sprites) {
stepSpring(s.squash, LAND_STIFFNESS, LAND_DAMPING, dt);
}
}
function settle() {
settlePlayer(hero);
shards9 = [];
pend9 = [];
swipeAt = -1;
shakeAmp9 = 0;
scoreShown = hero.game.score;
if (stuckAt9 >= 0) {
reset();
settlePlayer(hero);
}
}
// 정지 판 — 처음 판에서 자동 플레이가 고른 첫 수까지 둔 판
function restPose() {
choice9 = chooseMove(valuesOf(hero.game.tiles), SEARCH_DEPTH);
if (choice9.best >= 0) {
afterMove9(doMove(hero, choice9.best, 0));
}
settle();
}
// 09 화면 — Right 큰 판(흔들림만큼 밀림 · 쓸기 궤적 · 조각), Left 제목·score·largest tile·네 방향 막대
const F09 = { x: F09_X, y: F09_Y, cell: F09_CELL, gap: F09_GAP };
const LABEL_TITLE = '2048';
const LABEL_TAGLINE = 'push equal numbers together';
const LABEL_SCORE = 'score';
const LABEL_BEST = 'largest tile';
const LABEL_NEXT = 'next-move value';
const HERO_BARS = [BUTTER, LAVENDER, MINT, SKY];
function bindInput(signal) { void signal; }
function draw() {
clearStage(PAPER);
label(LABEL_TITLE, 24, 44, INK, 1, 44, 800, 'left');
label(LABEL_TAGLINE, 26, 76, INK_SOFT, 1, 11, 700, 'left');
label(LABEL_SCORE, 26, 104, INK_SOFT, 1, 10, 700, 'left');
label(String(Math.round(scoreShown)), 26, 126, VIOLET_DEEP, 1, 24, 800, 'left');
label(LABEL_BEST, 26, 156, INK_SOFT, 1, 10, 700, 'left');
const best = maxTile(hero);
if (best) {
drawTileAt(best, 50, 186, 44, 1, 1, 1, stepOf(best), true);
}
// 네 방향 기대값 막대 — 가장 낮은 방향 0.25, 가장 높은 방향 1
label(LABEL_NEXT, 104, 156, INK_SOFT, 1, 10, 700, 'left');
if (choice9) {
const ok = choice9.scores.filter(function (s) { return !isNaN(s); });
const lo = Math.min.apply(null, ok);
const hi = Math.max.apply(null, ok);
for (let d = 0; d < 4; d += 1) {
const s = choice9.scores[d];
const k = isNaN(s) ? 0 : (hi - lo < 1e-9 ? 1 : 0.25 + 0.75 * (s - lo) / (hi - lo));
fillRound(106 + d * 21, 208 - 44 * k, 15, 44 * k, 4, HERO_BARS[d], d === choice9.best ? 1 : 0.6);
}
}
const off = shake9(sceneT);
const ph = TAU * SHAKE_HZ * (sceneT - shakeAt9);
ctx.save();
ctx.translate(off * Math.sin(ph), off * 0.6 * Math.cos(ph * 1.3));
drawBoardBack(F09, 1);
if (fallAt9 < 0) {
drawView(hero.view, F09, sceneT, 1, scales9);
}
ctx.restore();
for (const b of bodies9) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.rot);
drawTileAt(b.v, 0, 0, F09.cell, 1, 1, 1, stepOf(b.v), true);
ctx.restore();
}
for (const s of shards9) {
const life = (sceneT - s.born) / BURST_LIFE_S;
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.rot);
ctx.fillStyle = rgba(mix(s.c, INK, 0.18), 1 - life * life);
ctx.fillRect(-s.size / 2, -s.size / 2, s.size, s.size);
ctx.restore();
}
// 흉내 낸 쓸기 — 판 가운데에서 고른 방향으로 그려지는 궤적
if (swipeAt >= 0) {
const u = clamp((sceneT - swipeAt) / (SWIPE_MS / 1000), 0, 1);
const a = DIR_ANGLE_09[swipeDir] * Math.PI / 180;
const cx = F09.x + frameSize(F09) / 2;
const cy = F09.y + frameSize(F09) / 2;
const len = SWIPE_LEN_PX;
line(cx, cy, cx + Math.cos(a) * len * u, cy + Math.sin(a) * len * u, 9, INK, 0.35);
disc(cx + Math.cos(a) * len * u, cy + Math.sin(a) * len * u, 12, INK, 0.3);
}
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시가 붙어 있는가 — 사람이 처음 손대면 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
if (statusEl) {
statusEl.setAttribute('aria-live', 'polite');
}
}
// 사람이 한 수를 두면 자동 시연은 수를 그만 두고, 판은 그 자리에서 사람 판이 된다
function takeOver() {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·Pause)에서 둔 수는 미끄러짐 없이 결과 판으로 바로 그린다
function showNow() {
if (still || paused) {
settle();
draw();
pushStatus();
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 자동 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 판으로 되돌려 다시 시연한다
function advance(dt) {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 정지 판 한 장만 그린다
function showRest() {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
}
else {
settle();
}
draw();
pushStatus();
}
function play() {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
if (!paused) {
startLoop();
}
}
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 방향키 — 판에 포커스가 있을 때만 받는다. 판 밖에서는 화살표가 원래대로 페이지를 스크롤한다
const KEY_DIRS = { ArrowLeft: DIR_LEFT, ArrowUp: DIR_UP, ArrowRight: DIR_RIGHT, ArrowDown: DIR_DOWN };
boardBox.addEventListener('keydown', function (e) {
const dir = KEY_DIRS[e.key];
if (dir === undefined || e.altKey || e.ctrlKey || e.metaKey) {
return;
}
e.preventDefault();
takeOver();
humanMove(dir);
showNow();
}, { signal: off.signal });
// 쓸기 — 누른 자리와 뗀 자리의 차, 걸린 시간을 쓸기 판정(judgeSwipe)에 넣는다. 화면 px 그대로
let downX = 0;
let downY = 0;
let downT = -1;
boardBox.addEventListener('pointerdown', function (e) {
downX = e.clientX;
downY = e.clientY;
downT = e.timeStamp;
}, { signal: off.signal });
boardBox.addEventListener('pointerup', function (e) {
if (downT < 0) {
return;
}
const v = judgeSwipe(e.clientX - downX, e.clientY - downY, e.timeStamp - downT);
downT = -1;
if (v.length < FLICK_MIN_PX) {
return;
}
takeOver();
onSwipe(v);
showNow();
}, { signal: off.signal });
boardBox.addEventListener('pointercancel', function () { downT = -1; }, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still && fontsReady) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 정지 판으로 멈추고, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
// 숫자 폭은 글꼴이 다 온 뒤에 잰다 — 그 전에 잰 폭은 대체 글꼴의 폭이라 글꼴이 바뀌는 순간 글자 크기 맞춤이 틀어진다.
// 나중에 다른 글꼴이 더 오면(loadingdone) 다시 잰다
let fontsReady = false;
document.fonts.addEventListener('loadingdone', function () {
if (!fontsReady) {
return;
}
remeasure();
draw();
}, { signal: off.signal });
document.fonts.ready.then(function () {
if (off.signal.aborted) {
return;
}
fontsReady = true;
remeasure();
if (still) {
showRest();
}
else {
play();
}
});
return function () {
stopLoop();
off.abort();
};
})();// AutoRunHero - 같은 항목 vanilla 판 index.html 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./AutoRunHero.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function AutoRunHero() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 시연이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function deg(rad: number): number { return rad * 180 / Math.PI; }
// ── 감쇠 스프링 ──────────────────────────────────────────────
// 가속 = 단단함 × (목표 − 위치) − 감쇠 × 속도. 속도를 먼저 고친 뒤 위치를 옮기는 적분을 1/240초 잔걸음으로 나눠 돌린다
type Spring = { x: number; v: number; to: number };
function makeSpring(x: number): Spring { return { x: x, v: 0, to: x }; }
const SPRING_SUBSTEP = 1 / 240;
function stepSpring(s: Spring, stiffness: number, damping: number, dt: number): void {
let left = dt;
while (left > 1e-9) {
const h = Math.min(SPRING_SUBSTEP, left);
s.v += (stiffness * (s.to - s.x) - damping * s.v) * h;
s.x += s.v * h;
left -= h;
}
}
function snapSpring(s: Spring, x: number): void { s.x = x; s.v = 0; s.to = x; }
// ── 글자 폭 ──────────────────────────────────────────────────
// 글자 하나의 가로 폭(px) — 글꼴 크기 px, 굵기 weight. 화면에서는 캔버스 measureText 로 잰 값으로 바뀐다(dom 쪽).
// 아래 대략 모형은 재기 전 자리 채움일 뿐 — 글자 폭은 반드시 글꼴이 다 온 뒤 다시 잰다
type Measure = (ch: string, px: number, weight: number) => number;
function roughWidth(ch: string, px: number, weight: number): number {
const wide = ch.charCodeAt(0) >= 0x1100;
const base = ch === ' ' ? 0.28 : (wide ? 0.95 : 0.6);
return px * base * (weight >= 700 ? 1.06 : 1);
}
// 글꼴이 다 온 뒤 잰 폭
let glyphWidth: Measure = roughWidth;
// 문자열 폭 = 글자 하나씩 잰 폭의 합
function textWidth(s: string, px: number, weight: number, m: Measure): number {
let w = 0;
for (const ch of Array.from(s)) { w += m(ch, px, weight); }
return w;
}
// 장면 시계(초)와 상태 줄 글자 — 장면 계산이 고치고, 화면 쪽이 읽어 그린다
let sceneT = 0;
let simStatus = '';
// 자동 시연이 수를 두는가 — 사람이 판에서 방향키·쓸기(04 는 백스페이스도)로 처음 손대면 꺼지고, 그 뒤로는 사람 수만 둔다
let autoOn = true;
// 파스텔 팔레트 — 크림 바탕 위 라벤더·민트·버터·산호. 글자는 진한 쪽으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_DIM: RGB = [236, 228, 214];
const BOARD_BG: RGB = [214, 204, 190];
const CELL_BG: RGB = [232, 224, 212];
const INK: RGB = [44, 38, 58];
const INK_SOFT: RGB = [104, 96, 118];
const CREAM: RGB = [255, 251, 242];
const LAVENDER: RGB = [196, 182, 238];
const MINT: RGB = [164, 218, 196];
const BUTTER: RGB = [246, 224, 150];
const CORAL: RGB = [244, 170, 152];
const SKY: RGB = [170, 206, 238];
const PINK: RGB = [244, 190, 212];
const MINT_DEEP: RGB = [36, 116, 90];
const CORAL_DEEP: RGB = [178, 66, 52];
const VIOLET_DEEP: RGB = [84, 58, 160];
function mix(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
// ── 판 규칙 ──────────────────────────────────────────────────
// 판은 칸 16개. 칸 번호 = 행 × 4 + 열, 값 0 = 빈칸
const SIZE = 4;
const CELLS = SIZE * SIZE;
// 방향 번호 — 0 왼쪽 · 1 위 · 2 오른쪽 · 3 아래
const DIR_LEFT = 0;
const DIR_UP = 1;
const DIR_RIGHT = 2;
const DIR_DOWN = 3;
const DIR_NAMES = ['왼쪽', '위', '오른쪽', '아래'];
// 방향마다 판을 시계 방향으로 몇 번 돌리면 그 방향의 벽이 왼쪽에 오는가 — 위는 세 번, 오른쪽은 두 번, 아래는 한 번
const TURNS = [0, 3, 2, 1];
// 새 타일이 4 일 확률 — 나머지는 2
const FOUR_CHANCE = 0.1;
type Tile = { id: number; v: number; cell: number };
// 한 수의 기록 — 미끄러진 타일(출발·도착 칸)과 합쳐진 타일(부모 번호들 → 새 번호), 얻은 점수, 수 앞뒤 판, 새로 돋은 타일
type Slide = { id: number; v: number; from: number; to: number };
type Merge = { id: number; v: number; cell: number; parents: number[] };
type Move = { dir: number; moved: boolean; slides: Slide[]; merges: Merge[]; gain: number; before: Tile[]; after: Tile[]; spawn: Tile | null };
type Game = { tiles: Tile[]; nextId: number; score: number; rng: () => number };
// 칸 번호 표를 시계 방향으로 한 번 돌린다 — 새 (행 r, 열 c) 자리에 원래 (행 3 − c, 열 r) 칸이 온다
function rotateCW(g: number[]): number[] {
const out: number[] = [];
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) { out.push(g[(SIZE - 1 - c) * SIZE + r]); }
}
return out;
}
// 방향 dir 로 밀 때 읽는 순서 — order[r × 4 + k] = 돌린 판의 행 r, 왼쪽 벽에서 k 번째 자리에 오는 원래 칸 번호
function pushOrder(dir: number): number[] {
let g: number[] = [];
for (let i = 0; i < CELLS; i += 1) { g.push(i); }
for (let t = 0; t < TURNS[dir]; t += 1) { g = rotateCW(g); }
return g;
}
const ORDERS: number[][] = [0, 1, 2, 3].map(pushOrder);
// 한 줄 압축 — 빈칸을 걷어 낸 뒤 왼쪽 벽부터 같은 값의 이웃을 합친다.
// lock 이면 이번 수에 합쳐서 생긴 타일은 같은 수 안에서 다시 합치지 않는다(2·2·4 → 4·4). lock 이 없으면 2·2·4 → 8
type LineOut = { ids: number[]; v: number; merged: boolean };
function compressLine(line: (Tile | null)[], lock: boolean): LineOut[] {
const out: LineOut[] = [];
for (const t of line) {
if (!t) { continue; }
const last = out.length ? out[out.length - 1] : null;
if (last && last.v === t.v && !(lock && last.merged)) {
last.v *= 2;
last.ids.push(t.id);
last.merged = true;
} else {
out.push({ ids: [t.id], v: t.v, merged: false });
}
}
return out;
}
function gridOf(tiles: Tile[]): (Tile | null)[] {
const g: (Tile | null)[] = [];
for (let i = 0; i < CELLS; i += 1) { g.push(null); }
for (const t of tiles) { g[t.cell] = t; }
return g;
}
function valuesOf(tiles: Tile[]): number[] {
const v: number[] = [];
for (let i = 0; i < CELLS; i += 1) { v.push(0); }
for (const t of tiles) { v[t.cell] = t.v; }
return v;
}
function copyTiles(tiles: Tile[]): Tile[] { return tiles.map(function (t: Tile): Tile { return { id: t.id, v: t.v, cell: t.cell }; }); }
// 한 수 밀기 — 판을 돌려 그 방향을 왼쪽으로 만든 순서표로 네 줄을 읽고, 줄마다 compressLine 한 결과를 원래 칸 번호로 되돌려 적는다
function slideBoard(g: Game, dir: number, lock: boolean): Move {
const cells = gridOf(g.tiles);
const order = ORDERS[dir];
const byId: { [id: number]: Tile } = {};
for (const t of g.tiles) { byId[t.id] = t; }
const slides: Slide[] = [];
const merges: Merge[] = [];
const after: Tile[] = [];
let gain = 0;
for (let r = 0; r < SIZE; r += 1) {
const line: (Tile | null)[] = [];
for (let k = 0; k < SIZE; k += 1) { line.push(cells[order[r * SIZE + k]]); }
compressLine(line, lock).forEach(function (o: LineOut, slot: number): void {
const to = order[r * SIZE + slot];
for (const id of o.ids) { slides.push({ id: id, v: byId[id].v, from: byId[id].cell, to: to }); }
if (o.merged) {
const nid = g.nextId;
g.nextId += 1;
merges.push({ id: nid, v: o.v, cell: to, parents: o.ids.slice() });
after.push({ id: nid, v: o.v, cell: to });
gain += o.v;
} else {
after.push({ id: o.ids[0], v: o.v, cell: to });
}
});
}
const moved = merges.length > 0 || slides.some(function (s: Slide): boolean { return s.from !== s.to; });
return { dir: dir, moved: moved, slides: slides, merges: merges, gain: gain, before: copyTiles(g.tiles), after: after, spawn: null };
}
function emptyCells(tiles: Tile[]): number[] {
const used: boolean[] = [];
for (let i = 0; i < CELLS; i += 1) { used.push(false); }
for (const t of tiles) { used[t.cell] = true; }
const out: number[] = [];
for (let i = 0; i < CELLS; i += 1) { if (!used[i]) { out.push(i); } }
return out;
}
// 빈칸 하나를 시드 난수로 뽑아 2 또는 4 를 놓는다 — 4 는 FOUR_CHANCE 의 확률
function spawnTile(g: Game): Tile | null {
const empty = emptyCells(g.tiles);
if (!empty.length) { return null; }
const cell = empty[Math.floor(g.rng() * empty.length)];
const v = g.rng() < FOUR_CHANCE ? 4 : 2;
const t: Tile = { id: g.nextId, v: v, cell: cell };
g.nextId += 1;
g.tiles.push(t);
return t;
}
// 한 수 두기 — 움직였을 때만 판을 바꾸고 새 타일을 하나 돋운다. 안 움직이는 방향은 수가 아니다(새 타일도 없음)
function playMove(g: Game, dir: number, lock: boolean): Move {
const mv = slideBoard(g, dir, lock);
if (!mv.moved) { return mv; }
g.tiles = mv.after.map(function (t: Tile): Tile { return { id: t.id, v: t.v, cell: t.cell }; });
g.score += mv.gain;
mv.spawn = spawnTile(g);
return mv;
}
function makeGame(values: number[], seed: number): Game {
const tiles: Tile[] = [];
let id = 1;
values.forEach(function (v: number, cell: number): void {
if (v) { tiles.push({ id: id, v: v, cell: cell }); id += 1; }
});
return { tiles: tiles, nextId: id, score: 0, rng: makeRng(seed) };
}
// 가로·세로로 맞닿은 같은 값 짝 — 이 짝이 하나도 없고 빈칸도 없으면 어느 방향으로도 못 민다(판 끝)
type Pair = { a: number; b: number };
function equalPairs(vals: number[]): Pair[] {
const out: Pair[] = [];
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) {
const i = r * SIZE + c;
if (!vals[i]) { continue; }
if (c + 1 < SIZE && vals[i + 1] === vals[i]) { out.push({ a: i, b: i + 1 }); }
if (r + 1 < SIZE && vals[i + SIZE] === vals[i]) { out.push({ a: i, b: i + SIZE }); }
}
}
return out;
}
function countEmpty(vals: number[]): number { let n = 0; for (const v of vals) { if (!v) { n += 1; } } return n; }
function isStuck(vals: number[]): boolean { return countEmpty(vals) === 0 && equalPairs(vals).length === 0; }
function rowOf(cell: number): number { return Math.floor(cell / SIZE); }
function colOf(cell: number): number { return cell % SIZE; }
// ── 쓸기 방향 판정 ───────────────────────────────────────────
// 시작점·끝점 차(dx, dy, 화면 px — y 는 아래로)와 걸린 시간(ms)으로 네 부채꼴 중 하나를 고르거나 버린다
// 이 길이보다 짧은 궤적은 버린다 — 다만 FLICK_SPEED_PX_S 보다 빠르면 짧아도 받아들인다(빠른 튕김)
const SWIPE_MIN_PX = 24;
const FLICK_SPEED_PX_S = 450;
// 튕김이라도 이보다 짧으면 손가락이 떨린 것으로 본다
const FLICK_MIN_PX = 8;
// 부채꼴 경계(45°·135°·225°·315°) 양쪽 이 각도 안은 애매한 대각선 — 버린다
const DEAD_ZONE_DEG = 10;
type Verdict = { dir: number; angle: number; length: number; speed: number; reason: string };
const VERDICT_OK = 'ok';
const VERDICT_SHORT = 'short';
const VERDICT_DIAGONAL = 'diagonal';
const VERDICT_FLICK = 'flick';
// 화면 각(0° 오른쪽, 90° 아래)을 부채꼴로 — 가장 가까운 90° 배수가 방향
const ANGLE_TO_DIR = [DIR_RIGHT, DIR_DOWN, DIR_LEFT, DIR_UP];
function judgeSwipe(dx: number, dy: number, ms: number): Verdict {
const length = Math.sqrt(dx * dx + dy * dy);
const speed = length / Math.max(1, ms) * 1000;
const angle = (deg(Math.atan2(dy, dx)) + 360) % 360;
const base = { dir: -1, angle: angle, length: length, speed: speed, reason: VERDICT_SHORT };
const flick = length >= FLICK_MIN_PX && speed >= FLICK_SPEED_PX_S;
if (length < SWIPE_MIN_PX && !flick) { return base; }
// 경계까지 남은 각 — 90° 마다 되풀이되는 자리에서 45° 와의 차
const toBorder = Math.abs((angle % 90) - 45);
if (toBorder < DEAD_ZONE_DEG) { base.reason = VERDICT_DIAGONAL; return base; }
base.dir = ANGLE_TO_DIR[Math.round(angle / 90) % 4];
base.reason = length < SWIPE_MIN_PX ? VERDICT_FLICK : VERDICT_OK;
return base;
}
// ── 타일 색과 숫자 크기 ──────────────────────────────────────
// 단계 = log2(값) — 2 는 1단계, 2048 은 11단계, 8192 는 13단계
function stepOf(v: number): number { return v > 0 ? Math.log2(v) : 0; }
// 파스텔 기준색과 그 색이 놓이는 단계 — 사이 단계는 oklab 에서 곧게 섞는다
const TILE_ANCHORS: RGB[] = [[250, 238, 218], [246, 224, 150], [244, 170, 152], [244, 190, 212], [196, 182, 238], [170, 206, 238], [164, 218, 196]];
const ANCHOR_STEPS = [1, 3, 5, 7, 9, 11, 13];
type Lab = [number, number, number];
function toLinear(c: number): number { const x = c / 255; return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); }
function fromLinear(x: number): number { const c = x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055; return clamp(c * 255, 0, 255); }
// sRGB → oklab (지각 균등 색 공간 — 같은 거리만큼 섞으면 눈에도 비슷한 만큼 바뀐다)
function rgbToOklab(c: RGB): Lab {
const r = toLinear(c[0]);
const g = toLinear(c[1]);
const b = toLinear(c[2]);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [0.2104542553 * l + 0.7936177850 * m - 0.0040720468 * s,
1.9779984951 * l - 2.4285922050 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.8086757660 * s];
}
function oklabToRgb(c: Lab): RGB {
const l = Math.pow(c[0] + 0.3963377774 * c[1] + 0.2158037573 * c[2], 3);
const m = Math.pow(c[0] - 0.1055613458 * c[1] - 0.0638541728 * c[2], 3);
const s = Math.pow(c[0] - 0.0894841775 * c[1] - 1.2914855480 * c[2], 3);
return [fromLinear(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
fromLinear(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
fromLinear(-0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s)];
}
const ANCHOR_LABS: Lab[] = TILE_ANCHORS.map(rgbToOklab);
// 단계(소수 가능)의 색 — 양옆 기준 단계 사이 몫 k 로 oklab 세 값을 각각 곧게 섞는다(선형 보간). 끝 바깥은 끝 색
function tileColor(step: number): RGB {
const last = ANCHOR_STEPS.length - 1;
if (step <= ANCHOR_STEPS[0]) { return TILE_ANCHORS[0]; }
if (step >= ANCHOR_STEPS[last]) { return TILE_ANCHORS[last]; }
let i = 0;
while (step > ANCHOR_STEPS[i + 1]) { i += 1; }
const k = (step - ANCHOR_STEPS[i]) / (ANCHOR_STEPS[i + 1] - ANCHOR_STEPS[i]);
const a = ANCHOR_LABS[i];
const b = ANCHOR_LABS[i + 1];
return oklabToRgb([lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]);
}
// 비교용 — 같은 두 색을 sRGB 값 그대로 섞은 색
function tileColorRgb(step: number): RGB {
const last = ANCHOR_STEPS.length - 1;
if (step <= ANCHOR_STEPS[0]) { return TILE_ANCHORS[0]; }
if (step >= ANCHOR_STEPS[last]) { return TILE_ANCHORS[last]; }
let i = 0;
while (step > ANCHOR_STEPS[i + 1]) { i += 1; }
const k = (step - ANCHOR_STEPS[i]) / (ANCHOR_STEPS[i + 1] - ANCHOR_STEPS[i]);
return mix(TILE_ANCHORS[i], TILE_ANCHORS[i + 1], k);
}
// 숫자 크기 맞춤 — 칸 폭 × DIGIT_PX_RATIO 로 시작해, 잰 숫자 폭이 칸 폭 × DIGIT_FILL 을 넘으면 그 비율만큼 줄인다
const DIGIT_PX_RATIO = 0.5;
const DIGIT_FILL = 0.8;
const DIGIT_WEIGHT = 800;
type DigitFit = { px: number; width: number; rawWidth: number; scale: number };
function fitDigits(text: string, cell: number, m: Measure): DigitFit {
const px0 = cell * DIGIT_PX_RATIO;
const raw = textWidth(text, px0, DIGIT_WEIGHT, m);
const room = cell * DIGIT_FILL;
const scale = raw > room ? room / raw : 1;
return { px: px0 * scale, width: raw * scale, rawWidth: raw, scale: scale };
}
// ── 화면 타일 — 논리 판과 따로, 미끄러지는 동안의 자리와 크기를 든다 ──
// 자리는 (열, 행) 소수 좌표. 시각은 모두 장면 시계(초)
type Sprite = {
id: number; v: number;
fx: number; fy: number; tx: number; ty: number;
t0: number; dur: number;
show: number; hide: number;
pop: Spring;
squash: Spring;
axis: number;
landed: boolean;
lockUntil: number;
};
// 미끄러짐 모양 — 걸리는 시간 = base + perCell × (칸 수 − 1). easeIn 이면 점점 빨라져 벽에 빠르게 닿는다(아니면 부드럽게 서서 멈춤)
type SlideStyle = { base: number; perCell: number; easeIn: boolean };
type View = { sprites: Sprite[]; style: SlideStyle };
// 합친 타일 크기 스프링 — 크기 1 에서 POP_KICK(1/초)의 속도로 차올라 넘쳤다가 1 로 앉는다. 새 타일은 0 에서 자란다
const POP_STIFFNESS = 420;
const POP_DAMPING = 18;
const POP_KICK = 6;
// 새 타일은 미끄러짐이 다 끝난 뒤 이만큼 있다가 돋는다
const SPAWN_DELAY_S = 0.04;
// 되감기 때 새 타일이 오그라들어 사라지는 데 쓰는 시간
const SHRINK_S = 0.22;
// 숨는 시각이 없는 타일 — 아주 먼 뒤
const NO_HIDE = 1e9;
function makeSprite(t: Tile, show: number): Sprite {
const c = colOf(t.cell);
const r = rowOf(t.cell);
return { id: t.id, v: t.v, fx: c, fy: r, tx: c, ty: r, t0: 0, dur: 0, show: show, hide: NO_HIDE, pop: makeSpring(1), squash: makeSpring(0), axis: 0, landed: true, lockUntil: -1 };
}
function makeView(tiles: Tile[], style: SlideStyle): View {
return { sprites: tiles.map(function (t: Tile): Sprite { return makeSprite(t, -1); }), style: style };
}
// 판 그대로 다시 놓기 — 움직임 줄이기·사람 수를 멈춘 화면에서 바로 보일 때
function settleView(view: View, tiles: Tile[]): void {
view.sprites = tiles.map(function (t: Tile): Sprite { return makeSprite(t, -1); });
}
function slideTime(style: SlideStyle, cells: number): number { return cells > 0 ? style.base + style.perCell * (cells - 1) : 0; }
function findSprite(view: View, id: number): Sprite | null {
for (const s of view.sprites) { if (s.id === id && s.hide >= NO_HIDE) { return s; } }
return null;
}
function aimSprite(s: Sprite, from: number, to: number, t: number, style: SlideStyle): void {
s.fx = colOf(from); s.fy = rowOf(from); s.tx = colOf(to); s.ty = rowOf(to);
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
s.axis = s.fx !== s.tx ? 0 : 1;
s.t0 = t;
s.dur = slideTime(style, cells);
s.landed = cells === 0;
}
// 한 수를 화면 타일로 — 미끄러짐 시작 t. 합친 타일은 마지막 부모가 닿는 순간 나타나 스프링으로 부풀고, 새 타일은 그 뒤에 돋는다. 끝나는 시각을 돌려준다
function animateMove(view: View, mv: Move, t: number, lockS: number): number {
let end = t;
const arrive: { [id: number]: number } = {};
for (const sl of mv.slides) {
let s = findSprite(view, sl.id);
if (!s) { s = makeSprite({ id: sl.id, v: sl.v, cell: sl.from }, -1); view.sprites.push(s); }
aimSprite(s, sl.from, sl.to, t, view.style);
arrive[sl.id] = t + s.dur;
end = Math.max(end, t + s.dur);
}
for (const m of mv.merges) {
let at = t;
for (const p of m.parents) { at = Math.max(at, arrive[p]); }
for (const p of m.parents) { const s = findSprite(view, p); if (s) { s.hide = at; } }
const born = makeSprite({ id: m.id, v: m.v, cell: m.cell }, at);
born.pop.v = POP_KICK;
born.lockUntil = at + lockS;
view.sprites.push(born);
}
if (mv.spawn) {
const sp = makeSprite(mv.spawn, end + SPAWN_DELAY_S);
snapSpring(sp.pop, 0);
sp.pop.to = 1;
view.sprites.push(sp);
}
return end + SPAWN_DELAY_S;
}
// 되감기 — 기록을 거꾸로: 새 타일은 오그라들어 사라지고, 합친 타일은 그 자리에서 두 부모로 갈라져 각자 출발 칸으로 미끄러져 간다
function animateUndo(view: View, mv: Move, t: number): number {
let end = t;
if (mv.spawn) {
const s = findSprite(view, mv.spawn.id);
if (s) { s.pop.to = 0; s.hide = t + SHRINK_S; }
end = t + SHRINK_S;
}
const merged: { [id: number]: boolean } = {};
for (const m of mv.merges) {
const s = findSprite(view, m.id);
if (s) { s.hide = t; }
for (const p of m.parents) { merged[p] = true; }
}
for (const sl of mv.slides) {
let s = merged[sl.id] ? null : findSprite(view, sl.id);
if (!s) { s = makeSprite({ id: sl.id, v: sl.v, cell: sl.to }, t); view.sprites.push(s); }
aimSprite(s, sl.to, sl.from, t, view.style);
end = Math.max(end, t + s.dur);
}
return end;
}
// 진행 몫 u(0~1)의 자리 몫과, 그때 1초에 가는 칸 수
function slideAt(s: Sprite, t: number, easeIn: boolean): { x: number; y: number; speed: number } {
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
if (s.dur <= 0 || t >= s.t0 + s.dur) { return { x: s.tx, y: s.ty, speed: 0 }; }
const u = clamp((t - s.t0) / s.dur, 0, 1);
const k = easeIn ? u * u : smooth(u);
const dk = easeIn ? 2 * u : 6 * u * (1 - u);
return { x: lerp(s.fx, s.tx, k), y: lerp(s.fy, s.ty, k), speed: dk * cells / s.dur };
}
// 미끄러짐이 끝난 순간(착지) — 그 직전 속도와 함께 알린다. 크기 스프링도 여기서 한 걸음
type Landing = { s: Sprite; speed: number };
function stepView(view: View, t: number, dt: number): Landing[] {
const out: Landing[] = [];
for (const s of view.sprites) {
if (t < s.show) { continue; }
stepSpring(s.pop, POP_STIFFNESS, POP_DAMPING, dt);
if (!s.landed && t >= s.t0 + s.dur) {
s.landed = true;
const cells = Math.abs(s.tx - s.fx) + Math.abs(s.ty - s.fy);
out.push({ s: s, speed: view.style.easeIn ? 2 * cells / s.dur : 0 });
}
}
view.sprites = view.sprites.filter(function (s: Sprite): boolean { return t < s.hide; });
return out;
}
function viewBusy(view: View, t: number): boolean {
for (const s of view.sprites) { if (t < s.t0 + s.dur || (s.show > t)) { return true; } }
return false;
}
// ── 판 하나 = 논리 판 + 화면 타일 + 입력 대기열 ─────────────
// 미끄러지는 동안 들어온 입력은 대기열에 넣었다가 미끄러짐이 끝나면 둔다. 대기열이 꽉 차 있으면 그 입력은 버린다
const INPUT_QUEUE_MAX = 1;
type Player = {
game: Game; view: View; lock: boolean; lockS: number;
queue: number[]; queueMax: number; busyUntil: number;
last: Move | null; history: Move[];
got: number; played: number; dropped: number;
};
function makePlayer(values: number[], seed: number, style: SlideStyle, lock: boolean, lockS: number): Player {
const g = makeGame(values, seed);
return { game: g, view: makeView(g.tiles, style), lock: lock, lockS: lockS, queue: [], queueMax: INPUT_QUEUE_MAX, busyUntil: -1, last: null, history: [], got: 0, played: 0, dropped: 0 };
}
// 지금 바로 한 수 — 안 움직이는 방향이면 판도 기록도 그대로
function doMove(p: Player, dir: number, t: number): Move {
const mv = playMove(p.game, dir, p.lock);
p.last = mv;
if (!mv.moved) { return mv; }
p.busyUntil = animateMove(p.view, mv, t, p.lockS);
p.history.push(mv);
p.played += 1;
return mv;
}
// 입력 하나 — 미끄러지는 중이면 대기열에, 대기열이 꽉 찼으면 버린다. 둔 수가 있으면 돌려준다
function pushInput(p: Player, dir: number, t: number): Move | null {
p.got += 1;
if (t < p.busyUntil) {
if (p.queue.length < p.queueMax) { p.queue.push(dir); } else { p.dropped += 1; }
return null;
}
return doMove(p, dir, t);
}
// 미끄러짐이 끝났으면 대기열 맨 앞 입력을 둔다
function pumpQueue(p: Player, t: number): Move | null {
if (t < p.busyUntil || !p.queue.length) { return null; }
const dir = p.queue.shift();
return dir === undefined ? null : doMove(p, dir, t);
}
function settlePlayer(p: Player): void {
settleView(p.view, p.game.tiles);
p.busyUntil = -1;
p.queue = [];
}
function maxTile(p: Player): number { let m = 0; for (const t of p.game.tiles) { m = Math.max(m, t.v); } return m; }
// 상태 줄 — 둔 방향 · 이번 수에 합친 횟수 · 점수
const STATUS_MOVE = '{d} · 합침 {m} · 점수 {s}';
const STATUS_BLOCKED = '{d} — 움직일 타일 없음';
function moveStatus(p: Player, mv: Move): string {
if (!mv.moved) { return STATUS_BLOCKED.replace('{d}', DIR_NAMES[mv.dir]); }
return STATUS_MOVE.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(p.game.score));
}
// ── 기대값 탐색 ──────────────────────────────────────────────
// 판 평가 = 빈칸 수 × EMPTY_WEIGHT + 한쪽으로 커지는 순서 × MONO_WEIGHT + 이웃 값의 매끄러움 × SMOOTH_WEIGHT. 값은 log2 단계로 본다
const EMPTY_WEIGHT = 2.7;
const MONO_WEIGHT = 1.0;
const SMOOTH_WEIGHT = 0.1;
// 몇 층까지 내다보는가 — 한 층 = 네 방향 밀기 + 빈칸마다 2·4 떨어짐
const SEARCH_DEPTH = 2;
// 평가한 끝판 수(잎) — 한 번 고를 때마다 0 에서 센다
let searchLeaves = 0;
function lineSteps(vals: number[], cells: number[]): number[] { return cells.map(function (i: number): number { return stepOf(vals[i]); }); }
const LINES: number[][] = (function (): number[][] {
const out: number[][] = [];
for (let r = 0; r < SIZE; r += 1) { out.push([0, 1, 2, 3].map(function (c: number): number { return r * SIZE + c; })); }
for (let c = 0; c < SIZE; c += 1) { out.push([0, 1, 2, 3].map(function (r: number): number { return r * SIZE + c; })); }
return out;
})();
// 한쪽으로 커지는 순서 — 줄마다 오르막 합과 내리막 합 중 작은 쪽을 벌점으로(한 방향으로만 커지면 0)
function monotonicity(vals: number[]): number {
let pen = 0;
for (const ln of LINES) {
const s = lineSteps(vals, ln);
let up = 0;
let down = 0;
for (let i = 0; i + 1 < s.length; i += 1) {
if (s[i + 1] > s[i]) { up += s[i + 1] - s[i]; } else { down += s[i] - s[i + 1]; }
}
pen += Math.min(up, down);
}
return -pen;
}
// 매끄러움 — 맞닿은 두 타일(빈칸 제외) 단계 차의 합을 벌점으로
function smoothness(vals: number[]): number {
let pen = 0;
for (let i = 0; i < CELLS; i += 1) {
if (!vals[i]) { continue; }
if (colOf(i) + 1 < SIZE && vals[i + 1]) { pen += Math.abs(stepOf(vals[i]) - stepOf(vals[i + 1])); }
if (rowOf(i) + 1 < SIZE && vals[i + SIZE]) { pen += Math.abs(stepOf(vals[i]) - stepOf(vals[i + SIZE])); }
}
return -pen;
}
function evaluate(vals: number[]): number {
searchLeaves += 1;
return countEmpty(vals) * EMPTY_WEIGHT + monotonicity(vals) * MONO_WEIGHT + smoothness(vals) * SMOOTH_WEIGHT;
}
// 값만으로 미는 판 — 타일 번호 없이, 같은 한 줄 압축(잠금 있음)
function slideValues(vals: number[], dir: number): { vals: number[]; moved: boolean } {
const order = ORDERS[dir];
const out: number[] = vals.slice();
let moved = false;
for (let r = 0; r < SIZE; r += 1) {
const line: number[] = [];
for (let k = 0; k < SIZE; k += 1) { const v = vals[order[r * SIZE + k]]; if (v) { line.push(v); } }
const packed: number[] = [];
let locked = false;
for (const v of line) {
if (packed.length && packed[packed.length - 1] === v && !locked) { packed[packed.length - 1] = v * 2; locked = true; } else { packed.push(v); locked = false; }
}
for (let k = 0; k < SIZE; k += 1) {
const v = k < packed.length ? packed[k] : 0;
if (out[order[r * SIZE + k]] !== v) { moved = true; }
out[order[r * SIZE + k]] = v;
}
}
return { vals: out, moved: moved };
}
// 떨어질 차례 — 빈칸마다 2(확률 1 − FOUR_CHANCE)·4(FOUR_CHANCE)를 놓아 본 값의 평균. 빈칸 하나하나는 같은 확률로 뽑힌다
function chanceNode(vals: number[], depth: number): number {
const empty: number[] = [];
for (let i = 0; i < CELLS; i += 1) { if (!vals[i]) { empty.push(i); } }
if (!empty.length) { return depth > 1 ? moveNode(vals, depth - 1) : evaluate(vals); }
let total = 0;
for (const cell of empty) {
for (const v of [2, 4]) {
const p = v === 2 ? 1 - FOUR_CHANCE : FOUR_CHANCE;
const next = vals.slice();
next[cell] = v;
total += p * (depth > 1 ? moveNode(next, depth - 1) : evaluate(next));
}
}
return total / empty.length;
}
// 내 차례 — 움직이는 방향 중 가장 높은 기대값. 어느 쪽도 못 밀면 그 판 평가 그대로
function moveNode(vals: number[], depth: number): number {
let best = -Infinity;
for (let d = 0; d < 4; d += 1) {
const s = slideValues(vals, d);
if (!s.moved) { continue; }
best = Math.max(best, chanceNode(s.vals, depth));
}
return best === -Infinity ? evaluate(vals) : best;
}
// 네 방향 점수 — 못 미는 방향은 NaN. 가장 높은 방향이 다음 수
type Choice = { scores: number[]; best: number; leaves: number };
function chooseMove(vals: number[], depth: number): Choice {
searchLeaves = 0;
const scores: number[] = [];
let best = -1;
for (let d = 0; d < 4; d += 1) {
const s = slideValues(vals, d);
const v = s.moved ? chanceNode(s.vals, depth) : NaN;
scores.push(v);
if (!isNaN(v) && (best < 0 || v > scores[best])) { best = d; }
}
return { scores: scores, best: best, leaves: searchLeaves };
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const boardEl = root.querySelector('[data-role="board"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !boardEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const boardBox: HTMLElement = boardEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
// 잰 폭은 100px 기준으로 한 번만 재 두고 크기에 비례해 쓴다. 글꼴이 새로 오면 통째로 비운다
const widthCache: { [key: string]: number } = {};
function measureWith(stack: string): Measure {
return function (ch: string, px: number, weight: number): number {
const key = stack + '|' + weight + '|' + ch;
let w = widthCache[key];
if (w === undefined) {
ctx.font = weight + ' 100px ' + stack;
w = ctx.measureText(ch).width;
widthCache[key] = w;
}
return w * px / 100;
};
}
function remeasure(): void {
for (const k of Object.keys(widthCache)) { delete widthCache[k]; }
glyphWidth = measureWith(FONT_STACK);
relayout();
}
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearStage(c: RGB): void {
ctx.fillStyle = rgba(c, 1);
ctx.fillRect(0, 0, W, H);
}
function setFont(px: number, weight: number, stack: string): void {
ctx.font = weight + ' ' + px.toFixed(2) + 'px ' + stack;
}
function label(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
setFont(px, weight, FONT_STACK);
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
function roundRectPath(x: number, y: number, w: number, h: number, r: number): void {
const rr = Math.max(0, Math.min(r, w / 2, h / 2));
ctx.beginPath();
ctx.moveTo(x + rr, y);
ctx.lineTo(x + w - rr, y);
ctx.arcTo(x + w, y, x + w, y + rr, rr);
ctx.lineTo(x + w, y + h - rr);
ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr);
ctx.lineTo(x + rr, y + h);
ctx.arcTo(x, y + h, x, y + h - rr, rr);
ctx.lineTo(x, y + rr);
ctx.arcTo(x, y, x + rr, y, rr);
ctx.closePath();
}
function fillRound(x: number, y: number, w: number, h: number, r: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
roundRectPath(x, y, w, h, r);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function disc(x: number, y: number, r: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function line(x1: number, y1: number, x2: number, y2: number, width: number, c: RGB, a: number): void {
if (a <= 0) { return; }
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineWidth = width;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
// 작은 알약 이름표
function pill(s: string, x: number, y: number, bg: RGB, fg: RGB, a: number, px: number): void {
setFont(px, 700, FONT_STACK);
const w = ctx.measureText(s).width + px * 1.2;
fillRound(x - w / 2, y - px * 0.85, w, px * 1.7, px * 0.85, bg, a);
label(s, x, y + 0.5, fg, a, px, 700, 'center');
}
// 상태 줄 — 바뀔 때만 글자를 고친다
let shownStatus = '';
function pushStatus(): void {
if (!statusEl || simStatus === shownStatus) { return; }
shownStatus = simStatus;
statusEl.textContent = simStatus;
}
// ── 판·타일 그리기 ───────────────────────────────────────────
// 판 자리 — 왼쪽 위 (x, y), 칸 한 변 cell, 칸 사이 gap
type Frame = { x: number; y: number; cell: number; gap: number };
function frameSize(f: Frame): number { return SIZE * f.cell + (SIZE + 1) * f.gap; }
function cellCenter(f: Frame, col: number, row: number): [number, number] {
return [f.x + f.gap + col * (f.cell + f.gap) + f.cell / 2, f.y + f.gap + row * (f.cell + f.gap) + f.cell / 2];
}
function drawBoardBack(f: Frame, a: number): void {
const s = frameSize(f);
fillRound(f.x, f.y, s, s, f.cell * 0.16, BOARD_BG, a);
for (let r = 0; r < SIZE; r += 1) {
for (let c = 0; c < SIZE; c += 1) {
const p = cellCenter(f, c, r);
fillRound(p[0] - f.cell / 2, p[1] - f.cell / 2, f.cell, f.cell, f.cell * 0.12, CELL_BG, a);
}
}
}
// 타일 하나 — 가운데 (cx, cy), 한 변 size, 가로·세로 배율 sx·sy, 색 단계 step(소수면 두 색 사이). fit 이 거짓이면 숫자를 줄이지 않는다(넘치는 판)
function drawTileAt(v: number, cx: number, cy: number, size: number, sx: number, sy: number, a: number, step: number, fit: boolean): void {
if (a <= 0.01 || sx <= 0.01 || sy <= 0.01) { return; }
const w = size * sx;
const h = size * sy;
fillRound(cx - w / 2, cy - h / 2, w, h, size * 0.12, tileColor(step), a);
const text = String(v);
const f = fitDigits(text, size, glyphWidth);
const px = fit ? f.px : size * DIGIT_PX_RATIO;
ctx.save();
ctx.translate(cx, cy);
ctx.scale(sx, sy);
setFont(px, DIGIT_WEIGHT, FONT_STACK);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(INK, a);
ctx.fillText(text, 0, px * 0.04);
ctx.restore();
}
// 화면 타일 전부 — 미끄러짐 모양은 view.style, 늘임은 stretch 가 정한다(가로 배율, 세로 배율을 돌려줌)
type Stretch = (s: Sprite, speed: number) => [number, number];
function noStretch(s: Sprite, speed: number): [number, number] { void s; void speed; return [1, 1]; }
function drawView(view: View, f: Frame, t: number, a: number, stretch: Stretch): void {
// 미끄러지는 타일을 나중에 — 서 있는 타일 위로 지나가게
const order = view.sprites.filter(function (s: Sprite): boolean { return t >= s.show; });
order.sort(function (p: Sprite, q: Sprite): number { return (p.landed ? 0 : 1) - (q.landed ? 0 : 1); });
for (const s of order) {
const at = slideAt(s, t, view.style.easeIn);
const p = cellCenter(f, at.x, at.y);
const k = Math.max(0, s.pop.x);
const st = stretch(s, at.speed);
drawTileAt(s.v, p[0], p[1], f.cell, k * st[0], k * st[1], a, stepOf(s.v), true);
if (t < s.lockUntil) {
// 잠금 표시 — 이번 수에 합쳐진 타일. 같은 수 안에서는 다시 합치지 않는다
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(VIOLET_DEEP, a * 0.9);
roundRectPath(p[0] - f.cell * 0.5 * k - 3, p[1] - f.cell * 0.5 * k - 3, f.cell * k + 6, f.cell * k + 6, f.cell * 0.16);
ctx.stroke();
}
}
}
// 09 — 첫 화면에서 스스로 두어 가는 2048 판. 시드 고정 중반 판에서 시작해 수마다
// ① chooseMove(07)로 방향을 고르고 ② 그 방향으로 흉내 낸 쓸기 궤적을 그려 judgeSwipe(03)로 판정한 뒤
// ③ 늘었다 줄며 미끄러지고(02) ④ 잠금 있는 한 줄 압축으로 합쳐(01) ⑤ 단계 색으로 그리고(05) ⑥ 조각을 튀기며 흔들린다(06).
// 판이 막히거나(빈칸 0 · 짝 0) ROUND_MOVES 수를 두면 타일이 떨어져 튄 뒤(08) 처음 판으로 돌아간다 — 탐색이 막힘을 잘 피해 한참 동안 막히지 않는다
const START_09 = [
2, 0, 4, 2,
8, 4, 16, 4,
16, 64, 32, 8,
128, 256, 64, 16
];
const SEED_09 = 909;
const SLIDE_BASE_S = 0.16;
const SLIDE_PER_CELL_S = 0.04;
const STYLE_09: SlideStyle = { base: SLIDE_BASE_S, perCell: SLIDE_PER_CELL_S, easeIn: true };
const STRETCH_PER_SPEED = 0.012;
const STRETCH_MAX = 1.4;
const LAND_KICK = 0.25;
const LAND_STIFFNESS = 600;
const LAND_DAMPING = 16;
const BURST_PER_STEP = 3;
const BURST_SPEED_PER_STEP = 34;
const BURST_GRAVITY = 520;
const BURST_DRAG = 2.4;
const BURST_LIFE_S = 0.8;
const BURST_LIFT = 60;
const SHAKE_PER_MERGE = 1.6;
const SHAKE_DECAY = 6;
const SHAKE_HZ = 16;
const FALL_GRAVITY = 1600;
const BOUNCE = 0.42;
const REST_SPEED = 70;
const COLLAPSE_SPREAD_S = 0.3;
const FALL_SPREAD_VX = 220;
// 수 사이 간격과 흉내 낸 쓸기 궤적(길이 px · 걸린 ms)
const FIRST_MOVE_09 = 0.2;
const MOVE_GAP_09 = 0.42;
const SWIPE_LEN_PX = 70;
const SWIPE_MS = 130;
// 방향 번호(왼쪽·위·오른쪽·아래)의 화면 각 — 0° 오른쪽, 90° 아래
const DIR_ANGLE_09 = [180, 270, 0, 90];
// 판이 막힌 뒤 무너지기까지, 다 떨어진 뒤 처음 판으로 돌아가기까지
const STUCK_HOLD_S = 0.35;
const FALLEN_HOLD_S = 0.5;
const SCORE_RATE = 10;
// 한 바퀴에 두는 수 — 이만큼 두면 막히지 않아도 무너뜨리고 처음 판으로
const ROUND_MOVES = 16;
const LOOP_S = 600;
// 판 자리 — 화면과 같은 값
const F09_X = 214;
const F09_Y = 16;
const F09_CELL = 52;
const F09_GAP = 6;
const FLOOR_09 = 290;
type Shard9 = { x: number; y: number; vx: number; vy: number; born: number; rot: number; spin: number; size: number; c: RGB };
type Body9 = { v: number; x: number; y: number; vx: number; vy: number; delay: number; resting: boolean; rot: number; spin: number };
let hero = makePlayer(START_09, SEED_09, STYLE_09, true, 0.25);
let fxRng = makeRng(99);
let shards9: Shard9[] = [];
let bodies9: Body9[] = [];
let pend9: { at: number; cell: number; v: number }[] = [];
let swipeAt = -1;
let swipeDir = -1;
let choice9: Choice | null = null;
let nextAt09 = FIRST_MOVE_09;
let shakeAt9 = -10;
let shakeAmp9 = 0;
let scoreShown = 0;
let stuckAt9 = -1;
let fallAt9 = -1;
let moves9 = 0;
function reset(): void {
hero = makePlayer(START_09, SEED_09, STYLE_09, true, 0.25);
fxRng = makeRng(99);
shards9 = [];
bodies9 = [];
pend9 = [];
swipeAt = -1;
swipeDir = -1;
choice9 = null;
nextAt09 = sceneT + FIRST_MOVE_09;
shakeAt9 = -10;
shakeAmp9 = 0;
scoreShown = 0;
stuckAt9 = -1;
fallAt9 = -1;
moves9 = 0;
stuckReal9 = false;
}
function relayout(): void { }
function center09(col: number, row: number): [number, number] {
return [F09_X + F09_GAP + col * (F09_CELL + F09_GAP) + F09_CELL / 2, F09_Y + F09_GAP + row * (F09_CELL + F09_GAP) + F09_CELL / 2];
}
function shake9(t: number): number { return t < shakeAt9 ? 0 : shakeAmp9 * Math.exp(-SHAKE_DECAY * (t - shakeAt9)); }
function scales9(s: Sprite, speed: number): [number, number] {
const along = Math.min(STRETCH_MAX, 1 + STRETCH_PER_SPEED * speed) * Math.max(0.55, 1 + s.squash.x);
return s.axis === 0 ? [along, 1 / along] : [1 / along, along];
}
const STATUS_HERO = '{d} 쓸기 · 합침 {m} · 점수 {s}';
const STATUS_FALL = '빈칸 0 · 짝 0 — 무너짐 · {n}수 만에';
const STATUS_ROUND = '{n}수 — 한 바퀴 끝, 무너짐';
let stuckReal9 = false;
function afterMove9(mv: Move | null): void {
if (!mv || !mv.moved) { return; }
moves9 += 1;
simStatus = STATUS_HERO.replace('{d}', DIR_NAMES[mv.dir]).replace('{m}', String(mv.merges.length)).replace('{s}', String(hero.game.score));
let first = NO_HIDE;
for (const s of hero.view.sprites) {
for (const m of mv.merges) { if (s.id === m.id) { first = Math.min(first, s.show); pend9.push({ at: s.show, cell: m.cell, v: m.v }); } }
}
if (mv.merges.length) {
shakeAmp9 = shake9(first) + SHAKE_PER_MERGE * mv.merges.length;
shakeAt9 = first;
}
stuckReal9 = isStuck(valuesOf(hero.game.tiles));
if (stuckReal9 || moves9 >= ROUND_MOVES) { stuckAt9 = hero.busyUntil + STUCK_HOLD_S; }
}
function humanMove(dir: number): void { if (stuckAt9 < 0) { afterMove9(pushInput(hero, dir, sceneT)); } }
function onSwipe(v: Verdict): void { if (v.dir >= 0) { humanMove(v.dir); } }
function burst9(cell: number, v: number, t: number): void {
const k = stepOf(v);
const c = center09(colOf(cell), rowOf(cell));
for (let i = 0; i < BURST_PER_STEP * k; i += 1) {
const a = fxRng() * TAU;
const sp = BURST_SPEED_PER_STEP * k * (0.55 + 0.45 * fxRng());
shards9.push({ x: c[0], y: c[1], vx: Math.cos(a) * sp, vy: Math.sin(a) * sp - BURST_LIFT, born: t, rot: fxRng() * TAU, spin: (fxRng() - 0.5) * 14, size: 3 + 3 * fxRng(), c: tileColor(k) });
}
}
function startFall9(): void {
bodies9 = hero.game.tiles.map(function (t: Tile): Body9 {
const c = center09(colOf(t.cell), rowOf(t.cell));
return { v: t.v, x: c[0], y: c[1], vx: (fxRng() - 0.5) * FALL_SPREAD_VX, vy: 0, delay: fxRng() * COLLAPSE_SPREAD_S, resting: false, rot: 0, spin: (fxRng() - 0.5) * 3 };
});
}
function stepFall9(dt: number, since: number): boolean {
let all = true;
for (const b of bodies9) {
if (b.resting) { continue; }
all = false;
if (since < b.delay) { continue; }
b.vy += FALL_GRAVITY * dt;
b.y += b.vy * dt;
b.x = clamp(b.x + b.vx * dt, F09_CELL / 2, W - F09_CELL / 2);
b.rot += b.spin * dt;
const floor = FLOOR_09 - F09_CELL / 2;
if (b.y >= floor) {
b.y = floor;
b.vy = -b.vy * BOUNCE;
b.vx *= 0.6;
b.spin *= 0.5;
if (Math.abs(b.vy) < REST_SPEED) { b.vy = 0; b.resting = true; }
}
}
return all;
}
let fallenAt9 = -1;
function update(dt: number): void {
if (stuckAt9 >= 0) {
if (fallAt9 < 0 && sceneT >= stuckAt9) {
fallAt9 = sceneT;
startFall9();
simStatus = (stuckReal9 ? STATUS_FALL : STATUS_ROUND).replace('{n}', String(moves9));
}
if (fallAt9 >= 0) {
if (stepFall9(dt, sceneT - fallAt9) && fallenAt9 < 0) { fallenAt9 = sceneT; }
if (fallenAt9 >= 0 && sceneT >= fallenAt9 + FALLEN_HOLD_S) { fallenAt9 = -1; reset(); }
}
} else {
// 쓸기 흉내 — 고른 방향으로 궤적을 그리고, 다 그리면 judgeSwipe 로 판정해 받은 방향으로 둔다
if (autoOn && swipeAt < 0 && sceneT >= nextAt09) {
choice9 = chooseMove(valuesOf(hero.game.tiles), SEARCH_DEPTH);
if (choice9.best >= 0) { swipeAt = sceneT; swipeDir = choice9.best; }
nextAt09 = sceneT + MOVE_GAP_09;
}
if (swipeAt >= 0 && sceneT >= swipeAt + SWIPE_MS / 1000) {
const a = DIR_ANGLE_09[swipeDir] * Math.PI / 180;
const v = judgeSwipe(Math.cos(a) * SWIPE_LEN_PX, Math.sin(a) * SWIPE_LEN_PX, SWIPE_MS);
swipeAt = -1;
if (v.dir >= 0) { afterMove9(pushInput(hero, v.dir, sceneT)); }
}
afterMove9(pumpQueue(hero, sceneT));
}
for (const p of pend9.filter(function (q: { at: number }): boolean { return sceneT >= q.at; })) { burst9(p.cell, p.v, sceneT); }
pend9 = pend9.filter(function (q: { at: number }): boolean { return sceneT < q.at; });
const drag = Math.exp(-BURST_DRAG * dt);
for (const s of shards9) {
s.vy += BURST_GRAVITY * dt;
s.vx *= drag;
s.vy *= drag;
s.x += s.vx * dt;
s.y += s.vy * dt;
s.rot += s.spin * dt;
}
shards9 = shards9.filter(function (s: Shard9): boolean { return sceneT - s.born < BURST_LIFE_S; });
scoreShown += (hero.game.score - scoreShown) * follow(SCORE_RATE, dt);
for (const l of stepView(hero.view, sceneT, dt)) { l.s.squash.v = -LAND_KICK * l.speed; }
for (const s of hero.view.sprites) { stepSpring(s.squash, LAND_STIFFNESS, LAND_DAMPING, dt); }
}
function settle(): void {
settlePlayer(hero);
shards9 = [];
pend9 = [];
swipeAt = -1;
shakeAmp9 = 0;
scoreShown = hero.game.score;
if (stuckAt9 >= 0) { reset(); settlePlayer(hero); }
}
// 정지 판 — 처음 판에서 자동 플레이가 고른 첫 수까지 둔 판
function restPose(): void {
choice9 = chooseMove(valuesOf(hero.game.tiles), SEARCH_DEPTH);
if (choice9.best >= 0) { afterMove9(doMove(hero, choice9.best, 0)); }
settle();
}
// 09 화면 — 오른쪽 큰 판(흔들림만큼 밀림 · 쓸기 궤적 · 조각), 왼쪽 제목·점수·가장 큰 타일·네 방향 막대
const F09: Frame = { x: F09_X, y: F09_Y, cell: F09_CELL, gap: F09_GAP };
const LABEL_TITLE = '2048';
const LABEL_TAGLINE = '같은 수를 밀어 합치기';
const LABEL_SCORE = '점수';
const LABEL_BEST = '가장 큰 타일';
const LABEL_NEXT = '다음 수 기대값';
const HERO_BARS: RGB[] = [BUTTER, LAVENDER, MINT, SKY];
function bindInput(signal: AbortSignal): void { void signal; }
function draw(): void {
clearStage(PAPER);
label(LABEL_TITLE, 24, 44, INK, 1, 44, 800, 'left');
label(LABEL_TAGLINE, 26, 76, INK_SOFT, 1, 11, 700, 'left');
label(LABEL_SCORE, 26, 104, INK_SOFT, 1, 10, 700, 'left');
label(String(Math.round(scoreShown)), 26, 126, VIOLET_DEEP, 1, 24, 800, 'left');
label(LABEL_BEST, 26, 156, INK_SOFT, 1, 10, 700, 'left');
const best = maxTile(hero);
if (best) { drawTileAt(best, 50, 186, 44, 1, 1, 1, stepOf(best), true); }
// 네 방향 기대값 막대 — 가장 낮은 방향 0.25, 가장 높은 방향 1
label(LABEL_NEXT, 104, 156, INK_SOFT, 1, 10, 700, 'left');
if (choice9) {
const ok = choice9.scores.filter(function (s: number): boolean { return !isNaN(s); });
const lo = Math.min.apply(null, ok);
const hi = Math.max.apply(null, ok);
for (let d = 0; d < 4; d += 1) {
const s = choice9.scores[d];
const k = isNaN(s) ? 0 : (hi - lo < 1e-9 ? 1 : 0.25 + 0.75 * (s - lo) / (hi - lo));
fillRound(106 + d * 21, 208 - 44 * k, 15, 44 * k, 4, HERO_BARS[d], d === choice9.best ? 1 : 0.6);
}
}
const off = shake9(sceneT);
const ph = TAU * SHAKE_HZ * (sceneT - shakeAt9);
ctx.save();
ctx.translate(off * Math.sin(ph), off * 0.6 * Math.cos(ph * 1.3));
drawBoardBack(F09, 1);
if (fallAt9 < 0) { drawView(hero.view, F09, sceneT, 1, scales9); }
ctx.restore();
for (const b of bodies9) {
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(b.rot);
drawTileAt(b.v, 0, 0, F09.cell, 1, 1, 1, stepOf(b.v), true);
ctx.restore();
}
for (const s of shards9) {
const life = (sceneT - s.born) / BURST_LIFE_S;
ctx.save();
ctx.translate(s.x, s.y);
ctx.rotate(s.rot);
ctx.fillStyle = rgba(mix(s.c, INK, 0.18), 1 - life * life);
ctx.fillRect(-s.size / 2, -s.size / 2, s.size, s.size);
ctx.restore();
}
// 흉내 낸 쓸기 — 판 가운데에서 고른 방향으로 그려지는 궤적
if (swipeAt >= 0) {
const u = clamp((sceneT - swipeAt) / (SWIPE_MS / 1000), 0, 1);
const a = DIR_ANGLE_09[swipeDir] * Math.PI / 180;
const cx = F09.x + frameSize(F09) / 2;
const cy = F09.y + frameSize(F09) / 2;
const len = SWIPE_LEN_PX;
line(cx, cy, cx + Math.cos(a) * len * u, cy + Math.sin(a) * len * u, 9, INK, 0.35);
disc(cx + Math.cos(a) * len * u, cy + Math.sin(a) * len * u, 12, INK, 0.3);
}
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시가 붙어 있는가 — 사람이 처음 손대면 표시를 떼고 상태 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
if (statusEl) { statusEl.setAttribute('aria-live', 'polite'); }
}
// 사람이 한 수를 두면 자동 시연은 수를 그만 두고, 판은 그 자리에서 사람 판이 된다
function takeOver(): void {
wake();
autoOn = false;
}
// 멈춘 화면(움직임 줄이기·일시정지)에서 둔 수는 미끄러짐 없이 결과 판으로 바로 그린다
function showNow(): void {
if (still || paused) {
settle();
draw();
pushStatus();
}
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 장면 시계를 dt 만큼 — 자동 시연 중에는 한 바퀴(LOOP_S)를 넘으면 처음 판으로 되돌려 다시 시연한다
function advance(dt: number): void {
sceneT += dt;
if (autoOn && sceneT >= LOOP_S) {
sceneT -= LOOP_S;
reset();
}
update(dt);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
pushStatus();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 정지 판 한 장만 그린다
function showRest(): void {
stopLoop();
if (autoOn) {
sceneT = 0;
reset();
restPose();
} else {
settle();
}
draw();
pushStatus();
}
function play(): void {
if (autoOn) {
sceneT = 0;
reset();
}
draw();
pushStatus();
if (!paused) { startLoop(); }
}
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 방향키 — 판에 포커스가 있을 때만 받는다. 판 밖에서는 화살표가 원래대로 페이지를 스크롤한다
const KEY_DIRS: { [key: string]: number } = { ArrowLeft: DIR_LEFT, ArrowUp: DIR_UP, ArrowRight: DIR_RIGHT, ArrowDown: DIR_DOWN };
boardBox.addEventListener('keydown', function (e: KeyboardEvent): void {
const dir = KEY_DIRS[e.key];
if (dir === undefined || e.altKey || e.ctrlKey || e.metaKey) { return; }
e.preventDefault();
takeOver();
humanMove(dir);
showNow();
}, { signal: off.signal });
// 쓸기 — 누른 자리와 뗀 자리의 차, 걸린 시간을 쓸기 판정(judgeSwipe)에 넣는다. 화면 px 그대로
let downX = 0;
let downY = 0;
let downT = -1;
boardBox.addEventListener('pointerdown', function (e: PointerEvent): void {
downX = e.clientX;
downY = e.clientY;
downT = e.timeStamp;
}, { signal: off.signal });
boardBox.addEventListener('pointerup', function (e: PointerEvent): void {
if (downT < 0) { return; }
const v = judgeSwipe(e.clientX - downX, e.clientY - downY, e.timeStamp - downT);
downT = -1;
if (v.length < FLICK_MIN_PX) { return; }
takeOver();
onSwipe(v);
showNow();
}, { signal: off.signal });
boardBox.addEventListener('pointercancel', function (): void { downT = -1; }, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still && fontsReady) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 정지 판으로 멈추고, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
bindInput(off.signal);
fitCanvas();
// 숫자 폭은 글꼴이 다 온 뒤에 잰다 — 그 전에 잰 폭은 대체 글꼴의 폭이라 글꼴이 바뀌는 순간 글자 크기 맞춤이 틀어진다.
// 나중에 다른 글꼴이 더 오면(loadingdone) 다시 잰다
let fontsReady = false;
document.fonts.addEventListener('loadingdone', function (): void {
if (!fontsReady) { return; }
remeasure();
draw();
}, { signal: off.signal });
document.fonts.ready.then(function (): void {
if (off.signal.aborted) { return; }
fontsReady = true;
remeasure();
if (still) { showRest(); } else { play(); }
});
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<div className={cx("cf__board")} data-role="board" tabIndex={0} role="application" aria-label={'2048 판 — 누른 뒤 화살표 키나 쓸기로 한 수'}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} aria-hidden={true}></canvas>
</div>
<p className={cx("cf__sr")}>{'2048 판이 스스로 다음 수를 셈해 쓸고, 늘었다 줄며 미끄러진 타일이 합쳐져 색이 바뀌고 조각이 튀며, 막히면 무너졌다 처음으로 돌아간다'}</p>
<p className={cx("cf__status")} data-role="status" aria-live="off">{'판을 펼치는 중'}</p>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 A Hero Scene Where the 2048 Board Plays Itself — Measured area 25.374% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · 2048 Game JavaScript: 9 Boards from Merges to AI
The post this demo lives in
2048 Game JavaScript: 9 Boards from Merges to AIA 2048 game javascript board folds rows. A row of 2, 2, 4 pushed left ends as 4, 4, not 8. 01 locks a merged tile for th See all demos in this post
If you like this effect - similar demos
game09 The hero scene — a finished wheel with a confetti burst and a saveable resultSpin Wheel: 9 Canvas Roulettes, Flick to Stop
game03 A Board Where Every Empty Cell Is Tinted by a Score Read Out to the Very End and the Best Cell Lights UpCSS Tic Tac Toe: 9 Boards That Count Ahead
game08 A Board Where Distance Becomes Heat Color and the Answer Appears as a ValleyMaze Generator: 9 Carve-and-Solve Scenes