02重力ドロップ
複数のボールが落ちて床に当たり、反発係数スライダーの値だけ跳ね返ります。位置はボールごとの translate 一つだけで動かし、ボール同士は重なりを押しのけるだけです。
使いどころ落としたファイルアイコン、ゲームっぽい報酬エフェクト
requestAnimationFrametranslatepointerdown

<svg width="0" height="0" style="position:absolute" aria-hidden="true">
<defs>
<symbol id="ico-drop" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<main class="stage">
<div class="gd is-demo" id="gd">
<p class="gd__hint"><svg class="gd__ico" aria-hidden="true"><use href="#ico-drop"/></svg><span>板をクリックすると4つのボールがもう一度落ち、反発スライダーの値だけ跳ね返ります</span></p>
<div class="gd__field" id="field" role="application" aria-label="重力の実験台" tabindex="0">
<span class="gd__ball gd__ball--a" aria-hidden="true"></span>
<span class="gd__ball gd__ball--b" aria-hidden="true"></span>
<span class="gd__ball gd__ball--c" aria-hidden="true"></span>
<span class="gd__ball gd__ball--d" aria-hidden="true"></span>
<span class="gd__floor" aria-hidden="true"></span>
</div>
<div class="gd__bar">
<label class="gd__dial">反発
<input type="range" id="rest" min="0.3" max="0.9" step="0.02" value="0.62" aria-label="反発係数">
<b class="gd__val" id="restVal">0.62</b>
</label>
<button class="gd__drop" id="drop" type="button">
<svg class="gd__ico" aria-hidden="true"><use href="#ico-drop"/></svg><span>もう一度落とす</span>
</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 중력 드롭 — 가속 1800px/s², 바닥 충돌은 반발계수(e) 만큼 되튕기는 준암시적 오일러 적분.
// 위치는 공마다 translate 하나뿐이고 공끼리는 겹침 밀어내기만 한다 (간이 충돌).
// 격자 미리보기(.is-demo)는 실제 시뮬레이션에서 표본 추출한 되튕김 곡선(높이가 e 배로 줄어드는 포물선 열)을
// 키프레임으로 재생한다. 루프의 시작과 끝을 바닥에 두어 경계 순간이동이 없다.
// 사람이 판을 클릭하면 JS 가 .is-demo 를 떼고 진짜 적분 경로로 넘어간다.
$duration: $dur-loop;
$easing: linear; // 포물선의 가감속은 키프레임 표본 자체에 들어 있다
$color: $stage-yellow;
@include stage($stage-yellow);
.gd { display: flex; flex-direction: column; gap: $sp-3; width: 100%; max-width: 424px; }
.gd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); color: $subject-ink;
}
.gd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $stage-orange; }
.gd__field {
position: relative; height: 176px; border-radius: $r-card;
background: rgba($subject-cream, .5);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer; overflow: hidden; touch-action: manipulation;
}
.gd__field:focus-visible { outline: 2px solid $stage-orange; outline-offset: 2px; }
.gd__floor { position: absolute; left: 0; right: 0; bottom: 0; height: 2px; background: rgba($subject-ink, .35); }
.gd__ball { position: absolute; left: 0; top: 0; border-radius: 50%; background: $subject-ink; }
// --bx 처음 x, --by 바닥 안착 y, --h 되튕김 높이 (모두 px 길이). 미리보기 키프레임이 참조한다
.gd__ball--a { width: 28px; height: 28px; --bx: 34px; --by: 148px; --h: 140px; }
.gd__ball--b { width: 20px; height: 20px; background: $stage-orange; --bx: 132px; --by: 156px; --h: 104px; }
.gd__ball--c { width: 24px; height: 24px; background: $subject-blue; --bx: 230px; --by: 152px; --h: 122px; }
.gd__ball--d { width: 16px; height: 16px; --bx: 322px; --by: 160px; --h: 88px; }
.gd__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; flex-wrap: wrap; }
.gd__dial {
display: inline-flex; align-items: center; gap: $sp-1;
font-weight: 700; font-size: clamp(10px, 2.6vw, 11px); color: $subject-ink;
}
.gd__dial input { width: 88px; accent-color: $stage-orange; }
.gd__val { min-width: 28px; text-align: center; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); }
.gd__drop {
display: inline-flex; align-items: center; gap: $sp-1; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; background: $subject-ink; color: $color; cursor: pointer;
font-family: inherit; font-weight: 800; font-size: clamp(10px, 2.6vw, 11px); line-height: 1;
}
.gd__drop .gd__ico { color: $color; }
// ── 격자 미리보기 루프 ─────────────────────────────────────────────
// 바닥(y 0)에서 크게 되튕어 올라갔다 e 배로 낮아지며 굴러떨어지는 모양.
// 공마다 음수 지연으로 위상만 민다 — 같은 키프레임을 공유하므로 루프는 닫혀 있다.
// 최고 높이는 표본 실측 기준 2초에 세 번 내려오는 수준이라 광과민성 기준(초당 3회) 아래다.
.gd.is-demo .gd__ball--a { animation: gdBounceA $duration $easing infinite; }
.gd.is-demo .gd__ball--b { animation: gdBounceA $duration $easing infinite; animation-delay: -660ms; }
.gd.is-demo .gd__ball--c { animation: gdBounceA $duration $easing infinite; animation-delay: -330ms; }
.gd.is-demo .gd__ball--d { animation: gdBounceA $duration $easing infinite; animation-delay: -880ms; }
@keyframes gdBounceA {
// 높이는 바닥 안착점(var(--by)) 에서 var(--h) 배수만큼 올라가는 상대값 — 공 크기와 떨어진 x 는 변수로 갈라 논다
0% { transform: translate(var(--bx), var(--by)); }
3% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .21)); }
6% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .57)); }
9% { transform: translate(var(--bx), calc(var(--by) - var(--h) * 1)); }
12% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .57)); }
15% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .21)); }
18% { transform: translate(var(--bx), var(--by)); }
24% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .4)); }
30% { transform: translate(var(--bx), var(--by)); }
34% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .23)); }
38% { transform: translate(var(--bx), var(--by)); }
41% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .11)); }
44% { transform: translate(var(--bx), var(--by)); }
46% { transform: translate(var(--bx), calc(var(--by) - var(--h) * .04)); }
48% { transform: translate(var(--bx), var(--by)); }
100% { transform: translate(var(--bx), var(--by)); }
}
@media (max-width: 340px) {
.gd { gap: $sp-2; }
.gd__field { height: 104px; }
.gd__ball--a { width: 20px; height: 20px; --by: 84px; --h: 72px; }
.gd__ball--b { width: 16px; height: 16px; --by: 88px; --h: 56px; }
.gd__ball--c { width: 18px; height: 18px; --by: 86px; --h: 64px; }
.gd__ball--d { width: 12px; height: 12px; --by: 92px; --h: 48px; }
.gd__dial input { width: 56px; }
}
// reduce 에서는 바닥에 안착한 정지 상태 한 장을 둔다
@media (prefers-reduced-motion: reduce) {
.gd.is-demo .gd__ball--a,
.gd.is-demo .gd__ball--b,
.gd.is-demo .gd__ball--c,
.gd.is-demo .gd__ball--d { animation: none !important; }
.gd.is-demo .gd__ball { transform: translate(var(--bx), var(--by)) !important; }
}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);
}
.gd {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
max-width: 424px;
}
.gd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
color: #17141a;
}
.gd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff4d1f;
}
.gd__field {
position: relative;
height: 176px;
border-radius: 16px;
background: rgba(255, 247, 230, 0.5);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
overflow: hidden;
touch-action: manipulation;
}
.gd__field:focus-visible {
outline: 2px solid #ff4d1f;
outline-offset: 2px;
}
.gd__floor {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: rgba(23, 20, 26, 0.35);
}
.gd__ball {
position: absolute;
left: 0;
top: 0;
border-radius: 50%;
background: #17141a;
}
.gd__ball--a {
width: 28px;
height: 28px;
--bx: 34px;
--by: 148px;
--h: 140px;
}
.gd__ball--b {
width: 20px;
height: 20px;
background: #ff4d1f;
--bx: 132px;
--by: 156px;
--h: 104px;
}
.gd__ball--c {
width: 24px;
height: 24px;
background: #2f6df6;
--bx: 230px;
--by: 152px;
--h: 122px;
}
.gd__ball--d {
width: 16px;
height: 16px;
--bx: 322px;
--by: 160px;
--h: 88px;
}
.gd__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.gd__dial {
display: inline-flex;
align-items: center;
gap: 4px;
font-weight: 700;
font-size: clamp(10px, 2.6vw, 11px);
color: #17141a;
}
.gd__dial input {
width: 88px;
accent-color: #ff4d1f;
}
.gd__val {
min-width: 28px;
text-align: center;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
}
.gd__drop {
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
border-radius: 999px;
padding: 4px 12px;
background: #17141a;
color: #ffd23f;
cursor: pointer;
font-family: inherit;
font-weight: 800;
font-size: clamp(10px, 2.6vw, 11px);
line-height: 1;
}
.gd__drop .gd__ico {
color: #ffd23f;
}
.gd.is-demo .gd__ball--a {
animation: gdBounceA 2s linear infinite;
}
.gd.is-demo .gd__ball--b {
animation: gdBounceA 2s linear infinite;
animation-delay: -660ms;
}
.gd.is-demo .gd__ball--c {
animation: gdBounceA 2s linear infinite;
animation-delay: -330ms;
}
.gd.is-demo .gd__ball--d {
animation: gdBounceA 2s linear infinite;
animation-delay: -880ms;
}
@keyframes gdBounceA {
0% {
transform: translate(var(--bx), var(--by));
}
3% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.21));
}
6% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.57));
}
9% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 1));
}
12% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.57));
}
15% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.21));
}
18% {
transform: translate(var(--bx), var(--by));
}
24% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.4));
}
30% {
transform: translate(var(--bx), var(--by));
}
34% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.23));
}
38% {
transform: translate(var(--bx), var(--by));
}
41% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.11));
}
44% {
transform: translate(var(--bx), var(--by));
}
46% {
transform: translate(var(--bx), calc(var(--by) - var(--h) * 0.04));
}
48% {
transform: translate(var(--bx), var(--by));
}
100% {
transform: translate(var(--bx), var(--by));
}
}
@media (max-width: 340px) {
.gd {
gap: 8px;
}
.gd__field {
height: 104px;
}
.gd__ball--a {
width: 20px;
height: 20px;
--by: 84px;
--h: 72px;
}
.gd__ball--b {
width: 16px;
height: 16px;
--by: 88px;
--h: 56px;
}
.gd__ball--c {
width: 18px;
height: 18px;
--by: 86px;
--h: 64px;
}
.gd__ball--d {
width: 12px;
height: 12px;
--by: 92px;
--h: 48px;
}
.gd__dial input {
width: 56px;
}
}
@media (prefers-reduced-motion: reduce) {
.gd.is-demo .gd__ball--a,
.gd.is-demo .gd__ball--b,
.gd.is-demo .gd__ball--c,
.gd.is-demo .gd__ball--d {
animation: none !important;
}
.gd.is-demo .gd__ball {
transform: translate(var(--bx), var(--by)) !important;
}
}var root = document.getElementById('gd');
var field = document.getElementById('field');
var els = {
a: root.querySelector('.gd__ball--a'),
b: root.querySelector('.gd__ball--b'),
c: root.querySelector('.gd__ball--c'),
d: root.querySelector('.gd__ball--d')
};
var restIn = document.getElementById('rest');
var restVal = document.getElementById('restVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
// 공 4개: 크기(px) 와 기준 x 위치(424px 판 기준). 실제 판 폭에 비례해 얹는다.
// 무게는 크기에 비례한다고 보고 반발은 전 공 공통 값 하나로 쓴다
var SPEC = [ { el: 'a', size: 28, x: 34 }, { el: 'b', size: 20, x: 132 }, { el: 'c', size: 24, x: 230 }, { el: 'd', size: 16, x: 322 } ];
var G = 1800, balls = [], last = 0, running = false;
function scaledX(x) { return x * field.clientWidth / 424; }
function reset(top) {
balls = SPEC.map(function (s) {
var y = top ? 4 : field.clientHeight - s.size;
return { el: els[s.el], size: s.size, x: scaledX(s.x), y: y, vx: 0, vy: 0, done: !top };
});
paint();
}
function paint() {
for (var i = 0; i < balls.length; i++) {
balls[i].el.style.transform = 'translate(' + balls[i].x + 'px, ' + balls[i].y + 'px)';
}
}
function step(now) {
var dt = Math.min((now - last) / 1000, 0.033);
last = now;
var e = Number(restIn.value), floor = field.clientHeight, all = true;
for (var i = 0; i < balls.length; i++) {
var b = balls[i];
if (b.done) { continue; }
b.vy += G * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// 바닥 충돌: 반발계수만큼 세게 되튕기고 남은 속도가 느리면 바닥에 붙여 끝낸다
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// 공끼리: 겹친 만큼 서로 민다 (간이 충돌 — 속도 교환까지는 하지 않는다)
for (var j = i + 1; j < balls.length; j++) {
var o = balls[j];
var dx = o.x - b.x, dy = o.y - b.y, gap = (b.size + o.size) / 2;
var dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
var push = (gap - dist) / 2, nx = dx / dist, ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
if (!b.done) { all = false; }
}
paint();
if (all) { running = false; return; }
requestAnimationFrame(step);
}
function drop() {
root.classList.remove('is-demo');
if (reduce.matches) { reset(false); return; }
reset(true);
if (!running) { running = true; last = performance.now(); requestAnimationFrame(step); }
}
field.addEventListener('pointerdown', drop);
document.getElementById('drop').addEventListener('click', drop);
field.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); drop(); }
});
restIn.addEventListener('input', function () { restVal.textContent = Number(restIn.value).toFixed(2); });
window.addEventListener('resize', function () { reset(false); });
reset(false);// GravityDrop — vanilla 판(run/292/demo/02-physics-gravity-drop/index.html) 의 React 포트.
// 가속 G = 1800px/s² 를 속도에 더하고 위치를 옮기는 준암시적 오일러 적분.
// 바닥에 닿으면 반발계수(e) 만큼 되튕기고, 남은 속도가 40 아래로 떨어지면 바닥에 붙여 끝낸다.
// 공끼리는 겹친 만큼 서로 미는 간이 충돌만 한다. 값·수치는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GravityDrop.module.scss";
export type GravityDropProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
// React 의 KeyboardEvent 제네릭이 없는 환경이라 필요한 것만 로컬로 선언한다
type KeyEvt = { key: string; preventDefault(): void };
type InputEvt = { target: { value: string } };
type Ball = { el: HTMLSpanElement; size: number; x: number; y: number; vx: number; vy: number; done: boolean };
// 공 4개: 크기(px) 와 기준 x 위치(424px 판 기준). 실제 판 폭에 비례해 얹는다.
// 무게는 크기에 비례한다고 보고 반발은 전 공 공통 값 하나로 쓴다
const SPEC: { size: number; x: number }[] = [
{ size: 28, x: 34 },
{ size: 20, x: 132 },
{ size: 24, x: 230 },
{ size: 16, x: 322 },
];
const G = 1800;
const FIELD_LABEL = "중력 실험판";
export default function GravityDrop({
duration = 2000,
easing = "linear",
color = "#ffd23f",
}: GravityDropProps) {
const fieldRef = useRef<HTMLDivElement | null>(null);
const ballARef = useRef<HTMLSpanElement | null>(null);
const ballBRef = useRef<HTMLSpanElement | null>(null);
const ballCRef = useRef<HTMLSpanElement | null>(null);
const ballDRef = useRef<HTMLSpanElement | null>(null);
const ballsRef = useRef<Ball[]>([]) as Box<Ball[]>;
const restRef = useRef<number>(0.62) as Box<number>;
const lastRef = useRef<number>(0) as Box<number>;
const rafRef = useRef<number>(0) as Box<number>;
const runningRef = useRef<boolean>(false) as Box<boolean>;
const [rest, setRest] = useState(0.62);
const [demo, setDemo] = useState(true);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ballEls(): (HTMLSpanElement | null)[] {
return [ballARef.current, ballBRef.current, ballCRef.current, ballDRef.current];
}
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
function scaledX(x: number): number {
const field = fieldRef.current;
return field ? (x * field.clientWidth) / 424 : x;
}
function reset(top: boolean): void {
const field = fieldRef.current;
if (!field) return;
const els = ballEls();
const next: Ball[] = [];
for (let i = 0; i < SPEC.length; i++) {
const el = els[i];
if (!el) continue;
const s = SPEC[i];
const y = top ? 4 : field.clientHeight - s.size;
next.push({ el: el, size: s.size, x: scaledX(s.x), y: y, vx: 0, vy: 0, done: !top });
}
ballsRef.current = next;
paint();
}
function paint(): void {
const balls = ballsRef.current;
for (let i = 0; i < balls.length; i++) {
balls[i].el.style.transform = "translate(" + balls[i].x + "px, " + balls[i].y + "px)";
}
}
function step(now: number): void {
const field = fieldRef.current;
if (!field) { runningRef.current = false; return; }
const dt = Math.min((now - lastRef.current) / 1000, 0.033);
lastRef.current = now;
const balls = ballsRef.current;
const e = restRef.current;
const floor = field.clientHeight;
let all = true;
for (let i = 0; i < balls.length; i++) {
const b = balls[i];
if (b.done) continue;
b.vy += G * dt;
b.x += b.vx * dt;
b.y += b.vy * dt;
// 바닥 충돌: 반발계수만큼 세게 되튕기고 남은 속도가 느리면 바닥에 붙여 끝낸다
if (b.y + b.size >= floor) {
b.y = floor - b.size;
if (Math.abs(b.vy) > 40) { b.vy = -b.vy * e; }
else { b.vy = 0; b.done = true; }
}
// 공끼리: 겹친 만큼 서로 민다 (간이 충돌 — 속도 교환까지는 하지 않는다)
for (let j = i + 1; j < balls.length; j++) {
const o = balls[j];
const dx = o.x - b.x;
const dy = o.y - b.y;
const gap = (b.size + o.size) / 2;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > 0 && dist < gap) {
const push = (gap - dist) / 2;
const nx = dx / dist;
const ny = dy / dist;
b.x -= nx * push; b.y -= ny * push;
o.x += nx * push; o.y += ny * push;
}
}
if (!b.done) all = false;
}
paint();
if (all) { runningRef.current = false; return; }
rafRef.current = requestAnimationFrame(step);
}
function drop(): void {
setDemo(false);
if (reduced()) { reset(false); return; }
reset(true);
if (!runningRef.current) {
runningRef.current = true;
lastRef.current = performance.now();
rafRef.current = requestAnimationFrame(step);
}
}
useEffect(() => {
reset(false);
function onResize(): void { reset(false); }
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onKeyDown(event: KeyEvt): void {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); drop(); }
}
const cls = [styles.root, demo ? styles.isDemo : ""].filter(Boolean).join(" ");
return (
<main className={styles.stage}>
<svg width="0" height="0" style={{ position: "absolute" }} aria-hidden="true">
<defs>
<symbol id="ico-drop" viewBox="0 0 256 256" fill="currentColor"><g><path d="M208 144a80 80 0 0 1-160 0c0-72 80-128 80-128s80 56 80 128" opacity=".2"/><path d="M174 47.75a254.2 254.2 0 0 0-41.45-38.3a8 8 0 0 0-9.18 0A254.2 254.2 0 0 0 82 47.75C54.51 79.32 40 112.6 40 144a88 88 0 0 0 176 0c0-31.4-14.51-64.68-42-96.25M128 216a72.08 72.08 0 0 1-72-72c0-57.23 55.47-105 72-118c16.53 13 72 60.75 72 118a72.08 72.08 0 0 1-72 72m55.89-62.66a57.6 57.6 0 0 1-46.56 46.55a9 9 0 0 1-1.33.11a8 8 0 0 1-1.32-15.89c16.57-2.79 30.63-16.85 33.44-33.45a8 8 0 0 1 15.78 2.68Z"/></g></symbol>
<symbol id="ico-reset" viewBox="0 0 256 256" fill="currentColor"><g><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z" opacity=".2"/><path d="M232 136a104 104 0 1 1-38.17-80.55L208 60v-4a8 8 0 0 1 16 0v34a8 8 0 0 1-8 8h-34a8 8 0 0 1 0-16h7.6l-8.89-7.4A88 88 0 1 0 216 136a8 8 0 0 1 16 0Z"/></g></symbol>
</defs>
</svg>
<div className={cls} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-drop" /></svg>
<span>판을 클릭하면 네 공이 다시 떨어지고 반발 슬라이더 값만큼 튀어 오릅니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={drop}
onKeyDown={onKeyDown}
>
<span className={`${styles.ball} ${styles.ballA}`} ref={ballARef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballB}`} ref={ballBRef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballC}`} ref={ballCRef} aria-hidden="true"></span>
<span className={`${styles.ball} ${styles.ballD}`} ref={ballDRef} aria-hidden="true"></span>
<span className={styles.floor} aria-hidden="true"></span>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반발
<input
type="range" min={0.3} max={0.9} step={0.02} value={rest} aria-label="반발계수"
onChange={(event: InputEvt) => { const v = Number(event.target.value); restRef.current = v; setRest(v); }}
/>
<b className={styles.val}>{rest.toFixed(2)}</b>
</label>
<button className={styles.drop} type="button" onClick={drop}>
<svg className={styles.ico} aria-hidden="true"><use href="#ico-drop" /></svg><span>다시 떨어뜨리기</span>
</button>
</div>
</div>
</main>
);
}02 重力ドロップ — 計測 累積面積 5.933% · 動いたフレーム 22/23GODRICH オリジナル · SCSS と React は zip に同梱 · 物理シミュレーション 作り方 9選 — バネから慣性スクラブまで
このデモが属する記事
物理シミュレーション 作り方 9選 — バネから慣性スクラブまで「物理シミュレーション 作り方」の基本は、requestAnimationFrame が毎フレーム速度と位置を計算し、その結果を transform だけで画面に反映することです。ライブラリを使わない9つのデモは、ばね追従・重力・振り子から この記事のデモをすべて見る


