09A Koi Pond Scene Where Ripples Spread in the Fish's Wake
The water is a height grid where each cell takes half the sum of its four neighbors minus its previous height to spread waves, three koi stir it with their tails, the pond floor wobbles by the surface slope, and lily pads bob on the surface.
Best forHero of a garden or inn landing page
WAVE_DAMPWAKE_GAINREFRACT

<main class="stage">
<div class="cf is-demo" id="cf">
<canvas class="cf__canvas" data-role="canvas" width="480" height="300" role="img" aria-label="A pond where ripples spread behind three koi, the floor pattern wavers and lily pads bob."></canvas>
<button class="cf__pause" data-role="pause" type="button" aria-pressed="false">Pause</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
.cf {
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;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: $sp-3;
bottom: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
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;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.cf__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 어떤 행동 중인지 글자로. 시연 중에는 aria-live off, 사람이 처음 손대면 polite
.cf__status {
position: absolute;
left: $sp-3;
top: $sp-3;
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status { font-size: 11px; padding: $sp-1 $sp-2; }
}
// 동작 줄이기 — 장면은 스크립트가 쉬는 자세 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.cf__pause { transition: none !important; }
.cf__pause:hover,
.cf__pause:active { 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);
}
.cf {
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;
}
.cf__canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
}
.cf__pause {
position: absolute;
right: 12px;
bottom: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
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);
}
.cf__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.cf__pause:hover {
transform: translateY(-2px);
}
.cf__pause:active {
transform: translateY(1px);
}
.cf__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.cf__status {
position: absolute;
left: 12px;
top: 12px;
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
pointer-events: none;
}
@media (max-width: 340px) {
.cf__pause,
.cf__status {
font-size: 11px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.cf__pause {
transition: none !important;
}
.cf__pause:hover,
.cf__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("cf");
if (!root) return;
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 wrapAngle(a) {
let r = a;
while (r > Math.PI) {
r -= TAU;
}
while (r < -Math.PI) {
r += TAU;
}
return r;
}
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate, dt) { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 흔들림·배치가 다시 나온다 (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;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 흔들림·배회 방향에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(480);
for (let i = 0; i < 256; i += 1) {
NOISE_TABLE[i] = noiseRng() * 2 - 1;
}
function smoothNoise(x) {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 8자 길 — 가로로 한 번(sin θ), 세로로 두 번(sin 2θ) 흔들리는 점. 판 가운데를 지나는 두 고리를 period 초에 한 바퀴
function figureEight(t, ax, ay, period) {
const a = TAU * t / period;
return [W / 2 + ax * Math.sin(a), H / 2 + ay * Math.sin(2 * a)];
}
// 마디 사슬 — xs[0], ys[0] 이 머리. 머리를 옮긴 뒤 부르면 뒤 마디마다 「앞 마디에서 지금 자기 쪽으로 향한 방향 × gap」 자리로 끌어온다.
// 간격만 지키므로 마디는 늘지도 줄지도 않고, 몸은 머리가 지나간 길을 대충 따라 눕는다.
// maxBend 가 π 보다 작으면 이웃 두 토막이 꺾이는 각을 ±maxBend(라디안)로 자른다 — π 이상(예: Math.PI)이면 각 한계 없음.
// backAngle 은 머리 뒤쪽 방향(머리 진행 방향 + π). 숫자면 첫 토막도 그 방향을 기준으로 자른다, NaN 이면 첫 토막은 자유
function followChain(xs, ys, gap, maxBend, backAngle) {
const n = xs.length;
let prevAng = backAngle;
for (let i = 1; i < n; i += 1) {
let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
if (maxBend < Math.PI && !isNaN(prevAng)) {
const d = wrapAngle(ang - prevAng);
if (d > maxBend) {
ang = prevAng + maxBend;
}
else if (d < -maxBend) {
ang = prevAng - maxBend;
}
}
xs[i] = xs[i - 1] + Math.cos(ang) * gap;
ys[i] = ys[i - 1] + Math.sin(ang) * gap;
prevAng = ang;
}
}
// 마디 i 에서 머리 쪽을 향한 방향(라디안) — 양 끝은 이웃 한 토막, 가운데는 앞뒤 마디를 이은 방향
function jointAngle(xs, ys, i) {
const n = xs.length;
if (i <= 0) {
return Math.atan2(ys[0] - ys[1], xs[0] - xs[1]);
}
if (i >= n - 1) {
return Math.atan2(ys[n - 2] - ys[n - 1], xs[n - 2] - xs[n - 1]);
}
return Math.atan2(ys[i - 1] - ys[i + 1], xs[i - 1] - xs[i + 1]);
}
// 이웃 두 토막 사이 꺾인 각(라디안, 부호 있음) — 마디 i 앞 토막과 뒤 토막의 방향 차
function bendAt(xs, ys, i) {
const a = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
const b = Math.atan2(ys[i + 1] - ys[i], xs[i + 1] - xs[i]);
return wrapAngle(b - a);
}
// 몸 윤곽 — 마디마다 방향의 법선(직각) 쪽으로 radii[i] 만큼 좌우로 민 점을 잇는다.
// 순서: 머리 왼쪽 → 왼쪽 옆구리를 따라 꼬리 → 꼬리 끝 → 오른쪽 옆구리를 따라 머리 → 머리 앞쪽 반원(HEAD_CAP_STEPS 조각). 닫힌 다각형, 점마다 x 와 y 를 차례로 넣은 한 줄 배열
const HEAD_CAP_STEPS = 6;
function bodyOutline(xs, ys, radii) {
const n = xs.length;
const pts = [];
for (let i = 0; i < n; i += 1) {
const a = jointAngle(xs, ys, i) + Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const tailA = jointAngle(xs, ys, n - 1) + Math.PI;
pts.push(xs[n - 1] + Math.cos(tailA) * radii[n - 1], ys[n - 1] + Math.sin(tailA) * radii[n - 1]);
for (let i = n - 1; i >= 0; i -= 1) {
const a = jointAngle(xs, ys, i) - Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const a0 = jointAngle(xs, ys, 0);
for (let k = 1; k < HEAD_CAP_STEPS; k += 1) {
const a = a0 - Math.PI / 2 + Math.PI * k / HEAD_CAP_STEPS;
pts.push(xs[0] + Math.cos(a) * radii[0], ys[0] + Math.sin(a) * radii[0]);
}
return pts;
}
// 몸이 스스로 겹친 정도 — 사슬에서 3칸 이상 떨어진 두 마디가 두 반지름 합보다 가까운 쌍의 수와 가장 가까운 거리
function selfOverlap(xs, ys, radii) {
const n = xs.length;
let count = 0;
let nearest = Infinity;
for (let i = 0; i < n; i += 1) {
for (let j = i + 3; j < n; j += 1) {
const d = Math.hypot(xs[i] - xs[j], ys[i] - ys[j]);
if (d < nearest) {
nearest = d;
}
if (d < radii[i] + radii[j]) {
count += 1;
}
}
}
return [count, nearest];
}
const KOI_JOINTS = 14;
// 이웃 두 토막이 꺾일 수 있는 한계(라디안). 0.35 ≈ 20°
const MAX_BEND = 0.35;
// 헤엄 — 최고 속도 SWIM_SPEED(px/초), 머리가 1초에 돌 수 있는 각 TURN_RATE(라디안), 목표 SLOW_RADIUS(px) 안에서는 속도를 줄인다
const SWIM_SPEED = 170;
const TURN_RATE = 3.2;
const SLOW_RADIUS = 90;
// 몸 흔들기 — 나아가는 방향에 sin(phase) × WIGGLE_AMP 를 더한다. phase 는 1px 나아갈 때 WIGGLE_FREQ 라디안씩
const WIGGLE_AMP = 0.24;
const WIGGLE_FREQ = 0.05;
// 가슴지느러미 — 벌어진 각 = FIN_BASE ± 앞몸 곡률 × FIN_GAIN. 바깥쪽 지느러미가 더 벌어진다
const FIN_BASE = 0.55;
const FIN_GAIN = 2.2;
// 꼬리지느러미 — 꼬리 토막 방향을 쫓는 스프링(세기 TAIL_STIFF, 감쇠 TAIL_DAMP). 몸보다 한 박자 늦게 흔들린다
const TAIL_STIFF = 60;
const TAIL_DAMP = 9;
// 몸 굵기 — u = 0(머리) ~ 1(꼬리). 앞 3할에서 가장 굵고 꼬리로 가늘어진다
function koiRadius(u, size) {
return size * (0.42 + 0.58 * Math.sin(Math.PI * clamp(0.28 + u * 0.72, 0, 1))) * (1 - 0.72 * u * u);
}
function makeKoi(x, y, heading, gap, size) {
const xs = new Float64Array(KOI_JOINTS);
const ys = new Float64Array(KOI_JOINTS);
const radii = new Float64Array(KOI_JOINTS);
for (let i = 0; i < KOI_JOINTS; i += 1) {
xs[i] = x - Math.cos(heading) * gap * i;
ys[i] = y - Math.sin(heading) * gap * i;
radii[i] = koiRadius(i / (KOI_JOINTS - 1), size);
}
return { xs: xs, ys: ys, radii: radii, gap: gap, heading: heading, speed: 0, phase: 0, curve: 0, finL: FIN_BASE, finR: FIN_BASE, tailAng: heading + Math.PI, tailVel: 0 };
}
// 한 걸음 — 머리를 목표 쪽으로 돌리고(1초에 TURN_RATE 까지) 나아간 뒤, 각 한계를 둔 사슬로 몸을 끌어온다
function steerKoi(k, dt, tx, ty, maxBend) {
const dx = tx - k.xs[0];
const dy = ty - k.ys[0];
const dist = Math.hypot(dx, dy);
const turn = wrapAngle(Math.atan2(dy, dx) - k.heading);
k.heading = wrapAngle(k.heading + clamp(turn, -TURN_RATE * dt, TURN_RATE * dt));
const want = SWIM_SPEED * clamp(dist / SLOW_RADIUS, 0.3, 1);
k.speed = lerp(k.speed, want, follow(3, dt));
k.phase += k.speed * dt * WIGGLE_FREQ;
const move = k.heading + Math.sin(k.phase) * WIGGLE_AMP;
k.xs[0] += Math.cos(move) * k.speed * dt;
k.ys[0] += Math.sin(move) * k.speed * dt;
followChain(k.xs, k.ys, k.gap, maxBend, k.heading + Math.PI);
// 앞몸 곡률 — 2·3·4번 마디의 꺾인 각 합. 부호가 휜 방향
const curve = bendAt(k.xs, k.ys, 2) + bendAt(k.xs, k.ys, 3) + bendAt(k.xs, k.ys, 4);
k.curve = lerp(k.curve, curve, follow(10, dt));
k.finL = lerp(k.finL, clamp(FIN_BASE + k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
k.finR = lerp(k.finR, clamp(FIN_BASE - k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
const n = KOI_JOINTS;
const tailTarget = Math.atan2(k.ys[n - 1] - k.ys[n - 2], k.xs[n - 1] - k.xs[n - 2]);
const acc = TAIL_STIFF * wrapAngle(tailTarget - k.tailAng) - TAIL_DAMP * k.tailVel;
k.tailVel += acc * dt;
k.tailAng = wrapAngle(k.tailAng + k.tailVel * dt);
}
// 쉬는 자세 — 머리 (x, y) 에서 heading 반대쪽으로 완만한 S 자(사인 반 바퀴 두 번)로 눕힌다
function restKoi(k, x, y, heading) {
k.heading = heading;
k.speed = 0;
for (let i = 0; i < KOI_JOINTS; i += 1) {
const u = i / (KOI_JOINTS - 1);
const side = Math.sin(u * TAU) * k.gap * 1.4;
k.xs[i] = x - Math.cos(heading) * k.gap * i - Math.sin(heading) * side;
k.ys[i] = y - Math.sin(heading) * k.gap * i + Math.cos(heading) * side;
}
k.curve = 0;
k.finL = FIN_BASE;
k.finR = FIN_BASE;
k.tailAng = Math.atan2(k.ys[KOI_JOINTS - 1] - k.ys[KOI_JOINTS - 2], k.xs[KOI_JOINTS - 1] - k.xs[KOI_JOINTS - 2]);
k.tailVel = 0;
}
// 파스텔 팔레트 — 크림·옅은 민트 바탕 위에 파스텔 생물, 윤곽선과 그림자는 진한 잉크색으로 명도 차를 둔다
const PAPER = [251, 247, 238];
const PAPER_LOW = [222, 242, 234];
const INK = [58, 50, 74];
const CREAM = [255, 251, 242];
const CORAL = [244, 162, 144];
const LAVENDER = [188, 170, 236];
const BUTTER = [246, 222, 140];
const SAGE = [156, 204, 164];
const PINK = [244, 176, 200];
const SKY = [156, 204, 236];
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 shade(c, k) { return [c[0] * k, c[1] * k, c[2] * k]; }
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;
// 목표점(판 좌표)과 그 속도(px/초) — 8자 길 또는 포인터. 그리기에서 눈동자 방향·목표 표시에 쓴다
let tx = W / 2;
let ty = H / 2;
let tvx = 0;
let tvy = 0;
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 clearStage(top, bottom) {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 점들을 지나는 매끈한 닫힌 곡선 — 이웃 두 점의 가운데를 잇고 점 자체는 2차 곡선의 조절점으로 쓴다
function smoothClosed(pts) {
const n = pts.length / 2;
ctx.beginPath();
ctx.moveTo((pts[2 * (n - 1)] + pts[0]) / 2, (pts[2 * (n - 1) + 1] + pts[1]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(pts[2 * i], pts[2 * i + 1], (pts[2 * i] + pts[2 * j]) / 2, (pts[2 * i + 1] + pts[2 * j + 1]) / 2);
}
ctx.closePath();
}
function fillShape(pts, fill, line) {
smoothClosed(pts);
ctx.fillStyle = rgba(fill, 1);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
}
// 바닥 그림자 — 같은 윤곽을 (ox, oy) 만큼 밀어 옅은 잉크색으로
function dropShadow(pts, ox, oy, alpha) {
ctx.save();
ctx.translate(ox, oy);
smoothClosed(pts);
ctx.fillStyle = rgba(INK, alpha);
ctx.fill();
ctx.restore();
}
// 눈 — 흰자 위 눈동자를 (lookX, lookY) 쪽으로 반지름의 0.4 만큼 민다. open 0~1 은 위 눈꺼풀이 덮는 정도의 반대
function drawEye(x, y, r, lookX, lookY, open) {
const a = Math.atan2(lookY - y, lookX - x);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + Math.cos(a) * r * 0.4, y + Math.sin(a) * r * 0.4, r * 0.55, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
if (open < 1) {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r + 0.5, 0, TAU);
ctx.clip();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fillRect(x - r - 1, y - r - 1, 2 * r + 2, (2 * r + 2) * (1 - open));
ctx.restore();
}
}
// 목표 표시 — 잉크색 고리와 점. 생물이 무엇을 쫓는지 보이게
function drawTarget(x, y) {
// 동작 줄이기의 쉬는 자세에서는 포인터가 올라왔을 때만 보인다
if (still && !ptrOn) {
return;
}
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, TAU);
ctx.fillStyle = rgba(INK, 0.7);
ctx.fill();
}
// 잉어 그리기 — 지느러미(몸 밑) → 몸 → 무늬 → 등지느러미 → 눈 순서. body·spot 은 몸 바탕색과 무늬색
function koiFin(x, y, ang, len, wide, color) {
ctx.save();
ctx.translate(x, y);
ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(len * 0.5, -wide, len, 0);
ctx.quadraticCurveTo(len * 0.5, wide * 0.35, 0, 0);
ctx.closePath();
ctx.fillStyle = rgba(color, 0.92);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
ctx.restore();
}
function drawKoi(k, body, spot, fin, lookX, lookY, line) {
const n = KOI_JOINTS;
const size = k.radii[3];
// 가슴지느러미 — 2번 마디 좌우. 몸 뒤쪽(진행 반대)에서 finL·finR 만큼 바깥으로 벌린다
const a2 = jointAngle(k.xs, k.ys, 2);
koiFin(k.xs[2] + Math.cos(a2 + Math.PI / 2) * k.radii[2] * 0.95, k.ys[2] + Math.sin(a2 + Math.PI / 2) * k.radii[2] * 0.95, a2 + Math.PI - k.finL, size * 2, size * 0.85, fin);
koiFin(k.xs[2] + Math.cos(a2 - Math.PI / 2) * k.radii[2] * 0.95, k.ys[2] + Math.sin(a2 - Math.PI / 2) * k.radii[2] * 0.95, a2 + Math.PI + k.finR, size * 2, size * 0.85, fin);
// 꼬리지느러미 — 꼬리 끝 마디에서 스프링 각 tailAng 을 가운데로 두 갈래
koiFin(k.xs[n - 1], k.ys[n - 1], k.tailAng + 0.42, size * 2.1, size * 0.8, fin);
koiFin(k.xs[n - 1], k.ys[n - 1], k.tailAng - 0.42, size * 2.1, size * 0.8, fin);
const outline = bodyOutline(k.xs, k.ys, k.radii);
fillShape(outline, body, line);
// 무늬 — 정해 둔 마디의 한쪽 옆으로 민 둥근 얼룩. 몸 윤곽 안으로 잘라 넘치지 않게
ctx.save();
smoothClosed(outline);
ctx.clip();
ctx.fillStyle = rgba(spot, 1);
const spots = [1, 0.35, 4, -0.5, 6, 0.55, 9, -0.2];
for (let s = 0; s < spots.length; s += 2) {
const i = spots[s];
const a = jointAngle(k.xs, k.ys, i) + Math.PI / 2;
ctx.beginPath();
ctx.arc(k.xs[i] + Math.cos(a) * k.radii[i] * spots[s + 1], k.ys[i] + Math.sin(a) * k.radii[i] * spots[s + 1], k.radii[i] * 0.85, 0, TAU);
ctx.fill();
}
ctx.restore();
// 등지느러미 — 3번에서 8번 마디까지 등줄기를 잇는 선
ctx.beginPath();
ctx.moveTo(k.xs[3], k.ys[3]);
for (let i = 4; i <= 8; i += 1) {
ctx.lineTo(k.xs[i], k.ys[i]);
}
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
const a0 = jointAngle(k.xs, k.ys, 0);
for (let s = -1; s <= 1; s += 2) {
const ex = k.xs[0] + Math.cos(a0 + s * Math.PI / 2) * k.radii[0] * 0.62;
const ey = k.ys[0] + Math.sin(a0 + s * Math.PI / 2) * k.radii[0] * 0.62;
drawEye(ex, ey, Math.max(2.2, k.radii[0] * 0.3), lookX, lookY, 1);
}
}
// 09 연못 — 수면을 GRID_W × GRID_H 높이 격자(한 칸 = 판 2px)로 두고, 1/WAVE_HZ 초마다
// 새 높이 = (위·아래·왼·오른 네 이웃의 합) ÷ 2 − 한 걸음 전 높이, 그 뒤 WAVE_DAMP 를 곱해 잦아들게 한다
const GRID_W = 240;
const GRID_H = 150;
const WAVE_HZ = 60;
const WAVE_DAMP = 0.985;
// 꼬리 물결 — 꼬리 끝이 지나는 칸에 (꼬리지느러미 각속도 × 꼬리 길이 + 헤엄 속도) × WAKE_GAIN × 걸음 시간 만큼 높이를 더한다
const WAKE_GAIN = 0.55;
const POND_KOI_GAP = 10;
const POND_KOI_SIZE = 13;
const PREWARM = 3;
function pathAt(t) { return figureEight(t, 175, 95, 9); }
let waveNow = new Float32Array(GRID_W * GRID_H);
let wavePrev = new Float32Array(GRID_W * GRID_H);
let waveAcc = 0;
let pondClock = 0;
// 잉어 셋 — 첫째는 목표를, 둘째·셋째는 같은 8자 길을 KOI_LAGS 초 늦게, 반쯤 줄인 고리로 쫓는다
const KOI_LAGS = [0, 3, 6];
const pondKoi = [];
for (let i = 0; i < 3; i += 1) {
pondKoi.push(makeKoi(W / 2, H / 2, 0, POND_KOI_GAP, POND_KOI_SIZE));
}
// 연잎 — 자리(px)·반지름·처음 각. 수면 높이와 기울기를 따라 흔들린다
const PADS = [70, 62, 26, 0.4, 404, 232, 30, 2.2, 392, 58, 20, 4.1, 96, 236, 18, 5.3];
function waveStep() {
const now = waveNow;
const prev = wavePrev;
for (let y = 1; y < GRID_H - 1; y += 1) {
const row = y * GRID_W;
for (let x = 1; x < GRID_W - 1; x += 1) {
const i = row + x;
prev[i] = ((now[i - 1] + now[i + 1] + now[i - GRID_W] + now[i + GRID_W]) * 0.5 - prev[i]) * WAVE_DAMP;
}
}
waveNow = prev;
wavePrev = now;
}
// 판 좌표 (x, y) 둘레 3×3 칸에 높이 amount 를 더한다(가운데 두 배)
function disturb(x, y, amount) {
const gx = Math.round(x / 2);
const gy = Math.round(y / 2);
if (gx < 2 || gy < 2 || gx > GRID_W - 3 || gy > GRID_H - 3) {
return;
}
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
waveNow[(gy + oy) * GRID_W + gx + ox] += amount * (ox === 0 && oy === 0 ? 2 : 1);
}
}
}
function heightAt(x, y) {
const gx = clamp(Math.round(x / 2), 1, GRID_W - 2);
const gy = clamp(Math.round(y / 2), 1, GRID_H - 2);
return waveNow[gy * GRID_W + gx];
}
function koiGoal(i, tx, ty) {
if (i === 0) {
return [tx, ty];
}
const p = figureEight(pondClock - KOI_LAGS[i], 175 - i * 40, 95 - i * 18, 9);
return p;
}
function reset() {
waveNow.fill(0);
wavePrev.fill(0);
waveAcc = 0;
pondClock = 0;
for (let i = 0; i < pondKoi.length; i += 1) {
const g = koiGoal(i, pathAt(0)[0], pathAt(0)[1]);
const fresh = makeKoi(g[0] - 30, g[1], 0, POND_KOI_GAP, POND_KOI_SIZE);
const k = pondKoi[i];
k.xs.set(fresh.xs);
k.ys.set(fresh.ys);
k.heading = 0;
k.speed = 0;
k.phase = i * 1.3;
k.tailAng = Math.PI;
k.tailVel = 0;
}
}
function update(dt, tx, ty, tvx, tvy) {
pondClock += dt;
for (let i = 0; i < pondKoi.length; i += 1) {
const k = pondKoi[i];
const g = koiGoal(i, tx, ty);
steerKoi(k, dt, g[0], g[1], MAX_BEND);
const n = KOI_JOINTS;
disturb(k.xs[n - 1], k.ys[n - 1], (Math.abs(k.tailVel) * POND_KOI_SIZE + k.speed) * WAKE_GAIN * dt);
}
waveAcc += dt;
let n = 0;
while (waveAcc >= 1 / WAVE_HZ && n < 4) {
waveStep();
waveAcc -= 1 / WAVE_HZ;
n += 1;
}
}
// 쉬는 자세 — 잔잔한 수면에 잉어 셋을 S 자로 눕혀 둔다
function restPose() {
waveNow.fill(0);
wavePrev.fill(0);
restKoi(pondKoi[0], W / 2 + 70, H / 2 - 10, 0.15);
restKoi(pondKoi[1], W / 2 - 60, H / 2 + 60, Math.PI - 0.3);
restKoi(pondKoi[2], W / 2 - 40, H / 2 - 70, Math.PI + 0.2);
}
// 굴절 — 바닥 무늬를 읽는 자리를 수면 기울기 × REFRACT 칸만큼 민다. 빛 — 기울기가 왼쪽 위를 향하면 밝게(LIGHT_GAIN)
const REFRACT = 1.6;
const LIGHT_GAIN = 0.05;
// 깊이 음영 — 연못 가운데로 갈수록 DEPTH_SHADE 만큼 어둡게
const DEPTH_SHADE = 0.22;
// 바닥 — 모래 바탕(값 노이즈 얼룩) 위에 시드 고정 조약돌 POND_PEBBLES 개. 한 번만 계산해 둔다
const POND_PEBBLES = 48;
const floorR = new Float32Array(GRID_W * GRID_H);
const floorG = new Float32Array(GRID_W * GRID_H);
const floorB = new Float32Array(GRID_W * GRID_H);
const depthK = new Float32Array(GRID_W * GRID_H);
const SAND = [168, 210, 204];
const PEBBLE_COLORS = [[226, 218, 196], [200, 190, 230], [182, 220, 176], [240, 206, 196]];
(function buildFloor() {
const rng = makeRng(4809);
for (let y = 0; y < GRID_H; y += 1) {
for (let x = 0; x < GRID_W; x += 1) {
const i = y * GRID_W + x;
const n = smoothNoise(x * 0.09 + y * 0.013) * 0.5 + smoothNoise(y * 0.11 + 40 + x * 0.02) * 0.5;
floorR[i] = SAND[0] + n * 10;
floorG[i] = SAND[1] + n * 10;
floorB[i] = SAND[2] + n * 8;
const dx = (x - GRID_W / 2) / (GRID_W / 2);
const dy = (y - GRID_H / 2) / (GRID_H / 2);
depthK[i] = 1 - DEPTH_SHADE * clamp(1 - (dx * dx + dy * dy), 0, 1);
}
}
for (let p = 0; p < POND_PEBBLES; p += 1) {
const px = rng() * GRID_W;
const py = rng() * GRID_H;
const pr = 2.5 + rng() * 5;
const c = PEBBLE_COLORS[Math.floor(rng() * PEBBLE_COLORS.length)];
for (let y = Math.max(0, Math.floor(py - pr)); y < Math.min(GRID_H, Math.ceil(py + pr)); y += 1) {
for (let x = Math.max(0, Math.floor(px - pr)); x < Math.min(GRID_W, Math.ceil(px + pr)); x += 1) {
const d = Math.hypot(x - px, (y - py) * 1.2) / pr;
if (d > 1) {
continue;
}
// 조약돌 윗면 — 왼쪽 위가 밝고 가장자리는 어둡게
const lit = 1.08 - 0.25 * d - 0.12 * ((x - px) + (y - py)) / pr;
const i = y * GRID_W + x;
floorR[i] = c[0] * lit;
floorG[i] = c[1] * lit;
floorB[i] = c[2] * lit;
}
}
}
})();
const pondCanvas = document.createElement('canvas');
pondCanvas.width = GRID_W;
pondCanvas.height = GRID_H;
const pondCtxRaw = pondCanvas.getContext('2d');
const pondImg = pondCtxRaw ? pondCtxRaw.createImageData(GRID_W, GRID_H) : null;
function renderWater() {
if (!pondCtxRaw || !pondImg) {
return;
}
const h = waveNow;
const px = pondImg.data;
for (let y = 0; y < GRID_H; y += 1) {
for (let x = 0; x < GRID_W; x += 1) {
const i = y * GRID_W + x;
const xl = x > 0 ? i - 1 : i;
const xr = x < GRID_W - 1 ? i + 1 : i;
const yu = y > 0 ? i - GRID_W : i;
const yd = y < GRID_H - 1 ? i + GRID_W : i;
const gx = h[xr] - h[xl];
const gy = h[yd] - h[yu];
const sx = clamp(Math.round(x + gx * REFRACT), 0, GRID_W - 1);
const sy = clamp(Math.round(y + gy * REFRACT), 0, GRID_H - 1);
const s = sy * GRID_W + sx;
const light = depthK[i] * (1 - (gx + gy) * LIGHT_GAIN);
const o = i * 4;
px[o] = floorR[s] * light;
px[o + 1] = floorG[s] * light;
px[o + 2] = floorB[s] * light;
px[o + 3] = 255;
}
}
pondCtxRaw.putImageData(pondImg, 0, 0);
}
function drawPad(i) {
const x0 = PADS[i * 4];
const y0 = PADS[i * 4 + 1];
const r = PADS[i * 4 + 2];
const hx = heightAt(x0 + 4, y0) - heightAt(x0 - 4, y0);
const hy = heightAt(x0, y0 + 4) - heightAt(x0, y0 - 4);
const x = x0 + hx * 3;
const y = y0 + hy * 3;
const rot = PADS[i * 4 + 3] + smoothNoise(pondClock * 0.3 + i * 9) * 0.25 + (hx - hy) * 0.03;
const sc = 1 + clamp(heightAt(x0, y0) * 0.012, -0.1, 0.1);
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(sc, sc);
ctx.beginPath();
ctx.ellipse(5, 6, r, r * 0.92, 0, 0.35, TAU - 0.35);
ctx.lineTo(5, 6);
ctx.closePath();
ctx.fillStyle = rgba(INK, 0.16);
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, r, 0.35, TAU - 0.35);
ctx.lineTo(0, 0);
ctx.closePath();
ctx.fillStyle = rgba(SAGE, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK, 0.35);
for (let v = 1; v < 6; v += 1) {
const a = 0.35 + (TAU - 0.7) * v / 6;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * r * 0.85, Math.sin(a) * r * 0.85);
ctx.stroke();
}
if (i === 0) {
ctx.beginPath();
ctx.arc(-r * 0.3, r * 0.15, r * 0.32, 0, TAU);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
ctx.restore();
}
const KOI_BODY = [CREAM, BUTTER, CREAM];
const KOI_SPOT = [CORAL, CORAL, INK];
function draw() {
renderWater();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(pondCanvas, 0, 0, W, H);
for (let i = 0; i < pondKoi.length; i += 1) {
dropShadow(bodyOutline(pondKoi[i].xs, pondKoi[i].ys, pondKoi[i].radii), 7, 11, 0.2);
}
for (let i = 0; i < pondKoi.length; i += 1) {
drawKoi(pondKoi[i], KOI_BODY[i], KOI_SPOT[i], SKY, tx, ty, 2);
}
for (let i = 0; i < PADS.length / 4; i += 1) {
drawPad(i);
}
drawTarget(tx, ty);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 미리 돌리기 — 첫 장면을 그리기 전에 PREWARM 초를 1/60초 걸음으로 계산해 생물이 길 위에 자리 잡은 채 시작한다
const PREWARM_STEP = 1 / 60;
// 시연 시계(초) — 8자 길 위의 자리를 정한다
let simTime = 0;
// 포인터(판 좌표) — 판 위에 있으면 목표를 포인터 쪽으로 옮긴다. 떠나면 다시 8자 길로 서서히 돌아간다
let ptrX = W / 2;
let ptrY = H / 2;
let ptrOn = false;
let ptrBlend = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 알림 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) {
live.setAttribute('aria-live', 'polite');
}
}
// 목표 옮기기 — 8자 길 자리와 포인터 자리를 ptrBlend 비율로 섞고, 그 변화로 목표 속도를 잰다
function moveTarget(dt) {
const p = pathAt(simTime);
ptrBlend = lerp(ptrBlend, ptrOn ? 1 : 0, follow(4, dt));
const nx = lerp(p[0], ptrX, ptrBlend);
const ny = lerp(p[1], ptrY, ptrBlend);
if (dt > 0) {
tvx = lerp(tvx, (nx - tx) / dt, follow(12, dt));
tvy = lerp(tvy, (ny - ty) / dt, follow(12, dt));
}
tx = nx;
ty = ny;
}
function advance(dt) {
simTime += dt;
moveTarget(dt);
update(dt, tx, ty, tvx, tvy);
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 동작 줄이기 — 루프를 돌리지 않고 쉬는 자세 한 장면만 그린다
function showRest() {
stopLoop();
reset();
restPose();
draw();
}
function play() {
reset();
simTime = 0;
const p = pathAt(0);
tx = p[0];
ty = p[1];
tvx = 0;
tvy = 0;
for (let t = 0; t < PREWARM; t += PREWARM_STEP) {
advance(PREWARM_STEP);
}
draw();
if (!paused) {
startLoop();
}
}
function setPointer(ev) {
const r = cv.getBoundingClientRect();
ptrX = (ev.clientX - r.left) * W / Math.max(1, r.width);
ptrY = (ev.clientY - r.top) * H / Math.max(1, r.height);
ptrOn = true;
wake();
// 동작 줄이기에서도 입력은 화면에 남는다 — 쉬는 자세 그대로 눈과 목표 표시만 포인터 쪽으로 다시 그린다
if (still) {
tx = ptrX;
ty = ptrY;
draw();
}
}
cv.addEventListener('pointermove', setPointer, { signal: off.signal });
cv.addEventListener('pointerdown', setPointer, { signal: off.signal });
function leavePointer() { ptrOn = false; }
cv.addEventListener('pointerleave', leavePointer, { signal: off.signal });
cv.addEventListener('pointercancel', leavePointer, { signal: off.signal });
pauseBtn.addEventListener('click', function () {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 쉬는 자세로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
window.addEventListener('resize', function () {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// KoiPondRippleScene - vanilla 판(09-creature-hero-koi-pond-ripple-scene) 의 React 포트. 같은 계산을 useEffect 안에서 돌린다.
import { useEffect, useRef } from "react";
import styles from "./KoiPondRippleScene.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function KoiPondRippleScene() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
type Pt = [number, number];
type RGB = [number, number, number];
// 판 좌표 — 캔버스 안은 늘 480×300. 화면에 보이는 크기와 상관없이 계산은 이 좌표로 한다
const W = 480;
const H = 300;
const TAU = Math.PI * 2;
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 wrapAngle(a: number): number {
let r = a;
while (r > Math.PI) { r -= TAU; }
while (r < -Math.PI) { r += TAU; }
return r;
}
// 따라가기 비율 — 1 − e^(−rate·dt). 프레임 시간이 달라도 1초에 같은 만큼 다가간다
function follow(rate: number, dt: number): number { return 1 - Math.exp(-rate * dt); }
// 시드 고정 난수 — 같은 시드면 같은 흔들림·배치가 다시 나온다 (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;
};
}
// 1차원 값 노이즈 — 정수 자리마다 시드로 정한 값(-1~1)을 두고 사이는 부드러운 보간. 흔들림·배회 방향에 쓴다
const NOISE_TABLE = new Float32Array(256);
const noiseRng = makeRng(480);
for (let i = 0; i < 256; i += 1) { NOISE_TABLE[i] = noiseRng() * 2 - 1; }
function smoothNoise(x: number): number {
const i = Math.floor(x);
const f = x - i;
const u = f * f * (3 - 2 * f);
return lerp(NOISE_TABLE[i & 255], NOISE_TABLE[(i + 1) & 255], u);
}
// 8자 길 — 가로로 한 번(sin θ), 세로로 두 번(sin 2θ) 흔들리는 점. 판 가운데를 지나는 두 고리를 period 초에 한 바퀴
function figureEight(t: number, ax: number, ay: number, period: number): Pt {
const a = TAU * t / period;
return [W / 2 + ax * Math.sin(a), H / 2 + ay * Math.sin(2 * a)];
}
// 마디 사슬 — xs[0], ys[0] 이 머리. 머리를 옮긴 뒤 부르면 뒤 마디마다 「앞 마디에서 지금 자기 쪽으로 향한 방향 × gap」 자리로 끌어온다.
// 간격만 지키므로 마디는 늘지도 줄지도 않고, 몸은 머리가 지나간 길을 대충 따라 눕는다.
// maxBend 가 π 보다 작으면 이웃 두 토막이 꺾이는 각을 ±maxBend(라디안)로 자른다 — π 이상(예: Math.PI)이면 각 한계 없음.
// backAngle 은 머리 뒤쪽 방향(머리 진행 방향 + π). 숫자면 첫 토막도 그 방향을 기준으로 자른다, NaN 이면 첫 토막은 자유
function followChain(xs: Float64Array, ys: Float64Array, gap: number, maxBend: number, backAngle: number): void {
const n = xs.length;
let prevAng = backAngle;
for (let i = 1; i < n; i += 1) {
let ang = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
if (maxBend < Math.PI && !isNaN(prevAng)) {
const d = wrapAngle(ang - prevAng);
if (d > maxBend) { ang = prevAng + maxBend; } else if (d < -maxBend) { ang = prevAng - maxBend; }
}
xs[i] = xs[i - 1] + Math.cos(ang) * gap;
ys[i] = ys[i - 1] + Math.sin(ang) * gap;
prevAng = ang;
}
}
// 마디 i 에서 머리 쪽을 향한 방향(라디안) — 양 끝은 이웃 한 토막, 가운데는 앞뒤 마디를 이은 방향
function jointAngle(xs: Float64Array, ys: Float64Array, i: number): number {
const n = xs.length;
if (i <= 0) { return Math.atan2(ys[0] - ys[1], xs[0] - xs[1]); }
if (i >= n - 1) { return Math.atan2(ys[n - 2] - ys[n - 1], xs[n - 2] - xs[n - 1]); }
return Math.atan2(ys[i - 1] - ys[i + 1], xs[i - 1] - xs[i + 1]);
}
// 이웃 두 토막 사이 꺾인 각(라디안, 부호 있음) — 마디 i 앞 토막과 뒤 토막의 방향 차
function bendAt(xs: Float64Array, ys: Float64Array, i: number): number {
const a = Math.atan2(ys[i] - ys[i - 1], xs[i] - xs[i - 1]);
const b = Math.atan2(ys[i + 1] - ys[i], xs[i + 1] - xs[i]);
return wrapAngle(b - a);
}
// 몸 윤곽 — 마디마다 방향의 법선(직각) 쪽으로 radii[i] 만큼 좌우로 민 점을 잇는다.
// 순서: 머리 왼쪽 → 왼쪽 옆구리를 따라 꼬리 → 꼬리 끝 → 오른쪽 옆구리를 따라 머리 → 머리 앞쪽 반원(HEAD_CAP_STEPS 조각). 닫힌 다각형, 점마다 x 와 y 를 차례로 넣은 한 줄 배열
const HEAD_CAP_STEPS = 6;
function bodyOutline(xs: Float64Array, ys: Float64Array, radii: Float64Array): number[] {
const n = xs.length;
const pts: number[] = [];
for (let i = 0; i < n; i += 1) {
const a = jointAngle(xs, ys, i) + Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const tailA = jointAngle(xs, ys, n - 1) + Math.PI;
pts.push(xs[n - 1] + Math.cos(tailA) * radii[n - 1], ys[n - 1] + Math.sin(tailA) * radii[n - 1]);
for (let i = n - 1; i >= 0; i -= 1) {
const a = jointAngle(xs, ys, i) - Math.PI / 2;
pts.push(xs[i] + Math.cos(a) * radii[i], ys[i] + Math.sin(a) * radii[i]);
}
const a0 = jointAngle(xs, ys, 0);
for (let k = 1; k < HEAD_CAP_STEPS; k += 1) {
const a = a0 - Math.PI / 2 + Math.PI * k / HEAD_CAP_STEPS;
pts.push(xs[0] + Math.cos(a) * radii[0], ys[0] + Math.sin(a) * radii[0]);
}
return pts;
}
// 몸이 스스로 겹친 정도 — 사슬에서 3칸 이상 떨어진 두 마디가 두 반지름 합보다 가까운 쌍의 수와 가장 가까운 거리
function selfOverlap(xs: Float64Array, ys: Float64Array, radii: Float64Array): [number, number] {
const n = xs.length;
let count = 0;
let nearest = Infinity;
for (let i = 0; i < n; i += 1) {
for (let j = i + 3; j < n; j += 1) {
const d = Math.hypot(xs[i] - xs[j], ys[i] - ys[j]);
if (d < nearest) { nearest = d; }
if (d < radii[i] + radii[j]) { count += 1; }
}
}
return [count, nearest];
}
// 잉어 한 마리 — 마디 사슬(머리 = 0) + 머리 방향·속도 + 지느러미 각 + 꼬리지느러미 스프링 상태
type Koi = {
xs: Float64Array; ys: Float64Array; radii: Float64Array; gap: number;
heading: number; speed: number; phase: number;
curve: number; finL: number; finR: number;
tailAng: number; tailVel: number;
};
const KOI_JOINTS = 14;
// 이웃 두 토막이 꺾일 수 있는 한계(라디안). 0.35 ≈ 20°
const MAX_BEND = 0.35;
// 헤엄 — 최고 속도 SWIM_SPEED(px/초), 머리가 1초에 돌 수 있는 각 TURN_RATE(라디안), 목표 SLOW_RADIUS(px) 안에서는 속도를 줄인다
const SWIM_SPEED = 170;
const TURN_RATE = 3.2;
const SLOW_RADIUS = 90;
// 몸 흔들기 — 나아가는 방향에 sin(phase) × WIGGLE_AMP 를 더한다. phase 는 1px 나아갈 때 WIGGLE_FREQ 라디안씩
const WIGGLE_AMP = 0.24;
const WIGGLE_FREQ = 0.05;
// 가슴지느러미 — 벌어진 각 = FIN_BASE ± 앞몸 곡률 × FIN_GAIN. 바깥쪽 지느러미가 더 벌어진다
const FIN_BASE = 0.55;
const FIN_GAIN = 2.2;
// 꼬리지느러미 — 꼬리 토막 방향을 쫓는 스프링(세기 TAIL_STIFF, 감쇠 TAIL_DAMP). 몸보다 한 박자 늦게 흔들린다
const TAIL_STIFF = 60;
const TAIL_DAMP = 9;
// 몸 굵기 — u = 0(머리) ~ 1(꼬리). 앞 3할에서 가장 굵고 꼬리로 가늘어진다
function koiRadius(u: number, size: number): number {
return size * (0.42 + 0.58 * Math.sin(Math.PI * clamp(0.28 + u * 0.72, 0, 1))) * (1 - 0.72 * u * u);
}
function makeKoi(x: number, y: number, heading: number, gap: number, size: number): Koi {
const xs = new Float64Array(KOI_JOINTS);
const ys = new Float64Array(KOI_JOINTS);
const radii = new Float64Array(KOI_JOINTS);
for (let i = 0; i < KOI_JOINTS; i += 1) {
xs[i] = x - Math.cos(heading) * gap * i;
ys[i] = y - Math.sin(heading) * gap * i;
radii[i] = koiRadius(i / (KOI_JOINTS - 1), size);
}
return { xs: xs, ys: ys, radii: radii, gap: gap, heading: heading, speed: 0, phase: 0, curve: 0, finL: FIN_BASE, finR: FIN_BASE, tailAng: heading + Math.PI, tailVel: 0 };
}
// 한 걸음 — 머리를 목표 쪽으로 돌리고(1초에 TURN_RATE 까지) 나아간 뒤, 각 한계를 둔 사슬로 몸을 끌어온다
function steerKoi(k: Koi, dt: number, tx: number, ty: number, maxBend: number): void {
const dx = tx - k.xs[0];
const dy = ty - k.ys[0];
const dist = Math.hypot(dx, dy);
const turn = wrapAngle(Math.atan2(dy, dx) - k.heading);
k.heading = wrapAngle(k.heading + clamp(turn, -TURN_RATE * dt, TURN_RATE * dt));
const want = SWIM_SPEED * clamp(dist / SLOW_RADIUS, 0.3, 1);
k.speed = lerp(k.speed, want, follow(3, dt));
k.phase += k.speed * dt * WIGGLE_FREQ;
const move = k.heading + Math.sin(k.phase) * WIGGLE_AMP;
k.xs[0] += Math.cos(move) * k.speed * dt;
k.ys[0] += Math.sin(move) * k.speed * dt;
followChain(k.xs, k.ys, k.gap, maxBend, k.heading + Math.PI);
// 앞몸 곡률 — 2·3·4번 마디의 꺾인 각 합. 부호가 휜 방향
const curve = bendAt(k.xs, k.ys, 2) + bendAt(k.xs, k.ys, 3) + bendAt(k.xs, k.ys, 4);
k.curve = lerp(k.curve, curve, follow(10, dt));
k.finL = lerp(k.finL, clamp(FIN_BASE + k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
k.finR = lerp(k.finR, clamp(FIN_BASE - k.curve * FIN_GAIN, 0.15, 1.45), follow(8, dt));
const n = KOI_JOINTS;
const tailTarget = Math.atan2(k.ys[n - 1] - k.ys[n - 2], k.xs[n - 1] - k.xs[n - 2]);
const acc = TAIL_STIFF * wrapAngle(tailTarget - k.tailAng) - TAIL_DAMP * k.tailVel;
k.tailVel += acc * dt;
k.tailAng = wrapAngle(k.tailAng + k.tailVel * dt);
}
// 쉬는 자세 — 머리 (x, y) 에서 heading 반대쪽으로 완만한 S 자(사인 반 바퀴 두 번)로 눕힌다
function restKoi(k: Koi, x: number, y: number, heading: number): void {
k.heading = heading;
k.speed = 0;
for (let i = 0; i < KOI_JOINTS; i += 1) {
const u = i / (KOI_JOINTS - 1);
const side = Math.sin(u * TAU) * k.gap * 1.4;
k.xs[i] = x - Math.cos(heading) * k.gap * i - Math.sin(heading) * side;
k.ys[i] = y - Math.sin(heading) * k.gap * i + Math.cos(heading) * side;
}
k.curve = 0;
k.finL = FIN_BASE;
k.finR = FIN_BASE;
k.tailAng = Math.atan2(k.ys[KOI_JOINTS - 1] - k.ys[KOI_JOINTS - 2], k.xs[KOI_JOINTS - 1] - k.xs[KOI_JOINTS - 2]);
k.tailVel = 0;
}
// 파스텔 팔레트 — 크림·옅은 민트 바탕 위에 파스텔 생물, 윤곽선과 그림자는 진한 잉크색으로 명도 차를 둔다
const PAPER: RGB = [251, 247, 238];
const PAPER_LOW: RGB = [222, 242, 234];
const INK: RGB = [58, 50, 74];
const CREAM: RGB = [255, 251, 242];
const CORAL: RGB = [244, 162, 144];
const LAVENDER: RGB = [188, 170, 236];
const BUTTER: RGB = [246, 222, 140];
const SAGE: RGB = [156, 204, 164];
const PINK: RGB = [244, 176, 200];
const SKY: RGB = [156, 204, 236];
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 shade(c: RGB, k: number): RGB { return [c[0] * k, c[1] * k, c[2] * k]; }
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;
// 목표점(판 좌표)과 그 속도(px/초) — 8자 길 또는 포인터. 그리기에서 눈동자 방향·목표 표시에 쓴다
let tx = W / 2;
let ty = H / 2;
let tvx = 0;
let tvy = 0;
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 clearStage(top: RGB, bottom: RGB): void {
const g = ctx.createLinearGradient(0, 0, 0, H);
g.addColorStop(0, rgba(top, 1));
g.addColorStop(1, rgba(bottom, 1));
ctx.fillStyle = g;
ctx.fillRect(0, 0, W, H);
}
// 점들을 지나는 매끈한 닫힌 곡선 — 이웃 두 점의 가운데를 잇고 점 자체는 2차 곡선의 조절점으로 쓴다
function smoothClosed(pts: number[]): void {
const n = pts.length / 2;
ctx.beginPath();
ctx.moveTo((pts[2 * (n - 1)] + pts[0]) / 2, (pts[2 * (n - 1) + 1] + pts[1]) / 2);
for (let i = 0; i < n; i += 1) {
const j = (i + 1) % n;
ctx.quadraticCurveTo(pts[2 * i], pts[2 * i + 1], (pts[2 * i] + pts[2 * j]) / 2, (pts[2 * i + 1] + pts[2 * j + 1]) / 2);
}
ctx.closePath();
}
function fillShape(pts: number[], fill: RGB, line: number): void {
smoothClosed(pts);
ctx.fillStyle = rgba(fill, 1);
ctx.fill();
if (line > 0) {
ctx.lineWidth = line;
ctx.lineJoin = 'round';
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
}
// 바닥 그림자 — 같은 윤곽을 (ox, oy) 만큼 밀어 옅은 잉크색으로
function dropShadow(pts: number[], ox: number, oy: number, alpha: number): void {
ctx.save();
ctx.translate(ox, oy);
smoothClosed(pts);
ctx.fillStyle = rgba(INK, alpha);
ctx.fill();
ctx.restore();
}
// 눈 — 흰자 위 눈동자를 (lookX, lookY) 쪽으로 반지름의 0.4 만큼 민다. open 0~1 은 위 눈꺼풀이 덮는 정도의 반대
function drawEye(x: number, y: number, r: number, lookX: number, lookY: number, open: number): void {
const a = Math.atan2(lookY - y, lookX - x);
ctx.beginPath();
ctx.arc(x, y, r, 0, TAU);
ctx.fillStyle = rgba(CREAM, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.beginPath();
ctx.arc(x + Math.cos(a) * r * 0.4, y + Math.sin(a) * r * 0.4, r * 0.55, 0, TAU);
ctx.fillStyle = rgba(INK, 1);
ctx.fill();
if (open < 1) {
ctx.save();
ctx.beginPath();
ctx.arc(x, y, r + 0.5, 0, TAU);
ctx.clip();
ctx.fillStyle = rgba(INK, 0.85);
ctx.fillRect(x - r - 1, y - r - 1, 2 * r + 2, (2 * r + 2) * (1 - open));
ctx.restore();
}
}
// 목표 표시 — 잉크색 고리와 점. 생물이 무엇을 쫓는지 보이게
function drawTarget(x: number, y: number): void {
// 동작 줄이기의 쉬는 자세에서는 포인터가 올라왔을 때만 보인다
if (still && !ptrOn) { return; }
ctx.beginPath();
ctx.arc(x, y, 7, 0, TAU);
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, TAU);
ctx.fillStyle = rgba(INK, 0.7);
ctx.fill();
}
// 잉어 그리기 — 지느러미(몸 밑) → 몸 → 무늬 → 등지느러미 → 눈 순서. body·spot 은 몸 바탕색과 무늬색
function koiFin(x: number, y: number, ang: number, len: number, wide: number, color: RGB): void {
ctx.save();
ctx.translate(x, y);
ctx.rotate(ang);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.quadraticCurveTo(len * 0.5, -wide, len, 0);
ctx.quadraticCurveTo(len * 0.5, wide * 0.35, 0, 0);
ctx.closePath();
ctx.fillStyle = rgba(color, 0.92);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 0.85);
ctx.stroke();
ctx.restore();
}
function drawKoi(k: Koi, body: RGB, spot: RGB, fin: RGB, lookX: number, lookY: number, line: number): void {
const n = KOI_JOINTS;
const size = k.radii[3];
// 가슴지느러미 — 2번 마디 좌우. 몸 뒤쪽(진행 반대)에서 finL·finR 만큼 바깥으로 벌린다
const a2 = jointAngle(k.xs, k.ys, 2);
koiFin(k.xs[2] + Math.cos(a2 + Math.PI / 2) * k.radii[2] * 0.95, k.ys[2] + Math.sin(a2 + Math.PI / 2) * k.radii[2] * 0.95, a2 + Math.PI - k.finL, size * 2, size * 0.85, fin);
koiFin(k.xs[2] + Math.cos(a2 - Math.PI / 2) * k.radii[2] * 0.95, k.ys[2] + Math.sin(a2 - Math.PI / 2) * k.radii[2] * 0.95, a2 + Math.PI + k.finR, size * 2, size * 0.85, fin);
// 꼬리지느러미 — 꼬리 끝 마디에서 스프링 각 tailAng 을 가운데로 두 갈래
koiFin(k.xs[n - 1], k.ys[n - 1], k.tailAng + 0.42, size * 2.1, size * 0.8, fin);
koiFin(k.xs[n - 1], k.ys[n - 1], k.tailAng - 0.42, size * 2.1, size * 0.8, fin);
const outline = bodyOutline(k.xs, k.ys, k.radii);
fillShape(outline, body, line);
// 무늬 — 정해 둔 마디의 한쪽 옆으로 민 둥근 얼룩. 몸 윤곽 안으로 잘라 넘치지 않게
ctx.save();
smoothClosed(outline);
ctx.clip();
ctx.fillStyle = rgba(spot, 1);
const spots: number[] = [1, 0.35, 4, -0.5, 6, 0.55, 9, -0.2];
for (let s = 0; s < spots.length; s += 2) {
const i = spots[s];
const a = jointAngle(k.xs, k.ys, i) + Math.PI / 2;
ctx.beginPath();
ctx.arc(k.xs[i] + Math.cos(a) * k.radii[i] * spots[s + 1], k.ys[i] + Math.sin(a) * k.radii[i] * spots[s + 1], k.radii[i] * 0.85, 0, TAU);
ctx.fill();
}
ctx.restore();
// 등지느러미 — 3번에서 8번 마디까지 등줄기를 잇는 선
ctx.beginPath();
ctx.moveTo(k.xs[3], k.ys[3]);
for (let i = 4; i <= 8; i += 1) { ctx.lineTo(k.xs[i], k.ys[i]); }
ctx.lineWidth = 2;
ctx.lineCap = 'round';
ctx.strokeStyle = rgba(INK, 0.55);
ctx.stroke();
const a0 = jointAngle(k.xs, k.ys, 0);
for (let s = -1; s <= 1; s += 2) {
const ex = k.xs[0] + Math.cos(a0 + s * Math.PI / 2) * k.radii[0] * 0.62;
const ey = k.ys[0] + Math.sin(a0 + s * Math.PI / 2) * k.radii[0] * 0.62;
drawEye(ex, ey, Math.max(2.2, k.radii[0] * 0.3), lookX, lookY, 1);
}
}
// 09 연못 — 수면을 GRID_W × GRID_H 높이 격자(한 칸 = 판 2px)로 두고, 1/WAVE_HZ 초마다
// 새 높이 = (위·아래·왼·오른 네 이웃의 합) ÷ 2 − 한 걸음 전 높이, 그 뒤 WAVE_DAMP 를 곱해 잦아들게 한다
const GRID_W = 240;
const GRID_H = 150;
const WAVE_HZ = 60;
const WAVE_DAMP = 0.985;
// 꼬리 물결 — 꼬리 끝이 지나는 칸에 (꼬리지느러미 각속도 × 꼬리 길이 + 헤엄 속도) × WAKE_GAIN × 걸음 시간 만큼 높이를 더한다
const WAKE_GAIN = 0.55;
const POND_KOI_GAP = 10;
const POND_KOI_SIZE = 13;
const PREWARM = 3;
function pathAt(t: number): Pt { return figureEight(t, 175, 95, 9); }
let waveNow = new Float32Array(GRID_W * GRID_H);
let wavePrev = new Float32Array(GRID_W * GRID_H);
let waveAcc = 0;
let pondClock = 0;
// 잉어 셋 — 첫째는 목표를, 둘째·셋째는 같은 8자 길을 KOI_LAGS 초 늦게, 반쯤 줄인 고리로 쫓는다
const KOI_LAGS: number[] = [0, 3, 6];
const pondKoi: Koi[] = [];
for (let i = 0; i < 3; i += 1) { pondKoi.push(makeKoi(W / 2, H / 2, 0, POND_KOI_GAP, POND_KOI_SIZE)); }
// 연잎 — 자리(px)·반지름·처음 각. 수면 높이와 기울기를 따라 흔들린다
const PADS: number[] = [70, 62, 26, 0.4, 404, 232, 30, 2.2, 392, 58, 20, 4.1, 96, 236, 18, 5.3];
function waveStep(): void {
const now = waveNow;
const prev = wavePrev;
for (let y = 1; y < GRID_H - 1; y += 1) {
const row = y * GRID_W;
for (let x = 1; x < GRID_W - 1; x += 1) {
const i = row + x;
prev[i] = ((now[i - 1] + now[i + 1] + now[i - GRID_W] + now[i + GRID_W]) * 0.5 - prev[i]) * WAVE_DAMP;
}
}
waveNow = prev;
wavePrev = now;
}
// 판 좌표 (x, y) 둘레 3×3 칸에 높이 amount 를 더한다(가운데 두 배)
function disturb(x: number, y: number, amount: number): void {
const gx = Math.round(x / 2);
const gy = Math.round(y / 2);
if (gx < 2 || gy < 2 || gx > GRID_W - 3 || gy > GRID_H - 3) { return; }
for (let oy = -1; oy <= 1; oy += 1) {
for (let ox = -1; ox <= 1; ox += 1) {
waveNow[(gy + oy) * GRID_W + gx + ox] += amount * (ox === 0 && oy === 0 ? 2 : 1);
}
}
}
function heightAt(x: number, y: number): number {
const gx = clamp(Math.round(x / 2), 1, GRID_W - 2);
const gy = clamp(Math.round(y / 2), 1, GRID_H - 2);
return waveNow[gy * GRID_W + gx];
}
function koiGoal(i: number, tx: number, ty: number): Pt {
if (i === 0) { return [tx, ty]; }
const p = figureEight(pondClock - KOI_LAGS[i], 175 - i * 40, 95 - i * 18, 9);
return p;
}
function reset(): void {
waveNow.fill(0);
wavePrev.fill(0);
waveAcc = 0;
pondClock = 0;
for (let i = 0; i < pondKoi.length; i += 1) {
const g = koiGoal(i, pathAt(0)[0], pathAt(0)[1]);
const fresh = makeKoi(g[0] - 30, g[1], 0, POND_KOI_GAP, POND_KOI_SIZE);
const k = pondKoi[i];
k.xs.set(fresh.xs);
k.ys.set(fresh.ys);
k.heading = 0;
k.speed = 0;
k.phase = i * 1.3;
k.tailAng = Math.PI;
k.tailVel = 0;
}
}
function update(dt: number, tx: number, ty: number, tvx: number, tvy: number): void {
pondClock += dt;
for (let i = 0; i < pondKoi.length; i += 1) {
const k = pondKoi[i];
const g = koiGoal(i, tx, ty);
steerKoi(k, dt, g[0], g[1], MAX_BEND);
const n = KOI_JOINTS;
disturb(k.xs[n - 1], k.ys[n - 1], (Math.abs(k.tailVel) * POND_KOI_SIZE + k.speed) * WAKE_GAIN * dt);
}
waveAcc += dt;
let n = 0;
while (waveAcc >= 1 / WAVE_HZ && n < 4) {
waveStep();
waveAcc -= 1 / WAVE_HZ;
n += 1;
}
}
// 쉬는 자세 — 잔잔한 수면에 잉어 셋을 S 자로 눕혀 둔다
function restPose(): void {
waveNow.fill(0);
wavePrev.fill(0);
restKoi(pondKoi[0], W / 2 + 70, H / 2 - 10, 0.15);
restKoi(pondKoi[1], W / 2 - 60, H / 2 + 60, Math.PI - 0.3);
restKoi(pondKoi[2], W / 2 - 40, H / 2 - 70, Math.PI + 0.2);
}
// 굴절 — 바닥 무늬를 읽는 자리를 수면 기울기 × REFRACT 칸만큼 민다. 빛 — 기울기가 왼쪽 위를 향하면 밝게(LIGHT_GAIN)
const REFRACT = 1.6;
const LIGHT_GAIN = 0.05;
// 깊이 음영 — 연못 가운데로 갈수록 DEPTH_SHADE 만큼 어둡게
const DEPTH_SHADE = 0.22;
// 바닥 — 모래 바탕(값 노이즈 얼룩) 위에 시드 고정 조약돌 POND_PEBBLES 개. 한 번만 계산해 둔다
const POND_PEBBLES = 48;
const floorR = new Float32Array(GRID_W * GRID_H);
const floorG = new Float32Array(GRID_W * GRID_H);
const floorB = new Float32Array(GRID_W * GRID_H);
const depthK = new Float32Array(GRID_W * GRID_H);
const SAND: RGB = [168, 210, 204];
const PEBBLE_COLORS: RGB[] = [[226, 218, 196], [200, 190, 230], [182, 220, 176], [240, 206, 196]];
(function buildFloor(): void {
const rng = makeRng(4809);
for (let y = 0; y < GRID_H; y += 1) {
for (let x = 0; x < GRID_W; x += 1) {
const i = y * GRID_W + x;
const n = smoothNoise(x * 0.09 + y * 0.013) * 0.5 + smoothNoise(y * 0.11 + 40 + x * 0.02) * 0.5;
floorR[i] = SAND[0] + n * 10;
floorG[i] = SAND[1] + n * 10;
floorB[i] = SAND[2] + n * 8;
const dx = (x - GRID_W / 2) / (GRID_W / 2);
const dy = (y - GRID_H / 2) / (GRID_H / 2);
depthK[i] = 1 - DEPTH_SHADE * clamp(1 - (dx * dx + dy * dy), 0, 1);
}
}
for (let p = 0; p < POND_PEBBLES; p += 1) {
const px = rng() * GRID_W;
const py = rng() * GRID_H;
const pr = 2.5 + rng() * 5;
const c = PEBBLE_COLORS[Math.floor(rng() * PEBBLE_COLORS.length)];
for (let y = Math.max(0, Math.floor(py - pr)); y < Math.min(GRID_H, Math.ceil(py + pr)); y += 1) {
for (let x = Math.max(0, Math.floor(px - pr)); x < Math.min(GRID_W, Math.ceil(px + pr)); x += 1) {
const d = Math.hypot(x - px, (y - py) * 1.2) / pr;
if (d > 1) { continue; }
// 조약돌 윗면 — 왼쪽 위가 밝고 가장자리는 어둡게
const lit = 1.08 - 0.25 * d - 0.12 * ((x - px) + (y - py)) / pr;
const i = y * GRID_W + x;
floorR[i] = c[0] * lit;
floorG[i] = c[1] * lit;
floorB[i] = c[2] * lit;
}
}
}
})();
const pondCanvas = document.createElement('canvas');
pondCanvas.width = GRID_W;
pondCanvas.height = GRID_H;
const pondCtxRaw = pondCanvas.getContext('2d');
const pondImg = pondCtxRaw ? pondCtxRaw.createImageData(GRID_W, GRID_H) : null;
function renderWater(): void {
if (!pondCtxRaw || !pondImg) { return; }
const h = waveNow;
const px = pondImg.data;
for (let y = 0; y < GRID_H; y += 1) {
for (let x = 0; x < GRID_W; x += 1) {
const i = y * GRID_W + x;
const xl = x > 0 ? i - 1 : i;
const xr = x < GRID_W - 1 ? i + 1 : i;
const yu = y > 0 ? i - GRID_W : i;
const yd = y < GRID_H - 1 ? i + GRID_W : i;
const gx = h[xr] - h[xl];
const gy = h[yd] - h[yu];
const sx = clamp(Math.round(x + gx * REFRACT), 0, GRID_W - 1);
const sy = clamp(Math.round(y + gy * REFRACT), 0, GRID_H - 1);
const s = sy * GRID_W + sx;
const light = depthK[i] * (1 - (gx + gy) * LIGHT_GAIN);
const o = i * 4;
px[o] = floorR[s] * light;
px[o + 1] = floorG[s] * light;
px[o + 2] = floorB[s] * light;
px[o + 3] = 255;
}
}
pondCtxRaw.putImageData(pondImg, 0, 0);
}
function drawPad(i: number): void {
const x0 = PADS[i * 4];
const y0 = PADS[i * 4 + 1];
const r = PADS[i * 4 + 2];
const hx = heightAt(x0 + 4, y0) - heightAt(x0 - 4, y0);
const hy = heightAt(x0, y0 + 4) - heightAt(x0, y0 - 4);
const x = x0 + hx * 3;
const y = y0 + hy * 3;
const rot = PADS[i * 4 + 3] + smoothNoise(pondClock * 0.3 + i * 9) * 0.25 + (hx - hy) * 0.03;
const sc = 1 + clamp(heightAt(x0, y0) * 0.012, -0.1, 0.1);
ctx.save();
ctx.translate(x, y);
ctx.rotate(rot);
ctx.scale(sc, sc);
ctx.beginPath();
ctx.ellipse(5, 6, r, r * 0.92, 0, 0.35, TAU - 0.35);
ctx.lineTo(5, 6);
ctx.closePath();
ctx.fillStyle = rgba(INK, 0.16);
ctx.fill();
ctx.beginPath();
ctx.arc(0, 0, r, 0.35, TAU - 0.35);
ctx.lineTo(0, 0);
ctx.closePath();
ctx.fillStyle = rgba(SAGE, 1);
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = rgba(INK, 0.35);
for (let v = 1; v < 6; v += 1) {
const a = 0.35 + (TAU - 0.7) * v / 6;
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(Math.cos(a) * r * 0.85, Math.sin(a) * r * 0.85);
ctx.stroke();
}
if (i === 0) {
ctx.beginPath();
ctx.arc(-r * 0.3, r * 0.15, r * 0.32, 0, TAU);
ctx.fillStyle = rgba(PINK, 1);
ctx.fill();
ctx.lineWidth = 1.5;
ctx.strokeStyle = rgba(INK, 1);
ctx.stroke();
}
ctx.restore();
}
const KOI_BODY: RGB[] = [CREAM, BUTTER, CREAM];
const KOI_SPOT: RGB[] = [CORAL, CORAL, INK];
function draw(): void {
renderWater();
ctx.imageSmoothingEnabled = true;
ctx.drawImage(pondCanvas, 0, 0, W, H);
for (let i = 0; i < pondKoi.length; i += 1) {
dropShadow(bodyOutline(pondKoi[i].xs, pondKoi[i].ys, pondKoi[i].radii), 7, 11, 0.2);
}
for (let i = 0; i < pondKoi.length; i += 1) {
drawKoi(pondKoi[i], KOI_BODY[i], KOI_SPOT[i], SKY, tx, ty, 2);
}
for (let i = 0; i < PADS.length / 4; i += 1) { drawPad(i); }
drawTarget(tx, ty);
}
// 한 프레임에 넘기는 시간 상한(초) — 탭을 오래 숨겼다 돌아온 첫 프레임에도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
// 미리 돌리기 — 첫 장면을 그리기 전에 PREWARM 초를 1/60초 걸음으로 계산해 생물이 길 위에 자리 잡은 채 시작한다
const PREWARM_STEP = 1 / 60;
// 시연 시계(초) — 8자 길 위의 자리를 정한다
let simTime = 0;
// 포인터(판 좌표) — 판 위에 있으면 목표를 포인터 쪽으로 옮긴다. 떠나면 다시 8자 길로 서서히 돌아간다
let ptrX = W / 2;
let ptrY = H / 2;
let ptrOn = false;
let ptrBlend = 0;
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 동작 줄이기 설정을 바꿔도 change 리스너가 이 값을 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 중인가 — 사람이 처음 손대면 시연 표시를 떼고 알림 줄을 polite 로 켠다
let demo = root.classList.contains('is-demo');
// 리스너는 한 묶음으로 달고 정리할 때 한 번에 뗀다
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
const live = root.querySelector('[aria-live]');
if (live) { live.setAttribute('aria-live', 'polite'); }
}
// 목표 옮기기 — 8자 길 자리와 포인터 자리를 ptrBlend 비율로 섞고, 그 변화로 목표 속도를 잰다
function moveTarget(dt: number): void {
const p = pathAt(simTime);
ptrBlend = lerp(ptrBlend, ptrOn ? 1 : 0, follow(4, dt));
const nx = lerp(p[0], ptrX, ptrBlend);
const ny = lerp(p[1], ptrY, ptrBlend);
if (dt > 0) {
tvx = lerp(tvx, (nx - tx) / dt, follow(12, dt));
tvy = lerp(tvy, (ny - ty) / dt, follow(12, dt));
}
tx = nx;
ty = ny;
}
function advance(dt: number): void {
simTime += dt;
moveTarget(dt);
update(dt, tx, ty, tvx, tvy);
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
draw();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 시계 다시 맞추기 — 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 동작 줄이기 — 루프를 돌리지 않고 쉬는 자세 한 장면만 그린다
function showRest(): void {
stopLoop();
reset();
restPose();
draw();
}
function play(): void {
reset();
simTime = 0;
const p = pathAt(0);
tx = p[0];
ty = p[1];
tvx = 0;
tvy = 0;
for (let t = 0; t < PREWARM; t += PREWARM_STEP) { advance(PREWARM_STEP); }
draw();
if (!paused) { startLoop(); }
}
function setPointer(ev: PointerEvent): void {
const r = cv.getBoundingClientRect();
ptrX = (ev.clientX - r.left) * W / Math.max(1, r.width);
ptrY = (ev.clientY - r.top) * H / Math.max(1, r.height);
ptrOn = true;
wake();
// 동작 줄이기에서도 입력은 화면에 남는다 — 쉬는 자세 그대로 눈과 목표 표시만 포인터 쪽으로 다시 그린다
if (still) {
tx = ptrX;
ty = ptrY;
draw();
}
}
cv.addEventListener('pointermove', setPointer, { signal: off.signal });
cv.addEventListener('pointerdown', setPointer, { signal: off.signal });
function leavePointer(): void { ptrOn = false; }
cv.addEventListener('pointerleave', leavePointer, { signal: off.signal });
cv.addEventListener('pointercancel', leavePointer, { signal: off.signal });
pauseBtn.addEventListener('click', function (): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}, { signal: off.signal });
// 탭이 숨으면 루프를 멈추고, 돌아오면 시계를 다시 맞춰 시작한다 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 페이지를 연 채 바뀌면 — 켜지면 쉬는 자세로 멈추고, 꺼지면 시연을 처음부터
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
window.addEventListener('resize', function (): void {
fitCanvas();
draw();
}, { signal: off.signal });
fitCanvas();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("cf") + " is-demo"} ref={rootRef}>
<canvas className={cx("cf__canvas")} data-role="canvas" width={480} height={300} role="img" aria-label="잉어 세 마리가 지나간 자리에 물결이 번지고 바닥 무늬가 일렁이며 연잎이 흔들리는 연못."></canvas>
<button className={cx("cf__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 A Koi Pond Scene Where Ripples Spread in the Fish's Wake — Measured area 51.417% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · Cursor Follow Animation: 9 Procedural Creatures
The post this demo lives in
Cursor Follow Animation: 9 Procedural CreaturesIn a cursor follow animation, with no joint keyframed, how does a creature swim and walk after a target? 01 keeps only t See all demos in this post
If you like this effect - similar demos
Interactions09 A Hero Scene Where a Pointer Wind Pushes the Smoke While Fireworks LaunchCSS Fireworks: 9 Bursts, Trails and Reflections
Interactions09 A Meadow Scene Where Fourteen Bubbles Ride the Wind, Each Computing Its Own Color and ReflectionBubble Animation CSS: 9 Soap-Film Scenes That Pop
Interactions08 City block — a scene where each building's height comes from the layout mathIsometric CSS: 9 True-Projection 3D Dioramas