벽돌깨기 게임 만들기 9가지 — 반사각은 맞은 위치가 정한다
벽돌깨기 게임 만들기는 공 하나가 어디에 맞느냐에 따라 결과가 갈리는 아홉 장면짜리 캔버스 데모다. 패들 정중앙이 아니라 끝으로 쳐라 — 맞은 위치 값이 반사각을 정한다.
01 brick-breaker-paddle-drag-lerp
02 brick-breaker-ball-reflect-hit-angle
03 brick-breaker-collision-score
04 brick-breaker-crack-multihit
05 brick-breaker-powerup-gravity-catch
06 brick-breaker-trail-speed-afterimage
07 brick-breaker-combo-multiplier-streak
08 brick-breaker-wall-bounce-mirror
09 brick-breaker-clear-particle-burst
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 드래그한 위치를 뒤따라가는 패들
- 02 맞은 위치로 반사각이 갈라지는 공
- 03 충돌 판정마다 점수가 오르는 벽돌 줄
- 04 여러 번 맞아야 부서지는 금 가는 벽돌
- 05 중력 값대로 떨어지는 아이템을 받는 패들
- 06 속도 값만큼 길어지는 공의 잔상
- 07 연속 타격마다 배수가 오르는 콤보 값
- 08 벽에 닿으면 각도가 뒤집히는 튕김
- 09 벽돌이 모두 사라지면 터지는 입자
아홉 개는 무작위로 늘어놓은 목록이 아니다. 01은 패들이 커서를 뒤따르는 기본 조작을 보여주고, 02는 그 조작으로 친 공이 어디로 튕기는지를 정하는 핵심 규칙이다. 03·04는 그 공이 벽돌에 닿았을 때 점수와 내구력이 어떻게 바뀌는지를, 05·06·07은 아이템·잔상·콤보로 화면에 층을 하나씩 얹고, 08은 좌우 벽에서의 반사를, 09는 레벨이 끝나는 순간을 마무리한다.
맞은 위치 값 하나가 반사각·중력·연속 타격까지 함께 가른다
아홉 개 모두 사각 범위가 겹치는 순간만 보는 aabb 판정과, 범위를 벗어나지 않게 붙잡는 clamp 함수 위에 서 있다. 2D 충돌 감지 문서가 설명하는 사각 판정 방식 그대로이고, 이 두 함수는 css 게임 제작에서 반복해서 재사용되는 조합이다. 이 위에서 02는 패들 중앙과의 거리 값을 반사각으로, 05는 각자의 값을 중력 낙하 속도로, 07은 연속 성공 횟수를 배수로 바꿔 셋 다 "값 하나가 결과를 가른다"는 같은 계산 축 위에 있다.
01드래그한 위치를 뒤따라가는 패들
패들이 커서를 그대로 따라가지 않고, 목표 위치와의 차이 값을 매 프레임 조금씩 줄이며 뒤따라가 관성이 붙은 것처럼 보인다. 게임 인트로에서 조작감을 먼저 보여줄 때 이 지연이 손맛을 만든다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">드래그한 위치를 뒤따라가는 패들</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41001);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP_FACTOR = 0.15; // 변형: PADDLE_LERP_FACTOR 0.15 → 0.4
var PADDLE_LERP = PADDLE_LERP_FACTOR;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 50;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) {}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {
var dragGapPx = paddle.target - (paddle.x + paddle.w / 2);
ctx.strokeStyle = 'rgba(255,247,230,.65)';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(paddle.target, PADDLE_Y + PADDLE_H / 2, 6, 0, Math.PI * 2);
ctx.stroke();
if (Math.abs(dragGapPx) > 1) {
ctx.beginPath();
ctx.moveTo(paddle.x + paddle.w / 2, PADDLE_Y + PADDLE_H / 2);
ctx.lineTo(paddle.target, PADDLE_Y + PADDLE_H / 2);
ctx.stroke();
}
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerPaddleDragLerp.module.scss";
export default function BrickBreakerPaddleDragLerp() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41001);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP_FACTOR = 0.15;
const PADDLE_LERP = PADDLE_LERP_FACTOR;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 50;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void {}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {
const dragGapPx = paddle.target - (paddle.x + paddle.w / 2);
ctx!.strokeStyle = "rgba(255,247,230,.65)";
ctx!.lineWidth = 1.5;
ctx!.beginPath();
ctx!.arc(paddle.target, PADDLE_Y + PADDLE_H / 2, 6, 0, Math.PI * 2);
ctx!.stroke();
if (Math.abs(dragGapPx) > 1) {
ctx!.beginPath();
ctx!.moveTo(paddle.x + paddle.w / 2, PADDLE_Y + PADDLE_H / 2);
ctx!.lineTo(paddle.target, PADDLE_Y + PADDLE_H / 2);
ctx!.stroke();
}
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>드래그한 위치를 뒤따라가는 패들</p>
</div>
</main>
);
}01 · 드래그한 위치를 뒤따라가는 패들 — 측정 누적면적 30.092% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
측정: 누적면적 30.1% · 강도 평균 80.4 · 23/23 프레임에서 움직였다.
02맞은 위치로 반사각이 갈라지는 공
공이 패들 중앙에 맞을 때와 끝에 맞을 때 반사각 값이 달라져, 같은 속도로 들어와도 맞은 위치에 따라 매번 다른 방향으로 튕겨 나간다. 이 편 전체가 공유하는 핵심 규칙이라 코어 플레이 화면에 어울린다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">맞은 위치로 반사각이 갈라지는 공</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41002);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.4;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 62;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var lastHitPos = 0, lastAngle = 0, guideTimer = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
lastHitPos = hitPos; lastAngle = angle; guideTimer = 0.4;
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) { if (guideTimer > 0) guideTimer -= dt; }
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {
if (guideTimer > 0) {
var a = guideTimer / 0.4;
var hx = paddle.x + paddle.w / 2 + lastHitPos * (paddle.w / 2);
var hy = PADDLE_Y;
var len = 46;
ctx.strokeStyle = 'rgba(255,209,63,' + (0.15 + a * 0.65) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(hx, hy);
ctx.lineTo(hx + Math.sin(lastAngle) * len, hy - Math.abs(Math.cos(lastAngle)) * len);
ctx.stroke();
}
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerBallReflectHitAngle.module.scss";
export default function BrickBreakerBallReflectHitAngle() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41002);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.4;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 62;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
let lastHitPos = 0, lastAngle = 0, guideTimer = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
lastHitPos = hitPos; lastAngle = angle; guideTimer = 0.4;
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void { if (guideTimer > 0) guideTimer -= dt; }
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {
if (guideTimer > 0) {
const a = guideTimer / 0.4;
const hx = paddle.x + paddle.w / 2 + lastHitPos * (paddle.w / 2);
const hy = PADDLE_Y;
const len = 46;
ctx!.strokeStyle = "rgba(255,209,63," + (0.15 + a * 0.65) + ")";
ctx!.lineWidth = 2;
ctx!.beginPath();
ctx!.moveTo(hx, hy);
ctx!.lineTo(hx + Math.sin(lastAngle) * len, hy - Math.abs(Math.cos(lastAngle)) * len);
ctx!.stroke();
}
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>맞은 위치로 반사각이 갈라지는 공</p>
</div>
</main>
);
}02 · 맞은 위치로 반사각이 갈라지는 공 — 측정 누적면적 35.241% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
lastHitPos = hitPos; lastAngle = angle; guideTimer = 0.4;
}
측정: 누적면적 35.2% · 강도 평균 103.6 · 23/23 프레임에서 움직였다.
03충돌 판정마다 점수가 오르는 벽돌 줄
공의 사각 범위와 벽돌의 사각 범위가 겹치는 순간을 aabb로 판정해 그 벽돌만 사라지고, 점수는 지금까지 파괴한 벽돌 수(cleared)에 벽돌당 점수(SCORE_PER_BRICK)를 다시 곱해 계산된다. 맞을 때마다 그냥 더하는 게 아니라 누적 파괴 수를 기준으로 매번 새로 구하는 값이다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">충돌 판정마다 점수가 오르는 벽돌 줄</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41003);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var SCORE_PER_BRICK = 10;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var score = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
score = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1; score = cleared * SCORE_PER_BRICK;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) {}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {
ctx.fillStyle = '#fff7e6';
ctx.font = '700 14px monospace';
ctx.textAlign = 'right';
ctx.fillText(String(score), LW - WALL - 2, WALL + 14);
ctx.textAlign = 'left';
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerCollisionScore.module.scss";
export default function BrickBreakerCollisionScore() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41003);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const SCORE_PER_BRICK = 10;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
let score = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
score = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1; score = cleared * SCORE_PER_BRICK;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void {}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {
ctx!.fillStyle = "#fff7e6";
ctx!.font = "700 14px monospace";
ctx!.textAlign = "right";
ctx!.fillText(String(score), LW - WALL - 2, WALL + 14);
ctx!.textAlign = "left";
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>충돌 판정마다 점수가 오르는 벽돌 줄</p>
</div>
</main>
);
}03 · 충돌 판정마다 점수가 오르는 벽돌 줄 — 측정 누적면적 30.576% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
측정: 누적면적 30.6% · 강도 평균 86.5 · 23/23 프레임에서 움직였다.
04여러 번 맞아야 부서지는 금 가는 벽돌
벽돌이 한 번에 사라지지 않고 맞은 횟수 값이 쌓일 때마다 금이 하나씩 더 그어지다가, 그 값이 한계(BRICK_HIT_LIMIT)에 닿으면 완전히 부서진다. 강한 장애물을 표현하고 싶을 때 쓴다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">여러 번 맞아야 부서지는 금 가는 벽돌</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41004);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var BRICK_HIT_LIMIT = 3; // 변형: BRICK_HIT_LIMIT 3 → 5
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.hits += 1;
if (b.hits >= BRICK_HIT_LIMIT) {
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) {}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function crackLines(b) {
ctx.strokeStyle = 'rgba(23,20,26,.55)';
ctx.lineWidth = 1;
for (var i = 0; i < b.hits; i += 1) {
var cx = b.x + b.w * (0.25 + i * 0.22);
ctx.beginPath();
ctx.moveTo(cx, b.y + 1);
ctx.lineTo(cx + 3, b.y + b.h / 2);
ctx.lineTo(cx - 2, b.y + b.h - 1);
ctx.stroke();
}
}
function drawBrick(b) {
var rowColor = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
var damage = b.hits / BRICK_HIT_LIMIT;
ctx.fillStyle = rowColor;
ctx.globalAlpha = 1 - damage * 0.45;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.globalAlpha = 1;
crackLines(b);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerCrackMultihit.module.scss";
export default function BrickBreakerCrackMultihit() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41004);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const BRICK_HIT_LIMIT = 3;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.hits += 1;
if (b.hits >= BRICK_HIT_LIMIT) {
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void {}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function crackLines(b: Brick): void {
ctx!.strokeStyle = "rgba(23,20,26,.55)";
ctx!.lineWidth = 1;
for (let i = 0; i < b.hits; i += 1) {
const cx = b.x + b.w * (0.25 + i * 0.22);
ctx!.beginPath();
ctx!.moveTo(cx, b.y + 1);
ctx!.lineTo(cx + 3, b.y + b.h / 2);
ctx!.lineTo(cx - 2, b.y + b.h - 1);
ctx!.stroke();
}
}
function drawBrick(b: Brick): void {
const rowColor = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
const damage = b.hits / BRICK_HIT_LIMIT;
ctx!.fillStyle = rowColor;
ctx!.globalAlpha = 1 - damage * 0.45;
ctx!.fillRect(b.x, b.y, b.w, b.h);
ctx!.globalAlpha = 1;
crackLines(b);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>여러 번 맞아야 부서지는 금 가는 벽돌</p>
</div>
</main>
);
}04 · 여러 번 맞아야 부서지는 금 가는 벽돌 — 측정 누적면적 30.507% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function crackLines(b) {
ctx.strokeStyle = 'rgba(23,20,26,.55)';
ctx.lineWidth = 1;
for (var i = 0; i < b.hits; i += 1) {
var cx = b.x + b.w * (0.25 + i * 0.22);
ctx.beginPath();
ctx.moveTo(cx, b.y + 1);
ctx.lineTo(cx + 3, b.y + b.h / 2);
ctx.lineTo(cx - 2, b.y + b.h - 1);
ctx.stroke();
}
}
측정: 누적면적 30.5% · 강도 평균 82.2 · 23/23 프레임에서 움직였다.
05중력 값대로 떨어지는 아이템을 받는 패들
벽돌이 사라진 자리에서 아이템이 나타나는데, 파괴 순번(dropSeq)이 짝수일 때만 등장해 벽돌 두 개 중 하나꼴로 떨어진다 — 매번 나오면 화면이 아이템으로 뒤덮이기 때문이다. 중력 가속 값만큼 점점 빠르게 떨어지다 패들 사각 범위와 겹치는 순간 효과가 적용된다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">중력 값대로 떨어지는 아이템을 받는 패들</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41005);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var GRAVITY_ACCEL = 260;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var powerups = []; var catchFlash = 0; var dropSeq = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
powerups = [];
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
dropSeq += 1;
if (dropSeq % 2 === 0) {
powerups.push({ x: b.x + b.w / 2, y: b.y + b.h / 2, vy: 0 });
}
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) {
if (catchFlash > 0) catchFlash -= dt;
for (var i = powerups.length - 1; i >= 0; i -= 1) {
var p = powerups[i];
p.vy += GRAVITY_ACCEL * dt;
p.y += p.vy * dt;
if (aabb(p.x - 4, p.y - 4, 8, 8, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
catchFlash = 0.35;
powerups.splice(i, 1);
} else if (p.y - 4 > LH) {
powerups.splice(i, 1);
}
}
}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {
ctx.fillStyle = '#ffd23f';
for (var i = 0; i < powerups.length; i += 1) {
var p = powerups[i];
ctx.beginPath();
ctx.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx.fill();
}
}
function drawExtraAbove() {
if (catchFlash > 0) {
ctx.strokeStyle = 'rgba(255,210,63,' + (catchFlash / 0.35) + ')';
ctx.lineWidth = 2;
ctx.strokeRect(paddle.x - 2, PADDLE_Y - 2, paddle.w + 4, PADDLE_H + 4);
}
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerPowerupGravityCatch.module.scss";
export default function BrickBreakerPowerupGravityCatch() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41005);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const GRAVITY_ACCEL = 260;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
type Powerup = { x: number; y: number; vy: number };
let powerups: Powerup[] = [];
let catchFlash = 0;
let dropSeq = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
powerups = [];
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
dropSeq += 1;
if (dropSeq % 2 === 0) {
powerups.push({ x: b.x + b.w / 2, y: b.y + b.h / 2, vy: 0 });
}
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void {
if (catchFlash > 0) catchFlash -= dt;
for (let i = powerups.length - 1; i >= 0; i -= 1) {
const p = powerups[i];
p.vy += GRAVITY_ACCEL * dt;
p.y += p.vy * dt;
if (aabb(p.x - 4, p.y - 4, 8, 8, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
catchFlash = 0.35;
powerups.splice(i, 1);
} else if (p.y - 4 > LH) {
powerups.splice(i, 1);
}
}
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {
ctx!.fillStyle = "#ffd23f";
for (let i = 0; i < powerups.length; i += 1) {
const p = powerups[i];
ctx!.beginPath();
ctx!.arc(p.x, p.y, 4, 0, Math.PI * 2);
ctx!.fill();
}
}
function drawExtraAbove(): void {
if (catchFlash > 0) {
ctx!.strokeStyle = "rgba(255,210,63," + (catchFlash / 0.35) + ")";
ctx!.lineWidth = 2;
ctx!.strokeRect(paddle.x - 2, PADDLE_Y - 2, paddle.w + 4, PADDLE_H + 4);
}
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>중력 값대로 떨어지는 아이템을 받는 패들</p>
</div>
</main>
);
}05 · 중력 값대로 떨어지는 아이템을 받는 패들 — 측정 누적면적 28.728% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepExtra(dt) {
if (catchFlash > 0) catchFlash -= dt;
for (var i = powerups.length - 1; i >= 0; i -= 1) {
var p = powerups[i];
p.vy += GRAVITY_ACCEL * dt;
p.y += p.vy * dt;
if (aabb(p.x - 4, p.y - 4, 8, 8, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
catchFlash = 0.35;
powerups.splice(i, 1);
} else if (p.y - 4 > LH) {
powerups.splice(i, 1);
}
}
}
측정: 누적면적 28.7% · 강도 평균 82.1 · 23/23 프레임에서 움직였다.
06속도 값만큼 길어지는 공의 잔상
공의 속도 값이 커질수록 뒤에 남는 잔상 개수와 길이가 함께 늘어나, 느릴 때는 잔상이 거의 없다가 빨라지면 꼬리처럼 길어진다. 빠른 구간에서 속도감을 강조하고 싶을 때 쓴다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">속도 값만큼 길어지는 공의 잔상</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41006);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var SPEED_STEP = 14;
var SPEED_MAX = 210;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var trail = []; var TRAIL_MAX = 16;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
trail = [];
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
var sp = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + SPEED_STEP, SPEED_MAX);
var ang2 = Math.atan2(ball.vx, -ball.vy);
ball.vx = Math.sin(ang2) * sp;
ball.vy = -Math.abs(Math.cos(ang2)) * sp;
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
trail.length = 0;
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function pushTrail() { trail.push({ x: ball.x, y: ball.y }); }
function stepExtra(dt) {
pushTrail();
var speedNow = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
var count = Math.round(clamp((speedNow - BASE_SPEED) / (SPEED_MAX - BASE_SPEED), 0, 1) * (TRAIL_MAX - 3)) + 3;
while (trail.length > count) trail.shift();
}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {
for (var i = 0; i < trail.length; i += 1) {
var t = trail[i];
var a = (i + 1) / trail.length;
ctx.fillStyle = 'rgba(110,200,255,' + (a * 0.5) + ')';
ctx.beginPath();
ctx.arc(t.x, t.y, BALL_R * (0.4 + a * 0.6), 0, Math.PI * 2);
ctx.fill();
}
}
function drawExtraAbove() {}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerTrailSpeedAfterimage.module.scss";
export default function BrickBreakerTrailSpeedAfterimage() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41006);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const SPEED_STEP = 14;
const SPEED_MAX = 210;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
type Pt = { x: number; y: number };
let trail: Pt[] = [];
const TRAIL_MAX = 16;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
trail = [];
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
const sp = Math.min(Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy) + SPEED_STEP, SPEED_MAX);
const ang2 = Math.atan2(ball.vx, -ball.vy);
ball.vx = Math.sin(ang2) * sp;
ball.vy = -Math.abs(Math.cos(ang2)) * sp;
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
trail.length = 0;
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function pushTrail(): void { trail.push({ x: ball.x, y: ball.y }); }
function stepExtra(dt: number): void {
pushTrail();
const speedNow = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
const count = Math.round(clamp((speedNow - BASE_SPEED) / (SPEED_MAX - BASE_SPEED), 0, 1) * (TRAIL_MAX - 3)) + 3;
while (trail.length > count) trail.shift();
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {
for (let i = 0; i < trail.length; i += 1) {
const t = trail[i];
const a = (i + 1) / trail.length;
ctx!.fillStyle = "rgba(110,200,255," + (a * 0.5) + ")";
ctx!.beginPath();
ctx!.arc(t.x, t.y, BALL_R * (0.4 + a * 0.6), 0, Math.PI * 2);
ctx!.fill();
}
}
function drawExtraAbove(): void {}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>속도 값만큼 길어지는 공의 잔상</p>
</div>
</main>
);
}06 · 속도 값만큼 길어지는 공의 잔상 — 측정 누적면적 31.421% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function pushTrail() { trail.push({ x: ball.x, y: ball.y }); }
function stepExtra(dt) {
pushTrail();
var speedNow = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
var count = Math.round(clamp((speedNow - BASE_SPEED) / (SPEED_MAX - BASE_SPEED), 0, 1) * (TRAIL_MAX - 3)) + 3;
while (trail.length > count) trail.shift();
}
측정: 누적면적 31.4% · 강도 평균 82.5 · 23/23 프레임에서 움직였다.
07연속 타격마다 배수가 오르는 콤보 값
벽돌을 끊기지 않고 연속으로 맞히는 동안 comboCount 값이 한 단계씩 오르고, 한 번이라도 놓치면 그 값이 처음으로 되돌아간다. 점수 배율 UI를 강조하는 화면에 어울린다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">연속 타격마다 배수가 오르는 콤보 값</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41007);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var COMBO_MULT_STEP = 0.5; // 변형: COMBO_MULT_STEP 0.5 → 1
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var comboCount = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1; comboCount += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetCombo();
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function resetCombo() { comboCount = 0; }
function stepExtra(dt) {}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {
var mult = 1 + comboCount * COMBO_MULT_STEP;
ctx.fillStyle = '#fff7e6';
ctx.font = '700 14px monospace';
ctx.textAlign = 'right';
ctx.fillText('\u00d7' + mult.toFixed(1), LW - WALL - 2, WALL + 14);
ctx.textAlign = 'left';
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerComboMultiplierStreak.module.scss";
export default function BrickBreakerComboMultiplierStreak() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41007);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const COMBO_MULT_STEP = 0.5;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
let comboCount = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1; comboCount += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetCombo();
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function resetCombo(): void { comboCount = 0; }
function stepExtra(dt: number): void {}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {
const mult = 1 + comboCount * COMBO_MULT_STEP;
ctx!.fillStyle = "#fff7e6";
ctx!.font = "700 14px monospace";
ctx!.textAlign = "right";
ctx!.fillText("\u00d7" + mult.toFixed(1), LW - WALL - 2, WALL + 14);
ctx!.textAlign = "left";
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>연속 타격마다 배수가 오르는 콤보 값</p>
</div>
</main>
);
}07 · 연속 타격마다 배수가 오르는 콤보 값 — 측정 누적면적 30.928% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1; comboCount += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetCombo();
resetBall();
}
측정: 누적면적 30.9% · 강도 평균 87.1 · 23/23 프레임에서 움직였다.
08벽에 닿으면 각도가 뒤집히는 튕김
공이 좌우 벽에 닿는 순간 가로 방향 각도 값의 부호만 반전되고 세로 방향 값은 그대로 남아, 들어간 각도 그대로 튕겨 나온다. 경계 충돌 물리를 보여주는 자리다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">벽에 닿으면 각도가 뒤집히는 튕김</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}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);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41008);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var WALL_MARGIN = WALL;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var mirrorFlashSide = null; var mirrorFlashTimer = 0;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); mirrorFlashSide = side; mirrorFlashTimer = 0.3; }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt) { if (mirrorFlashTimer > 0) mirrorFlashTimer -= dt; }
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {}
function drawExtraAbove() {
if (mirrorFlashTimer > 0) {
var x = mirrorFlashSide === 'left' ? WALL_MARGIN : LW - WALL_MARGIN;
ctx.strokeStyle = 'rgba(255,209,63,' + (mirrorFlashTimer / 0.3) + ')';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, WALL);
ctx.lineTo(x, LH - WALL);
ctx.stroke();
}
}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerWallBounceMirror.module.scss";
export default function BrickBreakerWallBounceMirror() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41008);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const WALL_MARGIN = WALL;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
let mirrorFlashSide: string | null = null;
let mirrorFlashTimer = 0;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); mirrorFlashSide = side; mirrorFlashTimer = 0.3; }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); rebuildLevel(); }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function stepExtra(dt: number): void { if (mirrorFlashTimer > 0) mirrorFlashTimer -= dt; }
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {}
function drawExtraAbove(): void {
if (mirrorFlashTimer > 0) {
const x = mirrorFlashSide === "left" ? WALL_MARGIN : LW - WALL_MARGIN;
ctx!.strokeStyle = "rgba(255,209,63," + (mirrorFlashTimer / 0.3) + ")";
ctx!.lineWidth = 2;
ctx!.beginPath();
ctx!.moveTo(x, WALL);
ctx!.lineTo(x, LH - WALL);
ctx!.stroke();
}
}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>벽에 닿으면 각도가 뒤집히는 튕김</p>
</div>
</main>
);
}08 · 벽에 닿으면 각도가 뒤집히는 튕김 — 측정 누적면적 30.296% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function onWallHit(side) { spawnFlash(ball.x, ball.y); mirrorFlashSide = side; mirrorFlashTimer = 0.3; }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
측정: 누적면적 30.3% · 강도 평균 93.6 · 23/23 프레임에서 움직였다.
09벽돌이 모두 사라지면 터지는 입자
남은 벽돌 개수 값이 0에 닿으면 코트 중앙 한 점에서 입자 36개가 사방으로 퍼져 나가며 다음 레벨로 넘어가는 것을 알린다. 화면 가장자리가 아니라 중앙에서 발산하는 방식이라 시선이 한 곳으로 모인다.

