跳ねるボール アニメーション 9選 — 衝突と重力の物理計算
跳ねるボール アニメーションを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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 速度を交換する弾性衝突ボール
- 02 落下して床で静まる重力落下ボール
- 03 壁に当たるたびに力を失う反発係数の壁バウンドボール
- 04 質量差で跳ねる高さが分かれるボール
- 05 ポインターが近づくと散らばる反発ボール
- 06 ポインターに引き寄せられて回るボール
- 07 ドラッグした速度のまま飛ぶ投げボール
- 08 上から降り注ぎ床に積み重なる落下ボール
- 09 複数のボール物理を一画面にまとめたヒーローシーン
跳ねるボール アニメーション 9つ — 値ひとつが動きをどう変えるか
以下の9つはすべて同じキャンバスループの上で動いています。速度を積分し、衝突か境界を解決し、実際に動いたピクセルだけを描き直すという流れです。項目01はそのループが一番わかりやすく現れたボールで、反発係数ひとつで運動量を交換する単純なペアです。項目05になると同じループが壁の代わりにポインター位置を読み取り、項目09では重力・壁バウンド・ペア衝突を1つのヒーローシーンにまとめています — これは物理シミュレーション 作り方 9選 — バネから慣性スクラブまでがバネや慣性スクラブで使っているのと同じ重ね方です。以下の並びは単純なペアから重力・壁の減衰、質量差、ポインターの力、ドラッグの運動量、積み重なる生成、最後に複合ヒーローシーンへと、1項目ごとにループへ渡す情報を1つずつ増やしています。
01速度を交換する弾性衝突ボール
resolvePair() はボール同士が触れた瞬間に衝突方向に沿って質量と速度を読み取り、BALL_RESTITUTION 0.82 で運動量を交換します。そのため重いボールはほとんど動かず、軽いボールだけが大きく弾き飛ばされ、ビリヤードの球が画面の中でぶつかり合っているように見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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 });
}
項目01は面積40.8%・強度平均106.2で、23/23 フレームが動きました。
02落下して床で静まる重力落下ボール
stepSim() は毎フレーム GRAVITY を下向きの速度に加算してボールを落とし、床に触れるたびにその速度へ FLOOR_RESTITUTION を掛けて弱めます。速度が SLEEP_THRESHOLD を下回るとボールは静止し、この落下から静止までの流れがローディング画面の背景として自然に見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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;
}
項目02は面積15.4%・強度平均103.7で、20/23 フレームだけが動きました — 静まった後は残りの時間ボールが止まったままだからです。
03壁に当たるたびに力を失う反発係数の壁バウンドボール
画面端に触れた瞬間に stepSim() の境界判定がその軸の速度を反転させて WALL_RESTITUTION を掛けるため、跳ねるたびに少しずつ勢いが弱まります。合計速度が MIN_SPEED を下回ると、これとは別の kick() がシード乱数で新しい角度と速度を選んで再び弾き飛ばし、画面に収まったまま動き続けます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
}
}
項目03は面積35.0%・強度平均96.4で、23/23 フレームが動きました。
04質量差で跳ねる高さが分かれるボール
restitutionFor() はボールごとの質量を MASSES の値からそのまま受け取り(半径はその半分として別に導かれます)、BASE_MASS_REST に BALL_MASS_BOUNCE_FACTOR を掛けた値をその質量で割るため、軽いボールは反発係数が0.95近くになり、重いボールは0.22近くまで下がります。同じ高さから同じ瞬間に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-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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) {
項目04は面積11.1%・強度平均108.9で、23/23 フレームが動きました — ボールが細い縦のレーンから外へ出ないため、9項目の中で最も面積が小さくなっています。
05ポインターが近づくと散らばる反発ボール
applyPointer() は毎フレームすべてのボールとポインターの距離を測り、POINTER_REPEL_RADIUS の内側では距離が近いほど強く押し出し、半径の外にいるボールは自分の定位置を中心にゆっくりとしたサイン波で漂います。この組み合わせによってボールがカーソルを積極的によけているように見えます。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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);
項目05は面積21.4%・強度平均88.1で、23/23 フレームが動きました。
06ポインターに引き寄せられて回るボール
stepSim() は毎フレーム ATTRACT_K を距離で割ったポインター方向への引力だけを計算し、initBalls() が生成時に一度だけ与えた横向きの初期速度(orbitSpeed、ATTRACT_K の平方根)が DAMPING の減衰の下でも残り続けるため、ボールはポインターに吸い込まれず一定距離を回り続けます。カーソルを追いかける小さな衛星のような動きで、寄っていくのではなく周りを回る装飾に向いています。

