캔버스 보이드(boids javascript) 군집 9종 — 새떼 애니메이션
보이드(boids javascript)는 이웃 반경 안 개체끼리 분리·정렬·결합 세 벡터를 더해 리더 없이 무리를 짓는 군집 시뮬레이션이며, 라이브러리·WebGL 없이 세 개의 벡터 규칙만으로 수백 마리 새떼가 짓는다는 것을 이 아홉 판이 캔버스와 순수 자바스크립트만으로 보여줍니다.
01 flock-three-rules
02 flock-bird-silhouette-wingbeat
03 flock-fish-school-depth
04 flock-predator-attack
05 flock-spatial-hash-grid
06 flock-murmuration-flowline
07 flock-attractor-orbit
08 flock-glow-plankton
09 flock-text-glyph-swarm
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집
- 02 새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털
- 03 물고기 떼 — 깊이감을 가진 스쿨링
- 04 천적 등장 — 도망 벡터가 쪼개는 떼
- 05 천 마리의 셈 — 공간 해시로 이웃 찾기
- 06 머뮤레이션 — 흐름선을 남기는 검은 구름
- 07 먹이 중심 — 인력 근처를 도는 궤도
- 08 발광 플랑크톤 — 밤바다에서 빛나는 떼
- 09 글자를 이루는 떼 — 포인터를 피해 모이는 문장
세 규칙 하나가 아홉 군집으로 갈라지는 이유
순서는 군집에 얹히는 힘의 개수로 갈립니다. 01 세 규칙의 떼는 분리·정렬·결합 세 벡터만으로 도는 최소 단위이고, 02 새떼 실루엣과 03 물고기 떼는 그 뼈대에 그리기(날갯짓 위상·가상 깊이)를 입힌 장면 판입니다. 04 천적은 네 번째 힘 "도망"이 처음 더해지는 판이고, 05 공간 해시는 힘은 그대로 두고 이웃을 찾는 셈을 격자로 바꿉니다. 06 머뮤레이션은 바람장을, 07 궤도는 인력점을, 08 플랑크톤은 발광 합성을 얹고, 09 글자 떼는 목표점 스프링까지 더해 문장을 군집으로 재조립합니다. 잔상 트레일을 쓰는 06·07·09는 이 순서에 처음 등장하는 누적 기법이기도 합니다.
군집은 왜 세 규칙으로 짓나? — 분리·정렬·결합과 이웃 반경의 셈
떼를 짓는 가장 단순한 길은 리더를 두는 것이 아니라 아무 개체도 우위가 없게 두는 것입니다. 각 개체는 자기 눈앞의 이웃 반경만 보고 셋을 계산합니다 — 가까우면 밀려나고(분리), 옆의 방향에 끌리고(정렬), 무리의 한가운데로 흘러갑니다(결합). 이 세 벡터를 무게로 합해 매 프레임 속도를 조금씩 틀면 개체 어느 것도 전체를 알지 못하는데 전체가 하나의 결을 짓습니다. 크레이그 레이놀즈가 1987년에 제안한 이 세 규칙의 조합이 지금도 flocking 알고리즘 의 표준형입니다. 이웃 반경이 얼마인가가 성격을 가릅니다 — 01 의 42px 처럼 넓으면 촘촘한 한 덩어리, 06 의 26px 처럼 좁으면 결이 실처럼 가늘어집니다. 이 편의 정체성이 이 지점입니다. 314 불꽃 파티클처럼 개체 하나하나가 제 갈 길을 계산하는 독립 파티클도 아니고, 306 처럼 점과 점을 스프링 제약으로 묶어 당기는 물리 그물도 아닙니다 — 이웃을 읽는 규칙 자체가 개체 사이를 이어 무리를 짓습니다.
항목별 코드
01세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집
인트로 배경·시뮬레이션 학습의 첫 화면에 쓰는, 개체 260마리가 세 벡터의 합만으로 무리를 짓는 최소 보이드입니다 — 실측 누적면적 41.242% · 강도 평균 124.8 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>개체 하나는 세 벡터의 합으로 움직입니다 — 분리(가까우면 밀림)·정렬(방향을 맞춤)·결합(중심으로 모임). 대표 개체에 화살표가 켜집니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="세 규칙 군집 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">260</b>개 개체 · 분리·정렬·결합</span>
<span class="cd__meta cd__meta--dim">캔버스 · 스티어링 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 세 규칙의 떼(01). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(세 스티어링 벡터 합산 + 토러스 랩)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
// 힘 벡터 시각화 색 — 본문 토큰 재사용
$flock-sep: #b9ddd1; // 분리 — 민트 계열(밝게 들어올림, BD26 파스텔 정정 S≈.35)
$flock-ali: #dae3ed; // 정렬 — 하늘 계열(BD26 파스텔 정정 L≈.89)
$flock-coh: #ffb9c8; // 결합 — 핑크 계열
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-sep: #{$flock-sep};
--flock-ali: #{$flock-ali};
--flock-coh: #{$flock-coh};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist; // 잉크 무대 위 — 먼 개체 톤 글자
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flock-lilac; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-lilac; font-weight: 700; } // 잉크 위 라일락 라벨
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 떼가 자리잡은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-sep: #b9ddd1;
--flock-ali: #dae3ed;
--flock-coh: #ffb9c8;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #b9a0ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #b9a0ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 세 규칙의 떼(01). 보이드 최소 구현 — 개체마다 이웃 반경을 훑어 세 스티어링 벡터를 계산한다.
// 분리(separation): 반경의 절반 안에 든 가까운 이웃에서 멀어지는 쪽으로 민다(가까울수록 큰 가중).
// 정렬(alignment): 이웃 속도의 평균 방향으로 자기 속도를 스티어.
// 결합(cohesion): 이웃 질량중심 방향으로 스티어.
// 셋을 무게로 합하고 최대 힘·최대 속도로 클램프한다. 화면은 토러스 랩 — 빠져나가면 반대편.
// 대표 개체 1마리엔 세 벡터를 얇은 화살표로 겹쳐 그려 규칙을 눈으로 읽게 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 260; // 개체 수 — 변형 "개체 수" 축(260 → 640)
var W_SEP = 1.5; // 분리 무게
var W_ALI = 1.0; // 정렬 무게
var W_COH = 1.0; // 결합 무게
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 떼로 흐른다
var SEED = 0x30c101;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_SEPL = tokRGB('--flock-sep');
var C_ALIG = tokRGB('--flock-ali');
var C_COHE = tokRGB('--flock-coh');
var C_LILA = tokRGB('--flock-lilac');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각 — 떼가 퍼진 한 장
// ── 시뮬 상수 ──
var R_NEIGH = 42; // 이웃 반경 (px)
var MAX_SPD = 128; // 최대 속도 (px/s)
var MAX_FOR = 340; // 최대 힘 (px/s^2) — 스티어 예리함
// ── 개체 ──
var boids = [];
function spawnB(b) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.ph = rand() * TAU; // 안내 위상 — 개체마다 궤적을 어긋나게
b.grp = arguments.length > 1 ? arguments[1] % 4 : 0; // 하위 무리 4개 — 각자 다른 궤적
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
var view = { sep: [0, 0], ali: [0, 0], coh: [0, 0] }; // 대표 개체 시각화용 최신값
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게 (1/d 가중)
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
// 정렬 — 이웃 평균 속도 방향으로
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
// 결합 — 이웃 질량중심으로
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
if (i === 0) { // 대표 개체 — 화살표 그릴 최신값
view.sep[0] = W_SEP * (sepX !== 0 || sepY !== 0 ? sC[0] : 0);
view.sep[1] = W_SEP * (sepY !== 0 || sepX !== 0 ? sC[1] : 0);
view.ali[0] = cnt > 0 ? W_ALI * sA[0] : 0;
view.ali[1] = cnt > 0 ? W_ALI * sA[1] : 0;
view.coh[0] = cnt > 0 ? W_COH * sB[0] : 0;
view.coh[1] = cnt > 0 ? W_COH * sB[1] : 0;
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;
}
}
function arrow(x, y, vx, vy, scale, col, wdt) {
var ex = x + vx * scale, ey = y + vy * scale;
ctx.strokeStyle = col;
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.stroke();
// 화살촉 — 벡터 방향으로 작은 갈래
var ang = Math.atan2(vy, vx);
var hl = 3.2 + wdt;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang - 0.5) * hl, ey - Math.sin(ang - 0.5) * hl);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang + 0.5) * hl, ey - Math.sin(ang + 0.5) * hl);
ctx.stroke();
}
function draw(tSec) {
// 무대 — 어스름 잉크
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 떼 — 사각 점
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
ctx.fillStyle = 'rgb(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ')';
ctx.fillRect(b.x - 1.5, b.y - 2, 3, 4);
}
// 대표 개체 0번 — 세 힘 벡터 화살표. 무대를 넓게 훑도록 개체 0의 속도에 느린 회전을 얹는다
var hero = boids[0];
if (hero) {
// 대표 개체 몸 — 라일락 강조
ctx.fillStyle = 'rgb(' + C_LILA[0] + ',' + C_LILA[1] + ',' + C_LILA[2] + ')';
ctx.fillRect(hero.x - 2, hero.y - 3, 4, 6);
var sc = 0.24;
arrow(hero.x, hero.y, view.sep[0], view.sep[1], sc, 'rgb(' + C_SEPL[0] + ',' + C_SEPL[1] + ',' + C_SEPL[2] + ')', 1.4);
arrow(hero.x, hero.y, view.ali[0], view.ali[1], sc, 'rgb(' + C_ALIG[0] + ',' + C_ALIG[1] + ',' + C_ALIG[2] + ')', 1.4);
arrow(hero.x, hero.y, view.coh[0], view.coh[1], sc, 'rgb(' + C_COHE[0] + ',' + C_COHE[1] + ',' + C_COHE[2] + ')', 1.4);
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) { // 크기가 바뀌면 위치 배율 보정 후 다시 시드 리셋
prngState = SEED >>> 0;
build();
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다(면적 커버리지)
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph; // 무리마다 회전 속도·위상이 다르다
b.vx += Math.cos(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
b.vy += Math.sin(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) step(1 / 60); // 떼가 자리잡은 뒤의 한 장
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// ThreeRules — vanilla 판(run/315/demo/01-flock-three-rules/index.html) 의 React 포트.
// 보이드 세 규칙(분리·정렬·결합) 스티어링 합산 + 토러스 랩 + 하위 무리 안내로 화면을 넓게 훑는다.
// 대표 개체 1마리에 세 힘 벡터 화살표를 겹쳐 규칙을 눈으로 읽게 한다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ThreeRules.module.scss";
export type ThreeRulesProps = {
duration?: string;
easing?: string;
color?: string;
boids?: number; // 개체 수 — 변형 "개체 수" 축(260 → 640)
weightSep?: number; // 분리 무게
weightAli?: number; // 정렬 무게
weightCoh?: number; // 결합 무게
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Boid = { x: number; y: number; vx: number; vy: number; ph: number; grp: number };
type Colors = { ink: RGB; mist: RGB; sep: RGB; ali: RGB; coh: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "세 규칙 군집 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 42; // 이웃 반경 (px)
const MAX_SPD = 128; // 최대 속도 (px/s)
const MAX_FOR = 340; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function ThreeRules({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
boids = 260,
weightSep = 1.5,
weightAli = 1.0,
weightCoh = 1.0,
}: ThreeRulesProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const nbRef = useRef(boids) as Box<number>;
const wsRef = useRef(weightSep) as Box<number>;
const waRef = useRef(weightAli) as Box<number>;
const wcRef = useRef(weightCoh) as Box<number>;
const boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const viewRef = useRef({ sep: [0, 0], ali: [0, 0], coh: [0, 0] }) as Box<{ sep: number[]; ali: number[]; coh: number[] }>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(boids));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function build(): void {
const rand = makePRNG(0x30c101);
const { w: W, h: H } = sizeRef.current;
const arr: Boid[] = [];
for (let i = 0; i < nbRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
ph: rand() * TAU,
grp: i % 4,
});
}
boidsRef.current = arr;
setN(String(nbRef.current));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt: number, tSec: number): void {
const boids = boidsRef.current;
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
b.vy += Math.sin(ga) * 150 * dt * (i === 0 ? 2.6 : 1);
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const boids = boidsRef.current;
const view = viewRef.current;
const n = boids.length;
for (let i = 0; i < n; i++) {
const b = boids[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = boids[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
const hasSep = sepX !== 0 || sepY !== 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += waRef.current * sA[0] + wcRef.current * sB[0];
fy += waRef.current * sA[1] + wcRef.current * sB[1];
}
if (hasSep) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += wsRef.current * sC[0]; fy += wsRef.current * sC[1];
}
if (i === 0) {
view.sep[0] = hasSep ? wsRef.current * sC[0] : 0;
view.sep[1] = hasSep ? wsRef.current * sC[1] : 0;
view.ali[0] = cnt > 0 ? waRef.current * sA[0] : 0;
view.ali[1] = cnt > 0 ? waRef.current * sA[1] : 0;
view.coh[0] = cnt > 0 ? wcRef.current * sB[0] : 0;
view.coh[1] = cnt > 0 ? wcRef.current * sB[1] : 0;
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;
}
}
function arrow(x: number, y: number, vx: number, vy: number, scale: number, col: string, wdt: number): void {
const ctx = ctxRef.current;
if (!ctx) return;
const ex = x + vx * scale, ey = y + vy * scale;
ctx.strokeStyle = col;
ctx.lineWidth = wdt;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(ex, ey);
ctx.stroke();
const ang = Math.atan2(vy, vx);
const hl = 3.2 + wdt;
ctx.beginPath();
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang - 0.5) * hl, ey - Math.sin(ang - 0.5) * hl);
ctx.moveTo(ex, ey);
ctx.lineTo(ex - Math.cos(ang + 0.5) * hl, ey - Math.sin(ang + 0.5) * hl);
ctx.stroke();
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col || boids.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = `rgb(${col.mist[0]},${col.mist[1]},${col.mist[2]})`;
for (let i = 0; i < boids.length; i++) {
const b = boids[i];
ctx.fillRect(b.x - 1.5, b.y - 2, 3, 4);
}
const hero = boids[0];
if (hero) {
ctx.fillStyle = `rgb(${col.lilac[0]},${col.lilac[1]},${col.lilac[2]})`;
ctx.fillRect(hero.x - 2, hero.y - 3, 4, 6);
const v = viewRef.current;
const sc = 0.24;
arrow(hero.x, hero.y, v.sep[0], v.sep[1], sc, `rgb(${col.sep[0]},${col.sep[1]},${col.sep[2]})`, 1.4);
arrow(hero.x, hero.y, v.ali[0], v.ali[1], sc, `rgb(${col.ali[0]},${col.ali[1]},${col.ali[2]})`, 1.4);
arrow(hero.x, hero.y, v.coh[0], v.coh[1], sc, `rgb(${col.coh[0]},${col.coh[1]},${col.coh[2]})`, 1.4);
}
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
herdGuide(dt, t);
step(dt);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nbRef.current = boids;
wsRef.current = weightSep;
waRef.current = weightAli;
wcRef.current = weightCoh;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
sep: tokRGB(root, "--flock-sep"),
ali: tokRGB(root, "--flock-ali"),
coh: tokRGB(root, "--flock-coh"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw();
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) step(1 / 60);
draw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [boids, weightSep, weightAli, weightCoh]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>개체 하나는 세 벡터의 합으로 움직입니다 — 분리(가까우면 밀림)·정렬(방향을 맞춤)·결합(중심으로 모임). 대표 개체에 화살표가 켜집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개 개체 · 분리·정렬·결합</span>
<span className={styles.metaDim}>캔버스 · 스티어링 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · 세 규칙의 떼 — 분리·정렬·결합만으로 도는 최소 군집 — 측정 누적면적 41.242% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
이웃 반경 안에서 세 벡터를 모은다
var R_NEIGH = 42; // 이웃 반경 (px)
var MAX_SPD = 128; // 최대 속도 (px/s)
var MAX_FOR = 340; // 최대 힘 (px/s^2) — 스티어 예리함
// 개체 i 의 한 스텝 — 이웃 j 를 훑어 세 스티어링 원료를 쌓는다
for (var j = 0; j < n; j++) {
if (j === i) continue;
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy; // 정렬 원료 — 이웃 속도
cx += o.x; cy += o.y; // 결합 원료 — 이웃 중심
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w; // 가까울수록 큰 가중
}
}
R_NEIGH 42px 안의 이웃만 셈에 들어가고 분리는 그 절반(21px) 안의 가까운 이웃에서만 거리에 반비례해 밀어내며, 정렬은 이웃 속도의 평균·결합은 이웃 질량중심 방향으로 원료를 나눠 쌓습니다.
세 벡터를 무게로 합해 스티어한다
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA); // 정렬
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB); // 결합
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC); // 분리
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
화면 밖은 토러스로 이어 붙인다
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -4) b.x += W + 8; else if (b.x > W + 4) b.x -= W + 8;
if (b.y < -4) b.y += H + 8; else if (b.y > H + 4) b.y -= H + 8;
벽 반사 대신 반대편으로 이어 붙여 떼가 화면 가장자리에서 끊기지 않고 흐르며, 최소 속도 규칙(MAX_SPD * 0.5 밑으로 안 떨어짐)이 개체가 제자리에서 얼어붙는 것도 막습니다.
02새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털
저녁 무드 페이지의 하늘 배경으로 쓰는, 삼각형 날개 두 개가 개체마다 어긋난 위상으로 파닥이며 진행 방향으로 몸이 기우는 검은 새떼입니다 — 실측 누적면적 39.212% · 강도 평균 69.5 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>저녁하늘을 가르는 새떼입니다 — 삼각형 날개 두 개가 개체마다 어긋난 위상으로 파닥이고, 진행 방향으로 몸이 기웁니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="새떼 실루엣 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">130</b>마리 새 · 날갯짓 위상</span>
<span class="cd__meta cd__meta--dim">ctx.rotate · sin 위상 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 새떼 실루엣(02). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(군집 스티어링 + 매 프레임 하늘 그라디언트 재도색 + 날갯짓 sin 위상)가 만든다.
// 무대는 저녁하늘(슬레이트→따뜻한 지평선). 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
$duration: $dur-loop;
$easing: $ease-spring;
$color: $flock-dusk;
@include stage($stage-ink);
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-dusk;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 새떼가 자리잡은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #3d4358;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 새떼 실루엣(02). 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩).
// 장면이 다르다: 배경은 매 프레임 세로 그라디언트(슬레이트→지평선 파스텔)를 다시 칠해 잔상을 남기지 않고,
// 개체는 실루엣 잉크 삼각형 날개 2개 — 날갯짓 위상이 개체마다 어긋나고(sin), 진행 방향으로 ctx.rotate 한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 130; // 새 수 — 밀도 축(90 → 260 수준으로 커버리지 확보)
var W_SEP = 1.6; // 분리 무게
var W_ALI = 1.2; // 정렬 무게
var W_COH = 1.0; // 결합 무게
var TONE = 0; // 실루엣 색 변형 — 0: 잉크(#23262f), 1: 라일락(#b9a0ff)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 새떼로 흐른다
var SEED = 0x30c102;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_DUSK = tokRGB('--flock-dusk');
var C_GLOW = tokRGB('--flock-glow');
var C_BIRD = tokRGB('--flock-bird');
var C_LILA = tokRGB('--flock-lilac');
var C_SILH = TONE ? C_LILA : C_BIRD; // 색 변형 — 실루엣 톤 교체
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각
// ── 시뮬 상수 ──
var R_NEIGH = 52; // 이웃 반경 (px)
var MAX_SPD = 130; // 최대 속도 (px/s)
var MAX_FOR = 260; // 최대 힘 (px/s^2)
// ── 새 개체 ──
var boids = [];
function spawnB(b, i) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.ph = rand() * TAU; // 날갯짓 위상 — 개체마다 어긋나게
b.flap = 5.2 + rand() * 3.4; // 날갯짓 속도 (rad/s)
b.sz = 0.8 + rand() * 0.55; // 날개 스케일
b.grp = (i || 0) % 4; // 하위 무리 — 화면 커버리지
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 180 * dt;
b.vy += Math.sin(ga) * 180 * dt;
}
}
function draw(tSec) {
// 1) 저녁하늘 — 매 프레임 세로 그라디언트를 다시 칠한다(잔상 없음). 지평선 파스텔이 아래.
// 하늘 전체가 은은히 숨쉬게 — 그라디언트 정지점을 tSec 로 미세하게 밀어 넓은 면적이 매 프레임 변한다
var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(' +
(C_DUSK[0] + (C_GLOW[0] - C_DUSK[0]) * 0.35 | 0) + ',' +
(C_DUSK[1] + (C_GLOW[1] - C_DUSK[1]) * 0.35 | 0) + ',' +
(C_DUSK[2] + (C_GLOW[2] - C_DUSK[2]) * 0.35 | 0) + ')');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
// 2) 새떼 — 삼각형 날개 2개 실루엣. 진행 방향으로 회전, 날갯짓은 sin 위상
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var flap = Math.sin(tSec * b.flap + b.ph); // -1..1 — 날개 펄럭임
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
var s = b.sz;
// 몸 — 진행축으로 가는 얇은 쐐기
ctx.fillStyle = 'rgb(' + C_SILH[0] + ',' + C_SILH[1] + ',' + C_SILH[2] + ')';
ctx.beginPath();
ctx.moveTo(5 * s, 0);
ctx.lineTo(-3 * s, 1.6 * s);
ctx.lineTo(-3 * s, -1.6 * s);
ctx.closePath();
ctx.fill();
// 날개 앞 — 위상이 양수일 때 위로, 음수일 때 아래로 접힌다
var wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, (-4.5 + wy) * s * 0 + wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
// 날개 뒤 — 앞 날개와 반대 위상(어긋난 두 박자)
var wy2 = -flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(-1.5 * s, 0);
ctx.lineTo(2.5 * s, (-4.5 + wy2) * s * 0 + wy2 - 4.5 * s);
ctx.lineTo(1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) {
prngState = SEED >>> 0;
build();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// BirdSilhouetteWingbeat — vanilla 판(run/315/demo/02-flock-bird-silhouette-wingbeat/index.html) 의 React 포트.
// 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩). 장면은 저녁하늘 — 매 프레임 세로 그라디언트를
// 다시 칠해 잔상을 남기지 않고, 개체는 실루엣 삼각형 날개 2개가 개체마다 어긋난 위상으로 파닥인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./BirdSilhouetteWingbeat.module.scss";
export type BirdSilhouetteWingbeatProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 새 수 — 밀도 축
tone?: 0 | 1; // 실루엣 색 변형 — 0: 잉크, 1: 라일락
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number; ph: number; flap: number; sz: number; grp: number };
type Colors = { dusk: RGB; glow: RGB; bird: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "새떼 실루엣 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 52; // 이웃 반경 (px)
const MAX_SPD = 130; // 최대 속도 (px/s)
const MAX_FOR = 260; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function BirdSilhouetteWingbeat({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#3d4358",
birds = 130,
tone = 0,
}: BirdSilhouetteWingbeatProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const nbRef = useRef(birds) as Box<number>;
const toneRef = useRef(tone) as Box<number>;
const birdsRef = useRef<Bird[]>([]) as Box<Bird[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function silhouetteRGB(col: Colors): RGB {
return toneRef.current ? col.lilac : col.bird;
}
function build(): void {
const rand = makePRNG(0x30c102);
const { w: W, h: H } = sizeRef.current;
const arr: Bird[] = [];
for (let i = 0; i < nbRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
ph: rand() * TAU,
flap: 5.2 + rand() * 3.4,
sz: 0.8 + rand() * 0.55,
grp: i % 4,
});
}
birdsRef.current = arr;
setN(String(nbRef.current));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
function herdGuide(dt: number, tSec: number): void {
const birds = birdsRef.current;
for (let i = 0; i < birds.length; i++) {
const b = birds[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 180 * dt;
b.vy += Math.sin(ga) * 180 * dt;
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const birds = birdsRef.current;
const n = birds.length;
for (let i = 0; i < n; i++) {
const b = birds[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = birds[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += 1.2 * sA[0] + 1.0 * sB[0];
fy += 1.2 * sA[1] + 1.0 * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += 1.6 * sC[0]; fy += 1.6 * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birds = birdsRef.current;
if (!ctx || !col || birds.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const silh = silhouetteRGB(col);
const breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
const horizon = H * (0.72 + 0.05 * breath);
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, `rgb(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]})`);
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), `rgb(${
(col.dusk[0] + (col.glow[0] - col.dusk[0]) * 0.35) | 0},${
(col.dusk[1] + (col.glow[1] - col.dusk[1]) * 0.35) | 0},${
(col.dusk[2] + (col.glow[2] - col.dusk[2]) * 0.35) | 0})`);
sky.addColorStop(1, `rgb(${col.glow[0]},${col.glow[1]},${col.glow[2]})`);
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < birds.length; i++) {
const b = birds[i];
const flap = Math.sin(tSec * b.flap + b.ph);
const ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
const s = b.sz;
ctx.fillStyle = `rgb(${silh[0]},${silh[1]},${silh[2]})`;
ctx.beginPath();
ctx.moveTo(5 * s, 0);
ctx.lineTo(-3 * s, 1.6 * s);
ctx.lineTo(-3 * s, -1.6 * s);
ctx.closePath();
ctx.fill();
const wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
const wy2 = -flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(-1.5 * s, 0);
ctx.lineTo(2.5 * s, wy2 - 4.5 * s);
ctx.lineTo(1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
ctx.restore();
}
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
herdGuide(dt, t);
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nbRef.current = birds;
toneRef.current = tone;
colRef.current = {
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
bird: tokRGB(root, "--flock-bird"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, tone]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>저녁하늘을 가르는 새떼입니다 — 삼각형 날개 두 개가 개체마다 어긋난 위상으로 파닥이고, 진행 방향으로 몸이 기웁니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 새 · 날갯짓 위상</span>
<span className={styles.metaDim}>ctx.rotate · sin 위상 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · 새떼 실루엣 — 날갯짓 위상이 어긋나는 깃털 — 측정 누적면적 39.212% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
날갯짓은 개체마다 다른 sin 위상
b.ph = rand() * TAU; // 날갯짓 위상 — 개체마다 어긋나게
b.flap = 5.2 + rand() * 3.4; // 날갯짓 속도 (rad/s)
b.sz = 0.8 + rand() * 0.55; // 날개 스케일
// 그리기 — 위상이 양수일 때 위로, 음수일 때 아래로 접힌다
var flap = Math.sin(tSec * b.flap + b.ph); // -1..1 — 날개 펄럭임
var ang = Math.atan2(b.vy, b.vx);
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang); // 진행 방향으로 몸을 회전
모든 새가 같은 박자로 파닥이면 기계 문양처럼 죽어 보이기 때문에, flap 속도와 ph 위상을 개체마다 흩어 둔 것이 떼가 한 몸처럼 보이면서도 낱마리가 살아 있는 느낌을 주는 차이입니다.
날개 두 개는 서로 반대 위상으로 접힌다
var wy = flap * 4.2 * s;
ctx.beginPath();
ctx.moveTo(1.5 * s, 0);
ctx.lineTo(-2.5 * s, (-4.5 + wy) * s * 0 + wy - 4.5 * s);
ctx.lineTo(-1.2 * s, -0.4 * s);
ctx.closePath();
ctx.fill();
// 날개 뒤 — 앞 날개와 반대 위상(어긋난 두 박자)
var wy2 = -flap * 4.2 * s;
저녁하늘은 숨쉬는 그라디언트 정지점
var breath = 0.5 + 0.5 * Math.sin(tSec * 0.9);
var horizon = H * (0.72 + 0.05 * breath);
var sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, 'rgb(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ')');
sky.addColorStop(Math.max(0.2, horizon / H - 0.22), 'rgb(...)');
sky.addColorStop(1, 'rgb(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ')');
ctx.fillStyle = sky;
ctx.fillRect(0, 0, W, H);
지평선 파스텔의 정지점을 tSec 로 미세하게 밀어 하늘 전체가 은은히 숨쉬게 하고, 새는 잔상 없이 매 프레임 완전히 다시 그려 01 과 대비되는 또렷한 실루엣을 만듭니다.
03물고기 떼 — 깊이감을 가진 스쿨링
아쿠아리움·해양 콘셉트 소개 화면에 쓰는, 개체마다 가상 깊이 z(0~1) 를 들고 다녀 멀수록 작고 옅게 그려지는 물고기 떼입니다 — 실측 누적면적 44.171% · 강도 평균 96.1 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>심해 무대 위 물고기 떼입니다 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게 그려져 층이 생기고, 유선형 몸과 꼬리가 진행 방향을 따라 눕습니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="물고기 떼 깊이 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">140</b>마리 물고기 · 깊이 z 층</span>
<span class="cd__meta cd__meta--dim">globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 물고기 떼(03). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(군집 스티어링 + 깊이 z 스케일·globalAlpha 층 + 꼬리 sin)가 만든다.
// 무대는 심해(어스름 잉크 + 상부 옅은 하늘빛 광원). 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(01~09 동일).
// ── 편 공통 파스텔 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-sky: #{$subject-sky}; // 심해 상부 광원·물고기 몸 — 전역 토큰 재사용
// 항목 반경 토큰(01~09 공통 — 실사용은 $r-* SCSS 토큰, 표기 참조용)
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
}
.cd__field:focus-visible { outline: 2px solid $subject-sky; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $subject-sky; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 물고기 떼가 자리잡은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-sky: #6ec8ff;
--radius-sm: 4px;
--radius-md: 10px;
--radius-lg: 20px;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
}
.cd__field:focus-visible {
outline: 2px solid #6ec8ff;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #6ec8ff;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 물고기 떼(03). 군집 코어는 01 과 같은 뼈대(분리·정렬·결합 + 토러스 랩).
// 장면이 다르다: 심해 무대(잉크 + 상부 옅은 하늘빛 radial 광원) 위에 개체마다 가상 깊이 z(0=가까움, 1=멀음) 을
// 들고 다닌다 — 멀수록 스케일이 작아지고 globalAlpha 가 옅아져 한 화면에 층이 생긴다.
// 몸은 유선형 타원 + 꼬리 삼각형, 진행 방향으로 ctx.rotate. 결합 계수가 뭉침 강도(변형 축).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_B = 140; // 물고기 수
var W_SEP = 1.6; // 분리 무게
var W_ALI = 1.1; // 정렬 무게
var W_COH = 0.6; // 결합 무게 — 변형 "결합 계수" 축(0.6 → 1.2, 뭉침 강도)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32. 미리보기가 항상 같은 물고기 떼로 흐른다
var SEED = 0x30c103;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_SKY = tokRGB('--flock-sky');
var C_LILA = tokRGB('--flock-lilac');
var C_SILH = C_SKY; // 물고기 몸색 — 심해 위 하늘빛 실루엣
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var STILL_T = 1.6; // reduce 에서 그리는 시각
// ── 시뮬 상수 ──
var R_NEIGH = 48; // 이웃 반경 (px)
var MAX_SPD = 100; // 최대 속도 (px/s)
var MAX_FOR = 260; // 최대 힘 (px/s^2)
// ── 물고기 개체 ──
var boids = [];
function spawnB(b, i) {
b.x = rand() * W;
b.y = rand() * H;
var ang = rand() * TAU;
var spd = MAX_SPD * (0.5 + rand() * 0.5);
b.vx = Math.cos(ang) * spd;
b.vy = Math.sin(ang) * spd;
b.z = rand(); // 가상 깊이 0(가까움)~1(멀음)
b.ph = rand() * TAU; // 안내 위상
b.sz = 0.75 + rand() * 0.6; // 몸 스케일
b.grp = (i || 0) % 4; // 하위 무리 — 화면 커버리지
}
function build() {
boids = [];
for (var i = 0; i < N_B; i++) { var b = {}; spawnB(b, i); boids.push(b); }
nOut.textContent = String(N_B);
}
// 이동 방향으로 가속을 제한하는 스티어 — 목표 속도에서 현재 속도를 뺀 뒤 힘 한계로 자른다
function steer(vx, vy, tvx, tvy, out) {
var sx = tvx - vx, sy = tvy - vy;
var m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = sx / m * MAX_FOR; sy = sy / m * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
var sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt) {
var n = boids.length;
for (var i = 0; i < n; i++) {
var b = boids[i];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (var j = 0; j < n; j++) {
if (j === i) continue;
var o = boids[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
var d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) { // 분리 — 반경 절반 안에서만, 가까울수록 세게
var w = (R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= dx / d * w; sepY -= dy / d * w;
}
}
var fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, (cx / cnt - b.x) * 2.2, (cy / cnt - b.y) * 2.2, sB);
fx += W_ALI * sA[0] + W_COH * sB[0];
fy += W_ALI * sA[1] + W_COH * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += W_SEP * sC[0]; fy += W_SEP * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { var k2 = MAX_SPD * 0.5 / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
// 토러스 랩 — 빠져나가면 반대편에서 등장
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
// 하위 무리 안내 — 4개 무리가 각자 다른 위상으로 무대를 돌아 화면 커버리지를 넓힌다
function herdGuide(dt, tSec) {
for (var i = 0; i < boids.length; i++) {
var b = boids[i];
var ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
function draw(tSec) {
// 1) 심해 무대 — 잉크 바탕 + 상부 옅은 하늘빛 광원(radial). 빛이 느리게 숨쉬어 넓은 면적이 매 프레임 변한다
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
var ly = -H * 0.1;
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
var la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, 'rgba(' + C_SKY[0] + ',' + C_SKY[1] + ',' + C_SKY[2] + ',' + la.toFixed(3) + ')');
lg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
// 2) 물고기 — 깊이 z 순으로 그린다(멀 것부터 → 가까운 것이 위에 덮임)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
for (var k = 0; k < order.length; k++) {
var b = order[k];
var depth = 1 - b.z; // 1 = 가까움, 0 = 멀음
var sc = (0.45 + 0.75 * depth) * b.sz; // 멀수록 작게
var alpha = 0.35 + 0.6 * depth; // 멀수록 옅게 — 층의 핵심
var ang = Math.atan2(b.vy, b.vx);
// 꼬리 흔들림 — 진행축 수직으로 sin
var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
// 몸 — 유선형 타원(앞이 둥글고 뒤로 갈수록 좁아지는 두 타원 겹침)
ctx.fillStyle = 'rgb(' + C_SILH[0] + ',' + C_SILH[1] + ',' + C_SILH[2] + ')';
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
// 꼬리 — 몸 뒤 삼각형, 수직 sin 흔들림
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// 3) 가까운 층 강조 — 라일락 포인트 개체 몇 마리(z 가 가장 작은 5마리)에 눈광점
var near = boids.slice().sort(function (p, q) { return p.z - q.z; });
for (var m = 0; m < Math.min(5, near.length); m++) {
var f2 = near[m];
var sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
var ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = 'rgb(' + C_LILA[0] + ',' + C_LILA[1] + ',' + C_LILA[2] + ')';
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
function fit() {
var r = field.getBoundingClientRect();
var oldW = W, oldH = H;
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
if (oldW !== W || oldH !== H) {
prngState = SEED >>> 0;
build();
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
herdGuide(dt, t);
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T); });
fit();
build();
if (reduce) {
for (var w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// FishSchoolDepth — vanilla 판(run/315/demo/03-flock-fish-school-depth/index.html) 의 React 포트.
// 군집 코어는 01 과 같은 뼈대. 장면은 심해 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게(globalAlpha)
// 그려져 층이 생긴다. 몸은 유선형 타원 + 꼬리 삼각형이 진행 방향으로 눕고 꼬리는 sin 으로 흔들린다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./FishSchoolDepth.module.scss";
export type FishSchoolDepthProps = {
duration?: string;
easing?: string;
color?: string;
fish?: number; // 물고기 수
cohesion?: number; // 결합 계수 — 변형 "뭉침 강도" 축(0.6 → 1.2)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Fish = { x: number; y: number; vx: number; vy: number; z: number; ph: number; sz: number; grp: number };
type Colors = { ink: RGB; mist: RGB; sky: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "물고기 떼 깊이 실험판";
const STILL_T = 1.6; // reduce 에서 그리는 시각
const R_NEIGH = 48; // 이웃 반경 (px)
const MAX_SPD = 100; // 최대 속도 (px/s)
const MAX_FOR = 260; // 최대 힘 (px/s^2)
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function FishSchoolDepth({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#6ec8ff",
fish = 140,
cohesion = 0.6,
}: FishSchoolDepthProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const nfRef = useRef(fish) as Box<number>;
const cohRef = useRef(cohesion) as Box<number>;
const fishRef = useRef<Fish[]>([]) as Box<Fish[]>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(fish));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function build(): void {
const rand = makePRNG(0x30c103);
const { w: W, h: H } = sizeRef.current;
const arr: Fish[] = [];
for (let i = 0; i < nfRef.current; i++) {
const ang = rand() * TAU;
const spd = MAX_SPD * (0.5 + rand() * 0.5);
arr.push({
x: rand() * W,
y: rand() * H,
vx: Math.cos(ang) * spd,
vy: Math.sin(ang) * spd,
z: rand(),
ph: rand() * TAU,
sz: 0.75 + rand() * 0.6,
grp: i % 4,
});
}
fishRef.current = arr;
setN(String(nfRef.current));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function steer(vx: number, vy: number, tvx: number, tvy: number, out: number[]): void {
let sx = tvx - vx, sy = tvy - vy;
const m = Math.hypot(sx, sy);
if (m > MAX_FOR) { sx = (sx / m) * MAX_FOR; sy = (sy / m) * MAX_FOR; }
out[0] = sx; out[1] = sy;
}
function herdGuide(dt: number, tSec: number): void {
const fish = fishRef.current;
for (let i = 0; i < fish.length; i++) {
const b = fish[i];
const ga = tSec * (1.3 + b.grp * 0.3) + b.ph;
b.vx += Math.cos(ga) * 150 * dt;
b.vy += Math.sin(ga) * 150 * dt;
}
}
const sA = [0, 0], sB = [0, 0], sC = [0, 0];
function step(dt: number): void {
const fish = fishRef.current;
const n = fish.length;
for (let i = 0; i < n; i++) {
const b = fish[i];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cx = 0, cy = 0, cnt = 0;
for (let j = 0; j < n; j++) {
if (j === i) continue;
const o = fish[j];
const dx = o.x - b.x, dy = o.y - b.y;
const d2 = dx * dx + dy * dy;
if (d2 > R_NEIGH * R_NEIGH || d2 === 0) continue;
const d = Math.sqrt(d2);
cnt++;
aliX += o.vx; aliY += o.vy;
cx += o.x; cy += o.y;
if (d < R_NEIGH * 0.5) {
const w = ((R_NEIGH * 0.5 - d) / (R_NEIGH * 0.5)) / (d / (R_NEIGH * 0.5) + 0.18);
sepX -= (dx / d) * w; sepY -= (dy / d) * w;
}
}
let fx = 0, fy = 0;
if (cnt > 0) {
steer(b.vx, b.vy, aliX / cnt, aliY / cnt, sA);
steer(b.vx, b.vy, ((cx / cnt) - b.x) * 2.2, ((cy / cnt) - b.y) * 2.2, sB);
fx += 1.1 * sA[0] + cohRef.current * sB[0];
fy += 1.1 * sA[1] + cohRef.current * sB[1];
}
if (sepX !== 0 || sepY !== 0) {
steer(b.vx, b.vy, b.vx + sepX * 60, b.vy + sepY * 60, sC);
fx += 1.6 * sC[0]; fy += 1.6 * sC[1];
}
b.vx += fx * dt; b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.5) { const k2 = (MAX_SPD * 0.5) / (sp || 1); b.vx *= k2; b.vy *= k2; }
b.x += b.vx * dt; b.y += b.vy * dt;
const { w: W, h: H } = sizeRef.current;
if (b.x < -8) b.x += W + 16; else if (b.x > W + 8) b.x -= W + 16;
if (b.y < -8) b.y += H + 16; else if (b.y > H + 8) b.y -= H + 16;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const fish = fishRef.current;
if (!ctx || !col || fish.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
const lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
const ly = -H * 0.1;
const lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
const lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
const la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, `rgba(${col.sky[0]},${col.sky[1]},${col.sky[2]},${la.toFixed(3)})`);
lg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = lg;
ctx.fillRect(0, 0, W, H);
const order = fish.slice().sort((p, q) => q.z - p.z);
for (let k = 0; k < order.length; k++) {
const b = order[k];
const depth = 1 - b.z;
const sc = (0.45 + 0.75 * depth) * b.sz;
const alpha = 0.35 + 0.6 * depth;
const ang = Math.atan2(b.vy, b.vx);
const tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
ctx.fillStyle = `rgb(${col.sky[0]},${col.sky[1]},${col.sky[2]})`;
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU);
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4);
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
const near = fish.slice().sort((p, q) => p.z - q.z);
for (let m = 0; m < Math.min(5, near.length); m++) {
const f2 = near[m];
const sc2 = (0.45 + 0.75 * (1 - f2.z)) * f2.sz;
const ang2 = Math.atan2(f2.vy, f2.vx);
ctx.save();
ctx.translate(f2.x, f2.y);
ctx.rotate(ang2);
ctx.fillStyle = `rgb(${col.lilac[0]},${col.lilac[1]},${col.lilac[2]})`;
ctx.beginPath();
ctx.arc(4.2 * sc2, -0.8 * sc2, 0.9 * sc2, 0, TAU);
ctx.fill();
ctx.restore();
}
}
let t0 = -1;
let prev = 0;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
const dt = Math.min(0.05, (now - (prev || now)) / 1000);
prev = now;
herdGuide(dt, t);
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
nfRef.current = fish;
cohRef.current = cohesion;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
sky: tokRGB(root, "--flock-sky"),
lilac: tokRGB(root, "--flock-lilac"),
};
fit();
build();
function onResize(): void {
fit();
build();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let w = 0; w < 96; w++) { herdGuide(1 / 60, w / 60); step(1 / 60); }
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fish, cohesion]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 24a16 16 0 0 1 16 16v8a80.06 80.06 0 0 1 63.94 63.94h8a16 16 0 0 1 0 32h-8A80.06 80.06 0 0 1 144 207.94v8a16 16 0 0 1-32 0v-8A80.06 80.06 0 0 1 48.06 144h-8a16 16 0 0 1 0-32h8A80.06 80.06 0 0 1 112 48.06v-8A16 16 0 0 1 128 24m-16 57.35V96a16 16 0 0 0 32 0V81.35A48.09 48.09 0 0 0 81.35 112H96a16 16 0 0 0 0-32h14.65M174.65 144H160a16 16 0 0 0 0 32h14.65A48.09 48.09 0 0 0 144 174.65V160a16 16 0 0 0-32 0v14.65A48.09 48.09 0 0 0 81.35 144" opacity=".2"/><path d="M232 128a104 104 1 1 1-104-104a104 104 0 0 1 104 104m-32 0a8 8 0 0 0-8-8h-9.34A56.1 56.1 0 0 0 136 77.34V68a8 8 0 0 0-16 0v9.34A56.1 56.1 0 0 0 77.34 120H68a8 8 0 0 0 0 16h9.34A56.1 56.1 0 0 0 120 179.66V188a8 8 0 0 0 16 0v-8.34A56.1 56.1 0 0 0 178.66 136H192a8 8 0 0 0 8-8m-40 0a32 32 0 1 1-32-32a32 32 0 0 1 32 32m-16 0a16 16 0 1 0-16 16a16 16 0 0 0 16-16"/></g></svg>
<span>심해 무대 위 물고기 떼입니다 — 개체마다 가상 깊이 z(0~1) 를 가져 멀수록 작고 옅게 그려져 층이 생기고, 유선형 몸과 꼬리가 진행 방향을 따라 눕습니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 물고기 · 깊이 z 층</span>
<span className={styles.metaDim}>globalAlpha · ellipse · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · 물고기 떼 — 깊이감을 가진 스쿨링 — 측정 누적면적 44.171% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
가상 깊이 z 로 크기와 알파를 정한다
b.z = rand(); // 가상 깊이 0(가까움)~1(멀음)
// 그리기 — 깊이 z 순으로 그린다(멀 것부터 → 가까운 것이 위에 덮임)
var order = boids.slice();
order.sort(function (p, q) { return q.z - p.z; });
var depth = 1 - b.z; // 1 = 가까움, 0 = 멀음
var sc = (0.45 + 0.75 * depth) * b.sz; // 멀수록 작게
var alpha = 0.35 + 0.6 * depth; // 멀수록 옅게 — 층의 핵심
z 값 하나가 스케일과 globalAlpha 를 함께 결정하고, 매 프레임 z 순으로 정렬해 멀리 있는 물고기부터 그리면 가까운 개체가 자연스럽게 위에 덮이는 깊이 질서가 생깁니다.
유선형 몸과 꼬리는 진행축으로 눕는다
var ang = Math.atan2(b.vy, b.vx);
// 꼬리 흔들림 — 진행축 수직으로 sin
var tail = Math.sin(tSec * (6 + 4 * depth) + b.ph) * 2.2 * sc;
ctx.save();
ctx.translate(b.x, b.y);
ctx.rotate(ang);
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.ellipse(0, 0, 7.5 * sc, 2.8 * sc, 0, 0, TAU); // 몸 — 유선형 타원
ctx.fill();
ctx.beginPath();
ctx.moveTo(-6 * sc, tail * 0.4); // 꼬리 — 뒤 삼각형
ctx.lineTo(-11 * sc, tail - 3.4 * sc);
ctx.lineTo(-11 * sc, tail + 3.4 * sc);
ctx.closePath();
ctx.fill();
상부 광원이 느리게 숨쉬는 심해 무대
var breathe = 0.5 + 0.5 * Math.sin(tSec * 0.8);
var lx = W * 0.5 + Math.sin(tSec * 0.3) * W * 0.1;
var ly = -H * 0.1;
var lr = Math.max(W, H) * (0.9 + 0.12 * breathe);
var lg = ctx.createRadialGradient(lx, ly, 6, lx, ly, lr);
var la = 0.16 + 0.05 * breathe;
lg.addColorStop(0, 'rgba(' + C_SKY[0] + ',' + C_SKY[1] + ',' + C_SKY[2] + ',' + la.toFixed(3) + ')');
lg.addColorStop(1, 'rgba(0,0,0,0)');
수면에서 스며드는 빛을 화면 밖 상단의 방사 그라디언트로 치고 반경과 알파가 0.8 rad/s 로 숨쉬며, z 가 가장 작은(가까운) 5마리에는 라일락 눈광점을 찍어 시선의 닻을 둡니다.
04천적 등장 — 도망 벡터가 쪼개는 떼
인터랙티브 히어로·마우스 반응 이벤트에 쓰는, 포인터를 천적으로 삼아 반경 안 개체에 강한 도망 벡터를 더해 떼가 갈라졌다 다시 봉합되는 판입니다 — 실측 누적면적 40.232% · 강도 평균 114.6 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80" opacity=".2"/><path d="M225.86 102.82c-3.77-13.68-11-26.16-21-36.21c-19.55-19.54-45.9-27.06-71.06-21.24a8 8 0 0 0-4.19 13.2l31 34.4l-11.6 11.6l-34.4-31a8 8 0 0 0-13.2 4.19c-5.82 25.16 1.7 51.51 21.24 71.06c14.36 14.36 33.15 22.06 52.4 22.94l-30 30a44 44 0 1 0 11.31 11.31l30-30c.88 19.25 8.58 38 22.94 52.4c19.55 19.54 45.9 27.06 71.06 21.24a8 8 0 0 0 4.19-13.2l-31-34.4l11.6-11.6l34.4 31a8 8 0 0 0 13.2-4.19c5.31-22.96-.44-46.94-16.79-65.5M120 216a28 28 0 1 1-28-28a28 28 0 0 1 28 28"/></g></svg>
<span>포인터를 천적으로 — 반경 안 개체는 거리에 반비례한 힘으로 도망치며 무리가 갈라졌다 다시 뭉칩니다. 포인터가 없으면 가상 천적이 스스로 순회합니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="새 떼 천적 회피 실험판. 화살표 키로 천적을 움직일 수 있습니다" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">160</b>개체 · 도망 반경 <b class="cd__val" id="cdR">90</b>px</span>
<span class="cd__meta cd__meta--dim">separation · alignment · cohesion · flee</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천적 등장(04). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(보이드 3규칙 + 천적 도망 벡터)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(04~06 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 천적 등장(04). 보이드 3규칙(분리·정렬·결합)에 네 번째 힘 "도망" 을 더한다.
// 포인터(또는 시드 고정 가상 천적)가 반경 안 개체마다 거리에 반비례하는 회피 벡터를 주입해
// 무리가 순간적으로 갈라졌다가, 천적이 멀어지면 결합력이 다시 봉합한다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N_BIRD = 160; // 개체 수
var FLEE_R = 90; // 천적 도망 반경(px) — 변형 "천적 반경" 축(90 → 160)
var FLEE_GAIN = 1.0; // 도망 힘 배율 변형
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
var rOut = document.getElementById('cdR');
nOut.textContent = String(N_BIRD);
rOut.textContent = String(FLEE_R);
// 시드 고정 PRNG — mulberry32
var SEED = 0x0b0104;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_GLOW = tokRGB('--flock-glow');
var C_DUSK = tokRGB('--flock-dusk');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var predX = 0.5, predY = 0.5; // 0..1 정규화 좌표
var manual = false;
// ── 보이드 파라미터 ──
var PERCEP = 40; // 이웃 지각 반경
var SEP_R = 18; // 분리 반경
var MAX_SPD = 96; // px/s
var MAX_FRC = 260; // px/s^2
var W_SEP = 1.7, W_ALI = 1.0, W_COH = 0.9;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
birds.push({
x: rand() * 300, y: rand() * 140,
vx: (rand() - 0.5) * MAX_SPD, vy: (rand() - 0.5) * MAX_SPD
});
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function step(dt) {
var predPx = predX * W, predPy = predY * H;
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx = 0, cy = 0;
var sepN = 0, aliN = 0, cohN = 0;
for (var j = 0; j < birds.length; j++) {
if (i === j) continue;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
// 토러스 최단거리(랩된 화면에서 가까운 쪽으로)
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx += dx; cy += dy; cohN++;
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx / cohN) * 0.04 * W_COH; fy += (cy / cohN) * 0.04 * W_COH; }
// ── 도망 — 천적 반경 안이면 거리에 반비례한 힘(가까울수록 급격히 강함) ──
var pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
var t = (FLEE_R - pd) / FLEE_R; // 0..1 — 가까울수록 1
var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN;
fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.28) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.28; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.28; }
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(tSec) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
// 지평선 은은한 슬레이트 띠 — 무대가 완전히 죽지 않게
var hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.22)');
hg.addColorStop(1, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
// 개체 — mist 톤 사각 점, 속도 방향으로 살짝 늘여 흐름감
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sp = Math.hypot(b.vx, b.vy);
var a = 0.42 + Math.min(0.5, sp / MAX_SPD * 0.5);
ctx.fillStyle = 'rgba(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ',' + a.toFixed(3) + ')';
var r = 2.1 + Math.min(1.4, sp / MAX_SPD * 1.4);
ctx.fillRect(b.x - r, b.y - r, r * 2, r * 2);
}
// 천적 링 — glow 톤. 숨쉬듯 반경이 진동한다
ctx.globalCompositeOperation = 'source-over';
var ringR = FLEE_R * (0.34 + 0.05 * Math.sin(tSec * 3.1));
var predPx = predX * W, predPy = predY * H;
ctx.strokeStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.55)';
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(predPx, predPy, ringR, 0, TAU);
ctx.stroke();
ctx.fillStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.85)';
ctx.beginPath();
ctx.arc(predPx, predPy, 3, 0, TAU);
ctx.fill();
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
if (!manual) {
// 가상 천적 — 시드 고정 Lissajous 순회(포인터 없어도 장면이 산다)
var ph = t * TAU / 5.4;
predX = 0.5 + 0.4 * Math.sin(ph);
predY = 0.5 + 0.34 * Math.sin(ph * 1.7 + 1.1);
}
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
predX = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
predY = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0.016); draw(1.2); }
});
field.addEventListener('pointerleave', function () {
if (manual) { predX = -1; predY = -1; } // 화면 밖으로 — 도망 힘 사실상 0
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); predX = 0.5; predY = 0.5; }
predX = Math.max(0, Math.min(1, predX + d[0]));
predY = Math.max(0, Math.min(1, predY + d[1]));
if (reduce) { step(0.016); draw(1.2); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.2); });
fit();
if (reduce) {
// reduce — 초기 대형이 흩어지기 전, 도망이 시작된 한 장면만 고정해 그린다
predX = 0.5; predY = 0.42;
for (var s = 0; s < 40; s++) step(0.033);
draw(1.2);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// PredatorAttack — vanilla 판(run/315/demo/04-flock-predator-attack/index.html) 의 React 포트.
// 보이드 3규칙(분리·정렬·결합)에 천적 도망 벡터를 더해 무리가 갈라졌다 재봉합한다.
// 미리보기는 시드 고정 Lissajous 가상 천적.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./PredatorAttack.module.scss";
export type PredatorAttackProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수
fleeRadius?: number; // 천적 도망 반경(px) — 변형 "천적 반경" 축(90 → 160)
fleeGain?: number; // 도망 힘 배율
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number };
type Colors = { ink: RGB; mist: RGB; glow: RGB; dusk: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "새 떼 천적 회피 실험판. 화살표 키로 천적을 움직일 수 있습니다";
const STILL_T = 1.2;
const PERCEP = 40;
const SEP_R = 18;
const MAX_SPD = 96;
const MAX_FRC = 260;
const W_SEP = 1.7;
const W_ALI = 1.0;
const W_COH = 0.9;
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function PredatorAttack({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 160,
fleeRadius = 90,
fleeGain = 1.0,
}: PredatorAttackProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const fleeRRef = useRef(fleeRadius) as Box<number>;
const fleeGRef = useRef(fleeGain) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const predXRef = useRef(0.5) as Box<number>;
const predYRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [rOut, setROut] = useState(String(fleeRadius));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0104);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
arr.push({
x: rand() * 300,
y: rand() * 140,
vx: (rand() - 0.5) * MAX_SPD,
vy: (rand() - 0.5) * MAX_SPD,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function step(dt: number): void {
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
const predPx = predXRef.current * W;
const predPy = predYRef.current * H;
const FLEE_R = fleeRRef.current;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
for (let j = 0; j < birdsArr.length; j++) {
if (i === j) continue;
const o = birdsArr[j];
let dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.04 * W_COH; fy += (cy0 / cohN) * 0.04 * W_COH; }
// 도망 — 천적 반경 안이면 거리에 반비례한 힘(가까울수록 급격히 강함)
let pdx = b.x - predPx, pdy = b.y - predPy;
if (pdx > W / 2) pdx -= W; else if (pdx < -W / 2) pdx += W;
if (pdy > H / 2) pdy -= H; else if (pdy < -H / 2) pdy += H;
const pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
const t = (FLEE_R - pd) / FLEE_R;
const mag = t * t * MAX_FRC * 3.2 * fleeGRef.current;
fx += (pdx / pd) * mag;
fy += (pdy / pd) * mag;
}
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.28) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.28; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.28; }
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.22)`);
hg.addColorStop(1, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0)`);
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const sp = Math.hypot(b.vx, b.vy);
const a = 0.42 + Math.min(0.5, (sp / MAX_SPD) * 0.5);
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},${a.toFixed(3)})`;
const r = 2.1 + Math.min(1.4, (sp / MAX_SPD) * 1.4);
ctx.fillRect(b.x - r, b.y - r, r * 2, r * 2);
}
ctx.globalCompositeOperation = "source-over";
const ringR = fleeRRef.current * (0.34 + 0.05 * Math.sin(tSec * 3.1));
const predPx = predXRef.current * W;
const predPy = predYRef.current * H;
ctx.strokeStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.55)`;
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.arc(predPx, predPy, ringR, 0, TAU);
ctx.stroke();
ctx.fillStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.85)`;
ctx.beginPath();
ctx.arc(predPx, predPy, 3, 0, TAU);
ctx.fill();
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 5.4;
predXRef.current = 0.5 + 0.4 * Math.sin(ph);
predYRef.current = 0.5 + 0.34 * Math.sin(ph * 1.7 + 1.1);
}
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
predXRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
predYRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) { step(0.016); draw(STILL_T); }
}
function onLeave(): void {
if (manualRef.current) { predXRef.current = -1; predYRef.current = -1; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; predXRef.current = 0.5; predYRef.current = 0.5; }
predXRef.current = Math.max(0, Math.min(1, predXRef.current + d[0]));
predYRef.current = Math.max(0, Math.min(1, predYRef.current + d[1]));
if (reduced()) { step(0.016); draw(STILL_T); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
fleeRRef.current = fleeRadius;
fleeGRef.current = fleeGain;
setROut(String(fleeRadius));
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
mist: tokRGB(root, "--flock-mist"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
predXRef.current = 0.5;
predYRef.current = 0.42;
for (let s = 0; s < 40; s++) step(0.033);
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, fleeRadius, fleeGain]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M128 32a96 96 0 1 0 96 96a96.11 96.11 0 0 0-96-96m0 176a80 80 0 1 1 80-80a80.09 80.09 0 0 1-80 80" opacity=".2"/><path d="M225.86 102.82c-3.77-13.68-11-26.16-21-36.21c-19.55-19.54-45.9-27.06-71.06-21.24a8 8 0 0 0-4.19 13.2l31 34.4l-11.6 11.6l-34.4-31a8 8 0 0 0-13.2 4.19c-5.82 25.16 1.7 51.51 21.24 71.06c14.36 14.36 33.15 22.06 52.4 22.94l-30 30a44 44 0 1 0 11.31 11.31l30-30c.88 19.25 8.58 38 22.94 52.4c19.55 19.54 45.9 27.06 71.06 21.24a8 8 0 0 0 4.19-13.2l-31-34.4l11.6-11.6l34.4 31a8 8 0 0 0 13.2-4.19c5.31-22.96-.44-46.94-16.79-65.5M120 216a28 28 0 1 1-28-28a28 28 0 0 1 28 28"/></g></svg>
<span>포인터를 천적으로 — 반경 안 개체는 거리에 반비례한 힘으로 도망치며 무리가 갈라졌다 다시 뭉칩니다. 포인터가 없으면 가상 천적이 스스로 순회합니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 도망 반경 <b className={styles.val}>{rOut}</b>px</span>
<span className={styles.metaDim}>separation · alignment · cohesion · flee</span>
</div>
</div>
</main>
);
}04 · 천적 등장 — 도망 벡터가 쪼개는 떼 — 측정 누적면적 40.232% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
네 번째 힘 도망은 거리의 제곱으로 세진다
var FLEE_R = 90; // 천적 도망 반경(px)
var pdx = b.x - predPx, pdy = b.y - predPy;
var pd = Math.hypot(pdx, pdy);
if (pd < FLEE_R && pd > 0.0001) {
var t = (FLEE_R - pd) / FLEE_R; // 0..1 — 가까울수록 1
var mag = t * t * MAX_FRC * 3.2 * FLEE_GAIN; // 제곱으로 급격히 강해짐
fx += (pdx / pd) * mag; fy += (pdy / pd) * mag;
}
t * t 제곱 곡선이 핵심이라 반경 끝에서는 거의 0이던 힘이 중심에 가까워질수록 급격히 커져, 천적 바로 앞의 개체는 튕기듯 피하고 반경 가장자리의 개체는 살짝 비켜섭니다.
갈라진 틈은 결합력이 다시 봉합한다
var PERCEP = 40; // 이웃 지각 반경
var SEP_R = 18; // 분리 반경
var W_SEP = 1.7, W_ALI = 1.0, W_COH = 0.9;
if (cohN) { fx += (cx / cohN) * 0.04 * W_COH; fy += (cy / cohN) * 0.04 * W_COH; }
토러스 최단거리로 화면 끝의 이웃도 잡는다
var dx = o.x - b.x, dy = o.y - b.y;
// 토러스 최단거리(랩된 화면에서 가까운 쪽으로)
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
좌표를 반영 대신 최단거리로 보정해 왼쪽 끝의 개체가 오른쪽 끝의 이웃을 실제 가까운 이웃으로 보게 하고, 도망 벡터도 같은 보정을 거쳐 화면 반대편 천적을 피해 갑니다.
05천 마리의 셈 — 공간 해시로 이웃 찾기
대규모 파티클 데모·성능 학습 자료로 쓰는, 화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사해 개체 1000마리의 이웃 탐색을 전수 비교에서 격자 조회로 떨어뜨리는 판입니다 — 실측 누적면적 48.21% · 강도 평균 71.3 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사합니다 — 개체 1000마리라도 전수 비교(1,000×1,000) 없이 무리가 만들어집니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="공간 해시 그리드 보이드 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">1000</b>개체 · 프레임당 이웃 조사 <b class="cd__val" id="cdChecks">0</b>회</span>
<span class="cd__meta cd__meta--dim">Map 해시 · 3x3 셀 조사 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 천 마리의 셈(05). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(공간 해시 3x3 셀 조사 보이드)가 만든다.
// 셀 경계는 화면에 그리지 않는다(측정 노이즈).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-sky;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — 04~06 동일) ──
$flock-ink: #1b1e26;
$flock-dusk: #3d4358;
$flock-glow: #f4cfb2;
$flock-bird: #23262f;
$flock-lilac: #b9a0ff;
$flock-mist: #d8dce6;
.cd {
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 천 마리의 셈(05). 전수 비교(O(n^2))는 1000개체에서 감당이 안 된다 — 화면을 48px 셀로 나눠
// Map<"cx,cy", 인덱스[]> 로 등록하고, 매 개체는 자기 셀 + 인접 8칸(3x3)만 조사한다.
// 조사 범위가 줄어도 무리 행동(분리·정렬·결합)은 그대로 만들어진다는 것이 이 데모의 요점.
(function () {
// ── 변형 상수 블록 ──
var N_BIRD = 1000; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
var CELL = 48; // 해시 셀 한 변(px)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
var chOut = document.getElementById('cdChecks');
nOut.textContent = String(N_BIRD);
var SEED = 0x0b0105;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_DUSK = tokRGB('--flock-dusk');
var C_LILAC = tokRGB('--flock-lilac');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
var PERCEP = 30, SEP_R = 12;
var MAX_SPD = 78, MAX_FRC = 220;
var W_SEP = 1.6, W_ALI = 1.0, W_COH = 0.85;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
birds.push({
x: rand() * 400, y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD, vy: (rand() - 0.5) * MAX_SPD
});
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
grid.clear();
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
var k = cellKey(cx, cy);
var arr = grid.get(k);
if (!arr) { arr = []; grid.set(k, arr); }
arr.push(i);
}
}
var checksThisFrame = 0;
function step(dt) {
buildGrid();
checksThisFrame = 0;
var predPx = (px01 - 0.5) * 2, predPy = (py01 - 0.5) * 2; // 포인터 -1..1 — 약한 조류(바람) 신호로만 사용
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
var sepN = 0, aliN = 0, cohN = 0;
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (var a = 0; a < arr.length; a++) {
var j = arr[a];
if (j === i) continue;
checksThisFrame++;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
// 약한 조류(포인터/가상) — 무리 전체가 한쪽으로 서서히 쏠리는 신호만
fx += predPx * 8; fy += predPy * 8;
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.3; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.3; }
b.x += b.vx * dt; b.y += b.vy * dt;
}
// 위치 갱신 뒤 랩(다음 buildGrid 가 이 값으로 다시 짓는다)
for (var w = 0; w < birds.length; w++) {
var bb = birds[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
}
function draw(tSec) {
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgb(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ')';
ctx.fillRect(0, 0, W, H);
var hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.2)');
hg.addColorStop(1, 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0)');
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = 'lighter';
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sp = Math.hypot(b.vx, b.vy);
var flash = (i % 7 === (((tSec * 6) | 0) % 7)) ? 0.22 : 0; // 지역 밀도 대신 순환 반짝임 — 정적 카드가 아니게
var a = 0.34 + Math.min(0.4, sp / MAX_SPD * 0.4) + flash;
var col = (i % 23 === 0) ? C_LILAC : C_MIST;
ctx.fillStyle = 'rgba(' + col[0] + ',' + col[1] + ',' + col[2] + ',' + a.toFixed(3) + ')';
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
chOut.textContent = String(checksThisFrame);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
if (!manual) {
var ph = t * TAU / 6.2;
px01 = 0.5 + 0.4 * Math.sin(ph);
py01 = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) { step(0.016); draw(1.2); }
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) { step(0.016); draw(1.2); }
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(1.2); });
fit();
if (reduce) {
for (var s = 0; s < 30; s++) step(0.033);
draw(1.2);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SpatialHashGrid — vanilla 판(run/315/demo/05-flock-spatial-hash-grid/index.html) 의 React 포트.
// 화면을 48px 셀로 나눠 Map<"cx,cy", 인덱스[]> 로 등록하고 자기+인접 8칸만 조사한다 —
// 1000개체를 전수 비교 없이 무리로 만든다. 미리보기는 시드 고정 Lissajous 조류 신호.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SpatialHashGrid.module.scss";
export type SpatialHashGridProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수 — 변형 "개체 수" 축(1000 → 2000)
cell?: number; // 해시 셀 한 변(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type PointerEvt = { clientX: number; clientY: number };
type KeyEvt = { key: string; preventDefault(): void };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number };
type Colors = { ink: RGB; mist: RGB; dusk: RGB; lilac: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "공간 해시 그리드 보이드 실험판";
const STILL_T = 1.2;
const PERCEP = 30;
const SEP_R = 12;
const MAX_SPD = 78;
const MAX_FRC = 220;
const W_SEP = 1.6;
const W_ALI = 1.0;
const W_COH = 0.85;
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function SpatialHashGrid({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 1000,
cell = 48,
}: SpatialHashGridProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const cellRef = useRef(cell) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const gridRef = useRef(new Map<string, number[]>()) as Box<Map<string, number[]>>;
const checksRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [checks, setChecks] = useState("0");
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function reduced(): boolean {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0105);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
arr.push({
x: rand() * 400,
y: rand() * 180,
vx: (rand() - 0.5) * MAX_SPD,
vy: (rand() - 0.5) * MAX_SPD,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function cellKey(cx: number, cy: number): string {
return cx + "," + cy;
}
// ── 공간 해시 — 매 프레임 다시 짓는다(개체가 매 프레임 옮겨가므로) ──
function buildGrid(): void {
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const CELL = cellRef.current;
grid.clear();
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const cx = Math.floor(b.x / CELL);
const cy = Math.floor(b.y / CELL);
const k = cellKey(cx, cy);
const arr = grid.get(k);
if (arr) arr.push(i);
else grid.set(k, [i]);
}
}
function step(dt: number): void {
buildGrid();
const grid = gridRef.current;
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
const CELL = cellRef.current;
let checks = 0;
const driftX = (pxRef.current - 0.5) * 2;
const driftY = (pyRef.current - 0.5) * 2;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
const gcx = Math.floor(b.x / CELL);
const gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (let oy = -1; oy <= 1; oy++) {
for (let ox = -1; ox <= 1; ox++) {
const arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (let a = 0; a < arr.length; a++) {
const j = arr[a];
if (j === i) continue;
checks++;
const o = birdsArr[j];
const dx = o.x - b.x;
const dy = o.y - b.y;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.05 * W_COH; fy += (cy0 / cohN) * 0.05 * W_COH; }
fx += driftX * 8;
fy += driftY * 8;
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.3; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.3; }
b.x += b.vx * dt;
b.y += b.vy * dt;
}
for (let w = 0; w < birdsArr.length; w++) {
const bb = birdsArr[w];
if (bb.x < 0) bb.x += W; else if (bb.x > W) bb.x -= W;
if (bb.y < 0) bb.y += H; else if (bb.y > H) bb.y -= H;
}
checksRef.current = checks;
}
function draw(tSec: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgb(${col.ink[0]},${col.ink[1]},${col.ink[2]})`;
ctx.fillRect(0, 0, W, H);
const hg = ctx.createLinearGradient(0, 0, 0, H);
hg.addColorStop(0, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.2)`);
hg.addColorStop(1, `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0)`);
ctx.fillStyle = hg;
ctx.fillRect(0, 0, W, H * 0.5);
ctx.globalCompositeOperation = "lighter";
const slot = ((tSec * 6) | 0) % 7;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
const sp = Math.hypot(b.vx, b.vy);
const flash = i % 7 === slot ? 0.22 : 0;
const a = 0.34 + Math.min(0.4, (sp / MAX_SPD) * 0.4) + flash;
const col2 = i % 23 === 0 ? col.lilac : col.mist;
ctx.fillStyle = `rgba(${col2[0]},${col2[1]},${col2[2]},${a.toFixed(3)})`;
ctx.fillRect(b.x - 1, b.y - 1, 2, 2);
}
setChecks(String(checksRef.current));
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 6.2;
pxRef.current = 0.5 + 0.4 * Math.sin(ph);
pyRef.current = 0.5 + 0.32 * Math.sin(ph * 1.4 + 0.6);
}
step(dt);
draw(t);
rafRef.current = requestAnimationFrame(frame);
}
function onPoint(e: PointerEvt): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduced()) { step(0.016); draw(STILL_T); }
}
function onLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: KeyEvt): void {
const deltas: Record<string, [number, number]> = {
ArrowLeft: [-0.06, 0], ArrowRight: [0.06, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
const d = deltas[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduced()) { step(0.016); draw(STILL_T); }
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
cellRef.current = cell;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
lilac: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (reduced()) draw(STILL_T);
}
window.addEventListener("resize", onResize);
if (reduced()) {
for (let s = 0; s < 30; s++) step(0.033);
draw(STILL_T);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, cell]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M40 40h64v64H40z" opacity=".2"/><path d="M96 32H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56H48V48h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-8 56h-40V48h40Zm-72 40H40a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56H48v-40h40Zm88-56h-56a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h56a8 8 0 0 0 8-8v-56a8 8 0 0 0-8-8m-8 56h-40v-40h40Z"/></g></svg>
<span>화면을 48px 셀로 나눠 자기 셀과 인접 8칸만 조사합니다 — 개체 1000마리라도 전수 비교(1,000×1,000) 없이 무리가 만들어집니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPoint}
onPointerLeave={onLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 프레임당 이웃 조사 <b className={styles.val}>{checks}</b>회</span>
<span className={styles.metaDim}>Map 해시 · 3x3 셀 조사 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · 천 마리의 셈 — 공간 해시로 이웃 찾기 — 측정 누적면적 48.21% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
매 프레임 Map 해시에 셀을 다시 짠다
var N_BIRD = 1000; // 개체 수
var CELL = 48; // 해시 셀 한 변(px)
var grid = new Map();
function cellKey(cx, cy) { return cx + ',' + cy; }
function buildGrid() {
grid.clear();
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var cx = Math.floor(b.x / CELL), cy = Math.floor(b.y / CELL);
var k = cellKey(cx, cy);
var arr = grid.get(k);
if (!arr) { arr = []; grid.set(k, arr); }
arr.push(i);
}
}
개체가 매 프레임 옮겨가므로 해시도 매 프레임 다시 지으며, "cx,cy" 문자열 키 하나에 그 셀의 인덱스 배열을 담는 것이 전부라 자료구조 부담이 없습니다.
자기 셀과 인접 8칸만 조사한다
var gcx = Math.floor(b.x / CELL), gcy = Math.floor(b.y / CELL);
// 자기 셀 + 인접 8칸(3x3) 만 조사 — 이것이 이 데모의 핵심
for (var oy = -1; oy <= 1; oy++) {
for (var ox = -1; ox <= 1; ox++) {
var arr = grid.get(cellKey(gcx + ox, gcy + oy));
if (!arr) continue;
for (var a = 0; a < arr.length; a++) {
var j = arr[a];
if (j === i) continue;
checksThisFrame++;
// 여기서 거리 재판정 — PERCEP 30 안이면 이웃
}
}
}
천 마리가 남는 이유
전수 비교는 1000마리에서 프레임당 백만 번의 거리 계산을 하지만, 같은 장면을 3×3 격자 조회로 바꾸면 조사는 개체당 실제 몇십 회로 떨어지고 화면의 checksThisFrame 이 그 차이를 숫자로 보여 줍니다.
06머뮤레이션 — 흐름선을 남기는 검은 구름
자연 다큐멘터리 무드의 히어로 배경에 쓰는, 지나간 자리를 반투명으로 덮어 잔상을 남기고 컬 노이즈 바람을 떼 전체에 불어 갈대밭 위 오르내리는 검은 물결을 만드는 판입니다 — 실측 누적면적 51.635%(아홉 판 중 가장 넓습니다) · 강도 평균 58.2 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 132c22-6 38 6 56 4s34-12 52-8 36 16 54 12 34-14 46-18l-2-10c-14 4-32 16-46 20s-34-8-52-12-36 8-54 10-34-10-52-4Z" opacity=".25"/><path d="M230.53 28.51a8 8 0 0 0-11.31 0L196.3 51.43A108 108 0 0 0 45.14 196.06l-22.8 22.79a8 8 0 1 0 11.31 11.31l23.24-23.23A108 108 0 0 0 202 60.31l23.17-23.18a8 8 0 0 0 5.36-8.62M38 128a90 90 0 0 1 148-68.66L59.34 186A89.75 89.75 0 0 1 38 128m90 90a89.75 89.75 0 0 1-58-21.06L196.66 70A90 90 0 0 1 128 218"/></g></svg>
<span>매 프레임 배경을 반투명하게 덮어 개체 궤적이 흐름선으로 남습니다 — 순수 사인 중첩 컬 노이즈 바람이 400개체의 검은 물결을 몰아갑니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="머뮤레이션 잔상 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">400</b>개체 · 잔상 0.14 덮개</span>
<span class="cd__meta cd__meta--dim">curl noise · 트레일 누적 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 머뮤레이션(06). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(잔상 트레일 + 컬 노이즈 바람 보이드)가 만든다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-pink;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 — 04~06 동일) ──
$flock-ink: #1b1e26;
$flock-dusk: #3d4358;
$flock-glow: #f4cfb2;
$flock-bird: #23262f;
$flock-lilac: #b9a0ff;
$flock-mist: #d8dce6;
.cd {
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .18);
cursor: default; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.18);
cursor: default;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 머뮤레이션(06). 잔상 트레일 — 매 프레임 배경을 rgba(ink, 0.14) 로 덮어 이전 프레임이
// 서서히 지워지며 개체 궤적이 흐름선으로 남는다(첫 프레임은 불투명 full).
// 바람은 2D potential(순수 사인 중첩)을 만들어 그 회전(컬)을 샘플링한다 — 라이브러리 0.
(function () {
// ── 변형 상수 블록 ──
var N_BIRD = 400; // 개체 수
var TRAIL = 0.14; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
var WIND = 1.0; // 바람 세기 배율 — 변형 "바람 세기" 축(1.0 → 2.0)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
nOut.textContent = String(N_BIRD);
var SEED = 0x0b0106;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_GLOW = tokRGB('--flock-glow');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var firstFrame = true;
var PERCEP = 26, SEP_R = 11;
var MAX_SPD = 88, MAX_FRC = 240;
var W_SEP = 1.5, W_ALI = 1.0, W_COH = 0.8;
var birds = [];
for (var i = 0; i < N_BIRD; i++) {
var an = rand() * TAU;
birds.push({
x: rand() * 400, y: rand() * 180,
vx: Math.cos(an) * MAX_SPD * 0.6, vy: Math.sin(an) * MAX_SPD * 0.6,
sz: 1.5 + rand() * 1.0
});
}
// ── 컬 노이즈 바람 — potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx) ──
// psi 는 사인 3겹 중첩(방향·주파수 다른 3개) — 라이브러리 없이 계산으로 만드는 흐름장.
function windAt(x, y, tSec) {
var s = 0.004; // 공간 주파수 기준
var psi =
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45;
var e = 1.5; // 유한차분 간격(px)
var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
// 회전장 — 발산 0(공기가 쌓이거나 빠지지 않는다)
var mag = 46 * WIND;
return [dpsiDy * mag, -dpsiDx * mag];
}
function psiAt(x, y, tSec) {
var s = 0.004;
return (
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45
);
}
function step(dt, tSec) {
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
var sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
var sepN = 0, aliN = 0, cohN = 0;
for (var j = 0; j < birds.length; j++) {
if (i === j) continue;
var o = birds[j];
var dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= dx / d / d * SEP_R; sy -= dy / d / d * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
var fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.045 * W_COH; fy += (cy0 / cohN) * 0.045 * W_COH; }
// 컬 노이즈 바람 — 무리 전체에 검은 물결을 만드는 주 동력
var wv = windAt(b.x, b.y, tSec);
fx += wv[0]; fy += wv[1];
var fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = fx / fl * MAX_FRC; fy = fy / fl * MAX_FRC; }
b.vx += fx * dt; b.vy += fy * dt;
var sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = b.vx / sp * MAX_SPD; b.vy = b.vy / sp * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = b.vx / (sp || 1) * MAX_SPD * 0.3; b.vy = b.vy / (sp || 1) * MAX_SPD * 0.3; }
b.x += b.vx * dt; b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
function draw(dt, tSec) {
// 1) 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
// 2) 지평선 따뜻한 띠 — 매 프레임 얇게 다시 그려 잔상 위에 산다
var hy = H * 0.82;
var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
hb.addColorStop(0.5, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.5)');
hb.addColorStop(1, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);
// 3) 개체 — 실루엣 잉크 점. 배경보다 살짝 밝은 먹(잔상 잉크와 대비 유지)
for (var i = 0; i < birds.length; i++) {
var b = birds[i];
ctx.fillStyle = 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.30)';
ctx.fillRect(b.x - b.sz, b.y - b.sz, b.sz * 2, b.sz * 2);
}
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
step(dt, t);
draw(dt, t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () { fit(); if (reduce) draw(0, 1.2); });
fit();
if (reduce) {
// reduce — 바람이 궤적을 그린 뒤의 한 장만 고정해 그린다
for (var s = 0; s < 60; s++) step(0.033, 1.0 + s * 0.033);
firstFrame = true;
draw(0, 3.0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MurmurationFlowline — vanilla 판(run/315/demo/06-flock-murmuration-flowline/index.html) 의 React 포트.
// 매 프레임 배경을 반투명(0.14)으로 덮어 개체 궤적이 흐름선으로 남고,
// 순수 사인 중첩 컬 노이즈 바람이 400개체의 검은 물결을 몰아간다(라이브러리 0).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MurmurationFlowline.module.scss";
export type MurmurationFlowlineProps = {
duration?: string;
easing?: string;
color?: string;
birds?: number; // 개체 수
trail?: number; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
wind?: number; // 바람 세기 배율 — 변형 "바람 세기" 축(1.0 → 2.0)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
// useRef 는 T | null 을 돌려준다 — 처음부터 값이 있고 절대 비지 않는 상자는 이렇게 좁혀 쓴다
type Box<T> = { current: T };
type RGB = [number, number, number];
type Bird = { x: number; y: number; vx: number; vy: number; sz: number };
type Colors = { ink: RGB; glow: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "머뮤레이션 잔상 실험판";
const STILL_T = 3.0;
const PERCEP = 26;
const SEP_R = 11;
const MAX_SPD = 88;
const MAX_FRC = 240;
const W_SEP = 1.5;
const W_ALI = 1.0;
const W_COH = 0.8;
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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 potential — 사인 3겹 중첩(방향·주파수 다른 3개). 라이브러리 없이 계산으로 만드는 흐름장
function psiAt(x: number, y: number, tSec: number): number {
const s = 0.004;
return (
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45
);
}
export default function MurmurationFlowline({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f4cfb2",
birds = 400,
trail = 0.14,
wind = 1.0,
}: MurmurationFlowlineProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const trailRef = useRef(trail) as Box<number>;
const windRef = useRef(wind) as Box<number>;
const birdsArrRef = useRef<Bird[]>([]) as Box<Bird[]>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const [n, setN] = useState(String(birds));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다 — 파스텔 토큰이 scss 한 곳에
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// 개체 풀 리빌드 — birds prop 이 바뀌거나 최초 마운트 때
function buildBirds(): void {
const rand = makePRNG(0x0b0106);
const arr: Bird[] = [];
for (let i = 0; i < birds; i++) {
const an = rand() * TAU;
arr.push({
x: rand() * 400,
y: rand() * 180,
vx: Math.cos(an) * MAX_SPD * 0.6,
vy: Math.sin(an) * MAX_SPD * 0.6,
sz: 1.5 + rand() * 1.0,
});
}
birdsArrRef.current = arr;
setN(String(birds));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
// ── 컬 노이즈 바람 — potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx). 발산 0 ──
function windAt(x: number, y: number, tSec: number): [number, number] {
const e = 1.5;
const dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
const dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
const mag = 46 * windRef.current;
return [dpsiDy * mag, -dpsiDx * mag];
}
function step(dt: number, tSec: number): void {
const birdsArr = birdsArrRef.current;
const { w: W, h: H } = sizeRef.current;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
let sx = 0, sy = 0, ax = 0, ay = 0, cx0 = 0, cy0 = 0;
let sepN = 0, aliN = 0, cohN = 0;
for (let j = 0; j < birdsArr.length; j++) {
if (i === j) continue;
const o = birdsArr[j];
let dx = o.x - b.x, dy = o.y - b.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d = Math.hypot(dx, dy);
if (d < PERCEP && d > 0.0001) {
if (d < SEP_R) { sx -= (dx / d / d) * SEP_R; sy -= (dy / d / d) * SEP_R; sepN++; }
ax += o.vx; ay += o.vy; aliN++;
cx0 += dx; cy0 += dy; cohN++;
}
}
let fx = 0, fy = 0;
if (sepN) { fx += (sx / sepN) * W_SEP; fy += (sy / sepN) * W_SEP; }
if (aliN) { fx += (ax / aliN - b.vx) * 0.06 * W_ALI; fy += (ay / aliN - b.vy) * 0.06 * W_ALI; }
if (cohN) { fx += (cx0 / cohN) * 0.045 * W_COH; fy += (cy0 / cohN) * 0.045 * W_COH; }
const wv = windAt(b.x, b.y, tSec);
fx += wv[0];
fy += wv[1];
const fl = Math.hypot(fx, fy);
if (fl > MAX_FRC) { fx = (fx / fl) * MAX_FRC; fy = (fy / fl) * MAX_FRC; }
b.vx += fx * dt;
b.vy += fy * dt;
const sp = Math.hypot(b.vx, b.vy);
if (sp > MAX_SPD) { b.vx = (b.vx / sp) * MAX_SPD; b.vy = (b.vy / sp) * MAX_SPD; }
else if (sp < MAX_SPD * 0.3) { b.vx = (b.vx / (sp || 1)) * MAX_SPD * 0.3; b.vy = (b.vy / (sp || 1)) * MAX_SPD * 0.3; }
b.x += b.vx * dt;
b.y += b.vy * dt;
if (b.x < 0) b.x += W; else if (b.x > W) b.x -= W;
if (b.y < 0) b.y += H; else if (b.y > H) b.y -= H;
}
}
function draw(): void {
const ctx = ctxRef.current;
const col = colRef.current;
const birdsArr = birdsArrRef.current;
if (!ctx || !col || birdsArr.length === 0) return;
const { w: W, h: H } = sizeRef.current;
// 1) 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : trailRef.current})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
// 2) 지평선 따뜻한 띠 — 매 프레임 얇게 다시 그려 잔상 위에 산다
const hy = H * 0.82;
const hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0)`);
hb.addColorStop(0.5, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.5)`);
hb.addColorStop(1, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0)`);
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);
// 3) 개체 — 지평선 톤 점(잔상 잉크 위 대비 유지)
ctx.fillStyle = `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},0.30)`;
for (let i = 0; i < birdsArr.length; i++) {
const b = birdsArr[i];
ctx.fillRect(b.x - b.sz, b.y - b.sz, b.sz * 2, b.sz * 2);
}
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
step(dt, t);
draw();
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
trailRef.current = trail;
windRef.current = wind;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
glow: tokRGB(root, "--flock-glow"),
};
buildBirds();
fit();
function onResize(): void {
fit();
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) draw();
}
window.addEventListener("resize", onResize);
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
// reduce — 바람이 궤적을 그린 뒤의 한 장만 고정해 그린다
for (let s = 0; s < 60; s++) step(0.033, 1.0 + s * 0.033);
firstRef.current = true;
draw();
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [birds, trail, wind]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M24 132c22-6 38 6 56 4s34-12 52-8 36 16 54 12 34-14 46-18l-2-10c-14 4-32 16-46 20s-34-8-52-12-36 8-54 10-34-10-52-4Z" opacity=".25"/><path d="M230.53 28.51a8 8 0 0 0-11.31 0L196.3 51.43A108 108 0 0 0 45.14 196.06l-22.8 22.79a8 8 0 1 0 11.31 11.31l23.24-23.23A108 108 0 0 0 202 60.31l23.17-23.18a8 8 0 0 0 5.36-8.62M38 128a90 90 0 0 1 148-68.66L59.34 186A89.75 89.75 0 0 1 38 128m90 90a89.75 89.75 0 0 1-58-21.06L196.66 70A90 90 0 0 1 128 218"/></g></svg>
<span>매 프레임 배경을 반투명하게 덮어 개체 궤적이 흐름선으로 남습니다 — 순수 사인 중첩 컬 노이즈 바람이 400개체의 검은 물결을 몰아갑니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개체 · 잔상 0.14 덮개</span>
<span className={styles.metaDim}>curl noise · 트레일 누적 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · 머뮤레이션 — 흐름선을 남기는 검은 구름 — 측정 누적면적 51.635% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
잔상은 반투명 덮개 알파 하나로 남긴다
var N_BIRD = 400; // 개체 수
var TRAIL = 0.14; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 0.14 로 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
컬 노이즈 바람은 사인 중첩의 회전장이다
// potential psi 의 회전(u, v) = (dpsi/dy, -dpsi/dx)
// psi 는 사인 3겹 중첩(방향·주파수 다른 3개) — 라이브러리 없이 계산으로 만드는 흐름장
var psi =
Math.sin(x * s * 1.7 + tSec * 0.31) * Math.cos(y * s * 2.3 - tSec * 0.23) * 1.0 +
Math.sin(x * s * 0.9 - tSec * 0.17 + 2.1) * Math.cos(y * s * 1.1 + tSec * 0.41 + 0.8) * 0.7 +
Math.sin((x + y) * s * 1.3 + tSec * 0.53 + 4.2) * 0.45;
var e = 1.5; // 유한차분 간격(px)
var dpsiDx = (psiAt(x + e, y, tSec) - psiAt(x - e, y, tSec)) / (2 * e);
var dpsiDy = (psiAt(x, y + e, tSec) - psiAt(x, y - e, tSec)) / (2 * e);
// 회전장 — 발산 0(공기가 쌓이거나 빠지지 않는다)
var mag = 46 * WIND;
return [dpsiDy * mag, -dpsiDx * mag];
포텐셜을 사인 세 겹으로 쌓고 그 회전(컬)을 유한차분으로 샘플링하면 발산이 0인 흐름장이 되어, 공기가 한쪽에 쌓이지 않는 노이즈 라이브러리 없는 바람으로 떼가 시들지 않고 계속 흐릅니다.
지평선 띠는 잔상 위에 매 프레임 산다
var hy = H * 0.82;
var hb = ctx.createLinearGradient(0, hy - 10, 0, hy + 8);
hb.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
hb.addColorStop(0.5, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0.5)');
hb.addColorStop(1, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',0)');
ctx.fillStyle = hb;
ctx.fillRect(0, hy - 10, W, 18);
07먹이 중심 — 인력 근처를 도는 궤도
명상·집중 페이지의 느린 배경으로 쓰는, 중앙 인력점 하나가 개체를 방사 감쇠 인력과 접선 힘으로 느린 소용돌이 궤도에 붙잡는 판입니다 — 실측 누적면적 31.175% · 강도 평균 84.0 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" stroke-width="14"><ellipse cx="128" cy="128" rx="96" ry="40"/><ellipse cx="128" cy="128" rx="40" ry="96" transform="rotate(45 128 128)"/></g><circle cx="128" cy="128" r="20" fill="currentColor"/></svg>
<span>중앙 인력점 하나가 110마리를 느린 소용돌이로 붙잡습니다 — 포인터 x 는 회전, y 는 인력 세기</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="궤도 소용돌이 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">110</b>마리 · 인력 소용돌이 궤도</span>
<span class="cd__meta cd__meta--dim">createRadialGradient · 이웃 3벡터 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 궤도를 도는 무리(07). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(인력점 소용돌이 + 군집 3벡터)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색
$flock-mist: #d8dce6; // 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 궤도를 도는 무리(07). 캔버스 중앙에 고정된 인력점 하나가 개체 110마리를 붙잡아
// 방사형 감쇠 인력 + 접선(소용돌이) 힘으로 느린 궤도를 돌게 한다. 그 위에 표준 군집
// 3벡터(분리·정렬·결합)를 약하게 얹어 개체가 서로 부딪히지 않고 무리 결을 유지한다.
// 포인터 x 는 소용돌이 회전 배율, y 는 인력 세기 배율 — 인력점 위치 자체는 항상 중앙 고정.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 220; // 개체 수 — 화면을 더 넓게 훑도록 110→220
var MAX_SPEED = 1.6; // 최대 속도(정규화) — 0.9→1.6, 넓은 궤도를 더 활발히 돈다
var ATTRACT = 26; // 인력 세기 — 변형 "인력 세기" 축(16 → 40). 낮게 잡아 궤도가 넓게 퍼진다
var SWIRL = 40; // 소용돌이(접선) 힘
var TRAIL = 0.08; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남아 누적면적이 커진다
// ────────────────────────────────────────────────────
var SPEED_UNIT = 140; // px/s 환산 — MAX_SPEED 를 실제 속도로
var MAXSPD_PXS = MAX_SPEED * SPEED_UNIT;
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x07f10c1;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
// 색은 CSS 커스텀 프로퍼티(--flock-*)에서 getComputedStyle 로 읽는다
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_DUSK = tokRGB('--flock-dusk');
var C_GLOW = tokRGB('--flock-glow');
var C_BIRD = tokRGB('--flock-bird');
var C_LILAC = tokRGB('--flock-lilac');
var C_MIST = tokRGB('--flock-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
var firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
// ── 이웃 반경 안 3벡터 파라미터(군집 코어 공통 뼈대) ──
var NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
var W_SEP = 20, W_ALI = 7, W_COH = 4.2;
var boids = [];
function spawn(b) {
var ang = rand() * TAU;
var rad = 30 + rand() * 260; // 넓게 흩어진 시작 — 안쪽 띠부터 화면 가장자리 궤도까지
b.x = rad * Math.cos(ang);
b.y = rad * Math.sin(ang);
b.vx = (rand() - 0.5) * 20;
b.vy = (rand() - 0.5) * 20;
b.orbBias = 0.55 + rand() * 1.65; // 개체마다 다른 목표 궤도 반경(0.55~2.2배) — 화면 전역을 훑는다
}
for (var i = 0; i < N; i++) { var b = {}; spawn(b); boids.push(b); }
// 표준 군집 3벡터 — 토러스 최단거리 보정, 분리·정렬·결합
function flockForce(idx, cx0, cy0) {
var p = boids[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = boids[j];
var dx = q.x - p.x, dy = q.y - p.y;
if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.2;
function draw(tSec, dt) {
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 TRAIL 로 궤적을 남겨 누적면적을 넓힌다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
var cx = W * 0.5, cy = H * 0.5;
var spinMul = 0.5 + px01 * 1.1; // 포인터 x — 소용돌이 배율
var pullMul = 0.5 + py01 * 1.1; // 포인터 y — 인력 세기 배율
// ── 배경 슬레이트 층 — 완만한 명도 파동으로 화면 넓은 영역이 실제로 변한다 ──
var skyA = 0.16 + 0.08 * Math.sin(tSec * 0.7);
ctx.fillStyle = 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',' + skyA.toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
// ── 인력점 미광 링 — breathe 로 반경·알파가 매 프레임 재계산된다 ──
var breathe = 0.86 + 0.14 * Math.sin(tSec * 1.6);
var R0 = Math.min(W, H) * 0.10 * breathe;
var RMAX = Math.min(W, H) * 0.46 * breathe;
var A0 = 0.62 * (0.7 + 0.3 * Math.sin(tSec * 2.1 + 0.4)) * (0.5 + 0.5 * pullMul);
ctx.globalCompositeOperation = 'lighter';
var LAYERS = 8;
for (var L = 0; L < LAYERS; L++) {
var Lt = L / (LAYERS - 1);
var R = R0 + (RMAX - R0) * Math.pow(Lt, 0.8);
var a = Math.max(0.02, A0 * Math.pow(R0 / R, 0.9));
var rg = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R);
rg.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',' + a.toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = rg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
// ── 궤도 안내 원 — 얇은 결(장식, 시청 방향 유도) ──
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = 'rgba(' + C_DUSK[0] + ',' + C_DUSK[1] + ',' + C_DUSK[2] + ',0.35)';
ctx.lineWidth = 1;
for (var ring = 1; ring <= 3; ring++) {
ctx.beginPath();
ctx.ellipse(cx, cy, 26 * ring, 26 * ring * 0.62, 0, 0, TAU);
ctx.stroke();
}
// ── 개체 갱신·묘화 ──
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
var p = boids[i2];
var dx = p.x, dy = p.y;
var d = Math.hypot(dx, dy) || 0.0001;
var nx = dx / d, ny = dy / d;
var orbR = 270 * p.orbBias; // 개체별 목표 궤도 반경 — 화면 전역에 궤도가 퍼진다
var decay = Math.exp(-(d * d) / (orbR * orbR)); // 가우시안 감쇠 — 중심 근처만 강히 당기고 바깥은 자유 궤도
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx;
var swirlR = 300 * p.orbBias;
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ff = flockForce(i2, cx, cy);
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
if (sp > MAXSPD_PXS) { p.vx = p.vx / sp * MAXSPD_PXS; p.vy = p.vy / sp * MAXSPD_PXS; }
p.x += p.vx * dt; p.y += p.vy * dt;
// 토러스 랩 — 화면 밖으로 크게 벗어나면 반대편으로
if (p.x > cx) p.x -= cx * 2; else if (p.x < -cx) p.x += cx * 2;
if (p.y > cy) p.y -= cy * 2; else if (p.y < -cy) p.y += cy * 2;
var fx2 = cx + p.x, fy2 = cy + p.y;
var mix = Math.min(1, d / 120);
var rC = C_GLOW[0] + (C_LILAC[0] - C_GLOW[0]) * mix;
var gC = C_GLOW[1] + (C_LILAC[1] - C_GLOW[1]) * mix;
var bC = C_GLOW[2] + (C_LILAC[2] - C_GLOW[2]) * mix;
var rad = 2.1 + 1.1 * (1 - mix);
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',0.55)';
ctx.fillRect(fx2 - rad, fy2 - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_MIST[0] + ',' + C_MIST[1] + ',' + C_MIST[2] + ',0.9)';
ctx.beginPath();
ctx.arc(cx, cy, 2.4, 0, TAU);
ctx.fill();
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
if (!manual) {
var ph = t * TAU / 5.4;
px01 = 0.5 + 0.36 * Math.sin(ph + 0.3);
py01 = 0.5 + 0.30 * Math.sin(1.7 * ph + 1.1);
}
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () {
if (manual) { px01 = 0.5; py01 = 0.5; }
});
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// AttractorOrbit — vanilla 판(run/315/demo/07-flock-attractor-orbit/index.html) 의 React 포트.
// 캔버스 중앙 인력점 하나가 개체들을 방사형 감쇠 인력 + 접선(소용돌이) 힘으로 붙잡아
// 느린 궤도를 돌게 한다. 개체마다 다른 목표 궤도 반경(orbBias)과 잔상 트레일로 화면 넓은
// 영역을 훑는다. 그 위에 표준 군집 3벡터(분리·정렬·결합)를 약하게 얹는다(라이브러리 0).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AttractorOrbit.module.scss";
export type AttractorOrbitProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수
maxSpeed?: number; // 최대 속도(정규화)
attract?: number; // 인력 세기 — 변형 "인력 세기" 축(16 → 40)
swirl?: number; // 소용돌이(접선) 힘
trail?: number; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Boid = { x: number; y: number; vx: number; vy: number; orbBias: number };
type Colors = { ink: RGB; dusk: RGB; glow: RGB; lilac: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "궤도 소용돌이 실험판";
const STILL_T = 1.2;
const SPEED_UNIT = 140;
const NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
const W_SEP = 20, W_ALI = 7, W_COH = 4.2;
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function AttractorOrbit({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
count = 220,
maxSpeed = 1.6,
attract = 26,
swirl = 40,
trail = 0.08,
}: AttractorOrbitProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function spawn(rand: () => number): Boid {
const ang = rand() * TAU;
const rad = 30 + rand() * 260;
return {
x: rad * Math.cos(ang),
y: rad * Math.sin(ang),
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
orbBias: 0.55 + rand() * 1.65,
};
}
function buildBoids(): void {
const rand = makePRNG(0x07f10c1);
const arr: Boid[] = [];
for (let i = 0; i < count; i++) arr.push(spawn(rand));
boidsRef.current = arr;
setN(String(count));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true;
}
function flockForce(idx: number, cx0: number, cy0: number): [number, number] {
const boids = boidsRef.current;
const p = boids[idx];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < boids.length; j++) {
if (j === idx) continue;
const q = boids[j];
let dx = q.x - p.x, dy = q.y - p.y;
if (dx > cx0) dx -= cx0 * 2; else if (dx < -cx0) dx += cx0 * 2;
if (dy > cy0) dy -= cy0 * 2; else if (dy < -cy0) dy += cy0 * 2;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col || boids.length === 0) return;
const { w: W, h: H } = sizeRef.current;
const maxSpdPxs = maxSpeed * SPEED_UNIT;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : trail})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
const cx = W * 0.5, cy = H * 0.5;
const spinMul = 0.5 + pxRef.current * 1.1;
const pullMul = 0.5 + pyRef.current * 1.1;
const skyA = 0.16 + 0.08 * Math.sin(tSec * 0.7);
ctx.fillStyle = `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},${skyA.toFixed(3)})`;
ctx.fillRect(0, 0, W, H);
const breathe = 0.86 + 0.14 * Math.sin(tSec * 1.6);
const R0 = Math.min(W, H) * 0.1 * breathe;
const RMAX = Math.min(W, H) * 0.46 * breathe;
const A0 = 0.62 * (0.7 + 0.3 * Math.sin(tSec * 2.1 + 0.4)) * (0.5 + 0.5 * pullMul);
ctx.globalCompositeOperation = "lighter";
const LAYERS = 8;
for (let L = 0; L < LAYERS; L++) {
const Lt = L / (LAYERS - 1);
const R = R0 + (RMAX - R0) * Math.pow(Lt, 0.8);
const a = Math.max(0.02, A0 * Math.pow(R0 / R, 0.9));
const rg = ctx.createRadialGradient(cx, cy, R * 0.05, cx, cy, R);
rg.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},${a.toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = rg;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
}
ctx.globalCompositeOperation = "source-over";
ctx.strokeStyle = `rgba(${col.dusk[0]},${col.dusk[1]},${col.dusk[2]},0.35)`;
ctx.lineWidth = 1;
for (let ring = 1; ring <= 3; ring++) {
ctx.beginPath();
ctx.ellipse(cx, cy, 26 * ring, 26 * ring * 0.62, 0, 0, TAU);
ctx.stroke();
}
ctx.globalCompositeOperation = "lighter";
for (let i2 = 0; i2 < boids.length; i2++) {
const p = boids[i2];
const dx = p.x, dy = p.y;
const d = Math.hypot(dx, dy) || 0.0001;
const nx = dx / d, ny = dy / d;
const orbR = 270 * p.orbBias;
const decay = Math.exp(-(d * d) / (orbR * orbR));
const pull = -attract * pullMul * decay;
const tx = -ny, ty = nx;
const swirlR = 300 * p.orbBias;
const sw = swirl * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
const ff = flockForce(i2, cx, cy);
const ax = nx * pull + tx * sw + ff[0];
const ay = ny * pull + ty * sw + ff[1];
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.94, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
if (sp > maxSpdPxs) { p.vx = (p.vx / sp) * maxSpdPxs; p.vy = (p.vy / sp) * maxSpdPxs; }
p.x += p.vx * dt; p.y += p.vy * dt;
if (p.x > cx) p.x -= cx * 2; else if (p.x < -cx) p.x += cx * 2;
if (p.y > cy) p.y -= cy * 2; else if (p.y < -cy) p.y += cy * 2;
const fx2 = cx + p.x, fy2 = cy + p.y;
const mix = Math.min(1, d / 120);
const rC = col.glow[0] + (col.lilac[0] - col.glow[0]) * mix;
const gC = col.glow[1] + (col.lilac[1] - col.glow[1]) * mix;
const bC = col.glow[2] + (col.lilac[2] - col.glow[2]) * mix;
const rad = 2.1 + 1.1 * (1 - mix);
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},0.55)`;
ctx.fillRect(fx2 - rad, fy2 - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.mist[0]},${col.mist[1]},${col.mist[2]},0.9)`;
ctx.beginPath();
ctx.arc(cx, cy, 2.4, 0, TAU);
ctx.fill();
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
if (!manualRef.current) {
const ph = (t * TAU) / 5.4;
pxRef.current = 0.5 + 0.36 * Math.sin(ph + 0.3);
pyRef.current = 0.5 + 0.3 * Math.sin(1.7 * ph + 1.1);
}
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void {
if (manualRef.current) { pxRef.current = 0.5; pyRef.current = 0.5; }
}
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) manualRef.current = true;
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
dusk: tokRGB(root, "--flock-dusk"),
glow: tokRGB(root, "--flock-glow"),
lilac: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildBoids();
fit();
function onResize(): void {
fit();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, maxSpeed, attract, swirl, trail]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="none" stroke="currentColor" strokeWidth="14"><ellipse cx="128" cy="128" rx="96" ry="40" /><ellipse cx="128" cy="128" rx="40" ry="96" transform="rotate(45 128 128)" /></g><circle cx="128" cy="128" r="20" fill="currentColor" /></svg>
<span>중앙 인력점 하나가 {n}마리를 느린 소용돌이로 붙잡습니다 — 포인터 x 는 회전, y 는 인력 세기</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 · 인력 소용돌이 궤도</span>
<span className={styles.metaDim}>createRadialGradient · 이웃 3벡터 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}07 · 먹이 중심 — 인력 근처를 도는 궤도 — 측정 누적면적 31.175% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
가우시안 감쇠 인력과 접선 소용돌이
var ATTRACT = 26; // 인력 세기 — 낮게 잡아 궤도가 넓게 퍼진다
var SWIRL = 40; // 소용돌이(접선) 힘
var orbR = 270 * p.orbBias; // 개체별 목표 궤도 반경
var decay = Math.exp(-(d * d) / (orbR * orbR)); // 가우시안 감쇠 — 중심 근처만 강히
var pull = -ATTRACT * pullMul * decay;
var tx = -ny, ty = nx; // 접선 방향(수직)
var swirl = SWIRL * spinMul * (0.58 + 0.42 * Math.exp(-(d * d) / (swirlR * swirlR)));
var ax = nx * pull + tx * swirl + ff[0];
var ay = ny * pull + ty * swirl + ff[1];
인력은 중심에서 멀수록 지수로 약해져 바깥 개체는 자유 궤도를 돌고 접선 힘이 그 궤도를 원을 그리게 미는데, 개체마다 orbBias(0.55~2.2배)가 달라 목표 궤도 반경이 흩어져 화면 전역에 궤도가 퍼집니다.
군집 3벡터는 약하게 얹어 결을 유지한다
var NEI_R = 26, NEI_R2 = NEI_R * NEI_R, SEP_R = 11;
var W_SEP = 20, W_ALI = 7, W_COH = 4.2;
// 표준 군집 3벡터 — 토러스 최단거리 보정, 분리·정렬·결합
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
잔상 0.08 덮개로 궤적을 길게 남긴다
var TRAIL = 0.08; // 잔상 덮개 알파 — 낮을수록 궤적이 길게 남는다
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
06 보다 낮은 0.08 덮개로 궤적을 더 길게 남겨 소용돌이의 흐름을 한눈에 읽게 합니다.
08발광 플랑크톤 — 밤바다에서 빛나는 떼
야간 이벤트·몰입형 스토리 페이지에 쓰는, 개체를 발광 점으로 그려 lighter 합성으로 겹치면 밝아지게 둔 밤바다의 야광 무리입니다 — 실측 누적면적 32.674% · 강도 평균 17.5 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="80" cy="96" r="10"/><circle cx="140" cy="70" r="7"/><circle cx="176" cy="130" r="12"/><circle cx="96" cy="160" r="8"/><circle cx="160" cy="190" r="6"/></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" stroke-width="10" stroke-linecap="round" opacity=".5"/></svg>
<span>180마리 발광 플랑크톤이 밤바다를 떠돕니다 — 포인터를 가까이 대면 빛무리가 흩어지며 도망칩니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="발광 플랑크톤 밤바다 실험판" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">180</b>마리 · lighter 발광 합성</span>
<span class="cd__meta cd__meta--dim">drawImage 스프라이트 · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 발광 플랑크톤(08). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(밤바다 그라디언트 + 발광 스프라이트 lighter 합성)가 만든다.
// 무대는 밤바다 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색(라일락 발광 개체)
$flock-mist: #d8dce6; // 먼 개체 흐릿
// ── 심해 전용 확장(08 만 — 심해 그라디언트·발광 톤. 전부 HLS 파스텔 안전값) ──
$flock-deep: #2c3750; // 심해 바닥 — 슬레이트보다 한 단 깊은 청
$flock-sea: #46608c; // 중간 수심
$flock-shallow: #5d7cae; // 얕은 수심 — 수면 쪽
$flock-plankton: #b4def0; // 발광 플랑크톤 기본 톤(파스텔 하늘)
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
--flock-deep: #{$flock-deep};
--flock-sea: #{$flock-sea};
--flock-shallow: #{$flock-shallow};
--flock-plankton: #{$flock-plankton};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-deep;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-plankton; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-plankton; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
--flock-deep: #2c3750;
--flock-sea: #46608c;
--flock-shallow: #5d7cae;
--flock-plankton: #b4def0;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #2c3750;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #b4def0;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #b4def0;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 발광 플랑크톤(08). 밤바다 배경도 캔버스가 매 프레임 그린다(깊이별 그라디언트가 느리게 흔들린다).
// 개체 180마리는 미리 만든 발광 스프라이트(오프스크린 캔버스 1장, createRadialGradient)를
// drawImage 로 찍고 globalCompositeOperation 'lighter' 로 겹치면 밝아지게 한다.
// 포인터(또는 시드 고정 가상 자극) 근처에서는 도망 벡터가 더해지고, 표준 군집 3벡터를
// 약하게 얹어 무리가 흩어졌다 다시 느슨히 모이는 결을 만든다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 180; // 개체 수
var GLOW_R = 7; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
var FLEE_R = 46; // 도망 반경(px)
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x08fee0d;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_DEEP = tokRGB('--flock-deep');
var C_MID = tokRGB('--flock-sea');
var C_SHALL = tokRGB('--flock-shallow');
var C_GLOWA = tokRGB('--flock-plankton');
var C_GLOWB = tokRGB('--flock-lilac');
var C_MIST = tokRGB('--flock-mist');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5;
var manual = false;
// ── 발광 스프라이트(오프스크린 1장) — drawImage 로 찍어 비용을 낮춘다 ──
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
var sctx = sprite.getContext('2d');
(function buildSprite() {
var r = spriteSize / 2;
var g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
})();
// ── 이웃 반경 안 3벡터 파라미터(군집 코어 공통 뼈대) — 느슨한 학교 짓기 ──
var NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var plks = [];
function spawn(p) {
p.x = rand() * 1; p.y = rand() * 1; // 0..1 정규화 좌표(필드에 곱해 사용)
p.vx = (rand() - 0.5) * 10;
p.vy = (rand() - 0.5) * 10;
p.ph = rand() * TAU;
p.hue = rand() < 0.16 ? 1 : 0; // 16% 는 라일락 톤
p.sz = 0.75 + rand() * 0.7;
}
for (var i = 0; i < N; i++) { var p = {}; spawn(p); plks.push(p); }
function flockForce(idx) {
var p = plks[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
var px = p.x * W, py = p.y * H;
for (var j = 0; j < N; j++) {
if (j === idx) continue;
var q = plks[j];
var qx = q.x * W, qy = q.y * H;
var dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.0;
function draw(tSec, dt) {
// ── 밤바다 배경 — 깊이별 그라디언트가 느리게 흔들린다(캔버스가 매 프레임 그린다) ──
ctx.globalCompositeOperation = 'source-over';
var shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
var seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, 'rgb(' + lerpC(C_SHALL, C_MID, shiftA * 0.3) + ')');
seaGrad.addColorStop(0.5, 'rgb(' + lerpC(C_MID, C_DEEP, 0.4 + shiftA * 0.2) + ')');
seaGrad.addColorStop(1, 'rgb(' + C_DEEP[0] + ',' + C_DEEP[1] + ',' + C_DEEP[2] + ')');
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
// 표류하는 해류 결 — 대각 띠가 위상만큼 흘러 화면 전체가 실제로 바뀐다
for (var band = 0; band < 3; band++) {
var by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
var bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = 'rgba(' + C_MID[0] + ',' + C_MID[1] + ',' + C_MID[2] + ',' + bandA.toFixed(3) + ')';
ctx.fillRect(0, by - 14, W, 28);
}
// 가상 자극 — 포인터 없을 때 화면을 천천히 순회(시드 고정)
var stimX, stimY;
if (manual) { stimX = px01 * W; stimY = py01 * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = 'lighter';
for (var i2 = 0; i2 < N; i2++) {
var p = plks[i2];
var fx3 = p.x * W, fy3 = p.y * H;
var dxs = fx3 - stimX, dys = fy3 - stimY;
var ds = Math.hypot(dxs, dys) || 0.0001;
var fleeX = 0, fleeY = 0;
if (ds < FLEE_R) {
var fleeMag = (1 - ds / FLEE_R) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
var ff = flockForce(i2);
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3; // 살짝 위로 뜨는 성향
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
var maxV = 70;
if (sp > maxV) { p.vx = p.vx / sp * maxV; p.vy = p.vy / sp * maxV; }
var nx2 = (fx3 + p.vx * dt + W) % W;
var ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
var glow = p.hue ? C_GLOWB : C_GLOWA;
var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.filter = 'none';
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
// 색조 스프라이트 캐시 — 두 색(플랑크톤/라일락)만 쓰므로 두 장만 만든다
var tintCache = {};
function tintedSprite(rgb) {
var key = rgb.join(',');
if (tintCache[key]) return tintCache[key];
var c = document.createElement('canvas');
c.width = spriteSize; c.height = spriteSize;
var cc = c.getContext('2d');
cc.drawImage(sprite, 0, 0);
cc.globalCompositeOperation = 'source-in';
cc.fillStyle = 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')';
cc.fillRect(0, 0, spriteSize, spriteSize);
tintCache[key] = c;
return c;
}
function lerpC(a, b, m) {
return (a[0] + (b[0] - a[0]) * m | 0) + ',' + (a[1] + (b[1] - a[1]) * m | 0) + ',' + (a[2] + (b[2] - a[2]) * m | 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
nOut.textContent = String(N);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); px01 = 0.5; py01 = 0.5; }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); if (reduce) draw(STILL_T, 0.016); });
fit();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// GlowPlankton — vanilla 판(run/315/demo/08-flock-glow-plankton/index.html) 의 React 포트.
// 밤바다 배경(깊이별 그라디언트가 느리게 흔들린다)도 캔버스가 매 프레임 그린다. 개체는
// 미리 만든 발광 스프라이트(오프스크린 캔버스, createRadialGradient)를 drawImage 로 찍고
// globalCompositeOperation 'lighter' 로 겹쳐 밝아지게 한다. 포인터(또는 가상 자극) 근처에서는
// 도망 벡터가 더해지고, 표준 군집 3벡터를 약하게 얹어 무리가 흩어졌다 다시 느슨히 모인다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./GlowPlankton.module.scss";
export type GlowPlanktonProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수
glowR?: number; // 발광 반경(px) — 변형 "발광 반경" 축(5 → 12)
fleeR?: number; // 도망 반경(px)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Plankton = { x: number; y: number; vx: number; vy: number; ph: number; hue: number; sz: number };
type Colors = { deep: RGB; sea: RGB; shallow: RGB; glowA: RGB; glowB: RGB; mist: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "발광 플랑크톤 밤바다 실험판";
const STILL_T = 1.0;
const NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
const W_SEP = 14, W_ALI = 5, W_COH = 2.4;
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
function lerpC(a: RGB, b: RGB, m: number): string {
return `${(a[0] + (b[0] - a[0]) * m) | 0},${(a[1] + (b[1] - a[1]) * m) | 0},${(a[2] + (b[2] - a[2]) * m) | 0}`;
}
export default function GlowPlankton({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b4def0",
count = 180,
glowR = 7,
fleeR = 46,
}: GlowPlanktonProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const spriteRef = useRef<HTMLCanvasElement | null>(null);
const spriteSizeRef = useRef(0) as Box<number>;
const tintCacheRef = useRef<Record<string, HTMLCanvasElement>>({}) as Box<Record<string, HTMLCanvasElement>>;
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const plksRef = useRef<Plankton[]>([]) as Box<Plankton[]>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
function buildSprite(): void {
const spriteSize = Math.max(8, Math.round(glowR * 6));
spriteSizeRef.current = spriteSize;
const sprite = document.createElement("canvas");
sprite.width = spriteSize; sprite.height = spriteSize;
const sctx = sprite.getContext("2d");
if (sctx) {
const r = spriteSize / 2;
const g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, "rgba(255,255,255,1)");
g.addColorStop(0.35, "rgba(255,255,255,0.55)");
g.addColorStop(1, "rgba(255,255,255,0)");
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
}
spriteRef.current = sprite;
tintCacheRef.current = {};
}
function tintedSprite(rgb: RGB): HTMLCanvasElement {
const key = rgb.join(",");
const cache = tintCacheRef.current;
if (cache[key]) return cache[key];
const spriteSize = spriteSizeRef.current;
const c = document.createElement("canvas");
c.width = spriteSize; c.height = spriteSize;
const cc = c.getContext("2d")!;
if (spriteRef.current) cc.drawImage(spriteRef.current, 0, 0);
cc.globalCompositeOperation = "source-in";
cc.fillStyle = `rgb(${rgb[0]},${rgb[1]},${rgb[2]})`;
cc.fillRect(0, 0, spriteSize, spriteSize);
cache[key] = c;
return c;
}
function spawn(rand: () => number): Plankton {
return {
x: rand(), y: rand(),
vx: (rand() - 0.5) * 10,
vy: (rand() - 0.5) * 10,
ph: rand() * TAU,
hue: rand() < 0.16 ? 1 : 0,
sz: 0.75 + rand() * 0.7,
};
}
function buildPlks(): void {
const rand = makePRNG(0x08fee0d);
const arr: Plankton[] = [];
for (let i = 0; i < count; i++) arr.push(spawn(rand));
plksRef.current = arr;
setN(String(count));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
}
function flockForce(idx: number): [number, number] {
const plks = plksRef.current;
const { w: W, h: H } = sizeRef.current;
const p = plks[idx];
const px = p.x * W, py = p.y * H;
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < plks.length; j++) {
if (j === idx) continue;
const q = plks[j];
const qx = q.x * W, qy = q.y * H;
let dx = qx - px, dy = qy - py;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const plks = plksRef.current;
if (!ctx || !col || plks.length === 0) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
const shiftA = 0.5 + 0.5 * Math.sin(tSec * 0.35);
const seaGrad = ctx.createLinearGradient(0, 0, 0, H);
seaGrad.addColorStop(0, `rgb(${lerpC(col.shallow, col.sea, shiftA * 0.3)})`);
seaGrad.addColorStop(0.5, `rgb(${lerpC(col.sea, col.deep, 0.4 + shiftA * 0.2)})`);
seaGrad.addColorStop(1, `rgb(${col.deep[0]},${col.deep[1]},${col.deep[2]})`);
ctx.fillStyle = seaGrad;
ctx.fillRect(0, 0, W, H);
for (let band = 0; band < 3; band++) {
const by = H * (0.2 + band * 0.32) + Math.sin(tSec * 0.5 + band * 1.7) * H * 0.08;
const bandA = 0.05 + 0.03 * Math.sin(tSec * 0.8 + band);
ctx.fillStyle = `rgba(${col.sea[0]},${col.sea[1]},${col.sea[2]},${bandA.toFixed(3)})`;
ctx.fillRect(0, by - 14, W, 28);
}
let stimX: number, stimY: number;
if (manualRef.current) { stimX = pxRef.current * W; stimY = pyRef.current * H; }
else {
stimX = W * (0.5 + 0.4 * Math.sin(tSec * 0.6));
stimY = H * (0.5 + 0.36 * Math.sin(tSec * 0.41 + 1.3));
}
ctx.globalCompositeOperation = "lighter";
const spriteSize = spriteSizeRef.current;
for (let i2 = 0; i2 < plks.length; i2++) {
const p = plks[i2];
const fx3 = p.x * W, fy3 = p.y * H;
const dxs = fx3 - stimX, dys = fy3 - stimY;
const ds = Math.hypot(dxs, dys) || 0.0001;
let fleeX = 0, fleeY = 0;
if (ds < fleeR) {
const fleeMag = (1 - ds / fleeR) * 120;
fleeX = (dxs / ds) * fleeMag;
fleeY = (dys / ds) * fleeMag;
}
const ff = flockForce(i2);
const driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
const driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3;
const ax = fleeX + ff[0] + driftX;
const ay = fleeY + ff[1] + driftY;
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.88, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
const maxV = 70;
if (sp > maxV) { p.vx = (p.vx / sp) * maxV; p.vy = (p.vy / sp) * maxV; }
const nx2 = (fx3 + p.vx * dt + W) % W;
const ny2 = (fy3 + p.vy * dt + H) % H;
p.x = nx2 / W; p.y = ny2 / H;
const glow = p.hue ? col.glowB : col.glowA;
const pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
const dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
}
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void { manualRef.current = false; }
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; pxRef.current = 0.5; pyRef.current = 0.5; }
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
deep: tokRGB(root, "--flock-deep"),
sea: tokRGB(root, "--flock-sea"),
shallow: tokRGB(root, "--flock-shallow"),
glowA: tokRGB(root, "--flock-plankton"),
glowB: tokRGB(root, "--flock-lilac"),
mist: tokRGB(root, "--flock-mist"),
};
buildSprite();
buildPlks();
fit();
function onResize(): void {
fit();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, glowR, fleeR]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><circle cx="80" cy="96" r="10" /><circle cx="140" cy="70" r="7" /><circle cx="176" cy="130" r="12" /><circle cx="96" cy="160" r="8" /><circle cx="160" cy="190" r="6" /></g><path d="M32 200q32-24 64 0t64 0t64 0" fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" opacity=".5" /></svg>
<span>{n}마리 발광 플랑크톤이 밤바다를 떠돕니다 — 포인터를 가까이 대면 빛무리가 흩어지며 도망칩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>마리 · lighter 발광 합성</span>
<span className={styles.metaDim}>drawImage 스프라이트 · globalCompositeOperation · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · 발광 플랑크톤 — 밤바다에서 빛나는 떼 — 측정 누적면적 32.674% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
발광 스프라이트 한 장을 미리 구워 찍는다
var N = 180; // 개체 수
var GLOW_R = 7; // 발광 반경(px)
var spriteSize = Math.max(8, Math.round(GLOW_R * 6));
var sprite = document.createElement('canvas');
sprite.width = spriteSize; sprite.height = spriteSize;
(function buildSprite() {
var r = spriteSize / 2;
var g = sctx.createRadialGradient(r, r, 0, r, r, r);
g.addColorStop(0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(255,255,255,0.55)');
g.addColorStop(1, 'rgba(255,255,255,0)');
sctx.fillStyle = g;
sctx.fillRect(0, 0, spriteSize, spriteSize);
})();
개체마다 방사 그라디언트를 새로 만들면 180장의 그라디언트 계산이 매 프레임 쌓이기 때문에, 흰 발광 스프라이트를 오프스크린에 한 장 구워 drawImage 로 찍는 것이 발광 떼의 성능 답입니다.
lighter 합성은 겹침을 빛으로 바꾼다
ctx.globalCompositeOperation = 'lighter';
var pulse = 0.7 + 0.3 * Math.sin(tSec * 3.1 + p.ph * 2);
var dw = spriteSize * p.sz * (0.85 + 0.3 * pulse);
ctx.save();
ctx.globalAlpha = 0.55 + 0.35 * pulse;
ctx.drawImage(tintedSprite(glow), nx2 - dw / 2, ny2 - dw / 2, dw, dw);
ctx.restore();
표류와 도망을 얹어 느슨한 학교를 짓는다
var NEI_R = 30, NEI_R2 = NEI_R * NEI_R, SEP_R = 12;
var W_SEP = 14, W_ALI = 5, W_COH = 2.4;
var FLEE_R = 46; // 도망 반경(px)
var driftX = Math.sin(tSec * 0.9 + p.ph) * 5;
var driftY = Math.cos(tSec * 0.7 + p.ph) * 4 - 3; // 살짝 위로 뜨는 성향
var ax = fleeX + ff[0] + driftX;
var ay = fleeY + ff[1] + driftY;
결합(2.4)을 아주 약하게 잡아 물고기 떼보다 느슨한 플랑크톤다운 부유 결을 만들고, 사인 표류에 -3 상수를 얹어 위로 뜨는 성향과 속도 감쇠(Math.pow(0.88, dt * 60))가 물의 점성을 대신합니다.
09글자를 이루는 떼 — 포인터를 피해 모이는 문장
히어로 헤드라인·전시 소개 문구에 쓰는, 글자의 밝은 픽셀 위치를 목표점으로 나눠 주고 포인터 근처에서만 도망 벡터를 더해 헤치면 흩어지고 비키면 다시 문장으로 돌아오는 판입니다 — 실측 누적면적 31.423% · 강도 평균 50.2 · 23/23 프레임이 움직였습니다.

<main class="stage">
<div class="cd is-demo" id="cdRoot">
<p class="cd__hint">
<svg class="cd__ico" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor" opacity=".9"><rect x="36" y="52" width="26" height="26" rx="4"/><rect x="88" y="40" width="18" height="18" rx="3"/><rect x="150" y="58" width="22" height="22" rx="4"/><rect x="60" y="112" width="20" height="20" rx="4"/><rect x="124" y="126" width="26" height="26" rx="5"/><rect x="182" y="104" width="16" height="16" rx="3"/><rect x="46" y="176" width="22" height="22" rx="4"/><rect x="110" y="188" width="18" height="18" rx="3"/><rect x="168" y="172" width="24" height="24" rx="4"/></g></svg>
<span>문장이 곧 목표점입니다 — 포인터(또는 화살표키)로 글자를 헤치면 떼가 흩어지고, 비키면 BOIDS 로 다시 조립됩니다</span>
</p>
<div class="cd__field" id="cdField" role="application" aria-label="글자를 이루는 군집 실험판 — 화살표키로 글자를 헤칠 수 있습니다" tabindex="0">
<canvas class="cd__cv" id="cdCv"></canvas>
</div>
<div class="cd__bar">
<span class="cd__meta"><b class="cd__val" id="cdN">320</b>개 목표점 · 스프링 재조립</span>
<span class="cd__meta cd__meta--dim">getImageData 샘플링 · fillText 오프스크린 · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 글자를 이루는 떼(09). CSS 애니메이션·@keyframes 를 아예 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(목표점 스프링 + 군집 3벡터 + 도망)가 만든다.
// 무대는 어스름 잉크. 파스텔 고정 팔레트를 --flock-* 로 편 공통 규격(07~09 동일).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
@include stage($stage-ink);
// ── 편 공통 파스텔 팔레트(315 07~09 — BD9~11 원칙) ──
$flock-ink: #1b1e26; // 어스름 무대
$flock-dusk: #3d4358; // 하늘 깊은 슬레이트
$flock-glow: #f4cfb2; // 지평선 따뜻한 파스텔
$flock-bird: #23262f; // 실루엣 잉크
$flock-lilac: #b9a0ff; // 변형 색(흩어진 개체가 물드는 색)
$flock-mist: #d8dce6; // 글자 색 — 먼 개체 흐릿
.cd {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --flock-* 를 받아 쓴다
--flock-ink: #{$flock-ink};
--flock-dusk: #{$flock-dusk};
--flock-glow: #{$flock-glow};
--flock-bird: #{$flock-bird};
--flock-lilac: #{$flock-lilac};
--flock-mist: #{$flock-mist};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.cd__hint {
display: flex; align-items: center; gap: $sp-1; margin: 0;
font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $flock-mist;
}
.cd__ico { width: 16px; height: 16px; flex: 0 0 auto; color: $flock-mist; }
.cd__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $flock-ink;
box-shadow: inset 0 0 0 1px rgba($flock-mist, .16);
cursor: crosshair; touch-action: none;
}
.cd__field:focus-visible { outline: 2px solid $flock-glow; outline-offset: 2px; }
.cd__cv { display: block; width: 100%; height: 100%; }
.cd__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.cd__meta { font-size: 10px; font-weight: 500; color: rgba($flock-mist, .72); }
.cd__val { color: $flock-glow; font-weight: 700; }
.cd__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.cd { gap: $sp-1; }
.cd__field { height: 100px; }
.cd__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 한 장만 남는다. 커서 신호만 거둔다.
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field { cursor: default !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cd {
--flock-ink: #1b1e26;
--flock-dusk: #3d4358;
--flock-glow: #f4cfb2;
--flock-bird: #23262f;
--flock-lilac: #b9a0ff;
--flock-mist: #d8dce6;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.cd__hint {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #d8dce6;
}
.cd__ico {
width: 16px;
height: 16px;
flex: 0 0 auto;
color: #d8dce6;
}
.cd__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1b1e26;
box-shadow: inset 0 0 0 1px rgba(216, 220, 230, 0.16);
cursor: crosshair;
touch-action: none;
}
.cd__field:focus-visible {
outline: 2px solid #f4cfb2;
outline-offset: 2px;
}
.cd__cv {
display: block;
width: 100%;
height: 100%;
}
.cd__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.cd__meta {
font-size: 10px;
font-weight: 500;
color: rgba(216, 220, 230, 0.72);
}
.cd__val {
color: #f4cfb2;
font-weight: 700;
}
.cd__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.cd {
gap: 4px;
}
.cd__field {
height: 100px;
}
.cd__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.cd.is-still .cd__field {
cursor: default !important;
}
}// 글자를 이루는 떼(09). 오프스크린 캔버스에 굵은 산세리프로 "BOIDS" 를 fillText 로 칠고
// getImageData 로 밝은 픽셀 좌표를 샘플(약 300~400점)해 개체별 목표점으로 배정한다.
// 스티어링 = 목표점 스프링(강) + 군집 3규칙(약) + 포인터 도망 벡터.
// 포인터가 없는 렌더 환경에서는 시드 고정 가상 포인터가 주기적으로 문장을 가로질러
// 흩었다 모으게 한다(23/23 프레임 보장). 오프스크린은 화면에 안 보인다.
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형 3종을 만든다 ──
var N = 320; // 개체 수(샘플 점 수에 맞춰 상한에서 잘린다)
var FLEE_R = 120; // 도망 반경(px) — 변형 "도망 반경" 축(28 → 120), 화면 넓은 영역까지 흩어진다
var SPRING = 6.0; // 목표점 스프링 세기 — 낮춰 흩어진 뒤 되돌아오는 데 시간이 걸리게 한다
var TRAIL = 0.07; // 잔상 덮개 알파 — 흩어지는 궤적을 남겨 누적면적을 넓힌다
// ────────────────────────────────────────────────────
var root = document.getElementById('cdRoot');
var field = document.getElementById('cdField');
var cv = document.getElementById('cdCv');
var ctx = cv.getContext('2d');
var nOut = document.getElementById('cdN');
// 시드 고정 PRNG — mulberry32
var SEED = 0x09b0d5;
var prngState = SEED >>> 0;
function rand() {
prngState = (prngState + 0x6D2B79F5) >>> 0;
var t = prngState;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
}
var css = getComputedStyle(root);
function tokRGB(name) {
var s = css.getPropertyValue(name).trim();
var hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
var C_INK = tokRGB('--flock-ink');
var C_MIST = tokRGB('--flock-mist');
var C_LILAC = tokRGB('--flock-lilac');
var C_GLOW = tokRGB('--flock-glow');
var TAU = Math.PI * 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0;
var raf = 0, t0 = -1;
var px01 = 0.5, py01 = 0.5; // 포인터(0..1) — 없으면 가상
var manual = false;
var firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
// ── 이웃 반경 안 3벡터(약하게) ──
var NEI_R = 22, NEI_R2 = NEI_R * NEI_R, SEP_R = 8;
var W_SEP = 9, W_ALI = 2.4, W_COH = 1.2;
// ── 목표점 샘플 — 오프스크린 fillText → getImageData ──
var targets = []; // {x,y} 정규화(0..1) 좌표
var boids = [];
var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4; // 픽셀 샘플 간격
function buildTargets() {
targets = [];
var off = document.createElement('canvas');
off.width = Math.max(64, Math.round(W));
off.height = Math.max(32, Math.round(H));
var octx = off.getContext('2d');
octx.fillStyle = '#000';
octx.fillRect(0, 0, off.width, off.height);
octx.fillStyle = '#fff';
octx.font = FONT_STACK;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
var word = 'BOIDS';
octx.fillText(word, off.width / 2, off.height / 2);
var availW = off.width * 0.92;
var m = octx.measureText(word);
if (m.width > availW) {
// 폭이 넘치면 글꼴 크기를 비례 축소해 다시 칠한다
var scale = availW / m.width;
var px2 = Math.max(28, Math.floor(100 * scale));
octx.font = '900 ' + px2 + 'px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
octx.fillText(word, off.width / 2, off.height / 2);
}
var img = octx.getImageData(0, 0, off.width, off.height);
var d = img.data, ow = off.width, oh = off.height;
for (var yy = 0; yy < oh; yy += sampleGap) {
for (var xx = 0; xx < ow; xx += sampleGap) {
if (d[(yy * ow + xx) * 4] > 128) {
targets.push({ x: xx / ow, y: yy / oh });
}
}
}
// 개체 수를 목표점 수에 맞춰 재배치(목표 인덱스는 그대로 유지)
var count = Math.min(N, targets.length);
boids = [];
for (var i = 0; i < count; i++) {
var tg = targets[i % targets.length];
boids.push({
x: tg.x * W + (rand() - 0.5) * 40,
y: tg.y * H + (rand() - 0.5) * 40,
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
tx: tg.x, ty: tg.y,
ph: rand() * TAU
});
}
nOut.textContent = String(count);
}
function flockForce(idx) {
var p = boids[idx];
var sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (var j = 0; j < boids.length; j++) {
if (j === idx) continue;
var q = boids[j];
var dx = q.x - p.x, dy = q.y - p.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
var d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
var d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
var fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
var STILL_T = 1.5;
function draw(tSec, dt) {
// 잔상 덮개 — 첫 프레임은 불투명 full, 이후 반투명 TRAIL 로 흩어지는 궤적을 남긴다
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
firstFrame = false;
// 가상 포인터 — 주기적으로 문장을 가로질러 흩었다 모으게(23/23 보장)
var sX, sY;
if (manual) { sX = px01 * W; sY = py01 * H; }
else {
var cyc = (tSec % 6.2) / 6.2; // 6.2초 주기 — 더 자주 문장을 가로지른다
var sweep = Math.sin(cyc * TAU);
sX = W * (0.5 + sweep * 0.5);
sY = H * (0.5 + 0.4 * Math.cos(cyc * TAU * 2));
}
// ── 목표점 미광 — 문장 자리를 은은히 비춘다(재조립 방향 감각) ──
var breathe = 0.85 + 0.15 * Math.sin(tSec * 1.8);
var gA = 0.05 * breathe;
var rg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.05, W / 2, H / 2, Math.max(W, H) * 0.52);
rg.addColorStop(0, 'rgba(' + C_GLOW[0] + ',' + C_GLOW[1] + ',' + C_GLOW[2] + ',' + gA.toFixed(3) + ')');
rg.addColorStop(1, 'rgba(0,0,0,0)');
ctx.globalCompositeOperation = 'lighter';
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
// ── 개체 — 목표 스프링 + 군집(약) + 도망 ──
for (var i = 0; i < boids.length; i++) {
var p = boids[i];
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING;
var ff = flockForce(i);
ax += ff[0]; ay += ff[1];
var dxs = p.x - sX, dys = p.y - sY;
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
var fm = (1 - ds / FLEE_R) * 460;
ax += (dxs / ds) * fm;
ay += (dys / ds) * fm;
}
var jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
var jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;
ax += jitX; ay += jitY;
p.vx += ax * dt; p.vy += ay * dt;
var damp = Math.pow(0.86, dt * 60);
p.vx *= damp; p.vy *= damp;
var sp = Math.hypot(p.vx, p.vy);
var maxV = 260;
if (sp > maxV) { p.vx = p.vx / sp * maxV; p.vy = p.vy / sp * maxV; }
p.x += p.vx * dt; p.y += p.vy * dt;
// 글자 색 $flock-mist — 도망 중(목표에서 멀수록) 라일락으로 물든다
var far = Math.min(1, Math.hypot(dx, dy) / 60);
var rC = C_MIST[0] + (C_LILAC[0] - C_MIST[0]) * far;
var gC = C_MIST[1] + (C_LILAC[1] - C_MIST[1]) * far;
var bC = C_MIST[2] + (C_LILAC[2] - C_MIST[2]) * far;
var rad = 2.6;
ctx.fillStyle = 'rgba(' + (rC | 0) + ',' + (gC | 0) + ',' + (bC | 0) + ',0.85)';
ctx.fillRect(p.x - rad, p.y - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = 'source-over';
}
function fit() {
var r = field.getBoundingClientRect();
W = r.width; H = r.height;
cv.width = Math.round(W); cv.height = Math.round(H);
firstFrame = true; // 리사이즈마다 잔상 캔버스를 다시 깔아야 한다
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
var dt = Math.min(0.05, (now - (frame.prev || now)) / 1000);
frame.prev = now;
draw(t, dt);
raf = requestAnimationFrame(frame);
}
field.addEventListener('pointermove', function (e) {
var r = field.getBoundingClientRect();
px01 = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
py01 = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manual) { manual = true; root.classList.remove('is-demo'); }
if (reduce) draw(STILL_T, 0.016);
});
field.addEventListener('pointerleave', function () { manual = false; });
field.addEventListener('keydown', function (e) {
var d = { ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06] }[e.key];
if (!d) return;
e.preventDefault();
if (!manual) { manual = true; root.classList.remove('is-demo'); px01 = 0.5; py01 = 0.5; }
px01 = Math.max(0, Math.min(1, px01 + d[0]));
py01 = Math.max(0, Math.min(1, py01 + d[1]));
if (reduce) draw(STILL_T, 0.016);
});
window.addEventListener('resize', function () { fit(); buildTargets(); if (reduce) draw(STILL_T, 0.016); });
fit();
buildTargets();
if (reduce) {
draw(STILL_T, 0);
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TextGlyphSwarm — vanilla 판(run/315/demo/09-flock-text-glyph-swarm/index.html) 의 React 포트.
// 오프스크린 캔버스에 굵은 산세리프로 "BOIDS" 를 fillText 로 칠고 getImageData 로 밝은 픽셀
// 좌표를 샘플해 개체별 목표점으로 배정한다. 스티어링 = 목표점 스프링(강) + 군집 3규칙(약) +
// 포인터 도망 벡터. 포인터가 없으면 시드 고정 가상 포인터가 주기적으로 문장을 가로지른다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TextGlyphSwarm.module.scss";
export type TextGlyphSwarmProps = {
duration?: string;
easing?: string;
color?: string;
count?: number; // 개체 수(샘플 점 수에 맞춰 상한에서 잘린다)
fleeR?: number; // 도망 반경(px) — 변형 "도망 반경" 축(28 → 120)
spring?: number; // 목표점 스프링 세기
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Target = { x: number; y: number };
type Boid = { x: number; y: number; vx: number; vy: number; tx: number; ty: number; ph: number };
type Colors = { ink: RGB; mist: RGB; lilac: RGB; glow: RGB };
const TAU = Math.PI * 2;
const FIELD_LABEL = "글자를 이루는 군집 실험판 — 화살표키로 글자를 헤칠 수 있습니다";
const STILL_T = 1.5;
const TRAIL = 0.07;
const NEI_R = 22, NEI_R2 = NEI_R * NEI_R, SEP_R = 8;
const W_SEP = 9, W_ALI = 2.4, W_COH = 1.2;
const FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
const SAMPLE_GAP = 4;
const WORD = "BOIDS";
const ARROW_STEP: Record<string, [number, number]> = {
ArrowLeft: [-0.07, 0], ArrowRight: [0.07, 0], ArrowUp: [0, -0.06], ArrowDown: [0, 0.06],
};
// 시드 고정 PRNG — mulberry32
function makePRNG(seed: number): () => number {
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;
};
}
export default function TextGlyphSwarm({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
count = 320,
fleeR = 120,
spring = 6.0,
}: TextGlyphSwarmProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const fieldRef = useRef<HTMLDivElement | null>(null);
const cvRef = useRef<HTMLCanvasElement | null>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const sizeRef = useRef({ w: 0, h: 0 }) as Box<{ w: number; h: number }>;
const colRef = useRef<Colors | null>(null);
const boidsRef = useRef<Boid[]>([]) as Box<Boid[]>;
const randRef = useRef<() => number>(makePRNG(0x09b0d5)) as Box<() => number>;
const firstRef = useRef(true) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const t0Ref = useRef(-1) as Box<number>;
const prevRef = useRef(0) as Box<number>;
const pxRef = useRef(0.5) as Box<number>;
const pyRef = useRef(0.5) as Box<number>;
const manualRef = useRef(false) as Box<boolean>;
const reduceRef = useRef(false) as Box<boolean>;
const [n, setN] = useState(String(count));
const [still, setStill] = useState(false);
const vars: Vars = { "--duration": duration, "--easing": easing, "--color": color };
function tokRGB(root: HTMLDivElement, name: string): RGB {
const s = getComputedStyle(root).getPropertyValue(name).trim();
const hx = s.length === 4
? s.charAt(1) + s.charAt(1) + s.charAt(2) + s.charAt(2) + s.charAt(3) + s.charAt(3)
: s.slice(1);
return [parseInt(hx.slice(0, 2), 16), parseInt(hx.slice(2, 4), 16), parseInt(hx.slice(4, 6), 16)];
}
// 오프스크린 fillText → getImageData 로 목표점 샘플, 개체 재배치
function buildTargets(): void {
const rand = randRef.current;
const { w: W, h: H } = sizeRef.current;
const targets: Target[] = [];
const off = document.createElement("canvas");
off.width = Math.max(64, Math.round(W));
off.height = Math.max(32, Math.round(H));
const octx = off.getContext("2d");
if (!octx) return;
octx.fillStyle = "#000";
octx.fillRect(0, 0, off.width, off.height);
octx.fillStyle = "#fff";
octx.font = FONT_STACK;
octx.textAlign = "center";
octx.textBaseline = "middle";
octx.fillText(WORD, off.width / 2, off.height / 2);
const availW = off.width * 0.92;
const m = octx.measureText(WORD);
if (m.width > availW) {
const scale = availW / m.width;
const px2 = Math.max(28, Math.floor(100 * scale));
octx.font = `900 ${px2}px "Segoe UI", "Helvetica Neue", Arial, sans-serif`;
octx.fillText(WORD, off.width / 2, off.height / 2);
}
const img = octx.getImageData(0, 0, off.width, off.height);
const d = img.data, ow = off.width, oh = off.height;
for (let yy = 0; yy < oh; yy += SAMPLE_GAP) {
for (let xx = 0; xx < ow; xx += SAMPLE_GAP) {
if (d[(yy * ow + xx) * 4] > 128) targets.push({ x: xx / ow, y: yy / oh });
}
}
const cnt = Math.min(count, targets.length);
const arr: Boid[] = [];
for (let i = 0; i < cnt; i++) {
const tg = targets[i % targets.length];
arr.push({
x: tg.x * W + (rand() - 0.5) * 40,
y: tg.y * H + (rand() - 0.5) * 40,
vx: (rand() - 0.5) * 20,
vy: (rand() - 0.5) * 20,
tx: tg.x, ty: tg.y,
ph: rand() * TAU,
});
}
boidsRef.current = arr;
setN(String(cnt));
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const ctx = cv.getContext("2d");
if (!ctx) return;
const r = field.getBoundingClientRect();
sizeRef.current.w = r.width;
sizeRef.current.h = r.height;
cv.width = Math.round(r.width);
cv.height = Math.round(r.height);
ctxRef.current = ctx;
firstRef.current = true;
}
function flockForce(idx: number): [number, number] {
const boids = boidsRef.current;
const { w: W, h: H } = sizeRef.current;
const p = boids[idx];
let sepX = 0, sepY = 0, aliX = 0, aliY = 0, cohX = 0, cohY = 0, cnt = 0;
for (let j = 0; j < boids.length; j++) {
if (j === idx) continue;
const q = boids[j];
let dx = q.x - p.x, dy = q.y - p.y;
if (dx > W / 2) dx -= W; else if (dx < -W / 2) dx += W;
if (dy > H / 2) dy -= H; else if (dy < -H / 2) dy += H;
const d2 = dx * dx + dy * dy;
if (d2 > NEI_R2) continue;
const d = Math.sqrt(d2) || 0.0001;
if (d < SEP_R) { sepX -= dx / d; sepY -= dy / d; }
aliX += q.vx; aliY += q.vy;
cohX += dx; cohY += dy;
cnt++;
}
let fx = sepX * W_SEP, fy = sepY * W_SEP;
if (cnt > 0) {
fx += (aliX / cnt - p.vx) * (W_ALI / 10);
fy += (aliY / cnt - p.vy) * (W_ALI / 10);
fx += (cohX / cnt) * (W_COH / 40);
fy += (cohY / cnt) * (W_COH / 40);
}
return [fx, fy];
}
function draw(tSec: number, dt: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const boids = boidsRef.current;
if (!ctx || !col) return;
const { w: W, h: H } = sizeRef.current;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = `rgba(${col.ink[0]},${col.ink[1]},${col.ink[2]},${firstRef.current ? 1 : TRAIL})`;
ctx.fillRect(0, 0, W, H);
firstRef.current = false;
let sX: number, sY: number;
if (manualRef.current) { sX = pxRef.current * W; sY = pyRef.current * H; }
else {
const cyc = (tSec % 6.2) / 6.2;
const sweep = Math.sin(cyc * TAU);
sX = W * (0.5 + sweep * 0.5);
sY = H * (0.5 + 0.4 * Math.cos(cyc * TAU * 2));
}
const breathe = 0.85 + 0.15 * Math.sin(tSec * 1.8);
const gA = 0.05 * breathe;
const rg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.05, W / 2, H / 2, Math.max(W, H) * 0.52);
rg.addColorStop(0, `rgba(${col.glow[0]},${col.glow[1]},${col.glow[2]},${gA.toFixed(3)})`);
rg.addColorStop(1, "rgba(0,0,0,0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = rg;
ctx.fillRect(0, 0, W, H);
for (let i = 0; i < boids.length; i++) {
const p = boids[i];
const dx = p.tx * W - p.x, dy = p.ty * H - p.y;
let ax = dx * spring, ay = dy * spring;
const ff = flockForce(i);
ax += ff[0]; ay += ff[1];
const dxs = p.x - sX, dys = p.y - sY;
const ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < fleeR) {
const fm = (1 - ds / fleeR) * 460;
ax += (dxs / ds) * fm;
ay += (dys / ds) * fm;
}
const jitX = Math.sin(tSec * 6.3 + p.ph) * 34;
const jitY = Math.cos(tSec * 5.1 + p.ph * 1.3) * 30;
ax += jitX; ay += jitY;
p.vx += ax * dt; p.vy += ay * dt;
const damp = Math.pow(0.86, dt * 60);
p.vx *= damp; p.vy *= damp;
const sp = Math.hypot(p.vx, p.vy);
const maxV = 260;
if (sp > maxV) { p.vx = (p.vx / sp) * maxV; p.vy = (p.vy / sp) * maxV; }
p.x += p.vx * dt; p.y += p.vy * dt;
const far = Math.min(1, Math.hypot(dx, dy) / 60);
const rC = col.mist[0] + (col.lilac[0] - col.mist[0]) * far;
const gC = col.mist[1] + (col.lilac[1] - col.mist[1]) * far;
const bC = col.mist[2] + (col.lilac[2] - col.mist[2]) * far;
const rad = 2.6;
ctx.fillStyle = `rgba(${rC | 0},${gC | 0},${bC | 0},0.85)`;
ctx.fillRect(p.x - rad, p.y - rad, rad * 2, rad * 2);
}
ctx.globalCompositeOperation = "source-over";
}
function frame(now: number): void {
if (t0Ref.current < 0) t0Ref.current = now;
const t = (now - t0Ref.current) / 1000;
const dt = Math.min(0.05, (now - (prevRef.current || now)) / 1000);
prevRef.current = now;
draw(t, dt);
rafRef.current = requestAnimationFrame(frame);
}
function onPointerMove(e: React.PointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const r = field.getBoundingClientRect();
pxRef.current = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
pyRef.current = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
if (!manualRef.current) manualRef.current = true;
if (reduceRef.current) draw(STILL_T, 0.016);
}
function onPointerLeave(): void { manualRef.current = false; }
function onKeyDown(e: React.KeyboardEvent<HTMLDivElement>): void {
const d = ARROW_STEP[e.key];
if (!d) return;
e.preventDefault();
if (!manualRef.current) { manualRef.current = true; pxRef.current = 0.5; pyRef.current = 0.5; }
pxRef.current = Math.max(0, Math.min(1, pxRef.current + d[0]));
pyRef.current = Math.max(0, Math.min(1, pyRef.current + d[1]));
if (reduceRef.current) draw(STILL_T, 0.016);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
reduceRef.current = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
colRef.current = {
ink: tokRGB(root, "--flock-ink"),
mist: tokRGB(root, "--flock-mist"),
lilac: tokRGB(root, "--flock-lilac"),
glow: tokRGB(root, "--flock-glow"),
};
randRef.current = makePRNG(0x09b0d5);
fit();
buildTargets();
function onResize(): void {
fit();
buildTargets();
if (reduceRef.current) draw(STILL_T, 0.016);
}
window.addEventListener("resize", onResize);
if (reduceRef.current) {
draw(STILL_T, 0);
setStill(true);
} else {
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [count, fleeR, spring]);
const rootCls = still ? `${styles.root} ${styles.isStill}` : styles.root;
return (
<main className={styles.stage}>
<div className={rootCls} ref={rootRef} style={vars}>
<p className={styles.hint}>
<svg className={styles.ico} viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor" opacity=".9"><rect x="36" y="52" width="26" height="26" rx="4" /><rect x="88" y="40" width="18" height="18" rx="3" /><rect x="150" y="58" width="22" height="22" rx="4" /><rect x="60" y="112" width="20" height="20" rx="4" /><rect x="124" y="126" width="26" height="26" rx="5" /><rect x="182" y="104" width="16" height="16" rx="3" /><rect x="46" y="176" width="22" height="22" rx="4" /><rect x="110" y="188" width="18" height="18" rx="3" /><rect x="168" y="172" width="24" height="24" rx="4" /></g></svg>
<span>문장이 곧 목표점입니다 — 포인터(또는 화살표키)로 글자를 헤치면 떼가 흩어지고, 비키면 BOIDS 로 다시 조립됩니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={onPointerLeave}
onKeyDown={onKeyDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<span className={styles.meta}><b className={styles.val}>{n}</b>개 목표점 · 스프링 재조립</span>
<span className={styles.metaDim}>getImageData 샘플링 · fillText 오프스크린 · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · 글자를 이루는 떼 — 포인터를 피해 모이는 문장 — 측정 누적면적 31.423% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
오프스크린 fillText 에서 목표점을 샘플한다
var FONT_STACK = '900 100px "Segoe UI", "Helvetica Neue", Arial, sans-serif';
var sampleGap = 4; // 픽셀 샘플 간격
octx.font = FONT_STACK;
octx.textAlign = 'center';
octx.textBaseline = 'middle';
octx.fillText(word, off.width / 2, off.height / 2); // word = 'BOIDS'
var img = octx.getImageData(0, 0, off.width, off.height);
var d = img.data, ow = off.width, oh = off.height;
for (var yy = 0; yy < oh; yy += sampleGap) {
for (var xx = 0; xx < ow; xx += sampleGap) {
if (d[(yy * ow + xx) * 4] > 128) {
targets.push({ x: xx / ow, y: yy / oh }); // 밝은 픽셀 = 목표점
}
}
}
화면에 안 보이는 오프스크린 캔버스에 굵은 산세리프로 문장을 칠하고 4px 간격으로 밝은 픽셀 좌표를 걷어 목표점 약 320개를 만들며, 글자가 폭을 넘기면 measureText 로 글꼴 크기를 비례 축소해 다시 칠합니다.
목표점 스프링과 도망 벡터의 줄다리기
var FLEE_R = 120; // 도망 반경(px)
var SPRING = 6.0; // 목표점 스프링 세기
var dx = p.tx * W - p.x, dy = p.ty * H - p.y;
var ax = dx * SPRING, ay = dy * SPRING; // 목표로 당기는 스프링
var ff = flockForce(i); // 군집 3벡터(약하게)
ax += ff[0]; ay += ff[1];
var dxs = p.x - sX, dys = p.y - sY;
var ds = Math.hypot(dxs, dys) || 0.0001;
if (ds < FLEE_R) {
var fm = (1 - ds / FLEE_R) * 460;
ax += (dxs / ds) * fm; ay += (dys / ds) * fm;
}
흩어지는 궤적은 잔상 0.07로 남긴다
var TRAIL = 0.07; // 잔상 덮개 알파 — 흩어지는 궤적을 남겨 누적면적을 넓힌다
ctx.fillStyle = 'rgba(' + C_INK[0] + ',' + C_INK[1] + ',' + C_INK[2] + ',' + (firstFrame ? 1 : TRAIL).toFixed(3) + ')';
ctx.fillRect(0, 0, W, H);
포인터가 문장을 가르고 지나가는 순간의 궤적이 잔상에 흐름선으로 쌓여 재조립 과정이 한 장면에 겹쳐 보이고, 포인터가 없는 렌더 환경에서는 6.2초 주기의 가상 포인터가 문장을 가로질러 흩었다 모읍니다.
군집을 실제로 쓰는 화면
로고나 브랜드 문구가 흩어졌다 다시 모이는 인트로에는 09 글자를 이루는 떼가 그대로 맞습니다 — 포인터가 다가오면 피하다가 다시 문장으로 합쳐지는 동작이 헤더 영역의 시선을 붙잡습니다. 카드 호버나 로딩 오버레이처럼 어두운 배경 위에서는 08 발광 플랑크톤처럼 빛만 남기는 장면이 무겁지 않게 어울립니다.
배경 스크롤 섹션에는 06 머뮤레이션처럼 흐름선을 남기며 크게 도는 검은 구름이나 02 새떼 실루엣처럼 위상이 어긋나는 조용한 반복이 맞고, 둘 다 텍스트 위에 겹치지 않는 여백에만 둡니다.
이웃 반경을 너무 넓히면 군집이 여기서 깨지는 함정
가장 먼저 깨지는 지점은 분리와 결합의 균형입니다. 결합을 아주 세우면 떼가 한 점으로 수축하다 분리 반사로 튕겨 나와 진동합니다. 03 물고기 떼를 만들 때 결합 계수를 더 세게 두었더니 뭉치가 똬리를 틀다 멈춰 버려 유영이 죽었고, 0.6으로 내리고서야 물결처럼 흐르는 뭉침이 살았습니다. 둘째, 06 컬 노이즈 바람의 유한차분 간격 e 를 1.5px 로 잡은 것은 흐름장이 매끄러운 사인 중첩이라 간격을 더 벌려도 방향이 급히 꺾이지 않기 때문입니다 — 값을 크게 벌리면 샘플이 표면을 못 쫓아 바람이 우드득 꺾입니다. 셋째, 05 격자의 셀 한 변(48px)을 지각 반경(30px)보다 작게 줄이면 3×3 밖의 이웃을 놓쳐 무리가 화면에서 반쯤 잘린 결로 갈라집니다. 마지막으로 09 의 목표점 배정은 인덱스 나눗셈(i % targets.length)으로 돌리는데, 샘플 간격을 촘촘히 줄여 목표점이 개체 수보다 많아지면 나머지 목표가 빈 자리로 남아 글자가 옅어집니다. 아홉 판의 바닐라 소스와 React 판을 열어 이 계수들을 직접 흔들어 보려면 암호 zip 을 열어야 하고, 여는 암호는 nhgr9myq 입니다.
이웃 수 하나로 갈리는 세 변형과 접근성 처리
값 하나를 바꾼 세 변형
| 이름 | 바꾼 값 | 느낌 |
|---|---|---|
| 개체 수 | 01 세 규칙 떼 260 → 640 | 떼가 짙어지는 대가로 프레임이 무거워진다 |
| 결합 세기 | 03 물고기 떼 결합 계수 0.6 → 1.2 | 뭉치가 단단해지지만 유영이 둔해진다 |
| 색 | $flock-ink → $flock-lilac | 검은 새 실루엣이 라일락 톤 물결로 바뀐다 |
셋 다 소스 맨 위 변형 상수 블록의 한 줄만 고치는 축입니다. 개체 수는 N_B 260을, 결합은 W_COH 0.6을, 색은 02 의 TONE 0→1 스위치를 움직이면 됩니다.
움직임을 줄인 사용자에게 남는 정지 프레임
prefers-reduced-motion: reduce 를 켠 사용자에게는 requestAnimationFrame 루프를 시작하지 않고, 미리 몇 스텝 시뮬레이션을 돌린 뒤 떼가 자리잡은 한 장만 그립니다 — 01·02·03 은 STILL_T 1.6초 시각, 04 는 도망이 시작된 순간(천적 0.5, 0.42 지점), 06 은 바람이 궤적을 그은 뒤 3.0초 장면입니다. 시뮬레이션 계산은 애니메이션과 똑같이 하고 화면만 한 번 그리므로 무리의 결이 그대로 읽힙니다.
키보드·포인터로 만지는 네 판
아홉 판 전부가 role="application" 과 tabindex="0" 을 가지고 있어 탭으로 항상 초점이 옮겨지며, 그중 04·05·07·08·09 는 키다운 핸들러가 더 있어 포인터·화살표 키로 천적과 자극을 몰 수 있습니다 — 04 는 방향키로 천적을, 07 은 가로 위치로 회전을·세로 위치로 인력 세기를, 09 는 화살표 키로 문장을 헤칩니다. reduced-motion 사용자도 손이 닿는 즉시 한 프레임을 다시 계산해 그립니다.
군집 시뮬레이션을 붙이기 전에 자주 나오는 질문
보이드 알고리즘 코드를 그대로 붙여 쓸 수 있나요?
붙여 쓸 수 있습니다. 각 판은 의존성 없이 짜여 있어, 암호 zip 에서 바닐라 판을 꺼내 <script> 로 붙이면 그대로 움직입니다. 개체 수·세 규칙 무게·이웃 반경은 각 판 소스 맨 위의 상수 블록에서 바꾸고, React 판도 같은 zip 에 들어 있습니다.
CSS 새떼 애니메이션으로도 이 효과를 낼 수 있나요?
정해 둔 경로를 도는 장식 새 몇 마리라면 CSS 키프레임으로 충분하지만, 이웃을 읽고 무리가 갈라졌다 봉합되는 군집은 매 프레임 개체 사이를 다시 계산해야 해서 CSS 로 만들 수 없습니다. boids algorithm javascript 검색으로 찾아오는 분이 원하는 것도 정확히 이 반응하는 떼 쪽입니다 — 리더 없이 규칙만으로 짓는 결이 캔버스 계산의 몫입니다.
개체를 몇 마리까지 늘려도 부드럽게 돌아가나요?
전수 비교 구조라면 개체 수의 제곱으로 셈이 늘어 300~400마리가 실용 한계입니다. 05 공간 해시는 화면을 48px 셀로 나눠 3×3 칸만 조사하므로 1000마리도 프레임당 조사가 수만 회 수준으로 떨어집니다. 셀 크기를 지각 반경보다 크게 유지하는 조건만 지키면 2000마리까지 늘려도 무리가 살아 있습니다.









