바운싱 볼 애니메이션 9가지 — 충돌·중력 물리 계산
01번 공은 반발계수 0.82로 서로 속도를 맞바꾸고, 04번 공은 같은 반발계수를 받고도 질량 차이 때문에 튀는 높이가 달라지며, 05번 공은 포인터가 가까워지면 그 계수 대신 반발력을 받아 흩어진다.
01 ball-physics-elastic-collision
02 ball-physics-gravity-drop-settle
03 ball-physics-wall-restitution-bounce
04 ball-physics-mass-bounce-height
05 ball-physics-mouse-repel-scatter
06 ball-physics-mouse-attract-orbit
07 ball-physics-drag-throw-momentum
08 ball-physics-spawn-cascade-stack
09 ball-physics-hero-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 속도를 서로 주고받는 탄성 충돌 공
- 02 떨어지다 바닥에서 잦아드는 중력 낙하 공
- 03 벽에 닿을 때마다 힘을 잃는 반발계수 벽 튕김 공
- 04 질량 차이로 튀는 높이가 갈라지는 공
- 05 포인터가 다가오면 흩어지는 반발 공
- 06 포인터 쪽으로 끌려가 도는 인력 공
- 07 드래그한 속도 그대로 날아가는 던지기 공
- 08 위에서 쏟아져 바닥에 쌓이는 낙하 공
- 09 여러 공 물리를 한 화면에 합친 히어로 장면
바운싱 볼 애니메이션 9가지 — 값 하나가 공의 움직임을 어떻게 바꾸는가
아홉 항목은 모두 FIXED_DT = 16 으로 고정한 시간 스텝 위에서 계산한다 — 프레임이 밀리든 빠르든 한 번의 stepSim 호출은 항상 16ms 분량만 움직인다. 여덟 항목은 seedRand() 로 씨앗을 고정한 난수를 시작 위치·속도에 써서 같은 순서로 재생되고, 유일한 예외는 뒤에서 다룬다. 공 하나에 적용되는 애니메이션 물리 법칙은 반발계수·질량·포인터 거리 세 값이 전부이고, 이 값들이 서로 다른 조합으로 섞이며 아홉 가지 다른 장면이 된다.
공 물리 종류별 코드
아래 아홉 개는 전체 코드가 아니라 값이 실제로 계산되는 구간만 발췌했고, 나머지는 압축 파일 안에 들어 있다. 아홉 항목 모두 애니메이션 물리 엔진 없이 반발계수·질량·포인터 거리 같은 값만 매 프레임 계산해서 움직인다.
01속도를 서로 주고받는 탄성 충돌 공
두 공이 겹치면 질량 비율만큼 서로 밀어내 위치를 갈라놓고, 그 자리의 법선 방향 속도를 반발계수 0.82로 교환해 튕겨 나간다. 겹침이 없으면 resolvePair 는 아무 계산도 하지 않고 즉시 반환한다. 23장 캡처 중 23장에서 공이 움직였고 누적면적은 40.8%, 충돌 강도 평균은 106.2였다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">공과 공이 부딪히는 순간 두 공의 질량과 속도를 계산해 그 값을 서로 맞바꿔, 무거운 공은 적게 밀리고 가벼운 공은 크게 튕겨 나간다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 두 공이 겹치는 순간 접촉면 법선(n)을 구해 각 공의 속도를 법선·접선 성분으로
// 나눈다. 접선 성분은 그대로 두고 법선 성분에만 반발계수(BALL_RESTITUTION)를 적용한
// 충격량 공식을 풀어 질량이 다른 두 공이 속도를 주고받게 한다 — 질량이 큰 쪽은
// 적게 밀리고 작은 쪽은 크게 튕긴다. 벽은 에너지를 보존해(WALL_RESTITUTION = 1) 장면이
// 계속 살아 있게 한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var BALL_RESTITUTION = 0.82;
var WALL_RESTITUTION = 1.0;
var STAGE_BG = '23,20,26';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427011);
var COLORS = ['#ff9ebb', '#4cd4a6', '#b9a0ff', '#6ec8ff'];
var balls = [];
function makeBalls() {
var radii = [12, 16, 20, 24];
balls = [];
for (var i = 0; i < radii.length; i += 1) {
var r = radii[i];
var ang = rand() * Math.PI * 2;
var spd = 90 + rand() * 90;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: r + rand() * (CALC_H - r * 2),
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
r: r,
mass: r,
color: COLORS[i % COLORS.length],
squash: 0
});
}
}
makeBalls();
var impacts = [];
function resolveWalls(b) {
var hit = 0;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vx)); }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vx)); }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vy)); }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = Math.max(hit, Math.abs(b.vy)); }
if (hit > 0) b.squash = Math.max(b.squash, Math.min(1, hit / 260));
}
function resolvePair(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
var minDist = a.r + b.r;
if (dist <= 0 || dist >= minDist) return;
var nx = dx / dist, ny = dy / dist;
var overlap = minDist - dist;
var totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var vn = rvx * nx + rvy * ny;
if (vn >= 0) return;
var j = -(1 + BALL_RESTITUTION) * vn / (1 / a.mass + 1 / b.mass);
var ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
var strength = Math.min(1, Math.abs(vn) / 220);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: elapsed, strength: strength });
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
resolveWalls(b);
b.squash = Math.max(0, b.squash - dtS * 3.2);
}
for (var p = 0; p < balls.length; p += 1) {
for (var q = p + 1; q < balls.length; q += 1) resolvePair(balls[p], balls[q]);
}
var live = [];
for (var k = 0; k < impacts.length; k += 1) {
if (nowMs - impacts[k].born < 340) live.push(impacts[k]);
}
impacts = live;
}
function drawBall(sx, sy, b) {
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var stretchX = 1 + b.squash * 0.5;
var stretchY = 1 - b.squash * 0.35;
ctx.save();
ctx.translate(px, py);
ctx.scale(stretchX, stretchY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(' + STAGE_BG + ',0.22)';
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < impacts.length; i += 1) {
var im = impacts[i];
var f = (nowMs - im.born) / 340;
var px = im.x * sx, py = im.y * sy;
var pr = (10 + f * 26) * ((sx + sy) / 2);
ctx.beginPath();
ctx.arc(px, py, pr, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,' + (im.strength * (1 - f) * 0.6) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
for (var j = 0; j < balls.length; j += 1) drawBall(sx, sy, balls[j]);
}
var cw = 480, ch = 300;
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;
ctx.fillStyle = 'rgb(' + STAGE_BG + ')';
ctx.fillRect(0, 0, cw, ch);
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 70 : 60;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16, elapsed); }
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsElasticCollision.module.scss";
type Ball = { x: number; y: number; vx: number; vy: number; r: number; mass: number; color: string; squash: number };
type Impact = { x: number; y: number; born: number; strength: number };
export default function BallPhysicsElasticCollision() {
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 CALC_W = 320, CALC_H = 200;
const BALL_RESTITUTION = 0.82;
const WALL_RESTITUTION = 1.0;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427011);
const RADII = [12, 16, 20, 24];
const COLORS = ["#ff9ebb", "#4cd4a6", "#b9a0ff", "#6ec8ff"];
let balls: Ball[] = [];
function makeBalls() {
balls = [];
for (let i = 0; i < RADII.length; i += 1) {
const r = RADII[i];
balls.push({
x: 40 + i * ((CALC_W - 80) / (RADII.length - 1)),
y: 30 + rand() * (CALC_H - 60),
vx: (rand() - 0.5) * 160,
vy: (rand() - 0.5) * 160,
r, mass: r,
color: COLORS[i],
squash: 0
});
}
}
makeBalls();
let impacts: Impact[] = [];
function resolveWalls(b: Ball) {
let hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (hit) b.squash = Math.max(b.squash, Math.min(0.7, Math.hypot(b.vx, b.vy) / 340));
}
function resolvePair(a: Ball, b: Ball, nowMs: number) {
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const minDist = a.r + b.r;
if (dist === 0 || dist >= minDist) return;
const nx = dx / dist, ny = dy / dist;
const overlap = minDist - dist;
const totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal > 0) return;
const j = -(1 + BALL_RESTITUTION) * velAlongNormal / (1 / a.mass + 1 / b.mass);
const ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
const strength = Math.min(1, Math.abs(velAlongNormal) / 300);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: nowMs, strength });
}
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (let i = 0; i < balls.length; i += 1) {
const b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
resolveWalls(b);
b.squash = Math.max(0, b.squash - dtS * 3.4);
}
for (let a = 0; a < balls.length; a += 1) {
for (let c = a + 1; c < balls.length; c += 1) resolvePair(balls[a], balls[c], nowMs);
}
impacts = impacts.filter((im) => nowMs - im.born < 240);
}
function drawBall(sx: number, sy: number, b: Ball) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const ang = Math.atan2(b.vy, b.vx);
const stretch = 1 + Math.min(0.4, Math.hypot(b.vx, b.vy) / 700) + b.squash * 0.2;
ctx!.save();
ctx!.translate(px, py);
ctx!.rotate(ang);
ctx!.scale(stretch, 1 / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(23,20,26,0.22)";
ctx!.fillRect(0, 0, cw, ch);
for (const im of impacts) {
const f = (nowMs - im.born) / 240;
ctx!.beginPath();
ctx!.arc(im.x * sx, im.y * sy, (6 + f * 20) * ((sx + sy) / 2), 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255," + (im.strength * (1 - f) * 0.5) + ")";
ctx!.lineWidth = 2;
ctx!.stroke();
}
for (const b of balls) drawBall(sx, sy, b);
}
function fit() {
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;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = 60;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16, elapsed); }
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>공과 공이 부딪히는 순간 두 공의 질량과 속도를 계산해 그 값을 서로 맞바꿔, 무거운 공은 적게 밀리고 가벼운 공은 크게 튕겨 나간다</p>
</div>
</main>
);
}01 · 속도를 서로 주고받는 탄성 충돌 공 — 측정 누적면적 40.801% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function resolvePair(a, b) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
var minDist = a.r + b.r;
if (dist <= 0 || dist >= minDist) return;
var nx = dx / dist, ny = dy / dist;
var overlap = minDist - dist;
var totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var vn = rvx * nx + rvy * ny;
if (vn >= 0) return;
var j = -(1 + BALL_RESTITUTION) * vn / (1 / a.mass + 1 / b.mass);
var ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
var strength = Math.min(1, Math.abs(vn) / 220);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: elapsed, strength: strength });
}
02떨어지다 바닥에서 잦아드는 중력 낙하 공
매 프레임 GRAVITY 만큼 아래로 속도를 더하다가 바닥에 닿으면 되튀지만, 튕겨 나가는 속도가 SLEEP_THRESHOLD 밑으로 떨어지면 그 공은 그대로 멈춘다. 멈춘 공은 squash 값만 서서히 줄어들 뿐 더는 위치가 바뀌지 않는다. 23장 중 20장에서만 움직임이 잡혔는데, 공이 일찍 잠들수록 뒤쪽 캡처는 정지 상태라 누적면적은 15.4%로 낮지만 강도 평균 103.7은 움직인 20장만 놓고 잰 값이라 다른 항목과 비슷한 수준이다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">매 프레임 아래 방향으로 속도를 더해 공을 떨어뜨리고, 바닥에 닿을 때마다 속도를 줄여 나가 결국 공이 멈춰 눕는다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-yellow;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffd23f;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 반고정 시간 스텝마다 속도에 GRAVITY * dt 를 더해 적분한다(오일러). 바닥에
// 닿으면 속도를 FLOOR_RESTITUTION 배로 줄여 되튕기고, 절대값이 SLEEP_THRESHOLD 보다
// 작아지면 움직임을 0으로 잠가 공이 멈춰 눕는다. 완전히 멈춘 뒤엔 일정 시간 뒤 같은
// 시퀀스를 처음부터 다시 시작해 2초 캡처창 안에 낙하-안정 사이클이 2회 들어온다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var GRAVITY = 900;
var FLOOR_RESTITUTION = 0.55;
var SLEEP_THRESHOLD = 26;
var SLEEP_HOLD_MS = 320;
var STAGE_BG = '255,247,230';
var REST_MS = 620;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427021);
var DROPS = [
{ x: 0.22, r: 13, color: '#ff9ebb', delay: 0 },
{ x: 0.5, r: 17, color: '#4cd4a6', delay: 200 },
{ x: 0.78, r: 11, color: '#b9a0ff', delay: 420 }
];
var balls = [];
function reset() {
balls = [];
for (var i = 0; i < DROPS.length; i += 1) {
var d = DROPS[i];
balls.push({
x0: CALC_W * d.x,
x: CALC_W * d.x,
y: 30,
vy: 0,
r: d.r,
color: d.color,
startAt: d.delay,
born: 0,
squash: 0,
settled: false
});
}
impacts = [];
}
var impacts = [];
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
if (nowMs < b.startAt) continue;
if (b.settled) {
b.squash = Math.max(0, b.squash - dtS * 6);
continue;
}
b.vy += GRAVITY * dtS;
b.y += b.vy * dtS;
var floor = CALC_H - b.r;
if (b.y >= floor) {
b.y = floor;
if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
b.vy = 0;
b.settled = true;
b.squash = 0.5;
impacts.push({ x: b.x, y: floor, born: nowMs, strength: 0.35 });
} else {
impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
b.vy = -b.vy * FLOOR_RESTITUTION;
}
}
b.squash = Math.max(0, b.squash - dtS * 3.5);
}
var live = [];
for (var k = 0; k < impacts.length; k += 1) {
if (nowMs - impacts[k].born < 300) live.push(impacts[k]);
}
impacts = live;
}
function allSettled() {
for (var i = 0; i < balls.length; i += 1) {
if (!balls[i].settled) return false;
}
return true;
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(23,20,26,0.08)';
ctx.lineWidth = 1;
for (var gy = 0; gy < CALC_H; gy += 40) {
ctx.beginPath();
ctx.moveTo(0, gy * sy);
ctx.lineTo(cw, gy * sy);
ctx.stroke();
}
for (var i = 0; i < impacts.length; i += 1) {
var im = impacts[i];
var f = (nowMs - im.born) / 300;
var px = im.x * sx, py = im.y * sy;
var pr = (8 + f * 22) * ((sx + sy) / 2);
ctx.beginPath();
ctx.ellipse(px, py, pr, pr * 0.32, 0, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(23,20,26,' + (im.strength * (1 - f) * 0.3) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
for (var j = 0; j < balls.length; j += 1) {
var b = balls[j];
if (nowMs < b.startAt) continue;
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 120);
var shw = pr * (0.9 + shf * 0.4);
ctx.beginPath();
ctx.ellipse(px, (CALC_H - 4) * sy, shw, shw * 0.26, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(23,20,26,' + (0.10 + shf * 0.16) + ')';
ctx.fill();
var sqX = 1 + b.squash * 0.55;
var sqY = 1 - b.squash * 0.38;
ctx.save();
ctx.translate(px, py + pr * b.squash * 0.38);
ctx.scale(sqX, sqY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
var hg = ctx.createRadialGradient(-pr * 0.3, -pr * 0.35, pr * 0.1, -pr * 0.3, -pr * 0.35, pr * 0.75);
hg.addColorStop(0, 'rgba(255,255,255,0.5)');
hg.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hg;
ctx.fill();
ctx.restore();
}
}
var cw = 480, ch = 300;
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;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
reset();
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
var settledAt = null;
var cycle = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
if (allSettled()) {
if (settledAt === null) settledAt = elapsed;
else if (elapsed - settledAt > REST_MS) {
settledAt = null;
cycle += 1;
elapsed = 0;
reset();
}
}
acc -= FIXED_DT;
}
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsGravityDropSettle.module.scss";
type Ball = {
x0: number; x: number; y: number; vy: number; r: number; color: string;
startAt: number; squash: number; settled: boolean;
};
type Impact = { x: number; y: number; born: number; strength: number };
export default function BallPhysicsGravityDropSettle() {
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 CALC_W = 320, CALC_H = 200;
const GRAVITY = 900;
const FLOOR_RESTITUTION = 0.55;
const SLEEP_THRESHOLD = 26;
const REST_MS = 620;
const DROPS = [
{ x: 0.22, r: 13, color: "#ff9ebb", delay: 0 },
{ x: 0.5, r: 17, color: "#4cd4a6", delay: 200 },
{ x: 0.78, r: 11, color: "#b9a0ff", delay: 420 }
];
let balls: Ball[] = [];
let impacts: Impact[] = [];
function reset() {
balls = DROPS.map((d) => ({
x0: CALC_W * d.x, x: CALC_W * d.x, y: 30, vy: 0,
r: d.r, color: d.color, startAt: d.delay, squash: 0, settled: false
}));
impacts = [];
}
reset();
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (const b of balls) {
if (nowMs < b.startAt) continue;
if (b.settled) { b.squash = Math.max(0, b.squash - dtS * 6); continue; }
b.vy += GRAVITY * dtS;
b.y += b.vy * dtS;
const floor = CALC_H - b.r;
if (b.y >= floor) {
b.y = floor;
if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
b.vy = 0; b.settled = true; b.squash = 0.5;
impacts.push({ x: b.x, y: floor, born: nowMs, strength: 0.35 });
} else {
impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
b.vy = -b.vy * FLOOR_RESTITUTION;
}
}
b.squash = Math.max(0, b.squash - dtS * 3.5);
}
impacts = impacts.filter((im) => nowMs - im.born < 300);
}
function allSettled() { return balls.every((b) => b.settled); }
let cw = 480, ch = 300;
function draw(nowMs: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(23,20,26,0.08)";
ctx!.lineWidth = 1;
for (let gy = 0; gy < CALC_H; gy += 40) {
ctx!.beginPath();
ctx!.moveTo(0, gy * sy);
ctx!.lineTo(cw, gy * sy);
ctx!.stroke();
}
for (const im of impacts) {
const f = (nowMs - im.born) / 300;
const px = im.x * sx, py = im.y * sy;
const pr = (8 + f * 22) * ((sx + sy) / 2);
ctx!.beginPath();
ctx!.ellipse(px, py, pr, pr * 0.32, 0, 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(23,20,26," + (im.strength * (1 - f) * 0.3) + ")";
ctx!.lineWidth = 2;
ctx!.stroke();
}
for (const b of balls) {
if (nowMs < b.startAt) continue;
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 120);
const shw = pr * (0.9 + shf * 0.4);
ctx!.beginPath();
ctx!.ellipse(px, (CALC_H - 4) * sy, shw, shw * 0.26, 0, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(23,20,26," + (0.10 + shf * 0.16) + ")";
ctx!.fill();
const sqX = 1 + b.squash * 0.55, sqY = 1 - b.squash * 0.38;
ctx!.save();
ctx!.translate(px, py + pr * b.squash * 0.38);
ctx!.scale(sqX, sqY);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
const hg = ctx!.createRadialGradient(-pr * 0.3, -pr * 0.35, pr * 0.1, -pr * 0.3, -pr * 0.35, pr * 0.75);
hg.addColorStop(0, "rgba(255,255,255,0.5)");
hg.addColorStop(1, "rgba(255,255,255,0)");
ctx!.fillStyle = hg;
ctx!.fill();
ctx!.restore();
}
}
function fit() {
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;
draw(elapsed);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let settledAt: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
if (allSettled()) {
if (settledAt === null) settledAt = elapsed;
else if (elapsed - settledAt > REST_MS) { settledAt = null; elapsed = 0; reset(); }
}
acc -= FIXED_DT;
}
draw(elapsed);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(elapsed);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>매 프레임 아래 방향으로 속도를 더해 공을 떨어뜨리고, 바닥에 닿을 때마다 속도를 줄여 나가 결국 공이 멈춰 눕는다</p>
</div>
</main>
);
}02 · 떨어지다 바닥에서 잦아드는 중력 낙하 공 — 측정 누적면적 15.354% · 움직인 프레임 20/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
if (nowMs < b.startAt) continue;
if (b.settled) {
b.squash = Math.max(0, b.squash - dtS * 6);
continue;
}
b.vy += GRAVITY * dtS;
b.y += b.vy * dtS;
var floor = CALC_H - b.r;
if (b.y >= floor) {
b.y = floor;
if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
b.vy = 0;
b.settled = true;
b.squash = 0.5;
impacts.push({ x: b.x, y: floor, born: nowMs, strength: 0.35 });
} else {
impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
b.vy = -b.vy * FLOOR_RESTITUTION;
}
}
03벽에 닿을 때마다 힘을 잃는 반발계수 벽 튕김 공
네 벽 중 하나에 닿을 때마다 그 축의 속도에 WALL_RESTITUTION 을 곱해 일부를 잃고, 속도가 MIN_SPEED 밑으로 느려지면 kick 이 새 각도와 KICK_SPEED 로 다시 쏴 보낸다. 공은 세 개뿐이라 서로 부딪히지 않고 각자 벽만 상대한다. 23장 모두 움직였고 누적면적 35.0%·강도 평균 96.4였다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">화면 가장자리에 닿는 순간 방향을 반대로 뒤집고 그 속도에 반발계수를 곱해 줄여, 튕길수록 공의 움직임이 조금씩 잦아든다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 공이 캔버스 네 변 중 하나에 닿으면 그 축의 속도 부호를 뒤집고 WALL_RESTITUTION
// 을 곱해 줄인다 — 반복할수록 속도 크기가 기하급수로 잦아든다. 전체 속도가 MIN_SPEED
// 아래로 내려가면 씨드 난수로 새 방향·속도를 받아 다시 살아난다(같은 감쇠 패턴을
// 반복해서 보여주기 위함이지 에너지가 새로 생기는 건 아니다).
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var WALL_RESTITUTION = 0.86;
var MIN_SPEED = 46;
var KICK_SPEED = 210;
var STAGE_BG = '255,77,31';
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427031);
var COLORS = ['#b9a0ff', '#6ec8ff', '#ff9ebb'];
var radii = [11, 15, 19];
var balls = [];
function kick(b) {
var ang = rand() * Math.PI * 2;
var spd = KICK_SPEED * (0.7 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.hits = 0;
}
for (var i = 0; i < radii.length; i += 1) {
var r = radii[i];
var b = { x: r + rand() * (CALC_W - r * 2), y: r + rand() * (CALC_H - r * 2), r: r, color: COLORS[i], squash: 0, hits: 0 };
kick(b);
balls.push(b);
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (hit) {
b.hits += 1;
b.squash = Math.min(1, Math.hypot(b.vx, b.vy) / 260 + 0.25);
}
b.squash = Math.max(0, b.squash - dtS * 3.4);
var speed = Math.hypot(b.vx, b.vy);
if (speed < MIN_SPEED) kick(b);
}
}
function drawBall(sx, sy, b) {
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var ang = Math.atan2(b.vy, b.vx);
var speed = Math.hypot(b.vx, b.vy);
var stretch = 1 + Math.min(0.4, speed / 900);
ctx.save();
ctx.translate(px, py);
ctx.rotate(ang);
ctx.scale(stretch, (1 - b.squash * 0.3) / stretch);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(' + STAGE_BG + ',0.24)';
ctx.fillRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(255,255,255,0.35)';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, cw - 4, ch - 4);
for (var j = 0; j < balls.length; j += 1) drawBall(sx, sy, balls[j]);
}
var cw = 480, ch = 300;
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;
ctx.fillStyle = 'rgb(' + STAGE_BG + ')';
ctx.fillRect(0, 0, cw, ch);
draw();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
var warmSteps = reduce ? 50 : 40;
for (var w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16); }
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT); acc -= FIXED_DT; }
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsWallRestitutionBounce.module.scss";
type Ball = { x: number; y: number; vx: number; vy: number; r: number; color: string; squash: number; hits: number };
export default function BallPhysicsWallRestitutionBounce() {
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 CALC_W = 320, CALC_H = 200;
const WALL_RESTITUTION = 0.86;
const MIN_SPEED = 46;
const KICK_SPEED = 210;
const STAGE_BG = "255,77,31";
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427031);
const COLORS = ["#b9a0ff", "#6ec8ff", "#ff9ebb"];
const radii = [11, 15, 19];
const balls: Ball[] = [];
function kick(b: Ball) {
const ang = rand() * Math.PI * 2;
const spd = KICK_SPEED * (0.7 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.hits = 0;
}
for (let i = 0; i < radii.length; i += 1) {
const r = radii[i];
const b: Ball = { x: r + rand() * (CALC_W - r * 2), y: r + rand() * (CALC_H - r * 2), vx: 0, vy: 0, r, color: COLORS[i], squash: 0, hits: 0 };
kick(b);
balls.push(b);
}
function stepSim(dtMs: number) {
const dtS = dtMs / 1000;
for (const b of balls) {
b.x += b.vx * dtS;
b.y += b.vy * dtS;
let hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (hit) {
b.hits += 1;
b.squash = Math.min(1, Math.hypot(b.vx, b.vy) / 260 + 0.25);
}
b.squash = Math.max(0, b.squash - dtS * 3.4);
const speed = Math.hypot(b.vx, b.vy);
if (speed < MIN_SPEED) kick(b);
}
}
let cw = 480, ch = 300;
function drawBall(sx: number, sy: number, b: Ball) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const ang = Math.atan2(b.vy, b.vx);
const speed = Math.hypot(b.vx, b.vy);
const stretch = 1 + Math.min(0.4, speed / 900);
ctx!.save();
ctx!.translate(px, py);
ctx!.rotate(ang);
ctx!.scale(stretch, (1 - b.squash * 0.3) / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(" + STAGE_BG + ",0.24)";
ctx!.fillRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(255,255,255,0.35)";
ctx!.lineWidth = 2;
ctx!.strokeRect(2, 2, cw - 4, ch - 4);
for (const b of balls) drawBall(sx, sy, b);
}
function fit() {
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;
ctx!.fillStyle = "rgb(" + STAGE_BG + ")";
ctx!.fillRect(0, 0, cw, ch);
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
const warmSteps = reduce ? 50 : 40;
for (let w = 0; w < warmSteps; w += 1) { elapsed += 16; stepSim(16); }
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT); acc -= FIXED_DT; }
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>화면 가장자리에 닿는 순간 방향을 반대로 뒤집고 그 속도에 반발계수를 곱해 줄여, 튕길수록 공의 움직임이 조금씩 잦아든다</p>
</div>
</main>
);
}03 · 벽에 닿을 때마다 힘을 잃는 반발계수 벽 튕김 공 — 측정 누적면적 35.015% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function kick(b) {
var ang = rand() * Math.PI * 2;
var spd = KICK_SPEED * (0.7 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.hits = 0;
}
for (var i = 0; i < radii.length; i += 1) {
var r = radii[i];
var b = { x: r + rand() * (CALC_W - r * 2), y: r + rand() * (CALC_H - r * 2), r: r, color: COLORS[i], squash: 0, hits: 0 };
kick(b);
balls.push(b);
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) { b.y = CALC_H - b.r; b.vy = -b.vy * WALL_RESTITUTION; hit = true; }
04질량 차이로 튀는 높이가 갈라지는 공
네 공은 같은 자리에서 같은 높이로 떨어지지만 restitutionFor 가 질량을 BASE_MASS_REST 와 BALL_MASS_BOUNCE_FACTOR 로 나눠 각자 다른 반발계수를 돌려준다. 질량이 클수록 반발계수가 작아져 무거운 공은 낮게, 가벼운 공은 높게 튄다. 23장 모두 움직였지만 누적면적은 11.1%로 아홉 항목 중 가장 낮은데, 화면을 채우는 건 매번 가장 가벼운 공 하나뿐이라 나온 결과다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">공마다 다른 크기를 질량값으로 써서 떨어질 때 힘을 계산해, 같은 높이에서 떨어져도 가벼운 공은 높이 튀어 오르고 무거운 공은 낮게 튄다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 공의 반지름을 그대로 질량으로 쓰고, 바닥에 닿을 때 되튀는 반발계수를
// BASE_MASS_REST * BALL_MASS_BOUNCE_FACTOR / mass 로 계산한다 — 질량이 작을수록
// 나눗셈 결과가 커져 반발계수가 크고, 질량이 클수록 작아 낮게 튄다. 네 공이 같은
// 높이·같은 시각에 떨어지므로 튀는 높이 차이가 질량 하나로만 갈린다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var GRAVITY = 900;
var BALL_MASS_BOUNCE_FACTOR = 1.4;
var BASE_MASS_REST = 6;
var SLEEP_THRESHOLD = 24;
var SLEEP_HOLD_MS = 300;
var STAGE_BG = '255,255,255';
var MASSES = [10, 16, 22, 30];
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb'];
var XPOS = [0.16, 0.4, 0.64, 0.86];
function restitutionFor(mass) {
return Math.max(0.22, Math.min(0.95, BASE_MASS_REST * BALL_MASS_BOUNCE_FACTOR / mass));
}
var balls = [];
function reset() {
balls = [];
for (var i = 0; i < MASSES.length; i += 1) {
balls.push({
x: CALC_W * XPOS[i],
y: 26,
vy: 0,
mass: MASSES[i],
r: MASSES[i] * 0.5,
color: COLORS[i],
rest: restitutionFor(MASSES[i]),
squash: 0,
settled: false
});
}
impacts = [];
}
var impacts = [];
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
if (b.settled) { b.squash = Math.max(0, b.squash - dtS * 6); continue; }
b.vy += GRAVITY * dtS;
b.y += b.vy * dtS;
var floor = CALC_H - b.r;
if (b.y >= floor) {
b.y = floor;
if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
b.vy = 0; b.settled = true; b.squash = 0.5;
} else {
impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
b.vy = -b.vy * b.rest;
}
}
b.squash = Math.max(0, b.squash - dtS * 3.5);
}
var live = [];
for (var k = 0; k < impacts.length; k += 1) { if (nowMs - impacts[k].born < 260) live.push(impacts[k]); }
impacts = live;
}
function allSettled() {
for (var i = 0; i < balls.length; i += 1) { if (!balls[i].settled) return false; }
return true;
}
function draw(nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(23,20,26,0.08)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, (CALC_H - 2) * sy);
ctx.lineTo(cw, (CALC_H - 2) * sy);
ctx.stroke();
for (var j = 0; j < balls.length; j += 1) {
var b = balls[j];
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 130);
var shw = pr * (0.9 + shf * 0.4);
ctx.beginPath();
ctx.ellipse(px, (CALC_H - 3) * sy, shw, shw * 0.24, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(23,20,26,' + (0.08 + shf * 0.14) + ')';
ctx.fill();
var sqX = 1 + b.squash * 0.5, sqY = 1 - b.squash * 0.35;
ctx.save();
ctx.translate(px, py + pr * b.squash * 0.35);
ctx.scale(sqX, sqY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
}
var cw = 480, ch = 300;
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;
draw(elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
reset();
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
var settledAt = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
if (allSettled()) {
if (settledAt === null) settledAt = elapsed;
else if (elapsed - settledAt > SLEEP_HOLD_MS) { settledAt = null; elapsed = 0; reset(); }
}
acc -= FIXED_DT;
}
draw(elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsMassBounceHeight.module.scss";
type Ball = { x: number; y: number; vy: number; mass: number; r: number; color: string; rest: number; squash: number; settled: boolean };
export default function BallPhysicsMassBounceHeight() {
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 CALC_W = 320, CALC_H = 200;
const GRAVITY = 900;
const BALL_MASS_BOUNCE_FACTOR = 1.4;
const BASE_MASS_REST = 6;
const SLEEP_THRESHOLD = 24;
const SLEEP_HOLD_MS = 300;
const MASSES = [10, 16, 22, 30];
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb"];
const XPOS = [0.16, 0.4, 0.64, 0.86];
function restitutionFor(mass: number) {
return Math.max(0.22, Math.min(0.95, (BASE_MASS_REST * BALL_MASS_BOUNCE_FACTOR) / mass));
}
let balls: Ball[] = [];
let impacts: { x: number; y: number; born: number; strength: number }[] = [];
function reset() {
balls = MASSES.map((mass, i) => ({
x: CALC_W * XPOS[i], y: 26, vy: 0, mass, r: mass * 0.5,
color: COLORS[i], rest: restitutionFor(mass), squash: 0, settled: false
}));
impacts = [];
}
reset();
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
for (const b of balls) {
if (b.settled) { b.squash = Math.max(0, b.squash - dtS * 6); continue; }
b.vy += GRAVITY * dtS;
b.y += b.vy * dtS;
const floor = CALC_H - b.r;
if (b.y >= floor) {
b.y = floor;
if (Math.abs(b.vy) < SLEEP_THRESHOLD) {
b.vy = 0; b.settled = true; b.squash = 0.5;
} else {
impacts.push({ x: b.x, y: floor, born: nowMs, strength: Math.min(1, Math.abs(b.vy) / 320) });
b.squash = Math.max(b.squash, Math.min(0.85, Math.abs(b.vy) / 300));
b.vy = -b.vy * b.rest;
}
}
b.squash = Math.max(0, b.squash - dtS * 3.5);
}
impacts = impacts.filter((im) => nowMs - im.born < 260);
}
function allSettled() { return balls.every((b) => b.settled); }
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(23,20,26,0.08)";
ctx!.lineWidth = 1;
ctx!.beginPath();
ctx!.moveTo(0, (CALC_H - 2) * sy);
ctx!.lineTo(cw, (CALC_H - 2) * sy);
ctx!.stroke();
for (const b of balls) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 130);
const shw = pr * (0.9 + shf * 0.4);
ctx!.beginPath();
ctx!.ellipse(px, (CALC_H - 3) * sy, shw, shw * 0.24, 0, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(23,20,26," + (0.08 + shf * 0.14) + ")";
ctx!.fill();
const sqX = 1 + b.squash * 0.5, sqY = 1 - b.squash * 0.35;
ctx!.save();
ctx!.translate(px, py + pr * b.squash * 0.35);
ctx!.scale(sqX, sqY);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
}
function fit() {
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;
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let settledAt: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
if (allSettled()) {
if (settledAt === null) settledAt = elapsed;
else if (elapsed - settledAt > SLEEP_HOLD_MS) { settledAt = null; elapsed = 0; reset(); }
}
acc -= FIXED_DT;
}
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>공마다 다른 크기를 질량값으로 써서 떨어질 때 힘을 계산해, 같은 높이에서 떨어져도 가벼운 공은 높이 튀어 오르고 무거운 공은 낮게 튄다</p>
</div>
</main>
);
}04 · 질량 차이로 튀는 높이가 갈라지는 공 — 측정 누적면적 11.113% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function restitutionFor(mass) {
return Math.max(0.22, Math.min(0.95, BASE_MASS_REST * BALL_MASS_BOUNCE_FACTOR / mass));
}
var balls = [];
function reset() {
balls = [];
for (var i = 0; i < MASSES.length; i += 1) {
balls.push({
x: CALC_W * XPOS[i],
y: 26,
vy: 0,
mass: MASSES[i],
r: MASSES[i] * 0.5,
color: COLORS[i],
rest: restitutionFor(MASSES[i]),
squash: 0,
settled: false
});
}
impacts = [];
}
var impacts = [];
function stepSim(dtMs, nowMs) {
05포인터가 다가오면 흩어지는 반발 공
포인터와의 거리가 POINTER_REPEL_RADIUS 안으로 들어오면 applyPointer 가 거리에 반비례하는 REPEL_FORCE 를 공에 더해 밀어내고, 포인터가 멀어지면 각 공은 DRIFT_SPEED 로 정해진 제자리로 천천히 돌아온다. 방향키도 같은 포인터 좌표를 옮기므로 마우스 없이도 흩어뜨릴 수 있다. 23장 모두 움직였고 누적면적 21.4%·강도 평균 88.1이었다.

<main class="stage">
<div class="bp is-demo" id="bpRoot" tabindex="0">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">포인터가 다가오면 거리에 반비례하는 힘으로 공들을 밀어내 흩어지게 하고, 멀어지면 다시 제자리 근처를 천천히 떠돈다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 포인터(실제 마우스/터치 또는 방향키로 움직이는 가상 포인터)와 공 사이 거리를
// 재서 POINTER_REPEL_RADIUS 안이면 거리에 반비례하는 밀어내는 힘을 가한다(가까울수록
// 세게). 반경 밖에서는 각 공이 자기 홈 위치 주변을 사인파로 천천히 떠돈다. 자동재생은
// 가상 포인터가 궤도를 그리며 이 힘 함수를 그대로 호출하고, 실제 입력이 오면 같은
// 함수를 실제 좌표로 호출하도록 전환한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var POINTER_REPEL_RADIUS = 90;
var REPEL_FORCE = 5200;
var DRIFT_SPEED = 0.55;
var DAMPING = 3.4;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427051);
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb', '#ffcf7a', '#8fd7ff'];
var balls = [];
for (var i = 0; i < 6; i += 1) {
var hx = 30 + rand() * (CALC_W - 60);
var hy = 30 + rand() * (CALC_H - 60);
balls.push({
hx: hx, hy: hy,
x: hx, y: hy,
vx: 0, vy: 0,
r: 9 + rand() * 7,
color: COLORS[i % COLORS.length],
phase: rand() * Math.PI * 2,
squash: 0
});
}
var pointer = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
var pointerActive = false;
function demoPointerPos(t) {
var tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 1.15) * (CALC_W * 0.36),
y: CALC_H * 0.5 + Math.sin(tt * 1.7) * (CALC_H * 0.34)
};
}
function applyPointer(px, py, dtS) {
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var dx = b.x - px, dy = b.y - py;
var dist = Math.hypot(dx, dy) || 0.001;
if (dist < POINTER_REPEL_RADIUS) {
var force = REPEL_FORCE * (1 - dist / POINTER_REPEL_RADIUS) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
b.squash = Math.min(1, b.squash + (1 - dist / POINTER_REPEL_RADIUS) * 0.6);
}
}
}
function stepSim(dtMs, nowMs, px, py) {
var dtS = dtMs / 1000;
applyPointer(px, py, dtS);
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var homeX = b.hx + Math.cos(nowMs / 1000 * DRIFT_SPEED + b.phase) * 10;
var homeY = b.hy + Math.sin(nowMs / 1000 * DRIFT_SPEED * 0.8 + b.phase) * 10;
var pull = 0.9;
b.vx += (homeX - b.x) * pull * dtS;
b.vy += (homeY - b.y) * pull * dtS;
b.vx -= b.vx * Math.min(1, DAMPING * dtS);
b.vy -= b.vy * Math.min(1, DAMPING * dtS);
b.x += b.vx * dtS;
b.y += b.vy * dtS;
if (b.x < b.r) { b.x = b.r; b.vx *= -0.5; }
if (b.x > CALC_W - b.r) { b.x = CALC_W - b.r; b.vx *= -0.5; }
if (b.y < b.r) { b.y = b.r; b.vy *= -0.5; }
if (b.y > CALC_H - b.r) { b.y = CALC_H - b.r; b.vy *= -0.5; }
b.squash = Math.max(0, b.squash - dtS * 2.6);
}
}
function draw(px, py) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
ctx.beginPath();
ctx.arc(px * sx, py * sy, POINTER_REPEL_RADIUS * ((sx + sy) / 2), 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.18)';
ctx.lineWidth = 1.5;
ctx.stroke();
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
var speed = Math.hypot(b.vx, b.vy);
var ang = Math.atan2(b.vy, b.vx);
var stretch = 1 + Math.min(0.5, speed / 500) + b.squash * 0.2;
ctx.save();
ctx.translate(ppx, ppy);
ctx.rotate(ang);
ctx.scale(stretch, 1 / stretch);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
ctx.beginPath();
ctx.arc(px * sx, py * sy, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fill();
}
var cw = 480, ch = 300;
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;
draw(pointer.x, pointer.y);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function toCalc(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var nx = (clientX - rect.left) / rect.width;
var ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx, cy) {
pointerActive = true;
root.classList.remove('is-demo');
var p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) {
stepSim(16, elapsed, pointer.x, pointer.y);
draw(pointer.x, pointer.y);
}
}
canvas.addEventListener('mousemove', function (e) { onRealPointer(e.clientX, e.clientY); });
canvas.addEventListener('touchmove', function (e) {
if (e.touches && e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
root.addEventListener('keydown', function (e) {
var step = 14;
var moved = true;
if (e.key === 'ArrowLeft') pointer.x -= step;
else if (e.key === 'ArrowRight') pointer.x += step;
else if (e.key === 'ArrowUp') pointer.y -= step;
else if (e.key === 'ArrowDown') pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root.classList.remove('is-demo');
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) {
stepSim(16, elapsed, pointer.x, pointer.y);
draw(pointer.x, pointer.y);
}
}
});
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
var pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, elapsed, pointer.x, pointer.y);
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(pointer.x, pointer.y);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsMouseRepelScatter.module.scss";
type Ball = { hx: number; hy: number; x: number; y: number; vx: number; vy: number; r: number; color: string; phase: number; squash: number };
export default function BallPhysicsMouseRepelScatter() {
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 CALC_W = 320, CALC_H = 200;
const POINTER_REPEL_RADIUS = 90;
const REPEL_FORCE = 5200;
const DRIFT_SPEED = 0.55;
const DAMPING = 3.4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427051);
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb", "#ffcf7a", "#8fd7ff"];
const balls: Ball[] = [];
for (let i = 0; i < 6; i += 1) {
const hx = 30 + rand() * (CALC_W - 60);
const hy = 30 + rand() * (CALC_H - 60);
balls.push({ hx, hy, x: hx, y: hy, vx: 0, vy: 0, r: 9 + rand() * 7, color: COLORS[i % COLORS.length], phase: rand() * Math.PI * 2, squash: 0 });
}
const pointer = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
let pointerActive = false;
function demoPointerPos(t: number) {
const tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 1.15) * (CALC_W * 0.36),
y: CALC_H * 0.5 + Math.sin(tt * 1.7) * (CALC_H * 0.34)
};
}
function applyPointer(px: number, py: number, dtS: number) {
for (const b of balls) {
const dx = b.x - px, dy = b.y - py;
const dist = Math.hypot(dx, dy) || 0.001;
if (dist < POINTER_REPEL_RADIUS) {
const force = (REPEL_FORCE * (1 - dist / POINTER_REPEL_RADIUS)) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
b.squash = Math.min(1, b.squash + (1 - dist / POINTER_REPEL_RADIUS) * 0.6);
}
}
}
function stepSim(dtMs: number, nowMs: number, px: number, py: number) {
const dtS = dtMs / 1000;
applyPointer(px, py, dtS);
for (const b of balls) {
const homeX = b.hx + Math.cos((nowMs / 1000) * DRIFT_SPEED + b.phase) * 10;
const homeY = b.hy + Math.sin((nowMs / 1000) * DRIFT_SPEED * 0.8 + b.phase) * 10;
const pull = 0.9;
b.vx += (homeX - b.x) * pull * dtS;
b.vy += (homeY - b.y) * pull * dtS;
b.vx -= b.vx * Math.min(1, DAMPING * dtS);
b.vy -= b.vy * Math.min(1, DAMPING * dtS);
b.x += b.vx * dtS;
b.y += b.vy * dtS;
if (b.x < b.r) { b.x = b.r; b.vx *= -0.5; }
if (b.x > CALC_W - b.r) { b.x = CALC_W - b.r; b.vx *= -0.5; }
if (b.y < b.r) { b.y = b.r; b.vy *= -0.5; }
if (b.y > CALC_H - b.r) { b.y = CALC_H - b.r; b.vy *= -0.5; }
b.squash = Math.max(0, b.squash - dtS * 2.6);
}
}
let cw = 480, ch = 300;
function draw(px: number, py: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
ctx!.beginPath();
ctx!.arc(px * sx, py * sy, POINTER_REPEL_RADIUS * ((sx + sy) / 2), 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255,0.18)";
ctx!.lineWidth = 1.5;
ctx!.stroke();
for (const b of balls) {
const ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
const speed = Math.hypot(b.vx, b.vy);
const ang = Math.atan2(b.vy, b.vx);
const stretch = 1 + Math.min(0.5, speed / 500) + b.squash * 0.2;
ctx!.save();
ctx!.translate(ppx, ppy);
ctx!.rotate(ang);
ctx!.scale(stretch, 1 / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
ctx!.beginPath();
ctx!.arc(px * sx, py * sy, 3, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(255,255,255,0.7)";
ctx!.fill();
}
function fit() {
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;
draw(pointer.x, pointer.y);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(clientX: number, clientY: number) {
const rect = canvas!.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx: number, cy: number) {
pointerActive = true;
root!.classList.remove("is-demo");
const p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
function onMouseMove(e: MouseEvent) { onRealPointer(e.clientX, e.clientY); }
function onTouchMove(e: TouchEvent) { if (e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY); }
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("touchmove", onTouchMove, { passive: true });
function onKeyDown(e: KeyboardEvent) {
const step = 14;
let moved = true;
if (e.key === "ArrowLeft") pointer.x -= step;
else if (e.key === "ArrowRight") pointer.x += step;
else if (e.key === "ArrowUp") pointer.y -= step;
else if (e.key === "ArrowDown") pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root!.classList.remove("is-demo");
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
}
root.addEventListener("keydown", onKeyDown);
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
const pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, elapsed, pointer.x, pointer.y);
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(pointer.x, pointer.y);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas!.removeEventListener("mousemove", onMouseMove);
canvas!.removeEventListener("touchmove", onTouchMove);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>포인터가 다가오면 거리에 반비례하는 힘으로 공들을 밀어내 흩어지게 하고, 멀어지면 다시 제자리 근처를 천천히 떠돈다</p>
</div>
</main>
);
}05 · 포인터가 다가오면 흩어지는 반발 공 — 측정 누적면적 21.441% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function applyPointer(px, py, dtS) {
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var dx = b.x - px, dy = b.y - py;
var dist = Math.hypot(dx, dy) || 0.001;
if (dist < POINTER_REPEL_RADIUS) {
var force = REPEL_FORCE * (1 - dist / POINTER_REPEL_RADIUS) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
b.squash = Math.min(1, b.squash + (1 - dist / POINTER_REPEL_RADIUS) * 0.6);
}
}
}
function stepSim(dtMs, nowMs, px, py) {
var dtS = dtMs / 1000;
applyPointer(px, py, dtS);
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var homeX = b.hx + Math.cos(nowMs / 1000 * DRIFT_SPEED + b.phase) * 10;
var homeY = b.hy + Math.sin(nowMs / 1000 * DRIFT_SPEED * 0.8 + b.phase) * 10;
var pull = 0.9;
b.vx += (homeX - b.x) * pull * dtS;
b.vy += (homeY - b.y) * pull * dtS;
b.vx -= b.vx * Math.min(1, DAMPING * dtS);
06포인터 쪽으로 끌려가 도는 인력 공
stepSim 은 포인터 방향 가속만 매 프레임 계산한다 — 거리에 반비례하는 힘으로 공을 포인터 쪽으로 끌어당기고 DAMPING 으로 속도를 조금씩 줄일 뿐, 옆으로 도는 힘을 여기서 새로 만들지는 않는다. 옆으로 도는 움직임은 initBalls 가 생성 시점에 한 번 준 접선 방향 초기속도가 감쇠 아래서도 남아 있는 결과라, 공은 포인터에 빨려 들어가지 않고 일정 거리에서 돈다. 23장 모두 움직였고 누적면적 20.4%·강도 평균 77.2였다.

<main class="stage">
<div class="bp is-demo" id="bpRoot" tabindex="0">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">포인터를 향해 당기는 인력에 처음부터 접선 방향 속도를 얹어 공이 빨려들지 않고 궤도를 그리며 돈다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-yellow);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-yellow;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffd23f;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 포인터 방향으로 거리에 반비례하는 인력(가속도 = ATTRACT_K / 거리)을 매 스텝
// 적용한다. 인력만 있으면 공이 포인터로 빨려들지만, 각 공에 반지름과 인력 세기로부터
// 계산한 원 궤도 속도(sqrt(ATTRACT_K))를 접선 방향 초기 속도로 얹어 두면 인력과
// 원심 방향 관성이 균형을 이뤄 공전 궤도가 만들어진다(오일러 적분이라 서서히
// 늘어나거나 줄어드는 궤도 — 완전한 원은 아니고 그래서 더 생동감 있다).
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var ATTRACT_K = 5200;
var DAMPING = 0.12;
var MIN_DIST = 14;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427061);
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb', '#ffcf7a'];
var orbitSpeed = Math.sqrt(ATTRACT_K);
var pointer = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
var pointerActive = false;
var balls = [];
function initBalls() {
balls = [];
for (var i = 0; i < 5; i += 1) {
var r0 = 46 + rand() * 74;
var th0 = rand() * Math.PI * 2;
var px = pointer.x + Math.cos(th0) * r0;
var py = pointer.y + Math.sin(th0) * r0;
var dirx = -Math.sin(th0), diry = Math.cos(th0);
var speed = orbitSpeed * (0.85 + rand() * 0.3);
balls.push({
x: px, y: py,
vx: dirx * speed, vy: diry * speed,
r: 8 + rand() * 6,
color: COLORS[i % COLORS.length],
squash: 0
});
}
}
function demoPointerPos(t) {
var tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 0.5) * (CALC_W * 0.14),
y: CALC_H * 0.5 + Math.sin(tt * 0.7) * (CALC_H * 0.14)
};
}
function stepSim(dtMs, px, py) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var dx = px - b.x, dy = py - b.y;
var dist = Math.max(MIN_DIST, Math.hypot(dx, dy));
var accel = ATTRACT_K / dist;
var ax = (dx / dist) * accel, ay = (dy / dist) * accel;
b.vx += ax * dtS;
b.vy += ay * dtS;
b.vx -= b.vx * DAMPING * dtS;
b.vy -= b.vy * DAMPING * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var speed = Math.hypot(b.vx, b.vy);
b.squash = Math.min(0.5, speed / 900);
}
}
function draw(px, py, nowMs) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(255,247,224,0.26)';
ctx.fillRect(0, 0, cw, ch);
ctx.beginPath();
ctx.arc(px * sx, py * sy, 4, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(23,20,26,0.55)';
ctx.fill();
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
var ang = Math.atan2(b.vy, b.vx);
var stretch = 1 + b.squash;
ctx.save();
ctx.translate(ppx, ppy);
ctx.rotate(ang);
ctx.scale(stretch, 1 / stretch);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
}
var cw = 480, ch = 300;
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;
ctx.fillStyle = '#fff7e0';
ctx.fillRect(0, 0, cw, ch);
draw(pointer.x, pointer.y, elapsed);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
initBalls();
function toCalc(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var nx = (clientX - rect.left) / rect.width;
var ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx, cy) {
pointerActive = true;
root.classList.remove('is-demo');
var p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) {
stepSim(16, pointer.x, pointer.y);
draw(pointer.x, pointer.y, elapsed);
}
}
canvas.addEventListener('mousemove', function (e) { onRealPointer(e.clientX, e.clientY); });
canvas.addEventListener('touchmove', function (e) {
if (e.touches && e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
root.addEventListener('keydown', function (e) {
var step = 14;
var moved = true;
if (e.key === 'ArrowLeft') pointer.x -= step;
else if (e.key === 'ArrowRight') pointer.x += step;
else if (e.key === 'ArrowUp') pointer.y -= step;
else if (e.key === 'ArrowDown') pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root.classList.remove('is-demo');
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) {
stepSim(16, pointer.x, pointer.y);
draw(pointer.x, pointer.y, elapsed);
}
}
});
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
var pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, pointer.x, pointer.y);
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y, elapsed);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(pointer.x, pointer.y, elapsed);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsMouseAttractOrbit.module.scss";
type Ball = { x: number; y: number; vx: number; vy: number; r: number; color: string; squash: number };
export default function BallPhysicsMouseAttractOrbit() {
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 CALC_W = 320, CALC_H = 200;
const ATTRACT_K = 5200;
const DAMPING = 0.12;
const MIN_DIST = 14;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427061);
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb", "#ffcf7a"];
const orbitSpeed = Math.sqrt(ATTRACT_K);
const pointer = { x: CALC_W * 0.5, y: CALC_H * 0.5 };
let pointerActive = false;
let balls: Ball[] = [];
function initBalls() {
balls = [];
for (let i = 0; i < 5; i += 1) {
const r0 = 46 + rand() * 74;
const th0 = rand() * Math.PI * 2;
const px = pointer.x + Math.cos(th0) * r0;
const py = pointer.y + Math.sin(th0) * r0;
const dirx = -Math.sin(th0), diry = Math.cos(th0);
const speed = orbitSpeed * (0.85 + rand() * 0.3);
balls.push({ x: px, y: py, vx: dirx * speed, vy: diry * speed, r: 8 + rand() * 6, color: COLORS[i % COLORS.length], squash: 0 });
}
}
initBalls();
function demoPointerPos(t: number) {
const tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 0.5) * (CALC_W * 0.14),
y: CALC_H * 0.5 + Math.sin(tt * 0.7) * (CALC_H * 0.14)
};
}
function stepSim(dtMs: number, px: number, py: number) {
const dtS = dtMs / 1000;
for (const b of balls) {
const dx = px - b.x, dy = py - b.y;
const dist = Math.max(MIN_DIST, Math.hypot(dx, dy));
const accel = ATTRACT_K / dist;
const ax = (dx / dist) * accel, ay = (dy / dist) * accel;
b.vx += ax * dtS;
b.vy += ay * dtS;
b.vx -= b.vx * DAMPING * dtS;
b.vy -= b.vy * DAMPING * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
const speed = Math.hypot(b.vx, b.vy);
b.squash = Math.min(0.5, speed / 900);
}
}
let cw = 480, ch = 300;
function draw(px: number, py: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(255,247,224,0.26)";
ctx!.fillRect(0, 0, cw, ch);
ctx!.beginPath();
ctx!.arc(px * sx, py * sy, 4, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(23,20,26,0.55)";
ctx!.fill();
for (const b of balls) {
const ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
const ang = Math.atan2(b.vy, b.vx);
const stretch = 1 + b.squash;
ctx!.save();
ctx!.translate(ppx, ppy);
ctx!.rotate(ang);
ctx!.scale(stretch, 1 / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
}
function fit() {
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;
ctx!.fillStyle = "#fff7e0";
ctx!.fillRect(0, 0, cw, ch);
draw(pointer.x, pointer.y);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(clientX: number, clientY: number) {
const rect = canvas!.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx: number, cy: number) {
pointerActive = true;
root!.classList.remove("is-demo");
const p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) { stepSim(16, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
function onMouseMove(e: MouseEvent) { onRealPointer(e.clientX, e.clientY); }
function onTouchMove(e: TouchEvent) { if (e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY); }
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("touchmove", onTouchMove, { passive: true });
function onKeyDown(e: KeyboardEvent) {
const step = 14;
let moved = true;
if (e.key === "ArrowLeft") pointer.x -= step;
else if (e.key === "ArrowRight") pointer.x += step;
else if (e.key === "ArrowUp") pointer.y -= step;
else if (e.key === "ArrowDown") pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root!.classList.remove("is-demo");
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) { stepSim(16, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
}
root.addEventListener("keydown", onKeyDown);
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
const pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, pointer.x, pointer.y);
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(pointer.x, pointer.y);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas!.removeEventListener("mousemove", onMouseMove);
canvas!.removeEventListener("touchmove", onTouchMove);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>포인터를 향해 당기는 인력에 처음부터 접선 방향 속도를 얹어 공이 빨려들지 않고 궤도를 그리며 돈다</p>
</div>
</main>
);
}06 · 포인터 쪽으로 끌려가 도는 인력 공 — 측정 누적면적 20.388% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, px, py) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var dx = px - b.x, dy = py - b.y;
var dist = Math.max(MIN_DIST, Math.hypot(dx, dy));
var accel = ATTRACT_K / dist;
var ax = (dx / dist) * accel, ay = (dy / dist) * accel;
b.vx += ax * dtS;
b.vy += ay * dtS;
b.vx -= b.vx * DAMPING * dtS;
b.vy -= b.vy * DAMPING * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var speed = Math.hypot(b.vx, b.vy);
b.squash = Math.min(0.5, speed / 900);
}
}
07드래그한 속도 그대로 날아가는 던지기 공
드래그하는 동안 recordSample 이 최근 SAMPLE_WINDOW_MS 안의 좌표만 남기고, 손을 떼면 releaseVelocity 가 그 구간의 이동량으로 속도를 계산해 throwBall 에 넘긴다. 방향키를 누르면 같은 throwBall 에 KEY_IMPULSE 만큼의 속도를 직접 더해 드래그 없이도 던질 수 있다. 23장 모두 움직였지만 AIR_DRAG 가 매 프레임 속도를 깎아 강도 평균은 59.8로 아홉 항목 중 가장 낮았고 누적면적은 16.2%였다.

<main class="stage">
<div class="bp is-demo" id="bpRoot" tabindex="0">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">공을 쥐고 움직인 최근 몇 프레임의 이동량으로 속도를 계산해, 놓는 순간 그 속도 그대로 던져지고 벽에 부딪히면 반사된다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-orange);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-orange;
touch-action: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ff4d1f;
touch-action: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 드래그 중 포인터 위치·시각을 최근 몇 개만 버퍼에 담아 두고, 놓는 순간 버퍼의
// 처음-마지막 샘플로 속도(이동거리/경과시간)를 계산해 그 속도로 공을 던진다(스냅샷
// 한 프레임 차분이 아니라 최근 구간 평균이라 손떨림에 덜 흔들린다). 던져진 공은
// 공기 저항으로 서서히 느려지며 벽에 부딪힐 때마다 WALL_BOUNCE 만큼 반사·감쇠한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var WALL_BOUNCE = 0.72;
var AIR_DRAG = 0.55;
var SAMPLE_WINDOW_MS = 140;
var KEY_IMPULSE = 130;
var THROW_IMPULSE = 260;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427071);
var ball = { x: CALC_W * 0.5, y: CALC_H * 0.5, vx: 90, vy: -60, r: 18, color: '#ffcf7a', squash: 0, heading: -0.9 };
var dragging = false;
var samples = [];
function recordSample(x, y, t) {
samples.push({ x: x, y: y, t: t });
while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
}
function releaseVelocity(nowT) {
if (samples.length < 2) return { vx: 0, vy: 0 };
var a = samples[0], b = samples[samples.length - 1];
var dt = Math.max(16, b.t - a.t) / 1000;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
}
function throwBall(vx, vy) {
ball.vx = vx; ball.vy = vy;
if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
if (dragging) return;
ball.vx -= ball.vx * AIR_DRAG * dtS;
ball.vy -= ball.vy * AIR_DRAG * dtS;
ball.x += ball.vx * dtS;
ball.y += ball.vy * dtS;
var hit = false;
if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.x + ball.r > CALC_W) { ball.x = CALC_W - ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (ball.y + ball.r > CALC_H) { ball.y = CALC_H - ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (hit) ball.squash = Math.min(1, Math.hypot(ball.vx, ball.vy) / 260 + 0.2);
ball.squash = Math.max(0, ball.squash - dtS * 3.4);
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(255,110,46,0.24)';
ctx.fillRect(0, 0, cw, ch);
ctx.strokeStyle = 'rgba(255,255,255,0.3)';
ctx.lineWidth = 2;
ctx.strokeRect(2, 2, cw - 4, ch - 4);
var px = ball.x * sx, py = ball.y * sy, pr = ball.r * ((sx + sy) / 2);
var speed = Math.hypot(ball.vx, ball.vy);
var stretch = dragging ? 1.08 : 1 + Math.min(0.4, speed / 600);
ctx.save();
ctx.translate(px, py);
ctx.rotate(ball.heading);
ctx.scale(stretch, (1 - ball.squash * 0.3) / stretch);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = ball.color;
ctx.fill();
ctx.restore();
if (dragging) {
ctx.beginPath();
ctx.arc(px, py, pr + 6, 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,0.6)';
ctx.lineWidth = 2;
ctx.stroke();
}
}
var cw = 480, ch = 300;
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;
draw();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function toCalc(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var nx = (clientX - rect.left) / rect.width;
var ny = (clientY - rect.top) / rect.height;
return { x: nx * CALC_W, y: ny * CALC_H };
}
function startDrag(cx, cy) {
root.classList.remove('is-demo');
var p = toCalc(cx, cy);
if (Math.hypot(p.x - ball.x, p.y - ball.y) > ball.r * 2.2) return;
dragging = true;
samples = [];
recordSample(p.x, p.y, performance.now());
}
function moveDrag(cx, cy) {
if (!dragging) return;
var p = toCalc(cx, cy);
ball.x = Math.max(ball.r, Math.min(CALC_W - ball.r, p.x));
ball.y = Math.max(ball.r, Math.min(CALC_H - ball.r, p.y));
recordSample(ball.x, ball.y, performance.now());
if (reduce) draw();
}
function endDrag() {
if (!dragging) return;
dragging = false;
var v = releaseVelocity(performance.now());
var mag = Math.hypot(v.vx, v.vy);
var maxSpeed = 420;
if (mag > maxSpeed) { v.vx = v.vx / mag * maxSpeed; v.vy = v.vy / mag * maxSpeed; }
throwBall(v.vx, v.vy);
if (reduce) { stepSim(16); draw(); }
}
canvas.addEventListener('mousedown', function (e) { startDrag(e.clientX, e.clientY); });
window.addEventListener('mousemove', function (e) { moveDrag(e.clientX, e.clientY); });
window.addEventListener('mouseup', endDrag);
canvas.addEventListener('touchstart', function (e) {
if (e.touches && e.touches[0]) startDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
canvas.addEventListener('touchmove', function (e) {
if (e.touches && e.touches[0]) moveDrag(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
canvas.addEventListener('touchend', endDrag);
root.addEventListener('keydown', function (e) {
var handled = true;
if (e.key === 'ArrowLeft') throwBall(ball.vx - KEY_IMPULSE, ball.vy);
else if (e.key === 'ArrowRight') throwBall(ball.vx + KEY_IMPULSE, ball.vy);
else if (e.key === 'ArrowUp') throwBall(ball.vx, ball.vy - KEY_IMPULSE);
else if (e.key === 'ArrowDown') throwBall(ball.vx, ball.vy + KEY_IMPULSE);
else if (e.key === ' ' || e.code === 'Space') {
var ang = ball.heading;
throwBall(Math.cos(ang) * THROW_IMPULSE, Math.sin(ang) * THROW_IMPULSE);
} else handled = false;
if (handled) {
e.preventDefault();
root.classList.remove('is-demo');
if (reduce) { stepSim(16); draw(); }
}
});
// 자동재생: 가상 드래그 경로를 샘플로 기록한 뒤 같은 releaseVelocity/throwBall 을
// 호출한다 — 실제 입력과 동일한 함수 경로다.
var demoState = 'idle';
var demoT0 = 0;
function demoDrive(nowMs) {
var cyc = nowMs % 1600;
if (cyc < 480) {
if (demoState !== 'drag') { demoState = 'drag'; dragging = true; samples = []; demoT0 = nowMs; }
var f = cyc / 480;
var sx0 = CALC_W * 0.28, sy0 = CALC_H * 0.7;
var ex0 = CALC_W * 0.62, ey0 = CALC_H * 0.32;
ball.x = sx0 + (ex0 - sx0) * f;
ball.y = sy0 + (ey0 - sy0) * f;
recordSample(ball.x, ball.y, nowMs);
} else if (demoState !== 'thrown') {
demoState = 'thrown';
dragging = false;
var v = releaseVelocity(nowMs);
var mag = Math.hypot(v.vx, v.vy) || 1;
var target = 300;
throwBall(v.vx / mag * target, v.vy / mag * target);
}
}
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
if (root.classList.contains('is-demo')) demoDrive(elapsed);
stepSim(FIXED_DT);
acc -= FIXED_DT;
}
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsDragThrowMomentum.module.scss";
type Sample = { x: number; y: number; t: number };
export default function BallPhysicsDragThrowMomentum() {
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 CALC_W = 320, CALC_H = 200;
const WALL_BOUNCE = 0.72;
const AIR_DRAG = 0.55;
const SAMPLE_WINDOW_MS = 140;
const KEY_IMPULSE = 130;
const THROW_IMPULSE = 260;
const ball = { x: CALC_W * 0.5, y: CALC_H * 0.5, vx: 90, vy: -60, r: 18, color: "#ffcf7a", squash: 0, heading: -0.9 };
let dragging = false;
let samples: Sample[] = [];
function recordSample(x: number, y: number, t: number) {
samples.push({ x, y, t });
while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
}
function releaseVelocity() {
if (samples.length < 2) return { vx: 0, vy: 0 };
const a = samples[0], b = samples[samples.length - 1];
const dt = Math.max(16, b.t - a.t) / 1000;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
}
function throwBall(vx: number, vy: number) {
ball.vx = vx; ball.vy = vy;
if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
}
function stepSim(dtMs: number) {
const dtS = dtMs / 1000;
if (dragging) return;
ball.vx -= ball.vx * AIR_DRAG * dtS;
ball.vy -= ball.vy * AIR_DRAG * dtS;
ball.x += ball.vx * dtS;
ball.y += ball.vy * dtS;
let hit = false;
if (ball.x - ball.r < 0) { ball.x = ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.x + ball.r > CALC_W) { ball.x = CALC_W - ball.r; ball.vx = -ball.vx * WALL_BOUNCE; hit = true; }
if (ball.y - ball.r < 0) { ball.y = ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (ball.y + ball.r > CALC_H) { ball.y = CALC_H - ball.r; ball.vy = -ball.vy * WALL_BOUNCE; hit = true; }
if (hit) ball.squash = Math.min(1, Math.hypot(ball.vx, ball.vy) / 260 + 0.2);
ball.squash = Math.max(0, ball.squash - dtS * 3.4);
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(255,110,46,0.24)";
ctx!.fillRect(0, 0, cw, ch);
ctx!.strokeStyle = "rgba(255,255,255,0.3)";
ctx!.lineWidth = 2;
ctx!.strokeRect(2, 2, cw - 4, ch - 4);
const px = ball.x * sx, py = ball.y * sy, pr = ball.r * ((sx + sy) / 2);
const speed = Math.hypot(ball.vx, ball.vy);
const stretch = dragging ? 1.08 : 1 + Math.min(0.4, speed / 600);
ctx!.save();
ctx!.translate(px, py);
ctx!.rotate(ball.heading);
ctx!.scale(stretch, (1 - ball.squash * 0.3) / stretch);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = ball.color;
ctx!.fill();
ctx!.restore();
if (dragging) {
ctx!.beginPath();
ctx!.arc(px, py, pr + 6, 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255,0.6)";
ctx!.lineWidth = 2;
ctx!.stroke();
}
}
function fit() {
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;
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(clientX: number, clientY: number) {
const rect = canvas!.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { x: nx * CALC_W, y: ny * CALC_H };
}
function startDrag(cx: number, cy: number) {
root!.classList.remove("is-demo");
const p = toCalc(cx, cy);
if (Math.hypot(p.x - ball.x, p.y - ball.y) > ball.r * 2.2) return;
dragging = true;
samples = [];
recordSample(p.x, p.y, performance.now());
}
function moveDrag(cx: number, cy: number) {
if (!dragging) return;
const p = toCalc(cx, cy);
ball.x = Math.max(ball.r, Math.min(CALC_W - ball.r, p.x));
ball.y = Math.max(ball.r, Math.min(CALC_H - ball.r, p.y));
recordSample(ball.x, ball.y, performance.now());
if (reduce) draw();
}
function endDrag() {
if (!dragging) return;
dragging = false;
const v = releaseVelocity();
const mag = Math.hypot(v.vx, v.vy);
const maxSpeed = 420;
if (mag > maxSpeed) { v.vx = (v.vx / mag) * maxSpeed; v.vy = (v.vy / mag) * maxSpeed; }
throwBall(v.vx, v.vy);
if (reduce) { stepSim(16); draw(); }
}
function onMouseDown(e: MouseEvent) { startDrag(e.clientX, e.clientY); }
function onMouseMove(e: MouseEvent) { moveDrag(e.clientX, e.clientY); }
function onMouseUp() { endDrag(); }
function onTouchStart(e: TouchEvent) { if (e.touches[0]) startDrag(e.touches[0].clientX, e.touches[0].clientY); }
function onTouchMove(e: TouchEvent) { if (e.touches[0]) moveDrag(e.touches[0].clientX, e.touches[0].clientY); }
function onTouchEnd() { endDrag(); }
canvas.addEventListener("mousedown", onMouseDown);
window.addEventListener("mousemove", onMouseMove);
window.addEventListener("mouseup", onMouseUp);
canvas.addEventListener("touchstart", onTouchStart, { passive: true });
canvas.addEventListener("touchmove", onTouchMove, { passive: true });
canvas.addEventListener("touchend", onTouchEnd);
function onKeyDown(e: KeyboardEvent) {
let handled = true;
if (e.key === "ArrowLeft") throwBall(ball.vx - KEY_IMPULSE, ball.vy);
else if (e.key === "ArrowRight") throwBall(ball.vx + KEY_IMPULSE, ball.vy);
else if (e.key === "ArrowUp") throwBall(ball.vx, ball.vy - KEY_IMPULSE);
else if (e.key === "ArrowDown") throwBall(ball.vx, ball.vy + KEY_IMPULSE);
else if (e.key === " " || e.code === "Space") {
const ang = ball.heading;
throwBall(Math.cos(ang) * THROW_IMPULSE, Math.sin(ang) * THROW_IMPULSE);
} else handled = false;
if (handled) {
e.preventDefault();
root!.classList.remove("is-demo");
if (reduce) { stepSim(16); draw(); }
}
}
root.addEventListener("keydown", onKeyDown);
let demoState = "idle";
function demoDrive(nowMs: number) {
const cyc = nowMs % 1600;
if (cyc < 480) {
if (demoState !== "drag") { demoState = "drag"; dragging = true; samples = []; }
const f = cyc / 480;
const sx0 = CALC_W * 0.28, sy0 = CALC_H * 0.7;
const ex0 = CALC_W * 0.62, ey0 = CALC_H * 0.32;
ball.x = sx0 + (ex0 - sx0) * f;
ball.y = sy0 + (ey0 - sy0) * f;
recordSample(ball.x, ball.y, nowMs);
} else if (demoState !== "thrown") {
demoState = "thrown";
dragging = false;
const v = releaseVelocity();
const mag = Math.hypot(v.vx, v.vy) || 1;
const target = 300;
throwBall((v.vx / mag) * target, (v.vy / mag) * target);
}
}
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
if (root!.classList.contains("is-demo")) demoDrive(elapsed);
stepSim(FIXED_DT);
acc -= FIXED_DT;
}
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas!.removeEventListener("mousedown", onMouseDown);
window.removeEventListener("mousemove", onMouseMove);
window.removeEventListener("mouseup", onMouseUp);
canvas!.removeEventListener("touchstart", onTouchStart);
canvas!.removeEventListener("touchmove", onTouchMove);
canvas!.removeEventListener("touchend", onTouchEnd);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>공을 쥐고 움직인 최근 몇 프레임의 이동량으로 속도를 계산해, 놓는 순간 그 속도 그대로 던져지고 벽에 부딪히면 반사된다</p>
</div>
</main>
);
}07 · 드래그한 속도 그대로 날아가는 던지기 공 — 측정 누적면적 16.193% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function recordSample(x, y, t) {
samples.push({ x: x, y: y, t: t });
while (samples.length && t - samples[0].t > SAMPLE_WINDOW_MS) samples.shift();
}
function releaseVelocity(nowT) {
if (samples.length < 2) return { vx: 0, vy: 0 };
var a = samples[0], b = samples[samples.length - 1];
var dt = Math.max(16, b.t - a.t) / 1000;
return { vx: (b.x - a.x) / dt, vy: (b.y - a.y) / dt };
}
function throwBall(vx, vy) {
ball.vx = vx; ball.vy = vy;
if (Math.hypot(vx, vy) > 1) ball.heading = Math.atan2(vy, vx);
}
function stepSim(dtMs) {
var dtS = dtMs / 1000;
if (dragging) return;
08위에서 쏟아져 바닥에 쌓이는 낙하 공
SPAWN_INTERVAL_MS 마다 새 공을 하나씩 떨어뜨리다가 MAX_BALLS 개에 닿으면 멈추고, 바닥에 쌓인 공들은 SEPARATE_ITERS 번 반복하는 겹침 보정으로 서로 밀어내며 자리를 잡는다. 이 보정은 공 하나마다 나머지 전부와 겹침을 확인하는 방식이라 공 수가 늘수록 계산량이 늘어난다. 23장 모두 움직였고 누적면적 33.3%·강도 평균 98.9였다.

<main class="stage">
<div class="bp is-demo" id="bpRoot">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">일정 간격으로 공을 위에서 계속 떨어뜨리고, 겹친 두 공은 서로 밀어내는 계산으로 떼어놓아 바닥에 쌓인다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-paper;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #ffffff;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
top: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: SPAWN_INTERVAL_MS 마다 새 공을 위에서 떨어뜨린다. 중력으로 낙하하다 바닥이나
// 다른 공과 겹치면 두 원의 중심 거리와 반지름 합을 비교해 겹친 만큼을 질량 비로
// 나눠 밀어내는 위치 보정을 매 프레임 반복한다(단순 충돌 한 번이 아니라 여러 쌍을
// 계속 풀어 쌓임을 만든다). 개수가 MAX_BALLS 에 닿으면 잠시 멈췄다가 전부 지우고
// 다시 쌓기 시작한다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var GRAVITY = 640;
var FLOOR_RESTITUTION = 0.28;
var SPAWN_INTERVAL_MS = 190;
var MAX_BALLS = 13;
var HOLD_MS = 480;
var SEPARATE_ITERS = 4;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427081);
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb', '#ffcf7a', '#8fd7ff'];
var balls = [];
var lastSpawn = -SPAWN_INTERVAL_MS;
var holdSince = null;
function spawn(nowMs) {
var r = 9 + rand() * 8;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: -r - rand() * 20,
vy: 40,
vx: (rand() - 0.5) * 20,
r: r,
color: COLORS[balls.length % COLORS.length],
squash: 0
});
}
function resetAll() {
balls = [];
lastSpawn = -SPAWN_INTERVAL_MS;
holdSince = null;
}
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
if (balls.length < MAX_BALLS && holdSince === null && nowMs - lastSpawn >= SPAWN_INTERVAL_MS) {
spawn(nowMs);
lastSpawn = nowMs;
}
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.vy += GRAVITY * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
b.vx -= b.vx * 0.6 * dtS;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * 0.5; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * 0.5; }
var floor = CALC_H - b.r;
if (b.y > floor) {
b.y = floor;
if (Math.abs(b.vy) > 30) b.squash = Math.min(0.8, Math.abs(b.vy) / 320);
b.vy = -b.vy * FLOOR_RESTITUTION;
if (Math.abs(b.vy) < 22) b.vy = 0;
}
b.squash = Math.max(0, b.squash - dtS * 3);
}
for (var iter = 0; iter < SEPARATE_ITERS; iter += 1) {
for (var a = 0; a < balls.length; a += 1) {
for (var c = a + 1; c < balls.length; c += 1) {
var ba = balls[a], bc = balls[c];
var dx = bc.x - ba.x, dy = bc.y - ba.y;
var dist = Math.hypot(dx, dy) || 0.001;
var minDist = ba.r + bc.r;
if (dist < minDist) {
var overlap = (minDist - dist) / 2;
var nx = dx / dist, ny = dy / dist;
ba.x -= nx * overlap; ba.y -= ny * overlap;
bc.x += nx * overlap; bc.y += ny * overlap;
if (ba.y + ba.r > CALC_H) ba.y = CALC_H - ba.r;
if (bc.y + bc.r > CALC_H) bc.y = CALC_H - bc.r;
}
}
}
}
if (balls.length >= MAX_BALLS) {
if (holdSince === null) holdSince = nowMs;
else if (nowMs - holdSince > HOLD_MS) { resetAll(); }
}
}
function draw() {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.clearRect(0, 0, cw, ch);
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
var px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
var sqY = 1 - b.squash * 0.4, sqX = 1 + b.squash * 0.55;
ctx.save();
ctx.translate(px, py + pr * b.squash * 0.4);
ctx.scale(sqX, sqY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
ctx.fillStyle = 'rgba(23,20,26,0.5)';
ctx.font = '11px sans-serif';
ctx.fillText(balls.length + ' / ' + MAX_BALLS, 8, ch - 8);
}
var cw = 480, ch = 300;
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;
draw();
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
resetAll();
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
stepSim(FIXED_DT, elapsed);
acc -= FIXED_DT;
}
draw();
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw();
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsSpawnCascadeStack.module.scss";
type Ball = { x: number; y: number; vy: number; vx: number; r: number; color: string; squash: number };
export default function BallPhysicsSpawnCascadeStack() {
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 CALC_W = 320, CALC_H = 200;
const GRAVITY = 640;
const FLOOR_RESTITUTION = 0.28;
const SPAWN_INTERVAL_MS = 190;
const MAX_BALLS = 13;
const HOLD_MS = 480;
const SEPARATE_ITERS = 4;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427081);
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb", "#ffcf7a", "#8fd7ff"];
let balls: Ball[] = [];
let lastSpawn = -SPAWN_INTERVAL_MS;
let holdSince: number | null = null;
function spawn() {
const r = 9 + rand() * 8;
balls.push({
x: r + rand() * (CALC_W - r * 2),
y: -r - rand() * 20,
vy: 40,
vx: (rand() - 0.5) * 20,
r, color: COLORS[balls.length % COLORS.length], squash: 0
});
}
function resetAll() {
balls = [];
lastSpawn = -SPAWN_INTERVAL_MS;
holdSince = null;
}
resetAll();
function stepSim(dtMs: number, nowMs: number) {
const dtS = dtMs / 1000;
if (balls.length < MAX_BALLS && holdSince === null && nowMs - lastSpawn >= SPAWN_INTERVAL_MS) {
spawn();
lastSpawn = nowMs;
}
for (const b of balls) {
b.vy += GRAVITY * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
b.vx -= b.vx * 0.6 * dtS;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * 0.5; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * 0.5; }
const floor = CALC_H - b.r;
if (b.y > floor) {
b.y = floor;
if (Math.abs(b.vy) > 30) b.squash = Math.min(0.8, Math.abs(b.vy) / 320);
b.vy = -b.vy * FLOOR_RESTITUTION;
if (Math.abs(b.vy) < 22) b.vy = 0;
}
b.squash = Math.max(0, b.squash - dtS * 3);
}
for (let iter = 0; iter < SEPARATE_ITERS; iter += 1) {
for (let a = 0; a < balls.length; a += 1) {
for (let c = a + 1; c < balls.length; c += 1) {
const ba = balls[a], bc = balls[c];
const dx = bc.x - ba.x, dy = bc.y - ba.y;
const dist = Math.hypot(dx, dy) || 0.001;
const minDist = ba.r + bc.r;
if (dist < minDist) {
const overlap = (minDist - dist) / 2;
const nx = dx / dist, ny = dy / dist;
ba.x -= nx * overlap; ba.y -= ny * overlap;
bc.x += nx * overlap; bc.y += ny * overlap;
if (ba.y + ba.r > CALC_H) ba.y = CALC_H - ba.r;
if (bc.y + bc.r > CALC_H) bc.y = CALC_H - bc.r;
}
}
}
}
if (balls.length >= MAX_BALLS) {
if (holdSince === null) holdSince = nowMs;
else if (nowMs - holdSince > HOLD_MS) resetAll();
}
}
let cw = 480, ch = 300;
function draw() {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.clearRect(0, 0, cw, ch);
for (const b of balls) {
const px = b.x * sx, py = b.y * sy, pr = b.r * ((sx + sy) / 2);
const sqY = 1 - b.squash * 0.4, sqX = 1 + b.squash * 0.55;
ctx!.save();
ctx!.translate(px, py + pr * b.squash * 0.4);
ctx!.scale(sqX, sqY);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
ctx!.fillStyle = "rgba(23,20,26,0.5)";
ctx!.font = "11px sans-serif";
ctx!.fillText(balls.length + " / " + MAX_BALLS, 8, ch - 8);
}
function fit() {
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;
draw();
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) { elapsed += FIXED_DT; stepSim(FIXED_DT, elapsed); acc -= FIXED_DT; }
draw();
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw();
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>일정 간격으로 공을 위에서 계속 떨어뜨리고, 겹친 두 공은 서로 밀어내는 계산으로 떼어놓아 바닥에 쌓인다</p>
</div>
</main>
);
}08 · 위에서 쏟아져 바닥에 쌓이는 낙하 공 — 측정 누적면적 33.263% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs) {
var dtS = dtMs / 1000;
if (balls.length < MAX_BALLS && holdSince === null && nowMs - lastSpawn >= SPAWN_INTERVAL_MS) {
spawn(nowMs);
lastSpawn = nowMs;
}
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.vy += GRAVITY * dtS;
b.x += b.vx * dtS;
b.y += b.vy * dtS;
b.vx -= b.vx * 0.6 * dtS;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * 0.5; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * 0.5; }
var floor = CALC_H - b.r;
if (b.y > floor) {
b.y = floor;
if (Math.abs(b.vy) > 30) b.squash = Math.min(0.8, Math.abs(b.vy) / 320);
b.vy = -b.vy * FLOOR_RESTITUTION;
if (Math.abs(b.vy) < 22) b.vy = 0;
}
b.squash = Math.max(0, b.squash - dtS * 3);
}
for (var iter = 0; iter < SEPARATE_ITERS; iter += 1) {
09여러 공 물리를 한 화면에 합친 히어로 장면
다섯 공이 HERO_GRAVITY 로 떨어지면서 동시에 포인터가 HERO_POINTER_REPEL_RADIUS 안에 들어오면 HERO_POINTER_FORCE 로 밀려나고, 네 벽 중 하나에 닿으면 HERO_WALL_RESTITUTION 만큼만 속도를 남긴다. 중력·포인터 반발·벽 반발 세 계산이 한 stepSim 안에서 같은 공에 동시에 적용된다. 23장 모두 움직였고 누적면적은 39.7%, 한 장에서 가장 넓게 찍힌 면적은 16.7%로 아홉 항목 중 가장 컸다.

<main class="stage">
<div class="bp is-demo" id="bpRoot" tabindex="0">
<canvas class="bp__canvas" id="bpCanvas" width="480" height="300"></canvas>
<p class="bp__caption">중력·바닥 반발·공끼리 탄성 충돌·포인터 밀어내기를 한 장면에 합쳐, 공들이 떨어지고 튕기고 부딪히고 흩어진다</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-ink);
.bp {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: $stage-ink;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: $sp-3;
bottom: $sp-3;
max-width: 80%;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 255, 255, .85);
color: $subject-ink;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption { font-size: 10px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
.bp { 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);
}
.bp {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: #17141a;
cursor: none;
}
.bp__canvas {
display: block;
width: 100%;
height: 100%;
}
.bp__caption {
position: absolute;
left: 12px;
bottom: 12px;
max-width: 80%;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.85);
color: #17141a;
font-weight: 500;
font-size: 12px;
line-height: 1.4;
pointer-events: none;
}
@media (max-width: 340px) {
.bp__caption {
font-size: 10px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.bp {
cursor: default;
}
}(function () {
// 기법: 앞선 세 항목의 계산을 한 장면에 합성한다 — ①매 스텝 HERO_GRAVITY 로 속도
// 적분(항목 02) ②포인터 반경 안이면 거리 반비례 힘으로 밀어냄(항목 05) ③위치 적분
// 뒤 벽·바닥에서 HERO_WALL_RESTITUTION 으로 반사, 공끼리는 질량비 임펄스로 탄성
// 충돌(항목 01) 한다. 상수 이름을 HERO_ 접두사로 통일해 세 계산이 한 루프 안에서
// 섞여도 출처를 구분할 수 있게 했다.
var root = document.getElementById('bpRoot');
var canvas = document.getElementById('bpCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var CALC_W = 320, CALC_H = 200;
var HERO_GRAVITY = 620;
var HERO_RESTITUTION = 0.78;
var HERO_WALL_RESTITUTION = 0.6;
var HERO_POINTER_REPEL_RADIUS = 78;
var HERO_POINTER_FORCE = 3600;
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var rand = seedRand(427091);
var COLORS = ['#6ec8ff', '#4cd4a6', '#b9a0ff', '#ff9ebb', '#ffcf7a'];
var RADII = [12, 15, 18, 13, 16];
var balls = [];
function initBalls() {
balls = [];
for (var i = 0; i < RADII.length; i += 1) {
var r = RADII[i];
balls.push({
x: 40 + i * ((CALC_W - 80) / (RADII.length - 1)),
y: 20 + rand() * 40,
vx: (rand() - 0.5) * 60,
vy: 0,
r: r, mass: r,
color: COLORS[i],
squash: 0
});
}
}
initBalls();
var pointer = { x: CALC_W * 0.5, y: CALC_H * 0.35 };
var pointerActive = false;
var impacts = [];
function demoPointerPos(t) {
var tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 0.9) * (CALC_W * 0.32),
y: CALC_H * 0.38 + Math.sin(tt * 1.3) * (CALC_H * 0.2)
};
}
function resolvePair(a, b, nowMs) {
var dx = b.x - a.x, dy = b.y - a.y;
var dist = Math.hypot(dx, dy);
var minDist = a.r + b.r;
if (dist === 0 || dist >= minDist) return;
var nx = dx / dist, ny = dy / dist;
var overlap = minDist - dist;
var totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
var rvx = b.vx - a.vx, rvy = b.vy - a.vy;
var velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal > 0) return;
var j = -(1 + HERO_RESTITUTION) * velAlongNormal / (1 / a.mass + 1 / b.mass);
var ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
var strength = Math.min(1, Math.abs(velAlongNormal) / 300);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: nowMs, strength: strength });
}
function stepSim(dtMs, nowMs, px, py) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.vy += HERO_GRAVITY * dtS;
var dx = b.x - px, dy = b.y - py;
var dist = Math.hypot(dx, dy) || 0.001;
if (dist < HERO_POINTER_REPEL_RADIUS) {
var force = HERO_POINTER_FORCE * (1 - dist / HERO_POINTER_REPEL_RADIUS) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
}
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * HERO_WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) {
b.y = CALC_H - b.r;
b.vy = -b.vy * HERO_WALL_RESTITUTION;
if (Math.abs(b.vy) < 24) b.vy = 0;
hit = true;
}
if (hit) b.squash = Math.max(b.squash, Math.min(0.75, Math.hypot(b.vx, b.vy) / 300));
b.squash = Math.max(0, b.squash - dtS * 3.2);
}
for (var a = 0; a < balls.length; a += 1) {
for (var c = a + 1; c < balls.length; c += 1) resolvePair(balls[a], balls[c], nowMs);
}
var live = [];
for (var k = 0; k < impacts.length; k += 1) { if (nowMs - impacts[k].born < 240) live.push(impacts[k]); }
impacts = live;
}
function draw(px, py) {
var sx = cw / CALC_W, sy = ch / CALC_H;
ctx.fillStyle = 'rgba(23,20,26,0.22)';
ctx.fillRect(0, 0, cw, ch);
for (var i = 0; i < impacts.length; i += 1) {
var im = impacts[i];
ctx.beginPath();
ctx.arc(im.x * sx, im.y * sy, (6 + im.strength * 14) * ((sx + sy) / 2), 0, Math.PI * 2);
ctx.strokeStyle = 'rgba(255,255,255,' + (im.strength * 0.4) + ')';
ctx.lineWidth = 2;
ctx.stroke();
}
for (var j = 0; j < balls.length; j += 1) {
var b = balls[j];
var ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
var shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 110);
ctx.beginPath();
ctx.ellipse(ppx, (CALC_H - 3) * sy, pr * (0.9 + shf * 0.3), pr * 0.22, 0, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(0,0,0,' + (0.1 + shf * 0.14) + ')';
ctx.fill();
var sqX = 1 + b.squash * 0.45, sqY = 1 - b.squash * 0.32;
ctx.save();
ctx.translate(ppx, ppy);
ctx.scale(sqX, sqY);
ctx.beginPath();
ctx.arc(0, 0, pr, 0, Math.PI * 2);
ctx.fillStyle = b.color;
ctx.fill();
ctx.restore();
}
ctx.beginPath();
ctx.arc(px * sx, py * sy, 3, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.65)';
ctx.fill();
}
var cw = 480, ch = 300;
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;
draw(pointer.x, pointer.y);
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var elapsed = 0;
if (reduce) root.classList.remove('is-demo');
function toCalc(clientX, clientY) {
var rect = canvas.getBoundingClientRect();
var nx = (clientX - rect.left) / rect.width;
var ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx, cy) {
pointerActive = true;
root.classList.remove('is-demo');
var p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
canvas.addEventListener('mousemove', function (e) { onRealPointer(e.clientX, e.clientY); });
canvas.addEventListener('touchmove', function (e) {
if (e.touches && e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY);
}, { passive: true });
root.addEventListener('keydown', function (e) {
var step = 14;
var moved = true;
if (e.key === 'ArrowLeft') pointer.x -= step;
else if (e.key === 'ArrowRight') pointer.x += step;
else if (e.key === 'ArrowUp') pointer.y -= step;
else if (e.key === 'ArrowDown') pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root.classList.remove('is-demo');
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
});
var lastTs = null;
var acc = 0;
var FIXED_DT = 16;
var cycleStart = 0;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
var pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, elapsed, pointer.x, pointer.y);
if (elapsed - cycleStart > 2600) { cycleStart = elapsed; initBalls(); }
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y);
}
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(pointer.x, pointer.y);
requestAnimationFrame(tick);
})();import { useEffect, useRef } from "react";
import styles from "./BallPhysicsHeroScene.module.scss";
type Ball = { x: number; y: number; vx: number; vy: number; r: number; mass: number; color: string; squash: number };
type Impact = { x: number; y: number; born: number; strength: number };
export default function BallPhysicsHeroScene() {
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 CALC_W = 320, CALC_H = 200;
const HERO_GRAVITY = 620;
const HERO_RESTITUTION = 0.78;
const HERO_WALL_RESTITUTION = 0.6;
const HERO_POINTER_REPEL_RADIUS = 78;
const HERO_POINTER_FORCE = 3600;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
const rand = seedRand(427091);
const COLORS = ["#6ec8ff", "#4cd4a6", "#b9a0ff", "#ff9ebb", "#ffcf7a"];
const RADII = [12, 15, 18, 13, 16];
let balls: Ball[] = [];
function initBalls() {
balls = RADII.map((r, i) => ({
x: 40 + i * ((CALC_W - 80) / (RADII.length - 1)),
y: 20 + rand() * 40,
vx: (rand() - 0.5) * 60,
vy: 0,
r, mass: r, color: COLORS[i], squash: 0
}));
}
initBalls();
const pointer = { x: CALC_W * 0.5, y: CALC_H * 0.35 };
let pointerActive = false;
let impacts: Impact[] = [];
function demoPointerPos(t: number) {
const tt = t / 1000;
return {
x: CALC_W * 0.5 + Math.cos(tt * 0.9) * (CALC_W * 0.32),
y: CALC_H * 0.38 + Math.sin(tt * 1.3) * (CALC_H * 0.2)
};
}
function resolvePair(a: Ball, b: Ball, nowMs: number) {
const dx = b.x - a.x, dy = b.y - a.y;
const dist = Math.hypot(dx, dy);
const minDist = a.r + b.r;
if (dist === 0 || dist >= minDist) return;
const nx = dx / dist, ny = dy / dist;
const overlap = minDist - dist;
const totalMass = a.mass + b.mass;
a.x -= nx * overlap * (b.mass / totalMass);
a.y -= ny * overlap * (b.mass / totalMass);
b.x += nx * overlap * (a.mass / totalMass);
b.y += ny * overlap * (a.mass / totalMass);
const rvx = b.vx - a.vx, rvy = b.vy - a.vy;
const velAlongNormal = rvx * nx + rvy * ny;
if (velAlongNormal > 0) return;
const j = -(1 + HERO_RESTITUTION) * velAlongNormal / (1 / a.mass + 1 / b.mass);
const ix = j * nx, iy = j * ny;
a.vx -= ix / a.mass; a.vy -= iy / a.mass;
b.vx += ix / b.mass; b.vy += iy / b.mass;
const strength = Math.min(1, Math.abs(velAlongNormal) / 300);
a.squash = Math.max(a.squash, strength);
b.squash = Math.max(b.squash, strength);
impacts.push({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, born: nowMs, strength });
}
function stepSim(dtMs: number, nowMs: number, px: number, py: number) {
const dtS = dtMs / 1000;
for (const b of balls) {
b.vy += HERO_GRAVITY * dtS;
const dx = b.x - px, dy = b.y - py;
const dist = Math.hypot(dx, dy) || 0.001;
if (dist < HERO_POINTER_REPEL_RADIUS) {
const force = (HERO_POINTER_FORCE * (1 - dist / HERO_POINTER_REPEL_RADIUS)) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
}
b.x += b.vx * dtS;
b.y += b.vy * dtS;
let hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * HERO_WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) {
b.y = CALC_H - b.r;
b.vy = -b.vy * HERO_WALL_RESTITUTION;
if (Math.abs(b.vy) < 24) b.vy = 0;
hit = true;
}
if (hit) b.squash = Math.max(b.squash, Math.min(0.75, Math.hypot(b.vx, b.vy) / 300));
b.squash = Math.max(0, b.squash - dtS * 3.2);
}
for (let a = 0; a < balls.length; a += 1) {
for (let c = a + 1; c < balls.length; c += 1) resolvePair(balls[a], balls[c], nowMs);
}
impacts = impacts.filter((im) => nowMs - im.born < 240);
}
let cw = 480, ch = 300;
function draw(px: number, py: number) {
const sx = cw / CALC_W, sy = ch / CALC_H;
ctx!.fillStyle = "rgba(23,20,26,0.22)";
ctx!.fillRect(0, 0, cw, ch);
for (const im of impacts) {
ctx!.beginPath();
ctx!.arc(im.x * sx, im.y * sy, (6 + im.strength * 14) * ((sx + sy) / 2), 0, Math.PI * 2);
ctx!.strokeStyle = "rgba(255,255,255," + (im.strength * 0.4) + ")";
ctx!.lineWidth = 2;
ctx!.stroke();
}
for (const b of balls) {
const ppx = b.x * sx, ppy = b.y * sy, pr = b.r * ((sx + sy) / 2);
const shf = Math.max(0, 1 - (CALC_H - b.r - b.y) / 110);
ctx!.beginPath();
ctx!.ellipse(ppx, (CALC_H - 3) * sy, pr * (0.9 + shf * 0.3), pr * 0.22, 0, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(0,0,0," + (0.1 + shf * 0.14) + ")";
ctx!.fill();
const sqX = 1 + b.squash * 0.45, sqY = 1 - b.squash * 0.32;
ctx!.save();
ctx!.translate(ppx, ppy);
ctx!.scale(sqX, sqY);
ctx!.beginPath();
ctx!.arc(0, 0, pr, 0, Math.PI * 2);
ctx!.fillStyle = b.color;
ctx!.fill();
ctx!.restore();
}
ctx!.beginPath();
ctx!.arc(px * sx, py * sy, 3, 0, Math.PI * 2);
ctx!.fillStyle = "rgba(255,255,255,0.65)";
ctx!.fill();
}
function fit() {
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;
draw(pointer.x, pointer.y);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let elapsed = 0;
if (reduce) root.classList.remove("is-demo");
function toCalc(clientX: number, clientY: number) {
const rect = canvas!.getBoundingClientRect();
const nx = (clientX - rect.left) / rect.width;
const ny = (clientY - rect.top) / rect.height;
return { x: Math.max(0, Math.min(CALC_W, nx * CALC_W)), y: Math.max(0, Math.min(CALC_H, ny * CALC_H)) };
}
function onRealPointer(cx: number, cy: number) {
pointerActive = true;
root!.classList.remove("is-demo");
const p = toCalc(cx, cy);
pointer.x = p.x; pointer.y = p.y;
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
function onMouseMove(e: MouseEvent) { onRealPointer(e.clientX, e.clientY); }
function onTouchMove(e: TouchEvent) { if (e.touches[0]) onRealPointer(e.touches[0].clientX, e.touches[0].clientY); }
canvas.addEventListener("mousemove", onMouseMove);
canvas.addEventListener("touchmove", onTouchMove, { passive: true });
function onKeyDown(e: KeyboardEvent) {
const step = 14;
let moved = true;
if (e.key === "ArrowLeft") pointer.x -= step;
else if (e.key === "ArrowRight") pointer.x += step;
else if (e.key === "ArrowUp") pointer.y -= step;
else if (e.key === "ArrowDown") pointer.y += step;
else moved = false;
if (moved) {
e.preventDefault();
pointerActive = true;
root!.classList.remove("is-demo");
pointer.x = Math.max(0, Math.min(CALC_W, pointer.x));
pointer.y = Math.max(0, Math.min(CALC_H, pointer.y));
if (reduce) { stepSim(16, elapsed, pointer.x, pointer.y); draw(pointer.x, pointer.y); }
}
}
root.addEventListener("keydown", onKeyDown);
let lastTs: number | null = null;
let acc = 0;
const FIXED_DT = 16;
let cycleStart = 0;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const frameDt = Math.min(ts - lastTs, 48);
lastTs = ts;
if (!reduce) {
acc += frameDt;
while (acc >= FIXED_DT) {
elapsed += FIXED_DT;
const pp = pointerActive ? pointer : demoPointerPos(elapsed);
if (!pointerActive) { pointer.x = pp.x; pointer.y = pp.y; }
stepSim(FIXED_DT, elapsed, pointer.x, pointer.y);
if (elapsed - cycleStart > 2600) { cycleStart = elapsed; initBalls(); }
acc -= FIXED_DT;
}
draw(pointer.x, pointer.y);
}
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(pointer.x, pointer.y);
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", fit);
canvas!.removeEventListener("mousemove", onMouseMove);
canvas!.removeEventListener("touchmove", onTouchMove);
root!.removeEventListener("keydown", onKeyDown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.bp} is-demo`} ref={rootRef} tabIndex={0}>
<canvas className={styles.bp__canvas} ref={canvasRef} width={480} height={300} />
<p className={styles.bp__caption}>중력·바닥 반발·공끼리 탄성 충돌·포인터 밀어내기를 한 장면에 합쳐, 공들이 떨어지고 튕기고 부딪히고 흩어진다</p>
</div>
</main>
);
}09 · 여러 공 물리를 한 화면에 합친 히어로 장면 — 측정 누적면적 39.741% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stepSim(dtMs, nowMs, px, py) {
var dtS = dtMs / 1000;
for (var i = 0; i < balls.length; i += 1) {
var b = balls[i];
b.vy += HERO_GRAVITY * dtS;
var dx = b.x - px, dy = b.y - py;
var dist = Math.hypot(dx, dy) || 0.001;
if (dist < HERO_POINTER_REPEL_RADIUS) {
var force = HERO_POINTER_FORCE * (1 - dist / HERO_POINTER_REPEL_RADIUS) / dist;
b.vx += dx * force * dtS;
b.vy += dy * force * dtS;
}
b.x += b.vx * dtS;
b.y += b.vy * dtS;
var hit = false;
if (b.x - b.r < 0) { b.x = b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.x + b.r > CALC_W) { b.x = CALC_W - b.r; b.vx = -b.vx * HERO_WALL_RESTITUTION; hit = true; }
if (b.y - b.r < 0) { b.y = b.r; b.vy = -b.vy * HERO_WALL_RESTITUTION; hit = true; }
if (b.y + b.r > CALC_H) {
b.y = CALC_H - b.r;
b.vy = -b.vy * HERO_WALL_RESTITUTION;
if (Math.abs(b.vy) < 24) b.vy = 0;
hit = true;
}
잰 바운싱 볼 아홉 항목의 실제 값 — 24장 캡처 기준
| 항목 | 누적면적 | 평균 강도 | 움직인 프레임 |
|---|---|---|---|
| 01 속도를 서로 주고받는 탄성 충돌 공 | 40.8% | 106.2 | 23/23 |
| 02 떨어지다 바닥에서 잦아드는 중력 낙하 공 | 15.4% | 103.7 | 20/23 |
| 03 벽에 닿을 때마다 힘을 잃는 반발계수 벽 튕김 공 | 35.0% | 96.4 | 23/23 |
| 04 질량 차이로 튀는 높이가 갈라지는 공 | 11.1% | 108.9 | 23/23 |
| 05 포인터가 다가오면 흩어지는 반발 공 | 21.4% | 88.1 | 23/23 |
| 06 포인터 쪽으로 끌려가 도는 인력 공 | 20.4% | 77.2 | 23/23 |
| 07 드래그한 속도 그대로 날아가는 던지기 공 | 16.2% | 59.8 | 23/23 |
| 08 위에서 쏟아져 바닥에 쌓이는 낙하 공 | 33.3% | 98.9 | 23/23 |
| 09 여러 공 물리를 한 화면에 합친 히어로 장면 | 39.7% | 99.9 | 23/23 |
값이 움직임을 바꾸는 곳 — 반발계수·질량·포인터 반경
BALL_RESTITUTION 은 01번 공이 부딪힐 때마다 잃는 속도의 비율을 정한다. 값을 낮추면 충돌한 두 공이 힘을 금방 잃고 서로 붙어 다니듯 느려지고, 값을 올리면 충돌마다 튕기는 세기가 오래 유지된다. BALL_MASS_BOUNCE_FACTOR 는 04번 공에서 질량과 반발계수를 연결하는 계수라, 값을 낮추면 무거운 공과 가벼운 공이 튀는 높이 차이가 줄고 값을 올리면 가벼운 공만 훨씬 높이 튄다. POINTER_REPEL_RADIUS 는 05번 공이 포인터를 감지하는 반경이라, 값을 낮추면 포인터에 바짝 다가가야만 반응하고 값을 올리면 멀리서부터 미리 흩어진다.
어디서 깨지는가 — 동작 줄임 설정·좁은 화면·공 개수가 많을 때
아홉 항목 모두 matchMedia('(prefers-reduced-motion: reduce)') 로 동작 줄임 설정을 읽어 자동 재생 루프를 멈춘다 — 다만 05·06·07·09 항목은 방향키 입력이 들어오면 그 프레임만 다시 계산해 화면을 갱신한다. 좁은 화면에서는 @media (max-width: 340px) 로 캡션 글자 크기만 10px 로 줄어들 뿐 캔버스 내부 계산 해상도는 그대로라, 화면이 작아질수록 공과 공 사이 간격이 시각적으로 더 좁아 보인다. 08번 항목은 MAX_BALLS 13개가 쌓일 때까지 공을 계속 늘리고 그때마다 SEPARATE_ITERS 번의 겹침 보정을 전체 공 쌍에 반복하므로, 공 수가 늘수록 계산량도 함께 늘어난다. 09번 히어로 장면도 다섯 공이 중력·포인터 반발·벽 반발을 동시에 계산해 좁은 화면일수록 공끼리 구분이 더 어려워진다. 이 아홉 개 원본은 압축 파일 안에 있고, 여는 비밀번호는 nqxed599 이다.
변형 세 가지와 접근성
| 이름 | 값 | 느낌 |
|---|---|---|
| 탄성 충돌 반발계수 조정 | 01 BALL_RESTITUTION 0.82 → 0.55 | 값을 줄이면 충돌한 두 공이 힘을 빨리 잃고 서로 붙어 다니듯 움직이고, 값을 늘리면 충돌마다 튕겨 나가는 세기가 오래 유지된다. |
| 질량별 튕김 높이 계수 조정 | 04 BALL_MASS_BOUNCE_FACTOR 1.4 → 2.2 | 값을 줄이면 무거운 공과 가벼운 공이 튀는 높이 차이가 작아지고, 값을 늘리면 가벼운 공만 훨씬 높이 튀어 오른다. |
| 포인터 반발 반경 조정 | 05 POINTER_REPEL_RADIUS 90 → 160 | 값을 줄이면 포인터에 아주 가까이 가야만 공들이 흩어지고, 값을 늘리면 포인터가 멀리 있어도 공들이 미리 자리를 비킨다. |
입력 경로가 있는 05·06·09 항목은 방향키 네 개로 포인터 좌표를 옮길 수 있고, 07 항목은 같은 방향키로 공에 KEY_IMPULSE 만큼의 속도를 직접 더해 드래그 없이도 던지기를 재현한다. 나머지 항목은 입력 없이 정해진 계산만 반복하므로 prefers-reduced-motion 값이 켜지면 그대로 멈춘다.
마우스 따라다니는 효과는 물리 기반 애니메이션에서 무엇을 계산하는가?
계산 방식은 항목마다 다르다. 05번과 09번은 포인터와의 거리를 매 프레임 다시 재고 그 거리에 반비례하는 힘을 더하지만, 06번은 포인터 방향 가속만 매 프레임 새로 구하고 옆으로 도는 움직임은 공을 만들 때 한 번 준 초기 속도가 감쇠 아래서 남아 있는 결과라 계산 시점이 다르다. 벽에 부딪혀 각도가 바뀌는 계산은 벽돌깨기 게임 만들기 9가지 — 반사각은 맞은 위치가 정한다 가, 힘을 받다가 마찰로 멈추는 계산은 룰렛 돌리기 — 튕겨 돌리고 마찰로 멈추는 돌림판 캔버스 9종 이 각각 다룬다.
바운싱 볼 아홉 개에서 자주 나오는 질문
바운싱 볼 애니메이션에 외부 물리 엔진이 들어가나요?
아니다. 아홉 항목 모두 반발계수·질량·거리 같은 값을 매 프레임 자체 계산해 위치와 속도를 갱신할 뿐, 별도의 물리 엔진 라이브러리를 불러오지 않는다.
마우스 따라다니는 효과를 다른 화면 크기에 그대로 붙여도 되나요?
붙일 수 있다. 계산은 고정된 내부 해상도 위에서 이뤄지고 화면 크기에 맞춰 비율만 늘어나므로, 좁은 화면에서도 같은 반응이 나온다. 다만 앞서 다룬 것처럼 화면이 좁을수록 공 사이 간격은 시각적으로 더 좁아 보인다.
공 개수를 마음대로 늘려도 되나요?
소스에서 배열 길이나 상수를 바꾸면 늘어난다. 다만 08번처럼 공마다 나머지 전부와 겹침을 확인하는 방식이 들어간 항목은 공 수가 늘수록 프레임마다 계산량이 함께 늘어난다는 점을 감안해야 한다.









