04천적 등장 — 도망 벡터가 쪼개는 떼
포인터를 천적으로 삼아 일정 반경 안의 개체에 강한 도망 벡터를 더하면, 떼가 순간 갈라졌다 다시 합쳐지는 물결이 살아 움직입니다.
쓰임인터랙티브 히어로·마우스 반응 이벤트
pointermovefillRectrequestAnimationFrame

<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 포함 · 캔버스 보이드(boids javascript) 군집 9종 — 새떼 애니메이션
이 데모가 속한 글
캔버스 보이드(boids javascript) 군집 9종 — 새떼 애니메이션보이드(boids javascript)는 이웃 반경 안 개체끼리 분리·정렬·결합 세 벡터를 더해 리더 없이 무리를 짓는 군집 시뮬레이션이며, 라이브러리·WebGL 없이 세 개의 벡터 규칙만으로 수백 마리 새떼가 짓는 이 글의 데모 전부 보기


