01チェーンロープ揺れ
複数の点を距離制約でつないでロープを作り、位置ベースのVerlet積分で速度変数なしに前フレームとの差だけから次フレームを計算します。制約を反復して解くほどロープが張ります。
使いどころ引っ張って遊ぶヒーローバナー、ドラッグで開く引き出しの取っ手
requestAnimationFramepointerdownpointermovetranslateY

<main class="stage">
<div class="rp" id="rp">
<p class="rp__hint">
<svg class="rp__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>端をつかんで引っ張ると、距離制約を繰り返し解きながらロープ全体がついてきます</span>
</p>
<div class="rp__field" id="field" role="application" aria-label="ロープ実験盤" tabindex="0">
<canvas class="rp__cv" id="cv"></canvas>
<b class="rp__badge" aria-hidden="true"><span class="rp__badgeNum" id="iterVal">4</span><span class="rp__badgeUnit">制約反復</span></b>
</div>
<div class="rp__bar">
<label class="rp__dial">反復
<input type="range" id="iter" min="1" max="8" step="1" value="4" aria-label="制約反復数">
</label>
<button class="rp__reset" id="reset" type="button">最初から</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 체인 로프 — 움직임은 전부 캔버스 rAF 루프 안의 Verlet 적분·제약 반복에 있다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 시계를 고정하지 않고
// 실시간으로 24장을 찍어 로프가 실제로 흔들리는 모습이 담긴다(도구/demo.py 렌더(), 077·012 선례 "애니메이션": 0).
// 캔버스가 쓰는 색은 여기서 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-paper);
.rp {
--bg: #{$subject-cream};
--ink: #{$subject-ink};
--knot: #{$color};
--hand: #{$subject-mint};
--pivot: #{$subject-ink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.rp__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink;
}
.rp__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $color; }
.rp__field {
position: relative; height: 160px;
border-radius: $r-card; overflow: hidden;
background: var(--bg);
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: grab; touch-action: none;
}
.rp__field:focus-visible { outline: 2px solid $color; outline-offset: 2px; }
.rp__field:active { cursor: grabbing; }
.rp__cv { display: block; width: 100%; height: 100%; }
// 제약 반복 수 — 슬라이더와 같은 값이 JS 배지에도 뜬다
.rp__badge {
position: absolute; right: $sp-2; top: $sp-2;
display: flex; align-items: baseline; gap: $sp-1;
padding: $sp-1 $sp-2; border-radius: $r-pill;
background: rgba($subject-ink, .78);
color: $subject-cream; font-weight: 700; font-size: 11px;
}
.rp__badgeNum { font-size: 13px; color: $stage-yellow; }
.rp__badgeUnit { font-weight: 700; }
.rp__bar {
display: flex; align-items: center; gap: $sp-3;
}
.rp__dial {
display: flex; align-items: center; gap: $sp-2; flex: 1 1 auto;
font-weight: 700; font-size: 12px; color: $subject-ink;
}
.rp__dial input[type="range"] { flex: 1 1 auto; accent-color: $color; }
.rp__reset {
flex: 0 0 auto; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-weight: 700; font-size: 12px;
background: $color; color: $subject-ink; cursor: pointer;
}
.rp__reset:active { transform: translateY(1px); }
@media (max-width: 340px) {
.rp { gap: $sp-1; }
.rp__field { height: 124px; }
.rp__badge { font-size: 10px; padding: 0 $sp-1; }
.rp__badgeNum { font-size: 11px; }
.rp__bar { gap: $sp-2; }
.rp__dial { font-size: 11px; }
.rp__reset { font-size: 11px; padding: $sp-1 $sp-2; }
}
// reduce — 잡아 당기는 조작은 살리고 자동 시연 루프는 JS 가 끈다.
// 잡은 점만 옮기고 나머지는 제약이 즉시 따라오므로 화면이 흔들리지 않는다.
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field { cursor: default !important; }
.rp.is-still .rp__badge { opacity: .92 !important; }
}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);
}
.rp {
--bg: #fff7e6;
--ink: #17141a;
--knot: #ff9ebb;
--hand: #4cd4a6;
--pivot: #17141a;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.rp__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
}
.rp__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #ff9ebb;
}
.rp__field {
position: relative;
height: 160px;
border-radius: 16px;
overflow: hidden;
background: var(--bg);
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: grab;
touch-action: none;
}
.rp__field:focus-visible {
outline: 2px solid #ff9ebb;
outline-offset: 2px;
}
.rp__field:active {
cursor: grabbing;
}
.rp__cv {
display: block;
width: 100%;
height: 100%;
}
.rp__badge {
position: absolute;
right: 8px;
top: 8px;
display: flex;
align-items: baseline;
gap: 4px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.78);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.rp__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.rp__badgeUnit {
font-weight: 700;
}
.rp__bar {
display: flex;
align-items: center;
gap: 12px;
}
.rp__dial {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
color: #17141a;
}
.rp__dial input[type=range] {
flex: 1 1 auto;
accent-color: #ff9ebb;
}
.rp__reset {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: 12px;
background: #ff9ebb;
color: #17141a;
cursor: pointer;
}
.rp__reset:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.rp {
gap: 4px;
}
.rp__field {
height: 124px;
}
.rp__badge {
font-size: 10px;
padding: 0 4px;
}
.rp__badgeNum {
font-size: 11px;
}
.rp__bar {
gap: 8px;
}
.rp__dial {
font-size: 11px;
}
.rp__reset {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.rp.is-still .rp__field {
cursor: default !important;
}
.rp.is-still .rp__badge {
opacity: 0.92 !important;
}
}// 체인 로프 — 위치 기반 Verlet 적분. 속도 변수를 두지 않고 "이전 위치와의 차이"가 곧 속도다.
// next = pos + (pos - prev) * damping + gravity * dt^2
// 그 다음 거리 제약(N 개 점을 이웃끼리 목표 간격으로 못 박기)을 여러 번 반복해 푼다.
// 反復 수가 로프의 강도다 — 1번이면 축 늘어나고 8번이면 팽팽한 사슬이 된다.
var root = document.getElementById('rp');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var iterIn = document.getElementById('iter');
var iterVal = document.getElementById('iterVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var INK = css.getPropertyValue('--ink').trim();
var BG = css.getPropertyValue('--bg').trim();
var KNOT = css.getPropertyValue('--knot').trim();
var HAND = css.getPropertyValue('--hand').trim();
var PIVOT = css.getPropertyValue('--pivot').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var N = 14; // 로프를 이루는 점의 수
var SEG = 0; // 마디 간 목표 거리(판 크기에 맞춰 초기화에서 정한다)
var GRAV = 1500; // 중력 가속도(px/s^2) — Verlet 은 위치에 dt^2 를 곱해 더한다
var DAMP = 0.995; // 속도 보존률(공기 저항)
var LOOP = 2; // 자동 시연 한 바퀴(초) — 캡처 창과 같아 gif 가 이어진다
var pts = []; // 점: {x, y, px, py, pinned}
var dragging = -1; // 잡고 있는 점 번호
var W = 0, H = 0, dpr = 1;
var t0 = performance.now();
function fit() {
var r = field.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
W = r.width; H = r.height;
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
function build() {
pts = [];
SEG = Math.min(26, (H - 60) / (N - 1));
var x0 = W * 0.5, y0 = 30;
for (var i = 0; i < N; i++) {
pts.push({ x: x0, y: y0 + i * SEG, px: x0, py: y0 + i * SEG, pinned: i === 0 });
}
dragging = -1;
}
// 한 프레임 — 적분 → 制約反復
function step(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
t0 = now;
var iters = Number(iterIn.value);
// (1) 관성: 이전 위치와의 차이가 곧 속도다
for (var i = 0; i < N; i++) {
var p = pts[i];
if (p.pinned || i === dragging) continue;
var vx = (p.x - p.px) * DAMP;
var vy = (p.y - p.py) * DAMP;
p.px = p.x; p.py = p.y;
p.x += vx;
p.y += vy + GRAV * dt * dt;
}
// (2) 제약: 이웃끼리 목표 간격으로 당긴다. 한 번으로는 안 풀린다 — 반복할수록 팽팽해진다
for (var k = 0; k < iters; k++) {
for (var j = 0; j < N - 1; j++) {
var a = pts[j], b = pts[j + 1];
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
var off = (d - SEG) / d * 0.5;
var ax = dx * off, ay = dy * off;
if (!a.pinned && j !== dragging || k === 0 && j !== dragging && !a.pinned) { /* no-op */ }
var aFree = !a.pinned && j !== dragging;
var bFree = !b.pinned && j + 1 !== dragging;
if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
}
}
if (dragging >= 0) { pts[dragging].x = dragX; pts[dragging].y = dragY; }
}
function draw() {
ctx.clearRect(0, 0, W, H);
// 로프 — 마디마다 두 겹(굵은 밝은 + 가는 어두운)으로 사슬 결을 낸다
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = INK;
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i = 1; i < N; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.strokeStyle = HAND;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (var i2 = 1; i2 < N; i2++) ctx.lineTo(pts[i2].x, pts[i2].y);
ctx.stroke();
// 고정점 핀
ctx.fillStyle = PIVOT;
ctx.beginPath();
ctx.arc(pts[0].x, pts[0].y, 6, 0, Math.PI * 2);
ctx.fill();
// 매듭 점 — 점의 개수만큼 사슬임을 눈으로 보여준다
for (var m = 1; m < N; m++) {
ctx.fillStyle = KNOT;
ctx.beginPath();
ctx.arc(pts[m].x, pts[m].y, 3.4, 0, Math.PI * 2);
ctx.fill();
}
// 끝 손잡이
var e = pts[N - 1];
ctx.fillStyle = HAND;
ctx.beginPath();
ctx.arc(e.x, e.y, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = INK;
ctx.beginPath();
ctx.arc(e.x, e.y, 3.5, 0, Math.PI * 2);
ctx.fill();
}
// 사람이 안 만졌을 때 도는 자동 시연 — 시드 고정 가상 포인터가 끝단을 8자로 끈다.
// 실제 손 조작(pointerdown)이 오면 이 루프를 끄고 진짜 포인터를 따라간다.
var manual = false;
function demoTarget(now) {
var ph = (now / 1000) / LOOP * Math.PI * 2;
return {
x: W * 0.5 + Math.sin(ph) * W * 0.3,
y: 70 + Math.sin(ph * 2) * 34
};
}
function frame(now) {
if (!manual) {
var t = demoTarget(now);
pts[N - 1].x = t.x; pts[N - 1].y = t.y;
}
step(now);
draw();
requestAnimationFrame(frame);
}
// 조작 — 끝단(또는 아무 매듭)을 잡아 끈다. 잡은 점은 제약에서 손으로 고정된 취급
var dragX = 0, dragY = 0;
function pick(x, y) {
var best = -1, bd = 26;
for (var i = 1; i < N; i++) {
var d = Math.hypot(pts[i].x - x, pts[i].y - y);
if (d < bd) { bd = d; best = i; }
}
return best;
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
dragging = pick(x, y);
if (dragging < 0) return;
manual = true;
dragX = x; dragY = y;
field.setPointerCapture(e.pointerId);
if (reduce.matches) {
// 줄인 모드에서는 관성 없이 잡은 점만 옮기고 나머지는 제약이 즉시 따라온다
pts[dragging].x = x; pts[dragging].y = y;
pts[dragging].px = x; pts[dragging].py = y;
}
});
field.addEventListener('pointermove', function (e) {
if (dragging < 0) return;
var r = field.getBoundingClientRect();
dragX = Math.max(4, Math.min(W - 4, e.clientX - r.left));
dragY = Math.max(4, Math.min(H - 4, e.clientY - r.top));
if (reduce.matches) { pts[dragging].x = dragX; pts[dragging].y = dragY; pts[dragging].px = dragX; pts[dragging].py = dragY; }
});
window.addEventListener('pointerup', function () { dragging = -1; });
document.getElementById('reset').addEventListener('click', function () { manual = false; build(); });
iterIn.addEventListener('input', function () { iterVal.textContent = iterIn.value; });
window.addEventListener('resize', fit);
fit();
requestAnimationFrame(frame);// RopeChain — vanilla 판(run/306/demo/01-verlet-rope-chain/index.html) 의 React 포트.
// 위치 기반 Verlet 적분: next = pos + (pos - prev) * damping + gravity * dt^2.
// 그 다음 거리 제약(이웃끼리 목표 간격)을 슬라이더 값만큼 반복해 푼다 — 반복 수가 로프의 강도다.
// 값·물리 상수·주석 의미는 vanilla 와 같다. 사람이 잡으면 미리보기를 끄고 진짜 포인터를 따라간다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import styles from "./RopeChain.module.scss";
export type RopeChainProps = {
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다. 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다 (292 선례)
type Box<T> = { current: T };
type InputEvt = { target: { value: string } };
type Pt = { x: number; y: number; px: number; py: number; pinned: boolean };
const N = 14; // 로프를 이루는 점의 수
const GRAV = 1500; // 중력 가속도(px/s^2) — Verlet 은 위치에 dt^2 를 곱해 더한다
const DAMP = 0.995; // 속도 보존률(공기 저항)
const LOOP = 2; // 자동 시연 한 바퀴(초) — 캡처 창과 같아 gif 가 이어진다
const FIELD_LABEL = "로프 실험판";
export default function RopeChain({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ff9ebb",
}: RopeChainProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const iterValRef = useRef<HTMLSpanElement | null>(null);
const ptsRef = useRef<Pt[]>([]) as Box<Pt[]>;
const sizeRef = useRef({ W: 0, H: 0, seg: 0 }) as Box<{ W: number; H: number; seg: number }>;
const dragRef = useRef({ i: -1, x: 0, y: 0 }) as Box<{ i: number; x: number; y: number }>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(0) as Box<number>;
const iterRef = useRef(4) as Box<number>;
const colorRef = useRef({ ink: "", bg: "", knot: "", hand: "", pivot: "" }) as Box<{ ink: string; bg: string; knot: string; hand: string; pivot: string }>;
const [iter, setIter] = useState(4);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
function ctx2d(): CanvasRenderingContext2D | null {
const cv = cvRef.current;
if (!cv) return null;
return cv.getContext("2d");
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
const ctx = ctx2d();
if (!field || !cv || !ctx) return;
const r = field.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
sizeRef.current.W = r.width;
sizeRef.current.H = r.height;
cv.width = Math.round(r.width * dpr);
cv.height = Math.round(r.height * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
build();
}
function build(): void {
const { H } = sizeRef.current;
const pts: Pt[] = [];
const SEG = Math.min(26, (H - 60) / (N - 1));
sizeRef.current.seg = SEG;
const x0 = sizeRef.current.W * 0.5, y0 = 30;
for (let i = 0; i < N; i++) {
pts.push({ x: x0, y: y0 + i * SEG, px: x0, py: y0 + i * SEG, pinned: i === 0 });
}
ptsRef.current = pts;
dragRef.current.i = -1;
}
// 한 프레임 — 적분 → 제약 반복. vanilla step() 과 같은 순서·계수
function step(now: number): void {
const ctx = ctx2d();
if (!ctx) return;
const { W, H, seg } = sizeRef.current;
const pts = ptsRef.current;
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
t0Ref.current = now;
const iters = iterRef.current;
const dragging = dragRef.current.i;
// (1) 관성: 이전 위치와의 차이가 곧 속도다
for (let i = 0; i < N; i++) {
const p = pts[i];
if (p.pinned || i === dragging) continue;
const vx = (p.x - p.px) * DAMP;
const vy = (p.y - p.py) * DAMP;
p.px = p.x; p.py = p.y;
p.x += vx;
p.y += vy + GRAV * dt * dt;
}
// (2) 제약: 이웃끼리 목표 간격으로 당긴다. 한 번으로는 안 풀린다 — 반복할수록 팽팽해진다
for (let k = 0; k < iters; k++) {
for (let j = 0; j < N - 1; j++) {
const a = pts[j], b = pts[j + 1];
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const off = (d - seg) / d * 0.5;
const ax = dx * off, ay = dy * off;
const aFree = !a.pinned && j !== dragging;
const bFree = !b.pinned && j + 1 !== dragging;
if (aFree && bFree) { a.x += ax; a.y += ay; b.x -= ax; b.y -= ay; }
else if (aFree) { a.x += ax * 2; a.y += ay * 2; }
else if (bFree) { b.x -= ax * 2; b.y -= ay * 2; }
}
}
if (dragging >= 0) { pts[dragging].x = dragRef.current.x; pts[dragging].y = dragRef.current.y; }
draw(ctx, pts, W, H);
}
// 그리기 — 마디마다 두 겹(굵은 밝은 + 가는 어두운)으로 사슬 결을 낸다
function draw(ctx: CanvasRenderingContext2D, pts: Pt[], W: number, H: number): void {
const c = colorRef.current;
ctx.clearRect(0, 0, W, H);
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = c.ink;
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < N; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
ctx.strokeStyle = c.hand;
ctx.lineWidth = 3;
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < N; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.stroke();
// 고정점 핀
ctx.fillStyle = c.pivot;
ctx.beginPath();
ctx.arc(pts[0].x, pts[0].y, 6, 0, Math.PI * 2);
ctx.fill();
// 매듭 점 — 점의 개수만큼 사슬임을 눈으로 보여준다
for (let m = 1; m < N; m++) {
ctx.fillStyle = c.knot;
ctx.beginPath();
ctx.arc(pts[m].x, pts[m].y, 3.4, 0, Math.PI * 2);
ctx.fill();
}
// 끝 손잡이
const e = pts[N - 1];
ctx.fillStyle = c.hand;
ctx.beginPath();
ctx.arc(e.x, e.y, 9, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = c.ink;
ctx.beginPath();
ctx.arc(e.x, e.y, 3.5, 0, Math.PI * 2);
ctx.fill();
}
// 사람이 안 만졌을 때 도는 자동 시연 — 가상 포인터가 끝단을 8자로 끈다
function demoTarget(now: number): { x: number; y: number } {
const { W } = sizeRef.current;
const ph = (now / 1000) / LOOP * Math.PI * 2;
return {
x: W * 0.5 + Math.sin(ph) * W * 0.3,
y: 70 + Math.sin(ph * 2) * 34
};
}
function loop(now: number): void {
if (!manualRef.current) {
const pts = ptsRef.current;
const t = demoTarget(now);
pts[N - 1].x = t.x; pts[N - 1].y = t.y;
}
step(now);
rafRef.current = requestAnimationFrame(loop);
}
useEffect(() => {
fit();
// 캔버스가 쓰는 색은 module.scss 의 커스텀 프로퍼티에서 읽는다 — 값은 스타일 한 곳에만 둔다
const css = getComputedStyle(rootRef.current as Element);
colorRef.current = {
ink: css.getPropertyValue("--ink").trim(),
bg: css.getPropertyValue("--bg").trim(),
knot: css.getPropertyValue("--knot").trim(),
hand: css.getPropertyValue("--hand").trim(),
pivot: css.getPropertyValue("--pivot").trim()
};
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
t0Ref.current = performance.now();
if (!reduce.matches) rafRef.current = requestAnimationFrame(loop);
else step(performance.now()); // 줄인 모드 — 처음 장면 한 장만 그린다
function onResize(): void { fit(); }
function onUp(): void { dragRef.current.i = -1; }
function onReduceChange(): void {
if (reduce.matches) {
manualRef.current = true;
cancelAnimationFrame(rafRef.current);
step(performance.now());
} else if (!manualRef.current) {
t0Ref.current = performance.now();
rafRef.current = requestAnimationFrame(loop);
}
}
window.addEventListener("resize", onResize);
window.addEventListener("pointerup", onUp);
reduce.addEventListener("change", onReduceChange);
return () => {
window.removeEventListener("resize", onResize);
window.removeEventListener("pointerup", onUp);
reduce.removeEventListener("change", onReduceChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
// 조작 — 끝단(또는 아무 매듭)을 잡아 끈다. 잡은 점은 제약에서 손으로 고정된 취급
function pick(x: number, y: number): number {
const pts = ptsRef.current;
let best = -1, bd = 26;
for (let i = 1; i < N; i++) {
const d = Math.hypot(pts[i].x - x, pts[i].y - y);
if (d < bd) { bd = d; best = i; }
}
return best;
}
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const r = field.getBoundingClientRect();
const x = event.clientX - r.left, y = event.clientY - r.top;
const i = pick(x, y);
if (i < 0) return;
manualRef.current = true;
dragRef.current = { i, x, y };
field.setPointerCapture(event.pointerId);
if (reduce.matches) {
// 줄인 모드에서는 관성 없이 잡은 점만 옮기고 나머지는 제약이 즉시 따라온다
const pts = ptsRef.current;
pts[i].x = x; pts[i].y = y;
pts[i].px = x; pts[i].py = y;
step(performance.now());
}
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
const drag = dragRef.current;
if (drag.i < 0 || !field) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const { W, H } = sizeRef.current;
const r = field.getBoundingClientRect();
drag.x = Math.max(4, Math.min(W - 4, event.clientX - r.left));
drag.y = Math.max(4, Math.min(H - 4, event.clientY - r.top));
if (reduce.matches) {
const pts = ptsRef.current;
pts[drag.i].x = drag.x; pts[drag.i].y = drag.y;
pts[drag.i].px = drag.x; pts[drag.i].py = drag.y;
step(performance.now());
}
}
function onReset(): void {
manualRef.current = false;
build();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) { step(performance.now()); return; }
t0Ref.current = performance.now();
cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(loop);
}
return (
<main className={styles.stage}>
<div className={styles.root} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224,128a96,96,0,0,1-96,96c-11.4,0-20-8.6-20-20s8.9-20,20-20a56,56,0,0,0,0-112c-11.1,0-20-8.9-20-20s8.6-20,20-20A96,96,0,0,1,224,128Z" opacity=".2"/><path d="M128,24A104,104,0,0,0,128,232c-15.8,0-28-12.3-28-28s12.4-28,28-28a48,48,0,0,0,0-96c-15.6,0-28-12.4-28-28S112.2,24,128,24Zm0,16c-6.9,0-12,5.2-12,12s5.3,12,12,12a64,64,0,0,1,0,128c-6.7,0-12,5.4-12,12s5.1,12,12,12a88,88,0,0,0,0-176Z"/></g></svg>
<span>끝을 잡아 끌면 로프 전체가 거리 제약을 반복해 풀며 따라옵니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={iterValRef}>{iter}</span><span className={styles.badgeUnit}>제약 반복</span></b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>반복
<input
type="range" min={1} max={8} step={1} value={iter} aria-label="제약 반복 수"
onChange={(event: InputEvt) => { const v = Number(event.target.value); iterRef.current = v; setIter(v); const el = iterValRef.current; if (el) el.textContent = String(v); }}
/>
</label>
<button className={styles.reset} type="button" onClick={onReset}>처음으로</button>
</div>
</div>
</main>
);
}01 チェーンロープ揺れ — 計測 累積面積 10.067% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · 物理エンジン 作り方 9選 — Verletロープからラグドールまで
このデモが属する記事
物理エンジン 作り方 9選 — Verletロープからラグドールまで『物理エンジン 作り方』の鍵は、速度を変数に持たず前フレームとの差だけで次の位置を決めるVerlet積分と、伸びた制約を繰り返し戻す反復解法の2つにあります。9つの実験盤はライブラリなしで動き、Canvas 2Dだけでロープ・布・ゼリー・ラ この記事のデモをすべて見る