<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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) {
項目06は面積20.4%・強度平均77.2で、23/23 フレームが動きました。
07ドラッグした速度のまま飛ぶ投げボール
recordSample() は直近 SAMPLE_WINDOW_MS の間のポインター位置だけを保持し、指を離した瞬間に releaseVelocity() が一番古いサンプルと一番新しいサンプルの差から速度を計算します。そのため投げた瞬間の速度は実際にドラッグした感覚とそのまま一致し、最後の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-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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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) {
項目07は面積16.2%・強度平均59.8(9項目中もっとも低い平均強度)で、23/23 フレームが動きました。
08上から降り注ぎ床に積み重なる落下ボール
spawn() は SPAWN_INTERVAL_MS ごとに新しいボールを1つ上から追加し、2つのボールが重なると stepSim() が SEPARATE_ITERS 回の分離処理を行って、それぞれの中心を結ぶ線に沿って重なった分の半分ずつ押し離します。この繰り返しの分離があるおかげで、落ちてきたボールの山が互いに沈み込まずに積み重なります。

<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">一定間隔でボールを上から落とし続け、重なった2つのボールは互いに押し離す計算で分離され、床に積み上がっていく。</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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(); }
}
}
項目08は面積33.3%・強度平均98.9で、23/23 フレームが動きました。
09複数のボール物理を一画面にまとめたヒーローシーン
ここでの stepSim() は HERO_GRAVITY と項目05のポインター反発力、項目01の resolvePair() による衝突処理を同じループの中でまとめて実行し、それぞれの定数を HERO_ 接頭辞で分けて見分けやすくしています。3つの技法を1つの場面に重ねることでヒーロー背景として十分な密度になり、どれか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 () {
// 기법: 앞선 세 항목의 계산을 한 장면에 합성한다 — ①매 스텝 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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
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));
項目09は面積39.7%・強度平均99.9で、23/23 フレームが動き、単一フレームの最大値は16.7%と9項目の中で最も大きな一撃でした。
9つの跳ねるボールパターンを面積と強度で測る
下の表は、キャプチャ間で画面が塗り直された面積の割合と、塗り直された場所での平均的な明るさの変化を並べたものです。項目04は塗り直された1ピクセルあたりの強度がもっとも高く、項目07はもっとも低く、項目09は全区間の平均は中間でありながら単一フレームで最も広い面積を塗り直しました。ボール 弾む アニメーションは面積が20%未満・強度が100未満のときに一番落ち着いて見え、9項目のうち半分ほどがこの範囲に収まっています。
| パターン | 累積面積 | 平均強度 | 動いたフレーム |
|---|---|---|---|
| 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 |
値が動きを変える場所 — 反発係数・質量・ポインター半径
下の3つの定数がほとんどの表情を決めています。BALL_RESTITUTION は項目01のペアが触れ合った後にどれだけ速度を保つかを、BALL_MASS_BOUNCE_FACTOR は項目04の軽いボールと重いボールがどれだけ離れた高さに落ち着くかを、POINTER_REPEL_RADIUS は項目05のボールがどれだけ早くポインターの前を空け始めるかを決めます。3つとも描かれるもの自体は変えず、同じ物理ループがどれだけ弾み、どれだけ不均等に、どれだけ慎重に見えるかを変えるだけですが、これこそがただ同じ落下を繰り返すだけのアニメーションと、ブックマークしておきたい重力 アニメーションの違いのほとんどを占めています。javascript ブロック崩し 反射角で変わる9パターンも同じようなフレームごとのループの上で、パドルに当たった時の反射角という別の定数を調整しています。
どこで壊れるか — 動作を減らす設定・狭い画面・ボール数が多いとき
この9つすべてで matchMedia を使って prefers-reduced-motion を読み取り、キャンバス自体を止めるのではなく読み込み時に自動再生用の .is-demo クラスを外しています。そのため項目06は実際にポインターが動いたときだけ軌道を描き、項目07も実際にドラッグしたときだけボールを投げます。fit() は320×200の計算空間をキャンバス要素の実測サイズに合わせて縮小するため、狭いスマートフォン幅では物理計算自体は変えずに各ボールの見た目のサイズだけが小さくなります。低スペック端末で特に注意したいのは項目08で、MAX_BALLS 個のボールが積み重なった後も stepSim() は毎フレームすべてのペアに対して SEPARATE_ITERS 回の分離処理を行い続けるため、重力や描画よりも先にこのペアごとのチェックでフレームが落ちやすくなります。これら9つのビルドの裏にある index.html/style.scss のペア(nqxed599)は、これだけ少ないボール物理アニメーションのコードで9つ同時に動かせることを確かめる一番早い方法です。
変形3つとアクセシビリティ
| バリエーション | 値の変更 | 感触 |
|---|---|---|
| 弾性衝突の反発係数を調整する | 01 BALL_RESTITUTION 0.82 → 0.55 |
値を下げると2つのボールがぶつかった後すぐにエネルギーを失って寄り添うように止まり、上げると跳ねる勢いが長く残ります。 |
| 質量と跳ね高さの係数を調整する | 04 BALL_MASS_BOUNCE_FACTOR 1.4 → 2.2 |
値を下げると重いボールと軽いボールの跳ね高さの差が縮まり、上げると軽いボールだけがはるかに高く跳ねるようになります。 |
| ポインター反発半径を調整する | 05 POINTER_REPEL_RADIUS 90 → 160 |
値を下げるとポインターがかなり近づいたときだけボールが散らばり、上げるとポインターが来る前から早めに場所を空けるようになります。 |
9項目すべてが自分のスクリプトの先頭で一度だけ prefers-reduced-motion を確認し、個々のプロパティを止めるのではなく自動再生クラスを外すことで応じているため、動作を減らしたい訪問者にも空白のキャンバスではなく静止したボールが見えます。矢印キーとEnterまたはSpaceキーは9項目のうち4つ、項目05・06・07・09で使え、これらは tabindex="0" を設定して keydown を監視し、仮想ポインターを動かす、軌道を揺らす、持っているボールを投げる、ヒーローシーンを視点移動するといった操作をマウスなしで行えます。CanvasRenderingContext2D、requestAnimationFrame()、prefers-reduced-motion はMDNに文書化されています。
衝突 アニメーションは何を計算しているのか?
この9つはどれも物理エンジンを読み込んでいません。それぞれが自前の単純なボール配列を持ち、実際に経過した時間に対して16msの固定 FIXED_DT を積み上げながら1コマずつ進め、重力・壁・ポインターの力・ペア衝突を Math.hypot() とベクトル計算の数行で解決しています — 物理エンジンが自分のAPIの中に隠している計算と同じ種類のものを、隠さずそのまま書いているだけです。9項目のうち8つは開始位置や角度をローカルの seedRand() で決めているため再読み込みしても毎回同じ見た目になりますが、項目04だけは例外で、4つのボールが固定位置から始まるため乱数シードそのものを必要としません。web ルーレット — 弾いて回し摩擦で止まる回転盤キャンバス9種も、複数のボールの代わりに1枚の回転盤の上で似た摩擦と反発係数の計算を行っています。
FAQ でよくある質問
これらの9つはマウス 追従 アニメーション cssの代わりにそのまま使えますか?
はい。どのデモもスクリプトを内蔵した単一の index.html で完結しているため、ファイルと対になる style.scss をコピーするだけで動きます。CSSの @keyframes やビルドツールには依存していません。
項目07の平均強度が他より低いのはなぜですか?
項目07は場面全体ではなく1つのボールだけを毎フレーム描き直すため、面積は項目02や項目05と同じくらいでも、1フレームごとの明るさの変化はそれより小さくなります — 23/23 フレームで平均した強度59.8は9項目の中で最も低い値です。
これら9つのような跳ねるボール アニメーションに物理エンジンは必要ですか?
必要ありません。ここにある9つはすべて重力・壁・質量・ポインターの力を固定タイムステップとわずかなベクトル計算だけで解決していて、これは物理エンジンが内部で行っているのと同じ計算を、インポートせずそのまま stepSim() に書き込んでいるだけです。









