07드래그한 속도 그대로 날아가는 던지기 공
손이 움직인 최근 몇 프레임의 이동량으로 속도를 계산해 두었다가, 손을 놓는 순간 그 속도 그대로 공을 날려 보낸다.
쓰임드래그로 던지는 카드·공 인터랙션
SAMPLE_WINDOW_MSKEY_IMPULSEreleaseVelocity

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


