낙엽 떨어지는 효과 9종 — 흔들림·바람·단풍 물듦·쌓이는 더미
낙엽 떨어지는 효과는 가지를 떠난 잎이 바람에 흔들리며 내려와 땅에 쌓이는 가을 장면을 웹 페이지 위에 움직이게 그리는 애니메이션이다. 잎을 곧장 아래로 떨어뜨리면 돌멩이처럼 보인다.
잎 한 장이 가지를 떠나 땅에 눕기까지 — 바람·빛·색·더미를 따로 계산한 장면
01 leaf-flutter-lift-drag-glide
02 leaf-3d-tumble-light-backside
03 leaf-noise-wind-gust-front
04 leaf-color-turn-vein-distance
05 leaf-stem-sway-fatigue-snap
06 leaf-ground-heap-repose-slide
07 leaf-broom-sweep-pressure-scatter
08 leaf-whirl-vortex-spiral-lift
09 leaf-hero-autumn-tree-depth-scene
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 기운 각도로 옆으로 미끄러지며 흔들리는 잎
- 02 세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎
- 03 돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람
- 04 잎맥에서 먼 가장자리부터 물드는 단풍
- 05 흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎
- 06 비탈이 가팔라지면 흘러내리는 낙엽 더미
- 07 빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당
- 08 회오리에 말려 나선으로 솟았다 흩어지는 잎
- 09 깊이마다 크기와 흐림이 다른 가을 나무 장면
아홉 장면은 사진이나 이미지 파일 없이 캔버스 2D 위에 잎 윤곽 72점과 잎맥을 계산으로 그리고, 시드를 고정해 열 때마다 같은 배치와 같은 바람으로 혼자 시연한다. 손을 대면 덧붙는 경로가 열린다. 01 은 포인터 둘레 80px 에 160px/s 옆바람을 불고, 03 은 포인터 둘레 70px 에 220px/s 돌풍을 일으키며, 07 은 빗자루가 정해 둔 길 대신 포인터를 따라간다. 캔버스 안 좌표는 480×300 으로 고정이고, 실제 화소는 화면 배율을 곱하되 2배를 넘지 않는다.
가을 이벤트 배너와 시즌 랜딩 첫 화면
가을 캠페인 랜딩의 첫 화면이라면 09 가 맞는다. 앞 층 잎은 크고 선명하게, 먼 층 잎은 작고 흐리게 내려오므로 제목 글자를 얹을 자리 뒤로 깊이가 생긴다. 폭이 좁은 계절 이벤트 배너에는 앞뒤로 뒤집히며 밝기가 바뀌는 02 의 여덟 장이 어울리고, 가을 낙엽 배경을 은은하게 깔고 싶은 시즌 랜딩에는 잎 세 장만 지그재그로 내려오는 01 이 덜 시끄럽다.
페이지 아래 장식부터 바람 화면까지
페이지 하단의 계절 장식에는 06 의 더미가 어울린다. 잎이 쌓일수록 둔덕이 자라다가 비탈이 가팔라지면 옆으로 흘러내려서, 바닥 한 줄에 낙엽 떨어지는 풍경이 담긴다. 날씨 앱의 바람 화면에는 돌풍 띠가 지나가는 03, 청소·정리 서비스 소개에는 빗자루가 잎을 쓸어 흩는 07, 가을 인사 카드에는 꼭지가 하나씩 끊기는 05, 이벤트 시작 연출에는 회오리가 잎을 들어 올리는 08, 계절이 바뀌는 상품 섹션에는 잎 한 장이 물드는 04 를 둔다.
낙엽 떨어지는 모습은 왜 곧게 떨어지면 돌멩이처럼 어색한가?
잎은 얇고 넓어서, 떨어지는 동안 몸이 기운 각도에 따라 공기가 잎을 옆으로 떠민다. 곧게 떨어뜨리는 애니메이션에는 이 옆 힘이 없으니 속도만 다를 뿐 무거운 물체가 떨어지는 길과 같아진다. CSS 입자 배경 편 의 벚꽃 잎은 키프레임으로 정해 둔 길을 따라가고 떨어지는 글자 편 은 바람에 나뭇잎처럼 흔들리는 글자를 다뤘는데, 이 편의 01 은 기울기에서 힘을 계산해 그 길이 저절로 나오게 한다.
기울기가 만드는 옆 미끄러짐
받음각 α 는 잎 면과 공기 흐름이 이루는 각이다. 양력은 |sin α·cos α| 에 비례해 45° 에서 가장 크고, 잎이 납작하게 누웠거나 칼날처럼 섰을 때 0 이 된다. 기운 잎은 옆으로 미끄러지며 속도가 붙고, 속도가 붙을수록 u² 를 곱한 양력도 커진다. 01 의 세 장에서 양력 최대는 997·994·934 이고, 받음각은 최대 88~90° 까지 돈다.
기울기를 되돌리는 회전력이 만드는 흔들림
미끄러지기만 하면 잎은 한쪽으로 날아가 버린다. 01 은 기울기의 sin 에 RIGHTING_K 14 를 곱한 회전력으로 잎을 수평 쪽으로 되돌리고, 각속도는 SPIN_DAMP 1.5 로 줄여 간다. 되돌아오는 사이 sin α 의 부호가 바뀌면 양력이 미는 쪽도 반대가 되어, 가로 속도가 4~5번 방향을 바꾸는 지그재그가 나온다. 양력 계수를 0.6 으로 낮춘 판에서는 옆 폭이 ±1.6~5.5px 로 줄어 거의 곧게 떨어지는데, 곧은 낙하가 왜 어색한지를 거꾸로 보여 주는 셈이다.
기울기는 양력으로, 바람은 노이즈로, 색은 잎맥 거리로 — 잎 한 장을 움직이는 계산
아홉 장면이 나눠 쓰는 계산 묶음은 넷이다. 01·05·09 는 흔들림, 05·09 는 꼭지 스프링, 03·05·09 는 바람, 06·09 는 더미 묶음을 쓰고, 02·04·07·08 은 묶음 없이 제 계산만 쓴다. 흔들림·바람 두 묶음과 04 의 제 계산을 숫자로 풀면 이렇다.
양력 sin 2α 와 저항 sin²α
저항은 AIR_K 0.075 × u² × (0.1 + sin²α) 라 잎이 공기를 정면으로 받을수록 커지고, 양력은 같은 u² 에 LEAF_LIFT_GAIN 1.6 과 |sin α·cos α| 를 곱한다. |sin α·cos α| 는 ½·|sin 2α| 와 같으니, 양력은 저항이 가장 큰 90° 가 아니라 그 절반인 45° 에서 정점을 찍는다. 저항은 공기 속도 반대쪽으로, 양력은 속도에 수직인 쪽으로 건다. 01 의 저항 최대는 482·480·456 으로 양력 최대의 절반쯤이다.
자리마다 다른 바람과 지나가는 띠
03 의 바람은 정수 격자에 값을 두고 3차 곡선으로 이어 붙인 부드러운 노이즈다. 척도 WIND_SCALE 0.007 이라 계산으로는 1/0.007 ≈ 143px 를 가야 격자 한 칸을 넘고, WIND_DRIFT 0.4 로 시간이 흐르며 바람 무늬가 옆으로 밀린다. 가로 바람은 기본 34 에 노이즈 × 46 을 더하고, 돌풍 띠 가운데는 시각 × GUST_SPEED 를 판 폭 + 띠 폭 여섯 배 + 간격 420 으로 나눈 나머지를 따라 왼쪽에서 오른쪽으로 되풀이해 지나간다. 노이즈 바람에 떠다니는 비눗방울 편 의 표류와 갈리는 지점이 이 띠로, 화면을 가로지르는 전선 안에 든 잎만 세게 민다.
잎맥까지의 거리로 정하는 물드는 순서
04 는 잎 안 픽셀마다 가장 가까운 잎맥 선분까지의 거리를 재고 최댓값으로 나눠 0~1 로 만든 다음, 0.66 × 거리 + 0.34 × 얼룩 노이즈를 그 픽셀이 물드는 순서로 삼는다. 순서 값은 0.01~0.881 에 퍼져 있고, 시간이 갈수록 내려오는 문턱을 먼저 넘는 가장자리부터 색이 바뀐다. 잎맥을 자라게 그리는 제너러티브 아트 편 과 달리 여기서 잎맥은 미리 정해 두고, 그 잎맥까지의 거리만 쓴다.
낙엽 떨어지는 효과 9종 — 양력 흔들림·세 축 뒤집힘·돌풍 띠·단풍 물듦·꼭지 끊김·흘러내리는 더미·빗자루 바람·회오리·가을 나무
데모마다 2초를 돌리며 24장을 캡처하고, 앞뒤 장의 차이 23개로 움직인 넓이와 세기를 셌다. 140장이 판 전체를 흐르는 03 이 71.5% 로 가장 넓고, 꼭지 여섯 개가 흔들리다 잎을 떨구는 05 가 8.5% 로 가장 좁다. 06 은 22/23, 나머지 여덟은 23/23 이다.
장면마다 잰 넓이·프레임·강도
| 항목 | 바뀐 넓이(누적면적) | 움직인 프레임 | 강도 평균 |
|---|---|---|---|
| 01 양력 흔들림 | 13.5% | 23/23 | 91.5 |
| 02 세 축 뒤집힘 | 29.3% | 23/23 | 70.0 |
| 03 돌풍 띠 | 71.5% | 23/23 | 83.6 |
| 04 단풍 물듦 | 22.3% | 23/23 | 29.1 |
| 05 꼭지 끊김 | 8.5% | 23/23 | 67.5 |
| 06 흘러내리는 더미 | 12.9% | 22/23 | 84.6 |
| 07 빗자루 바람 | 31.2% | 23/23 | 54.2 |
| 08 회오리 | 20.8% | 23/23 | 51.2 |
| 09 가을 나무 | 34.9% | 23/23 | 69.1 |
08 의 회오리는 오로라 배경 편 의 포인터 소용돌이 빛 가루처럼 속도장을 쓰지만, 가운데 통째 회전과 바깥 감쇠에 위로 끄는 바람을 더해 잎을 3차원 나선으로 띄운다는 점이 다르다.
01기운 각도로 옆으로 미끄러지며 흔들리는 잎
잎 세 장이 저마다 다른 기울기로 출발해, 받음각에서 나온 양력이 옆으로 밀고 복원 회전력이 기울기를 되돌리는 사이 지그재그로 내려온다. 아래 flutterStep 은 잎 속도에서 바람을 뺀 공기 속도를 잎 면 방향과 법선으로 나눠 저항과 양력을 따로 구하고, 첫 잎에는 두 힘을 화살표로 겹쳐 그린다. 소스 함수를 그대로 돌려 보면 세 장은 처음 자리 왼쪽으로 26.6~28.9px, 오른쪽으로 22.6~34.2px 를 오가며 3,872~4,912ms 에 바닥에 닿는다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="기운 각도에 따라 양력과 저항을 받아 옆으로 미끄러지며 흔들리듯 내려오는 잎 세 장."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x, y, tilt) {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f, windX, windY, dt, liftGain) {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) {
px /= pl;
py /= pl;
}
else {
px = 0;
py = 0;
}
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
// 처음 자리 x, y 와 기운 각(라디안)
const GLIDER_STARTS = [[128, 30, 0.6], [338, 14, -0.9], [236, -18, 0.3]];
const GLIDER_KINDS = [0, 1, 2];
const GLIDER_SIZE = 46;
const GROUND_Y = 272;
const START_MS = 200;
const LOOP_MS = 6400;
// 자취 — TRAIL_EVERY 걸음마다 한 점, 많아야 TRAIL_MAX 점
const TRAIL_EVERY = 4;
const TRAIL_MAX = 120;
// 힘 화살표 길이 (px 당 px/s²)
const ARROW_SCALE = 0.12;
// 포인터 바람 — 포인터에서 POINTER_RADIUS 안의 잎을 옆으로 민다 (px/s)
const POINTER_WIND = 160;
const POINTER_RADIUS = 80;
const ANGLE_LABEL = '받음각 {a}° · 양력 {l} · 저항 {d}';
const FORMULA_LABEL = '양력 ∝ sin 2α · 저항 ∝ sin²α';
let gliders = [];
let t = 0;
let steps = 0;
function build() {
gliders = GLIDER_STARTS.map(function (s, i) {
return { f: makeFlyer(s[0], s[1], s[2]), kind: GLIDER_KINDS[i], color: LEAF_COLORS[i * 2 % LEAF_COLORS.length], size: GLIDER_SIZE, trail: [], landed: false };
});
t = 0;
steps = 0;
}
function loopLength() { return LOOP_MS; }
function pointerWindAt(x, y) {
if (hoverX < 0) {
return 0;
}
const dx = x - hoverX;
const dy = y - hoverY;
const d2 = dx * dx + dy * dy;
return Math.sign(dx || 1) * POINTER_WIND * Math.exp(-d2 / (POINTER_RADIUS * POINTER_RADIUS));
}
function step(dt) {
t += dt * 1000;
if (t < START_MS) {
return;
}
steps += 1;
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
if (g.landed) {
continue;
}
flutterStep(g.f, pointerWindAt(g.f.x, g.f.y), 0, dt, LEAF_LIFT_GAIN);
if (steps % TRAIL_EVERY === 0) {
g.trail.push([g.f.x, g.f.y]);
if (g.trail.length > TRAIL_MAX) {
g.trail.shift();
}
}
if (g.f.y >= GROUND_Y) {
g.landed = true;
g.f.y = GROUND_Y;
g.f.tilt = 0;
}
}
}
// 동작 줄이기 — 셋 모두 바닥에 내려앉을 때까지 시뮬레이션을 그리지 않고 돌린 장면(지나온 자취 그대로)
function finalScene() {
build();
for (let i = 0; i < 900; i += 1) {
step(STEP_S);
}
}
function onWake() { }
function arrow(x, y, dx, dy, color) {
const len = Math.hypot(dx, dy);
if (len < 2) {
return;
}
const ux = dx / len;
const uy = dy / len;
ctx.strokeStyle = rgba(color, 0.95);
ctx.fillStyle = rgba(color, 0.95);
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + dx, y + dy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + dx, y + dy);
ctx.lineTo(x + dx - ux * 7 - uy * 4, y + dy - uy * 7 + ux * 4);
ctx.lineTo(x + dx - ux * 7 + uy * 4, y + dy - uy * 7 - ux * 4);
ctx.closePath();
ctx.fill();
}
function draw() {
clearSky();
drawGround(GROUND_Y + 8);
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
ctx.fillStyle = rgba(mixRGB(g.color, INK, 0.25), 0.55);
for (let k = 0; k < g.trail.length; k += 1) {
ctx.beginPath();
ctx.arc(g.trail[k][0], g.trail[k][1], 1.6, 0, TAU);
ctx.fill();
}
}
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
blitLeaf(spriteFor(g.kind, g.color, 0), g.f.x, g.f.y, g.size, g.f.tilt, 0.62, 1);
}
const lead = gliders[0];
if (lead && !lead.landed && t >= START_MS) {
const f = lead.f;
const u = Math.hypot(f.vx, f.vy);
if (u > 1) {
const vx = f.vx / u;
const vy = f.vy / u;
// 저항은 속도 반대, 양력은 속도에 수직 — 둘 다 마지막 걸음의 힘
arrow(f.x, f.y, -vx * f.drag * ARROW_SCALE, -vy * f.drag * ARROW_SCALE, INK);
const sx = Math.sign(-vy * 0 + (Math.cos(f.tilt) * vy - Math.sin(f.tilt) * vx) >= 0 ? 1 : -1);
arrow(f.x, f.y, vy * sx * f.lift * ARROW_SCALE, -vx * sx * f.lift * ARROW_SCALE, MAPLE);
}
}
const lf = lead ? lead.f : makeFlyer(0, 0, 0);
drawCaption(ANGLE_LABEL.replace('{a}', fmt(lf.aoa * 180 / Math.PI, 0)).replace('{l}', fmt(lf.lift, 0)).replace('{d}', fmt(lf.drag, 0)), 8, 8, false);
drawCaption(FORMULA_LABEL, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafFlutterLiftGlide - vanilla 판(01-leaf-flutter-lift-drag-glide) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafFlutterLiftGlide.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafFlutterLiftGlide() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 떨어지는 잎 한 장(옆에서 본 판) — 자리·속도(px/s), 기운 각 tilt(라디안, 0 이면 수평으로 누움)·각속도 spin.
// lift·drag·aoa 는 마지막 걸음의 양력·저항(px/s²)과 받음각(라디안) — 화살표·설명 글이 읽는다
type Flyer = { x: number; y: number; vx: number; vy: number; tilt: number; spin: number; lift: number; drag: number; aoa: number };
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x: number, y: number, tilt: number): Flyer {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f: Flyer, windX: number, windY: number, dt: number, liftGain: number): void {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) { px /= pl; py /= pl; } else { px = 0; py = 0; }
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
// 01 — 기운 각도로 양력·저항을 나눠 옆으로 미끄러지고, 기울기를 되돌리는 회전력으로 흔들리며 내려오는 잎 셋
type Glider = { f: Flyer; kind: number; color: RGB; size: number; trail: Pt[]; landed: boolean };
// 처음 자리 x, y 와 기운 각(라디안)
const GLIDER_STARTS: [number, number, number][] = [[128, 30, 0.6], [338, 14, -0.9], [236, -18, 0.3]];
const GLIDER_KINDS = [0, 1, 2];
const GLIDER_SIZE = 46;
const GROUND_Y = 272;
const START_MS = 200;
const LOOP_MS = 6400;
// 자취 — TRAIL_EVERY 걸음마다 한 점, 많아야 TRAIL_MAX 점
const TRAIL_EVERY = 4;
const TRAIL_MAX = 120;
// 힘 화살표 길이 (px 당 px/s²)
const ARROW_SCALE = 0.12;
// 포인터 바람 — 포인터에서 POINTER_RADIUS 안의 잎을 옆으로 민다 (px/s)
const POINTER_WIND = 160;
const POINTER_RADIUS = 80;
const ANGLE_LABEL = '받음각 {a}° · 양력 {l} · 저항 {d}';
const FORMULA_LABEL = '양력 ∝ sin 2α · 저항 ∝ sin²α';
let gliders: Glider[] = [];
let t = 0;
let steps = 0;
function build(): void {
gliders = GLIDER_STARTS.map(function (s: [number, number, number], i: number): Glider {
return { f: makeFlyer(s[0], s[1], s[2]), kind: GLIDER_KINDS[i], color: LEAF_COLORS[i * 2 % LEAF_COLORS.length], size: GLIDER_SIZE, trail: [], landed: false };
});
t = 0;
steps = 0;
}
function loopLength(): number { return LOOP_MS; }
function pointerWindAt(x: number, y: number): number {
if (hoverX < 0) { return 0; }
const dx = x - hoverX;
const dy = y - hoverY;
const d2 = dx * dx + dy * dy;
return Math.sign(dx || 1) * POINTER_WIND * Math.exp(-d2 / (POINTER_RADIUS * POINTER_RADIUS));
}
function step(dt: number): void {
t += dt * 1000;
if (t < START_MS) { return; }
steps += 1;
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
if (g.landed) { continue; }
flutterStep(g.f, pointerWindAt(g.f.x, g.f.y), 0, dt, LEAF_LIFT_GAIN);
if (steps % TRAIL_EVERY === 0) {
g.trail.push([g.f.x, g.f.y]);
if (g.trail.length > TRAIL_MAX) { g.trail.shift(); }
}
if (g.f.y >= GROUND_Y) {
g.landed = true;
g.f.y = GROUND_Y;
g.f.tilt = 0;
}
}
}
// 동작 줄이기 — 셋 모두 바닥에 내려앉을 때까지 시뮬레이션을 그리지 않고 돌린 장면(지나온 자취 그대로)
function finalScene(): void {
build();
for (let i = 0; i < 900; i += 1) { step(STEP_S); }
}
function onWake(): void { }
function arrow(x: number, y: number, dx: number, dy: number, color: RGB): void {
const len = Math.hypot(dx, dy);
if (len < 2) { return; }
const ux = dx / len;
const uy = dy / len;
ctx.strokeStyle = rgba(color, 0.95);
ctx.fillStyle = rgba(color, 0.95);
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + dx, y + dy);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + dx, y + dy);
ctx.lineTo(x + dx - ux * 7 - uy * 4, y + dy - uy * 7 + ux * 4);
ctx.lineTo(x + dx - ux * 7 + uy * 4, y + dy - uy * 7 - ux * 4);
ctx.closePath();
ctx.fill();
}
function draw(): void {
clearSky();
drawGround(GROUND_Y + 8);
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
ctx.fillStyle = rgba(mixRGB(g.color, INK, 0.25), 0.55);
for (let k = 0; k < g.trail.length; k += 1) {
ctx.beginPath();
ctx.arc(g.trail[k][0], g.trail[k][1], 1.6, 0, TAU);
ctx.fill();
}
}
for (let i = 0; i < gliders.length; i += 1) {
const g = gliders[i];
blitLeaf(spriteFor(g.kind, g.color, 0), g.f.x, g.f.y, g.size, g.f.tilt, 0.62, 1);
}
const lead = gliders[0];
if (lead && !lead.landed && t >= START_MS) {
const f = lead.f;
const u = Math.hypot(f.vx, f.vy);
if (u > 1) {
const vx = f.vx / u;
const vy = f.vy / u;
// 저항은 속도 반대, 양력은 속도에 수직 — 둘 다 마지막 걸음의 힘
arrow(f.x, f.y, -vx * f.drag * ARROW_SCALE, -vy * f.drag * ARROW_SCALE, INK);
const sx = Math.sign(-vy * 0 + (Math.cos(f.tilt) * vy - Math.sin(f.tilt) * vx) >= 0 ? 1 : -1);
arrow(f.x, f.y, vy * sx * f.lift * ARROW_SCALE, -vx * sx * f.lift * ARROW_SCALE, MAPLE);
}
}
const lf = lead ? lead.f : makeFlyer(0, 0, 0);
drawCaption(ANGLE_LABEL.replace('{a}', fmt(lf.aoa * 180 / Math.PI, 0)).replace('{l}', fmt(lf.lift, 0)).replace('{d}', fmt(lf.drag, 0)), 8, 8, false);
drawCaption(FORMULA_LABEL, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="기운 각도에 따라 양력과 저항을 받아 옆으로 미끄러지며 흔들리듯 내려오는 잎 세 장."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 기운 각도로 옆으로 미끄러지며 흔들리는 잎 — 측정 누적면적 13.519% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function flutterStep(f, windX, windY, dt, liftGain) {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
02세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎
잎 여덟 장이 각자 세 축으로 돌며 원근 배율 FOCAL_LENGTH / (FOCAL_LENGTH + z) 로 작아졌다 커지고, 뒤집혀 법선 z 가 양수가 되면 잎 색과 크림을 반반 섞은 뒷면 색으로 바뀐다. 아래 조각은 윤곽 72점을 화면으로 옮긴 뒤, 보이는 면의 법선과 왼쪽 위 앞에서 오는 빛 방향 LIGHT_DIR 의 내적으로 밝기 0.32 + 0.68 × 그 값을 정한다. 6초를 돌려 보면 잎마다 앞뒤가 6~8번 바뀌고 원근 배율은 0.757~1.232 사이를 오간다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="세 축으로 뒤집히며 떨어지는 잎들. 빛을 향한 면은 밝고 뒷면은 옅은 색으로 보인다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const TUMBLER_COUNT = 8;
// 원근 — 화면에서 FOCAL_LENGTH 만큼 떨어진 눈. 깊이 z 인 점은 FOCAL_LENGTH / (FOCAL_LENGTH + z) 배로 줄어든다
const FOCAL_LENGTH = 520;
// 빛이 오는 쪽(왼쪽 위 앞) — 단위 벡터로 맞춘다
const LIGHT_DIR = unit3([-0.45, -0.7, -0.55]);
// 밝기 = AMBIENT_LIGHT + (1 − AMBIENT_LIGHT) × 빛을 향한 정도
const AMBIENT_LIGHT = 0.32;
// 세 축 회전 빠르기(라디안/초) — 잎마다 0.6~1.4 배
const TUMBLE_RATES = [1.9, 2.6, 0.8];
const FALL_SPEED = 54;
const SWAY_AMP = 46;
const SWAY_RATE = 0.9;
const TUMBLE_GROUND = 274;
const START_MS_02 = 200;
const LOOP_MS_02 = 9000;
const FACE_FRONT = '앞면';
const FACE_BACK = '뒷면';
const FACE_LABEL = '{f} · 빛을 향한 정도 {b}%';
const SHADE_FORMULA = '밝기 = 법선 · 빛 방향 · 법선 z 로 앞뒤';
let tumblers = [];
let t02 = 0;
let recycled = 0;
function unit3(v) { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
// 회전 행렬 R = Rz · Ry · Rx (행 우선 9칸)
function rotation(rx, ry, rz) {
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
return [
cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx,
sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx,
-sy, cy * sx, cy * cx
];
}
function apply(m, x, y, z) {
return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z];
}
function build() {
const rng = makeRng(4792);
tumblers = [];
for (let i = 0; i < TUMBLER_COUNT; i += 1) {
const k = 0.6 + rng() * 0.8;
tumblers.push({
x0: 40 + (i + 0.5) / TUMBLER_COUNT * 400 + (rng() - 0.5) * 30,
y: -40 + ((i * 3) % TUMBLER_COUNT) / TUMBLER_COUNT * 330,
z0: rng() * 160 - 40,
rx: rng() * TAU, ry: rng() * TAU, rz: rng() * TAU,
wx: TUMBLE_RATES[0] * k * (rng() < 0.5 ? -1 : 1),
wy: TUMBLE_RATES[1] * (1.4 - k * 0.5),
wz: TUMBLE_RATES[2] * k,
kind: i % LEAF_KINDS,
color: LEAF_COLORS[i % LEAF_COLORS.length],
size: 58 + rng() * 26,
phase: rng() * TAU,
vy: FALL_SPEED * (0.75 + rng() * 0.5),
flips: 0,
wasFront: true
});
}
t02 = 0;
recycled = 0;
}
function loopLength() { return LOOP_MS_02; }
function step(dt) {
t02 += dt * 1000;
if (t02 < START_MS_02) {
return;
}
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.rx += p.wx * dt;
p.ry += p.wy * dt;
p.rz += p.wz * dt;
p.y += p.vy * dt;
// 화면 아래로 빠진 잎은 새로 만들지 않고 위로 되돌려 다시 쓴다
if (p.y > H + 70) {
p.y = -70;
recycled += 1;
}
}
}
function leafX(p) { return p.x0 + SWAY_AMP * Math.sin(p.phase + t02 / 1000 * SWAY_RATE); }
function leafZ(p) { return p.z0 + 60 * Math.sin(p.phase * 1.7 + t02 / 1000 * 0.6); }
// 한 장을 3차원으로 돌려 화면 좌표로. 법선은 잎 판의 (0, 0, −1) — 처음에 눈을 본다
function project(p) {
const m = rotation(p.rx, p.ry, p.rz);
const cx = leafX(p);
const cz = leafZ(p);
const half = p.size / 2;
const pts = [];
let depth = 0;
const outline = OUTLINES[p.kind];
function toScreen(u, v) {
const q = apply(m, u * half, v * half, 0);
const z = cz + q[2];
const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
}
for (let i = 0; i < outline.length; i += 1) {
pts.push(toScreen(outline[i][0], outline[i][1]));
}
const veins = VEINS[p.kind].map(function (s) { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
depth = cz;
const n = apply(m, 0, 0, -1);
// 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
const front = n[2] < 0;
const s = front ? 1 : -1;
const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
const base = front ? p.color : backColor(p.color);
const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };
}
// 동작 줄이기 — 바닥에 눕힌 잎들(앞으로 기운 판이라 납작하게 보인다)
function finalScene() {
build();
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.y = TUMBLE_GROUND - 6 - (i % 3) * 4;
p.rx = 1.25;
p.ry = (i % 2 === 0 ? 0.2 : -0.25);
p.z0 = (i % 4) * 30;
p.phase = 0;
p.x0 = 50 + i * 54;
}
t02 = START_MS_02;
}
function onWake() { }
function drawShape(sh) {
const pts = sh.pts;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) {
ctx.lineTo(pts[i][0], pts[i][1]);
}
ctx.closePath();
ctx.fillStyle = rgba(sh.fill, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, 0.4), 0.9);
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < sh.veins.length; i += 1) {
ctx.moveTo(sh.veins[i][0][0], sh.veins[i][0][1]);
ctx.lineTo(sh.veins[i][1][0], sh.veins[i][1][1]);
}
ctx.lineWidth = 0.9;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, sh.front ? 0.32 : 0.18), 0.8);
ctx.stroke();
}
function draw() {
clearSky();
drawGround(TUMBLE_GROUND + 6);
// 빛이 오는 자리 표시 — 왼쪽 위의 옅은 해
const sun = ctx.createRadialGradient(46, 40, 4, 46, 40, 70);
sun.addColorStop(0, rgba(CREAM, 0.95));
sun.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = sun;
ctx.fillRect(0, 0, 130, 120);
const shapes = tumblers.map(project);
// 먼 것부터 그린다
shapes.sort(function (a, b) { return b.depth - a.depth; });
for (let i = 0; i < shapes.length; i += 1) {
const sh = shapes[i];
if (sh.leaf.wasFront !== sh.front) {
sh.leaf.flips += 1;
sh.leaf.wasFront = sh.front;
}
drawShape(sh);
}
const lead = shapes.filter(function (s) { return s.leaf === tumblers[0]; })[0];
if (lead) {
drawCaption(FACE_LABEL.replace('{f}', lead.front ? FACE_FRONT : FACE_BACK).replace('{b}', fmt(lead.lit * 100, 0)), 8, 8, false);
}
drawCaption(SHADE_FORMULA, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafTumbleLightBackside - vanilla 판(02-leaf-3d-tumble-light-backside) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafTumbleLightBackside.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafTumbleLightBackside() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 02 — 세 축 회전 행렬로 뒤집히며 떨어지는 잎. 원근 투영으로 그리고, 면 법선과 빛 방향의 내적으로 밝기를,
// 법선 z 부호로 앞면·뒷면을 고른다. 잎 윤곽은 pre 의 단위 좌표(OUTLINES)를 그대로 3차원 판에 올린다
type V3 = [number, number, number];
type Tumbler = { x0: number; y: number; z0: number; rx: number; ry: number; rz: number; wx: number; wy: number; wz: number; kind: number; color: RGB; size: number; phase: number; vy: number; flips: number; wasFront: boolean };
type Shape = { pts: Pt[]; veins: [Pt, Pt][]; depth: number; lit: number; front: boolean; fill: RGB; leaf: Tumbler };
const TUMBLER_COUNT = 8;
// 원근 — 화면에서 FOCAL_LENGTH 만큼 떨어진 눈. 깊이 z 인 점은 FOCAL_LENGTH / (FOCAL_LENGTH + z) 배로 줄어든다
const FOCAL_LENGTH = 520;
// 빛이 오는 쪽(왼쪽 위 앞) — 단위 벡터로 맞춘다
const LIGHT_DIR: V3 = unit3([-0.45, -0.7, -0.55]);
// 밝기 = AMBIENT_LIGHT + (1 − AMBIENT_LIGHT) × 빛을 향한 정도
const AMBIENT_LIGHT = 0.32;
// 세 축 회전 빠르기(라디안/초) — 잎마다 0.6~1.4 배
const TUMBLE_RATES: V3 = [1.9, 2.6, 0.8];
const FALL_SPEED = 54;
const SWAY_AMP = 46;
const SWAY_RATE = 0.9;
const TUMBLE_GROUND = 274;
const START_MS_02 = 200;
const LOOP_MS_02 = 9000;
const FACE_FRONT = '앞면';
const FACE_BACK = '뒷면';
const FACE_LABEL = '{f} · 빛을 향한 정도 {b}%';
const SHADE_FORMULA = '밝기 = 법선 · 빛 방향 · 법선 z 로 앞뒤';
let tumblers: Tumbler[] = [];
let t02 = 0;
let recycled = 0;
function unit3(v: V3): V3 { const l = Math.hypot(v[0], v[1], v[2]) || 1; return [v[0] / l, v[1] / l, v[2] / l]; }
// 회전 행렬 R = Rz · Ry · Rx (행 우선 9칸)
function rotation(rx: number, ry: number, rz: number): number[] {
const cx = Math.cos(rx), sx = Math.sin(rx);
const cy = Math.cos(ry), sy = Math.sin(ry);
const cz = Math.cos(rz), sz = Math.sin(rz);
return [
cz * cy, cz * sy * sx - sz * cx, cz * sy * cx + sz * sx,
sz * cy, sz * sy * sx + cz * cx, sz * sy * cx - cz * sx,
-sy, cy * sx, cy * cx
];
}
function apply(m: number[], x: number, y: number, z: number): V3 {
return [m[0] * x + m[1] * y + m[2] * z, m[3] * x + m[4] * y + m[5] * z, m[6] * x + m[7] * y + m[8] * z];
}
function build(): void {
const rng = makeRng(4792);
tumblers = [];
for (let i = 0; i < TUMBLER_COUNT; i += 1) {
const k = 0.6 + rng() * 0.8;
tumblers.push({
x0: 40 + (i + 0.5) / TUMBLER_COUNT * 400 + (rng() - 0.5) * 30,
y: -40 + ((i * 3) % TUMBLER_COUNT) / TUMBLER_COUNT * 330,
z0: rng() * 160 - 40,
rx: rng() * TAU, ry: rng() * TAU, rz: rng() * TAU,
wx: TUMBLE_RATES[0] * k * (rng() < 0.5 ? -1 : 1),
wy: TUMBLE_RATES[1] * (1.4 - k * 0.5),
wz: TUMBLE_RATES[2] * k,
kind: i % LEAF_KINDS,
color: LEAF_COLORS[i % LEAF_COLORS.length],
size: 58 + rng() * 26,
phase: rng() * TAU,
vy: FALL_SPEED * (0.75 + rng() * 0.5),
flips: 0,
wasFront: true
});
}
t02 = 0;
recycled = 0;
}
function loopLength(): number { return LOOP_MS_02; }
function step(dt: number): void {
t02 += dt * 1000;
if (t02 < START_MS_02) { return; }
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.rx += p.wx * dt;
p.ry += p.wy * dt;
p.rz += p.wz * dt;
p.y += p.vy * dt;
// 화면 아래로 빠진 잎은 새로 만들지 않고 위로 되돌려 다시 쓴다
if (p.y > H + 70) { p.y = -70; recycled += 1; }
}
}
function leafX(p: Tumbler): number { return p.x0 + SWAY_AMP * Math.sin(p.phase + t02 / 1000 * SWAY_RATE); }
function leafZ(p: Tumbler): number { return p.z0 + 60 * Math.sin(p.phase * 1.7 + t02 / 1000 * 0.6); }
// 한 장을 3차원으로 돌려 화면 좌표로. 법선은 잎 판의 (0, 0, −1) — 처음에 눈을 본다
function project(p: Tumbler): Shape {
const m = rotation(p.rx, p.ry, p.rz);
const cx = leafX(p);
const cz = leafZ(p);
const half = p.size / 2;
const pts: Pt[] = [];
let depth = 0;
const outline = OUTLINES[p.kind];
function toScreen(u: number, v: number): Pt {
const q = apply(m, u * half, v * half, 0);
const z = cz + q[2];
const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
}
for (let i = 0; i < outline.length; i += 1) { pts.push(toScreen(outline[i][0], outline[i][1])); }
const veins: [Pt, Pt][] = VEINS[p.kind].map(function (s: [Pt, Pt]): [Pt, Pt] { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
depth = cz;
const n = apply(m, 0, 0, -1);
// 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
const front = n[2] < 0;
const s = front ? 1 : -1;
const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
const base = front ? p.color : backColor(p.color);
const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };
}
// 동작 줄이기 — 바닥에 눕힌 잎들(앞으로 기운 판이라 납작하게 보인다)
function finalScene(): void {
build();
for (let i = 0; i < tumblers.length; i += 1) {
const p = tumblers[i];
p.y = TUMBLE_GROUND - 6 - (i % 3) * 4;
p.rx = 1.25;
p.ry = (i % 2 === 0 ? 0.2 : -0.25);
p.z0 = (i % 4) * 30;
p.phase = 0;
p.x0 = 50 + i * 54;
}
t02 = START_MS_02;
}
function onWake(): void { }
function drawShape(sh: Shape): void {
const pts = sh.pts;
ctx.beginPath();
ctx.moveTo(pts[0][0], pts[0][1]);
for (let i = 1; i < pts.length; i += 1) { ctx.lineTo(pts[i][0], pts[i][1]); }
ctx.closePath();
ctx.fillStyle = rgba(sh.fill, 1);
ctx.fill();
ctx.lineWidth = 1.2;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, 0.4), 0.9);
ctx.stroke();
ctx.beginPath();
for (let i = 0; i < sh.veins.length; i += 1) {
ctx.moveTo(sh.veins[i][0][0], sh.veins[i][0][1]);
ctx.lineTo(sh.veins[i][1][0], sh.veins[i][1][1]);
}
ctx.lineWidth = 0.9;
ctx.strokeStyle = rgba(mixRGB(sh.fill, INK, sh.front ? 0.32 : 0.18), 0.8);
ctx.stroke();
}
function draw(): void {
clearSky();
drawGround(TUMBLE_GROUND + 6);
// 빛이 오는 자리 표시 — 왼쪽 위의 옅은 해
const sun = ctx.createRadialGradient(46, 40, 4, 46, 40, 70);
sun.addColorStop(0, rgba(CREAM, 0.95));
sun.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = sun;
ctx.fillRect(0, 0, 130, 120);
const shapes = tumblers.map(project);
// 먼 것부터 그린다
shapes.sort(function (a: Shape, b: Shape): number { return b.depth - a.depth; });
for (let i = 0; i < shapes.length; i += 1) {
const sh = shapes[i];
if (sh.leaf.wasFront !== sh.front) { sh.leaf.flips += 1; sh.leaf.wasFront = sh.front; }
drawShape(sh);
}
const lead = shapes.filter(function (s: Shape): boolean { return s.leaf === tumblers[0]; })[0];
if (lead) {
drawCaption(FACE_LABEL.replace('{f}', lead.front ? FACE_FRONT : FACE_BACK).replace('{b}', fmt(lead.lit * 100, 0)), 8, 8, false);
}
drawCaption(SHADE_FORMULA, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="세 축으로 뒤집히며 떨어지는 잎들. 빛을 향한 면은 밝고 뒷면은 옅은 색으로 보인다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 세 축으로 뒤집히며 앞뒷면 밝기가 바뀌는 잎 — 측정 누적면적 29.32% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function toScreen(u, v) {
const q = apply(m, u * half, v * half, 0);
const z = cz + q[2];
const k = FOCAL_LENGTH / (FOCAL_LENGTH + z);
return [W / 2 + (cx - W / 2 + q[0]) * k, H / 2 + (p.y - H / 2 + q[1]) * k];
}
for (let i = 0; i < outline.length; i += 1) {
pts.push(toScreen(outline[i][0], outline[i][1]));
}
const veins = VEINS[p.kind].map(function (s) { return [toScreen(s[0][0], s[0][1]), toScreen(s[1][0], s[1][1])]; });
depth = cz;
const n = apply(m, 0, 0, -1);
// 법선 z 가 음수면 앞면이 눈을 향한다. 보이는 면의 법선으로 빛과 내적한다
const front = n[2] < 0;
const s = front ? 1 : -1;
const lit = Math.max(0, s * (n[0] * LIGHT_DIR[0] + n[1] * LIGHT_DIR[1] + n[2] * LIGHT_DIR[2]));
const base = front ? p.color : backColor(p.color);
const bright = AMBIENT_LIGHT + (1 - AMBIENT_LIGHT) * lit;
const fill = mixRGB(mixRGB(base, INK, 0.36 * (1 - bright)), CREAM, 0.34 * lit * lit);
return { pts: pts, veins: veins, depth: depth, lit: lit, front: front, fill: fill, leaf: p };
03돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람
잎 140장이 부드러운 노이즈로 정한 자리별 바람을 따라 흐르다가, 복숭아색 돌풍 띠가 지나가는 동안 그 안의 잎만 세게 밀려 한쪽으로 쓸려 간다. gustWeight 는 띠 가운데에서 1, GUST_WIDTH 64px 떨어지면 1/e 인 종 모양 폭이고, windAt 은 여기에 GUST_GAIN 300 을 곱해 가로 바람에 더하면서 세로로는 살짝 들어 올린다. 판 가운데 x 200 에 시험 잎을 두면 띠 안에 1,232ms 머물며 가로 속도가 275px/s 까지 올라 2.8초에 329px 를 가는데, 돌풍이 없으면 61.7px 에 그친다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="바람에 떠다니는 잎 무리 위로 돌풍 띠가 왼쪽에서 오른쪽으로 지나가며 그 안의 잎을 세게 민다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t, speed) {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x, center) {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x, y, t, center, out) {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
const DRIFT_COUNT = 140;
// 잎 속도가 바람 속도로 다가가는 빠르기(1/초)
const WIND_COUPLING = 2.4;
// 바람이 없을 때 떨어지는 속도(px/s)
const DRIFT_FALL = 44;
// 바람과 잎 속도 차가 회전으로 바뀌는 비율
const SPIN_FROM_WIND = 0.03;
const DRIFT_START_MS = 200;
const DRIFT_LOOP_MS = 9600;
// 바닥 띠 윗선(y) — 동작 줄이기 판에서 잎이 눕는 자리
const DRIFT_FLOOR = 286;
// 포인터 돌풍 — 포인터 둘레 POINTER_GUST_R 안을 오른쪽으로 더 민다
const POINTER_GUST = 220;
const POINTER_GUST_R = 70;
const GUST_LABEL = '돌풍 띠 {v}px/s · 띠 안 잎 {n}장';
const WIND_FORMULA = '바람 = 노이즈 바람장 + 종 모양 돌풍 띠';
let drifters = [];
let t03 = 0;
let recycled = 0;
let inBand = 0;
const windTmp = [0, 0];
function build() {
const rng = makeRng(4793);
drifters = [];
for (let i = 0; i < DRIFT_COUNT; i += 1) {
drifters.push({ x: rng() * (W + 60) - 30, y: rng() * (H + 40) - 20, vx: WIND_BASE, vy: DRIFT_FALL, tilt: rng() * TAU, spin: 0, flip: rng() * TAU, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 15 + rng() * 13 });
}
t03 = 0;
recycled = 0;
inBand = 0;
}
function loopLength() { return DRIFT_LOOP_MS; }
function bandCenter() { return gustCenter(Math.max(0, t03 - DRIFT_START_MS) / 1000, GUST_SPEED); }
function step(dt) {
t03 += dt * 1000;
if (t03 < DRIFT_START_MS) {
return;
}
const ts = t03 / 1000;
const center = bandCenter();
let count = 0;
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
windAt(d.x, d.y, ts, center, windTmp);
if (hoverX >= 0) {
const dx = d.x - hoverX;
const dy = d.y - hoverY;
windTmp[0] += POINTER_GUST * Math.exp(-(dx * dx + dy * dy) / (POINTER_GUST_R * POINTER_GUST_R));
}
const ax = (windTmp[0] - d.vx) * WIND_COUPLING;
const ay = (windTmp[1] + DRIFT_FALL - d.vy) * WIND_COUPLING;
d.vx += ax * dt;
d.vy += ay * dt;
d.spin += ((windTmp[0] - d.vx) * SPIN_FROM_WIND - d.spin * 1.4) * dt;
d.tilt += d.spin * dt;
d.flip += (1.5 + Math.abs(d.spin)) * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
if (gustWeight(d.x, center) > 0.5) {
count += 1;
}
// 화면 밖으로 나간 잎은 반대쪽 끝으로 되돌려 다시 쓴다 — 잎 수는 DRIFT_COUNT 그대로
if (d.x > W + 30) {
d.x -= W + 60;
recycled += 1;
}
if (d.x < -30) {
d.x += W + 60;
recycled += 1;
}
if (d.y > H + 24) {
d.y -= H + 48;
recycled += 1;
}
if (d.y < -24) {
d.y += H + 48;
recycled += 1;
}
}
inBand = count;
}
// 동작 줄이기 — 바람이 잔 판. 잎은 모두 바닥 띠에 눕는다
function finalScene() {
build();
const rng = makeRng(47930);
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
d.x = 8 + (i + rng()) / drifters.length * (W - 16);
d.y = DRIFT_FLOOR + 3 + rng() * 7;
d.tilt = (rng() - 0.5) * 0.6;
d.flip = 1.2;
}
t03 = 0;
}
function onWake() { }
function drawBand(center) {
// 띠 세기를 6px 세로 줄마다 칠한다 — 가운데 진하고 바깥으로 종 모양으로 옅어진다
for (let x = 0; x < W; x += 6) {
const g = gustWeight(x + 3, center);
if (g < 0.02) {
continue;
}
ctx.fillStyle = rgba(PEACH, 0.34 * g);
ctx.fillRect(x, 0, 6, H);
}
// 바람결 선 — 띠 안에서만, 세기만큼 길게
ctx.strokeStyle = rgba(CREAM, 0.85);
ctx.lineWidth = 1.4;
ctx.beginPath();
for (let r = 0; r < 9; r += 1) {
const y = 22 + r * 31;
const x = center - 40 + ((r * 37) % 80);
const g = gustWeight(x, center);
if (g < 0.1) {
continue;
}
ctx.moveTo(x, y);
ctx.lineTo(x + 46 * g, y);
}
ctx.stroke();
}
function draw() {
clearSky();
drawGround(DRIFT_FLOOR);
if (t03 >= DRIFT_START_MS) {
drawBand(bandCenter());
}
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
blitLeaf(spriteFor(d.kind, LEAF_COLORS[d.color], 0), d.x, d.y, d.size, d.tilt, 0.3 + 0.7 * Math.abs(Math.cos(d.flip)), 1);
}
drawCaption(GUST_LABEL.replace('{v}', fmt(GUST_SPEED, 0)).replace('{n}', fmt(inBand, 0)), 8, 8, false);
drawCaption(WIND_FORMULA, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafNoiseGustFront - vanilla 판(03-leaf-noise-wind-gust-front) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafNoiseGustFront.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafNoiseGustFront() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t: number, speed: number): number {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x: number, center: number): number {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x: number, y: number, t: number, center: number, out: Pt): void {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
// 03 — 부드러운 노이즈 바람장 위로 돌풍 띠가 가로질러 지나가며 그 안의 잎만 세게 미는 장면.
// 잎은 바람 속도를 WIND_COUPLING 빠르기로 따라가고(공기 저항), 바람과 잎 속도 차가 회전을 만든다
type Drifter = { x: number; y: number; vx: number; vy: number; tilt: number; spin: number; flip: number; kind: number; color: number; size: number };
const DRIFT_COUNT = 140;
// 잎 속도가 바람 속도로 다가가는 빠르기(1/초)
const WIND_COUPLING = 2.4;
// 바람이 없을 때 떨어지는 속도(px/s)
const DRIFT_FALL = 44;
// 바람과 잎 속도 차가 회전으로 바뀌는 비율
const SPIN_FROM_WIND = 0.03;
const DRIFT_START_MS = 200;
const DRIFT_LOOP_MS = 9600;
// 바닥 띠 윗선(y) — 동작 줄이기 판에서 잎이 눕는 자리
const DRIFT_FLOOR = 286;
// 포인터 돌풍 — 포인터 둘레 POINTER_GUST_R 안을 오른쪽으로 더 민다
const POINTER_GUST = 220;
const POINTER_GUST_R = 70;
const GUST_LABEL = '돌풍 띠 {v}px/s · 띠 안 잎 {n}장';
const WIND_FORMULA = '바람 = 노이즈 바람장 + 종 모양 돌풍 띠';
let drifters: Drifter[] = [];
let t03 = 0;
let recycled = 0;
let inBand = 0;
const windTmp: Pt = [0, 0];
function build(): void {
const rng = makeRng(4793);
drifters = [];
for (let i = 0; i < DRIFT_COUNT; i += 1) {
drifters.push({ x: rng() * (W + 60) - 30, y: rng() * (H + 40) - 20, vx: WIND_BASE, vy: DRIFT_FALL, tilt: rng() * TAU, spin: 0, flip: rng() * TAU, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 15 + rng() * 13 });
}
t03 = 0;
recycled = 0;
inBand = 0;
}
function loopLength(): number { return DRIFT_LOOP_MS; }
function bandCenter(): number { return gustCenter(Math.max(0, t03 - DRIFT_START_MS) / 1000, GUST_SPEED); }
function step(dt: number): void {
t03 += dt * 1000;
if (t03 < DRIFT_START_MS) { return; }
const ts = t03 / 1000;
const center = bandCenter();
let count = 0;
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
windAt(d.x, d.y, ts, center, windTmp);
if (hoverX >= 0) {
const dx = d.x - hoverX;
const dy = d.y - hoverY;
windTmp[0] += POINTER_GUST * Math.exp(-(dx * dx + dy * dy) / (POINTER_GUST_R * POINTER_GUST_R));
}
const ax = (windTmp[0] - d.vx) * WIND_COUPLING;
const ay = (windTmp[1] + DRIFT_FALL - d.vy) * WIND_COUPLING;
d.vx += ax * dt;
d.vy += ay * dt;
d.spin += ((windTmp[0] - d.vx) * SPIN_FROM_WIND - d.spin * 1.4) * dt;
d.tilt += d.spin * dt;
d.flip += (1.5 + Math.abs(d.spin)) * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
if (gustWeight(d.x, center) > 0.5) { count += 1; }
// 화면 밖으로 나간 잎은 반대쪽 끝으로 되돌려 다시 쓴다 — 잎 수는 DRIFT_COUNT 그대로
if (d.x > W + 30) { d.x -= W + 60; recycled += 1; }
if (d.x < -30) { d.x += W + 60; recycled += 1; }
if (d.y > H + 24) { d.y -= H + 48; recycled += 1; }
if (d.y < -24) { d.y += H + 48; recycled += 1; }
}
inBand = count;
}
// 동작 줄이기 — 바람이 잔 판. 잎은 모두 바닥 띠에 눕는다
function finalScene(): void {
build();
const rng = makeRng(47930);
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
d.x = 8 + (i + rng()) / drifters.length * (W - 16);
d.y = DRIFT_FLOOR + 3 + rng() * 7;
d.tilt = (rng() - 0.5) * 0.6;
d.flip = 1.2;
}
t03 = 0;
}
function onWake(): void { }
function drawBand(center: number): void {
// 띠 세기를 6px 세로 줄마다 칠한다 — 가운데 진하고 바깥으로 종 모양으로 옅어진다
for (let x = 0; x < W; x += 6) {
const g = gustWeight(x + 3, center);
if (g < 0.02) { continue; }
ctx.fillStyle = rgba(PEACH, 0.34 * g);
ctx.fillRect(x, 0, 6, H);
}
// 바람결 선 — 띠 안에서만, 세기만큼 길게
ctx.strokeStyle = rgba(CREAM, 0.85);
ctx.lineWidth = 1.4;
ctx.beginPath();
for (let r = 0; r < 9; r += 1) {
const y = 22 + r * 31;
const x = center - 40 + ((r * 37) % 80);
const g = gustWeight(x, center);
if (g < 0.1) { continue; }
ctx.moveTo(x, y);
ctx.lineTo(x + 46 * g, y);
}
ctx.stroke();
}
function draw(): void {
clearSky();
drawGround(DRIFT_FLOOR);
if (t03 >= DRIFT_START_MS) { drawBand(bandCenter()); }
for (let i = 0; i < drifters.length; i += 1) {
const d = drifters[i];
blitLeaf(spriteFor(d.kind, LEAF_COLORS[d.color], 0), d.x, d.y, d.size, d.tilt, 0.3 + 0.7 * Math.abs(Math.cos(d.flip)), 1);
}
drawCaption(GUST_LABEL.replace('{v}', fmt(GUST_SPEED, 0)).replace('{n}', fmt(inBand, 0)), 8, 8, false);
drawCaption(WIND_FORMULA, W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="바람에 떠다니는 잎 무리 위로 돌풍 띠가 왼쪽에서 오른쪽으로 지나가며 그 안의 잎을 세게 민다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 돌풍 띠가 지나가며 잎 무리를 휩쓰는 바람 — 측정 누적면적 71.459% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x, center) {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x, y, t, center, out) {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
04잎맥에서 먼 가장자리부터 물드는 단풍
다섯 갈래 잎 한 장이 4,200ms 에 걸쳐 잎맥에서 먼 가장자리부터 초록·버터·살구·단풍 순으로 물들고, 노이즈 얼룩이 그 경계를 들쭉날쭉하게 만든다. 아래 paintField 는 매 프레임 문턱 th = 1 − p·(1 + RAMP_WIDTH) 하나만 옮기고, 미리 구해 둔 픽셀별 순서 값과 견줘 램프 색을 고른다. 잎 안 35,352 픽셀 가운데 첫 픽셀이 다 붉어지는 때는 1,796ms, 마지막은 4,371ms 이고, 잎맥 가까운 1,338 픽셀은 순서 평균이 0.18 이라 늦게 물든다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="다섯 갈래 잎 한 장이 잎맥에서 먼 가장자리부터 초록에서 노랑, 주황, 빨강으로 물든다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
// 04 — 다섯 갈래 잎 한 장이 잎맥에서 먼 가장자리부터 물든다. 잎 안 픽셀마다 가장 가까운 잎맥 선분까지의 거리를
// 오프스크린 판에 한 번만 재 두고(거리장), 매 프레임은 시간 문턱값과 비교해 색 램프에서 고르기만 한다
const TURN_KIND = 1;
// 거리장 판 크기 — 화면 배율(dpr)과 상관없이 고정. 배율 2 기기에서도 화소 수가 늘지 않는다
const FIELD_W = 300;
const FIELD_H = 244;
// 잎 단위 좌표 1 이 거리장 몇 픽셀인가
const FIELD_SCALE = 124;
// 노이즈 얼룩이 물드는 순서에 섞이는 몫과 얼룩 크기
const BLOTCH_MIX = 0.34;
const BLOTCH_SCALE = 0.045;
// 한 픽셀이 초록에서 빨강까지 가는 데 걸리는 문턱 폭(물드는 순서 값 기준)
const RAMP_WIDTH = 0.42;
const TURN_START_MS = 200;
const TURN_MS = 4200;
const TURN_HOLD_MS = 1800;
// 색 램프 — 초록 → 버터 노랑 → 살구 → 단풍 빨강. 색상 차가 기능이라 파스텔 안에서도 서로 멀게 둔다
const GREEN = [128, 180, 112];
const TURN_RAMP = [GREEN, BUTTER, APRICOT, MAPLE];
const TURN_LABEL = '물듦 {p}% · 잎맥에서 먼 쪽부터';
const COST_LABEL = '거리 계산 {d}회(한 번) · 이번 프레임 비교 {c}회';
const blotchNoise = makeNoise(4794);
// 거리장 결과 — 잎 안 픽셀의 판 위치, 물드는 순서 값(클수록 먼저), 잎맥 가까움(0~1)
let insideIdx = new Int32Array(0);
let turnOrder = new Float32Array(0);
let veinNear = new Float32Array(0);
let fieldBuilds = 0;
let distanceChecks = 0;
let rampCompares = 0;
let lastCompares = 0;
let fieldCv = null;
let fieldImg = null;
let t04 = 0;
// 점과 선분 사이 최단 거리
function segDist(px, py, a, b) {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const l2 = dx * dx + dy * dy || 1;
const k = clamp(((px - a[0]) * dx + (py - a[1]) * dy) / l2, 0, 1);
return Math.hypot(px - a[0] - dx * k, py - a[1] - dy * k);
}
function insidePoly(px, py, pts) {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
const a = pts[i];
const b = pts[j];
if ((a[1] > py) !== (b[1] > py) && px < (b[0] - a[0]) * (py - a[1]) / (b[1] - a[1]) + a[0]) {
inside = !inside;
}
}
return inside;
}
// 거리장 — 한 번만. 잎 안 픽셀마다 잎맥 선분 전부와 거리를 재 가장 가까운 것을 남긴다
function buildField() {
fieldBuilds += 1;
const cxp = FIELD_W / 2 + 18;
const cyp = FIELD_H / 2;
const outline = OUTLINES[TURN_KIND].map(function (p) { return [cxp + p[0] * FIELD_SCALE, cyp + p[1] * FIELD_SCALE]; });
const veins = VEINS[TURN_KIND].map(function (s) { return [[cxp + s[0][0] * FIELD_SCALE, cyp + s[0][1] * FIELD_SCALE], [cxp + s[1][0] * FIELD_SCALE, cyp + s[1][1] * FIELD_SCALE]]; });
const idx = [];
const dist = [];
let maxD = 1;
for (let y = 0; y < FIELD_H; y += 1) {
for (let x = 0; x < FIELD_W; x += 1) {
if (!insidePoly(x + 0.5, y + 0.5, outline)) {
continue;
}
let best = 1e9;
for (let s = 0; s < veins.length; s += 1) {
distanceChecks += 1;
const d = segDist(x + 0.5, y + 0.5, veins[s][0], veins[s][1]);
if (d < best) {
best = d;
}
}
idx.push(y * FIELD_W + x);
dist.push(best);
if (best > maxD) {
maxD = best;
}
}
}
insideIdx = new Int32Array(idx);
turnOrder = new Float32Array(idx.length);
veinNear = new Float32Array(idx.length);
for (let i = 0; i < idx.length; i += 1) {
const x = idx[i] % FIELD_W;
const y = Math.floor(idx[i] / FIELD_W);
const blotch = 0.5 + 0.5 * blotchNoise(x * BLOTCH_SCALE, y * BLOTCH_SCALE);
turnOrder[i] = (1 - BLOTCH_MIX) * (dist[i] / maxD) + BLOTCH_MIX * blotch;
veinNear[i] = clamp(1 - dist[i] / 2.6, 0, 1);
}
const cvs = document.createElement('canvas');
cvs.width = FIELD_W;
cvs.height = FIELD_H;
fieldCv = cvs;
const g = cvs.getContext('2d');
fieldImg = g ? g.createImageData(FIELD_W, FIELD_H) : null;
}
function rampColor(s) {
const k = clamp(s, 0, 1) * (TURN_RAMP.length - 1);
const i = Math.min(TURN_RAMP.length - 2, Math.floor(k));
return mixRGB(TURN_RAMP[i], TURN_RAMP[i + 1], k - i);
}
function progress() { return clamp((t04 - TURN_START_MS) / TURN_MS, 0, 1); }
// 매 프레임 — 문턱값 하나와 픽셀마다 비교만 한다. 거리는 다시 재지 않는다
function paintField(p) {
if (!fieldCv || !fieldImg) {
return;
}
const d = fieldImg.data;
const th = 1 - p * (1 + RAMP_WIDTH);
for (let i = 0; i < insideIdx.length; i += 1) {
const s = clamp((turnOrder[i] - th) / RAMP_WIDTH, 0, 1);
const c = mixRGB(rampColor(s), INK, 0.34 * veinNear[i]);
const o = insideIdx[i] * 4;
d[o] = c[0];
d[o + 1] = c[1];
d[o + 2] = c[2];
d[o + 3] = 255;
}
rampCompares += insideIdx.length;
lastCompares = insideIdx.length;
const g = fieldCv.getContext('2d');
if (g) {
g.putImageData(fieldImg, 0, 0);
}
}
function build() {
if (!fieldCv) {
buildField();
}
t04 = 0;
}
function loopLength() { return TURN_START_MS + TURN_MS + TURN_HOLD_MS; }
function step(dt) { t04 += dt * 1000; }
// 동작 줄이기 — 다 물든 잎
function finalScene() {
build();
t04 = TURN_START_MS + TURN_MS;
}
function onWake() { }
function drawLegend() {
const x0 = 10;
const y0 = 36;
for (let i = 0; i < 60; i += 1) {
ctx.fillStyle = rgba(rampColor(i / 59), 1);
ctx.fillRect(x0 + i * 2, y0, 2, 10);
}
// 지금 문턱이 램프 어디쯤인가
const p = progress();
ctx.fillStyle = rgba(INK, 1);
ctx.fillRect(x0 + p * 118, y0 - 4, 2, 18);
}
function draw() {
clearSky();
drawGround(266);
const p = progress();
paintField(p);
if (fieldCv) {
const sway = 0.05 * Math.sin(t04 / 1000 * 1.3);
ctx.save();
ctx.translate(W / 2 - 12, H / 2 + 6);
ctx.rotate(sway);
// 그늘 — 같은 판을 진한 색 반투명으로 살짝 아래에
ctx.globalAlpha = 0.16;
ctx.filter = 'brightness(0)';
ctx.drawImage(fieldCv, -FIELD_W / 2 + 6, -FIELD_H / 2 + 9);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.drawImage(fieldCv, -FIELD_W / 2, -FIELD_H / 2);
ctx.restore();
}
drawLegend();
drawCaption(TURN_LABEL.replace('{p}', fmt(p * 100, 0)), 8, 8, false);
drawCaption(COST_LABEL.replace('{d}', fmt(distanceChecks, 0)).replace('{c}', fmt(lastCompares, 0)), W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafVeinDistanceTurn - vanilla 판(04-leaf-color-turn-vein-distance) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafVeinDistanceTurn.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafVeinDistanceTurn() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 04 — 다섯 갈래 잎 한 장이 잎맥에서 먼 가장자리부터 물든다. 잎 안 픽셀마다 가장 가까운 잎맥 선분까지의 거리를
// 오프스크린 판에 한 번만 재 두고(거리장), 매 프레임은 시간 문턱값과 비교해 색 램프에서 고르기만 한다
const TURN_KIND = 1;
// 거리장 판 크기 — 화면 배율(dpr)과 상관없이 고정. 배율 2 기기에서도 화소 수가 늘지 않는다
const FIELD_W = 300;
const FIELD_H = 244;
// 잎 단위 좌표 1 이 거리장 몇 픽셀인가
const FIELD_SCALE = 124;
// 노이즈 얼룩이 물드는 순서에 섞이는 몫과 얼룩 크기
const BLOTCH_MIX = 0.34;
const BLOTCH_SCALE = 0.045;
// 한 픽셀이 초록에서 빨강까지 가는 데 걸리는 문턱 폭(물드는 순서 값 기준)
const RAMP_WIDTH = 0.42;
const TURN_START_MS = 200;
const TURN_MS = 4200;
const TURN_HOLD_MS = 1800;
// 색 램프 — 초록 → 버터 노랑 → 살구 → 단풍 빨강. 색상 차가 기능이라 파스텔 안에서도 서로 멀게 둔다
const GREEN: RGB = [128, 180, 112];
const TURN_RAMP: RGB[] = [GREEN, BUTTER, APRICOT, MAPLE];
const TURN_LABEL = '물듦 {p}% · 잎맥에서 먼 쪽부터';
const COST_LABEL = '거리 계산 {d}회(한 번) · 이번 프레임 비교 {c}회';
const blotchNoise = makeNoise(4794);
// 거리장 결과 — 잎 안 픽셀의 판 위치, 물드는 순서 값(클수록 먼저), 잎맥 가까움(0~1)
let insideIdx = new Int32Array(0);
let turnOrder = new Float32Array(0);
let veinNear = new Float32Array(0);
let fieldBuilds = 0;
let distanceChecks = 0;
let rampCompares = 0;
let lastCompares = 0;
let fieldCv: HTMLCanvasElement | null = null;
let fieldImg: ImageData | null = null;
let t04 = 0;
// 점과 선분 사이 최단 거리
function segDist(px: number, py: number, a: Pt, b: Pt): number {
const dx = b[0] - a[0];
const dy = b[1] - a[1];
const l2 = dx * dx + dy * dy || 1;
const k = clamp(((px - a[0]) * dx + (py - a[1]) * dy) / l2, 0, 1);
return Math.hypot(px - a[0] - dx * k, py - a[1] - dy * k);
}
function insidePoly(px: number, py: number, pts: Pt[]): boolean {
let inside = false;
for (let i = 0, j = pts.length - 1; i < pts.length; j = i, i += 1) {
const a = pts[i];
const b = pts[j];
if ((a[1] > py) !== (b[1] > py) && px < (b[0] - a[0]) * (py - a[1]) / (b[1] - a[1]) + a[0]) { inside = !inside; }
}
return inside;
}
// 거리장 — 한 번만. 잎 안 픽셀마다 잎맥 선분 전부와 거리를 재 가장 가까운 것을 남긴다
function buildField(): void {
fieldBuilds += 1;
const cxp = FIELD_W / 2 + 18;
const cyp = FIELD_H / 2;
const outline: Pt[] = OUTLINES[TURN_KIND].map(function (p: Pt): Pt { return [cxp + p[0] * FIELD_SCALE, cyp + p[1] * FIELD_SCALE]; });
const veins: [Pt, Pt][] = VEINS[TURN_KIND].map(function (s: [Pt, Pt]): [Pt, Pt] { return [[cxp + s[0][0] * FIELD_SCALE, cyp + s[0][1] * FIELD_SCALE], [cxp + s[1][0] * FIELD_SCALE, cyp + s[1][1] * FIELD_SCALE]]; });
const idx: number[] = [];
const dist: number[] = [];
let maxD = 1;
for (let y = 0; y < FIELD_H; y += 1) {
for (let x = 0; x < FIELD_W; x += 1) {
if (!insidePoly(x + 0.5, y + 0.5, outline)) { continue; }
let best = 1e9;
for (let s = 0; s < veins.length; s += 1) {
distanceChecks += 1;
const d = segDist(x + 0.5, y + 0.5, veins[s][0], veins[s][1]);
if (d < best) { best = d; }
}
idx.push(y * FIELD_W + x);
dist.push(best);
if (best > maxD) { maxD = best; }
}
}
insideIdx = new Int32Array(idx);
turnOrder = new Float32Array(idx.length);
veinNear = new Float32Array(idx.length);
for (let i = 0; i < idx.length; i += 1) {
const x = idx[i] % FIELD_W;
const y = Math.floor(idx[i] / FIELD_W);
const blotch = 0.5 + 0.5 * blotchNoise(x * BLOTCH_SCALE, y * BLOTCH_SCALE);
turnOrder[i] = (1 - BLOTCH_MIX) * (dist[i] / maxD) + BLOTCH_MIX * blotch;
veinNear[i] = clamp(1 - dist[i] / 2.6, 0, 1);
}
const cvs = document.createElement('canvas');
cvs.width = FIELD_W;
cvs.height = FIELD_H;
fieldCv = cvs;
const g = cvs.getContext('2d');
fieldImg = g ? g.createImageData(FIELD_W, FIELD_H) : null;
}
function rampColor(s: number): RGB {
const k = clamp(s, 0, 1) * (TURN_RAMP.length - 1);
const i = Math.min(TURN_RAMP.length - 2, Math.floor(k));
return mixRGB(TURN_RAMP[i], TURN_RAMP[i + 1], k - i);
}
function progress(): number { return clamp((t04 - TURN_START_MS) / TURN_MS, 0, 1); }
// 매 프레임 — 문턱값 하나와 픽셀마다 비교만 한다. 거리는 다시 재지 않는다
function paintField(p: number): void {
if (!fieldCv || !fieldImg) { return; }
const d = fieldImg.data;
const th = 1 - p * (1 + RAMP_WIDTH);
for (let i = 0; i < insideIdx.length; i += 1) {
const s = clamp((turnOrder[i] - th) / RAMP_WIDTH, 0, 1);
const c = mixRGB(rampColor(s), INK, 0.34 * veinNear[i]);
const o = insideIdx[i] * 4;
d[o] = c[0];
d[o + 1] = c[1];
d[o + 2] = c[2];
d[o + 3] = 255;
}
rampCompares += insideIdx.length;
lastCompares = insideIdx.length;
const g = fieldCv.getContext('2d');
if (g) { g.putImageData(fieldImg, 0, 0); }
}
function build(): void {
if (!fieldCv) { buildField(); }
t04 = 0;
}
function loopLength(): number { return TURN_START_MS + TURN_MS + TURN_HOLD_MS; }
function step(dt: number): void { t04 += dt * 1000; }
// 동작 줄이기 — 다 물든 잎
function finalScene(): void {
build();
t04 = TURN_START_MS + TURN_MS;
}
function onWake(): void { }
function drawLegend(): void {
const x0 = 10;
const y0 = 36;
for (let i = 0; i < 60; i += 1) {
ctx.fillStyle = rgba(rampColor(i / 59), 1);
ctx.fillRect(x0 + i * 2, y0, 2, 10);
}
// 지금 문턱이 램프 어디쯤인가
const p = progress();
ctx.fillStyle = rgba(INK, 1);
ctx.fillRect(x0 + p * 118, y0 - 4, 2, 18);
}
function draw(): void {
clearSky();
drawGround(266);
const p = progress();
paintField(p);
if (fieldCv) {
const sway = 0.05 * Math.sin(t04 / 1000 * 1.3);
ctx.save();
ctx.translate(W / 2 - 12, H / 2 + 6);
ctx.rotate(sway);
// 그늘 — 같은 판을 진한 색 반투명으로 살짝 아래에
ctx.globalAlpha = 0.16;
ctx.filter = 'brightness(0)';
ctx.drawImage(fieldCv, -FIELD_W / 2 + 6, -FIELD_H / 2 + 9);
ctx.filter = 'none';
ctx.globalAlpha = 1;
ctx.drawImage(fieldCv, -FIELD_W / 2, -FIELD_H / 2);
ctx.restore();
}
drawLegend();
drawCaption(TURN_LABEL.replace('{p}', fmt(p * 100, 0)), 8, 8, false);
drawCaption(COST_LABEL.replace('{d}', fmt(distanceChecks, 0)).replace('{c}', fmt(lastCompares, 0)), W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="다섯 갈래 잎 한 장이 잎맥에서 먼 가장자리부터 초록에서 노랑, 주황, 빨강으로 물든다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 잎맥에서 먼 가장자리부터 물드는 단풍 — 측정 누적면적 22.259% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function paintField(p) {
if (!fieldCv || !fieldImg) {
return;
}
const d = fieldImg.data;
const th = 1 - p * (1 + RAMP_WIDTH);
for (let i = 0; i < insideIdx.length; i += 1) {
const s = clamp((turnOrder[i] - th) / RAMP_WIDTH, 0, 1);
const c = mixRGB(rampColor(s), INK, 0.34 * veinNear[i]);
const o = insideIdx[i] * 4;
d[o] = c[0];
d[o + 1] = c[1];
d[o + 2] = c[2];
d[o + 3] = 255;
}
rampCompares += insideIdx.length;
lastCompares = insideIdx.length;
const g = fieldCv.getContext('2d');
if (g) {
g.putImageData(fieldImg, 0, 0);
}
}
05흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎
가지에 매달린 잎 여섯 장이 돌풍 띠가 왼쪽에서 오른쪽으로 지나갈 때 차례로 휘고, 꼭지 둘레 고리는 피로를 한계에 견준 비율만큼 차오르다 80% 를 넘으면 단풍색이 된다. stemStep 은 강성 STEM_K 70·감쇠 STEM_DAMP 2.2 의 각 스프링을 적분하며 휜 각도의 제곱을 피로로 쌓고, 꼭지마다 다른 한계 STEM_LIMITS 를 넘는 순간 끊긴 잎은 그때의 속도를 받아 01 의 flutterStep 으로 떨어진다. 꼭지는 x 순서로 1,120·7,280·1,728·2,128·2,304·8,240ms 에 끊겨, 첫 돌풍에 넷이 떨어지고 다음 돌풍에 둘이 떨어진다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="가지에 매달린 잎 여섯 장이 바람에 흔들리다 꼭지가 하나씩 끊겨 떨어진다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x, y, tilt) {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f, windX, windY, dt, liftGain) {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) {
px /= pl;
py /= pl;
}
else {
px = 0;
py = 0;
}
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
const STEM_K = 70;
const STEM_DAMP = 2.2;
const FATIGUE_RATE = 1;
// 바람이 꼭지를 돌리는 세기 — 회전력 = −sin(ang)·바람·STEM_WIND
const STEM_WIND = 0.05;
function makeStem(ax, ay, len, rest, limit) {
return { ax: ax, ay: ay, len: len, rest: rest, ang: rest, angVel: 0, fatigue: 0, limit: limit, snapped: false };
}
// 한 걸음 — 끊기는 걸음에 true. 끊긴 뒤에는 움직이지 않는다
function stemStep(s, windX, dt) {
if (s.snapped) {
return false;
}
const bend = s.ang - s.rest;
const torque = -Math.sin(s.ang) * windX * STEM_WIND;
s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
s.ang += s.angVel * dt;
s.fatigue += bend * bend * FATIGUE_RATE * dt;
if (s.fatigue >= s.limit) {
s.snapped = true;
return true;
}
return false;
}
// 꼭지 끝(잎이 붙은 자리)
function stemTip(s) { return [s.ax + Math.cos(s.ang) * s.len, s.ay + Math.sin(s.ang) * s.len]; }
// 반지름 r 자리의 속도 — 각속도 × r, 반지름에 수직. 끊기는 순간 이 속도를 그대로 자유 낙하로 넘긴다
function stemVelocityAt(s, r) { return [-Math.sin(s.ang) * s.angVel * r, Math.cos(s.ang) * s.angVel * r]; }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t, speed) {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x, center) {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x, y, t, center, out) {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
// 꼭지 자리(가지 위 x)와 피로 한계 — 한계가 작을수록 먼저 끊긴다
const STEM_SPOTS = [92, 148, 204, 262, 318, 376];
const STEM_LIMITS = [0.014, 0.05, 0.022, 0.028, 0.025, 0.045];
const STEM_LEN = 14;
const HANG_SIZE = 40;
const HANG_GROUND = 268;
const HANG_START_MS = 200;
const HANG_LOOP_MS = 11600;
const SNAP_LABEL = '피로 = 휜 각도² 의 누적 · 끊긴 꼭지 {n}/{m}';
const NEXT_LABEL = '다음 꼭지 피로 {f}%';
let hangers = [];
let t05 = 0;
let snapped = 0;
const wind05 = [0, 0];
function branchY(x) { return 58 - 0.00012 * (x - 240) * (x - 240) * 1.6 + 10 * Math.sin(x * 0.012); }
function build() {
hangers = STEM_SPOTS.map(function (x, i) {
const rest = Math.PI / 2 + (i % 2 === 0 ? 0.12 : -0.1);
return { stem: makeStem(x, branchY(x), STEM_LEN, rest, STEM_LIMITS[i]), fly: null, kind: i % LEAF_KINDS, color: LEAF_COLORS[(i + 1) % LEAF_COLORS.length], size: HANG_SIZE, snapMs: -1, landed: false };
});
t05 = 0;
snapped = 0;
}
function loopLength() { return HANG_LOOP_MS; }
function step(dt) {
t05 += dt * 1000;
if (t05 < HANG_START_MS) {
return;
}
const ts = (t05 - HANG_START_MS) / 1000;
const center = gustCenter(ts, GUST_SPEED);
for (let i = 0; i < hangers.length; i += 1) {
const h = hangers[i];
if (!h.fly) {
const tip = stemTip(h.stem);
windAt(tip[0], tip[1], ts, center, wind05);
if (stemStep(h.stem, wind05[0], dt)) {
// 끊김 — 잎 가운데(꼭지에서 반 잎만큼 더)의 속도를 그대로 넘긴다
const r = STEM_LEN + h.size / 2;
const v = stemVelocityAt(h.stem, r);
const f = makeFlyer(h.stem.ax + Math.cos(h.stem.ang) * r, h.stem.ay + Math.sin(h.stem.ang) * r, h.stem.ang);
f.vx = v[0];
f.vy = v[1];
f.spin = h.stem.angVel;
h.fly = f;
h.snapMs = t05;
snapped += 1;
}
continue;
}
if (h.landed) {
continue;
}
windAt(h.fly.x, h.fly.y, ts, center, wind05);
flutterStep(h.fly, wind05[0] * 0.5, 0, dt, LEAF_LIFT_GAIN);
if (h.fly.y >= HANG_GROUND) {
h.fly.y = HANG_GROUND;
h.fly.tilt = 0;
h.landed = true;
}
}
}
// 동작 줄이기 — 여섯 장 모두 끊겨 바닥에 내려앉은 판
function finalScene() {
build();
for (let i = 0; i < 2400; i += 1) {
step(STEP_S);
if (hangers.every(function (h) { return h.landed; })) {
break;
}
}
}
function onWake() { }
function drawBranch() {
ctx.strokeStyle = rgba(BARK, 1);
ctx.lineCap = 'round';
ctx.lineWidth = 9;
ctx.beginPath();
ctx.moveTo(-10, branchY(-10) + 4);
for (let x = 0; x <= 440; x += 10) {
ctx.lineTo(x, branchY(x) + (440 - x) * 0.012);
}
ctx.stroke();
ctx.strokeStyle = rgba(BARK_DEEP, 0.6);
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let x = 0; x <= 440; x += 10) {
const y = branchY(x) + (440 - x) * 0.012 + 3;
if (x === 0) {
ctx.moveTo(x, y);
}
else {
ctx.lineTo(x, y);
}
}
ctx.stroke();
}
// 피로 고리 — 꼭지 자리 둘레에 한계까지 남은 몫
function drawFatigue(h) {
const k = clamp(h.stem.fatigue / h.stem.limit, 0, 1);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(h.stem.ax, h.stem.ay - 13, 7, 0, TAU);
ctx.stroke();
ctx.strokeStyle = rgba(k > 0.8 ? MAPLE : BARK_DEEP, 1);
ctx.beginPath();
ctx.arc(h.stem.ax, h.stem.ay - 13, 7, -Math.PI / 2, -Math.PI / 2 + k * TAU);
ctx.stroke();
}
function draw() {
clearSky();
drawGround(HANG_GROUND + 8);
drawBranch();
let next = null;
for (let i = 0; i < hangers.length; i += 1) {
const h = hangers[i];
if (!h.fly) {
const tip = stemTip(h.stem);
ctx.strokeStyle = rgba(BARK_DEEP, 1);
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(h.stem.ax, h.stem.ay);
ctx.lineTo(tip[0], tip[1]);
ctx.stroke();
const c = h.size / 2;
blitLeaf(spriteFor(h.kind, h.color, 0), tip[0] + Math.cos(h.stem.ang) * c, tip[1] + Math.sin(h.stem.ang) * c, h.size, h.stem.ang, 0.7, 1);
drawFatigue(h);
if (!next || h.stem.fatigue / h.stem.limit > next.stem.fatigue / next.stem.limit) {
next = h;
}
}
else {
blitLeaf(spriteFor(h.kind, h.color, 0), h.fly.x, h.fly.y, h.size, h.fly.tilt, 0.62, 1);
}
}
drawCaption(SNAP_LABEL.replace('{n}', fmt(snapped, 0)).replace('{m}', fmt(hangers.length, 0)), 8, H - 28, false);
if (next) {
drawCaption(NEXT_LABEL.replace('{f}', fmt(clamp(next.stem.fatigue / next.stem.limit, 0, 1) * 100, 0)), W - 8, H - 28, true);
}
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafStemFatigueSnap - vanilla 판(05-leaf-stem-sway-fatigue-snap) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafStemFatigueSnap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafStemFatigueSnap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 떨어지는 잎 한 장(옆에서 본 판) — 자리·속도(px/s), 기운 각 tilt(라디안, 0 이면 수평으로 누움)·각속도 spin.
// lift·drag·aoa 는 마지막 걸음의 양력·저항(px/s²)과 받음각(라디안) — 화살표·설명 글이 읽는다
type Flyer = { x: number; y: number; vx: number; vy: number; tilt: number; spin: number; lift: number; drag: number; aoa: number };
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x: number, y: number, tilt: number): Flyer {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f: Flyer, windX: number, windY: number, dt: number, liftGain: number): void {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) { px /= pl; py /= pl; } else { px = 0; py = 0; }
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
// 가지에 매달린 잎 — 꼭지 자리(ax, ay)에서 길이 len 만큼 뻗은 각 스프링. ang 은 꼭지→잎 방향(라디안), rest 는 쉬는 각.
// fatigue 는 휜 각도²을 시간으로 쌓은 값, limit 을 넘으면 snapped
type Stem = { ax: number; ay: number; len: number; rest: number; ang: number; angVel: number; fatigue: number; limit: number; snapped: boolean };
const STEM_K = 70;
const STEM_DAMP = 2.2;
const FATIGUE_RATE = 1;
// 바람이 꼭지를 돌리는 세기 — 회전력 = −sin(ang)·바람·STEM_WIND
const STEM_WIND = 0.05;
function makeStem(ax: number, ay: number, len: number, rest: number, limit: number): Stem {
return { ax: ax, ay: ay, len: len, rest: rest, ang: rest, angVel: 0, fatigue: 0, limit: limit, snapped: false };
}
// 한 걸음 — 끊기는 걸음에 true. 끊긴 뒤에는 움직이지 않는다
function stemStep(s: Stem, windX: number, dt: number): boolean {
if (s.snapped) { return false; }
const bend = s.ang - s.rest;
const torque = -Math.sin(s.ang) * windX * STEM_WIND;
s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
s.ang += s.angVel * dt;
s.fatigue += bend * bend * FATIGUE_RATE * dt;
if (s.fatigue >= s.limit) { s.snapped = true; return true; }
return false;
}
// 꼭지 끝(잎이 붙은 자리)
function stemTip(s: Stem): Pt { return [s.ax + Math.cos(s.ang) * s.len, s.ay + Math.sin(s.ang) * s.len]; }
// 반지름 r 자리의 속도 — 각속도 × r, 반지름에 수직. 끊기는 순간 이 속도를 그대로 자유 낙하로 넘긴다
function stemVelocityAt(s: Stem, r: number): Pt { return [-Math.sin(s.ang) * s.angVel * r, Math.cos(s.ang) * s.angVel * r]; }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t: number, speed: number): number {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x: number, center: number): number {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x: number, y: number, t: number, center: number, out: Pt): void {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
// 05 — 가지 끝에 매달린 잎 여섯 장. 꼭지는 각 스프링(lib/stem)이고, 휜 각도²을 시간으로 쌓은 피로가 한계를 넘으면
// 꼭지가 끊겨 그 순간의 끝점 속도를 그대로 이어받아 01 과 같은 양력·저항 걸음(lib/flutter)으로 떨어진다
type Hanger = { stem: Stem; fly: Flyer | null; kind: number; color: RGB; size: number; snapMs: number; landed: boolean };
// 꼭지 자리(가지 위 x)와 피로 한계 — 한계가 작을수록 먼저 끊긴다
const STEM_SPOTS = [92, 148, 204, 262, 318, 376];
const STEM_LIMITS = [0.014, 0.05, 0.022, 0.028, 0.025, 0.045];
const STEM_LEN = 14;
const HANG_SIZE = 40;
const HANG_GROUND = 268;
const HANG_START_MS = 200;
const HANG_LOOP_MS = 11600;
const SNAP_LABEL = '피로 = 휜 각도² 의 누적 · 끊긴 꼭지 {n}/{m}';
const NEXT_LABEL = '다음 꼭지 피로 {f}%';
let hangers: Hanger[] = [];
let t05 = 0;
let snapped = 0;
const wind05: Pt = [0, 0];
function branchY(x: number): number { return 58 - 0.00012 * (x - 240) * (x - 240) * 1.6 + 10 * Math.sin(x * 0.012); }
function build(): void {
hangers = STEM_SPOTS.map(function (x: number, i: number): Hanger {
const rest = Math.PI / 2 + (i % 2 === 0 ? 0.12 : -0.1);
return { stem: makeStem(x, branchY(x), STEM_LEN, rest, STEM_LIMITS[i]), fly: null, kind: i % LEAF_KINDS, color: LEAF_COLORS[(i + 1) % LEAF_COLORS.length], size: HANG_SIZE, snapMs: -1, landed: false };
});
t05 = 0;
snapped = 0;
}
function loopLength(): number { return HANG_LOOP_MS; }
function step(dt: number): void {
t05 += dt * 1000;
if (t05 < HANG_START_MS) { return; }
const ts = (t05 - HANG_START_MS) / 1000;
const center = gustCenter(ts, GUST_SPEED);
for (let i = 0; i < hangers.length; i += 1) {
const h = hangers[i];
if (!h.fly) {
const tip = stemTip(h.stem);
windAt(tip[0], tip[1], ts, center, wind05);
if (stemStep(h.stem, wind05[0], dt)) {
// 끊김 — 잎 가운데(꼭지에서 반 잎만큼 더)의 속도를 그대로 넘긴다
const r = STEM_LEN + h.size / 2;
const v = stemVelocityAt(h.stem, r);
const f = makeFlyer(h.stem.ax + Math.cos(h.stem.ang) * r, h.stem.ay + Math.sin(h.stem.ang) * r, h.stem.ang);
f.vx = v[0];
f.vy = v[1];
f.spin = h.stem.angVel;
h.fly = f;
h.snapMs = t05;
snapped += 1;
}
continue;
}
if (h.landed) { continue; }
windAt(h.fly.x, h.fly.y, ts, center, wind05);
flutterStep(h.fly, wind05[0] * 0.5, 0, dt, LEAF_LIFT_GAIN);
if (h.fly.y >= HANG_GROUND) { h.fly.y = HANG_GROUND; h.fly.tilt = 0; h.landed = true; }
}
}
// 동작 줄이기 — 여섯 장 모두 끊겨 바닥에 내려앉은 판
function finalScene(): void {
build();
for (let i = 0; i < 2400; i += 1) {
step(STEP_S);
if (hangers.every(function (h: Hanger): boolean { return h.landed; })) { break; }
}
}
function onWake(): void { }
function drawBranch(): void {
ctx.strokeStyle = rgba(BARK, 1);
ctx.lineCap = 'round';
ctx.lineWidth = 9;
ctx.beginPath();
ctx.moveTo(-10, branchY(-10) + 4);
for (let x = 0; x <= 440; x += 10) { ctx.lineTo(x, branchY(x) + (440 - x) * 0.012); }
ctx.stroke();
ctx.strokeStyle = rgba(BARK_DEEP, 0.6);
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let x = 0; x <= 440; x += 10) {
const y = branchY(x) + (440 - x) * 0.012 + 3;
if (x === 0) { ctx.moveTo(x, y); } else { ctx.lineTo(x, y); }
}
ctx.stroke();
}
// 피로 고리 — 꼭지 자리 둘레에 한계까지 남은 몫
function drawFatigue(h: Hanger): void {
const k = clamp(h.stem.fatigue / h.stem.limit, 0, 1);
ctx.lineWidth = 3;
ctx.strokeStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.arc(h.stem.ax, h.stem.ay - 13, 7, 0, TAU);
ctx.stroke();
ctx.strokeStyle = rgba(k > 0.8 ? MAPLE : BARK_DEEP, 1);
ctx.beginPath();
ctx.arc(h.stem.ax, h.stem.ay - 13, 7, -Math.PI / 2, -Math.PI / 2 + k * TAU);
ctx.stroke();
}
function draw(): void {
clearSky();
drawGround(HANG_GROUND + 8);
drawBranch();
let next: Hanger | null = null;
for (let i = 0; i < hangers.length; i += 1) {
const h = hangers[i];
if (!h.fly) {
const tip = stemTip(h.stem);
ctx.strokeStyle = rgba(BARK_DEEP, 1);
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.moveTo(h.stem.ax, h.stem.ay);
ctx.lineTo(tip[0], tip[1]);
ctx.stroke();
const c = h.size / 2;
blitLeaf(spriteFor(h.kind, h.color, 0), tip[0] + Math.cos(h.stem.ang) * c, tip[1] + Math.sin(h.stem.ang) * c, h.size, h.stem.ang, 0.7, 1);
drawFatigue(h);
if (!next || h.stem.fatigue / h.stem.limit > next.stem.fatigue / next.stem.limit) { next = h; }
} else {
blitLeaf(spriteFor(h.kind, h.color, 0), h.fly.x, h.fly.y, h.size, h.fly.tilt, 0.62, 1);
}
}
drawCaption(SNAP_LABEL.replace('{n}', fmt(snapped, 0)).replace('{m}', fmt(hangers.length, 0)), 8, H - 28, false);
if (next) { drawCaption(NEXT_LABEL.replace('{f}', fmt(clamp(next.stem.fatigue / next.stem.limit, 0, 1) * 100, 0)), W - 8, H - 28, true); }
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="가지에 매달린 잎 여섯 장이 바람에 흔들리다 꼭지가 하나씩 끊겨 떨어진다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 흔들림이 쌓이면 꼭지가 끊겨 떨어지는 잎 — 측정 누적면적 8.544% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function stemStep(s, windX, dt) {
if (s.snapped) {
return false;
}
const bend = s.ang - s.rest;
const torque = -Math.sin(s.ang) * windX * STEM_WIND;
s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
s.ang += s.angVel * dt;
s.fatigue += bend * bend * FATIGUE_RATE * dt;
if (s.fatigue >= s.limit) {
s.snapped = true;
return true;
}
return false;
}
06비탈이 가팔라지면 흘러내리는 낙엽 더미
바닥을 12px 칸 40개로 나눠 이미 쌓인 160장 위에 x 236 둘레로 초당 80장씩 440장을 쏟고, 꼭대기 양옆에 한계 기울기 비탈을 점선으로 그린다. 아래 조각은 heapRelax 의 몸통으로, 칸마다 양옆 가운데 (높이 차 × LEAF_THICK 2.6) ÷ 칸 폭이 기울기 한계를 넘는 더 가파른 쪽으로 맨 위 잎 하나를 옮기고, 옮긴 잎은 260ms 동안 옛 자리에서 새 자리로 미끄러져 보인다. 다 쏟고 나면 600장이 꼭대기 124.8px·폭 300px 둔덕으로 서고, 그동안 흘러내림은 1,571번 일어난다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="한 자리로 쏟아지는 낙엽이 바닥에 쌓이고, 비탈이 가팔라지면 옆으로 흘러내려 둔덕이 된다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const LEAF_THICK = 2.6;
// 한계 기울기 — 이웃 칸과의 높이 차 ÷ 칸 폭 이 이 값을 넘으면 흘러내린다
const REPOSE_SLOPE = 0.9;
const SLIDE_MS = 260;
function heapMake(x0, count, cellW, ground) {
const cols = [];
for (let i = 0; i < count; i += 1) {
cols.push([]);
}
return { x0: x0, cellW: cellW, ground: ground, cols: cols, moves: 0 };
}
function heapCol(hp, x) { return clamp(Math.floor((x - hp.x0) / hp.cellW), 0, hp.cols.length - 1); }
function heapTop(hp, c) { return hp.ground - hp.cols[c].length * LEAF_THICK; }
function heapSlotX(hp, c, leaf) { return hp.x0 + (c + 0.5 + leaf.jitter * 0.6) * hp.cellW; }
function heapSlotY(hp, level) { return hp.ground - (level + 0.5) * LEAF_THICK; }
// 무너짐 — 칸마다 양옆을 보고, 높이 차 ÷ 칸 폭 이 slope 를 넘는 쪽(더 가파른 쪽)으로 맨 위 잎 하나를 옮긴다.
// 옮길 것이 없을 때까지 되풀이(한 번에 많아야 maxMoves). 훑는 방향은 판마다 바꿔 한쪽으로 쏠리지 않게
function heapRelax(hp, slope, maxMoves) {
const n = hp.cols.length;
let moved = 0;
let changed = true;
let pass = 0;
while (changed && moved < maxMoves) {
changed = false;
for (let i = 0; i < n && moved < maxMoves; i += 1) {
const c = pass % 2 === 0 ? i : n - 1 - i;
const h = hp.cols[c].length;
let best = -1;
let bestDrop = 0;
for (let k = 0; k < 2; k += 1) {
const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
if (nb < 0 || nb >= n) {
continue;
}
const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
if (drop > slope && drop > bestDrop) {
best = nb;
bestDrop = drop;
}
}
if (best < 0) {
continue;
}
const leaf = hp.cols[c].pop();
leaf.fromX = heapSlotX(hp, c, leaf);
leaf.fromY = heapSlotY(hp, h - 1);
leaf.slideAge = 0;
hp.cols[best].push(leaf);
moved += 1;
changed = true;
}
pass += 1;
}
hp.moves += moved;
return moved;
}
// 칸의 겉면 기울기(라디안) — 양옆 칸 높이 차로. 쌓인 잎은 이 각도로 눕힌다
function heapSlopeAngle(hp, c) {
const l = hp.cols[Math.max(0, c - 1)].length;
const r = hp.cols[Math.min(hp.cols.length - 1, c + 1)].length;
return -Math.atan2((r - l) * LEAF_THICK, hp.cellW * 2);
}
function heapCount(hp) {
let s = 0;
for (let i = 0; i < hp.cols.length; i += 1) {
s += hp.cols[i].length;
}
return s;
}
function heapAdvance(hp, dtMs) {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
for (let k = 0; k < col.length; k += 1) {
if (col[k].slideAge >= 0) {
col[k].slideAge += dtMs;
if (col[k].slideAge >= SLIDE_MS) {
col[k].slideAge = -1;
}
}
}
}
}
// 더미 그리기 — 아래 칸부터, 잎마다 칸 기울기로 눕힌 스프라이트. 흘러내리는 잎은 옛 자리에서 새 자리로 미끄러진다
function drawHeap(hp, size, blur, alpha) {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
const ang = heapSlopeAngle(hp, c);
for (let k = 0; k < col.length; k += 1) {
const lf = col[k];
let x = heapSlotX(hp, c, lf);
let y = heapSlotY(hp, k);
if (lf.slideAge >= 0) {
const s = smooth(lf.slideAge / SLIDE_MS);
x = lerp(lf.fromX, x, s);
y = lerp(lf.fromY, y, s);
}
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], blur), x, y, size, ang + lf.tilt, 0.55, alpha);
}
}
}
const CELL_W = 12;
const PILE_GROUND = 272;
// 쏟는 자리와 퍼짐(px), 초당 잎 수, 모두 몇 장
const POUR_X = 236;
const POUR_SPREAD = 16;
const POUR_RATE = 80;
const POUR_TOTAL = 440;
// 시작할 때 이미 쌓여 있는 잎 — 첫 2초 안에 흘러내림이 보이게
const PRESEED = 160;
const PILE_FALL = 150;
const PILE_LEAF_SIZE = 20;
// 잎 하나가 내려앉은 뒤 한 번에 옮길 수 있는 잎 수 상한
const RELAX_PER_LAND = 40;
const PILE_START_MS = 200;
const PILE_LOOP_MS = 10400;
const SLOPE_LABEL = '한계 기울기 {s} ({d}°) · 흘러내림 {m}회';
const PILE_LABEL = '더미 {n}장 · 꼭대기 {h}px';
let hp = heapMake(0, Math.ceil(W / CELL_W), CELL_W, PILE_GROUND);
let pouring = [];
let poured = 0;
let pourAcc = 0;
let t06 = 0;
let rng06 = makeRng(4796);
function newPileLeaf() {
return { kind: Math.floor(rng06() * LEAF_KINDS), color: Math.floor(rng06() * LEAF_COLORS.length), jitter: rng06() - 0.5, tilt: (rng06() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 };
}
function land(x, leaf) {
hp.cols[heapCol(hp, x)].push(leaf);
heapRelax(hp, REPOSE_SLOPE, RELAX_PER_LAND);
}
function build() {
rng06 = makeRng(4796);
hp = heapMake(0, Math.ceil(W / CELL_W), CELL_W, PILE_GROUND);
for (let i = 0; i < PRESEED; i += 1) {
land(POUR_X + (rng06() - 0.5) * POUR_SPREAD * 2, newPileLeaf());
}
// 미리 쌓은 잎은 미끄러지는 중으로 보이지 않게
for (let c = 0; c < hp.cols.length; c += 1) {
for (let k = 0; k < hp.cols[c].length; k += 1) {
hp.cols[c][k].slideAge = -1;
}
}
hp.moves = 0;
pouring = [];
poured = 0;
pourAcc = 0;
t06 = 0;
}
function loopLength() { return PILE_LOOP_MS; }
function step(dt) {
t06 += dt * 1000;
heapAdvance(hp, dt * 1000);
if (t06 < PILE_START_MS) {
return;
}
pourAcc += POUR_RATE * dt;
while (pourAcc >= 1 && poured < POUR_TOTAL) {
pourAcc -= 1;
poured += 1;
const lf = newPileLeaf();
pouring.push({ x: POUR_X + (rng06() - 0.5) * POUR_SPREAD * 2, y: -12 - rng06() * 30, vx: (rng06() - 0.5) * 24, vy: PILE_FALL * (0.8 + rng06() * 0.4), tilt: rng06() * TAU, spin: (rng06() - 0.5) * 6, kind: lf.kind, color: lf.color, jitter: lf.jitter });
}
const keep = [];
for (let i = 0; i < pouring.length; i += 1) {
const p = pouring[i];
p.x += (p.vx + 14 * Math.sin(p.tilt)) * dt;
p.y += p.vy * dt;
p.tilt += p.spin * dt;
const c = heapCol(hp, p.x);
if (p.y >= heapTop(hp, c) - 2) {
land(p.x, { kind: p.kind, color: p.color, jitter: p.jitter, tilt: (p.jitter) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
continue;
}
keep.push(p);
}
pouring = keep;
}
// 동작 줄이기 — 다 쏟고 흘러내림까지 끝난 더미
function finalScene() {
build();
for (let i = 0; i < 1600; i += 1) {
step(STEP_S);
if (poured >= POUR_TOTAL && pouring.length === 0) {
break;
}
}
heapAdvance(hp, SLIDE_MS);
}
function onWake() { }
function peak() {
let best = 0;
for (let c = 0; c < hp.cols.length; c += 1) {
if (hp.cols[c].length > hp.cols[best].length) {
best = c;
}
}
return best;
}
// 한계 기울기 안내선 — 꼭대기에서 양쪽으로 REPOSE_SLOPE 비탈
function drawGuide() {
const c = peak();
const top = heapTop(hp, c);
const x = hp.x0 + (c + 0.5) * CELL_W;
const run = (PILE_GROUND - top) / REPOSE_SLOPE;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = rgba(INK, 0.55);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(x - run, PILE_GROUND);
ctx.lineTo(x, top);
ctx.lineTo(x + run, PILE_GROUND);
ctx.stroke();
ctx.setLineDash([]);
}
function draw() {
clearSky();
drawGround(PILE_GROUND);
drawHeap(hp, PILE_LEAF_SIZE, 0, 1);
for (let i = 0; i < pouring.length; i += 1) {
const p = pouring[i];
blitLeaf(spriteFor(p.kind, LEAF_COLORS[p.color], 0), p.x, p.y, PILE_LEAF_SIZE, p.tilt, 0.4 + 0.5 * Math.abs(Math.sin(p.tilt)), 1);
}
drawGuide();
drawCaption(SLOPE_LABEL.replace('{s}', fmt(REPOSE_SLOPE, 1)).replace('{d}', fmt(Math.atan(REPOSE_SLOPE) * 180 / Math.PI, 0)).replace('{m}', fmt(hp.moves, 0)), 8, 8, false);
drawCaption(PILE_LABEL.replace('{n}', fmt(heapCount(hp), 0)).replace('{h}', fmt(PILE_GROUND - heapTop(hp, peak()), 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafHeapReposeSlide - vanilla 판(06-leaf-ground-heap-repose-slide) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafHeapReposeSlide.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafHeapReposeSlide() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 바닥 더미 — 바닥을 폭 cellW 칸으로 나누고 칸마다 쌓인 잎 목록을 둔다. 칸 높이 = 잎 수 × LEAF_THICK
// slideAge 가 0 이상이면 이웃 칸에서 흘러내려 오는 중(fromX, fromY 에서 제자리로)
type PileLeaf = { kind: number; color: number; jitter: number; tilt: number; slideAge: number; fromX: number; fromY: number };
type Heap = { x0: number; cellW: number; ground: number; cols: PileLeaf[][]; moves: number };
const LEAF_THICK = 2.6;
// 한계 기울기 — 이웃 칸과의 높이 차 ÷ 칸 폭 이 이 값을 넘으면 흘러내린다
const REPOSE_SLOPE = 0.9;
const SLIDE_MS = 260;
function heapMake(x0: number, count: number, cellW: number, ground: number): Heap {
const cols: PileLeaf[][] = [];
for (let i = 0; i < count; i += 1) { cols.push([]); }
return { x0: x0, cellW: cellW, ground: ground, cols: cols, moves: 0 };
}
function heapCol(hp: Heap, x: number): number { return clamp(Math.floor((x - hp.x0) / hp.cellW), 0, hp.cols.length - 1); }
function heapTop(hp: Heap, c: number): number { return hp.ground - hp.cols[c].length * LEAF_THICK; }
function heapSlotX(hp: Heap, c: number, leaf: PileLeaf): number { return hp.x0 + (c + 0.5 + leaf.jitter * 0.6) * hp.cellW; }
function heapSlotY(hp: Heap, level: number): number { return hp.ground - (level + 0.5) * LEAF_THICK; }
// 무너짐 — 칸마다 양옆을 보고, 높이 차 ÷ 칸 폭 이 slope 를 넘는 쪽(더 가파른 쪽)으로 맨 위 잎 하나를 옮긴다.
// 옮길 것이 없을 때까지 되풀이(한 번에 많아야 maxMoves). 훑는 방향은 판마다 바꿔 한쪽으로 쏠리지 않게
function heapRelax(hp: Heap, slope: number, maxMoves: number): number {
const n = hp.cols.length;
let moved = 0;
let changed = true;
let pass = 0;
while (changed && moved < maxMoves) {
changed = false;
for (let i = 0; i < n && moved < maxMoves; i += 1) {
const c = pass % 2 === 0 ? i : n - 1 - i;
const h = hp.cols[c].length;
let best = -1;
let bestDrop = 0;
for (let k = 0; k < 2; k += 1) {
const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
if (nb < 0 || nb >= n) { continue; }
const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
if (drop > slope && drop > bestDrop) { best = nb; bestDrop = drop; }
}
if (best < 0) { continue; }
const leaf = hp.cols[c].pop() as PileLeaf;
leaf.fromX = heapSlotX(hp, c, leaf);
leaf.fromY = heapSlotY(hp, h - 1);
leaf.slideAge = 0;
hp.cols[best].push(leaf);
moved += 1;
changed = true;
}
pass += 1;
}
hp.moves += moved;
return moved;
}
// 칸의 겉면 기울기(라디안) — 양옆 칸 높이 차로. 쌓인 잎은 이 각도로 눕힌다
function heapSlopeAngle(hp: Heap, c: number): number {
const l = hp.cols[Math.max(0, c - 1)].length;
const r = hp.cols[Math.min(hp.cols.length - 1, c + 1)].length;
return -Math.atan2((r - l) * LEAF_THICK, hp.cellW * 2);
}
function heapCount(hp: Heap): number {
let s = 0;
for (let i = 0; i < hp.cols.length; i += 1) { s += hp.cols[i].length; }
return s;
}
function heapAdvance(hp: Heap, dtMs: number): void {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
for (let k = 0; k < col.length; k += 1) {
if (col[k].slideAge >= 0) {
col[k].slideAge += dtMs;
if (col[k].slideAge >= SLIDE_MS) { col[k].slideAge = -1; }
}
}
}
}
// 더미 그리기 — 아래 칸부터, 잎마다 칸 기울기로 눕힌 스프라이트. 흘러내리는 잎은 옛 자리에서 새 자리로 미끄러진다
function drawHeap(hp: Heap, size: number, blur: number, alpha: number): void {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
const ang = heapSlopeAngle(hp, c);
for (let k = 0; k < col.length; k += 1) {
const lf = col[k];
let x = heapSlotX(hp, c, lf);
let y = heapSlotY(hp, k);
if (lf.slideAge >= 0) {
const s = smooth(lf.slideAge / SLIDE_MS);
x = lerp(lf.fromX, x, s);
y = lerp(lf.fromY, y, s);
}
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], blur), x, y, size, ang + lf.tilt, 0.55, alpha);
}
}
}
// 06 — 한 자리로 쏟아지는 잎이 바닥 칸 높이맵(lib/heap)에 쌓이고, 이웃 칸과의 높이 차 ÷ 칸 폭이
// REPOSE_SLOPE 를 넘을 때마다 맨 위 잎이 낮은 쪽으로 흘러내려 더미가 한계 기울기의 둔덕으로 정리된다
type Pouring = { x: number; y: number; vx: number; vy: number; tilt: number; spin: number; kind: number; color: number; jitter: number };
const CELL_W = 12;
const PILE_GROUND = 272;
// 쏟는 자리와 퍼짐(px), 초당 잎 수, 모두 몇 장
const POUR_X = 236;
const POUR_SPREAD = 16;
const POUR_RATE = 80;
const POUR_TOTAL = 440;
// 시작할 때 이미 쌓여 있는 잎 — 첫 2초 안에 흘러내림이 보이게
const PRESEED = 160;
const PILE_FALL = 150;
const PILE_LEAF_SIZE = 20;
// 잎 하나가 내려앉은 뒤 한 번에 옮길 수 있는 잎 수 상한
const RELAX_PER_LAND = 40;
const PILE_START_MS = 200;
const PILE_LOOP_MS = 10400;
const SLOPE_LABEL = '한계 기울기 {s} ({d}°) · 흘러내림 {m}회';
const PILE_LABEL = '더미 {n}장 · 꼭대기 {h}px';
let hp: Heap = heapMake(0, Math.ceil(W / CELL_W), CELL_W, PILE_GROUND);
let pouring: Pouring[] = [];
let poured = 0;
let pourAcc = 0;
let t06 = 0;
let rng06 = makeRng(4796);
function newPileLeaf(): PileLeaf {
return { kind: Math.floor(rng06() * LEAF_KINDS), color: Math.floor(rng06() * LEAF_COLORS.length), jitter: rng06() - 0.5, tilt: (rng06() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 };
}
function land(x: number, leaf: PileLeaf): void {
hp.cols[heapCol(hp, x)].push(leaf);
heapRelax(hp, REPOSE_SLOPE, RELAX_PER_LAND);
}
function build(): void {
rng06 = makeRng(4796);
hp = heapMake(0, Math.ceil(W / CELL_W), CELL_W, PILE_GROUND);
for (let i = 0; i < PRESEED; i += 1) { land(POUR_X + (rng06() - 0.5) * POUR_SPREAD * 2, newPileLeaf()); }
// 미리 쌓은 잎은 미끄러지는 중으로 보이지 않게
for (let c = 0; c < hp.cols.length; c += 1) { for (let k = 0; k < hp.cols[c].length; k += 1) { hp.cols[c][k].slideAge = -1; } }
hp.moves = 0;
pouring = [];
poured = 0;
pourAcc = 0;
t06 = 0;
}
function loopLength(): number { return PILE_LOOP_MS; }
function step(dt: number): void {
t06 += dt * 1000;
heapAdvance(hp, dt * 1000);
if (t06 < PILE_START_MS) { return; }
pourAcc += POUR_RATE * dt;
while (pourAcc >= 1 && poured < POUR_TOTAL) {
pourAcc -= 1;
poured += 1;
const lf = newPileLeaf();
pouring.push({ x: POUR_X + (rng06() - 0.5) * POUR_SPREAD * 2, y: -12 - rng06() * 30, vx: (rng06() - 0.5) * 24, vy: PILE_FALL * (0.8 + rng06() * 0.4), tilt: rng06() * TAU, spin: (rng06() - 0.5) * 6, kind: lf.kind, color: lf.color, jitter: lf.jitter });
}
const keep: Pouring[] = [];
for (let i = 0; i < pouring.length; i += 1) {
const p = pouring[i];
p.x += (p.vx + 14 * Math.sin(p.tilt)) * dt;
p.y += p.vy * dt;
p.tilt += p.spin * dt;
const c = heapCol(hp, p.x);
if (p.y >= heapTop(hp, c) - 2) {
land(p.x, { kind: p.kind, color: p.color, jitter: p.jitter, tilt: (p.jitter) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
continue;
}
keep.push(p);
}
pouring = keep;
}
// 동작 줄이기 — 다 쏟고 흘러내림까지 끝난 더미
function finalScene(): void {
build();
for (let i = 0; i < 1600; i += 1) {
step(STEP_S);
if (poured >= POUR_TOTAL && pouring.length === 0) { break; }
}
heapAdvance(hp, SLIDE_MS);
}
function onWake(): void { }
function peak(): number {
let best = 0;
for (let c = 0; c < hp.cols.length; c += 1) { if (hp.cols[c].length > hp.cols[best].length) { best = c; } }
return best;
}
// 한계 기울기 안내선 — 꼭대기에서 양쪽으로 REPOSE_SLOPE 비탈
function drawGuide(): void {
const c = peak();
const top = heapTop(hp, c);
const x = hp.x0 + (c + 0.5) * CELL_W;
const run = (PILE_GROUND - top) / REPOSE_SLOPE;
ctx.setLineDash([5, 5]);
ctx.strokeStyle = rgba(INK, 0.55);
ctx.lineWidth = 1.3;
ctx.beginPath();
ctx.moveTo(x - run, PILE_GROUND);
ctx.lineTo(x, top);
ctx.lineTo(x + run, PILE_GROUND);
ctx.stroke();
ctx.setLineDash([]);
}
function draw(): void {
clearSky();
drawGround(PILE_GROUND);
drawHeap(hp, PILE_LEAF_SIZE, 0, 1);
for (let i = 0; i < pouring.length; i += 1) {
const p = pouring[i];
blitLeaf(spriteFor(p.kind, LEAF_COLORS[p.color], 0), p.x, p.y, PILE_LEAF_SIZE, p.tilt, 0.4 + 0.5 * Math.abs(Math.sin(p.tilt)), 1);
}
drawGuide();
drawCaption(SLOPE_LABEL.replace('{s}', fmt(REPOSE_SLOPE, 1)).replace('{d}', fmt(Math.atan(REPOSE_SLOPE) * 180 / Math.PI, 0)).replace('{m}', fmt(hp.moves, 0)), 8, 8, false);
drawCaption(PILE_LABEL.replace('{n}', fmt(heapCount(hp), 0)).replace('{h}', fmt(PILE_GROUND - heapTop(hp, peak()), 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="한 자리로 쏟아지는 낙엽이 바닥에 쌓이고, 비탈이 가팔라지면 옆으로 흘러내려 둔덕이 된다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 비탈이 가팔라지면 흘러내리는 낙엽 더미 — 측정 누적면적 12.862% · 움직인 프레임 22/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
const h = hp.cols[c].length;
let best = -1;
let bestDrop = 0;
for (let k = 0; k < 2; k += 1) {
const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
if (nb < 0 || nb >= n) {
continue;
}
const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
if (drop > slope && drop > bestDrop) {
best = nb;
bestDrop = drop;
}
}
if (best < 0) {
continue;
}
const leaf = hp.cols[c].pop();
leaf.fromX = heapSlotX(hp, c, leaf);
leaf.fromY = heapSlotY(hp, h - 1);
leaf.slideAge = 0;
hp.cols[best].push(leaf);
moved += 1;
changed = true;
07빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당
위에서 내려다본 마당에 잎 150장이 깔리고, 빗자루가 두 번 굽은 530px 길을 170px/s 로 지나가며 둘레 66px 안의 잎을 밀어 흩는다. 무게 w = (1 − d/R)² 는 반지름 절반에서 0.25 로 줄고, 충격량은 바깥 방향 0.55 와 진행 방향 0.45 를 섞으며, 진행 방향과 바깥 방향의 외적이 옆구리를 맞은 잎일수록 세게 돌린다. 2초 동안 맞은 잎은 59장, 길 끝까지는 102장이고, 맞은 잎은 평균 34.6px 밀려 가며 회전 속도는 최대 9.2rad/s 까지 오른다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="위에서 본 마당에서 빗자루가 곡선 길을 따라 지나가며 낙엽을 밀어 흩고 맞은 잎이 빙글 돈다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const YARD_LEAVES = 150;
const BROOM_RADIUS = 66;
// 충격량 세기(px/s, 빗자루 바로 앞에서) · 엇갈린 몫이 회전으로 바뀌는 비율 · 바닥 마찰(1/초)
const PUSH_GAIN = 1400;
const SPIN_GAIN = 0.05;
const YARD_FRICTION = 3.4;
const BROOM_SPEED = 170;
const PATH_SAMPLES = 160;
const YARD_START_MS = 200;
const BROOM_LABEL = '밀어내기 = (1 − d/R)² · R {r}px';
const HIT_LABEL = '맞은 잎 {n}장';
let yard = [];
let pathPts = [];
let pathLen = [];
let t07 = 0;
let broomX = 0;
let broomY = 0;
let headX = 1;
let headY = 0;
let hits = 0;
// 빗자루 길 — 왼쪽에서 오른쪽으로 두 번 굽는 곡선. 점 사이 거리를 쌓아 누적 길이로 매개한다
function makePath() {
pathPts = [];
pathLen = [];
let acc = 0;
for (let i = 0; i <= PATH_SAMPLES; i += 1) {
const u = i / PATH_SAMPLES;
const p = [30 + u * 420, 156 + 78 * Math.sin(u * TAU * 1.25 + 0.4) * (0.6 + 0.4 * u)];
if (i > 0) {
acc += Math.hypot(p[0] - pathPts[i - 1][0], p[1] - pathPts[i - 1][1]);
}
pathPts.push(p);
pathLen.push(acc);
}
}
// 누적 길이 s 자리 — 이분 탐색으로 구간을 찾아 그 안에서 선형 보간
function pathAt(s, out) {
const total = pathLen[pathLen.length - 1];
const q = clamp(s, 0, total);
let lo = 0;
let hi = pathLen.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (pathLen[mid] <= q) {
lo = mid;
}
else {
hi = mid;
}
}
const seg = pathLen[hi] - pathLen[lo] || 1;
const k = (q - pathLen[lo]) / seg;
out[0] = lerp(pathPts[lo][0], pathPts[hi][0], k);
out[1] = lerp(pathPts[lo][1], pathPts[hi][1], k);
return Math.atan2(pathPts[hi][1] - pathPts[lo][1], pathPts[hi][0] - pathPts[lo][0]);
}
const broomTmp = [0, 0];
function build() {
makePath();
const rng = makeRng(4797);
yard = [];
for (let i = 0; i < YARD_LEAVES; i += 1) {
yard.push({ x: 20 + rng() * 440, y: 20 + rng() * 260, vx: 0, vy: 0, ang: rng() * TAU, spin: 0, hop: 0, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 18 + rng() * 12, hit: false });
}
t07 = 0;
hits = 0;
pathAt(0, broomTmp);
broomX = broomTmp[0];
broomY = broomTmp[1];
}
function loopLength() { return YARD_START_MS + pathLen[pathLen.length - 1] / BROOM_SPEED * 1000 + 1400; }
function step(dt) {
t07 += dt * 1000;
if (t07 < YARD_START_MS) {
return;
}
const px = broomX;
const py = broomY;
if (hoverX >= 0) {
// 포인터가 판 위에 있으면 빗자루가 포인터를 따라간다
broomX = lerp(broomX, hoverX, 0.25);
broomY = lerp(broomY, hoverY, 0.25);
}
else {
pathAt((t07 - YARD_START_MS) / 1000 * BROOM_SPEED, broomTmp);
broomX = broomTmp[0];
broomY = broomTmp[1];
}
const mv = Math.hypot(broomX - px, broomY - py);
if (mv > 0.01) {
headX = (broomX - px) / mv;
headY = (broomY - py) / mv;
}
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
const dx = lf.x - broomX;
const dy = lf.y - broomY;
const d = Math.hypot(dx, dy);
if (d < BROOM_RADIUS && mv > 0.01) {
const w = (1 - d / BROOM_RADIUS) * (1 - d / BROOM_RADIUS);
const ax = d > 0.01 ? dx / d : headX;
const ay = d > 0.01 ? dy / d : headY;
// 미는 방향 = 바깥 방향과 빗자루 진행 방향을 섞은 것
const jx = (0.55 * ax + 0.45 * headX) * w * PUSH_GAIN * dt;
const jy = (0.55 * ay + 0.45 * headY) * w * PUSH_GAIN * dt;
lf.vx += jx;
lf.vy += jy;
// 진행 방향과 바깥 방향의 외적 — 옆구리를 맞은 잎일수록 세게 돈다
lf.spin += (headX * ay - headY * ax) * w * PUSH_GAIN * SPIN_GAIN * dt;
lf.hop = Math.min(1, lf.hop + w * 4 * dt);
if (!lf.hit) {
lf.hit = true;
hits += 1;
}
}
const fr = Math.exp(-YARD_FRICTION * dt);
lf.vx *= fr;
lf.vy *= fr;
lf.spin *= fr;
lf.hop *= Math.exp(-2.5 * dt);
lf.x += lf.vx * dt;
lf.y += lf.vy * dt;
lf.ang += lf.spin * dt;
// 마당 담장 — 판 밖으로 나가면 튕겨 돌아온다
if (lf.x < 8) {
lf.x = 8;
lf.vx = Math.abs(lf.vx) * 0.4;
}
if (lf.x > W - 8) {
lf.x = W - 8;
lf.vx = -Math.abs(lf.vx) * 0.4;
}
if (lf.y < 8) {
lf.y = 8;
lf.vy = Math.abs(lf.vy) * 0.4;
}
if (lf.y > H - 8) {
lf.y = H - 8;
lf.vy = -Math.abs(lf.vy) * 0.4;
}
}
}
// 동작 줄이기 — 빗자루가 길 끝까지 지나간 뒤의 마당
function finalScene() {
build();
const n = Math.ceil((loopLength() - 400) / FIXED_DT);
for (let i = 0; i < n; i += 1) {
step(STEP_S);
}
}
function onWake() { }
function drawYard() {
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(mixRGB(SOIL, SOIL_DEEP, 0.35), 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 정해 둔 길 — 옅은 점선
ctx.setLineDash([3, 7]);
ctx.strokeStyle = rgba(SOIL_DEEP, 0.9);
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < pathPts.length; i += 1) {
if (i === 0) {
ctx.moveTo(pathPts[i][0], pathPts[i][1]);
}
else {
ctx.lineTo(pathPts[i][0], pathPts[i][1]);
}
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawBroom() {
const ang = Math.atan2(headY, headX);
// 밀어내기 장 테두리
const ring = ctx.createRadialGradient(broomX, broomY, 0, broomX, broomY, BROOM_RADIUS);
ring.addColorStop(0, rgba(CREAM, 0.5));
ring.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(broomX, broomY, BROOM_RADIUS, 0, TAU);
ctx.fill();
ctx.save();
ctx.translate(broomX, broomY);
ctx.rotate(ang);
// 자루 — 진행 반대쪽으로 뻗는다
ctx.strokeStyle = rgba(BARK, 1);
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-6, 0);
ctx.lineTo(-64, 18);
ctx.stroke();
// 솔 — 진행 방향에 가로로 놓인 부채꼴
ctx.fillStyle = rgba(BUTTER, 1);
ctx.strokeStyle = rgba(BARK_DEEP, 1);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-8, -20);
ctx.lineTo(10, -26);
ctx.lineTo(10, 26);
ctx.lineTo(-8, 20);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.beginPath();
for (let k = -3; k <= 3; k += 1) {
ctx.moveTo(-6, k * 6);
ctx.lineTo(9, k * 7.5);
}
ctx.strokeStyle = rgba(mixRGB(BUTTER, BARK_DEEP, 0.5), 0.9);
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function draw() {
drawYard();
// 그림자 먼저 — 뜬 잎일수록 그림자가 멀어진다
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
if (lf.hop < 0.05) {
continue;
}
ctx.fillStyle = rgba(SOIL_DEEP, 0.4 * lf.hop);
ctx.beginPath();
ctx.ellipse(lf.x + 6 * lf.hop, lf.y + 8 * lf.hop, lf.size * 0.45, lf.size * 0.2, lf.ang, 0, TAU);
ctx.fill();
}
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], 0), lf.x, lf.y, lf.size * (1 + 0.25 * lf.hop), lf.ang, 1, 1);
}
if (t07 >= YARD_START_MS) {
drawBroom();
}
drawCaption(BROOM_LABEL.replace('{r}', fmt(BROOM_RADIUS, 0)), 8, 8, false);
drawCaption(HIT_LABEL.replace('{n}', fmt(hits, 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafBroomPushScatter - vanilla 판(07-leaf-broom-sweep-pressure-scatter) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafBroomPushScatter.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafBroomPushScatter() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 07 — 위에서 내려다본 마당. 빗자루 머리가 정해 둔 곡선 길을 누적 길이로 따라가고, 둘레 BROOM_RADIUS 안의 잎에
// (1 − d/R)² 로 약해지는 충격량을 준다. 미는 방향의 접선 성분(빗자루 진행 방향과 엇갈린 몫)이 잎을 돌리는 토크가 된다
type Lying = { x: number; y: number; vx: number; vy: number; ang: number; spin: number; hop: number; kind: number; color: number; size: number; hit: boolean };
const YARD_LEAVES = 150;
const BROOM_RADIUS = 66;
// 충격량 세기(px/s, 빗자루 바로 앞에서) · 엇갈린 몫이 회전으로 바뀌는 비율 · 바닥 마찰(1/초)
const PUSH_GAIN = 1400;
const SPIN_GAIN = 0.05;
const YARD_FRICTION = 3.4;
const BROOM_SPEED = 170;
const PATH_SAMPLES = 160;
const YARD_START_MS = 200;
const BROOM_LABEL = '밀어내기 = (1 − d/R)² · R {r}px';
const HIT_LABEL = '맞은 잎 {n}장';
let yard: Lying[] = [];
let pathPts: Pt[] = [];
let pathLen: number[] = [];
let t07 = 0;
let broomX = 0;
let broomY = 0;
let headX = 1;
let headY = 0;
let hits = 0;
// 빗자루 길 — 왼쪽에서 오른쪽으로 두 번 굽는 곡선. 점 사이 거리를 쌓아 누적 길이로 매개한다
function makePath(): void {
pathPts = [];
pathLen = [];
let acc = 0;
for (let i = 0; i <= PATH_SAMPLES; i += 1) {
const u = i / PATH_SAMPLES;
const p: Pt = [30 + u * 420, 156 + 78 * Math.sin(u * TAU * 1.25 + 0.4) * (0.6 + 0.4 * u)];
if (i > 0) { acc += Math.hypot(p[0] - pathPts[i - 1][0], p[1] - pathPts[i - 1][1]); }
pathPts.push(p);
pathLen.push(acc);
}
}
// 누적 길이 s 자리 — 이분 탐색으로 구간을 찾아 그 안에서 선형 보간
function pathAt(s: number, out: Pt): number {
const total = pathLen[pathLen.length - 1];
const q = clamp(s, 0, total);
let lo = 0;
let hi = pathLen.length - 1;
while (hi - lo > 1) {
const mid = (lo + hi) >> 1;
if (pathLen[mid] <= q) { lo = mid; } else { hi = mid; }
}
const seg = pathLen[hi] - pathLen[lo] || 1;
const k = (q - pathLen[lo]) / seg;
out[0] = lerp(pathPts[lo][0], pathPts[hi][0], k);
out[1] = lerp(pathPts[lo][1], pathPts[hi][1], k);
return Math.atan2(pathPts[hi][1] - pathPts[lo][1], pathPts[hi][0] - pathPts[lo][0]);
}
const broomTmp: Pt = [0, 0];
function build(): void {
makePath();
const rng = makeRng(4797);
yard = [];
for (let i = 0; i < YARD_LEAVES; i += 1) {
yard.push({ x: 20 + rng() * 440, y: 20 + rng() * 260, vx: 0, vy: 0, ang: rng() * TAU, spin: 0, hop: 0, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 18 + rng() * 12, hit: false });
}
t07 = 0;
hits = 0;
pathAt(0, broomTmp);
broomX = broomTmp[0];
broomY = broomTmp[1];
}
function loopLength(): number { return YARD_START_MS + pathLen[pathLen.length - 1] / BROOM_SPEED * 1000 + 1400; }
function step(dt: number): void {
t07 += dt * 1000;
if (t07 < YARD_START_MS) { return; }
const px = broomX;
const py = broomY;
if (hoverX >= 0) {
// 포인터가 판 위에 있으면 빗자루가 포인터를 따라간다
broomX = lerp(broomX, hoverX, 0.25);
broomY = lerp(broomY, hoverY, 0.25);
} else {
pathAt((t07 - YARD_START_MS) / 1000 * BROOM_SPEED, broomTmp);
broomX = broomTmp[0];
broomY = broomTmp[1];
}
const mv = Math.hypot(broomX - px, broomY - py);
if (mv > 0.01) { headX = (broomX - px) / mv; headY = (broomY - py) / mv; }
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
const dx = lf.x - broomX;
const dy = lf.y - broomY;
const d = Math.hypot(dx, dy);
if (d < BROOM_RADIUS && mv > 0.01) {
const w = (1 - d / BROOM_RADIUS) * (1 - d / BROOM_RADIUS);
const ax = d > 0.01 ? dx / d : headX;
const ay = d > 0.01 ? dy / d : headY;
// 미는 방향 = 바깥 방향과 빗자루 진행 방향을 섞은 것
const jx = (0.55 * ax + 0.45 * headX) * w * PUSH_GAIN * dt;
const jy = (0.55 * ay + 0.45 * headY) * w * PUSH_GAIN * dt;
lf.vx += jx;
lf.vy += jy;
// 진행 방향과 바깥 방향의 외적 — 옆구리를 맞은 잎일수록 세게 돈다
lf.spin += (headX * ay - headY * ax) * w * PUSH_GAIN * SPIN_GAIN * dt;
lf.hop = Math.min(1, lf.hop + w * 4 * dt);
if (!lf.hit) { lf.hit = true; hits += 1; }
}
const fr = Math.exp(-YARD_FRICTION * dt);
lf.vx *= fr;
lf.vy *= fr;
lf.spin *= fr;
lf.hop *= Math.exp(-2.5 * dt);
lf.x += lf.vx * dt;
lf.y += lf.vy * dt;
lf.ang += lf.spin * dt;
// 마당 담장 — 판 밖으로 나가면 튕겨 돌아온다
if (lf.x < 8) { lf.x = 8; lf.vx = Math.abs(lf.vx) * 0.4; }
if (lf.x > W - 8) { lf.x = W - 8; lf.vx = -Math.abs(lf.vx) * 0.4; }
if (lf.y < 8) { lf.y = 8; lf.vy = Math.abs(lf.vy) * 0.4; }
if (lf.y > H - 8) { lf.y = H - 8; lf.vy = -Math.abs(lf.vy) * 0.4; }
}
}
// 동작 줄이기 — 빗자루가 길 끝까지 지나간 뒤의 마당
function finalScene(): void {
build();
const n = Math.ceil((loopLength() - 400) / FIXED_DT);
for (let i = 0; i < n; i += 1) { step(STEP_S); }
}
function onWake(): void { }
function drawYard(): void {
const g = ctx.createLinearGradient(0, 0, W, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(mixRGB(SOIL, SOIL_DEEP, 0.35), 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
// 정해 둔 길 — 옅은 점선
ctx.setLineDash([3, 7]);
ctx.strokeStyle = rgba(SOIL_DEEP, 0.9);
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < pathPts.length; i += 1) {
if (i === 0) { ctx.moveTo(pathPts[i][0], pathPts[i][1]); } else { ctx.lineTo(pathPts[i][0], pathPts[i][1]); }
}
ctx.stroke();
ctx.setLineDash([]);
}
function drawBroom(): void {
const ang = Math.atan2(headY, headX);
// 밀어내기 장 테두리
const ring = ctx.createRadialGradient(broomX, broomY, 0, broomX, broomY, BROOM_RADIUS);
ring.addColorStop(0, rgba(CREAM, 0.5));
ring.addColorStop(1, rgba(CREAM, 0));
ctx.fillStyle = ring;
ctx.beginPath();
ctx.arc(broomX, broomY, BROOM_RADIUS, 0, TAU);
ctx.fill();
ctx.save();
ctx.translate(broomX, broomY);
ctx.rotate(ang);
// 자루 — 진행 반대쪽으로 뻗는다
ctx.strokeStyle = rgba(BARK, 1);
ctx.lineWidth = 6;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.moveTo(-6, 0);
ctx.lineTo(-64, 18);
ctx.stroke();
// 솔 — 진행 방향에 가로로 놓인 부채꼴
ctx.fillStyle = rgba(BUTTER, 1);
ctx.strokeStyle = rgba(BARK_DEEP, 1);
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.moveTo(-8, -20);
ctx.lineTo(10, -26);
ctx.lineTo(10, 26);
ctx.lineTo(-8, 20);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.beginPath();
for (let k = -3; k <= 3; k += 1) { ctx.moveTo(-6, k * 6); ctx.lineTo(9, k * 7.5); }
ctx.strokeStyle = rgba(mixRGB(BUTTER, BARK_DEEP, 0.5), 0.9);
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
}
function draw(): void {
drawYard();
// 그림자 먼저 — 뜬 잎일수록 그림자가 멀어진다
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
if (lf.hop < 0.05) { continue; }
ctx.fillStyle = rgba(SOIL_DEEP, 0.4 * lf.hop);
ctx.beginPath();
ctx.ellipse(lf.x + 6 * lf.hop, lf.y + 8 * lf.hop, lf.size * 0.45, lf.size * 0.2, lf.ang, 0, TAU);
ctx.fill();
}
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], 0), lf.x, lf.y, lf.size * (1 + 0.25 * lf.hop), lf.ang, 1, 1);
}
if (t07 >= YARD_START_MS) { drawBroom(); }
drawCaption(BROOM_LABEL.replace('{r}', fmt(BROOM_RADIUS, 0)), 8, 8, false);
drawCaption(HIT_LABEL.replace('{n}', fmt(hits, 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="위에서 본 마당에서 빗자루가 곡선 길을 따라 지나가며 낙엽을 밀어 흩고 맞은 잎이 빙글 돈다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 빗자루 바람이 지나가며 낙엽을 쓸어 흩는 마당 — 측정 누적면적 31.231% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
for (let i = 0; i < yard.length; i += 1) {
const lf = yard[i];
const dx = lf.x - broomX;
const dy = lf.y - broomY;
const d = Math.hypot(dx, dy);
if (d < BROOM_RADIUS && mv > 0.01) {
const w = (1 - d / BROOM_RADIUS) * (1 - d / BROOM_RADIUS);
const ax = d > 0.01 ? dx / d : headX;
const ay = d > 0.01 ? dy / d : headY;
// 미는 방향 = 바깥 방향과 빗자루 진행 방향을 섞은 것
const jx = (0.55 * ax + 0.45 * headX) * w * PUSH_GAIN * dt;
const jy = (0.55 * ay + 0.45 * headY) * w * PUSH_GAIN * dt;
lf.vx += jx;
lf.vy += jy;
// 진행 방향과 바깥 방향의 외적 — 옆구리를 맞은 잎일수록 세게 돈다
lf.spin += (headX * ay - headY * ax) * w * PUSH_GAIN * SPIN_GAIN * dt;
lf.hop = Math.min(1, lf.hop + w * 4 * dt);
if (!lf.hit) {
lf.hit = true;
hits += 1;
}
}
08회오리에 말려 나선으로 솟았다 흩어지는 잎
잎 120장이 회오리 가운데로 빨려 들며 나선을 그리고 솟았다가, 세기가 잦아드는 1,600ms 동안 바깥으로 흩어져 내려앉는다. tangential 은 반지름 30 안에서는 통째로 도는 4.2·r, 바깥에서는 4.2·30²/r 이라 경계에서 126px/s 로 가장 빠르고, 상승 기류는 UPDRAFT 420 에 exp(−r/70) 과 (1 − 높이/LOFT_TOP) 을 곱해 높이 230 에 가까울수록 잦아든다. 2초에 떠 있는 잎은 63장, 3,696ms 에는 120장 전부가 떠 있고, 평균 반지름은 96.8px 에서 다 내려앉은 뒤 122.6px 로 넓어진다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="바닥의 낙엽이 회오리에 말려 나선을 그리며 솟아올랐다가 바람이 잦아들자 바깥으로 흩어져 내려앉는다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const SWIRL_COUNT = 120;
const CORE_RADIUS = 30;
// 가운데 통째 회전의 각속도(라디안/초) — 바깥 접선 속도는 SPIN_RATE · CORE_RADIUS² / r
const SPIN_RATE = 4.2;
const INFLOW = 70;
const UPDRAFT = 420;
// 상승 기류가 미치는 반지름 척도 — 세기 ∝ exp(−r / LIFT_REACH)
const LIFT_REACH = 70;
// 상승 기류가 0 이 되는 높이(px) — 이 높이에 가까울수록 위로 끄는 힘이 줄어 잎이 판 밖으로 나가지 않는다
const LOFT_TOP = 230;
const SWIRL_SINK = 120;
// 소용돌이가 잦아든 뒤 바깥으로 흩는 세기(px/s²)
const SCATTER_PUSH = 180;
const SWIRL_FOCAL = 420;
const SWIRL_CENTER_X = 240;
const SWIRL_GROUND = 266;
// 바닥 원판을 눕혀 보이게 하는 비율 — 깊이 z 가 화면 y 로 이만큼 내려온다
const FLOOR_TILT = 0.32;
const SWIRL_START_MS = 200;
// 세기 곡선 — 오름 · 머묾 · 잦아듦 (ms)
const SWIRL_RISE_MS = 500;
const SWIRL_HOLD_MS = 4200;
const SWIRL_FADE_MS = 1600;
const SWIRL_LOOP_MS = 9400;
const SWIRL_LABEL = '가운데 통째 회전 · 바깥 1/r · 세기 {s}%';
const LOFT_LABEL = '떠 있는 잎 {n}장 · 가장 높이 {h}px';
let swirlers = [];
let t08 = 0;
let strength = 0;
function build() {
const rng = makeRng(4798);
swirlers = [];
for (let i = 0; i < SWIRL_COUNT; i += 1) {
swirlers.push({ r: 22 + rng() * 150, th: rng() * TAU, h: 0, vr: 0, vh: 0, tilt: rng() * TAU, spin: 0, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 16 + rng() * 12 });
}
t08 = 0;
strength = 0;
}
function loopLength() { return SWIRL_LOOP_MS; }
function swirlStrength(ms) {
const a = ms - SWIRL_START_MS;
if (a <= 0) {
return 0;
}
if (a < SWIRL_RISE_MS) {
return smooth(a / SWIRL_RISE_MS);
}
if (a < SWIRL_RISE_MS + SWIRL_HOLD_MS) {
return 1;
}
return 1 - smooth((a - SWIRL_RISE_MS - SWIRL_HOLD_MS) / SWIRL_FADE_MS);
}
// 반지름 r 의 접선 속도(px/s) — 안은 강체 회전, 바깥은 1/r
function tangential(r) { return r < CORE_RADIUS ? SPIN_RATE * r : SPIN_RATE * CORE_RADIUS * CORE_RADIUS / r; }
function step(dt) {
t08 += dt * 1000;
strength = swirlStrength(t08);
const s = strength;
for (let i = 0; i < swirlers.length; i += 1) {
const p = swirlers[i];
const onGround = p.h <= 0 && p.vh <= 0;
// 바닥에 누운 잎은 바람이 충분히 셀 때만 움직인다
const grip = onGround ? clamp((s - 0.15) / 0.3, 0, 1) : 1;
const vt = tangential(p.r) * s * grip;
p.th += vt / Math.max(p.r, 4) * dt;
const lift = UPDRAFT * s * Math.exp(-p.r / LIFT_REACH) * clamp(1 - p.h / LOFT_TOP, 0, 1);
p.vh += (lift - SWIRL_SINK) * dt * 2;
p.vh -= p.vh * 1.4 * dt;
p.h += p.vh * dt;
if (p.h < 0) {
p.h = 0;
p.vh = 0;
}
// 흡입은 바깥에서, 높이 올라간 잎은 원심으로 밀려나고, 세기가 줄면 바깥으로 흩는다
const inflow = p.r > CORE_RADIUS * 0.7 ? -INFLOW * s * grip : 0;
const outward = (p.h > 120 ? 0.004 * vt * vt / Math.max(p.r, 8) : 0) + SCATTER_PUSH * (1 - s) * (p.h > 0 ? 1 : 0.2);
p.vr += (inflow + outward - p.vr * 1.6) * dt;
p.r = Math.max(6, p.r + p.vr * dt * grip);
p.spin += ((vt * 0.02) - p.spin * 1.2) * dt;
p.tilt += (p.spin + 1.2 * s) * dt;
if (onGround && s < 0.1) {
p.vr *= Math.exp(-6 * dt);
}
}
}
// 동작 줄이기 — 회오리가 지나가고 다시 흩어져 내려앉은 판
function finalScene() {
build();
const n = Math.ceil((SWIRL_START_MS + SWIRL_RISE_MS + SWIRL_HOLD_MS + SWIRL_FADE_MS) / FIXED_DT);
for (let i = 0; i < n; i += 1) {
step(STEP_S);
}
// 잦아든 뒤 모든 잎이 바닥에 닿을 때까지
for (let i = 0; i < 600; i += 1) {
if (swirlers.every(function (p) { return p.h <= 0; })) {
break;
}
step(STEP_S);
}
}
function onWake() { }
function view(p) {
const x = Math.cos(p.th) * p.r;
const z = Math.sin(p.th) * p.r;
const k = SWIRL_FOCAL / (SWIRL_FOCAL + z);
return { x: SWIRL_CENTER_X + x * k, y: SWIRL_GROUND + (z * FLOOR_TILT - p.h) * k, k: k, z: z, p: p };
}
function drawFunnel() {
if (strength < 0.02) {
return;
}
// 깔때기 고리 — 높이마다 반지름이 넓어지는 타원
for (let i = 0; i < 7; i += 1) {
const hgt = 20 + i * 32;
const rr = CORE_RADIUS + hgt * 0.42;
ctx.strokeStyle = rgba(CREAM, 0.6 * strength);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(SWIRL_CENTER_X, SWIRL_GROUND - hgt, rr, rr * FLOOR_TILT, 0, 0, TAU);
ctx.stroke();
}
}
function draw() {
clearSky();
drawGround(SWIRL_GROUND - 52);
// 바닥 원판의 소용돌이 자국
ctx.fillStyle = rgba(SOIL_DEEP, 0.35 * strength);
ctx.beginPath();
ctx.ellipse(SWIRL_CENTER_X, SWIRL_GROUND, 120, 120 * FLOOR_TILT, 0, 0, TAU);
ctx.fill();
const views = swirlers.map(view);
// 먼 것(z 큰 것)부터
views.sort(function (a, b) { return b.z - a.z; });
let half = Math.floor(views.length / 2);
for (let i = 0; i < views.length; i += 1) {
if (i === half) {
drawFunnel();
half = -1;
}
const v = views[i];
const p = v.p;
// 먼 잎은 살짝 옅게 — 깊이가 보이게
const fade = 1 - clamp(v.z / 500, 0, 0.3);
blitLeaf(spriteFor(p.kind, LEAF_COLORS[p.color], 0), v.x, v.y, p.size * v.k, p.tilt, p.h > 0 ? 0.3 + 0.6 * Math.abs(Math.sin(p.tilt * 1.3)) : 0.5, fade);
}
let lofted = 0;
let top = 0;
for (let i = 0; i < swirlers.length; i += 1) {
if (swirlers[i].h > 4) {
lofted += 1;
}
top = Math.max(top, swirlers[i].h);
}
drawCaption(SWIRL_LABEL.replace('{s}', fmt(strength * 100, 0)), 8, 8, false);
drawCaption(LOFT_LABEL.replace('{n}', fmt(lofted, 0)).replace('{h}', fmt(top, 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafWhirlSpiralLift - vanilla 판(08-leaf-whirl-vortex-spiral-lift) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafWhirlSpiralLift.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafWhirlSpiralLift() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 08 — 회오리. 가운데 CORE_RADIUS 안은 통째로 돌고(접선 속도 ∝ r) 바깥은 1/r 로 느려지는 소용돌이에
// 안쪽으로 끄는 흡입(INFLOW)과 가운데일수록 센 상승 기류(UPDRAFT)를 더한다. 세기가 줄면 잎이 바깥으로 흩어져 내려앉는다.
// 잎 자리는 원기둥 좌표(반지름·각·높이)로 두고, 원근 투영한 뒤 깊이 순으로 그린다
type Swirler = { r: number; th: number; h: number; vr: number; vh: number; tilt: number; spin: number; kind: number; color: number; size: number };
const SWIRL_COUNT = 120;
const CORE_RADIUS = 30;
// 가운데 통째 회전의 각속도(라디안/초) — 바깥 접선 속도는 SPIN_RATE · CORE_RADIUS² / r
const SPIN_RATE = 4.2;
const INFLOW = 70;
const UPDRAFT = 420;
// 상승 기류가 미치는 반지름 척도 — 세기 ∝ exp(−r / LIFT_REACH)
const LIFT_REACH = 70;
// 상승 기류가 0 이 되는 높이(px) — 이 높이에 가까울수록 위로 끄는 힘이 줄어 잎이 판 밖으로 나가지 않는다
const LOFT_TOP = 230;
const SWIRL_SINK = 120;
// 소용돌이가 잦아든 뒤 바깥으로 흩는 세기(px/s²)
const SCATTER_PUSH = 180;
const SWIRL_FOCAL = 420;
const SWIRL_CENTER_X = 240;
const SWIRL_GROUND = 266;
// 바닥 원판을 눕혀 보이게 하는 비율 — 깊이 z 가 화면 y 로 이만큼 내려온다
const FLOOR_TILT = 0.32;
const SWIRL_START_MS = 200;
// 세기 곡선 — 오름 · 머묾 · 잦아듦 (ms)
const SWIRL_RISE_MS = 500;
const SWIRL_HOLD_MS = 4200;
const SWIRL_FADE_MS = 1600;
const SWIRL_LOOP_MS = 9400;
const SWIRL_LABEL = '가운데 통째 회전 · 바깥 1/r · 세기 {s}%';
const LOFT_LABEL = '떠 있는 잎 {n}장 · 가장 높이 {h}px';
let swirlers: Swirler[] = [];
let t08 = 0;
let strength = 0;
function build(): void {
const rng = makeRng(4798);
swirlers = [];
for (let i = 0; i < SWIRL_COUNT; i += 1) {
swirlers.push({ r: 22 + rng() * 150, th: rng() * TAU, h: 0, vr: 0, vh: 0, tilt: rng() * TAU, spin: 0, kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, size: 16 + rng() * 12 });
}
t08 = 0;
strength = 0;
}
function loopLength(): number { return SWIRL_LOOP_MS; }
function swirlStrength(ms: number): number {
const a = ms - SWIRL_START_MS;
if (a <= 0) { return 0; }
if (a < SWIRL_RISE_MS) { return smooth(a / SWIRL_RISE_MS); }
if (a < SWIRL_RISE_MS + SWIRL_HOLD_MS) { return 1; }
return 1 - smooth((a - SWIRL_RISE_MS - SWIRL_HOLD_MS) / SWIRL_FADE_MS);
}
// 반지름 r 의 접선 속도(px/s) — 안은 강체 회전, 바깥은 1/r
function tangential(r: number): number { return r < CORE_RADIUS ? SPIN_RATE * r : SPIN_RATE * CORE_RADIUS * CORE_RADIUS / r; }
function step(dt: number): void {
t08 += dt * 1000;
strength = swirlStrength(t08);
const s = strength;
for (let i = 0; i < swirlers.length; i += 1) {
const p = swirlers[i];
const onGround = p.h <= 0 && p.vh <= 0;
// 바닥에 누운 잎은 바람이 충분히 셀 때만 움직인다
const grip = onGround ? clamp((s - 0.15) / 0.3, 0, 1) : 1;
const vt = tangential(p.r) * s * grip;
p.th += vt / Math.max(p.r, 4) * dt;
const lift = UPDRAFT * s * Math.exp(-p.r / LIFT_REACH) * clamp(1 - p.h / LOFT_TOP, 0, 1);
p.vh += (lift - SWIRL_SINK) * dt * 2;
p.vh -= p.vh * 1.4 * dt;
p.h += p.vh * dt;
if (p.h < 0) { p.h = 0; p.vh = 0; }
// 흡입은 바깥에서, 높이 올라간 잎은 원심으로 밀려나고, 세기가 줄면 바깥으로 흩는다
const inflow = p.r > CORE_RADIUS * 0.7 ? -INFLOW * s * grip : 0;
const outward = (p.h > 120 ? 0.004 * vt * vt / Math.max(p.r, 8) : 0) + SCATTER_PUSH * (1 - s) * (p.h > 0 ? 1 : 0.2);
p.vr += (inflow + outward - p.vr * 1.6) * dt;
p.r = Math.max(6, p.r + p.vr * dt * grip);
p.spin += ((vt * 0.02) - p.spin * 1.2) * dt;
p.tilt += (p.spin + 1.2 * s) * dt;
if (onGround && s < 0.1) { p.vr *= Math.exp(-6 * dt); }
}
}
// 동작 줄이기 — 회오리가 지나가고 다시 흩어져 내려앉은 판
function finalScene(): void {
build();
const n = Math.ceil((SWIRL_START_MS + SWIRL_RISE_MS + SWIRL_HOLD_MS + SWIRL_FADE_MS) / FIXED_DT);
for (let i = 0; i < n; i += 1) { step(STEP_S); }
// 잦아든 뒤 모든 잎이 바닥에 닿을 때까지
for (let i = 0; i < 600; i += 1) {
if (swirlers.every(function (p: Swirler): boolean { return p.h <= 0; })) { break; }
step(STEP_S);
}
}
function onWake(): void { }
type SwirlView = { x: number; y: number; k: number; z: number; p: Swirler };
function view(p: Swirler): SwirlView {
const x = Math.cos(p.th) * p.r;
const z = Math.sin(p.th) * p.r;
const k = SWIRL_FOCAL / (SWIRL_FOCAL + z);
return { x: SWIRL_CENTER_X + x * k, y: SWIRL_GROUND + (z * FLOOR_TILT - p.h) * k, k: k, z: z, p: p };
}
function drawFunnel(): void {
if (strength < 0.02) { return; }
// 깔때기 고리 — 높이마다 반지름이 넓어지는 타원
for (let i = 0; i < 7; i += 1) {
const hgt = 20 + i * 32;
const rr = CORE_RADIUS + hgt * 0.42;
ctx.strokeStyle = rgba(CREAM, 0.6 * strength);
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(SWIRL_CENTER_X, SWIRL_GROUND - hgt, rr, rr * FLOOR_TILT, 0, 0, TAU);
ctx.stroke();
}
}
function draw(): void {
clearSky();
drawGround(SWIRL_GROUND - 52);
// 바닥 원판의 소용돌이 자국
ctx.fillStyle = rgba(SOIL_DEEP, 0.35 * strength);
ctx.beginPath();
ctx.ellipse(SWIRL_CENTER_X, SWIRL_GROUND, 120, 120 * FLOOR_TILT, 0, 0, TAU);
ctx.fill();
const views = swirlers.map(view);
// 먼 것(z 큰 것)부터
views.sort(function (a: SwirlView, b: SwirlView): number { return b.z - a.z; });
let half = Math.floor(views.length / 2);
for (let i = 0; i < views.length; i += 1) {
if (i === half) { drawFunnel(); half = -1; }
const v = views[i];
const p = v.p;
// 먼 잎은 살짝 옅게 — 깊이가 보이게
const fade = 1 - clamp(v.z / 500, 0, 0.3);
blitLeaf(spriteFor(p.kind, LEAF_COLORS[p.color], 0), v.x, v.y, p.size * v.k, p.tilt, p.h > 0 ? 0.3 + 0.6 * Math.abs(Math.sin(p.tilt * 1.3)) : 0.5, fade);
}
let lofted = 0;
let top = 0;
for (let i = 0; i < swirlers.length; i += 1) { if (swirlers[i].h > 4) { lofted += 1; } top = Math.max(top, swirlers[i].h); }
drawCaption(SWIRL_LABEL.replace('{s}', fmt(strength * 100, 0)), 8, 8, false);
drawCaption(LOFT_LABEL.replace('{n}', fmt(lofted, 0)).replace('{h}', fmt(top, 0)), W - 8, 8, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="바닥의 낙엽이 회오리에 말려 나선을 그리며 솟아올랐다가 바람이 잦아들자 바깥으로 흩어져 내려앉는다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 회오리에 말려 나선으로 솟았다 흩어지는 잎 — 측정 누적면적 20.78% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function tangential(r) { return r < CORE_RADIUS ? SPIN_RATE * r : SPIN_RATE * CORE_RADIUS * CORE_RADIUS / r; }
function step(dt) {
t08 += dt * 1000;
strength = swirlStrength(t08);
const s = strength;
for (let i = 0; i < swirlers.length; i += 1) {
const p = swirlers[i];
const onGround = p.h <= 0 && p.vh <= 0;
// 바닥에 누운 잎은 바람이 충분히 셀 때만 움직인다
const grip = onGround ? clamp((s - 0.15) / 0.3, 0, 1) : 1;
const vt = tangential(p.r) * s * grip;
p.th += vt / Math.max(p.r, 4) * dt;
const lift = UPDRAFT * s * Math.exp(-p.r / LIFT_REACH) * clamp(1 - p.h / LOFT_TOP, 0, 1);
p.vh += (lift - SWIRL_SINK) * dt * 2;
p.vh -= p.vh * 1.4 * dt;
p.h += p.vh * dt;
if (p.h < 0) {
p.h = 0;
p.vh = 0;
}
09깊이마다 크기와 흐림이 다른 가을 나무 장면
앞·중간·먼 세 층에 잎을 나눠 크기 1·0.62·0.38배, 흐림 0·0.5·1.6, 빠르기 1·0.72·0.45배로 그리고, 나무에서 끊긴 잎이 바람을 타고 내려와 바닥 더미에 쌓이면 그 자리에 새 잎이 다시 달린다. 아래 growTree 는 시드 47990 으로 고정한 재귀 두 갈래 분기라 깊이 6·갈래 각 0.44rad·길이 비 0.74 로 가지 63개와 끝 32개를 만들고, 나무는 오프스크린에 한 번만 그려 750프레임 뒤에도 treeDraws 가 1 이다. 2초 안에 6장, 12초 동안 14장이 끊기고 더미는 84장이 된다.

<main class="stage">
<div class="lf is-demo" id="lf">
<canvas class="lf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="가을 나무에서 끊긴 잎이 바람을 타고 내려와 바닥 더미에 쌓이고, 앞과 먼 곳의 잎이 크기와 흐림을 달리하며 날린다."></canvas>
<button class="lf__pause" data-role="pause" type="button" aria-pressed="false">일시정지</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.lf {
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: $subject-cream;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
// 일시정지 단추 — 장면 오른쪽 위. aria-pressed 가 참이면 멈춘 상태라 눌린 모양으로
.lf__pause {
position: absolute;
right: $sp-2;
top: 30px;
padding: $sp-1 $sp-3;
border: 0;
border-radius: $r-pill;
background: $subject-cream;
color: $subject-ink;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed="true"] {
background: $subject-ink;
color: $subject-cream;
}
.lf__pause:focus-visible {
outline: 2px solid $subject-ink;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause { font-size: 11px; padding: $sp-1 $sp-2; top: 26px; }
}
// 동작 줄이기 — 장면은 스크립트가 잎이 내려앉은 한 장면으로 멈춘다. 단추도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.lf__pause { transition: none !important; }
.lf__pause:hover { transform: none !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);
}
.lf {
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: #fff7e6;
}
.lf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.lf__pause {
position: absolute;
right: 8px;
top: 30px;
padding: 4px 12px;
border: 0;
border-radius: 999px;
background: #fff7e6;
color: #17141a;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.lf__pause:hover {
transform: translateY(-1px);
}
.lf__pause[aria-pressed=true] {
background: #17141a;
color: #fff7e6;
}
.lf__pause:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
@media (max-width: 340px) {
.lf__pause {
font-size: 11px;
padding: 4px 8px;
top: 26px;
}
}
@media (prefers-reduced-motion: reduce) {
.lf__pause {
transition: none !important;
}
.lf__pause:hover {
transform: none !important;
}
}(function () {
const root = document.getElementById("lf");
if (!root) return;
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP = [212, 230, 243];
const SKY_LOW = [253, 245, 231];
const CREAM = [255, 250, 241];
const INK = [70, 54, 58];
const BARK = [118, 88, 76];
const BARK_DEEP = [82, 60, 54];
const SOIL = [228, 208, 180];
const SOIL_DEEP = [176, 146, 118];
const APRICOT = [240, 166, 114];
const PEACH = [246, 194, 156];
const BUTTER = [238, 206, 110];
const MAPLE = [222, 112, 98];
const SAGE = [146, 182, 132];
const LEAF_COLORS = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function smooth(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c, a) {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a, b, k) { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed) {
let s = seed >>> 0;
return function () {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed) {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) {
vals[i] = rng() * 2 - 1;
perm[i] = i;
}
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) {
perm[i + 256] = perm[i];
}
function corner(ix, iy) { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x, y) {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind) {
const pts = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
}
else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
}
else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind) {
const out = [];
if (kind === 1) {
const base = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g, kind, k) {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) {
g.lineTo(pts[i][0] * k, pts[i][1] * k);
}
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g, side, r) {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch];
}
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) {
sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch];
}
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind, color, blur) {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) {
return hit;
}
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) {
return sp;
}
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) {
blurSprite(g, side, Math.round(blur * SPRITE_RES));
}
return sp;
}
function backColor(c) { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]');
const pauseEl = root.querySelector('[data-role="pause"]');
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) {
return;
}
const cv = canvasEl;
const ctx = ctxRaw;
const pauseBtn = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp, x, y, size, angle, squash, alpha) {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas() {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky() {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y) {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text, x, y, alignRight) {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v, digits) { return v.toFixed(digits); }
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x, y, tilt) {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f, windX, windY, dt, liftGain) {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) {
px /= pl;
py /= pl;
}
else {
px = 0;
py = 0;
}
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
const STEM_K = 70;
const STEM_DAMP = 2.2;
const FATIGUE_RATE = 1;
// 바람이 꼭지를 돌리는 세기 — 회전력 = −sin(ang)·바람·STEM_WIND
const STEM_WIND = 0.05;
function makeStem(ax, ay, len, rest, limit) {
return { ax: ax, ay: ay, len: len, rest: rest, ang: rest, angVel: 0, fatigue: 0, limit: limit, snapped: false };
}
// 한 걸음 — 끊기는 걸음에 true. 끊긴 뒤에는 움직이지 않는다
function stemStep(s, windX, dt) {
if (s.snapped) {
return false;
}
const bend = s.ang - s.rest;
const torque = -Math.sin(s.ang) * windX * STEM_WIND;
s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
s.ang += s.angVel * dt;
s.fatigue += bend * bend * FATIGUE_RATE * dt;
if (s.fatigue >= s.limit) {
s.snapped = true;
return true;
}
return false;
}
// 꼭지 끝(잎이 붙은 자리)
function stemTip(s) { return [s.ax + Math.cos(s.ang) * s.len, s.ay + Math.sin(s.ang) * s.len]; }
// 반지름 r 자리의 속도 — 각속도 × r, 반지름에 수직. 끊기는 순간 이 속도를 그대로 자유 낙하로 넘긴다
function stemVelocityAt(s, r) { return [-Math.sin(s.ang) * s.angVel * r, Math.cos(s.ang) * s.angVel * r]; }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t, speed) {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x, center) {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x, y, t, center, out) {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
const LEAF_THICK = 2.6;
// 한계 기울기 — 이웃 칸과의 높이 차 ÷ 칸 폭 이 이 값을 넘으면 흘러내린다
const REPOSE_SLOPE = 0.9;
const SLIDE_MS = 260;
function heapMake(x0, count, cellW, ground) {
const cols = [];
for (let i = 0; i < count; i += 1) {
cols.push([]);
}
return { x0: x0, cellW: cellW, ground: ground, cols: cols, moves: 0 };
}
function heapCol(hp, x) { return clamp(Math.floor((x - hp.x0) / hp.cellW), 0, hp.cols.length - 1); }
function heapTop(hp, c) { return hp.ground - hp.cols[c].length * LEAF_THICK; }
function heapSlotX(hp, c, leaf) { return hp.x0 + (c + 0.5 + leaf.jitter * 0.6) * hp.cellW; }
function heapSlotY(hp, level) { return hp.ground - (level + 0.5) * LEAF_THICK; }
// 무너짐 — 칸마다 양옆을 보고, 높이 차 ÷ 칸 폭 이 slope 를 넘는 쪽(더 가파른 쪽)으로 맨 위 잎 하나를 옮긴다.
// 옮길 것이 없을 때까지 되풀이(한 번에 많아야 maxMoves). 훑는 방향은 판마다 바꿔 한쪽으로 쏠리지 않게
function heapRelax(hp, slope, maxMoves) {
const n = hp.cols.length;
let moved = 0;
let changed = true;
let pass = 0;
while (changed && moved < maxMoves) {
changed = false;
for (let i = 0; i < n && moved < maxMoves; i += 1) {
const c = pass % 2 === 0 ? i : n - 1 - i;
const h = hp.cols[c].length;
let best = -1;
let bestDrop = 0;
for (let k = 0; k < 2; k += 1) {
const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
if (nb < 0 || nb >= n) {
continue;
}
const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
if (drop > slope && drop > bestDrop) {
best = nb;
bestDrop = drop;
}
}
if (best < 0) {
continue;
}
const leaf = hp.cols[c].pop();
leaf.fromX = heapSlotX(hp, c, leaf);
leaf.fromY = heapSlotY(hp, h - 1);
leaf.slideAge = 0;
hp.cols[best].push(leaf);
moved += 1;
changed = true;
}
pass += 1;
}
hp.moves += moved;
return moved;
}
// 칸의 겉면 기울기(라디안) — 양옆 칸 높이 차로. 쌓인 잎은 이 각도로 눕힌다
function heapSlopeAngle(hp, c) {
const l = hp.cols[Math.max(0, c - 1)].length;
const r = hp.cols[Math.min(hp.cols.length - 1, c + 1)].length;
return -Math.atan2((r - l) * LEAF_THICK, hp.cellW * 2);
}
function heapCount(hp) {
let s = 0;
for (let i = 0; i < hp.cols.length; i += 1) {
s += hp.cols[i].length;
}
return s;
}
function heapAdvance(hp, dtMs) {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
for (let k = 0; k < col.length; k += 1) {
if (col[k].slideAge >= 0) {
col[k].slideAge += dtMs;
if (col[k].slideAge >= SLIDE_MS) {
col[k].slideAge = -1;
}
}
}
}
}
// 더미 그리기 — 아래 칸부터, 잎마다 칸 기울기로 눕힌 스프라이트. 흘러내리는 잎은 옛 자리에서 새 자리로 미끄러진다
function drawHeap(hp, size, blur, alpha) {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
const ang = heapSlopeAngle(hp, c);
for (let k = 0; k < col.length; k += 1) {
const lf = col[k];
let x = heapSlotX(hp, c, lf);
let y = heapSlotY(hp, k);
if (lf.slideAge >= 0) {
const s = smooth(lf.slideAge / SLIDE_MS);
x = lerp(lf.fromX, x, s);
y = lerp(lf.fromY, y, s);
}
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], blur), x, y, size, ang + lf.tilt, 0.55, alpha);
}
}
}
// 층 0 앞 · 1 중간 · 2 먼
const LAYER_SCALE = [1, 0.62, 0.38];
const LAYER_BLUR = [0, 0.5, 1.6];
const LAYER_SPEED = [1, 0.72, 0.45];
const NEAR_COUNT = 7;
const FAR_COUNT = 44;
const HANG_COUNT = 18;
// 나무 — 몇 번 갈라지는가 · 갈래 각(라디안) · 한 번 갈라질 때 길이 비
const BRANCH_DEPTH = 6;
const BRANCH_SPREAD = 0.44;
const BRANCH_SHRINK = 0.74;
const TREE_X = 168;
const TREE_GROUND = 262;
const TRUNK_LEN = 74;
const BASE_LEAF = 40;
const SCENE_START_MS = 200;
const SCENE_LOOP_MS = 12000;
const LAYER_LABEL = '앞 · 중간 · 먼 층 = 크기 ×{a} · ×{b} · ×{c}';
const FALL_LABEL = '끊긴 잎 {n}장 · 더미 {m}장';
let segs = [];
let tips = [];
let treeCv = null;
let treeDraws = 0;
let near = [];
let far = [];
let hang = [];
let pile9 = heapMake(0, 60, 8, TREE_GROUND);
let t09 = 0;
let fallen = 0;
let rng09 = makeRng(4799);
const wind9 = [0, 0];
// 재귀 분기 — 같은 시드면 같은 나무
function growTree() {
segs = [];
tips = [];
const rng = makeRng(47990);
function branch(x, y, ang, len, depth, w) {
const x1 = x + Math.cos(ang) * len;
const y1 = y + Math.sin(ang) * len;
segs.push({ x0: x, y0: y, x1: x1, y1: y1, w: w });
if (depth >= BRANCH_DEPTH) {
tips.push([x1, y1]);
return;
}
const j = (rng() - 0.5) * 0.18;
branch(x1, y1, ang - BRANCH_SPREAD + j, len * BRANCH_SHRINK, depth + 1, w * 0.68);
branch(x1, y1, ang + BRANCH_SPREAD + j, len * BRANCH_SHRINK * 0.94, depth + 1, w * 0.68);
}
branch(TREE_X, TREE_GROUND, -Math.PI / 2, TRUNK_LEN, 1, 12);
}
// 나무와 붙어 있는 잎 무리는 오프스크린에 한 번만 그린다 — 매 프레임은 이 판 하나를 찍는다
function paintTree() {
treeDraws += 1;
const cvs = document.createElement('canvas');
cvs.width = W * 2;
cvs.height = H * 2;
treeCv = cvs;
const g = cvs.getContext('2d');
if (!g) {
return;
}
g.scale(2, 2);
g.lineCap = 'round';
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
g.strokeStyle = rgba(i === 0 ? BARK_DEEP : BARK, 1);
g.lineWidth = s.w;
g.beginPath();
g.moveTo(s.x0, s.y0);
g.lineTo(s.x1, s.y1);
g.stroke();
}
const rng = makeRng(47991);
const size = BASE_LEAF * LAYER_SCALE[1];
for (let i = 0; i < tips.length; i += 1) {
for (let k = 0; k < 3; k += 1) {
const sp = spriteFor((i + k) % LEAF_KINDS, LEAF_COLORS[(i * 3 + k) % LEAF_COLORS.length], LAYER_BLUR[1]);
const kk = size / SPRITE_SIZE;
g.save();
g.translate(tips[i][0] + (rng() - 0.5) * 16, tips[i][1] + (rng() - 0.5) * 12);
g.rotate(rng() * TAU);
g.scale(kk, kk * 0.7);
g.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
g.restore();
}
}
}
function newDrift(layer, scatter) {
const sz = BASE_LEAF * LAYER_SCALE[layer] * (0.8 + rng09() * 0.4);
return { x: scatter ? rng09() * W : -30, y: rng09() * (layer === 0 ? H : TREE_GROUND - 40), vx: 0, vy: 0, tilt: rng09() * TAU, flip: rng09() * TAU, kind: Math.floor(rng09() * LEAF_KINDS), color: Math.floor(rng09() * LEAF_COLORS.length), size: sz };
}
function newHang(spot) {
const tip = tips[spot];
return { stem: makeStem(tip[0], tip[1], 6, Math.PI / 2 + (rng09() - 0.5) * 0.4, 0.004 + rng09() * 0.05), fly: null, kind: Math.floor(rng09() * LEAF_KINDS), color: Math.floor(rng09() * LEAF_COLORS.length), spot: spot };
}
function build() {
rng09 = makeRng(4799);
if (!treeCv) {
growTree();
paintTree();
}
near = [];
far = [];
hang = [];
for (let i = 0; i < NEAR_COUNT; i += 1) {
near.push(newDrift(0, true));
}
for (let i = 0; i < FAR_COUNT; i += 1) {
far.push(newDrift(2, true));
}
for (let i = 0; i < HANG_COUNT; i += 1) {
hang.push(newHang(Math.floor(i * tips.length / HANG_COUNT + 1) % tips.length));
}
pile9 = heapMake(0, 60, 8, TREE_GROUND);
for (let i = 0; i < 70; i += 1) {
pile9.cols[heapCol(pile9, TREE_X + (rng09() - 0.5) * 150)].push({ kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, jitter: rng09() - 0.5, tilt: (rng09() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
heapRelax(pile9, REPOSE_SLOPE, 40);
}
for (let c = 0; c < pile9.cols.length; c += 1) {
for (let k = 0; k < pile9.cols[c].length; k += 1) {
pile9.cols[c][k].slideAge = -1;
}
}
t09 = 0;
fallen = 0;
}
function loopLength() { return SCENE_LOOP_MS; }
function driftStep(d, layer, ts, center, dt) {
const sp = LAYER_SPEED[layer];
windAt(d.x, d.y, ts, center, wind9);
d.vx += (wind9[0] * sp - d.vx) * 2.2 * dt;
d.vy += ((wind9[1] + 46) * sp - d.vy) * 2.2 * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.tilt += (0.8 + 0.01 * Math.abs(wind9[0])) * sp * dt;
d.flip += 2 * sp * dt;
if (d.x > W + 40) {
d.x = -40;
}
if (d.y > H + 40) {
d.y = -40;
}
if (d.y < -40) {
d.y = H + 40;
}
}
function step(dt) {
t09 += dt * 1000;
heapAdvance(pile9, dt * 1000);
if (t09 < SCENE_START_MS) {
return;
}
const ts = (t09 - SCENE_START_MS) / 1000;
const center = gustCenter(ts, GUST_SPEED);
for (let i = 0; i < near.length; i += 1) {
driftStep(near[i], 0, ts, center, dt);
}
for (let i = 0; i < far.length; i += 1) {
driftStep(far[i], 2, ts, center, dt);
}
for (let i = 0; i < hang.length; i += 1) {
const h = hang[i];
if (!h.fly) {
const tip = stemTip(h.stem);
windAt(tip[0], tip[1], ts, center, wind9);
if (stemStep(h.stem, wind9[0] * LAYER_SPEED[1], dt)) {
const r = 6 + BASE_LEAF * LAYER_SCALE[1] / 2;
const v = stemVelocityAt(h.stem, r);
const f = makeFlyer(h.stem.ax + Math.cos(h.stem.ang) * r, h.stem.ay + Math.sin(h.stem.ang) * r, h.stem.ang);
f.vx = v[0];
f.vy = v[1];
h.fly = f;
fallen += 1;
}
continue;
}
windAt(h.fly.x, h.fly.y, ts, center, wind9);
flutterStep(h.fly, wind9[0] * LAYER_SPEED[1] * 0.6, 0, dt, LEAF_LIFT_GAIN);
const c = heapCol(pile9, h.fly.x);
if (h.fly.y >= heapTop(pile9, c) - 1 || h.fly.x > W + 30) {
if (h.fly.x <= W + 30) {
pile9.cols[c].push({ kind: h.kind, color: h.color, jitter: rng09() - 0.5, tilt: (rng09() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
heapRelax(pile9, REPOSE_SLOPE, 40);
}
// 내려앉은 잎 자리에는 새 잎이 다시 달린다 — 잎 수는 HANG_COUNT 그대로
hang[i] = newHang(h.spot);
}
}
}
// 동작 줄이기 — 바람이 잔 판. 6초를 그리지 않고 돌려 더미를 쌓은 뒤, 날던 잎은 모두 땅에 눕힌다
function finalScene() {
build();
const n = Math.ceil(6000 / FIXED_DT);
for (let i = 0; i < n; i += 1) {
step(STEP_S);
}
for (let i = 0; i < hang.length; i += 1) {
if (hang[i].fly) {
hang[i] = newHang(hang[i].spot);
}
}
for (let i = 0; i < near.length; i += 1) {
near[i].x = 30 + i * (W - 60) / Math.max(1, near.length - 1);
near[i].y = H - 12 - (i % 2) * 6;
near[i].tilt = (i % 2 === 0 ? 0.2 : -0.25);
near[i].flip = 1.1;
}
for (let i = 0; i < far.length; i += 1) {
far[i].y = 214 - 26 * Math.sin(far[i].x * 0.011 + 0.6) - 12 * Math.sin(far[i].x * 0.031);
far[i].tilt = 0;
far[i].flip = 1.2;
}
}
function onWake() { }
function drawDrift(d, layer) {
blitLeaf(spriteFor(d.kind, LEAF_COLORS[d.color], LAYER_BLUR[layer]), d.x, d.y, d.size, d.tilt, 0.3 + 0.7 * Math.abs(Math.cos(d.flip)), layer === 2 ? 0.8 : 1);
}
function drawHills() {
ctx.fillStyle = rgba(mixRGB(SKY_TOP, SAGE, 0.35), 1);
ctx.beginPath();
ctx.moveTo(0, TREE_GROUND);
for (let x = 0; x <= W; x += 12) {
ctx.lineTo(x, 200 - 26 * Math.sin(x * 0.011 + 0.6) - 12 * Math.sin(x * 0.031));
}
ctx.lineTo(W, TREE_GROUND);
ctx.closePath();
ctx.fill();
}
function draw() {
clearSky();
drawHills();
for (let i = 0; i < far.length; i += 1) {
drawDrift(far[i], 2);
}
drawGround(TREE_GROUND);
if (treeCv) {
ctx.drawImage(treeCv, 0, 0, W, H);
}
const msize = BASE_LEAF * LAYER_SCALE[1];
for (let i = 0; i < hang.length; i += 1) {
const h = hang[i];
const sp = spriteFor(h.kind, LEAF_COLORS[h.color], LAYER_BLUR[1]);
if (!h.fly) {
const tip = stemTip(h.stem);
blitLeaf(sp, tip[0] + Math.cos(h.stem.ang) * msize / 2, tip[1] + Math.sin(h.stem.ang) * msize / 2, msize, h.stem.ang, 0.7, 1);
}
else {
blitLeaf(sp, h.fly.x, h.fly.y, msize, h.fly.tilt, 0.62, 1);
}
}
drawHeap(pile9, msize * 0.8, LAYER_BLUR[1], 1);
for (let i = 0; i < near.length; i += 1) {
drawDrift(near[i], 0);
}
drawCaption(LAYER_LABEL.replace('{a}', fmt(LAYER_SCALE[0], 2)).replace('{b}', fmt(LAYER_SCALE[1], 2)).replace('{c}', fmt(LAYER_SCALE[2], 2)), 8, 8, false);
drawCaption(FALL_LABEL.replace('{n}', fmt(fallen, 0)).replace('{m}', fmt(heapCount(pile9), 0)), W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause() { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev) {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render() { draw(); }
cv.addEventListener('pointermove', function (ev) {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) {
render();
}
}, { signal: off.signal });
cv.addEventListener('pointerleave', function () {
hoverX = -1;
hoverY = -1;
if (paused && ready) {
render();
}
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill() {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play() {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function () {
wake();
paused = !paused;
syncPause();
if (paused) {
stopLoop();
render();
return;
}
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) {
play();
}
else {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref() {
still = reduceQuery.matches;
if (!ready) {
return;
}
paused = still;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
return;
}
if (!paused && ready) {
startLoop();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
if (ready) {
render();
}
}, { signal: off.signal });
function begin() {
fitCanvas();
build();
ready = true;
syncPause();
if (still) {
showStill();
}
else {
play();
}
}
begin();
return function () {
alive = false;
stopLoop();
off.abort();
};
})();// LeafAutumnTreeDepth - vanilla 판(09-leaf-hero-autumn-tree-depth-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./LeafAutumnTreeDepth.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function LeafAutumnTreeDepth() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type RGB = [number, number, number];
type Pt = [number, number];
// 잎 스프라이트 한 장 — 오프스크린 캔버스와, 판 좌표로 잰 반 변(기준 크기 SPRITE_SIZE 일 때)
type Sprite = { cv: HTMLCanvasElement; half: number };
const W = 480;
const H = 300;
// 시계 — rAF 타임스탬프를 쌓아 16ms 고정 스텝으로만 진행한다
const FIXED_DT = 16;
const STEP_S = FIXED_DT / 1000;
// 한 프레임에 따라잡는 시간 상한(ms) — 숨었던 탭이 돌아와 첫 간격이 수 초여도 이만큼만 쌓는다
const DT_MAX = 100;
const MAX_STEPS = 6;
const TAU = Math.PI * 2;
// 파스텔 팔레트 — 옅은 하늘·크림 바탕 위에 잎을 얹고, 줄기·흙·그늘은 진한 쪽으로 명도 차를 둔다
const SKY_TOP: RGB = [212, 230, 243];
const SKY_LOW: RGB = [253, 245, 231];
const CREAM: RGB = [255, 250, 241];
const INK: RGB = [70, 54, 58];
const BARK: RGB = [118, 88, 76];
const BARK_DEEP: RGB = [82, 60, 54];
const SOIL: RGB = [228, 208, 180];
const SOIL_DEEP: RGB = [176, 146, 118];
const APRICOT: RGB = [240, 166, 114];
const PEACH: RGB = [246, 194, 156];
const BUTTER: RGB = [238, 206, 110];
const MAPLE: RGB = [222, 112, 98];
const SAGE: RGB = [146, 182, 132];
const LEAF_COLORS: RGB[] = [APRICOT, BUTTER, MAPLE, PEACH, SAGE];
// 잎 모양 셋 — 0 둥근 잎 · 1 다섯 갈래 잎 · 2 길쭉한 잎. 윤곽은 꼭지 쪽이 x = −1, 끝이 x = +1 인 단위 좌표
const LEAF_KINDS = 3;
const OUTLINE_POINTS = 72;
// 스프라이트는 기준 크기로 한 번만 그리고, 찍을 때는 크기 배율만 곱한다
const SPRITE_SIZE = 48;
const SPRITE_RES = 2;
const CAPTION_FONT = '600 11px system-ui, sans-serif';
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function smooth(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
function rgba(c: RGB, a: number): string {
return 'rgba(' + Math.round(clamp(c[0], 0, 255)) + ',' + Math.round(clamp(c[1], 0, 255)) + ',' + Math.round(clamp(c[2], 0, 255)) + ',' + clamp(a, 0, 1).toFixed(3) + ')';
}
function mixRGB(a: RGB, b: RGB, k: number): RGB { return [lerp(a[0], b[0], k), lerp(a[1], b[1], k), lerp(a[2], b[2], k)]; }
// 시드 고정 난수 — 같은 시드면 같은 잎 배치·같은 바람이 다시 나온다 (32비트 정수 섞기)
function makeRng(seed: number): () => number {
let s = seed >>> 0;
return function (): number {
s = (s + 0x6d2b79f5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// 부드러운 2D 노이즈 — 정수 격자점마다 시드로 정한 값(−1~1)을 두고 칸 안은 3차 곡선으로 섞는다
function makeNoise(seed: number): (x: number, y: number) => number {
const rng = makeRng(seed);
const perm = new Uint8Array(512);
const vals = new Float32Array(256);
for (let i = 0; i < 256; i += 1) { vals[i] = rng() * 2 - 1; perm[i] = i; }
for (let i = 255; i > 0; i -= 1) {
const j = Math.floor(rng() * (i + 1));
const tmp = perm[i];
perm[i] = perm[j];
perm[j] = tmp;
}
for (let i = 0; i < 256; i += 1) { perm[i + 256] = perm[i]; }
function corner(ix: number, iy: number): number { return vals[perm[(ix & 255) + perm[iy & 255]]]; }
return function (x: number, y: number): number {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = x - ix;
const fy = y - iy;
const u = fx * fx * (3 - 2 * fx);
const v = fy * fy * (3 - 2 * fy);
const top = lerp(corner(ix, iy), corner(ix + 1, iy), u);
const low = lerp(corner(ix, iy + 1), corner(ix + 1, iy + 1), u);
return lerp(top, low, v);
};
}
// 잎 윤곽 — 사진 없이 곡선으로. 둘레를 OUTLINE_POINTS 점으로 나눈다
function leafOutline(kind: number): Pt[] {
const pts: Pt[] = [];
for (let i = 0; i < OUTLINE_POINTS; i += 1) {
const a = i / OUTLINE_POINTS * TAU;
const c = Math.cos(a);
const s = Math.sin(a);
if (kind === 1) {
// 다섯 갈래 — 반지름이 둘레를 따라 다섯 번 부풀었다 들어간다
const r = 0.6 + 0.4 * Math.pow(Math.abs(Math.cos(2.5 * a)), 0.8);
pts.push([r * c, r * s * 0.95]);
} else if (kind === 2) {
pts.push([c, 0.3 * s * (1 - 0.25 * c)]);
} else {
// 둥근 잎 — 꼭지 쪽이 넓고 끝으로 갈수록 좁아지는 물방울
pts.push([c, 0.52 * s * (1 - 0.3 * c)]);
}
}
return pts;
}
const OUTLINES: Pt[][] = [leafOutline(0), leafOutline(1), leafOutline(2)];
// 잎맥 선분 — 가운데 맥 하나와 곁맥. 갈래 잎은 갈래 끝마다 하나
function leafVeins(kind: number): [Pt, Pt][] {
const out: [Pt, Pt][] = [];
if (kind === 1) {
const base: Pt = [-0.4, 0];
for (let k = -2; k <= 2; k += 1) {
const a = k * 0.4 * Math.PI;
out.push([base, [Math.cos(a) * 0.86, Math.sin(a) * 0.82]]);
}
return out;
}
const halfW = kind === 2 ? 0.3 : 0.52;
const taper = kind === 2 ? 0.25 : 0.3;
out.push([[-1, 0], [0.92, 0]]);
const roots = [-0.55, -0.15, 0.25, 0.6];
for (let i = 0; i < roots.length; i += 1) {
const x0 = roots[i];
const x1 = Math.min(0.95, x0 + 0.32);
const y1 = halfW * Math.sqrt(Math.max(0, 1 - x1 * x1)) * (1 - taper * x1) * 0.8;
out.push([[x0, 0], [x1, y1]]);
out.push([[x0, 0], [x1, -y1]]);
}
return out;
}
const VEINS: [Pt, Pt][][] = [leafVeins(0), leafVeins(1), leafVeins(2)];
function leafPathOn(g: CanvasRenderingContext2D, kind: number, k: number): void {
const pts = OUTLINES[kind];
g.beginPath();
g.moveTo(pts[0][0] * k, pts[0][1] * k);
for (let i = 1; i < pts.length; i += 1) { g.lineTo(pts[i][0] * k, pts[i][1] * k); }
g.closePath();
}
// 스프라이트 흐림 — 가로·세로 상자 평균을 두 번. 투명 가장자리가 검게 번지지 않게 알파를 곱한 값으로 섞는다
function blurSprite(g: CanvasRenderingContext2D, side: number, r: number): void {
const img = g.getImageData(0, 0, side, side);
const d = img.data;
const n = side * side;
const buf = new Float32Array(n * 4);
const tmp = new Float32Array(n * 4);
for (let i = 0; i < n; i += 1) {
const a = d[i * 4 + 3] / 255;
buf[i * 4] = d[i * 4] * a;
buf[i * 4 + 1] = d[i * 4 + 1] * a;
buf[i * 4 + 2] = d[i * 4 + 2] * a;
buf[i * 4 + 3] = d[i * 4 + 3];
}
const span = r * 2 + 1;
for (let pass = 0; pass < 2; pass += 1) {
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += buf[(y * side + clamp(x + k, 0, side - 1)) * 4 + ch]; }
tmp[(y * side + x) * 4 + ch] = sum / span;
}
}
}
for (let y = 0; y < side; y += 1) {
for (let x = 0; x < side; x += 1) {
for (let ch = 0; ch < 4; ch += 1) {
let sum = 0;
for (let k = -r; k <= r; k += 1) { sum += tmp[(clamp(y + k, 0, side - 1) * side + x) * 4 + ch]; }
buf[(y * side + x) * 4 + ch] = sum / span;
}
}
}
}
for (let i = 0; i < n; i += 1) {
const a = buf[i * 4 + 3];
const inv = a > 0.5 ? 255 / a : 0;
d[i * 4] = buf[i * 4] * inv;
d[i * 4 + 1] = buf[i * 4 + 1] * inv;
d[i * 4 + 2] = buf[i * 4 + 2] * inv;
d[i * 4 + 3] = a;
}
g.putImageData(img, 0, 0);
}
const spriteCache = new Map<string, Sprite>();
// 함정 1 실물 계수 — 잎 경로를 실제로 그린 횟수(spriteBuilds)와 스프라이트를 찍은 횟수(leafBlits)
let spriteBuilds = 0;
let leafBlits = 0;
// 같은 모양·같은 색·같은 흐림이면 한 번만 그린다. 뒷면은 크림을 섞은 옅은 색으로 따로 한 장
function spriteFor(kind: number, color: RGB, blur: number): Sprite {
const key = kind + '|' + color.join(',') + '|' + blur;
const hit = spriteCache.get(key);
if (hit) { return hit; }
spriteBuilds += 1;
const pad = 3 + Math.ceil(blur * 2);
const side = Math.ceil((SPRITE_SIZE + pad * 2) * SPRITE_RES);
const cvs = document.createElement('canvas');
cvs.width = side;
cvs.height = side;
const sp: Sprite = { cv: cvs, half: side / SPRITE_RES / 2 };
spriteCache.set(key, sp);
const g = cvs.getContext('2d');
if (!g) { return sp; }
const k = SPRITE_SIZE * SPRITE_RES / 2;
g.translate(side / 2, side / 2);
// 꼭지
g.beginPath();
g.moveTo(-k * 0.95, 0);
g.lineTo(-k * 1.18, k * 0.05);
g.lineWidth = 1.6 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.5), 1);
g.stroke();
leafPathOn(g, kind, k);
g.fillStyle = rgba(color, 1);
g.fill();
g.lineWidth = 1.1 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.38), 0.95);
g.stroke();
const veins = VEINS[kind];
g.beginPath();
for (let i = 0; i < veins.length; i += 1) {
g.moveTo(veins[i][0][0] * k, veins[i][0][1] * k);
g.lineTo(veins[i][1][0] * k, veins[i][1][1] * k);
}
g.lineWidth = 0.8 * SPRITE_RES;
g.strokeStyle = rgba(mixRGB(color, INK, 0.3), 0.75);
g.stroke();
g.setTransform(1, 0, 0, 1, 0, 0);
if (blur > 0) { blurSprite(g, side, Math.round(blur * SPRITE_RES)); }
return sp;
}
function backColor(c: RGB): RGB { return mixRGB(c, CREAM, 0.5); }
const canvasEl = root.querySelector('[data-role="canvas"]') as HTMLCanvasElement | null;
const pauseEl = root.querySelector('[data-role="pause"]') as HTMLButtonElement | null;
const ctxRaw = canvasEl ? canvasEl.getContext('2d') : null;
if (!canvasEl || !ctxRaw || !pauseEl) { return; }
const cv: HTMLCanvasElement = canvasEl;
const ctx: CanvasRenderingContext2D = ctxRaw;
const pauseBtn: HTMLButtonElement = pauseEl;
// 스프라이트 찍기 — 자리(x, y)·크기·회전, squash 는 잎 면이 돌아 보이는 세로 납작함(0~1)
function blitLeaf(sp: Sprite, x: number, y: number, size: number, angle: number, squash: number, alpha: number): void {
leafBlits += 1;
const k = size / SPRITE_SIZE;
ctx.save();
ctx.globalAlpha = clamp(alpha, 0, 1);
ctx.translate(x, y);
ctx.rotate(angle);
ctx.scale(k, k * Math.max(0.06, squash));
ctx.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
ctx.restore();
}
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 뗀다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
// 포인터 자리(판 좌표) — 판 밖이면 −1
let hoverX = -1;
let hoverY = -1;
function fitCanvas(): void {
// 배율은 2 에서 자른다 — 배율 3 기기에서 화소가 2.25배로 늘어 프레임마다 일이 그만큼 커지지 않게
const dpr = Math.min(window.devicePixelRatio || 1, 2);
cv.width = Math.round(W * dpr);
cv.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function clearSky(): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(SKY_TOP, 1));
g.addColorStop(1, rgba(SKY_LOW, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
function drawGround(y: number): void {
const g = ctx.createLinearGradient(0, y, 0, H);
g.addColorStop(0, rgba(SOIL, 1));
g.addColorStop(1, rgba(SOIL_DEEP, 1));
ctx.fillStyle = g;
ctx.fillRect(0, y, W, H - y);
ctx.fillStyle = rgba(SOIL_DEEP, 0.9);
ctx.fillRect(0, y, W, 1.5);
}
// 판 위 설명 글 — 크림 알약 바탕에 진한 글자
function drawCaption(text: string, x: number, y: number, alignRight: boolean): void {
ctx.font = CAPTION_FONT;
const bw = ctx.measureText(text).width + 14;
const bh = 20;
const bx = alignRight ? x - bw : x;
ctx.fillStyle = rgba(CREAM, 0.9);
ctx.beginPath();
ctx.moveTo(bx + 8, y);
ctx.arcTo(bx + bw, y, bx + bw, y + bh, 8);
ctx.arcTo(bx + bw, y + bh, bx, y + bh, 8);
ctx.arcTo(bx, y + bh, bx, y, 8);
ctx.arcTo(bx, y, bx + bw, y, 8);
ctx.closePath();
ctx.fill();
ctx.fillStyle = rgba(INK, 1);
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(text, bx + 7, y + bh / 2 + 0.5);
}
function fmt(v: number, digits: number): string { return v.toFixed(digits); }
// 떨어지는 잎 한 장(옆에서 본 판) — 자리·속도(px/s), 기운 각 tilt(라디안, 0 이면 수평으로 누움)·각속도 spin.
// lift·drag·aoa 는 마지막 걸음의 양력·저항(px/s²)과 받음각(라디안) — 화살표·설명 글이 읽는다
type Flyer = { x: number; y: number; vx: number; vy: number; tilt: number; spin: number; lift: number; drag: number; aoa: number };
const GRAVITY = 380;
// 공기 힘 세기 — 저항 = AIR_K·u²·(DRAG_BASE + sin²α), 양력 = LEAF_LIFT_GAIN·AIR_K·u²·|sin α·cos α|
const AIR_K = 0.075;
const DRAG_BASE = 0.1;
const LEAF_LIFT_GAIN = 1.6;
// 회전력 — 받음각 쪽 압력 중심 어긋남(TWIST_GAIN)과 기울기를 되돌리는 힘(RIGHTING_K), 각속도 감쇠(SPIN_DAMP)
const TWIST_GAIN = 0.002;
const RIGHTING_K = 14;
const SPIN_DAMP = 1.5;
function makeFlyer(x: number, y: number, tilt: number): Flyer {
return { x: x, y: y, vx: 0, vy: 0, tilt: tilt, spin: 0, lift: 0, drag: 0, aoa: 0 };
}
// 한 걸음 — 잎이 느끼는 공기 속도(잎 속도 − 바람)를 잎 면 방향(cos α)과 법선 방향(sin α)으로 나눠
// 저항은 공기 속도 반대로, 양력은 그에 수직으로(법선 압력이 미는 쪽) 더하고, 회전력으로 기울기를 바꾼다
function flutterStep(f: Flyer, windX: number, windY: number, dt: number, liftGain: number): void {
const rx = f.vx - windX;
const ry = f.vy - windY;
const u2 = rx * rx + ry * ry;
const u = Math.sqrt(u2);
let ax = 0;
let ay = GRAVITY;
let torque = -RIGHTING_K * Math.sin(f.tilt);
f.lift = 0;
f.drag = 0;
if (u > 1e-6) {
const tx = Math.cos(f.tilt);
const ty = Math.sin(f.tilt);
const nx = -ty;
const ny = tx;
const cosA = (rx * tx + ry * ty) / u;
const sinA = (rx * nx + ry * ny) / u;
f.aoa = Math.atan2(Math.abs(sinA), Math.abs(cosA));
const drag = AIR_K * u2 * (DRAG_BASE + sinA * sinA);
const lift = liftGain * AIR_K * u2 * Math.abs(sinA * cosA);
// 양력 방향 — 법선 압력(−sgn(sin α)·n)에서 공기 속도 방향 성분을 빼 속도에 수직으로
const side = sinA >= 0 ? -1 : 1;
let px = side * nx;
let py = side * ny;
const along = (px * rx + py * ry) / u;
px -= along * rx / u;
py -= along * ry / u;
const pl = Math.hypot(px, py);
if (pl > 1e-6) { px /= pl; py /= pl; } else { px = 0; py = 0; }
ax += -drag * rx / u + lift * px;
ay += -drag * ry / u + lift * py;
torque += -TWIST_GAIN * u2 * sinA * cosA;
f.lift = lift;
f.drag = drag;
}
f.spin += (torque - SPIN_DAMP * f.spin) * dt;
f.tilt += f.spin * dt;
f.vx += ax * dt;
f.vy += ay * dt;
f.x += f.vx * dt;
f.y += f.vy * dt;
}
// 가지에 매달린 잎 — 꼭지 자리(ax, ay)에서 길이 len 만큼 뻗은 각 스프링. ang 은 꼭지→잎 방향(라디안), rest 는 쉬는 각.
// fatigue 는 휜 각도²을 시간으로 쌓은 값, limit 을 넘으면 snapped
type Stem = { ax: number; ay: number; len: number; rest: number; ang: number; angVel: number; fatigue: number; limit: number; snapped: boolean };
const STEM_K = 70;
const STEM_DAMP = 2.2;
const FATIGUE_RATE = 1;
// 바람이 꼭지를 돌리는 세기 — 회전력 = −sin(ang)·바람·STEM_WIND
const STEM_WIND = 0.05;
function makeStem(ax: number, ay: number, len: number, rest: number, limit: number): Stem {
return { ax: ax, ay: ay, len: len, rest: rest, ang: rest, angVel: 0, fatigue: 0, limit: limit, snapped: false };
}
// 한 걸음 — 끊기는 걸음에 true. 끊긴 뒤에는 움직이지 않는다
function stemStep(s: Stem, windX: number, dt: number): boolean {
if (s.snapped) { return false; }
const bend = s.ang - s.rest;
const torque = -Math.sin(s.ang) * windX * STEM_WIND;
s.angVel += (-STEM_K * bend - STEM_DAMP * s.angVel + torque) * dt;
s.ang += s.angVel * dt;
s.fatigue += bend * bend * FATIGUE_RATE * dt;
if (s.fatigue >= s.limit) { s.snapped = true; return true; }
return false;
}
// 꼭지 끝(잎이 붙은 자리)
function stemTip(s: Stem): Pt { return [s.ax + Math.cos(s.ang) * s.len, s.ay + Math.sin(s.ang) * s.len]; }
// 반지름 r 자리의 속도 — 각속도 × r, 반지름에 수직. 끊기는 순간 이 속도를 그대로 자유 낙하로 넘긴다
function stemVelocityAt(s: Stem, r: number): Pt { return [-Math.sin(s.ang) * s.angVel * r, Math.cos(s.ang) * s.angVel * r]; }
// 바람장 — 부드러운 노이즈로 자리마다 방향·세기를 정하고, 화면을 가로지르는 돌풍 띠가 그 위에 더해진다
const WIND_SCALE = 0.007;
const WIND_DRIFT = 0.4;
const WIND_BASE = 34;
const WIND_SWIRL = 46;
// 돌풍 띠 — 가운데가 GUST_SPEED px/s 로 오른쪽으로 가고, 세기는 가운데에서 멀수록 종 모양 폭(GUST_WIDTH)으로 줄어든다
const GUST_SPEED = 220;
const GUST_WIDTH = 64;
const GUST_GAIN = 300;
const GUST_GAP = 420;
const windNoise = makeNoise(4790);
function gustCenter(t: number, speed: number): number {
const span = W + GUST_WIDTH * 6 + GUST_GAP;
return -GUST_WIDTH * 1.5 + ((t * speed) % span);
}
// 종 모양 폭 — 띠 가운데에서 1, GUST_WIDTH 만큼 떨어지면 1/e
function gustWeight(x: number, center: number): number {
const k = (x - center) / GUST_WIDTH;
return Math.exp(-k * k);
}
// 자리 (x, y)·시각 t(초)의 바람(px/s) — out[0] 가로, out[1] 세로(아래가 +)
function windAt(x: number, y: number, t: number, center: number, out: Pt): void {
const n1 = windNoise(x * WIND_SCALE - t * WIND_DRIFT, y * WIND_SCALE);
const n2 = windNoise(x * WIND_SCALE + 31.7, y * WIND_SCALE - t * WIND_DRIFT * 0.7);
const g = gustWeight(x, center);
out[0] = WIND_BASE + WIND_SWIRL * n1 + GUST_GAIN * g;
// 돌풍 안은 살짝 위로 들어 올린다
out[1] = WIND_SWIRL * 0.6 * n2 - GUST_GAIN * 0.18 * g;
}
// 바닥 더미 — 바닥을 폭 cellW 칸으로 나누고 칸마다 쌓인 잎 목록을 둔다. 칸 높이 = 잎 수 × LEAF_THICK
// slideAge 가 0 이상이면 이웃 칸에서 흘러내려 오는 중(fromX, fromY 에서 제자리로)
type PileLeaf = { kind: number; color: number; jitter: number; tilt: number; slideAge: number; fromX: number; fromY: number };
type Heap = { x0: number; cellW: number; ground: number; cols: PileLeaf[][]; moves: number };
const LEAF_THICK = 2.6;
// 한계 기울기 — 이웃 칸과의 높이 차 ÷ 칸 폭 이 이 값을 넘으면 흘러내린다
const REPOSE_SLOPE = 0.9;
const SLIDE_MS = 260;
function heapMake(x0: number, count: number, cellW: number, ground: number): Heap {
const cols: PileLeaf[][] = [];
for (let i = 0; i < count; i += 1) { cols.push([]); }
return { x0: x0, cellW: cellW, ground: ground, cols: cols, moves: 0 };
}
function heapCol(hp: Heap, x: number): number { return clamp(Math.floor((x - hp.x0) / hp.cellW), 0, hp.cols.length - 1); }
function heapTop(hp: Heap, c: number): number { return hp.ground - hp.cols[c].length * LEAF_THICK; }
function heapSlotX(hp: Heap, c: number, leaf: PileLeaf): number { return hp.x0 + (c + 0.5 + leaf.jitter * 0.6) * hp.cellW; }
function heapSlotY(hp: Heap, level: number): number { return hp.ground - (level + 0.5) * LEAF_THICK; }
// 무너짐 — 칸마다 양옆을 보고, 높이 차 ÷ 칸 폭 이 slope 를 넘는 쪽(더 가파른 쪽)으로 맨 위 잎 하나를 옮긴다.
// 옮길 것이 없을 때까지 되풀이(한 번에 많아야 maxMoves). 훑는 방향은 판마다 바꿔 한쪽으로 쏠리지 않게
function heapRelax(hp: Heap, slope: number, maxMoves: number): number {
const n = hp.cols.length;
let moved = 0;
let changed = true;
let pass = 0;
while (changed && moved < maxMoves) {
changed = false;
for (let i = 0; i < n && moved < maxMoves; i += 1) {
const c = pass % 2 === 0 ? i : n - 1 - i;
const h = hp.cols[c].length;
let best = -1;
let bestDrop = 0;
for (let k = 0; k < 2; k += 1) {
const nb = c + ((k + pass) % 2 === 0 ? -1 : 1);
if (nb < 0 || nb >= n) { continue; }
const drop = (h - hp.cols[nb].length) * LEAF_THICK / hp.cellW;
if (drop > slope && drop > bestDrop) { best = nb; bestDrop = drop; }
}
if (best < 0) { continue; }
const leaf = hp.cols[c].pop() as PileLeaf;
leaf.fromX = heapSlotX(hp, c, leaf);
leaf.fromY = heapSlotY(hp, h - 1);
leaf.slideAge = 0;
hp.cols[best].push(leaf);
moved += 1;
changed = true;
}
pass += 1;
}
hp.moves += moved;
return moved;
}
// 칸의 겉면 기울기(라디안) — 양옆 칸 높이 차로. 쌓인 잎은 이 각도로 눕힌다
function heapSlopeAngle(hp: Heap, c: number): number {
const l = hp.cols[Math.max(0, c - 1)].length;
const r = hp.cols[Math.min(hp.cols.length - 1, c + 1)].length;
return -Math.atan2((r - l) * LEAF_THICK, hp.cellW * 2);
}
function heapCount(hp: Heap): number {
let s = 0;
for (let i = 0; i < hp.cols.length; i += 1) { s += hp.cols[i].length; }
return s;
}
function heapAdvance(hp: Heap, dtMs: number): void {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
for (let k = 0; k < col.length; k += 1) {
if (col[k].slideAge >= 0) {
col[k].slideAge += dtMs;
if (col[k].slideAge >= SLIDE_MS) { col[k].slideAge = -1; }
}
}
}
}
// 더미 그리기 — 아래 칸부터, 잎마다 칸 기울기로 눕힌 스프라이트. 흘러내리는 잎은 옛 자리에서 새 자리로 미끄러진다
function drawHeap(hp: Heap, size: number, blur: number, alpha: number): void {
for (let c = 0; c < hp.cols.length; c += 1) {
const col = hp.cols[c];
const ang = heapSlopeAngle(hp, c);
for (let k = 0; k < col.length; k += 1) {
const lf = col[k];
let x = heapSlotX(hp, c, lf);
let y = heapSlotY(hp, k);
if (lf.slideAge >= 0) {
const s = smooth(lf.slideAge / SLIDE_MS);
x = lerp(lf.fromX, x, s);
y = lerp(lf.fromY, y, s);
}
blitLeaf(spriteFor(lf.kind, LEAF_COLORS[lf.color], blur), x, y, size, ang + lf.tilt, 0.55, alpha);
}
}
}
// 09 — 가을 나무 장면. 앞·중간·먼 세 층에 잎을 나눠 멀수록 작게(LAYER_SCALE)·흐리게(LAYER_BLUR)·느리게(LAYER_SPEED) 그린다.
// 중간 층 나무는 시드 고정 재귀 분기로 한 번만 그려 두고, 가지 끝 꼭지(05 의 lib/stem)가 끊기면 잎이 노이즈 바람(03 의 lib/wind)과
// 양력 걸음(lib/flutter)으로 내려와 바닥 더미(06 의 lib/heap)에 쌓인다. 모두 tail 의 한 시계로 돈다
type Drift9 = { x: number; y: number; vx: number; vy: number; tilt: number; flip: number; kind: number; color: number; size: number };
type Hang9 = { stem: Stem; fly: Flyer | null; kind: number; color: number; spot: number };
type Seg = { x0: number; y0: number; x1: number; y1: number; w: number };
// 층 0 앞 · 1 중간 · 2 먼
const LAYER_SCALE = [1, 0.62, 0.38];
const LAYER_BLUR = [0, 0.5, 1.6];
const LAYER_SPEED = [1, 0.72, 0.45];
const NEAR_COUNT = 7;
const FAR_COUNT = 44;
const HANG_COUNT = 18;
// 나무 — 몇 번 갈라지는가 · 갈래 각(라디안) · 한 번 갈라질 때 길이 비
const BRANCH_DEPTH = 6;
const BRANCH_SPREAD = 0.44;
const BRANCH_SHRINK = 0.74;
const TREE_X = 168;
const TREE_GROUND = 262;
const TRUNK_LEN = 74;
const BASE_LEAF = 40;
const SCENE_START_MS = 200;
const SCENE_LOOP_MS = 12000;
const LAYER_LABEL = '앞 · 중간 · 먼 층 = 크기 ×{a} · ×{b} · ×{c}';
const FALL_LABEL = '끊긴 잎 {n}장 · 더미 {m}장';
let segs: Seg[] = [];
let tips: Pt[] = [];
let treeCv: HTMLCanvasElement | null = null;
let treeDraws = 0;
let near: Drift9[] = [];
let far: Drift9[] = [];
let hang: Hang9[] = [];
let pile9: Heap = heapMake(0, 60, 8, TREE_GROUND);
let t09 = 0;
let fallen = 0;
let rng09 = makeRng(4799);
const wind9: Pt = [0, 0];
// 재귀 분기 — 같은 시드면 같은 나무
function growTree(): void {
segs = [];
tips = [];
const rng = makeRng(47990);
function branch(x: number, y: number, ang: number, len: number, depth: number, w: number): void {
const x1 = x + Math.cos(ang) * len;
const y1 = y + Math.sin(ang) * len;
segs.push({ x0: x, y0: y, x1: x1, y1: y1, w: w });
if (depth >= BRANCH_DEPTH) { tips.push([x1, y1]); return; }
const j = (rng() - 0.5) * 0.18;
branch(x1, y1, ang - BRANCH_SPREAD + j, len * BRANCH_SHRINK, depth + 1, w * 0.68);
branch(x1, y1, ang + BRANCH_SPREAD + j, len * BRANCH_SHRINK * 0.94, depth + 1, w * 0.68);
}
branch(TREE_X, TREE_GROUND, -Math.PI / 2, TRUNK_LEN, 1, 12);
}
// 나무와 붙어 있는 잎 무리는 오프스크린에 한 번만 그린다 — 매 프레임은 이 판 하나를 찍는다
function paintTree(): void {
treeDraws += 1;
const cvs = document.createElement('canvas');
cvs.width = W * 2;
cvs.height = H * 2;
treeCv = cvs;
const g = cvs.getContext('2d');
if (!g) { return; }
g.scale(2, 2);
g.lineCap = 'round';
for (let i = 0; i < segs.length; i += 1) {
const s = segs[i];
g.strokeStyle = rgba(i === 0 ? BARK_DEEP : BARK, 1);
g.lineWidth = s.w;
g.beginPath();
g.moveTo(s.x0, s.y0);
g.lineTo(s.x1, s.y1);
g.stroke();
}
const rng = makeRng(47991);
const size = BASE_LEAF * LAYER_SCALE[1];
for (let i = 0; i < tips.length; i += 1) {
for (let k = 0; k < 3; k += 1) {
const sp = spriteFor((i + k) % LEAF_KINDS, LEAF_COLORS[(i * 3 + k) % LEAF_COLORS.length], LAYER_BLUR[1]);
const kk = size / SPRITE_SIZE;
g.save();
g.translate(tips[i][0] + (rng() - 0.5) * 16, tips[i][1] + (rng() - 0.5) * 12);
g.rotate(rng() * TAU);
g.scale(kk, kk * 0.7);
g.drawImage(sp.cv, -sp.half, -sp.half, sp.half * 2, sp.half * 2);
g.restore();
}
}
}
function newDrift(layer: number, scatter: boolean): Drift9 {
const sz = BASE_LEAF * LAYER_SCALE[layer] * (0.8 + rng09() * 0.4);
return { x: scatter ? rng09() * W : -30, y: rng09() * (layer === 0 ? H : TREE_GROUND - 40), vx: 0, vy: 0, tilt: rng09() * TAU, flip: rng09() * TAU, kind: Math.floor(rng09() * LEAF_KINDS), color: Math.floor(rng09() * LEAF_COLORS.length), size: sz };
}
function newHang(spot: number): Hang9 {
const tip = tips[spot];
return { stem: makeStem(tip[0], tip[1], 6, Math.PI / 2 + (rng09() - 0.5) * 0.4, 0.004 + rng09() * 0.05), fly: null, kind: Math.floor(rng09() * LEAF_KINDS), color: Math.floor(rng09() * LEAF_COLORS.length), spot: spot };
}
function build(): void {
rng09 = makeRng(4799);
if (!treeCv) { growTree(); paintTree(); }
near = [];
far = [];
hang = [];
for (let i = 0; i < NEAR_COUNT; i += 1) { near.push(newDrift(0, true)); }
for (let i = 0; i < FAR_COUNT; i += 1) { far.push(newDrift(2, true)); }
for (let i = 0; i < HANG_COUNT; i += 1) { hang.push(newHang(Math.floor(i * tips.length / HANG_COUNT + 1) % tips.length)); }
pile9 = heapMake(0, 60, 8, TREE_GROUND);
for (let i = 0; i < 70; i += 1) {
pile9.cols[heapCol(pile9, TREE_X + (rng09() - 0.5) * 150)].push({ kind: i % LEAF_KINDS, color: i % LEAF_COLORS.length, jitter: rng09() - 0.5, tilt: (rng09() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
heapRelax(pile9, REPOSE_SLOPE, 40);
}
for (let c = 0; c < pile9.cols.length; c += 1) { for (let k = 0; k < pile9.cols[c].length; k += 1) { pile9.cols[c][k].slideAge = -1; } }
t09 = 0;
fallen = 0;
}
function loopLength(): number { return SCENE_LOOP_MS; }
function driftStep(d: Drift9, layer: number, ts: number, center: number, dt: number): void {
const sp = LAYER_SPEED[layer];
windAt(d.x, d.y, ts, center, wind9);
d.vx += (wind9[0] * sp - d.vx) * 2.2 * dt;
d.vy += ((wind9[1] + 46) * sp - d.vy) * 2.2 * dt;
d.x += d.vx * dt;
d.y += d.vy * dt;
d.tilt += (0.8 + 0.01 * Math.abs(wind9[0])) * sp * dt;
d.flip += 2 * sp * dt;
if (d.x > W + 40) { d.x = -40; }
if (d.y > H + 40) { d.y = -40; }
if (d.y < -40) { d.y = H + 40; }
}
function step(dt: number): void {
t09 += dt * 1000;
heapAdvance(pile9, dt * 1000);
if (t09 < SCENE_START_MS) { return; }
const ts = (t09 - SCENE_START_MS) / 1000;
const center = gustCenter(ts, GUST_SPEED);
for (let i = 0; i < near.length; i += 1) { driftStep(near[i], 0, ts, center, dt); }
for (let i = 0; i < far.length; i += 1) { driftStep(far[i], 2, ts, center, dt); }
for (let i = 0; i < hang.length; i += 1) {
const h = hang[i];
if (!h.fly) {
const tip = stemTip(h.stem);
windAt(tip[0], tip[1], ts, center, wind9);
if (stemStep(h.stem, wind9[0] * LAYER_SPEED[1], dt)) {
const r = 6 + BASE_LEAF * LAYER_SCALE[1] / 2;
const v = stemVelocityAt(h.stem, r);
const f = makeFlyer(h.stem.ax + Math.cos(h.stem.ang) * r, h.stem.ay + Math.sin(h.stem.ang) * r, h.stem.ang);
f.vx = v[0];
f.vy = v[1];
h.fly = f;
fallen += 1;
}
continue;
}
windAt(h.fly.x, h.fly.y, ts, center, wind9);
flutterStep(h.fly, wind9[0] * LAYER_SPEED[1] * 0.6, 0, dt, LEAF_LIFT_GAIN);
const c = heapCol(pile9, h.fly.x);
if (h.fly.y >= heapTop(pile9, c) - 1 || h.fly.x > W + 30) {
if (h.fly.x <= W + 30) {
pile9.cols[c].push({ kind: h.kind, color: h.color, jitter: rng09() - 0.5, tilt: (rng09() - 0.5) * 1.2, slideAge: -1, fromX: 0, fromY: 0 });
heapRelax(pile9, REPOSE_SLOPE, 40);
}
// 내려앉은 잎 자리에는 새 잎이 다시 달린다 — 잎 수는 HANG_COUNT 그대로
hang[i] = newHang(h.spot);
}
}
}
// 동작 줄이기 — 바람이 잔 판. 6초를 그리지 않고 돌려 더미를 쌓은 뒤, 날던 잎은 모두 땅에 눕힌다
function finalScene(): void {
build();
const n = Math.ceil(6000 / FIXED_DT);
for (let i = 0; i < n; i += 1) { step(STEP_S); }
for (let i = 0; i < hang.length; i += 1) { if (hang[i].fly) { hang[i] = newHang(hang[i].spot); } }
for (let i = 0; i < near.length; i += 1) {
near[i].x = 30 + i * (W - 60) / Math.max(1, near.length - 1);
near[i].y = H - 12 - (i % 2) * 6;
near[i].tilt = (i % 2 === 0 ? 0.2 : -0.25);
near[i].flip = 1.1;
}
for (let i = 0; i < far.length; i += 1) {
far[i].y = 214 - 26 * Math.sin(far[i].x * 0.011 + 0.6) - 12 * Math.sin(far[i].x * 0.031);
far[i].tilt = 0;
far[i].flip = 1.2;
}
}
function onWake(): void { }
function drawDrift(d: Drift9, layer: number): void {
blitLeaf(spriteFor(d.kind, LEAF_COLORS[d.color], LAYER_BLUR[layer]), d.x, d.y, d.size, d.tilt, 0.3 + 0.7 * Math.abs(Math.cos(d.flip)), layer === 2 ? 0.8 : 1);
}
function drawHills(): void {
ctx.fillStyle = rgba(mixRGB(SKY_TOP, SAGE, 0.35), 1);
ctx.beginPath();
ctx.moveTo(0, TREE_GROUND);
for (let x = 0; x <= W; x += 12) { ctx.lineTo(x, 200 - 26 * Math.sin(x * 0.011 + 0.6) - 12 * Math.sin(x * 0.031)); }
ctx.lineTo(W, TREE_GROUND);
ctx.closePath();
ctx.fill();
}
function draw(): void {
clearSky();
drawHills();
for (let i = 0; i < far.length; i += 1) { drawDrift(far[i], 2); }
drawGround(TREE_GROUND);
if (treeCv) { ctx.drawImage(treeCv, 0, 0, W, H); }
const msize = BASE_LEAF * LAYER_SCALE[1];
for (let i = 0; i < hang.length; i += 1) {
const h = hang[i];
const sp = spriteFor(h.kind, LEAF_COLORS[h.color], LAYER_BLUR[1]);
if (!h.fly) {
const tip = stemTip(h.stem);
blitLeaf(sp, tip[0] + Math.cos(h.stem.ang) * msize / 2, tip[1] + Math.sin(h.stem.ang) * msize / 2, msize, h.stem.ang, 0.7, 1);
} else {
blitLeaf(sp, h.fly.x, h.fly.y, msize, h.fly.tilt, 0.62, 1);
}
}
drawHeap(pile9, msize * 0.8, LAYER_BLUR[1], 1);
for (let i = 0; i < near.length; i += 1) { drawDrift(near[i], 0); }
drawCaption(LAYER_LABEL.replace('{a}', fmt(LAYER_SCALE[0], 2)).replace('{b}', fmt(LAYER_SCALE[1], 2)).replace('{c}', fmt(LAYER_SCALE[2], 2)), 8, 8, false);
drawCaption(FALL_LABEL.replace('{n}', fmt(fallen, 0)).replace('{m}', fmt(heapCount(pile9), 0)), W - 8, H - 28, true);
}
// 일시정지 단추 — aria-pressed 가 참이면 멈춘 상태. 동작 줄이기면 멈춘 채로 시작한다
let paused = still;
// 지금 판이 동작 줄이기 한 장면인가 — 사람이 재생을 누르면 처음부터 다시 시작한다
let stillShown = false;
let ready = false;
// 정리 뒤(리액트 언마운트)에 늦게 온 알림이 일을 시작하지 않게
let alive = true;
// 시연 시계(ms) — 16ms 고정 스텝으로만 늘어난다
let clock = 0;
function syncPause(): void { pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false'); }
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
onWake();
}
// 포인터 자리를 판 좌표(480×300)로 — 화면에 보이는 캔버스 크기와 상관없이
function toBoard(ev: PointerEvent): Pt {
const r = cv.getBoundingClientRect();
return [(ev.clientX - r.left) * W / Math.max(1, r.width), (ev.clientY - r.top) * H / Math.max(1, r.height)];
}
function render(): void { draw(); }
cv.addEventListener('pointermove', function (ev: PointerEvent): void {
const p = toBoard(ev);
hoverX = p[0];
hoverY = p[1];
wake();
if (paused && ready) { render(); }
}, { signal: off.signal });
cv.addEventListener('pointerleave', function (): void {
hoverX = -1;
hoverY = -1;
if (paused && ready) { render(); }
}, { signal: off.signal });
let rafId = 0;
let lastTs = -1;
let acc = 0;
function frame(ts: number): void {
if (paused || !alive) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) { clock = 0; build(); }
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 기준을 새로 잡는다 — 첫 프레임 간격을 0 으로
lastTs = -1;
acc = 0;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 잎이 이미 내려앉은 한 장면만 그린다
function showStill(): void {
stopLoop();
finalScene();
stillShown = true;
render();
}
function play(): void {
clock = 0;
stillShown = false;
build();
render();
startLoop();
}
pauseBtn.addEventListener('click', function (): void {
wake();
paused = !paused;
syncPause();
if (paused) { stopLoop(); render(); return; }
// 동작 줄이기여도 사람이 직접 누르면 재생한다 — 한 장면에서 처음부터
if (stillShown) { play(); } else { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 내려앉은 장면에서 멈추고, 꺼지면 시연을 처음부터
function onMotionPref(): void {
still = reduceQuery.matches;
if (!ready) { return; }
paused = still;
syncPause();
if (still) { showStill(); } else { play(); }
}
reduceQuery.addEventListener('change', onMotionPref, { signal: off.signal });
// 탭이 숨으면 루프를 세우고, 돌아오면 시계를 다시 잡아 숨어 있던 시간을 한 번에 따라잡지 않는다
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); return; }
if (!paused && ready) { startLoop(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
if (ready) { render(); }
}, { signal: off.signal });
function begin(): void {
fitCanvas();
build();
ready = true;
syncPause();
if (still) { showStill(); } else { play(); }
}
begin();
return function (): void {
alive = false;
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("lf") + " is-demo"} ref={rootRef}>
<canvas className={cx("lf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="가을 나무에서 끊긴 잎이 바람을 타고 내려와 바닥 더미에 쌓이고, 앞과 먼 곳의 잎이 크기와 흐림을 달리하며 날린다."></canvas>
<button className={cx("lf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 깊이마다 크기와 흐림이 다른 가을 나무 장면 — 측정 누적면적 34.949% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
function growTree() {
segs = [];
tips = [];
const rng = makeRng(47990);
function branch(x, y, ang, len, depth, w) {
const x1 = x + Math.cos(ang) * len;
const y1 = y + Math.sin(ang) * len;
segs.push({ x0: x, y0: y, x1: x1, y1: y1, w: w });
if (depth >= BRANCH_DEPTH) {
tips.push([x1, y1]);
return;
}
const j = (rng() - 0.5) * 0.18;
branch(x1, y1, ang - BRANCH_SPREAD + j, len * BRANCH_SHRINK, depth + 1, w * 0.68);
branch(x1, y1, ang + BRANCH_SPREAD + j, len * BRANCH_SHRINK * 0.94, depth + 1, w * 0.68);
}
branch(TREE_X, TREE_GROUND, -Math.PI / 2, TRUNK_LEN, 1, 12);
}
양력을 약하게, 돌풍을 빠르게, 더미 비탈을 낮게 — 숫자 하나로 달라지는 가을 바람
세 줄 모두 소스의 상수 한 줄만 바꾸고 같은 함수를 다시 돌려 잰 값이다. 눈여겨볼 것은 가운데 줄로, 돌풍을 거의 두 배 빠르게 하면 잎은 오히려 덜 밀린다. 띠에 실려 가는 시간이 1,232ms 에서 368ms 로 줄기 때문이다.
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 양력을 약하게 | 01 LEAF_LIFT_GAIN 1.6 → 0.6 |
양력 최대가 934~997 에서 54~160 으로 줄고, 옆 폭이 왼쪽 26.6~28.9px·오른쪽 22.6~34.2px 에서 ±1.6~5.5px 로 좁아져 거의 곧게 떨어진다. 내려앉는 시각은 3,872~4,912ms 에서 3,776~4,560ms 로 크게 다르지 않다 |
| 돌풍을 빠르게 | 03 GUST_SPEED 220 → 420 |
판 가운데 시험 잎의 최고 가로 속도가 275 에서 177px/s 로, 2.8초 이동이 329 에서 195.7px 로 준다. 띠가 다시 오는 간격은 5,836ms 에서 3,057ms 로 짧아지고, 한때 띠 안에 든 잎은 최대 104장에서 61장이 된다 |
| 더미 비탈을 낮게 | 06 REPOSE_SLOPE 0.9 → 0.4 |
비탈이 42° 에서 21.8° 로 눕자 600장 꼭대기가 124.8px 에서 62.4px 로 절반이 되고, 폭 300px 이던 더미가 판 끝 480px 까지 퍼진다. 흘러내림은 1,571번에서 3,416번으로 두 배 넘게 는다 |
낙엽이 깨지기 쉬운 때 — 잎이 수백 장으로 늘었을 때·탭을 다녀온 뒤 한꺼번에 튀는 잎·고해상도 화면의 단풍 물들이기
잎이 수백 장으로 늘었을 때
잎마다 윤곽 72점을 경로로 잇고 잎맥까지 긋는 일을 매번 되풀이하면, 2초(16ms 걸음 125번) 동안 03 하나에서만 17,500번이다. 02·04 를 뺀 일곱 장면은 잎 모양 3 × 색 5 = 스프라이트 15장을 처음 한 번만 오프스크린에 그려 두고, 그다음부터는 자리·크기·회전·세로 납작함만 바꿔 drawImage 로 찍는다. 2초 동안 잰 찍기 횟수는 03 17,500·06 28,190·07 18,750·08 15,000·09 17,375번인데 새로 그린 스프라이트는 다섯 장면 모두 0 이다(09 는 흐림 단계까지 더해 35장). 잎 수도 상한에 묶었다. 03 의 140장은 판 밖으로 나가면 반대쪽 끝으로 돌아와 2초에 42번 다시 쓰이고, 09 는 내려앉은 잎 자리에 새 잎을 단다. 이 절에서 다루는 잎 수 상한·스프라이트·고정 크기 거리장을 그대로 담은 소스 아홉 벌과 리액트 판은 zip 하나에 들어 있고, 압축은 fxr3gktp 을 넣어야 풀린다.
탭을 다녀온 뒤 한꺼번에 튀는 잎
탭을 숨겼다가 돌아오면 첫 프레임과 앞 프레임의 시각 차가 수 초로 벌어질 수 있다. 아래 frame 은 그 차를 DT_MAX 100ms 까지만 쌓고, 16ms 걸음을 한 프레임에 최대 6번만 돈다.
function frame(ts) {
if (paused || !alive) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
// dt 상한 — 한 프레임에 DT_MAX 까지만 쌓는다. 숨었던 탭이 돌아와 간격이 수 초여도 잎이 순간이동하지 않게
acc += Math.min(ts - lastTs, DT_MAX);
lastTs = ts;
let n = 0;
while (acc >= FIXED_DT && n < MAX_STEPS) {
clock += FIXED_DT;
if (clock >= loopLength()) {
clock = 0;
build();
}
step(STEP_S);
acc -= FIXED_DT;
n += 1;
}
render();
rafId = window.requestAnimationFrame(frame);
}
16ms 간격 40프레임 뒤에 5초를 비운 한 프레임을 넣어 보면, 상한이 있을 때 그 프레임은 96ms 만 돌아 01 첫 잎이 7px 움직인다. 상한을 없애면 5,008ms 를 한 번에 돌아 잎이 220.4px 를 순간이동하고, 세 장 다 바닥에 닿아 있다. 06 은 더 심해서 상한이 있으면 그 프레임에 8장을 쏟고 착지는 0장이지만, 없으면 401장을 쏟아 331장이 착지하고 흘러내림 1,069번이 한 프레임에 몰린다. 그래서 visibilitychange 로 숨으면 루프를 세우고, 돌아오면 startLoop 가 시계 기준을 다시 잡는다.
고해상도 화면의 단풍 물들이기
04 의 거리장을 캔버스 화소 수로 잡으면 480×300 에 배율 2 를 곱한 576,000 픽셀이 되어, 계산으로는 300×244 고정 판(73,200 픽셀)의 576,000/73,200 ≈ 7.9배다. 이 판은 거리장 크기를 화면 배율과 떼어 두고, 아래 이중 반복으로 잎 안 35,352 픽셀 × 잎맥 5 = 176,760번의 거리 계산을 buildField 에서 한 번만 한다(fieldBuilds 는 다시 만들어도 1).
for (let y = 0; y < FIELD_H; y += 1) {
for (let x = 0; x < FIELD_W; x += 1) {
if (!insidePoly(x + 0.5, y + 0.5, outline)) {
continue;
}
let best = 1e9;
for (let s = 0; s < veins.length; s += 1) {
distanceChecks += 1;
const d = segDist(x + 0.5, y + 0.5, veins[s][0], veins[s][1]);
if (d < best) {
best = d;
}
}
idx.push(y * FIELD_W + x);
dist.push(best);
if (best > maxD) {
maxD = best;
}
}
}
매 프레임은 앞의 paintField 처럼 문턱과 35,352번 견주기만 하니, 거리를 프레임마다 다시 재는 176,760번보다 횟수로 5분의 1 이다.
움직임 줄이기에서는 이미 내려앉은 잎으로 — 일시정지 버튼과 장면 설명을 갖춘 접근성
장면을 말로 적은 설명
아홉 캔버스 모두 role="img" 와 지금 장면을 말로 적은 aria-label 을 달아, 화면 읽기 프로그램이 그림 대신 문장을 읽는다. 문장은 문구.json 에 세 언어로 들어 있다.
키보드로 누르는 일시정지
일시정지는 aria-pressed 를 단 네이티브 <button> 이라 Tab 으로 가서 Enter 나 Space 로 누르면 되고, 멈춘 동안은 바탕이 진한 잉크색·글자가 크림색으로 뒤집힌다. 포커스가 오면 :focus-visible 의 2px 윤곽이 보인다.
움직임 줄이기에서 남는 판
prefers-reduced-motion 이 reduce 면 아홉 장면은 멈춘 채로 시작해 루프 없이 마지막 판 한 장을 그린다. 01 은 세 장이 자취를 남긴 채 내려앉은 판, 02 는 바닥에 눕힌 여덟 장, 03 은 바닥 띠에 눕힌 140장, 04 는 다 물든 잎, 05 는 여섯 장이 끊겨 바닥에 놓인 판, 06 은 흘러내림이 끝난 더미, 07 은 빗자루가 길 끝까지 지난 마당, 08 은 잦아든 뒤 바닥에 닿은 잎들, 09 는 6초를 돌린 더미에 날던 잎까지 땅에 눕힌 판이다. 재생을 누르면 처음부터 움직이고, 페이지를 연 채 설정을 바꾸면 그 변화도 다시 읽는다.
가을 페이지 배경이나 계절 배너에 낙엽을 깔 때 자주 묻는 질문
낙엽 떨어지는 gif 를 넣는 대신 이 코드를 쓰면 무엇이 달라지나요?
이 판은 시드를 고정해 열 때마다 같은 장면을 다시 계산하고, 포인터가 오면 01·03 은 바람을 보태고 07 은 빗자루가 따라온다. 움직임 줄이기 설정을 읽어 멈춘 판을 보여 주고 일시정지 버튼도 있어서, 정해진 장면을 되풀이하는 움짤보다 화면 쪽 사정에 맞추기가 쉬운 것으로 보인다.
잎을 더 많이 깔아도 화면이 버티나요?
02·04 를 뺀 장면에서 잎 한 장을 그리는 비용은 스프라이트 한 번 찍기라, 잰 다섯 장면은 2초에 15,000~28,190번을 찍어도 새로 그린 스프라이트가 0 이다. 다만 찍는 횟수는 잎 수에 비례해 늘어나므로, 03 처럼 수를 정해 두고 판 밖으로 나간 잎을 반대쪽에서 다시 쓰는 방식이 안전해 보인다.
잎 색을 사이트 색에 맞추려면 어디를 고치나요?
잎 다섯 색은 LEAF_COLORS 의 RGB 배열 다섯 개이고, 02 의 뒷면 색은 backColor 가 그 색과 크림을 반반 섞어 따로 만든다. 스프라이트는 모양·색·흐림을 묶은 키로 처음 쓸 때 한 번 그려지니 색을 바꿔도 비용은 같다. 04 의 물드는 색만은 초록에서 단풍으로 가는 네 단계 TURN_RAMP 를 따로 고쳐야 한다.









