01パドルに当たった位置で決める反射角
パドル中心から当たった位置までの距離を半幅で割った値で反射角を決めます。中央はまっすぐ、端は斜めに跳ね、右の盤は鏡の反射だけを使って球が一本の筋に閉じ込められる対比版です。
使いどころパドルで球の向きを狙えるようにするブロック崩しの基本の決まり
PADDLE_WhitOffsetMAX_ANGLE

<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 に同梱 · ブロック崩しゲーム 9種 — パドルの反射角から高速球のスイープ衝突と連鎖爆発まで
このデモが属する記事
ブロック崩しゲーム 9種 — パドルの反射角から高速球のスイープ衝突と連鎖爆発までブロック崩しはボールが当たった位置をどうやって向きに変えるのでしょうか。01番はパドル中心からの距離で反射角を決め、02番は一コマの移動を刻んで速い球がブロックを飛び越えないようにし、08番は壁の反射を展開して落下地点を先に計算します。 この記事のデモをすべて見る