<main class="stage">
<div class="vd is-demo" id="vdRoot">
<canvas class="vd__canvas" id="vdCanvas" width="480" height="300"></canvas>
<p class="vd__caption">벽돌이 모두 사라지면 터지는 입자</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.vd {
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;
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 72%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.vd { cursor: default; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.vd {
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);
}
.vd__canvas {
display: block;
width: 100%;
height: 100%;
}
.vd__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 72%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.vd__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vd {
cursor: default;
}
}(function () {
var root = document.getElementById('vdRoot');
var canvas = document.getElementById('vdCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var LW = 320, LH = 200, WALL = 6;
var PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
var BALL_R = 4;
var BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
var BASE_SPEED = 130;
var BIAS_PERIOD = 560;
var STAGE_BG = '#17141a';
var BRICK_ROW_COLORS = ['#2f6df6', '#ff9ebb', '#4cd4a6'];
var PADDLE_COLOR = '#fff7e6';
var BALL_COLOR = '#fff7e6';
var FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(41009);
function clamp(v, a, b) { return v < a ? a : (v > b ? b : v); }
function aabb(ax, ay, aw, ah, bx, by, bw, bh) { return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by; }
var PADDLE_LERP = 0.32;
var MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
var PARTICLE_COUNT = 36;
var PARTICLE_GRAVITY = 180;
function buildBricks() {
var totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
var startX = (LW - totalW) / 2;
var arr = [];
for (var r = 0; r < BRICK_ROWS; r += 1) {
for (var c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
var bricks = buildBricks();
var cleared = 0;
var particles = []; var burstPending = false;
function rebuildLevel() {
bricks = buildBricks();
cleared = 0;
}
var flashes = [];
function spawnFlash(x, y) { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt) {
for (var i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes() {
for (var i = 0; i < flashes.length; i += 1) {
var f = flashes[i];
var p = f.t / FLASH_LIFE;
var r = 14 + FLASH_MAX_R * p;
var a = (1 - p) * 0.42;
var g = ctx.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, 'rgba(255,247,230,' + a + ')');
g.addColorStop(0.6, 'rgba(255,209,63,' + (a * 0.6) + ')');
g.addColorStop(1, 'rgba(255,209,63,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx.fill();
}
}
var paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
var ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall() {
var ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
var pointerActive = false;
var keys = { left: false, right: false };
var biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs) {
if (pointerActive || reduce) return;
var slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
var bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt) {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
var goal = paddle.target - paddle.w / 2;
var k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side) { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side) {
ball.vx = side === 'left' ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit() {
var hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
var angle = hitPos * MAX_REFLECT_ANGLE;
var speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear() { spawnFlash(LW / 2, LH / 2); spawnBurst();
burstPending = true; }
function onBrickHit(b) {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss() {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt) {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce('left'); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce('right'); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (var i = 0; i < bricks.length; i += 1) {
var b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function spawnBurst() {
for (var i = 0; i < PARTICLE_COUNT; i += 1) {
var ang = (i / PARTICLE_COUNT) * Math.PI * 2 + rand() * 0.3;
var sp = 60 + rand() * 90;
particles.push({ x: LW / 2, y: LH / 2 - 20, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1 });
}
}
function stepExtra(dt) {
for (var i = particles.length - 1; i >= 0; i -= 1) {
var p = particles[i];
p.vy += PARTICLE_GRAVITY * dt;
p.vx *= (1 - 0.6 * dt);
p.x += p.vx * dt; p.y += p.vy * dt;
p.life -= dt * 0.7;
if (p.life <= 0) particles.splice(i, 1);
}
if (burstPending && particles.length === 0) {
burstPending = false;
rebuildLevel();
}
}
function update(dt, tMs) {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt() {
ctx.fillStyle = STAGE_BG;
ctx.fillRect(0, 0, LW, LH);
}
function drawBrick(b) {
ctx.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks() {
for (var i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle() {
ctx.fillStyle = PADDLE_COLOR;
var r = 3;
ctx.beginPath();
ctx.moveTo(paddle.x + r, PADDLE_Y);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx.closePath();
ctx.fill();
}
function drawBall() {
ctx.fillStyle = BALL_COLOR;
ctx.beginPath();
ctx.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx.fill();
}
function drawExtraBelow() {
for (var i = 0; i < particles.length; i += 1) {
var p = particles[i];
ctx.fillStyle = 'rgba(255,209,63,' + Math.max(p.life, 0) + ')';
ctx.beginPath();
ctx.arc(p.x, p.y, 2.4, 0, Math.PI * 2);
ctx.fill();
}
}
function drawExtraAbove() {}
var cw = 480, ch = 300, sx = 1, sy = 1;
function fit() {
var r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas.width = cw; canvas.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render() {
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.save();
ctx.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx.restore();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = !reduce;
if (reduce) { root.classList.remove('is-demo'); }
function calcFromEvent(e) {
var r = wrap.getBoundingClientRect();
var t0 = (e.touches && e.touches[0]) ? e.touches[0] : e;
var relX = (t0.clientX - r.left) / r.width * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
wrap.addEventListener('pointerdown', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
wrap.addEventListener('pointermove', function (e) { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } });
window.addEventListener('pointerup', function () { pointerActive = false; });
wrap.addEventListener('pointerleave', function () { pointerActive = false; });
window.addEventListener('keydown', function (e) {
if (e.key === 'ArrowLeft') { keys.left = true; pointerActive = true; }
else if (e.key === 'ArrowRight') { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
});
window.addEventListener('keyup', function (e) {
if (e.key === 'ArrowLeft') keys.left = false;
else if (e.key === 'ArrowRight') keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
});
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
render();
if (playing) requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BrickBreakerClearParticleBurst.module.scss";
export default function BrickBreakerClearParticleBurst() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const wrap = canvas.parentElement as HTMLElement;
const LW = 320, LH = 200, WALL = 6;
const PADDLE_H = 8, PADDLE_Y = LH - 18, PADDLE_W = 46;
const BALL_R = 4;
const BRICK_COLS = 6, BRICK_ROWS = 3, BRICK_W = 44, BRICK_H = 12, BRICK_GAP = 4, BRICK_TOP = 22;
const BASE_SPEED = 130;
const BIAS_PERIOD = 560;
const STAGE_BG = "#17141a";
const BRICK_ROW_COLORS = ["#2f6df6", "#ff9ebb", "#4cd4a6"];
const PADDLE_COLOR = "#fff7e6";
const BALL_COLOR = "#fff7e6";
const FLASH_LIFE = 0.4, FLASH_MAX_R = 130;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(41009);
function clamp(v: number, a: number, b: number): number { return v < a ? a : (v > b ? b : v); }
function aabb(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean {
return ax < bx + bw && ax + aw > bx && ay < by + bh && ay + ah > by;
}
const PADDLE_LERP = 0.32;
const MAX_REFLECT_ANGLE = Math.PI / 180 * 48;
const PARTICLE_COUNT = 36;
const PARTICLE_GRAVITY = 180;
type Brick = { x: number; y: number; w: number; h: number; alive: boolean; hits: number };
function buildBricks(): Brick[] {
const totalW = BRICK_COLS * BRICK_W + (BRICK_COLS - 1) * BRICK_GAP;
const startX = (LW - totalW) / 2;
const arr: Brick[] = [];
for (let r = 0; r < BRICK_ROWS; r += 1) {
for (let c = 0; c < BRICK_COLS; c += 1) {
arr.push({ x: startX + c * (BRICK_W + BRICK_GAP), y: BRICK_TOP + r * (BRICK_H + BRICK_GAP), w: BRICK_W, h: BRICK_H, alive: true, hits: 0 });
}
}
return arr;
}
let bricks: Brick[] = buildBricks();
let cleared = 0;
type Particle = { x: number; y: number; vx: number; vy: number; life: number };
let particles: Particle[] = [];
let burstPending = false;
function rebuildLevel(): void {
bricks = buildBricks();
cleared = 0;
}
type Flash = { x: number; y: number; t: number };
const flashes: Flash[] = [];
function spawnFlash(x: number, y: number): void { flashes.push({ x: x, y: y, t: 0.001 }); }
function stepFlashes(dt: number): void {
for (let i = flashes.length - 1; i >= 0; i -= 1) {
flashes[i].t += dt;
if (flashes[i].t > FLASH_LIFE) flashes.splice(i, 1);
}
}
function drawFlashes(): void {
for (let i = 0; i < flashes.length; i += 1) {
const f = flashes[i];
const p = f.t / FLASH_LIFE;
const r = 14 + FLASH_MAX_R * p;
const a = (1 - p) * 0.42;
const g = ctx!.createRadialGradient(f.x, f.y, 0, f.x, f.y, r);
g.addColorStop(0, "rgba(255,247,230," + a + ")");
g.addColorStop(0.6, "rgba(255,209,63," + (a * 0.6) + ")");
g.addColorStop(1, "rgba(255,209,63,0)");
ctx!.fillStyle = g;
ctx!.beginPath();
ctx!.arc(f.x, f.y, r, 0, Math.PI * 2);
ctx!.fill();
}
}
const paddle = { x: LW / 2 - PADDLE_W / 2, w: PADDLE_W, target: LW / 2 };
const ball = { x: LW / 2, y: PADDLE_Y - 40, vx: 0, vy: 0 };
function resetBall(): void {
const ang = rand() * 0.5 - 0.25;
ball.x = LW / 2; ball.y = PADDLE_Y - 40;
ball.vx = Math.sin(ang) * BASE_SPEED;
ball.vy = -Math.cos(ang) * BASE_SPEED;
}
resetBall();
let pointerActive = false;
const keys = { left: false, right: false };
const biasSchedule = [0, 0.75, -0.75, 0.35, -0.35, 0.6];
function autoplay(tMs: number): void {
if (pointerActive || reduce) return;
const slot = Math.floor(tMs / BIAS_PERIOD) % biasSchedule.length;
const bias = biasSchedule[slot] * (paddle.w / 2);
paddle.target = ball.x + bias;
}
function paddleStep(dt: number): void {
if (keys.left) paddle.target -= 160 * dt;
if (keys.right) paddle.target += 160 * dt;
paddle.target = clamp(paddle.target, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
const goal = paddle.target - paddle.w / 2;
const k = 1 - Math.pow(1 - PADDLE_LERP, dt * 60);
paddle.x += (goal - paddle.x) * k;
paddle.x = clamp(paddle.x, WALL, LW - WALL - paddle.w);
}
function onWallHit(side: string): void { spawnFlash(ball.x, ball.y); }
function mirrorWallBounce(side: string): void {
ball.vx = side === "left" ? Math.abs(ball.vx) : -Math.abs(ball.vx);
onWallHit(side);
}
function onPaddleHit(): void {
const hitPos = clamp((ball.x - (paddle.x + paddle.w / 2)) / (paddle.w / 2), -1, 1);
const angle = hitPos * MAX_REFLECT_ANGLE;
const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
ball.vx = Math.sin(angle) * speed;
ball.vy = -Math.abs(Math.cos(angle)) * speed;
ball.y = PADDLE_Y - BALL_R - 0.5;
spawnFlash(ball.x, PADDLE_Y);
}
function onLevelClear(): void { spawnFlash(LW / 2, LH / 2); spawnBurst();
burstPending = true; }
function onBrickHit(b: Brick): void {
spawnFlash(b.x + b.w / 2, b.y + b.h / 2);
b.alive = false; cleared += 1;
if (cleared >= bricks.length) { onLevelClear(); }
}
function onMiss(): void {
spawnFlash(ball.x, LH - 4);
resetBall();
}
function ballStep(dt: number): void {
ball.x += ball.vx * dt;
ball.y += ball.vy * dt;
if (ball.x - BALL_R <= WALL) { ball.x = WALL + BALL_R; mirrorWallBounce("left"); }
else if (ball.x + BALL_R >= LW - WALL) { ball.x = LW - WALL - BALL_R; mirrorWallBounce("right"); }
if (ball.y - BALL_R <= WALL) { ball.y = WALL + BALL_R; ball.vy = Math.abs(ball.vy); }
if (ball.vy > 0 && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, paddle.x, PADDLE_Y, paddle.w, PADDLE_H)) {
onPaddleHit();
}
for (let i = 0; i < bricks.length; i += 1) {
const b = bricks[i];
if (b.alive && aabb(ball.x - BALL_R, ball.y - BALL_R, BALL_R * 2, BALL_R * 2, b.x, b.y, b.w, b.h)) {
ball.vy = -ball.vy;
onBrickHit(b);
break;
}
}
if (ball.y - BALL_R > LH) onMiss();
}
function spawnBurst(): void {
for (let i = 0; i < PARTICLE_COUNT; i += 1) {
const ang = (i / PARTICLE_COUNT) * Math.PI * 2 + rand() * 0.3;
const sp = 60 + rand() * 90;
particles.push({ x: LW / 2, y: LH / 2 - 20, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1 });
}
}
function stepExtra(dt: number): void {
for (let i = particles.length - 1; i >= 0; i -= 1) {
const p = particles[i];
p.vy += PARTICLE_GRAVITY * dt;
p.vx *= (1 - 0.6 * dt);
p.x += p.vx * dt; p.y += p.vy * dt;
p.life -= dt * 0.7;
if (p.life <= 0) particles.splice(i, 1);
}
if (burstPending && particles.length === 0) {
burstPending = false;
rebuildLevel();
}
}
function update(dt: number, tMs: number): void {
autoplay(tMs);
paddleStep(dt);
ballStep(dt);
stepFlashes(dt);
stepExtra(dt);
}
function drawCourt(): void {
ctx!.fillStyle = STAGE_BG;
ctx!.fillRect(0, 0, LW, LH);
}
function drawBrick(b: Brick): void {
ctx!.fillStyle = BRICK_ROW_COLORS[Math.floor((b.y - BRICK_TOP) / (BRICK_H + BRICK_GAP)) % BRICK_ROW_COLORS.length];
ctx!.fillRect(b.x, b.y, b.w, b.h);
}
function drawBricks(): void {
for (let i = 0; i < bricks.length; i += 1) {
if (bricks[i].alive) drawBrick(bricks[i]);
}
}
function drawPaddle(): void {
ctx!.fillStyle = PADDLE_COLOR;
const r = 3;
ctx!.beginPath();
ctx!.moveTo(paddle.x + r, PADDLE_Y);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x + paddle.w, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y + PADDLE_H, r);
ctx!.arcTo(paddle.x, PADDLE_Y + PADDLE_H, paddle.x, PADDLE_Y, r);
ctx!.arcTo(paddle.x, PADDLE_Y, paddle.x + paddle.w, PADDLE_Y, r);
ctx!.closePath();
ctx!.fill();
}
function drawBall(): void {
ctx!.fillStyle = BALL_COLOR;
ctx!.beginPath();
ctx!.arc(ball.x, ball.y, BALL_R, 0, Math.PI * 2);
ctx!.fill();
}
function drawExtraBelow(): void {
for (let i = 0; i < particles.length; i += 1) {
const p = particles[i];
ctx!.fillStyle = "rgba(255,209,63," + Math.max(p.life, 0) + ")";
ctx!.beginPath();
ctx!.arc(p.x, p.y, 2.4, 0, Math.PI * 2);
ctx!.fill();
}
}
function drawExtraAbove(): void {}
let cw = 480, ch = 300, sx = 1, sy = 1;
function fit(): void {
const r = wrap.getBoundingClientRect();
cw = Math.max(1, Math.round(r.width));
ch = Math.max(1, Math.round(r.height));
canvas!.width = cw; canvas!.height = ch;
sx = cw / LW; sy = ch / LH;
render();
}
function render(): void {
ctx!.setTransform(1, 0, 0, 1, 0, 0);
ctx!.clearRect(0, 0, cw, ch);
ctx!.save();
ctx!.scale(sx, sy);
drawCourt();
drawFlashes();
drawBricks();
drawExtraBelow();
drawPaddle();
drawBall();
drawExtraAbove();
ctx!.restore();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const playing = !reduce;
if (reduce) { root.classList.remove("is-demo"); }
function calcFromEvent(e: PointerEvent): void {
const r = wrap.getBoundingClientRect();
const relX = ((e.clientX - r.left) / r.width) * LW;
paddle.target = clamp(relX, WALL + paddle.w / 2, LW - WALL - paddle.w / 2);
}
function onPointerDown(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerMove(e: PointerEvent): void { pointerActive = true; calcFromEvent(e); if (reduce) { paddleStep(0); render(); } }
function onPointerUp(): void { pointerActive = false; }
function onPointerLeave(): void { pointerActive = false; }
wrap.addEventListener("pointerdown", onPointerDown);
wrap.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
wrap.addEventListener("pointerleave", onPointerLeave);
function onKeyDown(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") { keys.left = true; pointerActive = true; }
else if (e.key === "ArrowRight") { keys.right = true; pointerActive = true; }
else return;
if (reduce) { paddleStep(0.05); render(); }
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key === "ArrowLeft") keys.left = false;
else if (e.key === "ArrowRight") keys.right = false;
if (!keys.left && !keys.right) pointerActive = false;
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("keyup", onKeyUp);
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number): void {
if (lastTs === null) lastTs = ts;
const dt = Math.min((ts - lastTs) / 1000, 0.05);
lastTs = ts;
if (playing) update(dt, ts);
render();
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
render();
if (playing) raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("keyup", onKeyUp);
window.removeEventListener("pointerup", onPointerUp);
wrap.removeEventListener("pointerdown", onPointerDown);
wrap.removeEventListener("pointermove", onPointerMove);
wrap.removeEventListener("pointerleave", onPointerLeave);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.vd} is-demo`} ref={rootRef}>
<canvas className={styles.vd__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.vd__caption}>벽돌이 모두 사라지면 터지는 입자</p>
</div>
</main>
);
}09 · 벽돌이 모두 사라지면 터지는 입자 — 측정 누적면적 29.451% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function spawnBurst() {
for (var i = 0; i < PARTICLE_COUNT; i += 1) {
var ang = (i / PARTICLE_COUNT) * Math.PI * 2 + rand() * 0.3;
var sp = 60 + rand() * 90;
particles.push({ x: LW / 2, y: LH / 2 - 20, vx: Math.cos(ang) * sp, vy: Math.sin(ang) * sp, life: 1 });
}
}
function stepExtra(dt) {
for (var i = particles.length - 1; i >= 0; i -= 1) {
var p = particles[i];
p.vy += PARTICLE_GRAVITY * dt;
p.vx *= (1 - 0.6 * dt);
p.x += p.vx * dt; p.y += p.vy * dt;
p.life -= dt * 0.7;
if (p.life <= 0) particles.splice(i, 1);
}
if (burstPending && particles.length === 0) {
burstPending = false;
rebuildLevel();
}
}
측정: 누적면적 29.5% · 강도 평균 90.4 · 23/23 프레임에서 움직였다.
값이 반응을 바꾸는 곳 — 위치·중력·연속 횟수
02의 hitPos는 패들 중앙에서 벗어난 거리를 -1~1 값으로 재고 여기에 MAX_REFLECT_ANGLE(62도)을 곱해 반사 각도를 만든다 — 패들 가장자리에 맞을수록 hitPos가 1에 가까워져 공이 거의 수평으로 튕겨 나간다. 05의 GRAVITY_ACCEL(260)은 아이템의 낙하 속도(vy)에 매 프레임 더해지는 가속도 값이라, 떨어지는 시간이 길어질수록 속도가 일정하지 않고 점점 더 빠르게 늘어난다. 07의 comboCount는 COMBO_MULT_STEP(0.5)을 곱해 mult = 1 + comboCount * COMBO_MULT_STEP 공식으로 배수를 만들어 화면 오른쪽 위에 "×1.5"처럼 표시하는데, 이 배수는 화면 표시용일 뿐 실제 점수(score)는 03에서 본 것처럼 파괴한 벽돌 수(cleared)에만 곱해진다.
캐주얼 웹 게임 화면에서 아홉 조각이 쓰이는 자리
온보딩 미니게임이나 사이드 프로젝트로 벽돌깨기 만들기를 시도할 때, 01은 조작 인트로 화면에서 손맛을 먼저 보여주는 용도로 맞다. 03·04는 메인 플레이 화면의 기본 규칙이고, 05·07은 점수판·콤보 UI를 강조하고 싶은 상단 HUD 옆에 붙이면 값이 오르내리는 걸 눈으로 확인시켜 준다. 09의 중앙 발산 입자는 레벨 전환·결제 완료처럼 한 화면이 끝났다는 신호가 필요한 자리에 그대로 옮겨 써도 된다.
캡처 24장에서 뽑아낸 아홉 항목의 실측 수치
24장을 캡처해 그 사이 23개 구간 각각의 픽셀 변화를 잰 값이다. 누적면적은 23개 구간의 변화율을 더한 값이라 100%를 넘을 수 있다.
| NN 이름 | 누적면적 % | 강도 평균 | x/23 |
|---|---|---|---|
| 01 드래그한 위치를 뒤따라가는 패들 | 30.1 | 80.4 | 23/23 |
| 02 맞은 위치로 반사각이 갈라지는 공 | 35.2 | 103.6 | 23/23 |
| 03 충돌 판정마다 점수가 오르는 벽돌 줄 | 30.6 | 86.5 | 23/23 |
| 04 여러 번 맞아야 부서지는 금 가는 벽돌 | 30.5 | 82.2 | 23/23 |
| 05 중력 값대로 떨어지는 아이템을 받는 패들 | 28.7 | 82.1 | 23/23 |
| 06 속도 값만큼 길어지는 공의 잔상 | 31.4 | 82.5 | 23/23 |
| 07 연속 타격마다 배수가 오르는 콤보 값 | 30.9 | 87.1 | 23/23 |
| 08 벽에 닿으면 각도가 뒤집히는 튕김 | 30.3 | 93.6 | 23/23 |
| 09 벽돌이 모두 사라지면 터지는 입자 | 29.5 | 90.4 | 23/23 |
02가 반사각 계산과 화살표 안내선까지 함께 그리느라 강도 평균이 가장 높고, 05는 아이템이 없는 구간이 섞여 있어 누적면적이 가장 낮다.
상수 하나씩만 바꿔 보는 변형 세 가지
데모 우측 조작 패널의 슬라이더로 값을 직접 옮겨볼 수 있고, 실제로 바뀌는 줄은 항목 폴더 상단의 상수 선언 한 줄뿐이다.
| 이름 | 값 | 느낌 |
|---|---|---|
| 패들 추적 계수 값 조정 | 01 PADDLE_LERP_FACTOR 0.15 → 0.4 | 패들이 커서에 거의 붙어 지연 없이 반응한다 — 낮은 값에서 느껴지던 무거운 추격감이 사라진다. |
| 벽돌 내구 횟수 값 조정 | 04 BRICK_HIT_LIMIT 3 → 5 | 벽돌 하나가 버티는 시간이 늘어 금이 다섯 번 그어질 때까지 살아남는다 — 초반 벽이 훨씬 단단해진다. |
| 콤보 배수 증가 값 조정 | 07 COMBO_MULT_STEP 0.5 → 1 | 두 번만 연속으로 맞혀도 배수가 크게 뛰어올라 우측 상단 숫자가 초반부터 빠르게 불어난다. |
zip을 열면 각 항목 폴더의 index.html 맨 위쪽 상수 선언에서 이 값들을 바로 찾을 수 있고, 압축을 풀 때 묻는 비밀번호는 nnb29y4j 이며 이 글에서는 이 자리에서만 적는다.
css 게임 만들기 콘텐츠 301·332·357편과 무엇이 다른가?
CSS 게임 만들기 9종은 클릭 반응형 미니게임 모음이라 물리 계산보다 규칙 자체가 주인공이고, 룰렛 돌리기 9종은 회전 마찰 감쇠 값 하나로 아홉 장면을 만든다. 이 편은 그 둘과 달리 반사각·중력·연속 배수처럼 서로 다른 계산 세 갈래를 한 코트 안에 모아 놓았고, 버튼 눌림 효과 9종의 되돌아오는 값 하나짜리 구조보다 상태(벽돌 생존·콤보·아이템)가 여러 개 겹쳐 있다는 점이 다르다.
어디서 깨지는가 — 조작 반응이 사라지는 세 가지 조건
세 조건에서 이 아홉 개가 예상과 다르게 움직인다. 동작 줄임 설정에서는 드래그 중에도 매 프레임이 아니라 이벤트가 올 때만 패들이 갱신되니, 계속 움직이는 데모라고 생각하고 만지면 뚝뚝 끊기는 것처럼 보인다 — 의도한 동작이다. 프레임 시간이 느려지는 저사양 기기에서는 한 프레임의 delta 값을 최대 0.05초로 묶어(Math.min((ts - lastTs) / 1000, 0.05)) 공이 벽돌을 그대로 통과하는 터널링을 막는데, 이 clamp를 지우고 옮겨 쓰면 프레임이 크게 떨어지는 순간 공이 벽돌 사이를 그냥 지나간다. 터치 기기에서는 패들 조작 영역에 touch-action 규칙이 없어 pointermove로 처리하는 드래그와 페이지 스크롤이 동시에 반응해 서로 다툰다.
동작을 줄이면 무엇이 남고 무엇이 사라지는가 — 접근성
브라우저가 prefers-reduced-motion: reduce를 보고하면 아홉 데모 모두 공을 튀기던 자동 시연 재생을 멈춘다 — reduce 값이 참이면 root.classList.remove('is-demo')로 자동 재생 클래스를 떼어내기 때문이다. 그렇다고 조작까지 죽지는 않는다. 포인터를 누르거나 화살표 키(ArrowLeft·ArrowRight)를 한 번 누르면 그 순간만 패들을 한 프레임 계산해 그려 주는 단일 스텝 방식으로 바뀐다. 벽돌 배치와 아이템이 튀는 각도는 seedRand로 만든 결정론적 난수라 새로고침해도 매번 같은 순서로 재현된다. prefers-reduced-motion 문서가 설명하는 것처럼, 줄여야 하는 건 애니메이션이지 조작 가능성이 아니다.
이 벽돌깨기 코드를 처음 써보는 사람이 자주 묻는 것
벽돌 내구 횟수나 콤보 배수 값을 더 극단적으로 조절해도 되나요?
네, 04의 BRICK_HIT_LIMIT과 07의 COMBO_MULT_STEP은 둘 다 상수 한 줄이라 값만 바꾸면 바로 다른 난이도로 반응한다. 다만 값을 너무 키우면 강도 평균이 떨어져 화면이 밋밋해질 수 있어 변형 표의 범위 정도를 권장한다.
아홉 개 중 하나만 떼어서 다른 프로젝트에 붙여도 동작하나요?
된다. 각 항목은 seedRand·aabb·clamp 같은 공용 함수 몇 개만 공유할 뿐 상태 변수는 항목마다 독립돼 있어, zip 안 폴더 하나만 그대로 옮겨도 그 항목 혼자 동작한다.
벽돌깨기 만들기를 처음 해보는데 어떤 항목부터 보면 되나요?
02번부터 보는 걸 권한다. 패들에 맞은 위치 값이 반사각을 정하는 규칙이 나머지 여덟 개가 공유하는 기본 물리라, 이걸 먼저 읽으면 05·07·08의 응용이 쉽게 읽힌다.









