ブロック崩しゲーム 9種 — パドルの反射角から高速球のスイープ衝突と連鎖爆発まで
ブロック崩しはボールが当たった位置をどうやって向きに変えるのでしょうか。01番はパドル中心からの距離で反射角を決め、02番は一コマの移動を刻んで速い球がブロックを飛び越えないようにし、08番は壁の反射を展開して落下地点を先に計算します。
ブロック崩しは球が跳ねる絵ではなく、当たった位置と移動の区間という二つの値から反射の向きも衝突の順番もブロックの状態もすべて計算する仕組みです — ブロック崩しゲームを作るには、どの値が球の向きを決めるのかを知ることから始まります
01 bb-paddle-hit-offset-bounce-angle
02 bb-swept-circle-brick-tunneling-guard
03 bb-brick-face-corner-normal-pick
04 bb-paddle-velocity-spin-curve-transfer
05 bb-multiball-split-fixed-angle-table
06 bb-brick-hit-points-crack-tint
07 bb-ball-speed-ramp-trail-length
08 bb-landing-predict-wall-unfold-autopilot
09 bb-string-grid-level-chain-blast-wave
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 パドルに当たった位置で決める反射角
- 02 速い球を捕まえるスイープ衝突
- 03 角で反射する面を選ぶ法線
- 04 パドルの速さが球を曲げる回転の伝わり
- 05 固定の角度表で分かれるマルチボール
- 06 耐久に応じて広がるブロックのひび
- 07 速度段階に応じて伸びる残像
- 08 壁の反射を展開して落下地点を予測するパドル
- 09 文字列の格子レベルと連鎖爆発の波
九つの場面は同じ計算の枠を使い、場面ごとに違うのはスイッチが一つ二つと定数がいくつかだけです。球は位置・速度・回転、ブロックは耐久と時刻、パドルは横位置と速度という値にすぎず、キャンバスはその値を塗る仕事だけを受け持ちます。
球・パドル・ブロックは一つの計算の枠からだけ出ます
World が Ball・Brick・Mode を持ち、一歩の stepWorld がパドルの操作、球ごとの moveBall、ブロックの reflectBricks の順に値を変えます。drawWorld は値を読んで描くだけなので、場面が違っても衝突の結果は同じ関数から出て、右の対比盤は Mode のスイッチだけを変えて動かした同じ計算です。
固定の歩み 1/120秒と DT_MAX 0.05
rAF が知らせる経過時間は DT_MAX の0.05秒で切り、たまった時間を STEP_H の1/120秒ずつの歩みに刻んで stepWorld を呼びます。モニターが60Hzでも144Hzでも一歩の計算時間が同じなので球の軌跡が揃い、隠れたタブから戻っても一度に0.05秒より先へは進みません。ただし02番だけは w.step を FRAME_H の1/60秒にして、advanceWorld(w, dt × SLOW) で場面を遅く見せています。
// 時間を固定の歩みに刻んで呼ぶ — 一度に DT_MAX を超える時間は足さない
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
乱数はひび割れの形にだけ使います
球の動きに乱数はなく、発射角は LAUNCH_DEG、パドルに当たる位置は AIMS と aims、分裂の角は ANGLE_TABLE というあらかじめ書いた表から順に取ります。乱数は掛け算1664525・足し算1013904223の種を固定した生成器 seeded ひとつだけで、ブロックの行と列から種を決めてひび線の形にだけ使います。
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
ブロック崩し 9種 — パドルの位置で決める反射角・一コマを刻むスイープ衝突・角の法線選び・パドルの速さが作る曲がる軌道・固定角度表のマルチボール・耐久ごとのひび・速度段階と残像・落下地点を予測するパドル・文字列の格子レベルと連鎖爆発
九つの場面を描画して測った面積・フレーム・強度
| 場面 | 累計面積 | 動いたフレーム | 強度の平均 |
|---|---|---|---|
| 01 反射角 | 11.2% | 23/23 | 90.7 |
| 02 スイープ衝突 | 29.8% | 22/23 | 62.5 |
| 03 角の法線 | 11.3% | 23/23 | 87.4 |
| 04 回転の伝わり | 15.1% | 23/23 | 67.9 |
| 05 マルチボール | 12.9% | 23/23 | 88.9 |
| 06 耐久のひび | 7.9% | 21/23 | 86.8 |
| 07 速度段階 | 9.1% | 23/23 | 93.9 |
| 08 落下予測 | 14.8% | 23/23 | 87.4 |
| 09 文字列レベル | 12.4% | 23/23 | 86.8 |
累計面積がいちばん広いのは02の29.8%で、いちばん狭いのは06の7.9%です。動いたフレームは02が22/23、06が21/23で、残り七つの場面は23/23、強度の平均は07の93.9が最も高くなっています。
01パドルに当たった位置で決める反射角
パドルで球の向きを狙う決まりを最初に覚えるときに触る値で、パドル中心から球までの距離を半幅で割った hitOffset が−1から1になり、反射角は u × MAX_ANGLE の58度です。右の盤は vy = −|vy| の鏡の反射だけを使うので同じ筋を上下するだけになります。当たった位置−1・−0.5・0・0.5・1は−58.0度・−29.0度・0度・29.0度・57.9度で、12秒に左は11枚、右は4枚を壊しました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="左はパドルの中心から当たった位置までの距離で反射角を決め、右はミラー反射だけを使い、両方の盤に同じブロック4段が並びます。左の球は受ける位置が変わるたびに別の列へ飛んでブロックを壊し、右の球は同じ縦の列を上り下りするだけです"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 패들 맞은 자리로 정하는 반사각(왼쪽) vs ミラー反射のみ(오른쪽) ─────────────────
const PADDLE_W = 96;
// 맞은 자리를 바꿔 가며 받는 순서 — 패들 반폭 대비 비율(−1 왼쪽 끝 · 0 가운데 · 1 오른쪽 끝)
const AIMS = [0.1, 0.7, -0.55, 0.35, -0.8, 0.6, -0.3];
const BASE_SPEED = 280;
const LAP_S = 12.0;
const START_S = 3.0;
const REST_S = 6.0;
let WS = [];
function build() {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판은 같은 벽돌·같은 공·같은 자동 조종 — 다른 건 패들이 공의 방향을 정하는 규칙 하나(angle 0 맞은 자리 / 1 거울)
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: i, spin: 0, push: 0, decay: 0 });
w.pw = PADDLE_W;
// 공을 벽돌 한 칸의 한가운데 아래에서 쏜다 — 거울 반사는 같은 줄을 오르내리는 모습이 그대로 나온다
w.px = 98;
w.speed = BASE_SPEED;
w.aims = AIMS;
w.launches = [0];
w.trailN = 20;
fillGrid(w, 4, 8, 22, 12, 2, function () { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt) {
for (const w of WS) {
advanceWorld(w, dt);
}
}
function statusText() {
return '左 {a}枚・右 {b}枚 破壊'.replace('{a}', String(WS[0].broken)).replace('{b}', String(WS[1].broken));
}
// 직접 하면 왼쪽 판의 패들을 몬다
// ── draw ──────────────────────────────────────────────
// 패들 위 부채 — 맞은 자리(−1~1)가 각도(−MAX_ANGLE~MAX_ANGLE)로 바뀌는 모양과 마지막으로 맞은 자리
function fanOver(w) {
for (let i = -1; i <= 1; i += 1) {
const a = i * MAX_ANGLE;
const x0 = w.px + i * w.pw / 2;
seg(x0, w.py - 3, x0 + Math.sin(a) * 26, w.py - 3 - Math.cos(a) * 26, 1.6, LAV_DEEP, 0.55);
}
const age = w.t - w.padT;
if (age >= 0 && age < 0.9) {
const x = w.px + w.lastU * w.pw / 2;
ring(x, w.py + 1, 4 + age * 14, 2, ROSE_DEEP, 0.9 * (1 - age / 0.9));
disc(x, w.py + 1, 2.6, ROSE_DEEP, 1 - age / 0.9);
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, fanOver);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '当たった位置で反射角', false);
panelTag(WS[1], 'ミラー反射のみ', true);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PaddleHitOffsetBounceAngle - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PaddleHitOffsetBounceAngle.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PaddleHitOffsetBounceAngle() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 01 패들 맞은 자리로 정하는 반사각(왼쪽) vs 거울 반사만(오른쪽) ─────────────────
const PADDLE_W = 96;
// 맞은 자리를 바꿔 가며 받는 순서 — 패들 반폭 대비 비율(−1 왼쪽 끝 · 0 가운데 · 1 오른쪽 끝)
const AIMS = [0.1, 0.7, -0.55, 0.35, -0.8, 0.6, -0.3];
const BASE_SPEED = 280;
const LAP_S = 12.0;
const START_S = 3.0;
const REST_S = 6.0;
let WS: World[] = [];
function build(): void {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판은 같은 벽돌·같은 공·같은 자동 조종 — 다른 건 패들이 공의 방향을 정하는 규칙 하나(angle 0 맞은 자리 / 1 거울)
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: i, spin: 0, push: 0, decay: 0 });
w.pw = PADDLE_W;
// 공을 벽돌 한 칸의 한가운데 아래에서 쏜다 — 거울 반사는 같은 줄을 오르내리는 모습이 그대로 나온다
w.px = 98;
w.speed = BASE_SPEED;
w.aims = AIMS;
w.launches = [0];
w.trailN = 20;
fillGrid(w, 4, 8, 22, 12, 2, function (): number { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt: number): void {
for (const w of WS) { advanceWorld(w, dt); }
}
function statusText(): string {
return '왼쪽 {a}장 · 오른쪽 {b}장 깨짐'.replace('{a}', String(WS[0].broken)).replace('{b}', String(WS[1].broken));
}
// 직접 하면 왼쪽 판의 패들을 몬다
// ── draw ──────────────────────────────────────────────
// 패들 위 부채 — 맞은 자리(−1~1)가 각도(−MAX_ANGLE~MAX_ANGLE)로 바뀌는 모양과 마지막으로 맞은 자리
function fanOver(w: World): void {
for (let i = -1; i <= 1; i += 1) {
const a = i * MAX_ANGLE;
const x0 = w.px + i * w.pw / 2;
seg(x0, w.py - 3, x0 + Math.sin(a) * 26, w.py - 3 - Math.cos(a) * 26, 1.6, LAV_DEEP, 0.55);
}
const age = w.t - w.padT;
if (age >= 0 && age < 0.9) {
const x = w.px + w.lastU * w.pw / 2;
ring(x, w.py + 1, 4 + age * 14, 2, ROSE_DEEP, 0.9 * (1 - age / 0.9));
disc(x, w.py + 1, 2.6, ROSE_DEEP, 1 - age / 0.9);
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, fanOver);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '맞은 자리로 반사각', false);
panelTag(WS[1], '거울 반사만', true);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'패들 중심에서 맞은 자리까지의 거리로 반사각을 정하는 왼쪽 판과 거울 반사만 쓰는 오른쪽 판이 같은 벽돌 4줄을 두고 나란히 돈다. 왼쪽 공은 받는 자리가 바뀔 때마다 다른 줄로 날아가 벽돌을 깨고, 오른쪽 공은 같은 세로줄만 오르내린다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}01 · パドルに当たった位置で決める反射角 — 計測 累積面積 11.156% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
02速い球を捕まえるスイープ衝突
球が速くなってもブロックを飛び越えないための衝突処理で、一コマの移動を STEP_MAX の4px以下に切り上げて刻み、歩みごとにブロックを調べます。この場面は一コマ42pxで11の欠片になり、ブロックの厚さ6に球の直径10を足した16pxより長いと、コマ末の位置だけを見る盤は一列を飛び越えます。出発の位相を200通りにして撃つとスイープは0%、コマ末だけの盤は62.0%が通り抜け、式の(移動−16)÷移動の61.9%と合い、画面の12秒では右の盤だけが12回通り抜けました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="球が1フレームに42ピクセル跳ぶスロー画面で、左は一歩を細かく刻みながらブロックを検査し、右は歩の終わりの位置だけを検査します。左の球はブロックに触れた瞬間に跳ね返りますが、右の球はブロックの段を飛び越えてすり抜け、すり抜けた回数が状態行に増えていきます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 빠른 공을 잡는 スイープ衝突(왼쪽) vs フレーム末の位置だけ 검사(오른쪽) ─────────────────
// 한 프레임에 공이 가는 길이(px) = BALL_SPEED × FRAME_H. 벽돌 두께 + 공 지름보다 길면 한 줄을 통째로 건너뛴다
const FRAME_H = 1 / 60;
const BALL_SPEED = 2520;
const BRICK_H = 6;
// 스윕은 한 프레임 이동을 STEP_MAX 이하 조각(올림(이동 ÷ STEP_MAX) 개)으로 쪼갠다
const STEP_MAX = 4;
// 장면 시간을 늦춰 보여 준다 — 공이 너무 빨라 눈으로 따라갈 수 없다
const SLOW = 0.3;
const LAP_S = 12.0;
const START_S = 1.4;
const REST_S = 3.2;
let WS = [];
function build() {
WS = [];
for (let i = 0; i < 2; i += 1) {
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: i === 0, stepMax: STEP_MAX, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.step = FRAME_H;
w.speed = BALL_SPEED;
w.pw = 80;
w.px = 82;
w.follow = 40;
w.vmax = 2400;
w.aims = [0.5, -0.4, 0.3, -0.6];
w.trailN = 5;
// 줄 사이 간격을 프레임 이동 길이(42px)에 맞춘다 — 첫 번째 올라가는 길에는 어느 프레임 끝 위치도 벽돌 상자에 들어가지 않는다
const gap = BALL_SPEED * FRAME_H;
for (let r = 0; r < 4; r += 1) {
// 줄마다 벽돌 네 장을 공 지름보다 넓게 띄운다 — 이웃 벽돌로 번져 깨지는 일 없이 통과만 따로 본다
for (let c = 0; c < 4; c += 1) {
addBrick(w, 8 + c * 58.7, 23 + r * gap, 30, BRICK_H, 1, r, c);
}
}
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt) {
for (const w of WS) {
advanceWorld(w, dt * SLOW);
}
}
function statusText() {
return '左 {a}枚・右 {b}枚・右のすり抜け {m}回'.replace('{a}', String(WS[0].broken)).replace('{b}', String(WS[1].broken)).replace('{m}', String(WS[1].missed));
}
// ── draw ──────────────────────────────────────────────
// 한 프레임 이동 길이를 잇는 선 — 마지막 프레임에서 공이 어디서 어디로 건너뛰었는지
function jumpOver(w) {
for (const b of w.balls) {
const n = b.trail.length;
if (n < 4 || b.st !== 0) {
continue;
}
seg(b.trail[n - 4], b.trail[n - 3], b.trail[n - 2], b.trail[n - 1], 2, ROSE_DEEP, 0.45);
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, jumpOver);
drawWorld(WS[1], false, LAV_DEEP, null, jumpOver);
panelTag(WS[0], 'スイープ衝突', false);
panelTag(WS[1], 'フレーム末の位置だけ', true);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SweptCircleBrickTunnelingGuard - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SweptCircleBrickTunnelingGuard.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SweptCircleBrickTunnelingGuard() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 02 빠른 공을 잡는 스윕 충돌(왼쪽) vs 프레임 끝 위치만 검사(오른쪽) ─────────────────
// 한 프레임에 공이 가는 길이(px) = BALL_SPEED × FRAME_H. 벽돌 두께 + 공 지름보다 길면 한 줄을 통째로 건너뛴다
const FRAME_H = 1 / 60;
const BALL_SPEED = 2520;
const BRICK_H = 6;
// 스윕은 한 프레임 이동을 STEP_MAX 이하 조각(올림(이동 ÷ STEP_MAX) 개)으로 쪼갠다
const STEP_MAX = 4;
// 장면 시간을 늦춰 보여 준다 — 공이 너무 빨라 눈으로 따라갈 수 없다
const SLOW = 0.3;
const LAP_S = 12.0;
const START_S = 1.4;
const REST_S = 3.2;
let WS: World[] = [];
function build(): void {
WS = [];
for (let i = 0; i < 2; i += 1) {
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: i === 0, stepMax: STEP_MAX, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.step = FRAME_H;
w.speed = BALL_SPEED;
w.pw = 80;
w.px = 82;
w.follow = 40;
w.vmax = 2400;
w.aims = [0.5, -0.4, 0.3, -0.6];
w.trailN = 5;
// 줄 사이 간격을 프레임 이동 길이(42px)에 맞춘다 — 첫 번째 올라가는 길에는 어느 프레임 끝 위치도 벽돌 상자에 들어가지 않는다
const gap = BALL_SPEED * FRAME_H;
for (let r = 0; r < 4; r += 1) {
// 줄마다 벽돌 네 장을 공 지름보다 넓게 띄운다 — 이웃 벽돌로 번져 깨지는 일 없이 통과만 따로 본다
for (let c = 0; c < 4; c += 1) { addBrick(w, 8 + c * 58.7, 23 + r * gap, 30, BRICK_H, 1, r, c); }
}
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt: number): void {
for (const w of WS) { advanceWorld(w, dt * SLOW); }
}
function statusText(): string {
return '왼쪽 {a}장 · 오른쪽 {b}장 · 오른쪽 통과 {m}번'.replace('{a}', String(WS[0].broken)).replace('{b}', String(WS[1].broken)).replace('{m}', String(WS[1].missed));
}
// ── draw ──────────────────────────────────────────────
// 한 프레임 이동 길이를 잇는 선 — 마지막 프레임에서 공이 어디서 어디로 건너뛰었는지
function jumpOver(w: World): void {
for (const b of w.balls) {
const n = b.trail.length;
if (n < 4 || b.st !== 0) { continue; }
seg(b.trail[n - 4], b.trail[n - 3], b.trail[n - 2], b.trail[n - 1], 2, ROSE_DEEP, 0.45);
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, jumpOver);
drawWorld(WS[1], false, LAV_DEEP, null, jumpOver);
panelTag(WS[0], '스윕 충돌', false);
panelTag(WS[1], '프레임 끝 위치만', true);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'프레임마다 공이 42픽셀씩 건너뛰는 느린 화면에서 왼쪽은 한 걸음을 잘게 쪼개 벽돌을 검사하고 오른쪽은 걸음 끝 위치만 검사한다. 왼쪽 공은 벽돌에 닿는 즉시 튕기지만 오른쪽 공은 벽돌 줄을 건너뛰어 통과하고, 통과 횟수가 상태 줄에 올라간다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}02 · 速い球を捕まえるスイープ衝突 — 計測 累積面積 29.834% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// 一コマで球が進む長さ(px) = BALL_SPEED × FRAME_H。ブロックの厚さ + 球の直径より長いと一列を丸ごと飛び越える
const FRAME_H = 1 / 60;
const BALL_SPEED = 2520;
const BRICK_H = 6;
// スイープは一コマの移動を STEP_MAX 以下の欠片(切り上げ(移動 ÷ STEP_MAX) 個)に刻む
const STEP_MAX = 4;
// 場面の時間を遅くして見せる — 球が速すぎて目で追えない
const SLOW = 0.3;
03角で反射する面を選ぶ法線
ブロックの角をかすめた球が見当違いの向きへ行かないようにする反射の決まりで、重なりの深さを横の ox と縦の oy で測り、浅いほうの軸の面で押し出してその軸の速度だけを反転します。右の盤は vy = −vy しか使わないので、横腹に当たると球がブロックの中へ入り込みます。柱ひとつを5か所×41通りの角度で205回撃ち、球の中心が柱の中に6歩以上とどまった回数を数えると、面を選ぶ盤は0回、速度yだけの反転は66回でした。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="壊れない柱二本と棒一本の間に同じ球を撃ち込みます。左の盤は重なりの浅い軸の面で反射するので角に当たっても近い面で跳ね、右の盤は上下の速度だけを反転するため柱の脇腹にめり込んで震えます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 모서리에서 반사 면을 고르는 법선(왼쪽) vs 위아래 속도만 뒤집기(오른쪽) ─────────────────
// 벽돌은 깨지지 않는 기둥 — 같은 공이 같은 자리를 맞아 어느 쪽으로 튕기는지만 본다
const BASE_SPEED = 250;
// 공을 새로 쏠 때 차례로 쓰는 발사 각도(세로 기준 도) — 기둥 옆면과 모서리를 맞는 각도들
const LAUNCH_DEG = [-21, 27, -9, 33, -26, 14, -33, 21];
const LAP_S = 12.0;
const START_S = 1.2;
const REST_S = 4.0;
let WS = [];
function build() {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 face 하나 — 0 은 겹친 깊이가 얕은 축의 면, 1 은 速度yだけ反転
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: i, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 70;
w.px = 111;
w.speed = BASE_SPEED;
w.launches = LAUNCH_DEG;
w.aims = [0, 0.3, -0.3];
w.trailN = 30;
// 기둥 둘과 가운데 막대 — hp 99 는 깨지지 않는다
addBrick(w, 46, 60, 16, 70, 99, 0, 0);
addBrick(w, 160, 60, 16, 70, 99, 0, 1);
addBrick(w, 91, 120, 40, 14, 99, 1, 0);
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt) {
for (const w of WS) {
advanceWorld(w, dt);
}
}
function statusText() {
return '左 反射{a}回・右 めり込んだ歩数{b}'.replace('{a}', String(WS[0].hits)).replace('{b}', String(WS[1].stuck));
}
// ── draw ──────────────────────────────────────────────
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], true, LAV_DEEP, null, null);
drawWorld(WS[1], true, LAV_DEEP, null, null);
panelTag(WS[0], '浅い軸の面で反射', false);
panelTag(WS[1], '速度yだけ反転', true);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BrickFaceCornerNormalPick - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BrickFaceCornerNormalPick.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BrickFaceCornerNormalPick() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 03 모서리에서 반사 면을 고르는 법선(왼쪽) vs 위아래 속도만 뒤집기(오른쪽) ─────────────────
// 벽돌은 깨지지 않는 기둥 — 같은 공이 같은 자리를 맞아 어느 쪽으로 튕기는지만 본다
const BASE_SPEED = 250;
// 공을 새로 쏠 때 차례로 쓰는 발사 각도(세로 기준 도) — 기둥 옆면과 모서리를 맞는 각도들
const LAUNCH_DEG = [-21, 27, -9, 33, -26, 14, -33, 21];
const LAP_S = 12.0;
const START_S = 1.2;
const REST_S = 4.0;
let WS: World[] = [];
function build(): void {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 face 하나 — 0 은 겹친 깊이가 얕은 축의 면, 1 은 속도 y 만 뒤집기
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: i, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 70;
w.px = 111;
w.speed = BASE_SPEED;
w.launches = LAUNCH_DEG;
w.aims = [0, 0.3, -0.3];
w.trailN = 30;
// 기둥 둘과 가운데 막대 — hp 99 는 깨지지 않는다
addBrick(w, 46, 60, 16, 70, 99, 0, 0);
addBrick(w, 160, 60, 16, 70, 99, 0, 1);
addBrick(w, 91, 120, 40, 14, 99, 1, 0);
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt: number): void {
for (const w of WS) { advanceWorld(w, dt); }
}
function statusText(): string {
return '왼쪽 반사 {a}번 · 오른쪽 파고든 걸음 {b}번'.replace('{a}', String(WS[0].hits)).replace('{b}', String(WS[1].stuck));
}
// ── draw ──────────────────────────────────────────────
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], true, LAV_DEEP, null, null);
drawWorld(WS[1], true, LAV_DEEP, null, null);
panelTag(WS[0], '얕은 축의 면에서 반사', false);
panelTag(WS[1], '속도 y 만 뒤집기', true);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'깨지지 않는 기둥 둘과 막대 하나 사이로 같은 공을 쏘아, 왼쪽 판은 겹친 깊이가 얕은 축의 면에서 반사해 모서리에서도 가까운 면으로 튕기고 오른쪽 판은 위아래 속도만 뒤집어 기둥 옆구리를 파고들며 떨리는 모습을 보여 준다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}03 · 角で反射する面を選ぶ法線 — 計測 累積面積 11.336% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
if (w.mode.face === 0) {
// 重なりの浅い軸の面が当たった面 — その面の外側へ押し出して、その軸の速度だけを反転する
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 対比盤 — 面を選ばず上下の速度だけを反転する。横腹に当たるとブロックの中へ入り込む
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
04パドルの速さが球を曲げる回転の伝わり
パドルを動かしながら打つ技に意味を持たせるため、当たる瞬間にパドルの速さへ SPIN_GAIN の0.15を掛けて回転に渡し、回転は歩みごとに SIDE_PUSH ぶん球を横へ押して SPIN_DECAY で減ります。左のパドルは−300px/秒で走って回転−45を受け取り、右は止まったままで回転0です。同じ位置 x 111に横速度86.7px/秒で当たっても、0.5秒後の横の移動は+19.3pxと+43.4pxに分かれました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="左のパドルは球を打つ瞬間に横へ走って球に回転を伝え、軌跡が曲がっていきます。右のパドルは同じ位置を止まったまま打つので軌跡はまっすぐです。打った後、二つの盤の球の軌跡が分かれます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 패들 속도가 공을 휘게 하는 회전 전달 — 왼쪽은 움직이며 치고 오른쪽은 선 채로 같은 자리를 친다 ─────────────────
// 회전 = 맞는 순간 패들 속도 × SPIN_GAIN. 회전은 프레임마다 SIDE_PUSH 만큼 옆으로 미는 힘이 되고 SPIN_DECAY 로 줄어든다
const SPIN_GAIN = 0.15;
const SPIN_DECAY = 0.7;
const SIDE_PUSH = 5;
// 패들이 맞히는 순간 옆으로 움직이는 속도(px/초) · 맞기 전에 그 속도로 달려오기 시작하는 시간(초)
const SWIPE_V = 300;
const LEAD_S = 0.2;
const BASE_SPEED = 250;
const LAP_S = 12.0;
const START_S = 1.6;
const REST_S = 4.6;
let WS = [];
// 맞은 순간 패들 속도(px/초)와 공이 받은 회전 — 상태 줄과 설명에 쓴다
let hitV = [0, 0];
let hitSpin = [0, 0];
// 두 판 모두 같은 자리(aim)를 치게 맞춘다. 왼쪽만 맞히는 순간 패들이 SWIPE_V 로 옆으로 달린다
function swipeCtl(w, dt) {
if (w.hand) {
autoCtl(w, dt);
return;
}
const b = pickBall(w);
let tgt = w.px;
let v = 0;
if (b) {
const L = landing(w, b);
const aim = w.aims[w.padHits % w.aims.length];
const hit = L[0] - aim * w.pw / 2;
// 달려오는 방향 — 맞히는 자리가 판 오른쪽이면 오른쪽으로, 왼쪽이면 왼쪽으로 달린다(달리기 시작점이 벽 밖으로 나가지 않게)
const dir = hit > w.w / 2 ? 1 : -1;
if (w.balls.length > 0 && w.mode.spin > 0) {
if (L[1] > LEAD_S) {
tgt = hit - dir * SWIPE_V * LEAD_S;
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
else {
w.pv = dir * SWIPE_V;
v = w.pv;
}
}
else {
tgt = hit;
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
}
else {
w.pv = 0;
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
if (v === 0 && w.mode.spin <= 0) {
w.pv = 0;
}
}
function build() {
WS = [];
hitV = [0, 0];
hitSpin = [0, 0];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 spin 하나 — 오른쪽은 패들 속도를 회전으로 넘기지 않는다
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: i === 0 ? SPIN_GAIN : 0, push: SIDE_PUSH, decay: SPIN_DECAY });
w.pw = 64;
w.px = 111;
w.speed = BASE_SPEED;
w.follow = 30;
w.vmax = 900;
w.aims = [0.35, -0.35, 0.5, -0.5];
w.launches = [0];
w.trailN = 90;
w.ctl = swipeCtl;
const idx = i;
w.onPad = function (ww, b) { hitV[idx] = Math.round(ww.pv); hitSpin[idx] = Math.round(b.spin * 10) / 10; };
fillGrid(w, 2, 6, 24, 12, 3, function () { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt) {
for (const w of WS) {
advanceWorld(w, dt);
}
}
function statusText() {
return 'パドル速度 {v}px/秒・球の回転 {s}'.replace('{v}', String(hitV[0])).replace('{s}', String(hitSpin[0]));
}
// ── draw ──────────────────────────────────────────────
// 공의 긴 자취 — 휘는 정도를 눈으로 비교한다
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, ROSE_DEEP, null, null);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '動きながら打つ', false);
panelTag(WS[1], '止まったまま打つ', true);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PaddleVelocitySpinCurveTransfer - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PaddleVelocitySpinCurveTransfer.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PaddleVelocitySpinCurveTransfer() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 04 패들 속도가 공을 휘게 하는 회전 전달 — 왼쪽은 움직이며 치고 오른쪽은 선 채로 같은 자리를 친다 ─────────────────
// 회전 = 맞는 순간 패들 속도 × SPIN_GAIN. 회전은 프레임마다 SIDE_PUSH 만큼 옆으로 미는 힘이 되고 SPIN_DECAY 로 줄어든다
const SPIN_GAIN = 0.15;
const SPIN_DECAY = 0.7;
const SIDE_PUSH = 5;
// 패들이 맞히는 순간 옆으로 움직이는 속도(px/초) · 맞기 전에 그 속도로 달려오기 시작하는 시간(초)
const SWIPE_V = 300;
const LEAD_S = 0.2;
const BASE_SPEED = 250;
const LAP_S = 12.0;
const START_S = 1.6;
const REST_S = 4.6;
let WS: World[] = [];
// 맞은 순간 패들 속도(px/초)와 공이 받은 회전 — 상태 줄과 설명에 쓴다
let hitV: number[] = [0, 0];
let hitSpin: number[] = [0, 0];
// 두 판 모두 같은 자리(aim)를 치게 맞춘다. 왼쪽만 맞히는 순간 패들이 SWIPE_V 로 옆으로 달린다
function swipeCtl(w: World, dt: number): void {
if (w.hand) { autoCtl(w, dt); return; }
const b = pickBall(w);
let tgt = w.px;
let v = 0;
if (b) {
const L = landing(w, b);
const aim = w.aims[w.padHits % w.aims.length];
const hit = L[0] - aim * w.pw / 2;
// 달려오는 방향 — 맞히는 자리가 판 오른쪽이면 오른쪽으로, 왼쪽이면 왼쪽으로 달린다(달리기 시작점이 벽 밖으로 나가지 않게)
const dir = hit > w.w / 2 ? 1 : -1;
if (w.balls.length > 0 && w.mode.spin > 0) {
if (L[1] > LEAD_S) { tgt = hit - dir * SWIPE_V * LEAD_S; w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax); } else { w.pv = dir * SWIPE_V; v = w.pv; }
} else {
tgt = hit;
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
} else { w.pv = 0; }
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
if (v === 0 && w.mode.spin <= 0) { w.pv = 0; }
}
function build(): void {
WS = [];
hitV = [0, 0];
hitSpin = [0, 0];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 spin 하나 — 오른쪽은 패들 속도를 회전으로 넘기지 않는다
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: i === 0 ? SPIN_GAIN : 0, push: SIDE_PUSH, decay: SPIN_DECAY });
w.pw = 64;
w.px = 111;
w.speed = BASE_SPEED;
w.follow = 30;
w.vmax = 900;
w.aims = [0.35, -0.35, 0.5, -0.5];
w.launches = [0];
w.trailN = 90;
w.ctl = swipeCtl;
const idx = i;
w.onPad = function (ww: World, b: Ball): void { hitV[idx] = Math.round(ww.pv); hitSpin[idx] = Math.round(b.spin * 10) / 10; };
fillGrid(w, 2, 6, 24, 12, 3, function (): number { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt: number): void {
for (const w of WS) { advanceWorld(w, dt); }
}
function statusText(): string {
return '패들 속도 {v}px/초 · 공 회전 {s}'.replace('{v}', String(hitV[0])).replace('{s}', String(hitSpin[0]));
}
// ── draw ──────────────────────────────────────────────
// 공의 긴 자취 — 휘는 정도를 눈으로 비교한다
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, ROSE_DEEP, null, null);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '움직이며 치기', false);
panelTag(WS[1], '선 채로 치기', true);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'왼쪽 패들은 공을 맞히는 순간 옆으로 달려 공에 회전을 넘겨 자취가 휘어 가고, 오른쪽 패들은 같은 자리를 선 채로 쳐서 자취가 곧게 간다. 맞은 뒤 두 판의 공 자취가 갈라진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}04 · パドルの速さが球を曲げる回転の伝わり — 計測 累積面積 15.136% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const m = w.mode;
if (m.spin > 0) {
// 回転はコマごとに横へ押す力になり、少しずつ減る
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
05固定の角度表で分かれるマルチボール
アイテムで球が増える決まりを乱数なしで作るため、+ のブロックが壊れたとき ANGLE_TABLE の次の値を新しい球 SPLIT_N 個の角にして、BALL_MAX で止めます。同じ盤を二度回して球の位置と壊れた数を一つにまとめて比べると、12秒のあいだ毎秒12/12が一致しました。12秒で分裂は5回、球は最大5個、壊れたブロックは23枚で、一歩の検査回数は球ごとに同じです。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="プラス印のついたブロック七枚のどれかを壊すと、あらかじめ書いた角度表の次の値で球が二個ずつ分かれ、最大五個まで増えます。表の中で次に使う角度が濃く光ります。何度回しても同じ軌跡になります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 고정 각도표로 갈라지는 멀티볼 ─────────────────
// 갈라질 때 난수 대신 이 표의 다음 값을 쓴다 — 세로 기준 도(°). 몇 번을 돌려도 같은 궤적이 나온다
const ANGLE_TABLE = [-38, 22, -14, 46, -52, 8, 30, -26];
const SPLIT_N = 2;
const BALL_MAX = 5;
const BASE_SPEED = 270;
const LAP_S = 12.0;
const START_S = 1.2;
const REST_S = 6.0;
let WS = [];
let tableAt = 0;
let splits = 0;
// + 가 달린 벽돌(kind 1)이 깨질 때 공 SPLIT_N 개를 그 자리에서 표의 다음 각도로 내보낸다. 공은 BALL_MAX 까지만 늘어난다
function onSplit(w, k) {
if (k.kind !== 1) {
return;
}
for (let i = 0; i < SPLIT_N; i += 1) {
if (w.balls.length >= BALL_MAX) {
return;
}
const b = newBall(w, w.balls.length + splits * 10);
const a = ANGLE_TABLE[tableAt % ANGLE_TABLE.length] * Math.PI / 180;
tableAt += 1;
b.x = k.x + k.w / 2;
b.y = k.y + k.h + BALL_R;
b.vx = Math.sin(a) * w.speed;
b.vy = Math.cos(a) * w.speed;
b.st = 0;
w.balls.push(b);
}
splits += 1;
}
function build() {
tableAt = 0;
splits = 0;
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 84;
w.py = w.h - 34;
w.px = 180;
w.speed = BASE_SPEED;
w.aims = [0.2, -0.3, 0.45, -0.1];
w.launches = [12];
w.trailN = 7;
w.onBreak = onSplit;
fillGrid(w, 5, 12, 24, 14, 2, function () { return 1; });
// + 벽돌 — 줄·칸 자리
const spots = [[4, 5], [4, 2], [3, 8], [2, 4], [4, 9], [3, 1], [4, 7]];
for (const k of w.bricks) {
for (const s of spots) {
if (k.row === s[0] && k.col === s[1]) {
k.kind = 1;
}
}
}
w.balls.push(newBall(w, 0));
WS = [w];
}
function update(dt) {
advanceWorld(WS[0], dt);
}
function flying(w) {
let n = 0;
for (const b of w.balls) {
if (b.st !== 2) {
n += 1;
}
}
return n;
}
function statusText() {
return '球 {b}個・残りブロック {k}枚'.replace('{b}', String(flying(WS[0]))).replace('{k}', String(aliveCount(WS[0])));
}
// ── draw ──────────────────────────────────────────────
// + 표시와 각도표 — 표 안에서 다음에 쓸 값이 진하게 켜진다
function splitMarks(w) {
for (const k of w.bricks) {
if (k.kind !== 1 || k.deadT >= 0) {
continue;
}
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
seg(cx - 4, cy, cx + 4, cy, 2, INK, 1);
seg(cx, cy - 4, cx, cy + 4, 2, INK, 1);
}
}
function tableChips(w) {
const n = ANGLE_TABLE.length;
const next = tableAt % n;
for (let i = 0; i < n; i += 1) {
const x = w.w - 12 - (n - i) * 32;
const on = i === next;
box(x, w.h - 17, 30, 13, 6, on ? INK : LAV, on ? 1 : 0.55);
text(String(ANGLE_TABLE[i]), x + 15, w.h - 10.5, on ? PAPER : INK, 1, 9, 700, 'center');
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, function (w) { splitMarks(w); tableChips(w); });
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// MultiballSplitFixedAngleTable - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./MultiballSplitFixedAngleTable.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function MultiballSplitFixedAngleTable() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 05 고정 각도표로 갈라지는 멀티볼 ─────────────────
// 갈라질 때 난수 대신 이 표의 다음 값을 쓴다 — 세로 기준 도(°). 몇 번을 돌려도 같은 궤적이 나온다
const ANGLE_TABLE = [-38, 22, -14, 46, -52, 8, 30, -26];
const SPLIT_N = 2;
const BALL_MAX = 5;
const BASE_SPEED = 270;
const LAP_S = 12.0;
const START_S = 1.2;
const REST_S = 6.0;
let WS: World[] = [];
let tableAt = 0;
let splits = 0;
// + 가 달린 벽돌(kind 1)이 깨질 때 공 SPLIT_N 개를 그 자리에서 표의 다음 각도로 내보낸다. 공은 BALL_MAX 까지만 늘어난다
function onSplit(w: World, k: Brick): void {
if (k.kind !== 1) { return; }
for (let i = 0; i < SPLIT_N; i += 1) {
if (w.balls.length >= BALL_MAX) { return; }
const b = newBall(w, w.balls.length + splits * 10);
const a = ANGLE_TABLE[tableAt % ANGLE_TABLE.length] * Math.PI / 180;
tableAt += 1;
b.x = k.x + k.w / 2;
b.y = k.y + k.h + BALL_R;
b.vx = Math.sin(a) * w.speed;
b.vy = Math.cos(a) * w.speed;
b.st = 0;
w.balls.push(b);
}
splits += 1;
}
function build(): void {
tableAt = 0;
splits = 0;
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 84;
w.py = w.h - 34;
w.px = 180;
w.speed = BASE_SPEED;
w.aims = [0.2, -0.3, 0.45, -0.1];
w.launches = [12];
w.trailN = 7;
w.onBreak = onSplit;
fillGrid(w, 5, 12, 24, 14, 2, function (): number { return 1; });
// + 벽돌 — 줄·칸 자리
const spots = [[4, 5], [4, 2], [3, 8], [2, 4], [4, 9], [3, 1], [4, 7]];
for (const k of w.bricks) {
for (const s of spots) { if (k.row === s[0] && k.col === s[1]) { k.kind = 1; } }
}
w.balls.push(newBall(w, 0));
WS = [w];
}
function update(dt: number): void {
advanceWorld(WS[0], dt);
}
function flying(w: World): number {
let n = 0;
for (const b of w.balls) { if (b.st !== 2) { n += 1; } }
return n;
}
function statusText(): string {
return '공 {b}개 · 남은 벽돌 {k}장'.replace('{b}', String(flying(WS[0]))).replace('{k}', String(aliveCount(WS[0])));
}
// ── draw ──────────────────────────────────────────────
// + 표시와 각도표 — 표 안에서 다음에 쓸 값이 진하게 켜진다
function splitMarks(w: World): void {
for (const k of w.bricks) {
if (k.kind !== 1 || k.deadT >= 0) { continue; }
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
seg(cx - 4, cy, cx + 4, cy, 2, INK, 1);
seg(cx, cy - 4, cx, cy + 4, 2, INK, 1);
}
}
function tableChips(w: World): void {
const n = ANGLE_TABLE.length;
const next = tableAt % n;
for (let i = 0; i < n; i += 1) {
const x = w.w - 12 - (n - i) * 32;
const on = i === next;
box(x, w.h - 17, 30, 13, 6, on ? INK : LAV, on ? 1 : 0.55);
text(String(ANGLE_TABLE[i]), x + 15, w.h - 10.5, on ? PAPER : INK, 1, 9, 700, 'center');
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, function (w: World): void { splitMarks(w); tableChips(w); });
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'벽돌 일곱 장에 붙은 더하기 표시를 깨면 공이 미리 적어 둔 각도표의 다음 값으로 두 개씩 갈라져 최대 다섯 개까지 늘고, 표에서 다음에 쓸 각도가 진하게 켜진다. 몇 번을 돌려도 같은 궤적이 나온다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}05 · 固定の角度表で分かれるマルチボール — 計測 累積面積 12.935% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function onSplit(w, k) {
if (k.kind !== 1) {
return;
}
for (let i = 0; i < SPLIT_N; i += 1) {
if (w.balls.length >= BALL_MAX) {
return;
}
const b = newBall(w, w.balls.length + splits * 10);
const a = ANGLE_TABLE[tableAt % ANGLE_TABLE.length] * Math.PI / 180;
tableAt += 1;
b.x = k.x + k.w / 2;
b.y = k.y + k.h + BALL_R;
b.vx = Math.sin(a) * w.speed;
b.vy = Math.cos(a) * w.speed;
b.st = 0;
w.balls.push(b);
}
splits += 1;
}
06耐久に応じて広がるブロックのひび
何度も当てないと壊れない硬いブロックを整数ひとつの耐久で表し、hurt が当たるたびに hp -= 1 をして、残りの耐久は色の段階、当たった回数はひび線の本数に変わります。上から hp 1・2・3・4で積んだ10×4の盤40枚は、hp 1が16枚、2・3・4が各8枚なので、すべて壊すには88回当てることになります。12秒では28回当たってブロック9枚が壊れ、同時にひびが入ったブロックは最大6枚でした。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="ブロックごとに耐久が4から1まであり、当たるたびに色が一段変わってひび割れの線が一本増え、最後の一撃で破片になって散ります。二つの球が下の段の硬いブロックから割っていきます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 체력에 따라 갈라지는 벽돌 균열 ─────────────────
// 벽돌마다 남은 체력(정수)을 두고 맞을 때마다 1 씩 줄인다. 색 단계는 남은 체력, 균열 선은 맞은 횟수(처음 체력 − 남은 체력, 최대 CRACK_N 개)
const HP_MAX = 4;
// 남은 체력 1~4 의 색 — 체력이 줄수록 옅어지고, 한 단계씩 바뀐다
const TINT_STEP = [INK, BUTTER, MINT, WATER, LAV];
const BASE_SPEED = 330;
const LAP_S = 12.0;
const START_S = 3.4;
const REST_S = 6.0;
let WS = [];
function hpOf(r, c) {
// 공이 먼저 닿는 아래 줄일수록 단단하다 — 아래부터 4, 3, 2, 1. 양 끝 칸은 한 대에 깨진다
if (c === 0 || c === 9) {
return 1;
}
return [1, 2, 3, 4][r];
}
function build() {
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 120;
w.px = 230;
w.speed = BASE_SPEED;
w.aims = [0.3, -0.4, 0.55, -0.2, 0.1];
w.launches = [10, -16, 22];
w.trailN = 22;
w.paint = function (k) { return TINT_STEP[clamp(k.hp, 1, HP_MAX)]; };
fillGrid(w, 4, 10, 86, 18, 3, hpOf);
w.balls.push(newBall(w, 0));
// 둘째 공은 늦게 — 두 공이 같은 벽돌을 이어 맞는 장면이 나온다
for (let i = 1; i < 2; i += 1) {
const bi = newBall(w, i);
bi.born = 1.4;
w.balls.push(bi);
}
WS = [w];
}
function update(dt) {
advanceWorld(WS[0], dt);
}
function statusText() {
return '残りブロック {k}枚・命中 {h}回'.replace('{k}', String(aliveCount(WS[0]))).replace('{h}', String(WS[0].hits));
}
// ── draw ──────────────────────────────────────────────
// 체력 숫자 — 벽돌 가운데 작은 글씨(체력 2 이상일 때만)
function hpNums(w) {
for (const k of w.bricks) {
if (k.deadT >= 0 || k.hp < 2) {
continue;
}
text(String(k.hp), k.x + k.w / 2, k.y + k.h / 2 + 0.5, INK, 0.85, 10, 800, 'center');
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, hpNums);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BrickHitPointsCrackTint - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BrickHitPointsCrackTint.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BrickHitPointsCrackTint() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 06 체력에 따라 갈라지는 벽돌 균열 ─────────────────
// 벽돌마다 남은 체력(정수)을 두고 맞을 때마다 1 씩 줄인다. 색 단계는 남은 체력, 균열 선은 맞은 횟수(처음 체력 − 남은 체력, 최대 CRACK_N 개)
const HP_MAX = 4;
// 남은 체력 1~4 의 색 — 체력이 줄수록 옅어지고, 한 단계씩 바뀐다
const TINT_STEP: RGB[] = [INK, BUTTER, MINT, WATER, LAV];
const BASE_SPEED = 330;
const LAP_S = 12.0;
const START_S = 3.4;
const REST_S = 6.0;
let WS: World[] = [];
function hpOf(r: number, c: number): number {
// 공이 먼저 닿는 아래 줄일수록 단단하다 — 아래부터 4, 3, 2, 1. 양 끝 칸은 한 대에 깨진다
if (c === 0 || c === 9) { return 1; }
return [1, 2, 3, 4][r];
}
function build(): void {
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 120;
w.px = 230;
w.speed = BASE_SPEED;
w.aims = [0.3, -0.4, 0.55, -0.2, 0.1];
w.launches = [10, -16, 22];
w.trailN = 22;
w.paint = function (k: Brick): RGB { return TINT_STEP[clamp(k.hp, 1, HP_MAX)]; };
fillGrid(w, 4, 10, 86, 18, 3, hpOf);
w.balls.push(newBall(w, 0));
// 둘째 공은 늦게 — 두 공이 같은 벽돌을 이어 맞는 장면이 나온다
for (let i = 1; i < 2; i += 1) {
const bi = newBall(w, i);
bi.born = 1.4;
w.balls.push(bi);
}
WS = [w];
}
function update(dt: number): void {
advanceWorld(WS[0], dt);
}
function statusText(): string {
return '남은 벽돌 {k}장 · 맞은 횟수 {h}번'.replace('{k}', String(aliveCount(WS[0]))).replace('{h}', String(WS[0].hits));
}
// ── draw ──────────────────────────────────────────────
// 체력 숫자 — 벽돌 가운데 작은 글씨(체력 2 이상일 때만)
function hpNums(w: World): void {
for (const k of w.bricks) {
if (k.deadT >= 0 || k.hp < 2) { continue; }
text(String(k.hp), k.x + k.w / 2, k.y + k.h / 2 + 0.5, INK, 0.85, 10, 800, 'center');
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, hpNums);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'벽돌마다 체력이 4에서 1까지 있어 맞을 때마다 색이 한 단계씩 바뀌고 균열 선이 하나씩 늘며, 마지막 타격에서 조각으로 흩어진다. 공 두 개가 아래 줄의 단단한 벽돌부터 갈라 간다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}06 · 耐久に応じて広がるブロックのひび — 計測 累積面積 7.872% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
07速度段階に応じて伸びる残像
進むほど緊張が高まる速度の曲線は、speedFor が壊した数を CLEAR_N の3で割った商の数だけ SPEED_STEP の70を足し、SPEED_MAX の590で切って作り、残像の長さは round(速度 × TRAIL_LEN) + 2 で追って伸ばします。壊した数0なら速度190で残像16、18枚なら速度590で残像46です。12秒で24枚を壊し、上限の速度には10.0秒で届き、歩みが STEP_MAX の欠片に刻まれるので、この場面で通り抜けは0回でした。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="壊したブロック三枚ごとに球の速度が一段上がり、毎秒190から590ピクセルまで達して、残像が速度に比例して長くなります。左下の階段バーが今の段を点灯します"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 속도 단계에 따라 길어지는 잔상 ─────────────────
// 깬 벽돌 CLEAR_N 장마다 속도가 SPEED_STEP 만큼 한 단계 오른다. 잔상은 최근 위치 목록의 길이(TRAIL_LEN)를 속도에 비례해 늘린 것
const BASE_SPEED = 190;
const SPEED_STEP = 70;
const CLEAR_N = 3;
const TRAIL_LEN = 0.075;
const SPEED_MAX = 590;
const LAP_S = 12.0;
const START_S = 4.0;
const REST_S = 7.0;
let WS = [];
function speedFor(broken) {
return Math.min(SPEED_MAX, BASE_SPEED + Math.floor(broken / CLEAR_N) * SPEED_STEP);
}
function build() {
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 100;
w.px = 200;
w.speed = BASE_SPEED;
w.aims = [0.35, -0.3, 0.5, -0.45, 0.15];
w.launches = [14];
fillGrid(w, 5, 12, 60, 14, 2, function () { return 1; });
w.balls.push(newBall(w, 0));
WS = [w];
}
function update(dt) {
const w = WS[0];
// 단계가 오르면 속도와 잔상 길이가 같이 바뀐다 — 一歩進める 이동은 늘 4px 이하 조각으로 쪼개진다(02번 스윕 충돌과 같다)
w.speed = speedFor(w.broken);
w.trailN = Math.round(w.speed * TRAIL_LEN) + 2;
advanceWorld(w, dt);
}
function statusText() {
return '速度 {v}px/秒・壊したブロック {k}枚'.replace('{v}', String(Math.round(WS[0].speed))).replace('{k}', String(WS[0].broken));
}
// ── draw ──────────────────────────────────────────────
// 속도 계단 — 단계마다 한 칸씩 켜지는 막대
function ramp(w) {
const n = Math.round((SPEED_MAX - BASE_SPEED) / SPEED_STEP) + 1;
const cur = Math.round((w.speed - BASE_SPEED) / SPEED_STEP);
for (let i = 0; i < n; i += 1) {
const bh = 5 + i * 2.2;
box(12 + i * 10, 40 - bh, 8, bh, 2, i <= cur ? INK : LAV, i <= cur ? 1 : 0.5);
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, ROSE_DEEP, null, ramp);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// BallSpeedRampTrailLength - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./BallSpeedRampTrailLength.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function BallSpeedRampTrailLength() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 07 속도 단계에 따라 길어지는 잔상 ─────────────────
// 깬 벽돌 CLEAR_N 장마다 속도가 SPEED_STEP 만큼 한 단계 오른다. 잔상은 최근 위치 목록의 길이(TRAIL_LEN)를 속도에 비례해 늘린 것
const BASE_SPEED = 190;
const SPEED_STEP = 70;
const CLEAR_N = 3;
const TRAIL_LEN = 0.075;
const SPEED_MAX = 590;
const LAP_S = 12.0;
const START_S = 4.0;
const REST_S = 7.0;
let WS: World[] = [];
function speedFor(broken: number): number {
return Math.min(SPEED_MAX, BASE_SPEED + Math.floor(broken / CLEAR_N) * SPEED_STEP);
}
function build(): void {
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 100;
w.px = 200;
w.speed = BASE_SPEED;
w.aims = [0.35, -0.3, 0.5, -0.45, 0.15];
w.launches = [14];
fillGrid(w, 5, 12, 60, 14, 2, function (): number { return 1; });
w.balls.push(newBall(w, 0));
WS = [w];
}
function update(dt: number): void {
const w = WS[0];
// 단계가 오르면 속도와 잔상 길이가 같이 바뀐다 — 한 걸음 이동은 늘 4px 이하 조각으로 쪼개진다(02번 스윕 충돌과 같다)
w.speed = speedFor(w.broken);
w.trailN = Math.round(w.speed * TRAIL_LEN) + 2;
advanceWorld(w, dt);
}
function statusText(): string {
return '속도 {v}px/초 · 깬 벽돌 {k}장'.replace('{v}', String(Math.round(WS[0].speed))).replace('{k}', String(WS[0].broken));
}
// ── draw ──────────────────────────────────────────────
// 속도 계단 — 단계마다 한 칸씩 켜지는 막대
function ramp(w: World): void {
const n = Math.round((SPEED_MAX - BASE_SPEED) / SPEED_STEP) + 1;
const cur = Math.round((w.speed - BASE_SPEED) / SPEED_STEP);
for (let i = 0; i < n; i += 1) {
const bh = 5 + i * 2.2;
box(12 + i * 10, 40 - bh, 8, bh, 2, i <= cur ? INK : LAV, i <= cur ? 1 : 0.5);
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, ROSE_DEEP, null, ramp);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'깬 벽돌 세 장마다 공 속도가 한 단계씩 올라 초속 190에서 590 픽셀까지 가고, 잔상이 속도에 비례해 길어진다. 왼쪽 아래의 계단 막대가 지금 단계를 켠다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}07 · 速度段階に応じて伸びる残像 — 計測 累積面積 9.067% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function update(dt) {
const w = WS[0];
// 段階が上がると速度と残像の長さが一緒に変わる — 一歩の移動は4px以下の欠片に刻まれる(02番のスイープ衝突と同じ)
w.speed = speedFor(w.broken);
w.trailN = Math.round(w.speed * TRAIL_LEN) + 2;
advanceWorld(w, dt);
}
08壁の反射を展開して落下地点を予測するパドル
自動プレイのデモや練習相手のパドルは、壁の反射を展開して落下地点を求めます。球がパドルの高さに届く時刻 t の b.x + b.vx × t を foldW で盤の幅に折りたたみ、パドルは FOLLOW_EASE でその地点を追い、右の盤は球の今の横位置だけを追います。24秒のあいだの取りこぼしは0回と7回で、落下の0.25秒前に出した予測と実際に当たった横位置の差は17回で平均1.36px、最大2.67pxでした。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="左のパドルは球の壁反射を展開して落下地点を先に求め、点線の軌跡の先へなめらかに移動します。右のパドルは球の今の横位置だけを追って遅れ、球を取り逃します。取り逃した回数が状態行に増えていきます"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 벽 반사를 펴서 낙하 지점을 예측하는 패들(왼쪽) vs 공의 지금 위치만 따라가는 패들(오른쪽) ─────────────────
// 공이 패들 높이에 닿는 시각 T 까지 벽 반사 없이 곧게 간 가로 위치를 판 폭 안쪽(FOLD_W)으로 접어 넣으면 낙하 지점(LAND_X)이다
// 패들은 그 지점으로 FOLLOW_EASE 만큼 부드럽게 따라간다
const FOLLOW_EASE = 10;
const PAD_VMAX = 150;
const PADDLE_W = 48;
const BASE_SPEED = 330;
const LAUNCH_DEG = [34, -28, 41, -36, 22, -45, 30, -19];
const LAP_S = 12.0;
const START_S = 2.4;
const REST_S = 5.0;
let WS = [];
// 예측 지점의 가로 위치 — foldW 가 접어 넣은 값(판 안쪽 BALL_R ~ w−BALL_R)
function landX(w, b) {
return landing(w, b)[0];
}
function build() {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 패들이 어디를 보고 달리는지 하나 — naive 는 공의 지금 가로 위치
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = PADDLE_W;
w.px = 111;
w.speed = BASE_SPEED;
w.follow = FOLLOW_EASE;
w.vmax = PAD_VMAX;
w.naive = i === 1;
w.aims = [0.5, -0.6, 0.4, -0.7];
w.launches = LAUNCH_DEG;
w.trailN = 10;
fillGrid(w, 2, 8, 30, 12, 2, function () { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt) {
for (const w of WS) {
advanceWorld(w, dt);
}
}
function statusText() {
return '左 取り逃し{a}回・右 取り逃し{b}回'.replace('{a}', String(WS[0].lost)).replace('{b}', String(WS[1].lost));
}
// ── draw ──────────────────────────────────────────────
// 접힌 낙하 궤적 — 지금 공 위치에서 낙하 지점까지 같은 접기(foldW)를 두 축에 써서 점선으로 그린다
function predicted(w) {
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const L = landing(w, b);
const n = 28;
let ox = b.x;
let oy = b.y;
for (let i = 1; i <= n; i += 1) {
const t = L[1] * i / n;
const x = foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R);
const y = foldW(b.y + b.vy * t, BALL_R, w.py - BALL_R);
if ((i % 2) === 0) {
seg(ox, oy, x, y, 1.6, ROSE_DEEP, 0.7);
}
ox = x;
oy = y;
}
ring(landX(w, b), w.py - BALL_R, 5.5, 2, ROSE_DEEP, 0.9);
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, predicted);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '壁の反射を展開して予測', false);
panelTag(WS[1], '今の位置だけ追う', true);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// LandingPredictWallUnfoldAutopilot - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./LandingPredictWallUnfoldAutopilot.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LandingPredictWallUnfoldAutopilot() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 08 벽 반사를 펴서 낙하 지점을 예측하는 패들(왼쪽) vs 공의 지금 위치만 따라가는 패들(오른쪽) ─────────────────
// 공이 패들 높이에 닿는 시각 T 까지 벽 반사 없이 곧게 간 가로 위치를 판 폭 안쪽(FOLD_W)으로 접어 넣으면 낙하 지점(LAND_X)이다
// 패들은 그 지점으로 FOLLOW_EASE 만큼 부드럽게 따라간다
const FOLLOW_EASE = 10;
const PAD_VMAX = 150;
const PADDLE_W = 48;
const BASE_SPEED = 330;
const LAUNCH_DEG = [34, -28, 41, -36, 22, -45, 30, -19];
const LAP_S = 12.0;
const START_S = 2.4;
const REST_S = 5.0;
let WS: World[] = [];
// 예측 지점의 가로 위치 — foldW 가 접어 넣은 값(판 안쪽 BALL_R ~ w−BALL_R)
function landX(w: World, b: Ball): number {
return landing(w, b)[0];
}
function build(): void {
WS = [];
for (let i = 0; i < 2; i += 1) {
// 두 판의 차이는 패들이 어디를 보고 달리는지 하나 — naive 는 공의 지금 가로 위치
const w = newWorld(12 + i * 234, 50, 222, 238, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = PADDLE_W;
w.px = 111;
w.speed = BASE_SPEED;
w.follow = FOLLOW_EASE;
w.vmax = PAD_VMAX;
w.naive = i === 1;
w.aims = [0.5, -0.6, 0.4, -0.7];
w.launches = LAUNCH_DEG;
w.trailN = 10;
fillGrid(w, 2, 8, 30, 12, 2, function (): number { return 1; });
w.balls.push(newBall(w, 0));
WS.push(w);
}
}
function update(dt: number): void {
for (const w of WS) { advanceWorld(w, dt); }
}
function statusText(): string {
return '왼쪽 놓침 {a}번 · 오른쪽 놓침 {b}번'.replace('{a}', String(WS[0].lost)).replace('{b}', String(WS[1].lost));
}
// ── draw ──────────────────────────────────────────────
// 접힌 낙하 궤적 — 지금 공 위치에서 낙하 지점까지 같은 접기(foldW)를 두 축에 써서 점선으로 그린다
function predicted(w: World): void {
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const L = landing(w, b);
const n = 28;
let ox = b.x;
let oy = b.y;
for (let i = 1; i <= n; i += 1) {
const t = L[1] * i / n;
const x = foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R);
const y = foldW(b.y + b.vy * t, BALL_R, w.py - BALL_R);
if ((i % 2) === 0) { seg(ox, oy, x, y, 1.6, ROSE_DEEP, 0.7); }
ox = x;
oy = y;
}
ring(landX(w, b), w.py - BALL_R, 5.5, 2, ROSE_DEEP, 0.9);
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, predicted);
drawWorld(WS[1], false, LAV_DEEP, null, null);
panelTag(WS[0], '벽 반사를 펴서 예측', false);
panelTag(WS[1], '지금 위치만 따라가기', true);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'왼쪽 패들은 공의 벽 반사를 펴서 낙하 지점을 미리 구해 점선 궤적 끝으로 부드럽게 이동하고, 오른쪽 패들은 공의 지금 가로 위치만 따라가다 늦어 공을 놓친다. 놓친 횟수가 상태 줄에 올라간다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}08 · 壁の反射を展開して落下地点を予測するパドル — 計測 累積面積 14.792% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 球がパドルの高さに届くまでの残り時間(秒)とそのときの横位置 — ブロックは無視して壁だけを展開する
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
09文字列の格子レベルと連鎖爆発の波
レベル編集と連鎖演出、ランディングのヒーローイントロには、レベルを LEVEL_ROWS の文字列六行で書く方式が合います。爆発ブロックが壊れると BLAST_R の1.25マス以内のブロックへ、距離(マス)×WAVE_DELAY の0.08×2だけ遅らせた導火線をかけ、導火線の時刻が過ぎれば耐久に関係なく壊れます。ブロック60枚のうち爆発は8枚で、12秒に爆発2回(0.80秒・0.97秒)、導火線で壊れた9枚が0.87〜1.14秒にすべて壊れました。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__pad" data-role="pad" tabindex="0" role="application" aria-label="ブロック崩しの舞台 — 左右の矢印キーでパドル、Enterで発射、Pで一時停止、Nで一歩、Rでやり直し">
<canvas class="vj__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="一行ずつの文字列で書いたレベルがブロックの格子に変わり、爆発ブロックを壊すと当たったマスから一マスずつ広がる波が隣のブロックを遅れて連鎖的に爆破します。波の輪が広がり、爆発したブロックは破片になって散ります"></canvas>
</div>
<p class="vj__status" data-role="status" aria-live="off">ブロック崩しデモ</p>
<div class="vj__ctrl">
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
<button class="vj__step" data-role="step" type="button">一歩進める</button>
</div>
<p class="vj__done" data-role="done" role="status" aria-live="polite"></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vj {
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;
}
// 무대 상자 — 여기서 시작한 터치만 패들 끌기로 읽는다(touch-action: none 은 이 상자에만). 무대 밖 터치는 페이지 스크롤 그대로
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: $sp-3;
top: $sp-3;
display: flex;
gap: $sp-2;
}
.vj__pause,
.vj__step {
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;
}
.vj__step {
background: $subject-cream;
color: $subject-ink;
}
// 스크린리더에만 읽히는 완료 알림
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 장면은 스크립트가 정지 장면 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step { transition: none !important; }
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step: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);
}
.vj {
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;
}
.vj__pad {
position: absolute;
inset: 0;
border-radius: inherit;
outline: none;
touch-action: none;
cursor: ew-resize;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.vj__ctrl {
position: absolute;
right: 12px;
top: 12px;
display: flex;
gap: 8px;
}
.vj__pause,
.vj__step {
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);
}
.vj__step {
background: #fff7e6;
color: #17141a;
}
.vj__done {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover,
.vj__step:hover {
transform: translateY(-2px);
}
.vj__pause:active,
.vj__step:active {
transform: translateY(1px);
}
.vj__pause:focus-visible,
.vj__step:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 190px);
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;
}
@media (max-width: 340px) {
.vj__pause,
.vj__step,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause,
.vj__step {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active,
.vj__step:hover,
.vj__step:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
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 ease(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed) {
let s = (seed >>> 0) || 1;
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP = [248, 244, 252];
const BG_BOT = [228, 234, 250];
const FIELD = [253, 251, 255];
const FIELD_EDGE = [196, 186, 240];
const ROSE = [244, 166, 188];
const ROSE_DEEP = [190, 52, 86];
const MINT = [158, 218, 196];
const BUTTER = [250, 226, 148];
const WATER = [166, 206, 246];
const LAV = [196, 182, 240];
const LAV_DEEP = [96, 72, 168];
const INK = [40, 36, 54];
const PAPER = [250, 246, 240];
const ROWCOL = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
function newWorld(x, y, w, h, mode) {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w, x, y, bw, bh, hp, row, col) {
const k = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w, rows, cols, top, bh, gap, hpOf) {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) {
addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c);
}
}
}
}
function newBall(w, id) {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w, b, deg) {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w) {
let n = 0;
for (const k of w.bricks) {
if (k.deadT < 0) {
n += 1;
}
}
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w, b) { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v, lo, hi) {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w, b) {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) {
t = (down - b.y) / b.vy;
}
else {
t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy);
}
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w) {
let best = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) {
continue;
}
const t = landing(w, b)[1];
if (t < bt) {
bt = t;
best = b;
}
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w, dt) {
if (w.hand) {
if (w.keyDir !== 0) {
w.pv = w.keyDir * HAND_V;
}
else if (w.pointX >= 0) {
w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax);
}
else {
w.pv = 0;
}
}
else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0, y0, x1, y1, k) {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) {
return false;
}
}
else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) {
const s = ta;
ta = tb;
tb = s;
}
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) {
return false;
}
}
}
return true;
}
function hurt(w, k) {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) {
return;
}
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) {
w.onBreak(w, k);
}
}
}
function reflectBricks(w, b, h) {
let best = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) {
continue;
}
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) {
continue;
}
const d = Math.min(ox, oy);
if (d > bd) {
bd = d;
best = k;
}
}
if (!best) {
return;
}
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
}
else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
}
else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) {
w.stuck += 1;
}
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w, b, h) {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) {
b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s;
s = Math.hypot(b.vx, b.vy);
}
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) {
b.x = BALL_R;
b.vx = Math.abs(b.vx);
}
if (b.x > w.w - BALL_R) {
b.x = w.w - BALL_R;
b.vx = -Math.abs(b.vx);
}
if (b.y < BALL_R) {
b.y = BALL_R;
b.vy = Math.abs(b.vy);
}
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
}
else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) {
b.spin += w.pv * m.spin;
}
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) {
w.onPad(w, b);
}
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) {
continue;
}
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) {
continue;
}
if (segHitsBrick(px0, py0, b.x, b.y, k)) {
k.mis = w.t;
w.missed += 1;
}
}
if (b.y > w.h + BALL_R + 2) {
b.st = 2;
w.lost += 1;
break;
}
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) {
b.trail.splice(0, 2);
}
}
function stepWorld(w, h) {
w.t += h;
if (w.ctl) {
w.ctl(w, h);
}
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
}
}
else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) {
continue;
}
let others = 0;
for (const o of w.balls) {
if (o !== b && o.st !== 2) {
others += 1;
}
}
if (others > 0) {
w.balls.splice(i, 1);
}
else {
b.st = 1;
b.born = w.t;
b.trail = [];
b.x = w.px;
b.y = w.py - BALL_R;
}
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w, dt) {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]');
const padEl = root.querySelector('[data-role="pad"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const statusEl = root.querySelector('[data-role="status"]');
const stepEl = root.querySelector('[data-role="step"]');
const doneEl = root.querySelector('[data-role="done"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
const stepBtn = stepEl;
const doneP = doneEl;
const pad = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top, bot) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x, y, r, width, c, a) {
if (r <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(x, y, w, h, r, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x, y, w, h, r, width, c, a) {
if (w <= 0.2 || h <= 0.2 || a <= 0) {
return;
}
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s, x, y, c, a, px, weight, align) {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s, x, y, bg, fg, px, align) {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w, s, bad) {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k, n) {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w, k, hatch) {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) {
return;
}
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) {
seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3);
}
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) {
crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp));
}
}
function drawBall(w, b, col) {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) {
return;
}
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w) {
if (w.pw <= 0) {
return;
}
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w, hatch, ballCol, under, over) {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) {
disc(gx, gy, 0.9, FIELD_EDGE, 0.5);
}
}
if (under) {
under(w);
}
for (const k of w.bricks) {
drawBrick(w, k, hatch);
}
drawPaddle(w);
for (const b of w.balls) {
drawBall(w, b, ballCol);
}
if (over) {
over(w);
}
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s) {
if (!statusEl || s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
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() {
if (manual) {
return;
}
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone() {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt) {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) {
lap += 1;
begin();
}
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t) {
while (sceneT < t - 1e-6) {
advance(Math.min(1 / 30, t - sceneT));
}
}
function begin() {
sceneT = 0;
build();
if (lap === 0 && !still) {
runTo(START_S);
}
}
function showNow() {
draw();
pushStatus(statusText());
checkDone();
}
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);
}
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play() {
begin();
showNow();
if (!paused) {
startLoop();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
function restart() {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 一歩進める — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce() {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) {
const d = Math.min(1 / 30, left);
advance(d);
left -= d;
}
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld() {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) {
launchBall(w, b, w.launches[w.launchAt % w.launches.length]);
w.launchAt += 1;
return true;
}
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 一時停止), P 는 一時停止, N 은 一歩進める, R 은 다시
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) {
startLoop();
}
}
else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) {
showNow();
}
else {
togglePause();
}
}
else if (k === 'p' || k === 'P') {
togglePause();
}
else if (k === 'n' || k === 'N') {
stepOnce();
}
else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e) {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) {
WS[0].keyDir = 0;
}
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e) {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e) {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) {
startLoop();
}
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e) {
if (manual) {
WS[0].pointX = pointOf(e);
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 문자열 격자 레벨과 연쇄 폭발 파장 ─────────────────
// 레벨은 줄 단위 문자열 — 글자 하나가 벽돌 한 칸: . 빈 칸 · 1~3 체력 · B 폭발 벽돌(체력 1)
const LEVEL_ROWS = [
'..11222211..',
'.11B2222B11.',
'112B3333B211',
'12B322223B21',
'.1223322311.',
'..112BB211..',
];
// 폭발 반경(칸) · 한 칸 퍼지는 데 걸리는 시간(초). 파장은 BLAST_R 칸 안의 벽돌에 거리 × WAVE_DELAY 만큼 늦게 닿는다
const BLAST_R = 1.25;
const WAVE_DELAY = 0.08;
// 폭발 고리가 보이는 시간(초)
const RING_LIFE = 0.34;
const BASE_SPEED = 300;
const LAP_S = 12.0;
const START_S = 0.6;
const REST_S = 5.4;
let WS = [];
let blasts = [];
function parseLevel(w, rows) {
const cols = 12;
const gap = 2;
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows.length; r += 1) {
for (let c = 0; c < cols; c += 1) {
const ch = rows[r].charAt(c);
if (ch === '' || ch === '.') {
continue;
}
const bomb = ch === 'B';
const k = addBrick(w, 8 + c * (bw + gap), 30 + r * 17, bw, 15, bomb ? 1 : Number(ch), r, c);
if (bomb) {
k.kind = 2;
}
}
}
}
// 폭발 벽돌이 깨지는 순간 — 그 칸에서 BLAST_R 칸 안의 살아 있는 벽돌에 도화선(fuse)을 건다. 이웃이 폭발 벽돌이면 그 칸에서 또 이어진다
function onBlast(w, k) {
if (k.kind !== 2) {
return;
}
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
blasts.push([cx, cy, w.t]);
const cell = k.w + 2;
for (const o of w.bricks) {
if (o.deadT >= 0 || o === k) {
continue;
}
const d = Math.hypot(o.x + o.w / 2 - cx, o.y + o.h / 2 - cy) / cell;
if (d > BLAST_R) {
continue;
}
const f = w.t + d * WAVE_DELAY * 2;
if (o.fuse < 0 || f < o.fuse) {
o.fuse = f;
}
}
}
// 도화선 시각이 지난 벽돌을 한 번에 깬다 — 체력이 얼마든 터진다
function fuseTick(w) {
for (const k of w.bricks) {
if (k.deadT < 0 && k.fuse >= 0 && w.t >= k.fuse) {
k.hp = 1;
hurt(w, k);
}
}
}
function ctl9(w, dt) {
autoCtl(w, dt);
fuseTick(w);
}
function build() {
blasts = [];
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 120;
w.px = 228;
w.speed = BASE_SPEED;
w.aims = [0.3, -0.4, 0.2, -0.2];
w.launches = [-9, 17, -24];
w.trailN = 9;
w.onBreak = onBlast;
w.ctl = ctl9;
parseLevel(w, LEVEL_ROWS);
w.balls.push(newBall(w, 0));
// 둘째 공은 늦게 — 두 공이 서로 다른 구멍으로 파고든다
const b2 = newBall(w, 1);
b2.born = 2.2;
w.balls.push(b2);
WS = [w];
}
function update(dt) {
advanceWorld(WS[0], dt);
}
function statusText() {
return '残りブロック {k}枚・爆発 {b}回'.replace('{k}', String(aliveCount(WS[0]))).replace('{b}', String(blasts.length));
}
// ── draw ──────────────────────────────────────────────
// 폭발 벽돌 표시와 퍼지는 파장 고리
function blastMarks(w) {
for (const k of w.bricks) {
if (k.kind !== 2 || k.deadT >= 0) {
continue;
}
disc(k.x + k.w / 2, k.y + k.h / 2, 3.6, INK, 0.9);
seg(k.x + k.w / 2 - 6, k.y + k.h / 2, k.x + k.w / 2 + 6, k.y + k.h / 2, 1.4, INK, 0.7);
seg(k.x + k.w / 2, k.y + k.h / 2 - 6, k.x + k.w / 2, k.y + k.h / 2 + 6, 1.4, INK, 0.7);
}
for (const b of blasts) {
const age = w.t - b[2];
if (age < 0 || age > RING_LIFE) {
continue;
}
// 고리 속도 = 한 칸(36.8px)을 WAVE_DELAY × 2 초에 — 도화선이 걸리는 속도와 같고, 폭발 반경에서 멈춰 사라진다
const r = Math.min(age * (36.8 / (WAVE_DELAY * 2)), BLAST_R * 36.8 + 6);
ring(b[0], b[1], r, 3, ROSE_DEEP, 0.85 * (1 - age / RING_LIFE));
ring(b[0], b[1], Math.max(0, r - 9), 2, ROSE, 0.6 * (1 - age / RING_LIFE));
}
}
function draw() {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, blastMarks);
}
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// StringGridLevelChainBlastWave - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./StringGridLevelChainBlastWave.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function StringGridLevelChainBlastWave() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type RGBA = [number, 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 ease(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 시드 고정 난수 — 선형 합동 생성기(곱 1664525, 더함 1013904223). 같은 시드면 같은 균열 모양
function seeded(seed: number): () => number {
let s = (seed >>> 0) || 1;
return function (): number {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
// 파스텔 팔레트 — 벽돌은 줄마다 다른 파스텔, 공·패들·글자는 진한 잉크
const BG_TOP: RGB = [248, 244, 252];
const BG_BOT: RGB = [228, 234, 250];
const FIELD: RGB = [253, 251, 255];
const FIELD_EDGE: RGB = [196, 186, 240];
const ROSE: RGB = [244, 166, 188];
const ROSE_DEEP: RGB = [190, 52, 86];
const MINT: RGB = [158, 218, 196];
const BUTTER: RGB = [250, 226, 148];
const WATER: RGB = [166, 206, 246];
const LAV: RGB = [196, 182, 240];
const LAV_DEEP: RGB = [96, 72, 168];
const INK: RGB = [40, 36, 54];
const PAPER: RGB = [250, 246, 240];
const ROWCOL: RGB[] = [ROSE, BUTTER, MINT, WATER, LAV];
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) + ')';
}
// 장면 시계(초)·판 반복 번호·직접 하는 중인지
let sceneT = 0;
let lap = 0;
let manual = false;
// ── 공 하나의 한 걸음을 계산하는 곳 ───────────────────────────
// 공 반지름 · 고정 걸음 · 한 번에 이을 수 있는 시간 상한 · 패들 맞은 자리가 만드는 가장 큰 각도(세로 기준)
const BALL_R = 5;
const STEP_H = 1 / 120;
const DT_MAX = 0.05;
const MAX_ANGLE = 58 * Math.PI / 180;
const SERVE_WAIT = 0.5;
// 모드 — 장면마다 어느 규칙을 켜고 끄는지. 같은 계산에 스위치만 다르게 둬서 오른쪽 대비판이 나온다
// sweep: 한 걸음 이동을 stepMax 이하 조각으로 쪼개 조각마다 벽돌 검사 / face: 0 겹침이 얕은 축의 면에서 반사, 1 속도 y 만 뒤집기
// angle: 0 패들 맞은 자리로 반사각, 1 거울 반사 / spin: 패들 속도 → 회전 전달 비율 / push: 회전이 옆으로 미는 세기 / decay: 회전이 줄어드는 비율
type Mode = { sweep: boolean; stepMax: number; face: number; angle: number; spin: number; push: number; decay: number };
type Brick = {
x: number; y: number; w: number; h: number; hp: number; hp0: number; row: number; col: number; kind: number;
hitT: number; deadT: number; fuse: number; mis: number;
};
type Ball = { x: number; y: number; vx: number; vy: number; spin: number; st: number; born: number; trail: number[]; id: number };
type World = {
x: number; y: number; w: number; h: number;
t: number; acc: number; step: number;
mode: Mode;
bricks: Brick[]; balls: Ball[];
px: number; py: number; pw: number; ph: number; pv: number;
speed: number; trailN: number;
follow: number; vmax: number; naive: boolean;
aims: number[]; launches: number[]; launchAt: number;
ctl: ((w: World, dt: number) => void) | null;
hand: boolean; keyDir: number; pointX: number;
broken: number; hits: number; padHits: number; lost: number; missed: number; stuck: number;
padT: number; lastU: number; lastBreak: number;
onBreak: ((w: World, b: Brick) => void) | null;
onPad: ((w: World, b: Ball) => void) | null;
paint: ((k: Brick) => RGB) | null;
};
function newWorld(x: number, y: number, w: number, h: number, mode: Mode): World {
return {
x: x, y: y, w: w, h: h, t: 0, acc: 0, step: STEP_H, mode: mode, bricks: [], balls: [],
px: w / 2, py: h - 22, pw: 96, ph: 8, pv: 0, speed: 250, trailN: 10,
follow: 12, vmax: 520, naive: false, aims: [0], launches: [0], launchAt: 0, ctl: autoCtl,
hand: false, keyDir: 0, pointX: -1,
broken: 0, hits: 0, padHits: 0, lost: 0, missed: 0, stuck: 0, padT: -9, lastU: 0, lastBreak: -9,
onBreak: null, onPad: null, paint: null,
};
}
function addBrick(w: World, x: number, y: number, bw: number, bh: number, hp: number, row: number, col: number): Brick {
const k: Brick = { x: x, y: y, w: bw, h: bh, hp: hp, hp0: hp, row: row, col: col, kind: 0, hitT: -9, deadT: -1, fuse: -1, mis: -9 };
w.bricks.push(k);
return k;
}
// 줄×칸 격자 — 가로는 판 폭에 맞춰 칸 폭을 나누고 줄 간격은 bh + gap
function fillGrid(w: World, rows: number, cols: number, top: number, bh: number, gap: number, hpOf: (r: number, c: number) => number): void {
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows; r += 1) {
for (let c = 0; c < cols; c += 1) {
const hp = hpOf(r, c);
if (hp > 0) { addBrick(w, 8 + c * (bw + gap), top + r * (bh + gap), bw, bh, hp, r, c); }
}
}
}
function newBall(w: World, id: number): Ball {
return { x: w.px, y: w.py - BALL_R, vx: 0, vy: 0, spin: 0, st: 1, born: w.t, trail: [], id: id };
}
// 발사 — 세로에서 deg 도 기울인 방향으로 w.speed
function launchBall(w: World, b: Ball, deg: number): void {
const a = deg * Math.PI / 180;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
b.spin = 0;
b.st = 0;
}
function aliveCount(w: World): number {
let n = 0;
for (const k of w.bricks) { if (k.deadT < 0) { n += 1; } }
return n;
}
// 패들 맞은 자리 — 패들 중심에서 공까지의 거리를 반폭으로 나눈 값(−1~1)
function hitOffset(w: World, b: Ball): number { return clamp((b.x - w.px) / (w.pw / 2), -1, 1); }
// 벽 반사를 펴서 낙하 지점 — 판 폭 안쪽(r ~ w−r)으로 접어 넣는다. 접은 값 = 반사를 다 거친 위치
function foldW(v: number, lo: number, hi: number): number {
const len = hi - lo;
const per = 2 * len;
const u = ((v - lo) % per + per) % per;
return lo + (u <= len ? u : per - u);
}
// 공이 패들 높이에 닿을 때까지 남은 시간(초)과 그때의 가로 위치 — 벽돌은 무시, 벽만 편다
function landing(w: World, b: Ball): number[] {
const down = w.py - BALL_R;
let t = 0;
if (b.vy > 0) { t = (down - b.y) / b.vy; } else { t = ((b.y - BALL_R) + (down - BALL_R)) / Math.max(1e-6, -b.vy); }
return [foldW(b.x + b.vx * t, BALL_R, w.w - BALL_R), t];
}
function pickBall(w: World): Ball | null {
let best: Ball | null = null;
let bt = 1e9;
for (const b of w.balls) {
if (b.st !== 0) { continue; }
const t = landing(w, b)[1];
if (t < bt) { bt = t; best = b; }
}
return best;
}
const HAND_V = 280;
// 패들 조종 — 자동은 낙하 지점에서 맞은 자리(aim)만큼 비켜 서서 반사각을 정하고, naive 는 낙하 지점 대신 공의 지금 가로 위치(같은 aim 만큼 비켜서)만 따라간다
function autoCtl(w: World, dt: number): void {
if (w.hand) {
if (w.keyDir !== 0) { w.pv = w.keyDir * HAND_V; } else if (w.pointX >= 0) { w.pv = clamp((w.pointX - w.px) * 18, -w.vmax, w.vmax); } else { w.pv = 0; }
} else {
const b = pickBall(w);
let tgt = w.px;
if (b) {
const aim = w.aims[w.padHits % w.aims.length];
tgt = (w.naive ? b.x : landing(w, b)[0]) - aim * w.pw / 2;
}
w.pv = clamp((tgt - w.px) * w.follow, -w.vmax, w.vmax);
}
const nx = clamp(w.px + w.pv * dt, w.pw / 2, w.w - w.pw / 2);
w.pv = (nx - w.px) / dt;
w.px = nx;
}
// 선분(공이 한 조각에 지나간 길)이 벽돌을 공 반지름만큼 부풀린 상자와 만나는지 — 슬랩 검사. 통과 진단용.
// 상자 가장자리 2px 안쪽만 센다(공은 둥근데 상자는 모서리가 각져 스치기만 한 것을 통과로 세지 않으려고)
function segHitsBrick(x0: number, y0: number, x1: number, y1: number, k: Brick): boolean {
let t0 = 0;
let t1 = 1;
const dx = x1 - x0;
const dy = y1 - y0;
const lo = [k.x - BALL_R + 2, k.y - BALL_R + 2];
const hi = [k.x + k.w + BALL_R - 2, k.y + k.h + BALL_R - 2];
const o = [x0, y0];
const d = [dx, dy];
for (let a = 0; a < 2; a += 1) {
if (Math.abs(d[a]) < 1e-9) {
if (o[a] < lo[a] || o[a] > hi[a]) { return false; }
} else {
let ta = (lo[a] - o[a]) / d[a];
let tb = (hi[a] - o[a]) / d[a];
if (ta > tb) { const s = ta; ta = tb; tb = s; }
t0 = Math.max(t0, ta);
t1 = Math.min(t1, tb);
if (t0 > t1) { return false; }
}
}
return true;
}
function hurt(w: World, k: Brick): void {
k.hitT = w.t;
w.hits += 1;
if (k.hp >= 90) { return; }
k.hp -= 1;
if (k.hp <= 0) {
k.deadT = w.t;
w.broken += 1;
w.lastBreak = w.t;
if (w.onBreak) { w.onBreak(w, k); }
}
}
function reflectBricks(w: World, b: Ball, h: number): void {
let best: Brick | null = null;
let bd = 0;
for (const k of w.bricks) {
if (k.deadT >= 0) { continue; }
const ox = BALL_R + k.w / 2 - Math.abs(b.x - (k.x + k.w / 2));
const oy = BALL_R + k.h / 2 - Math.abs(b.y - (k.y + k.h / 2));
if (ox <= 0 || oy <= 0) { continue; }
const d = Math.min(ox, oy);
if (d > bd) { bd = d; best = k; }
}
if (!best) { return; }
const k = best;
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
if (w.mode.face === 0) {
// 겹침 깊이가 얕은 축의 면이 맞은 면 — 그 면의 바깥 쪽으로 밀어내고 그 축의 속도만 뒤집는다
const ox = BALL_R + k.w / 2 - Math.abs(b.x - cx);
const oy = BALL_R + k.h / 2 - Math.abs(b.y - cy);
if (ox < oy) {
const dir = b.x < cx ? -1 : 1;
b.x += dir * ox;
b.vx = dir * Math.abs(b.vx);
} else {
const dir = b.y < cy ? -1 : 1;
b.y += dir * oy;
b.vy = dir * Math.abs(b.vy);
}
} else {
// 대비판 — 면을 고르지 않고 위아래 속도만 뒤집는다. 옆구리를 맞으면 벽돌 안으로 파고든다
if (w.t - k.hitT < h * 1.5) { w.stuck += 1; }
b.vy = -b.vy;
}
hurt(w, k);
}
function moveBall(w: World, b: Ball, h: number): void {
const m = w.mode;
if (m.spin > 0) {
// 회전은 프레임마다 옆으로 미는 힘이 되고 서서히 줄어든다
b.vx += b.spin * m.push * h;
b.spin *= Math.exp(-m.decay * h);
}
// 속도 크기는 늘 w.speed — 회전이 방향만 바꾼다. 거의 수평이 되면 세로 성분을 살려 갇힘을 막는다
let s = Math.hypot(b.vx, b.vy) || 1;
if (Math.abs(b.vy) < 0.2 * s) { b.vy = (b.vy < 0 ? -1 : 1) * 0.2 * s; s = Math.hypot(b.vx, b.vy); }
b.vx *= w.speed / s;
b.vy *= w.speed / s;
const dist = w.speed * h;
const n = m.sweep ? Math.max(1, Math.ceil(dist / m.stepMax)) : 1;
const sx = b.x;
const sy = b.y;
for (let i = 0; i < n; i += 1) {
const dt = h / n;
const py0 = b.y;
const px0 = b.x;
const pvx = b.vx;
const pvy = b.vy;
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < BALL_R) { b.x = BALL_R; b.vx = Math.abs(b.vx); }
if (b.x > w.w - BALL_R) { b.x = w.w - BALL_R; b.vx = -Math.abs(b.vx); }
if (b.y < BALL_R) { b.y = BALL_R; b.vy = Math.abs(b.vy); }
// 패들 — 공 아래쪽이 패들 윗면 선을 넘어간 걸음에서 맞은 자리를 잰다
if (b.vy > 0 && py0 + BALL_R <= w.py + 0.001 && b.y + BALL_R >= w.py && Math.abs(b.x - w.px) <= w.pw / 2 + BALL_R * 0.6) {
b.y = w.py - BALL_R;
const u = hitOffset(w, b);
if (m.angle === 0) {
const a = u * MAX_ANGLE;
b.vx = Math.sin(a) * w.speed;
b.vy = -Math.cos(a) * w.speed;
} else {
b.vy = -Math.abs(b.vy);
}
if (m.spin > 0) { b.spin += w.pv * m.spin; }
w.padHits += 1;
w.padT = w.t;
w.lastU = u;
if (w.onPad) { w.onPad(w, b); }
}
reflectBricks(w, b, h);
// 통과 진단 — 이 조각이 지나간 길이 벽돌 상자와 만났는데 맞지 않았으면 통과로 센다(벽돌마다 0.5초에 한 번)
// 벽·패들·벽돌에서 튕긴 조각은 직선이 아니라서 건너뛴다
const bent = b.vx !== pvx || b.vy !== pvy;
for (const k of w.bricks) {
if (bent || k.deadT >= 0 || k.hitT === w.t || w.t - k.mis < 0.5) { continue; }
// 끝 위치가 벽돌과 겹쳐 있으면 닿은 것이지 건너뛴 게 아니다
if (Math.abs(b.x - (k.x + k.w / 2)) < BALL_R + k.w / 2 && Math.abs(b.y - (k.y + k.h / 2)) < BALL_R + k.h / 2) { continue; }
if (segHitsBrick(px0, py0, b.x, b.y, k)) { k.mis = w.t; w.missed += 1; }
}
if (b.y > w.h + BALL_R + 2) { b.st = 2; w.lost += 1; break; }
}
b.trail.push(b.x, b.y);
while (b.trail.length > w.trailN * 2) { b.trail.splice(0, 2); }
}
function stepWorld(w: World, h: number): void {
w.t += h;
if (w.ctl) { w.ctl(w, h); }
for (const b of w.balls) {
if (b.st === 1) {
b.x = w.px;
b.y = w.py - BALL_R;
if (!manual && w.t - b.born >= SERVE_WAIT) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; }
} else if (b.st === 0) {
moveBall(w, b, h);
}
}
// 바닥으로 빠진 공 — 공이 하나 더 남아 있으면 지우고, 마지막이면 패들 위에서 다시 시작한다
for (let i = w.balls.length - 1; i >= 0; i -= 1) {
const b = w.balls[i];
if (b.st !== 2) { continue; }
let others = 0;
for (const o of w.balls) { if (o !== b && o.st !== 2) { others += 1; } }
if (others > 0) { w.balls.splice(i, 1); } else { b.st = 1; b.born = w.t; b.trail = []; b.x = w.px; b.y = w.py - BALL_R; }
}
}
// 시간을 고정 걸음으로 쪼개 부른다 — 한 번에 DT_MAX 를 넘는 시간은 이어 붙이지 않는다
function advanceWorld(w: World, dt: number): void {
w.acc += Math.min(dt, DT_MAX);
while (w.acc >= w.step - 1e-9) {
stepWorld(w, w.step);
w.acc -= w.step;
}
}
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const padEl = root.querySelector('[data-role="pad"]') as HTMLElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const statusEl = root.querySelector('[data-role="status"]') as HTMLElement | null;
const stepEl = root.querySelector('[data-role="step"]') as HTMLButtonElement | null;
const doneEl = root.querySelector('[data-role="done"]') as HTMLElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl || !padEl || !stepEl || !doneEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
const stepBtn: HTMLButtonElement = stepEl;
const doneP: HTMLElement = doneEl;
const pad: HTMLElement = padEl;
// 외부 웹폰트는 부르지 않는다 — 기기에 있는 글꼴 목록만
const FONT_STACK = 'system-ui, -apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
let dpr = 1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘지 않게
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 skyFill(top: RGB, bot: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bot, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
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 ring(x: number, y: number, r: number, width: number, c: RGB, a: number): void {
if (r <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function seg(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 box(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; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.fillStyle = rgba(c, a);
ctx.fill();
}
function outline(x: number, y: number, w: number, h: number, r: number, width: number, c: RGB, a: number): void {
if (w <= 0.2 || h <= 0.2 || a <= 0) { return; }
ctx.beginPath();
ctx.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
ctx.lineWidth = width;
ctx.strokeStyle = rgba(c, a);
ctx.stroke();
}
function text(s: string, x: number, y: number, c: RGB, a: number, px: number, weight: number, align: CanvasTextAlign): void {
ctx.font = weight + ' ' + px + 'px ' + FONT_STACK;
ctx.textAlign = align;
ctx.textBaseline = 'middle';
ctx.fillStyle = rgba(c, a);
ctx.fillText(s, x, y);
}
// 글자 이름표 — 진한 알약 위에 크림색 글자
function tag(s: string, x: number, y: number, bg: RGB, fg: RGB, px: number, align: CanvasTextAlign): void {
ctx.font = '700 ' + px + 'px ' + FONT_STACK;
const w = ctx.measureText(s).width + px * 1.1;
const left = align === 'center' ? x - w / 2 : (align === 'right' ? x - w : x);
box(left, y - px * 0.82, w, px * 1.64, px * 0.82, bg, 0.94);
text(s, left + w / 2, y + 0.5, fg, 1, px, 700, 'center');
}
// 장면 이름표(무대 좌표) — 판 안쪽 위 가운데(바깥 위는 상태 줄·버튼 자리)
function panelTag(w: World, s: string, bad: boolean): void {
tag(s, w.x + w.w / 2, w.y + 12, bad ? ROSE_DEEP : INK, PAPER, 10, 'center');
}
// ── 판 그리기 ────────────────────────────────────────────
// 상태(벽돌·공·패들)를 그릴 뿐이고 규칙은 건드리지 않는다. 좌표는 판 안쪽(왼쪽 위가 0,0)
// 균열 선 개수의 상한(체력 4 벽돌이 마지막 한 대 전에 가장 많이 갈라진 모습)
const CRACK_N = 3;
const BREAK_S = 0.6;
function crackLines(k: Brick, n: number): void {
const rng = seeded(k.row * 131 + k.col * 17 + 7);
for (let i = 0; i < n; i += 1) {
const sx = k.x + k.w * (0.2 + 0.6 * rng());
const top = (i % 2) === 0;
let cx = sx;
let cy = top ? k.y : k.y + k.h;
const ty = top ? k.y + k.h * 0.9 : k.y + k.h * 0.1;
const steps = 3;
for (let s = 0; s < steps; s += 1) {
const nx = cx + (rng() - 0.5) * k.w * 0.34;
const ny = cy + (ty - cy) / (steps - s);
seg(cx, cy, nx, ny, 1.4, INK, 0.62);
cx = nx;
cy = ny;
}
}
}
function drawBrick(w: World, k: Brick, hatch: boolean): void {
const base = w.paint ? w.paint(k) : ROWCOL[k.row % ROWCOL.length];
const dead = k.deadT >= 0;
if (dead) {
const age = w.t - k.deadT;
if (age >= BREAK_S) { return; }
const p = age / BREAK_S;
const sc = 1 - ease(clamp(age / 0.14, 0, 1));
box(k.x + k.w * (1 - sc) / 2, k.y + k.h * (1 - sc) / 2, k.w * sc, k.h * sc, 3, base, 1 - p);
// 조각 — 번호로 정한 방향으로 날아가며 줄어든다(난수 없이 칸 번호에서 계산)
outline(k.x - age * 45, k.y - age * 45, k.w + age * 90, k.h + age * 90, 4 + age * 45, 2.2, mix(base, INK, 0.4), 0.6 * (1 - p));
for (let i = 0; i < 8; i += 1) {
const an = (i * 1.27 + k.col * 0.7 + k.row * 1.9) % TAU;
const sp = 38 + 11 * i;
const fx = k.x + k.w / 2 + Math.cos(an) * sp * age;
const fy = k.y + k.h / 2 + Math.sin(an) * sp * age + 120 * age * age;
box(fx - 4, fy - 3, 8 * (1 - p), 6 * (1 - p), 2, base, 1 - p);
}
return;
}
const age = w.t - k.hitT;
const sq = age >= 0 && age < 0.18 ? Math.sin(age / 0.18 * Math.PI) : 0;
if (age >= 0 && age < 0.3) {
const e = age * 40;
outline(k.x - e, k.y - e, k.w + 2 * e, k.h + 2 * e, 4 + e, 2.2, mix(base, INK, 0.45), 0.75 * (1 - age / 0.3));
}
const bx = k.x + sq * 1.2;
const by = k.y + sq * 1.2;
const bw = k.w - sq * 2.4;
const bh = k.h - sq * 2.4;
box(bx, by + 2, bw, bh, 3, mix(base, INK, 0.28), 1);
box(bx, by, bw, bh, 3, mix(base, [255, 255, 255], sq * 0.6), 1);
box(bx + 2, by + 1.5, bw - 4, 2, 1, [255, 255, 255], 0.5);
if (hatch) {
for (let i = 1; i < 4; i += 1) { seg(bx + bw * i / 4 - 3, by + bh - 2, bx + bw * i / 4 + 3, by + 2, 1.2, INK, 0.3); }
}
if (k.hp0 > 1 && k.hp0 < 90 && k.hp < k.hp0) { crackLines(k, Math.min(CRACK_N, k.hp0 - k.hp)); }
}
function drawBall(w: World, b: Ball, col: RGB): void {
const n = b.trail.length / 2;
for (let i = 0; i < n; i += 1) {
const f = (i + 1) / n;
disc(b.trail[i * 2], b.trail[i * 2 + 1], BALL_R * (0.5 + 0.65 * f), col, 0.14 + 0.4 * f * f);
}
if (b.st === 2) { return; }
disc(b.x, b.y, BALL_R + 3, col, 0.18);
disc(b.x, b.y, BALL_R, INK, 1);
disc(b.x - 1.7, b.y - 1.8, 1.6, [255, 255, 255], 0.85);
}
function drawPaddle(w: World): void {
if (w.pw <= 0) { return; }
const age = w.t - w.padT;
const sq = age >= 0 && age < 0.16 ? Math.sin(age / 0.16 * Math.PI) : 0;
box(w.px - w.pw / 2, w.py + 2, w.pw, w.ph, 4, mix(LAV, INK, 0.45), 1);
box(w.px - w.pw / 2, w.py + sq * 2, w.pw, w.ph - sq * 2, 4, INK, 1);
box(w.px - w.pw / 2 + 4, w.py + 1.5 + sq * 2, w.pw - 8, 2, 1, MINT, 0.9);
}
// 판 하나 — under 는 벽돌 아래(바닥 표시 등), over 는 공 위(예측선·고리 등). 둘 다 판 좌표
function drawWorld(w: World, hatch: boolean, ballCol: RGB, under: ((w: World) => void) | null, over: ((w: World) => void) | null): void {
ctx.save();
box(w.x, w.y, w.w, w.h, 8, FIELD, 1);
ctx.beginPath();
ctx.roundRect(w.x, w.y, w.w, w.h, 8);
ctx.clip();
ctx.translate(w.x, w.y);
for (let gy = 20; gy < w.h; gy += 20) {
for (let gx = 20; gx < w.w; gx += 20) { disc(gx, gy, 0.9, FIELD_EDGE, 0.5); }
}
if (under) { under(w); }
for (const k of w.bricks) { drawBrick(w, k, hatch); }
drawPaddle(w);
for (const b of w.balls) { drawBall(w, b, ballCol); }
if (over) { over(w); }
ctx.restore();
outline(w.x, w.y, w.w, w.h, 8, 2, FIELD_EDGE, 1);
}
let shownStatus = '';
function pushStatus(s: string): void {
if (!statusEl || s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
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 {
if (manual) { return; }
manual = true;
WS[0].hand = true;
wake();
}
// 한 판 시연이 끝났음을 스크린리더에 한 번만 알린다 — 판이 되풀이돼도 다시 읽지 않는다
let doneSaid = false;
function checkDone(): void {
if (!manual && !doneSaid && sceneT >= LAP_S - 0.6) {
doneSaid = true;
doneP.textContent = statusText();
}
}
function advance(dt: number): void {
sceneT += dt;
update(dt);
if (!manual && sceneT >= LAP_S) { lap += 1; begin(); }
}
// 첫 판은 START_S 만큼 앞서 시작한다 — 공이 벽돌을 깨는 중인 장면에서 보인다
function runTo(t: number): void {
while (sceneT < t - 1e-6) { advance(Math.min(1 / 30, t - sceneT)); }
}
function begin(): void {
sceneT = 0;
build();
if (lap === 0 && !still) { runTo(START_S); }
}
function showNow(): void {
draw();
pushStatus(statusText());
checkDone();
}
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); }
showNow();
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();
lap = 0;
sceneT = 0;
build();
runTo(REST_S);
showNow();
}
function play(): void {
begin();
showNow();
if (!paused) { startLoop(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
function restart(): void {
manual = false;
doneSaid = false;
lap += 1;
begin();
showNow();
}
// 한 걸음 — 멈춘 채 0.1초만 앞으로. 직접 하던 중이면 그대로 이어서
const STEP_ONE = 0.1;
function stepOnce(): void {
wake();
if (!paused) {
paused = true;
pauseBtn.setAttribute('aria-pressed', 'true');
stopLoop();
}
let left = STEP_ONE;
while (left > 1e-6) { const d = Math.min(1 / 30, left); advance(d); left -= d; }
showNow();
}
// 멈춰 선 공이 있으면 발사하고 true — 직접 하는 중 공이 빠지면 패들 위에서 기다린다
function launchHeld(): boolean {
const w = WS[0];
for (const b of w.balls) {
if (b.st === 1) { launchBall(w, b, w.launches[w.launchAt % w.launches.length]); w.launchAt += 1; return true; }
}
return false;
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
stepBtn.addEventListener('click', stepOnce, { signal: off.signal });
// 키보드 — 무대에 포커스가 있을 때만. 방향키는 패들, Enter·Space 는 발사(멈춘 공이 없으면 일시정지), P 는 일시정지, N 은 한 걸음, R 은 다시
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
const k = e.key;
if (k === 'ArrowLeft' || k === 'ArrowRight') {
e.preventDefault();
takeOver();
WS[0].keyDir = k === 'ArrowLeft' ? -1 : 1;
if (!paused && !rafId && !still) { startLoop(); }
} else if (k === 'Enter' || k === ' ') {
e.preventDefault();
if (manual && launchHeld()) { showNow(); } else { togglePause(); }
} else if (k === 'p' || k === 'P') {
togglePause();
} else if (k === 'n' || k === 'N') {
stepOnce();
} else if (k === 'r' || k === 'R') {
restart();
}
}, { signal: off.signal });
pad.addEventListener('keyup', function (e: KeyboardEvent): void {
if ((e.key === 'ArrowLeft' && WS[0].keyDir < 0) || (e.key === 'ArrowRight' && WS[0].keyDir > 0)) { WS[0].keyDir = 0; }
}, { signal: off.signal });
// 마우스·터치 — 누르면 패들이 그 자리를 따라가고 이어서 움직이는 대로 따라가며, 멈춘 공이 있으면 발사
function pointOf(e: MouseEvent): number {
const rect = cv.getBoundingClientRect();
return (e.clientX - rect.left) / rect.width * W - WS[0].x;
}
pad.addEventListener('pointerdown', function (e: PointerEvent): void {
takeOver();
WS[0].pointX = pointOf(e);
WS[0].keyDir = 0;
launchHeld();
if (!paused && !rafId && !still) { startLoop(); }
}, { signal: off.signal });
pad.addEventListener('pointermove', function (e: PointerEvent): void {
if (manual) { WS[0].pointX = pointOf(e); }
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 정지 장면 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { lap = 0; play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
showNow();
}, { signal: off.signal });
// ── 09 문자열 격자 레벨과 연쇄 폭발 파장 ─────────────────
// 레벨은 줄 단위 문자열 — 글자 하나가 벽돌 한 칸: . 빈 칸 · 1~3 체력 · B 폭발 벽돌(체력 1)
const LEVEL_ROWS = [
'..11222211..',
'.11B2222B11.',
'112B3333B211',
'12B322223B21',
'.1223322311.',
'..112BB211..',
];
// 폭발 반경(칸) · 한 칸 퍼지는 데 걸리는 시간(초). 파장은 BLAST_R 칸 안의 벽돌에 거리 × WAVE_DELAY 만큼 늦게 닿는다
const BLAST_R = 1.25;
const WAVE_DELAY = 0.08;
// 폭발 고리가 보이는 시간(초)
const RING_LIFE = 0.34;
const BASE_SPEED = 300;
const LAP_S = 12.0;
const START_S = 0.6;
const REST_S = 5.4;
let WS: World[] = [];
let blasts: number[][] = [];
function parseLevel(w: World, rows: string[]): void {
const cols = 12;
const gap = 2;
const bw = (w.w - 16 - (cols - 1) * gap) / cols;
for (let r = 0; r < rows.length; r += 1) {
for (let c = 0; c < cols; c += 1) {
const ch = rows[r].charAt(c);
if (ch === '' || ch === '.') { continue; }
const bomb = ch === 'B';
const k = addBrick(w, 8 + c * (bw + gap), 30 + r * 17, bw, 15, bomb ? 1 : Number(ch), r, c);
if (bomb) { k.kind = 2; }
}
}
}
// 폭발 벽돌이 깨지는 순간 — 그 칸에서 BLAST_R 칸 안의 살아 있는 벽돌에 도화선(fuse)을 건다. 이웃이 폭발 벽돌이면 그 칸에서 또 이어진다
function onBlast(w: World, k: Brick): void {
if (k.kind !== 2) { return; }
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
blasts.push([cx, cy, w.t]);
const cell = k.w + 2;
for (const o of w.bricks) {
if (o.deadT >= 0 || o === k) { continue; }
const d = Math.hypot(o.x + o.w / 2 - cx, o.y + o.h / 2 - cy) / cell;
if (d > BLAST_R) { continue; }
const f = w.t + d * WAVE_DELAY * 2;
if (o.fuse < 0 || f < o.fuse) { o.fuse = f; }
}
}
// 도화선 시각이 지난 벽돌을 한 번에 깬다 — 체력이 얼마든 터진다
function fuseTick(w: World): void {
for (const k of w.bricks) {
if (k.deadT < 0 && k.fuse >= 0 && w.t >= k.fuse) {
k.hp = 1;
hurt(w, k);
}
}
}
function ctl9(w: World, dt: number): void {
autoCtl(w, dt);
fuseTick(w);
}
function build(): void {
blasts = [];
const w = newWorld(12, 40, 456, 248, { sweep: true, stepMax: 4, face: 0, angle: 0, spin: 0, push: 0, decay: 0 });
w.pw = 120;
w.px = 228;
w.speed = BASE_SPEED;
w.aims = [0.3, -0.4, 0.2, -0.2];
w.launches = [-9, 17, -24];
w.trailN = 9;
w.onBreak = onBlast;
w.ctl = ctl9;
parseLevel(w, LEVEL_ROWS);
w.balls.push(newBall(w, 0));
// 둘째 공은 늦게 — 두 공이 서로 다른 구멍으로 파고든다
const b2 = newBall(w, 1);
b2.born = 2.2;
w.balls.push(b2);
WS = [w];
}
function update(dt: number): void {
advanceWorld(WS[0], dt);
}
function statusText(): string {
return '남은 벽돌 {k}장 · 폭발 {b}번'.replace('{k}', String(aliveCount(WS[0]))).replace('{b}', String(blasts.length));
}
// ── draw ──────────────────────────────────────────────
// 폭발 벽돌 표시와 퍼지는 파장 고리
function blastMarks(w: World): void {
for (const k of w.bricks) {
if (k.kind !== 2 || k.deadT >= 0) { continue; }
disc(k.x + k.w / 2, k.y + k.h / 2, 3.6, INK, 0.9);
seg(k.x + k.w / 2 - 6, k.y + k.h / 2, k.x + k.w / 2 + 6, k.y + k.h / 2, 1.4, INK, 0.7);
seg(k.x + k.w / 2, k.y + k.h / 2 - 6, k.x + k.w / 2, k.y + k.h / 2 + 6, 1.4, INK, 0.7);
}
for (const b of blasts) {
const age = w.t - b[2];
if (age < 0 || age > RING_LIFE) { continue; }
// 고리 속도 = 한 칸(36.8px)을 WAVE_DELAY × 2 초에 — 도화선이 걸리는 속도와 같고, 폭발 반경에서 멈춰 사라진다
const r = Math.min(age * (36.8 / (WAVE_DELAY * 2)), BLAST_R * 36.8 + 6);
ring(b[0], b[1], r, 3, ROSE_DEEP, 0.85 * (1 - age / RING_LIFE));
ring(b[0], b[1], Math.max(0, r - 9), 2, ROSE, 0.6 * (1 - age / RING_LIFE));
}
}
function draw(): void {
skyFill(BG_TOP, BG_BOT);
drawWorld(WS[0], false, LAV_DEEP, null, blastMarks);
}
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__pad")} data-role="pad" tabIndex={0} role="application" aria-label={'벽돌깨기 시연 무대 — 좌우 방향키로 패들, Enter 로 발사, P 로 일시정지, N 으로 한 걸음, R 로 다시'}>
<canvas className={cx("vj__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label={'줄 단위 문자열로 적은 레벨이 벽돌 격자로 바뀌고, 폭발 벽돌을 깨면 맞은 칸에서 한 칸씩 퍼지는 파장이 이웃 벽돌을 지연 시간을 두고 연쇄로 터뜨린다. 파장 고리가 퍼지고 터진 벽돌은 조각으로 흩어진다'}></canvas>
</div>
<p className={cx("vj__status")} data-role="status" aria-live="off">{'벽돌깨기 시연'}</p>
<div className={cx("vj__ctrl")}>
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
<button className={cx("vj__step")} data-role="step" type="button">{'한 걸음'}</button>
</div>
<p className={cx("vj__done")} data-role="done" role="status" aria-live="polite"></p>
</div>
);
}09 · 文字列の格子レベルと連鎖爆発の波 — 計測 累積面積 12.41% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function onBlast(w, k) {
if (k.kind !== 2) {
return;
}
const cx = k.x + k.w / 2;
const cy = k.y + k.h / 2;
blasts.push([cx, cy, w.t]);
const cell = k.w + 2;
for (const o of w.bricks) {
if (o.deadT >= 0 || o === k) {
continue;
}
const d = Math.hypot(o.x + o.w / 2 - cx, o.y + o.h / 2 - cy) / cell;
if (d > BLAST_R) {
continue;
}
const f = w.t + d * WAVE_DELAY * 2;
if (o.fuse < 0 || f < o.fuse) {
o.fuse = f;
}
}
}
ブロック崩しをコードで組むと、ボール一つの一コマはどんな値いくつで決まるのでしょうか?
球ひとつが持つ値
球は位置 x・y、速度 vx・vy、回転 spin、状態 st、軌跡の履歴 trail に分かれます。状態は0が飛行中、1がパドルの上、2が落下で、速度の大きさは球ではなく盤の w.speed が決めます。
| 値 | 意味 | 変える場所 |
|---|---|---|
vx・vy |
向き | パドルに当たった位置・壁・ブロックの面 |
spin |
横へ押す力 | パドルの速さ(04) |
st |
飛行中・パドルの上・落下 | 発射・床 |
hp・deadT・fuse |
ブロックの耐久・壊れた時刻・導火線 | hurt・onBlast(06・09) |
スイッチひとつが対比盤を作ります
Mode の sweep・face・angle・spin は、計算のたった一か所だけを変えるスイッチです。01の angle、02の sweep、03の face、04の spin が右の盤で違うほうに入り、ほかの計算は同じなので、二つの盤の差はその決まりひとつのぶんです。
ボールの位置と速度を受け取り、移動の区間を刻んで、最初に触れるブロックを探し、当たった面の法線で速度を反射する — ブロック崩し一コマが計算される順番
一歩の順番
moveBall は回転を横へ押す力として足し、速度の大きさを w.speed に合わせ、ほぼ水平なら縦の成分を20%まで戻します。続いて移動の長さを STEP_MAX 以下の欠片に刻み、欠片ごとに壁の反射、パドル上面の線を通ったかの検査、reflectBricks を順に行います。いちばん深く重なったブロックひとつだけを選ぶので、一つの欠片で処理するブロックはひとつです。
欠片の数は切り上げで決まります
欠片の数は、移動の長さを STEP_MAX で割って切り上げた値です。02の一コマ42pxは11の欠片、07の上限速度590px/秒は一歩1/120秒で4.92pxなので2欠片になります。欠片が小さいほど球がブロックの箱を飛び越える隙は減りますが、計算はそのぶん増え、移動の区間を刻む考え方は MDN の 2D collision detection が扱う衝突検査の延長です。
ブロック崩しゲームの無料版のようにルールを重ねて実際のゲームに付けるとき — ライブラリなしで作る・レベルを文字列で書く方法・球の速度を上げる時点
ライブラリなしで作る
必要なのはキャンバス2Dだけで、九つの場面はライブラリ・WebGL・CDNを使いません。ブロック崩し web のページに得点や残機やメニューを付けるのはこの計算の枠の外の仕事なので、World の値を読む側で足せば済みます。
レベルを文字列で書く方法
09は . が空きマス、1〜3 が耐久、B が爆発ブロックの12マス×6行の文字列で盤を作ります。ブロック崩し ローグライク のように盤が頻繁に変わるゲームなら、行の文字列を差し替えるだけでレベルを増やせて、parseLevel は文字ひとつをブロック一マスとして読みます。
球の速度を上げる時点
速度は speedFor が壊した数から計算し、上げる瞬間に trailN も一緒に変えます。速度を上げても歩みの欠片の数がそれに合わせて増え、07の12秒の場面では通り抜けが0回だったので、難しさの曲線は SPEED_STEP と CLEAR_N の二つの値で調整します。
ピンボールの記事との違い
ピンボールの記事はフリッパーとバンパーを選ぶ仕組みを扱い、この記事はパドルの位置・移動の区間・ブロックの状態をひとつの計算の枠から取り出す構造を扱います。格子の決まりを時間表で見せる方法が気になるなら、マインスイーパーの記事も同じ枠を使っています。
ブロック崩しが壊れる瞬間 — 球が速くなるとブロックをすり抜ける落とし穴・角に当たって見当違いの向きに跳ねる反射・パドルの端で球が水平に閉じ込められる角度
球が速くなるとブロックをすり抜けます
02の右の盤のようにコマ末の位置だけを調べると、移動が16pxを超えた瞬間からブロック一列を飛び越えます。一コマ20pxで20.5%、30pxで46.5%、42pxで62.0%が通り抜け、10pxでは0%でした。直す方法は、移動を STEP_MAX 以下に刻むことひとつです。
角に当たって見当違いの向きに跳ねます
03の右の盤は速度yだけを反転するので、ブロックの横腹に当たった球がブロックの中で繰り返し当たります。画面の12秒に右は254回当たったうち、入り込んだ歩みが242でした。重なりの浅い軸を選べば、角をかすめても近い面で跳ね返ります。
パドルの端で球が水平に閉じ込められます
01の右の盤は鏡の反射だけを使い、同じ縦の筋のブロック四枚を壊して同じ筋を上下するだけで止まりました。球がほぼ水平になると一行に閉じ込められやすいので、moveBall は縦の成分を速度の20%より下にしません。
下の箱で受け取るzipには九つの場面の完成ソースと編集用のファイルが入っていて、開くときの圧縮パスワードはfj24x8mtです。
パドルの幅と球の速度段階と回転の伝わり方を変えると、感じはどれほど変わるのでしょうか — PADDLE_W・SPEED_STEP・SPIN_GAIN を変えた違い
| 名前 | 変えた値 | 感じ |
|---|---|---|
| パドルの幅を狭める | 01 PADDLE_W 96 → 56 |
当たった位置の割合は同じなので反射角の表(−1〜1が±58度)はそのままです。変わるのはpxの幅で、同じ0.7の位置がパドルの上で33.6pxから19.6pxに狭まり、球を受けられる半幅は51pxから31pxに減ります。自動操縦は落下地点に立つので12秒の結果は左11枚・右4枚のままでしたが、人が動かすときの余裕はそのぶん減ります |
| 速度段階を急にする | 07 SPEED_STEP 70 → 150 |
速度が190 → 340 → 490 → 590(SPEED_MAX で切れる)と三段階で終わり、最高速度に届く時刻が10.0秒から5.3秒に早まり、12秒に壊すブロックは24枚から27枚に増えます。速度が高くても歩みが STEP_MAX の欠片に刻まれるので、07の12秒の場面では通り抜けが0回ですが、コマを1/30秒に伸ばすと590px/秒の球は一コマで19.7px進み、ブロックの厚さ+直径の16pxを超えるので02の式で19%が通り抜けます |
| 回転の伝わりを大きくする | 04 SPIN_GAIN 0.15 → 0.45 |
同じ最初の当たり(パドル速度300px/秒、球の横速度86.7px/秒)で回転が45から135になり、0.5秒後の横の移動が+19.3px(右へ少し進む)から−28.2px(当たった位置を過ぎて左へ戻る)に変わり、横速度は−4.8から−163px/秒になります。回転を渡さない右の盤は+43.4pxのまま真っ直ぐ進みます |
値を変える前に見ること
三つの値は互いに触れません。PADDLE_W はpxの幅だけ、SPEED_STEP は速度の段階だけ、SPIN_GAIN は回転だけを変えるので、一度にひとつだけ変えれば、どの値が感じを変えたのかが見えます。
動きを減らす設定では止まった一枚のブロック盤です — 場面の名前と残りのブロック数を文で読み上げるアクセシビリティ
矢印キーでパドルを動かし Enter で発射します
舞台の箱は九つの場面とも role="application" で tabindex 0なのでフォーカスを受け、矢印キーを最初に押すと自動のデモが止まって人が動かす側になります。Enter・Space は止まった球を発射し、止まった球がなければ一時停止で、P は一時停止、N は0.1秒の一歩、R は最初からです。マウスやタッチは押すとパドルがその位置へ追いかけます。
状態の行は文が変わったときだけ書き直します
状態の行はデモ中は aria-live="off" で読まれず、人が動かす瞬間に polite へ変わり、文が変わったときだけ書き直します。一つの盤が終わる0.6秒前には、role="status" の隠れた段落に結果の文を一度だけ書きます。
動きを減らす設定と隠れたタブ
prefers-reduced-motion がオンならループを回さず、showRest が REST_S 秒の地点の止まった場面を一枚だけ描きます。タブが隠れたらループを止め、戻ったらまた始めます。この処理は MDN の Page Visibility API と prefers-reduced-motion の文書に従います。
// タブが隠れたら止め、戻ったら時計を合わせ直して始める — 隠れていた数秒が一コマに集まらないように
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 設定が変わったら — オンなら止まった場面を一枚、オフならまた動かす
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
lap = 0;
play();
}
}, { signal: off.signal });
ブロック崩しのよくある質問 — ライブラリなしで作る・球がブロックをすり抜けるときの対処・ウェブに載せるときに必要なこと
ブロック崩しゲームを載せるページに、ライブラリは必要ですか
キャンバス2Dと rAF だけで足ります。九つの場面はライブラリ・WebGL・CDNを使わず、場面のソースは一つのファイルに計算の枠と場面をいっしょに入れています。
球がブロックをすり抜けるときはどうすればよいですか
移動の区間を刻むのが答えです。STEP_MAX をブロックの厚さと球の直径の合計より小さくしておけば、02のスイープの12秒の場面では通り抜けが0回でした。
ウェブに載せるときは何が必要ですか
静的ファイルひと組で足ります。キャンバスは devicePixelRatio を2までしか掛けず、外部フォントもサーバー呼び出しもないので、どの静的ホスティングにも載せられます。
出典は MDN の requestAnimationFrame の文書です。









