03Soft-body jelly
Rim points held in a ring constraint get pushed outward by a pressure force derived from the gap between target and current enclosed area, so a poke wobbles the blob back to its original volume.
Best forA poke-able hero mascot, a squishy like-button feedback
requestAnimationFramepointerdowntranslateY

<main class="stage">
<div class="jb" id="jb">
<p class="jb__hint">
<svg class="jb__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>Press the jelly and pressure drops by the poked area — the rest wobbles to bring the volume back</span>
</p>
<div class="jb__field" id="field" role="application" aria-label="Jelly pressure experiment board" tabindex="0">
<canvas class="jb__cv" id="cv"></canvas>
<b class="jb__badge" aria-hidden="true"><span class="jb__badgeNum" id="prVal">100</span><span class="jb__badgeUnit">pressure %</span></b>
</div>
<div class="jb__bar">
<label class="jb__dial">pressure
<input type="range" id="pr" min="1" max="10" step="1" value="5" aria-label="pressure strength">
</label>
<button class="jb__reset" id="reset" type="button">Reset</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 말랑 젤리 — 움직임은 전부 캔버스 rAF 루프 안의 Verlet 적분 + 압력 모델에 있다.
// @keyframes 를 하나도 두지 않는다 — CSS 애니메이션이 0개여야 렌더러가 실시간으로 24장을 찍어
// 젤리가 실제로 출렁이는 모습이 담긴다(도구/demo.py 렌더(), 01·077 선례 "애니메이션": 0).
// 캔버스가 쓰는 색은 여기서 커스텀 프로퍼티로 내려보낸다 — 값은 토큰 한 곳에만 둔다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-mint;
@include stage($stage-yellow);
.jb {
--ink: #{$subject-ink};
--gloss: rgba(255, 255, 255, .55);
--pulp: #{$color};
--press: #{$subject-pink};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.jb__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;
}
.jb__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $subject-orange-deep; }
.jb__field {
position: relative; height: 160px;
border-radius: $r-card; overflow: hidden;
background: #ebe7d6;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
cursor: pointer; touch-action: none;
}
.jb__field:focus-visible { outline: 2px solid $subject-orange-deep; outline-offset: 2px; }
.jb__cv { display: block; width: 100%; height: 100%; }
// 압력 비율 — 현재 면적 / 목표 면적(%). 슬라이더와 별개로 시뮬에서 갱신된다
.jb__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;
}
.jb__badgeNum { font-size: 13px; color: $stage-yellow; }
.jb__badgeUnit { font-weight: 700; }
.jb__bar {
display: flex; align-items: center; gap: $sp-3;
}
.jb__dial {
display: flex; align-items: center; gap: $sp-2; flex: 1 1 auto;
font-weight: 700; font-size: 12px; color: $subject-ink;
}
.jb__dial input[type="range"] { flex: 1 1 auto; accent-color: $subject-orange-deep; }
.jb__reset {
flex: 0 0 auto; border: 0; border-radius: $r-pill;
padding: $sp-1 $sp-3; font-weight: 700; font-size: 12px;
background: $subject-ink; color: $subject-cream; cursor: pointer;
}
.jb__reset:active { transform: translateY(1px); }
@media (max-width: 340px) {
.jb { gap: $sp-1; }
.jb__field { height: 114px; }
.jb__badge { font-size: 10px; padding: 0 $sp-1; }
.jb__badgeNum { font-size: 11px; }
.jb__bar { gap: $sp-2; }
.jb__dial { font-size: 11px; }
.jb__reset { font-size: 11px; padding: $sp-1 $sp-2; }
}
// reduce — 자동 시연 루프는 JS 가 끈다(가상 손가락이 멈춘다).
// 누르는 조작은 살아 있고 출렁임 없이 눌린 장면 한 장만 그린다.
@media (prefers-reduced-motion: reduce) {
.jb.is-still .jb__field { cursor: default !important; }
.jb.is-still .jb__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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.jb {
--ink: #17141a;
--gloss: rgba(255, 255, 255, .55);
--pulp: #4cd4a6;
--press: #ff9ebb;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.jb__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;
}
.jb__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #c93000;
}
.jb__field {
position: relative;
height: 160px;
border-radius: 16px;
overflow: hidden;
background: #ebe7d6;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
cursor: pointer;
touch-action: none;
}
.jb__field:focus-visible {
outline: 2px solid #c93000;
outline-offset: 2px;
}
.jb__cv {
display: block;
width: 100%;
height: 100%;
}
.jb__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;
}
.jb__badgeNum {
font-size: 13px;
color: #ffd23f;
}
.jb__badgeUnit {
font-weight: 700;
}
.jb__bar {
display: flex;
align-items: center;
gap: 12px;
}
.jb__dial {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
font-weight: 700;
font-size: 12px;
color: #17141a;
}
.jb__dial input[type=range] {
flex: 1 1 auto;
accent-color: #c93000;
}
.jb__reset {
flex: 0 0 auto;
border: 0;
border-radius: 999px;
padding: 4px 12px;
font-weight: 700;
font-size: 12px;
background: #17141a;
color: #fff7e6;
cursor: pointer;
}
.jb__reset:active {
transform: translateY(1px);
}
@media (max-width: 340px) {
.jb {
gap: 4px;
}
.jb__field {
height: 114px;
}
.jb__badge {
font-size: 10px;
padding: 0 4px;
}
.jb__badgeNum {
font-size: 11px;
}
.jb__bar {
gap: 8px;
}
.jb__dial {
font-size: 11px;
}
.jb__reset {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.jb.is-still .jb__field {
cursor: default !important;
}
.jb.is-still .jb__badge {
opacity: 0.92 !important;
}
}// 말랑 젤리 — 소프트바디의 정석 "pressure 모델". 테두리 점 N 개를 이웃 거리 제약(+ 대각 보강)으로 묶고,
// 슈루스호크 공식으로 구한 현재 다각형 면적이 목표 면적에 못 미치는 만큼을 "pressure"으로 바꿔
// 각 테두리 점을 바깥 법선 방향으로 민다: F = (A0 - A) / A0 * k * edgeNormal
// 속도 변수는 없다 — 위치 기반 Verlet 적분 next = pos + (pos - prev) * damp + accel * dt^2 뿐.
// 누르면(pointer) 테두리가 안쪽으로 밀려 면적이 줄고, 떼면 압력이 면적을 다시 채우며 출렁인다.
var root = document.getElementById('jb');
var field = document.getElementById('field');
var cv = document.getElementById('cv');
var prIn = document.getElementById('pr');
var prVal = document.getElementById('prVal');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');
var ctx = cv.getContext('2d');
var css = getComputedStyle(root);
var INK = css.getPropertyValue('--ink').trim();
var GLOSS = css.getPropertyValue('--gloss').trim();
var PULP = css.getPropertyValue('--pulp').trim();
var PRESS = css.getPropertyValue('--press').trim();
var FONT = '-apple-system, "Segoe UI", "Noto Sans KR", sans-serif';
var N = 20; // 테두리 점 수
var GRAV = 900; // 중력(px/s^2) — 젤리는 무거우니 낮게
var DAMP = 0.988; // 속도 보존률(젤리 특유의 출렁 거듭감쇠)
var LOOP = 2; // 자동 시연 한 바퀴(초)
var pts = []; // 점: {x, y, px, py, nx, ny(법선)}
var RAD = 0, AREA0 = 0; // 시작 반지름 · 목표 면적
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 = [];
RAD = Math.min(W, H) * 0.30;
var cx = W * 0.5, cy = H * 0.44;
for (var i = 0; i < N; i++) {
var a = i / N * Math.PI * 2 - Math.PI / 2;
var x = cx + Math.cos(a) * RAD, y = cy + Math.sin(a) * RAD;
pts.push({ x: x, y: y, px: x, py: y, nx: 0, ny: 0 });
}
AREA0 = area();
pressing = false;
}
// 슈루스호크 공식 — 다각형 면적. 시계 반대 순회면 양수가 나온다
function area() {
var s = 0;
for (var i = 0; i < N; i++) {
var a = pts[i], b = pts[(i + 1) % N];
s += a.x * b.y - b.x * a.y;
}
return Math.abs(s) * 0.5;
}
// 이웃 거리를 목표 간격으로 못 박는 제약 한 번. idx 원 주위로 민다
function solveDist(a, b, len, rigid) {
var dx = b.x - a.x, dy = b.y - a.y;
var d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
var off = (d - len) / d * 0.5 * rigid;
var ox = dx * off, oy = dy * off;
a.x += ox; a.y += oy;
b.x -= ox; b.y -= oy;
}
// 한 프레임 — 적분 → pressure → solver passes → 누름·바닥
function step(now) {
var dt = Math.min((now - t0) / 1000, 0.033);
t0 = now;
var k = Number(prIn.value);
var rest = RAD * Math.PI * 2 / N; // 이웃 목표 spacing(시작 원 둘레 기준)
// (1) 관성 + 중력
for (var i = 0; i < N; i++) {
var p = pts[i];
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) pressure — 면적 부족분을 법선 방향 힘으로. 법선은 이웃 점의 차분으로 계산한다
var A = area();
var press = (AREA0 - A) / AREA0 * k * 7200 * dt * dt;
for (var v = 0; v < N; v++) {
var q = pts[v];
var pr = pts[(v + N - 1) % N], nx2 = pts[(v + 1) % N];
var tx = nx2.x - pr.x, ty = nx2.y - pr.y;
var tl = Math.hypot(tx, ty) || 0.0001;
// 점은 반시계로 배치했다(슈루스호크 양수). 이 순회에서 바깥 법선은 (ty, -tx) —
// 부호를 반대로 쓰면 부족한 면적을 안쪽으로 밀어 젤리가 스스로 접혀버린다
q.nx = ty / tl; q.ny = -tx / tl;
q.x += q.nx * press;
q.y += q.ny * press;
}
// (3) solver passes — 이웃 거리 + 한 칸 건너 대각 보강(찌그러짐 방지)
for (var it = 0; it < 5; it++) {
for (var c = 0; c < N; c++) solveDist(pts[c], pts[(c + 1) % N], rest, 1);
for (var g = 0; g < N; g++) solveDist(pts[g], pts[(g + 2) % N], rest * 2 * 0.92, 0.24);
}
// (4) 누름 — 누른 점 방향으로 가장 가까운 테두리 점 몇 개를 민다
if (pressing) {
for (var m = 0; m < N; m++) {
var s = pts[m];
var dxp = s.x - ptrX, dyp = s.y - ptrY;
var dp = Math.hypot(dxp, dyp);
if (dp < RAD * 0.7) {
var push = (1 - dp / (RAD * 0.7)) * 8;
s.x += dxp / (dp || 1) * push;
s.y += dyp / (dp || 1) * push;
}
}
}
// (5) 바닥 스프링 — 젤리가 바닥에 쿵 박지 않고 살짝 눌러 앉는다
var floorY = H - RAD * 0.36;
for (var f = 0; f < N; f++) {
var t = pts[f];
if (t.y > floorY) t.y = floorY + (t.y - floorY) * 0.70;
}
}
function draw() {
ctx.clearRect(0, 0, W, H);
// 몸통 — 테두리를 2차 곡선으로 이어 젤리의 동글동글한 결을 낸다
var cx = 0, cy = 0;
for (var q = 0; q < N; q++) { cx += pts[q].x; cy += pts[q].y; }
cx /= N; cy /= N;
ctx.beginPath();
ctx.moveTo((pts[0].x + pts[N - 1].x) * 0.5, (pts[0].y + pts[N - 1].y) * 0.5);
for (var i = 0; i < N; i++) {
var a = pts[i], b = pts[(i + 1) % N];
ctx.quadraticCurveTo(a.x, a.y, (a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
}
ctx.closePath();
ctx.fillStyle = PULP;
ctx.fill();
// 윤곽선 — 몸통 경로 그대로. 광택을 그린 뒤에 stroke 하면
// '현재 경로'가 타원으로 바뀌어 있어 몸통이 아니라 타원에 줄이 쳐진다.
ctx.lineJoin = 'round';
ctx.lineWidth = 3;
ctx.strokeStyle = INK;
ctx.stroke();
// 광택 — 젤리 반사 하이라이트. 몸통 중심을 따라가게(화면 고정이면 가라앉은 젤리와 어긋난다)
ctx.save();
ctx.clip();
ctx.fillStyle = GLOSS;
ctx.beginPath();
ctx.ellipse(cx - RAD * 0.26, cy - RAD * 0.42, RAD * 0.34, RAD * 0.2, -0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// 누르는 커서 표시
if (pressing) {
ctx.fillStyle = PRESS;
ctx.beginPath();
ctx.arc(ptrX, ptrY, 7, 0, Math.PI * 2);
ctx.fill();
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 가상 손가락이 8자로 젤리를 누른다.
// Math.random 금지 — 사인파로 완전 결정적이다. pointerdown 이 오면 manual 로 넘어간다.
var manual = false;
var pressing = false;
var ptrX = 0, ptrY = 0;
function demoTarget(now) {
var ph = (now / 1000) / LOOP * Math.PI * 2;
return {
x: W * 0.5 + Math.sin(ph) * W * 0.30,
y: H * 0.44 + Math.sin(ph * 2 + 1.2) * RAD * 0.75
};
}
var frameN = 0;
function frame(now) {
if (!manual) {
var t = demoTarget(now);
ptrX = t.x; ptrY = t.y;
// 8자 원점이 젤리 중심 근처로 지나갈 때만 누른 것으로 취급
pressing = Math.hypot(t.x - W * 0.5, t.y - H * 0.44) < RAD * 0.9;
}
step(now);
draw();
frameN++;
if (frameN % 12 === 0) prVal.textContent = Math.round(area() / AREA0 * 100);
requestAnimationFrame(frame);
}
// 조작 — 젤리를 눌러 민다. 젤리 내부(무게중심 기준)를 찍어도 눌린다
function inside(x, y) {
var cx = 0, cy = 0;
for (var i = 0; i < N; i++) { cx += pts[i].x; cy += pts[i].y; }
cx /= N; cy /= N;
return Math.hypot(x - cx, y - cy) < RAD * 1.25;
}
field.addEventListener('pointerdown', function (e) {
var r = field.getBoundingClientRect();
var x = e.clientX - r.left, y = e.clientY - r.top;
manual = true;
pressing = true;
ptrX = x; ptrY = y;
field.setPointerCapture(e.pointerId);
if (reduce.matches) {
// 줄인 모드에서는 출렁임 없이 눌린 장면만 그린다
step(performance.now());
draw();
}
});
field.addEventListener('pointermove', function (e) {
if (!pressing) return;
var r = field.getBoundingClientRect();
ptrX = e.clientX - r.left;
ptrY = e.clientY - r.top;
});
window.addEventListener('pointerup', function () { pressing = false; });
document.getElementById('reset').addEventListener('click', function () {
manual = false; build();
prVal.textContent = 100;
});
prIn.addEventListener('input', function () { prVal.textContent = Math.round(area() / AREA0 * 100); });
window.addEventListener('resize', fit);
fit();
requestAnimationFrame(frame);// SoftbodyJelly — vanilla 판(run/306/demo/03-softbody-pressure-jelly/index.html) 의 React 포트.
// 압력 모델 소프트바디: 테두리 점 20개를 이웃 거리 제약(+대각 보강)으로 묶고,
// 슈루스호크 공식으로 구한 다각형 면적의 목표 대비 부족분을 바깥 법선 방향 "압력"으로 바꿔 민다.
// 위치 기반 Verlet 적분(속도 변수 없음). 값·주석 의미는 vanilla 와 같다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./SoftbodyJelly.module.scss";
export type SoftbodyJellyProps = {
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; nx: number; ny: number };
const N = 20; // 테두리 점 수
const GRAV = 900; // 중력(px/s^2)
const DAMP = 0.988; // 속도 보존률(출렁 거듭감쇠)
const LOOP = 2; // 자동 시연 한 바퀴(초)
const SPRING = 8; // 바닥 스프링 강도
const FIELD_LABEL = "젤리 압력 실험판";
export default function SoftbodyJelly({
duration = 2000,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#4cd4a6",
}: SoftbodyJellyProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const prValRef = useRef<HTMLSpanElement | null>(null);
const prInRef = useRef<HTMLInputElement | null>(null);
const ptsRef = useRef<Pt[]>([]) as Box<Pt[]>;
const geoRef = useRef({ W: 0, H: 0, RAD: 0, AREA0: 0 }) as Box<{ W: number; H: number; RAD: number; AREA0: number }>;
const ptrRef = useRef({ pressing: false, x: 0, y: 0 }) as Box<{ pressing: boolean; 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 frameRef = useRef(0) as Box<number>;
const [press, setPress] = useState(5);
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 area(): number {
const pts = ptsRef.current;
let s = 0;
for (let i = 0; i < N; i++) {
const a = pts[i], b = pts[(i + 1) % N];
s += a.x * b.y - b.x * a.y;
}
return Math.abs(s) * 0.5;
}
function build(): void {
const g = geoRef.current;
const pts: Pt[] = [];
g.RAD = Math.min(g.W, g.H) * 0.30;
const cx = g.W * 0.5, cy = g.H * 0.44;
for (let i = 0; i < N; i++) {
const a = (i / N) * Math.PI * 2 - Math.PI / 2;
const x = cx + Math.cos(a) * g.RAD, y = cy + Math.sin(a) * g.RAD;
pts.push({ x, y, px: x, py: y, nx: 0, ny: 0 });
}
ptsRef.current = pts;
g.AREA0 = area();
ptrRef.current.pressing = false;
}
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;
geoRef.current.W = r.width;
geoRef.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 solveDist(a: Pt, b: Pt, len: number, rigid: number): void {
const dx = b.x - a.x, dy = b.y - a.y;
const d = Math.sqrt(dx * dx + dy * dy) || 0.0001;
const off = ((d - len) / d) * 0.5 * rigid;
const ox = dx * off, oy = dy * off;
a.x += ox; a.y += oy;
b.x -= ox; b.y -= oy;
}
// 한 프레임 — 적분 → 압력 → 제약 반복 → 누름·바닥. vanilla step() 과 같은 순서·계수
function step(now: number): void {
const ctx = ctx2d();
if (!ctx) return;
const { W, H, RAD, AREA0 } = geoRef.current;
const pts = ptsRef.current;
const dt = Math.min((now - t0Ref.current) / 1000, 0.033);
t0Ref.current = now;
const k = prInRef.current ? Number(prInRef.current.value) : 5;
const rest = (RAD * Math.PI * 2) / N;
// (1) 관성 + 중력
for (let i = 0; i < N; i++) {
const p = pts[i];
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) 압력 — 면적 부족분을 법선 방향 힘으로
const A = area();
const pressF = ((AREA0 - A) / AREA0) * k * 30 * dt * dt;
for (let v = 0; v < N; v++) {
const q = pts[v];
const pr = pts[(v + N - 1) % N], nx2 = pts[(v + 1) % N];
const tx = nx2.x - pr.x, ty = nx2.y - pr.y;
const tl = Math.hypot(tx, ty) || 0.0001;
// 시계 반대 순회에서 접선의 왼쪽(-ty, tx)이 바깥 법선이다
q.nx = -ty / tl; q.ny = tx / tl;
q.x += q.nx * pressF;
q.y += q.ny * pressF;
}
// (3) 제약 반복 — 이웃 거리 + 대각 보강
for (let it = 0; it < 5; it++) {
for (let c = 0; c < N; c++) solveDist(pts[c], pts[(c + 1) % N], rest, 1);
for (let g = 0; g < N; g++) solveDist(pts[g], pts[(g + 2) % N], rest * 2 * 0.88, 0.24);
}
// (4) 누름 — 누른 점 방향으로 가장 가까운 테두리 점 몇 개를 민다
if (ptrRef.current.pressing) {
for (let m = 0; m < N; m++) {
const s = pts[m];
const dxp = s.x - ptrRef.current.x, dyp = s.y - ptrRef.current.y;
const dp = Math.hypot(dxp, dyp);
if (dp < RAD * 0.9) {
const push = (1 - dp / (RAD * 0.9)) * 26;
s.x += (dxp / (dp || 1)) * push;
s.y += (dyp / (dp || 1)) * push;
}
}
}
// (5) 바닥 스프링
const floorY = H - RAD * 0.36;
for (let f = 0; f < N; f++) {
const t = pts[f];
if (t.y > floorY) t.y = floorY - (t.y - floorY) * (1 - 1 / (1 + SPRING * 0.001));
}
}
function draw(ink: string, gloss: string, pulp: string, pressCol: string): void {
const ctx = ctx2d();
if (!ctx) return;
const { W, H, RAD } = geoRef.current;
const pts = ptsRef.current;
ctx.clearRect(0, 0, W, H);
// 몸통 — 테두리를 2차 곡선으로 이어 젤리의 동글동글한 결을 낸다
ctx.beginPath();
ctx.moveTo((pts[0].x + pts[N - 1].x) * 0.5, (pts[0].y + pts[N - 1].y) * 0.5);
for (let i = 0; i < N; i++) {
const a = pts[i], b = pts[(i + 1) % N];
ctx.quadraticCurveTo(a.x, a.y, (a.x + b.x) * 0.5, (a.y + b.y) * 0.5);
}
ctx.closePath();
ctx.fillStyle = pulp;
ctx.fill();
// 윤곽선 — 몸통 경로 그대로. 광택을 그린 뒤에 stroke 하면
// '현재 경로'가 타원으로 바뀌어 있어 몸통이 아니라 타원에 줄이 쳐진다.
ctx.lineJoin = "round";
ctx.lineWidth = 3;
ctx.strokeStyle = ink;
ctx.stroke();
// 광택 — 젤리 반사 하이라이트. 몸통 중심을 따라가게(화면 고정이면 가라앉은 젤리와 어긋난다)
let cx = 0, cy = 0;
for (let q = 0; q < N; q++) { cx += pts[q].x; cy += pts[q].y; }
cx /= N; cy /= N;
ctx.save();
ctx.clip();
ctx.fillStyle = gloss;
ctx.beginPath();
ctx.ellipse(cx - RAD * 0.26, cy - RAD * 0.42, RAD * 0.34, RAD * 0.2, -0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (ptrRef.current.pressing) {
ctx.fillStyle = pressCol;
ctx.beginPath();
ctx.arc(ptrRef.current.x, ptrRef.current.y, 7, 0, Math.PI * 2);
ctx.fill();
}
}
// 사람이 안 만졌을 때 도는 자동 시연 — 가상 손가락이 8자로 젤리를 누른다(사인파, 난수 없음)
function demoTarget(now: number): { x: number; y: number } {
const { W, H, RAD } = geoRef.current;
const ph = ((now / 1000) / LOOP) * Math.PI * 2;
return {
x: W * 0.5 + Math.sin(ph) * W * 0.30,
y: H * 0.44 + Math.sin(ph * 2 + 1.2) * RAD * 0.75
};
}
function stillFrame(c: { ink: string; gloss: string; pulp: string; press: string }): void {
// 줄인 모션 — 출렁임 없이 눌린 장면 한 장만 그린다
t0Ref.current = performance.now();
step(performance.now());
draw(c.ink, c.gloss, c.pulp, c.press);
}
useEffect(() => {
fit();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
// 캔버스가 쓰는 색은 module.scss 의 커스텀 프로퍼티에서 읽는다 — 값은 스타일 한 곳에만 둔다
const css = getComputedStyle(rootRef.current as Element);
const c = {
ink: css.getPropertyValue("--ink").trim() || "#17141a",
gloss: css.getPropertyValue("--gloss").trim() || "rgba(255,255,255,.55)",
pulp: css.getPropertyValue("--pulp").trim() || "#4cd4a6",
press: css.getPropertyValue("--press").trim() || "#ff9ebb"
};
t0Ref.current = performance.now();
function frame(now: number): void {
if (!manualRef.current) {
const t = demoTarget(now);
ptrRef.current.x = t.x; ptrRef.current.y = t.y;
const { W, H, RAD } = geoRef.current;
ptrRef.current.pressing = Math.hypot(t.x - W * 0.5, t.y - H * 0.44) < RAD * 0.9;
}
step(now);
draw(c.ink, c.gloss, c.pulp, c.press);
frameRef.current++;
if (frameRef.current % 12 === 0 && prValRef.current) {
prValRef.current.textContent = String(Math.round((area() / geoRef.current.AREA0) * 100));
}
rafRef.current = requestAnimationFrame(frame);
}
if (!reduce.matches) rafRef.current = requestAnimationFrame(frame);
else stillFrame(c);
function onResize(): void { fit(); }
function onReduceChange(): void {
if (reduce.matches) {
manualRef.current = true;
cancelAnimationFrame(rafRef.current);
stillFrame(c);
} else if (!manualRef.current) {
t0Ref.current = performance.now();
rafRef.current = requestAnimationFrame(frame);
}
}
window.addEventListener("resize", onResize);
reduce.addEventListener("change", onReduceChange);
return () => {
window.removeEventListener("resize", onResize);
reduce.removeEventListener("change", onReduceChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
function onPointerDown(event: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
manualRef.current = true;
ptrRef.current.pressing = true;
const r = field.getBoundingClientRect();
ptrRef.current.x = event.clientX - r.left;
ptrRef.current.y = event.clientY - r.top;
field.setPointerCapture(event.pointerId);
}
function onPointerMove(event: ReactPointerEvent<HTMLDivElement>): void {
if (!ptrRef.current.pressing) return;
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
ptrRef.current.x = event.clientX - r.left;
ptrRef.current.y = event.clientY - r.top;
}
function onPointerUp(): void {
ptrRef.current.pressing = false;
}
function onReset(): void {
manualRef.current = false;
build();
if (prValRef.current) prValRef.current.textContent = "100";
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
if (reduce.matches) return; // 줄인 모드에서는 정지 장면만 둔다
t0Ref.current = performance.now();
cancelAnimationFrame(rafRef.current);
const css = getComputedStyle(rootRef.current as Element);
const c = {
ink: css.getPropertyValue("--ink").trim() || "#17141a",
gloss: css.getPropertyValue("--gloss").trim() || "rgba(255,255,255,.55)",
pulp: css.getPropertyValue("--pulp").trim() || "#4cd4a6",
press: css.getPropertyValue("--press").trim() || "#ff9ebb"
};
rafRef.current = requestAnimationFrame(function loop(now: number): void {
if (!manualRef.current) {
const t = demoTarget(now);
ptrRef.current.x = t.x; ptrRef.current.y = t.y;
const { W, H, RAD } = geoRef.current;
ptrRef.current.pressing = Math.hypot(t.x - W * 0.5, t.y - H * 0.44) < RAD * 0.9;
}
step(now);
draw(c.ink, c.gloss, c.pulp, c.press);
frameRef.current++;
if (frameRef.current % 12 === 0 && prValRef.current) {
prValRef.current.textContent = String(Math.round((area() / geoRef.current.AREA0) * 100));
}
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}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
<b className={styles.badge} aria-hidden="true"><span className={styles.badgeNum} ref={prValRef}>100</span><span className={styles.badgeUnit}>압력 비율%</span></b>
</div>
<div className={styles.bar}>
<label className={styles.dial}>압력
<input
ref={prInRef}
type="range" min={1} max={10} step={1} value={press} aria-label="압력 세기"
onChange={(event: InputEvt) => setPress(Number(event.target.value))}
/>
</label>
<button className={styles.reset} type="button" onClick={onReset}>처음으로</button>
</div>
</div>
</main>
);
}03 Soft-body jelly — Measured area 6.381% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Verlet Physics 9 Demos: No Library, Just Canvas
The post this demo lives in
Verlet Physics 9 Demos: No Library, Just CanvasVerlet physics moves a point by the gap between where it is now and where it was last frame — no velocity stored. Nine c See all demos in this post
If you like this effect - similar demos
Creative UI01 Spring followJavaScript Physics Simulation: 9 Demos, No Library
Creative UI05 Draw your own line — sketch it, and circles redraw it liveFourier Epicycles, 9 Ways (No Library)
Creative UI07 Launch a comet — drag angle and power to throw a star in yourselfN Body Simulation, 9 Ways (Galaxy, No Library)