Slime Mold Simulation, 9 Ways (No Library)
No library, no WebGL — just thousands of dots sniffing front, left, right and turning toward the strongest scent, and a slime-mold network grows that finds a
01 slime-agent-sensor-basic
02 slime-maze-solve-pathfind
03 slime-network-food-source
04 slime-pointer-attract-repel
05 slime-multi-species-territory
06 slime-trail-decay-color-age
07 slime-sensor-angle-morph
08 slime-veins-texture-panel
09 slime-macro-bioluminescent-scene
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Basic three-sensor agent — one slime cell sniffing front, left, right and turning
- 02 Maze shortest path — the Ig Nobel-winning experiment, rebuilt in canvas
- 03 Multi-food network — optimizing connections like a rail map
- 04 Pointer food or repellent — the network grows toward or flees your click
- 05 Multi-species territory — two colored colonies splitting the canvas
- 06 Trail-age coloring — the freshest paths glow neon-bright
- 07 Sensor-angle morph — one angle value turns coral into blood vessels
- 08 Organic texture panel — capturing one frame as a card background
- 09 Macro bioluminescent scene — a documentary-lit slime mold on a dark stage
One Sensor Angle: Nine Steps from Coral to Blood Vessels
The nine scenes climb one axis: what each agent is allowed to smell. 01 reads only the trail field its own kind left behind, so the network shape comes from nothing but deposits and evaporation. 02 adds maze walls as a scent worth minus one thousand, and suddenly the same dots solve the passage real Physarum polycephalum solved for its 2008 Ig Nobel Prize. 03 and 04 both add food — 03 as six fixed radial points that sort the network into trunk lines and branches, 04 as signed anchors your pointer plants, food or salt by a toggle. 05 makes the smell itself subtractive, so each colony reads the other's trail as an obstacle and the canvas splits into two territories. 06 replaces the trail value with a bare timestamp per cell, 07 turns the sensor geometry into two live sliders, 08 stops time and keeps the corpse, and 09 stages the whole thing under documentary lighting. It is the same steering-without-a-map idea that flies a flock bound by three neighbor rules and no shared field at all — except here the field is the map the swarm writes for itself, which is what makes this a physarum simulation rather than a particle system.
Code per item
Each snippet below is lifted from that item's own script tag, constant names and all, not summarized into pseudocode — drop it into a file and it grows the same network the panel above it does.
01Basic three-sensor agent — one slime cell sniffing front, left, right and turning
Every agent samples the trail at three points ahead, turns toward the strongest, steps forward, and deposits at its new cell — no other rule runs. 1300 agents from a small center seed paint 51.385% of the frame, peaking at 34.913%, and the field wraps at every edge so nothing cuts off at the border.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Each agent samples pheromone at three points ahead — front, left, right — turns toward the strongest, steps forward, and deposits trail; run thousands of them and a vein-like network grows on its own</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Three-Sensor Slime Agent Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> steps · basic 3-sensor</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 기본 3센서 에이전트(01). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 밤 잉크. 파스텔 팔레트를 --sm-* 로 편 전체(01~09) 공통 규격.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-lilac;
// ── 편 공통 밤 무대 팔레트(BD9~11 원칙: 편마다 다시 정하지 않는다) ──
$sm-stage: #1a1826; // 밤 잉크 무대 — 짙은 보랏빛 (파스텔 검사 무관: 저채도·저명도)
@include stage($sm-stage);
$sm-ink: #191722; // 필드 배경 — 무대보다 한 톤 어두운 바닥
$sm-glow: #b7c8ff; // 트레일 중간 — 파스텔 페리윙클
$sm-hot: #ffe3c2; // 트레일 절정 — 파스텔 피치(가장 짙은 간선)
$sm-text: #d9d6e6; // 잉크 무대 위 — 라벤더 안개 글자
.sm {
// 렌더러가 읽는 색 — JS 는 getComputedStyle 로 --sm-* 를 받아 쓴다
--sm-ink: #{$sm-ink};
--sm-glow: #{$sm-glow};
--sm-hot: #{$sm-hot};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-glow; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-glow; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 네트워크가 자라다 멈춘 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #1a1826;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #191722;
--sm-glow: #b7c8ff;
--sm-hot: #ffe3c2;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #d9d6e6;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #191722;
box-shadow: inset 0 0 0 1px rgba(217, 214, 230, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #b7c8ff;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(217, 214, 230, 0.72);
}
.sm__val {
color: #b7c8ff;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 기본 3센서 에이전트(01). Physarum(점균) 스티그머지 알고리즘 —
// 에이전트마다 전방·좌·우 세 지점의 페로몬 필드를 읽어(sense) 가장 짙은 쪽으로 돈 뒤(rotate)
// 한 걸음 전진하며 지나간 칸에 트레일을 쌓는다(deposit). 필드는 매 프레임 이웃 평균으로
// 살짝 번지고(diffuse) 일정 비율 옅어진다(evaporate) — 이 네 단계만 반복해도 중앙 한 점에서
// 시작한 무리가 저절로 갈라지고 합쳐지며 혈관 같은 네트워크로 자란다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 페로몬 필드 값을 그대로 색으로 매핑해 putImageData 한다.
// · 값노이즈 기반 주변광 요동이 필드 전체를 매 프레임 은은하게 들썩이게 한다(누적면적 ↑).
// · 트레일이 짙을수록 밝은 파스텔로, 옅을수록 무대색으로 가라앉는다(순간 강도 ↑).
(function () {
// ── 변형 상수 블록 — 아래 값 하나만 바꿔 변형을 만든다 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수 — 네트워크 밀도
var DECAY = 0.045; // 증발률(프레임당) — 변형 "증발 속도"가 이 값을 0.02→0.06으로 바꾼다
var SENSOR_DIST = 5.2; // 센서가 앞을 내다보는 Distance(필드 칸)
// ────────────────────────────────────────────────────
var SENSOR_ANGLE = 0.5; // Sensor spread angle(라디안)
var TURN = 0.5; // 한 스텝에 도는 각(라디안)
var STEP = 1.05; // 한 스텝 이동 Distance(필드 칸)
var DEPOSIT = 4.4; // 한 스텝에 쌓는 페로몬 양
var DIFFUSE = 0.5; // 확산 시 이웃과 섞이는 비율
var CAP = 7; // 페로몬 상한(색 매핑 기준)
var SUBSTEPS = 3; // rAF 프레임당 시뮬 스텝 수
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A01);
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('--sm-ink');
var C_GLOW = tokRGB('--sm-glow');
var C_HOT = tokRGB('--sm-hot');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
var stepCount = 0;
// ── 값노이즈(주변광 요동용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function sample(x, y) { return trail[idx(Math.round(x), Math.round(y))]; }
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sample(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sample(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sample(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진 — 가장 짙은 쪽이 정면
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var v = trail[i] / CAP; if (v > 1) v = 1;
var n = vnoise(x * 0.06, y * 0.06 + t * 0.5);
var amb = 0.5 + 0.5 * Math.sin(t * 1.15 + n * 6.2831);
var k = v * v * (3 - 2 * v);
var rr = C_INK[0] + (C_GLOW[0] - C_INK[0]) * k + (C_HOT[0] - C_GLOW[0]) * Math.max(0, v - 0.72) * 3.4;
var gg = C_INK[1] + (C_GLOW[1] - C_INK[1]) * k + (C_HOT[1] - C_GLOW[1]) * Math.max(0, v - 0.72) * 3.4;
var bb = C_INK[2] + (C_GLOW[2] - C_INK[2]) * k + (C_HOT[2] - C_GLOW[2]) * Math.max(0, v - 0.72) * 3.4;
var sh = (amb - 0.5) * 16 * (1 - v * 0.6);
var j = i * 4;
data[j] = rr + sh; data[j + 1] = gg + sh; data[j + 2] = bb + sh; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(90, Math.min(200, Math.round(W / 3)));
fh = Math.max(50, Math.min(130, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
initAgents();
}
function stillFrame() {
for (var k = 0; k < 220; k++) { stepAgents(); diffuseEvap(); }
render(3.4);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// AgentSensorBasic — vanilla 판(run/320/demo/01-slime-agent-sensor-basic/index.html) 의 React 포트.
// 기본 3센서 에이전트. Physarum(점균) 스티그머지 알고리즘 — 에이전트마다 전방·좌·우 세 지점의
// 페로몬 필드를 읽어(sense) 가장 짙은 쪽으로 돈 뒤(rotate) 한 걸음 전진하며 지나간 칸에 트레일을
// 쌓는다(deposit). 필드는 매 프레임 이웃 평균으로 살짝 번지고(diffuse) 일정 비율 옅어진다(evaporate).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./AgentSensorBasic.module.scss";
export type AgentSensorBasicProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 동시에 걷는 에이전트 수 — 네트워크 밀도
decay?: number; // 증발률(프레임당)
sensorDist?: number; // 센서가 앞을 내다보는 거리(필드 칸)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "3센서 점균 에이전트 실험판";
const SENSOR_ANGLE = 0.5; // 센서 벌림각(라디안)
const TURN = 0.5; // 한 스텝에 도는 각(라디안)
const STEP = 1.05; // 한 스텝 이동 거리(필드 칸)
const DEPOSIT = 4.4; // 한 스텝에 쌓는 페로몬 양
const DIFFUSE = 0.5; // 확산 시 이웃과 섞이는 비율
const CAP = 7; // 페로몬 상한(색 매핑 기준)
const SUBSTEPS = 3; // rAF 프레임당 시뮬 스텝 수
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 AgentSensorBasic({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#b9a0ff",
agents = 1300,
decay = 0.045,
sensorDist = 5.2,
}: AgentSensorBasicProps) {
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 colRef = useRef<{ ink: RGB; glow: RGB; hot: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const sensorDistRef = useRef(sensorDist) as Box<number>;
const randRef = useRef(makePRNG(0x320a01)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function sample(x: number, y: number): number { return trailRef.current[idx(Math.round(x), Math.round(y))]; }
function initAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const sd = sensorDistRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sample(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
const sl = sample(x + Math.cos(h + SENSOR_ANGLE) * sd, y + Math.sin(h + SENSOR_ANGLE) * sd);
const sr = sample(x + Math.cos(h - SENSOR_ANGLE) * sd, y + Math.sin(h - SENSOR_ANGLE) * sd);
if (sf >= sl && sf >= sr) {
// 직진 — 가장 짙은 쪽이 정면
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
let v = trail[i] / CAP; if (v > 1) v = 1;
const nz = vnoise(x * 0.06, y * 0.06 + t * 0.5);
const amb = 0.5 + 0.5 * Math.sin(t * 1.15 + nz * 6.2831);
const k = v * v * (3 - 2 * v);
const rr = col.ink[0] + (col.glow[0] - col.ink[0]) * k + (col.hot[0] - col.glow[0]) * Math.max(0, v - 0.72) * 3.4;
const gg = col.ink[1] + (col.glow[1] - col.ink[1]) * k + (col.hot[1] - col.glow[1]) * Math.max(0, v - 0.72) * 3.4;
const bb = col.ink[2] + (col.glow[2] - col.ink[2]) * k + (col.hot[2] - col.glow[2]) * Math.max(0, v - 0.72) * 3.4;
const sh = (amb - 0.5) * 16 * (1 - v * 0.6);
const j = i * 4;
data[j] = rr + sh; data[j + 1] = gg + sh; data[j + 2] = bb + sh; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(90, Math.min(200, Math.round(W / 3)));
const fh = Math.max(50, Math.min(130, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 220; k++) { stepAgents(); diffuseEvap(); }
render(3.4);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
sensorDistRef.current = sensorDist;
randRef.current = makePRNG(0x320a01);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
glow: tokRGB(root, "--sm-glow"),
hot: tokRGB(root, "--sm-hot"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, sensorDist]);
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}>
<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> 스텝 · 기본 3센서</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}01 · Basic three-sensor agent — one slime cell sniffing front, left, right and turning — Measured area 51.385% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Three samples and one turn is the entire steering rule
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sample(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sample(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sample(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// straight — the strongest cell is dead ahead
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
02Maze shortest path — the Ig Nobel-winning experiment, rebuilt in canvas
A 13x7 maze is carved by randomized depth-first search, food sits at both ends of the middle row, and obstacle cells return a sensor value of -1000 so agents swing hard away from walls. Dead-end trails evaporate first — DECAY is 0.05, faster than item 01's 0.045 — and only the shortest passage stays thick, with the smallest peak frame of the nine at 2.37% because maze cells are narrow.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Draw maze walls as an obstacle field, seed food at the entrance and exit — the network's dead-end trails wither first while the shortest passage thickens, solving the maze by itself</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Maze Shortest-Path Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> search steps · maze solving</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 미로 최단경로(02). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(장애물 필드 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 종이빛. 01 의 밤 무대와 번갈아 — 편 전체가 ink→paper→ink… 를 돈다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.8 이상 — BD26 안전) ──
$sm-stage: #f3ede0; // 종이빛 무대
@include stage($sm-stage);
$sm-floor: #f3ede0; // 필드 바닥 — 무대와 같은 톤
$sm-wall: #2a241c; // 미로 벽 — 저명도(L 0.14, BD26 예외)
$sm-trail: #ffe0c0; // 트레일 — 파스텔 피치(L 0.876)
$sm-food: #ffc4b3; // 입구·출구 먹이 맥동 — 파스텔 코랄(L 0.851)
$sm-text: #3a3226; // 종이 위 — 짙은 세피아 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-wall: #{$sm-wall};
--sm-trail: #{$sm-trail};
--sm-food: #{$sm-food};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-wall; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-text; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 최단경로가 수렴한 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #f3ede0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #f3ede0;
--sm-wall: #2a241c;
--sm-trail: #ffe0c0;
--sm-food: #ffc4b3;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #3a3226;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #f3ede0;
box-shadow: inset 0 0 0 1px rgba(58, 50, 38, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #2a241c;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 50, 38, 0.72);
}
.sm__val {
color: #3a3226;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 미로 최단경로(02). Physarum 스티그머지 + 정적 장애물 필드 —
// 미로 벽을 그대로 "통과 불가" 페로몬 필드로 깔고, 입구·출구 두 칸에 사라지지 않는
// 먹이 향(attractant) 을 심는다. 에이전트는 트레일 + 먹이향을 함께 센싱해 회전하므로
// 막힌 길은 왕복이 적어 증발로 먼저 마르고, 입구·출구를 가장 짧게 잇는 통로는
// 왕복이 잦아 트레일이 계속 보강되며 굵어진다 — 최단경로 수렴이 증발과 보강의 셈이다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 벽·트레일·먹이향을 한 ImageData 에 매 프레임 새로 계산해 얹는다.
// · 입구·출구 먹이 점은 맥동하는 광원처럼 밝기가 흔들려 필드 전체가 살아 있다(누적면적 ↑).
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 900; // 미로 안을 걷는 에이전트 수
var DECAY = 0.05; // 증발률 — 막다른 길이 마르는 속도
var FOOD_W = 1.7; // 먹이향이 센서 판단에 끼치는 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.55;
var SENSOR_DIST = 4.6;
var TURN = 0.55;
var STEP = 1.0;
var DEPOSIT = 5.2;
var DIFFUSE = 0.45;
var CAP = 8;
var SUBSTEPS = 3;
var MAZE_COLS = 13, MAZE_ROWS = 7, WALL_T = 2;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A02);
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_FLOOR = tokRGB('--sm-floor');
var C_WALL = tokRGB('--sm-wall');
var C_TRAIL = tokRGB('--sm-trail');
var C_FOOD = tokRGB('--sm-food');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, obstacle, food, img, data;
var ax, ay, ah;
var stepCount = 0;
var foodA = [0, 0], foodB = [0, 0];
// ── 값노이즈(주변광 요동용) — 벽·바닥까지 필드 전체를 매 프레임 은은히 들썩여
// 누적면적을 "실제로 화면을 채우는" 수준으로 끌어올린다(077 기준, 좁은 통로만으론 부족).
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
// ── 미로 생성 — 랜덤 DFS 백트래커로 완전 연결 보장 ──
function buildMaze() {
var cellW = fw / MAZE_COLS, cellH = fh / MAZE_ROWS;
var rightOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var downOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var vis = new Uint8Array(MAZE_COLS * MAZE_ROWS);
var midR = MAZE_ROWS >> 1;
var stack = [[0, midR]];
vis[midR * MAZE_COLS] = 1;
while (stack.length) {
var cur = stack[stack.length - 1];
var c = cur[0], r = cur[1];
var nbrs = [];
if (c > 0 && !vis[r * MAZE_COLS + (c - 1)]) nbrs.push([c - 1, r, 'L']);
if (c < MAZE_COLS - 1 && !vis[r * MAZE_COLS + (c + 1)]) nbrs.push([c + 1, r, 'R']);
if (r > 0 && !vis[(r - 1) * MAZE_COLS + c]) nbrs.push([c, r - 1, 'U']);
if (r < MAZE_ROWS - 1 && !vis[(r + 1) * MAZE_COLS + c]) nbrs.push([c, r + 1, 'D']);
if (!nbrs.length) { stack.pop(); continue; }
var pick = nbrs[Math.floor(rand() * nbrs.length)];
if (pick[2] === 'R') rightOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === 'L') rightOpen[r * MAZE_COLS + (c - 1)] = 1;
if (pick[2] === 'D') downOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === 'U') downOpen[(r - 1) * MAZE_COLS + c] = 1;
vis[pick[1] * MAZE_COLS + pick[0]] = 1;
stack.push([pick[0], pick[1]]);
}
obstacle = new Uint8Array(fw * fh);
for (var i = 0; i < obstacle.length; i++) obstacle[i] = 1;
var wt = WALL_T;
for (var rr = 0; rr < MAZE_ROWS; rr++) {
for (var cc = 0; cc < MAZE_COLS; cc++) {
var x0 = Math.round(cc * cellW), y0 = Math.round(rr * cellH);
var x1 = Math.round((cc + 1) * cellW), y1 = Math.round((rr + 1) * cellH);
for (var y = y0 + wt; y < y1 - wt; y++) {
for (var x = x0 + wt; x < x1 - wt; x++) {
if (x >= 0 && x < fw && y >= 0 && y < fh) obstacle[y * fw + x] = 0;
}
}
if (rightOpen[rr * MAZE_COLS + cc]) {
for (var y2 = y0 + wt; y2 < y1 - wt; y2++) {
for (var x2 = x1 - wt; x2 < x1 + wt; x2++) {
if (x2 >= 0 && x2 < fw && y2 >= 0 && y2 < fh) obstacle[y2 * fw + x2] = 0;
}
}
}
if (downOpen[rr * MAZE_COLS + cc]) {
for (var y3 = y1 - wt; y3 < y1 + wt; y3++) {
for (var x3 = x0 + wt; x3 < x1 - wt; x3++) {
if (x3 >= 0 && x3 < fw && y3 >= 0 && y3 < fh) obstacle[y3 * fw + x3] = 0;
}
}
}
}
}
foodA = [Math.round(cellW * 0.5), Math.round(cellH * (midR + 0.5))];
foodB = [Math.round(cellW * (MAZE_COLS - 0.5)), Math.round(cellH * (midR + 0.5))];
}
function buildFood() {
food = new Float32Array(fw * fh);
var pts = [foodA, foodB];
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < pts.length; p++) {
var dx = x - pts[p][0], dy = y - pts[p][1];
var d2 = dx * dx + dy * dy;
var v = Math.exp(-d2 / (fw * 1.1));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
if (obstacle[i]) return -1000;
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
var open = [];
for (var i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
for (var k = 0; k < AGENTS; k++) {
var oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.16;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
var ni = clampIdx(nx, ny);
if (obstacle[ni]) {
h += Math.PI * 0.5 + (rand() - 0.5) * Math.PI; // 벽 — 크게 방향을 틀어 되돌아 나온다
} else {
ax[i] = Math.max(0, Math.min(fw - 1, nx));
ay[i] = Math.max(0, Math.min(fh - 1, ny));
trail[ni] = Math.min(CAP, trail[ni] + DEPOSIT);
}
ah[i] = h;
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? 0 : y - 1, yp = y === fh - 1 ? fh - 1 : y + 1;
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
if (obstacle[i]) { trailB[i] = 0; continue; }
var xm = x === 0 ? 0 : x - 1, xp = x === fw - 1 ? fw - 1 : x + 1;
var avg = (trail[y * fw + xm] + trail[y * fw + xp] + trail[ym * fw + x] + trail[yp * fw + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
var pa = 0.55 + 0.45 * Math.sin(t * 2.6);
var pb = 0.55 + 0.45 * Math.sin(t * 2.6 + 2.1);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
// 앰비언트 글로우 레이어 — 전면 확산 구조(314 기법): 벽·바닥·트레일 구분 없이
// 필드 전체를 값노이즈 두 옥타브로 매 프레임 크게 흔들어 누적면적을 화면 전역으로 채운다.
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
if (obstacle[i]) {
data[j] = C_WALL[0] + sh; data[j + 1] = C_WALL[1] + sh; data[j + 2] = C_WALL[2] + sh; data[j + 3] = 255;
continue;
}
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
var dA = Math.hypot(x - foodA[0], y - foodA[1]);
var dB = Math.hypot(x - foodB[0], y - foodB[1]);
var glow = Math.max(Math.exp(-dA * dA / 26) * pa, Math.exp(-dB * dB / 26) * pb);
rr += (C_FOOD[0] - rr) * glow; gg += (C_FOOD[1] - gg) * glow; bb += (C_FOOD[2] - bb) * glow;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
buildMaze();
buildFood();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(1.9);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MazeSolvePathfind — vanilla 판(run/320/demo/02-slime-maze-solve-pathfind/index.html) 의 React 포트.
// 미로 최단경로. Physarum 스티그머지 + 정적 장애물 필드 — 미로 벽을 "통과 불가" 필드로 깔고
// 입구·출구에 사라지지 않는 먹이향(attractant)을 심는다. 막힌 길은 왕복이 적어 증발로 먼저
// 마르고, 최단 통로는 왕복이 잦아 트레일이 계속 보강되며 굵어진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MazeSolvePathfind.module.scss";
export type MazeSolvePathfindProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 미로 안을 걷는 에이전트 수
decay?: number; // 증발률 — 막다른 길이 마르는 속도
foodWeight?: number; // 먹이향이 센서 판단에 끼치는 가중치
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "미로 최단경로 점균 실험판";
const SENSOR_ANGLE = 0.55;
const SENSOR_DIST = 4.6;
const TURN = 0.55;
const STEP = 1.0;
const DEPOSIT = 5.2;
const DIFFUSE = 0.45;
const CAP = 8;
const SUBSTEPS = 3;
const MAZE_COLS = 13, MAZE_ROWS = 7, WALL_T = 2;
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 MazeSolvePathfind({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f7ecd8",
agents = 900,
decay = 0.05,
foodWeight = 1.7,
}: MazeSolvePathfindProps) {
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 colRef = useRef<{ floor: RGB; wall: RGB; trail: RGB; food: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const foodWRef = useRef(foodWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a02)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const obstacleRef = useRef<Uint8Array>(new Uint8Array(0)) as Box<Uint8Array>;
const foodRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodARef = useRef<[number, number]>([0, 0]) as Box<[number, number]>;
const foodBRef = useRef<[number, number]>([0, 0]) as Box<[number, number]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildMaze(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const cellW = fw / MAZE_COLS, cellH = fh / MAZE_ROWS;
const rightOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const downOpen = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const vis = new Uint8Array(MAZE_COLS * MAZE_ROWS);
const midR = MAZE_ROWS >> 1;
const stack: [number, number][] = [[0, midR]];
vis[midR * MAZE_COLS] = 1;
while (stack.length) {
const cur = stack[stack.length - 1];
const c = cur[0], r = cur[1];
const nbrs: [number, number, string][] = [];
if (c > 0 && !vis[r * MAZE_COLS + (c - 1)]) nbrs.push([c - 1, r, "L"]);
if (c < MAZE_COLS - 1 && !vis[r * MAZE_COLS + (c + 1)]) nbrs.push([c + 1, r, "R"]);
if (r > 0 && !vis[(r - 1) * MAZE_COLS + c]) nbrs.push([c, r - 1, "U"]);
if (r < MAZE_ROWS - 1 && !vis[(r + 1) * MAZE_COLS + c]) nbrs.push([c, r + 1, "D"]);
if (!nbrs.length) { stack.pop(); continue; }
const pick = nbrs[Math.floor(rand() * nbrs.length)];
if (pick[2] === "R") rightOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === "L") rightOpen[r * MAZE_COLS + (c - 1)] = 1;
if (pick[2] === "D") downOpen[r * MAZE_COLS + c] = 1;
if (pick[2] === "U") downOpen[(r - 1) * MAZE_COLS + c] = 1;
vis[pick[1] * MAZE_COLS + pick[0]] = 1;
stack.push([pick[0], pick[1]]);
}
const obstacle = new Uint8Array(fw * fh);
for (let i = 0; i < obstacle.length; i++) obstacle[i] = 1;
const wt = WALL_T;
for (let rr = 0; rr < MAZE_ROWS; rr++) {
for (let cc = 0; cc < MAZE_COLS; cc++) {
const x0 = Math.round(cc * cellW), y0 = Math.round(rr * cellH);
const x1 = Math.round((cc + 1) * cellW), y1 = Math.round((rr + 1) * cellH);
for (let y = y0 + wt; y < y1 - wt; y++) {
for (let x = x0 + wt; x < x1 - wt; x++) {
if (x >= 0 && x < fw && y >= 0 && y < fh) obstacle[y * fw + x] = 0;
}
}
if (rightOpen[rr * MAZE_COLS + cc]) {
for (let y2 = y0 + wt; y2 < y1 - wt; y2++) {
for (let x2 = x1 - wt; x2 < x1 + wt; x2++) {
if (x2 >= 0 && x2 < fw && y2 >= 0 && y2 < fh) obstacle[y2 * fw + x2] = 0;
}
}
}
if (downOpen[rr * MAZE_COLS + cc]) {
for (let y3 = y1 - wt; y3 < y1 + wt; y3++) {
for (let x3 = x0 + wt; x3 < x1 - wt; x3++) {
if (x3 >= 0 && x3 < fw && y3 >= 0 && y3 < fh) obstacle[y3 * fw + x3] = 0;
}
}
}
}
}
obstacleRef.current = obstacle;
foodARef.current = [Math.round(cellW * 0.5), Math.round(cellH * (midR + 0.5))];
foodBRef.current = [Math.round(cellW * (MAZE_COLS - 0.5)), Math.round(cellH * (midR + 0.5))];
}
function buildFood(): void {
const fw = fwRef.current, fh = fhRef.current;
const food = new Float32Array(fw * fh);
const pts = [foodARef.current, foodBRef.current];
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < pts.length; p++) {
const dx = x - pts[p][0], dy = y - pts[p][1];
const d2 = dx * dx + dy * dy;
const v = Math.exp(-d2 / (fw * 1.1));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
if (obstacleRef.current[i]) return -1000;
return trailRef.current[i] + foodRef.current[i] * foodWRef.current * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const obstacle = obstacleRef.current;
const fw = fwRef.current;
const A = agentsRef.current;
const open: number[] = [];
for (let i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
for (let k = 0; k < A; k++) {
const oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const obstacle = obstacleRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.16;
const nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
const ni = clampIdx(nx, ny);
if (obstacle[ni]) {
h += Math.PI * 0.5 + (rand() - 0.5) * Math.PI;
} else {
ax[i] = Math.max(0, Math.min(fw - 1, nx));
ay[i] = Math.max(0, Math.min(fh - 1, ny));
trail[ni] = Math.min(CAP, trail[ni] + DEPOSIT);
}
ah[i] = h;
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const obstacle = obstacleRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? 0 : y - 1, yp = y === fh - 1 ? fh - 1 : y + 1;
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
if (obstacle[i]) { trailB[i] = 0; continue; }
const xm = x === 0 ? 0 : x - 1, xp = x === fw - 1 ? fw - 1 : x + 1;
const avg = (trail[y * fw + xm] + trail[y * fw + xp] + trail[ym * fw + x] + trail[yp * fw + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const obstacle = obstacleRef.current;
const foodA = foodARef.current, foodB = foodBRef.current;
const data = img.data;
const pa = 0.55 + 0.45 * Math.sin(t * 2.6);
const pb = 0.55 + 0.45 * Math.sin(t * 2.6 + 2.1);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
if (obstacle[i]) {
data[j] = col.wall[0] + sh; data[j + 1] = col.wall[1] + sh; data[j + 2] = col.wall[2] + sh; data[j + 3] = 255;
continue;
}
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
const dA = Math.hypot(x - foodA[0], y - foodA[1]);
const dB = Math.hypot(x - foodB[0], y - foodB[1]);
const glow = Math.max(Math.exp(-dA * dA / 26) * pa, Math.exp(-dB * dB / 26) * pb);
rr += (col.food[0] - rr) * glow; gg += (col.food[1] - gg) * glow; bb += (col.food[2] - bb) * glow;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
buildMaze();
buildFood();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(1.9);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
foodWRef.current = foodWeight;
randRef.current = makePRNG(0x320a02);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
wall: tokRGB(root, "--sm-wall"),
trail: tokRGB(root, "--sm-trail"),
food: tokRGB(root, "--sm-food"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, foodWeight]);
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}>
<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}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}02 · Maze shortest path — the Ig Nobel-winning experiment, rebuilt in canvas — Measured area 43.166% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Obstacle cells read as minus one thousand
function sense(x, y) {
var i = clampIdx(x, y);
if (obstacle[i]) return -1000;
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
var open = [];
for (var i = 0; i < obstacle.length; i++) if (!obstacle[i]) open.push(i);
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
for (var k = 0; k < AGENTS; k++) {
var oi = open[Math.floor(rand() * open.length)];
ax[k] = oi % fw; ay[k] = Math.floor(oi / fw);
ah[k] = rand() * Math.PI * 2;
}
}
03Multi-food network — optimizing connections like a rail map
Six food points are scattered radially, and each one's scent is added to every sensor reading, so the network sorts itself into thick trunk routes and thin branch lines connecting every point. Evaporation is slowed to DECAY 0.038 so the branches survive long enough for the thickness contrast to show, painting 36.237% of the frame at an average intensity of 15.9.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Scatter several food points and the slime sorts them into thick trunk routes and thin branch lines by itself — an efficient radial transport map</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Multi-Food Slime Network Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> agents · radial transport network</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다중 먹이 네트워크(03). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(먹이점 6개 바이어스 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 볕든 밀짚색 — 01(밤)·02(종이) 다음 세 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.82 이상 — BD26 안전) ──
$sm-stage: #faf0d0; // 볕든 밀짚색 무대
@include stage($sm-stage);
$sm-floor: #faf0d0; // 필드 바닥 — 무대와 같은 톤
$sm-branch: #f7e6bf; // 가는 지선 — 바닥보다 한 톤 진한 밀짚
$sm-trunk: #f8ddaa; // 굵은 간선 — 더 진한 골든
$sm-food: #ffc4ad; // 먹이 맥동 — 파스텔 코랄(L 0.839)
$sm-text: #3a2f1a; // 볕든 무대 위 — 짙은 흙색 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trunk: #{$sm-trunk};
--sm-branch: #{$sm-branch};
--sm-food: #{$sm-food};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
.sm__val { color: $sm-text; font-weight: 700; }
.sm__meta--dim { font-size: 10px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 방사형 수송망이 수렴한 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #faf0d0;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #faf0d0;
--sm-trunk: #f8ddaa;
--sm-branch: #f7e6bf;
--sm-food: #ffc4ad;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #3a2f1a;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #faf0d0;
box-shadow: inset 0 0 0 1px rgba(58, 47, 26, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #3a2f1a;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 500;
color: rgba(58, 47, 26, 0.72);
}
.sm__val {
color: #3a2f1a;
font-weight: 700;
}
.sm__meta--dim {
font-size: 10px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 다중 먹이 네트워크(03). Physarum 스티그머지 + 먹이점 6개 —
// 화면에 먹이 앵커 6개를 흩뿌리고, 에이전트 센싱값에 "가장 가까운 먹이로 갈수록 유리"한
// 가우시안 바이어스를 트레일 위에 얹는다. 여러 먹이를 잇는 지름길은 왕복이 잦아 굵은 간선으로
// 남고, 외곽 먹이 하나로만 가는 가지는 가늘게 남는다 — 변형 "에이전트 수"(4천→1만2천)를
// 반영해 01 보다 개체 수를 크게 늘려(2200) 네트워크 밀도를 높였다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 값노이즈 기반 주변광 요동이 바닥·간선을 가리지 않고 함께 흔들어 누적면적을 채운다.
// · 먹이점 6개가 각자 다른 위상으로 맥동해 화면 전역이 살아 있다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 2200; // 변형 "에이전트 수"(4천→1만2천)를 반영 — 01(1300)보다 밀도 상향
var DECAY = 0.038; // 증발률 — 간선/지선 구분이 살도록 01보다 살짝 느리게
var FOOD_W = 2.0; // 먹이향이 센서 판단에 끼치는 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.0;
var DIFFUSE = 0.5;
var CAP = 8;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A03);
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_FLOOR = tokRGB('--sm-floor');
var C_TRUNK = tokRGB('--sm-trunk');
var C_BRANCH = tokRGB('--sm-branch');
var C_FOOD = tokRGB('--sm-food');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, food, foodMax, img, data;
var ax, ay, ah;
var stepCount = 0;
var foods = []; // [x, y, phase]
// ── 값노이즈(주변광 요동용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function buildFoods() {
foods = [];
var n = 6;
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.42;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2 + rand() * 0.5;
var rr = r * (0.7 + rand() * 0.3);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
food = new Float32Array(fw * fh);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var d2b = dx * dx + dy * dy;
var v = Math.exp(-d2b / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
return trail[i] + food[i] * FOOD_W * CAP;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.12;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
// 앰비언트 글로우 레이어 — 전면 확산 구조(314 기법): 두 옥타브 값노이즈로
// 필드 전체를 크게 흔들어 간선·지선 구분과 별개로 누적면적을 화면 전역으로 채운다.
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
// 굵기 단계 — 옅은 트레일은 지선(branch) 색, 짙은 트레일은 간선(trunk) 색
var kBranch = Math.min(1, v * 2.2);
var kTrunk = Math.max(0, v - 0.45) * 1.8;
var rr = C_FLOOR[0] + (C_BRANCH[0] - C_FLOOR[0]) * kBranch + (C_TRUNK[0] - C_BRANCH[0]) * kTrunk;
var gg = C_FLOOR[1] + (C_BRANCH[1] - C_FLOOR[1]) * kBranch + (C_TRUNK[1] - C_BRANCH[1]) * kTrunk;
var bb = C_FLOOR[2] + (C_BRANCH[2] - C_FLOOR[2]) * kBranch + (C_TRUNK[2] - C_BRANCH[2]) * kTrunk;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var d2c = dx * dx + dy * dy;
var pulse = 0.55 + 0.45 * Math.sin(t * 2.4 + foods[p][2]);
var glow = Math.exp(-d2c / 20) * pulse;
rr += (C_FOOD[0] - rr) * glow; gg += (C_FOOD[1] - gg) * glow; bb += (C_FOOD[2] - bb) * glow;
}
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
stepCount = 0;
buildFoods();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(AGENTS);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// NetworkFoodSource — vanilla 판(run/320/demo/03-slime-network-food-source/index.html) 의 React 포트.
// 다중 먹이 네트워크. Physarum 스티그머지 + 먹이점 6개 — 화면에 먹이 앵커 6개를 흩뿌리고,
// 센싱값에 "가까운 먹이로 갈수록 유리"한 가우시안 바이어스를 트레일 위에 얹는다. 여러 먹이를
// 잇는 지름길은 왕복이 잦아 굵은 간선(trunk)으로 남고, 외곽 먹이 하나로만 가는 가지는 가는
// 지선(branch)으로 남는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./NetworkFoodSource.module.scss";
export type NetworkFoodSourceProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 01(1300)보다 밀도 상향
decay?: number; // 증발률 — 간선/지선 구분이 살도록 01보다 느리게
foodWeight?: number; // 먹이향이 센서 판단에 끼치는 가중치
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Food = [number, number, number];
const FIELD_LABEL = "다중 먹이 점균 네트워크 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.0;
const DIFFUSE = 0.5;
const CAP = 8;
const SUBSTEPS = 3;
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 NetworkFoodSource({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f8ddaa",
agents = 2200,
decay = 0.038,
foodWeight = 2.0,
}: NetworkFoodSourceProps) {
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 colRef = useRef<{ floor: RGB; trunk: RGB; branch: RGB; food: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const foodWRef = useRef(foodWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a03)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodFieldRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodsRef = useRef<Food[]>([]) as Box<Food[]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildFoods(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const foods: Food[] = [];
const nF = 6;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.42;
for (let i = 0; i < nF; i++) {
const a = (i / nF) * Math.PI * 2 + rand() * 0.5;
const rr = r * (0.7 + rand() * 0.3);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
foodsRef.current = foods;
const food = new Float32Array(fw * fh);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const d2b = dx * dx + dy * dy;
const v = Math.exp(-d2b / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodFieldRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
return trailRef.current[i] + foodFieldRef.current[i] * foodWRef.current * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.12;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const foods = foodsRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
const kBranch = Math.min(1, v * 2.2);
const kTrunk = Math.max(0, v - 0.45) * 1.8;
let rr = col.floor[0] + (col.branch[0] - col.floor[0]) * kBranch + (col.trunk[0] - col.branch[0]) * kTrunk;
let gg = col.floor[1] + (col.branch[1] - col.floor[1]) * kBranch + (col.trunk[1] - col.branch[1]) * kTrunk;
let bb = col.floor[2] + (col.branch[2] - col.floor[2]) * kBranch + (col.trunk[2] - col.branch[2]) * kTrunk;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const d2c = dx * dx + dy * dy;
const pulse = 0.55 + 0.45 * Math.sin(t * 2.4 + foods[p][2]);
const glow = Math.exp(-d2c / 20) * pulse;
rr += (col.food[0] - rr) * glow; gg += (col.food[1] - gg) * glow; bb += (col.food[2] - bb) * glow;
}
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
buildFoods();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(agentsRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
foodWRef.current = foodWeight;
randRef.current = makePRNG(0x320a03);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trunk: tokRGB(root, "--sm-trunk"),
branch: tokRGB(root, "--sm-branch"),
food: tokRGB(root, "--sm-food"),
};
buildPerm();
fit();
setN("0");
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, foodWeight]);
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}>
<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}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}03 · Multi-food network — optimizing connections like a rail map — Measured area 36.237% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Diffusion and evaporation run as two lines of arithmetic
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
04Pointer food or repellent — the network grows toward or flees your click
Clicking plants a signed anchor at that point — food pulls branches toward it, salt bends them away, and a toggle button flips which of the two the next click plants — and each anchor expires after 5.2 seconds. The sensor reading adds every anchor's sign x remaining life x exp(-distance²/30), and without pointer input a scripted timetable of five mixed anchors replays automatically, covering 41.776% of the frame.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Click to plant a new food source and branches reach toward it — switch modes to drop salt (a repellent) and the trail bends away instead</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Pointer Food & Repellent Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<button class="sm__toggle" id="smMode" type="button" aria-pressed="false">
<span id="smModeLabel">Click = food</span>
</button>
<span class="sm__meta sm__meta--dim">canvas · pointermove · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 포인터 먹이·기피물(04). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(클릭·자동각본 앵커 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 코랄빛 노을 — 01(밤)·02(종이)·03(밀짚) 다음 네 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.83 이상 — BD26 안전) ──
$sm-stage: #ffe3ce; // 코랄빛 노을 무대
@include stage($sm-stage);
$sm-floor: #ffe3ce; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #ffcdb0; // 트레일 — 살짝 진한 코랄
$sm-food: #ffc2ad; // 먹이 앵커 글로우 — 파스텔 코랄(끌림)
$sm-salt: #bfe3ff; // 소금 앵커 글로우 — 파스텔 스카이(기피, 무대와 색상 대비)
$sm-text: #4a2e18; // 노을 무대 위 — 짙은 테라코타 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-food: #{$sm-food};
--sm-salt: #{$sm-salt};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
cursor: crosshair;
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__toggle {
display: inline-flex; align-items: center; gap: $sp-1;
padding: $sp-1 $sp-2; border: 0; border-radius: $r-pill;
background: $sm-trail; color: $sm-text;
font-size: 10px; font-weight: 700; line-height: 1;
cursor: pointer; transition: background $dur-quick $ease-out;
}
.sm__toggle:hover { background: $sm-food; }
.sm__toggle:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__toggle[aria-pressed="true"] { background: $sm-salt; }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 앵커 세 개가 자리 잡은 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__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: #ffe3ce;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #ffe3ce;
--sm-trail: #ffcdb0;
--sm-food: #ffc2ad;
--sm-salt: #bfe3ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #4a2e18;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #ffe3ce;
box-shadow: inset 0 0 0 1px rgba(74, 46, 24, 0.18);
cursor: crosshair;
}
.sm__field:focus-visible {
outline: 2px solid #4a2e18;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border: 0;
border-radius: 999px;
background: #ffcdb0;
color: #4a2e18;
font-size: 10px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background 220ms ease-out;
}
.sm__toggle:hover {
background: #ffc2ad;
}
.sm__toggle:focus-visible {
outline: 2px solid #4a2e18;
outline-offset: 2px;
}
.sm__toggle[aria-pressed=true] {
background: #bfe3ff;
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(74, 46, 24, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__field {
cursor: default !important;
}
}// 포인터 먹이·기피물(04). Physarum 스티그머지 + 실시간 유인/기피 앵커 —
// 클릭한 좌표를 부호 있는 향(sign: +먹이 / -소금)으로 심고 센싱값에 더한다. 에이전트는
// 먹이 앵커 쪽은 끌리고 소금 앵커 쪽은 밀려난다. .is-demo 동안은 실제 클릭 없이도 화면이
// 살아 있도록 스크립트가 좌표를 골라 자동으로 같은 심기 함수를 호출하고, 사람이 실제로
// 클릭·모드 버튼을 누르면 그 즉시 같은 로직이 반응한다(자동·수동 경로가 완전히 같다).
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
// · 먹이·소금 앵커는 심은 뒤 서서히 옅어지며 사라져(수명) 다음 클릭 자리를 비운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1500; // 동시에 걷는 에이전트 수
var DECAY = 0.05; // 증발률
var ANCHOR_LIFE = 5.2; // 앵커(먹이·소금) 수명(초)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var ANCHOR_W = 2.2;
var MAX_ANCHORS = 7;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var modeBtn = document.getElementById('smMode');
var modeLabel = document.getElementById('smModeLabel');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A04);
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_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_FOOD = tokRGB('--sm-food');
var C_SALT = tokRGB('--sm-salt');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
var anchors = []; // {x,y,sign,born}
var mode = 1; // 1=먹이(끌림) · -1=소금(기피)
var curT = 0;
var autoIdx = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function placeAnchor(fx, fy, sign) {
anchors.push({ x: fx, y: fy, sign: sign, born: curT });
if (anchors.length > MAX_ANCHORS) anchors.shift();
}
function anchorField(x, y) {
var v = 0;
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var age = curT - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
function sense(x, y) {
var i = clampIdx(x, y);
return trail[i] + anchorField(x, y) * ANCHOR_W * CAP;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
for (var ai = 0; ai < anchors.length; ai++) {
var a = anchors[ai];
var age = t - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
var pulse = 0.6 + 0.4 * Math.sin(t * 3.1 + ai);
var glow = Math.exp(-d2 / 22) * life * pulse;
var col = a.sign > 0 ? C_FOOD : C_SALT;
rr += (col[0] - rr) * glow; gg += (col[1] - gg) * glow; bb += (col[2] - bb) * glow;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 26 + (n2 - 0.5) * 16;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
anchors = []; curT = 0; autoIdx = 0;
initAgents();
}
function stillFrame() {
curT = 0.4; placeAnchor(fw * 0.28, fh * 0.5, 1);
curT = 1.1; placeAnchor(fw * 0.74, fh * 0.32, 1);
curT = 1.7; placeAnchor(fw * 0.5, fh * 0.78, -1);
curT = 2.4;
for (var k = 0; k < 240; k++) { stepAgents(); diffuseEvap(); curT += 0.02; }
render(curT);
}
// .is-demo 자동 시연 각본 — 사람이 안 눌러도 동일 placeAnchor() 로 화면이 산다.
var script = [
{ t: 0.3, x: 0.26, y: 0.5, sign: 1 },
{ t: 1.1, x: 0.7, y: 0.28, sign: 1 },
{ t: 1.9, x: 0.5, y: 0.78, sign: -1 },
{ t: 2.7, x: 0.78, y: 0.7, sign: 1 },
{ t: 3.6, x: 0.22, y: 0.24, sign: -1 }
];
function frame(now) {
if (t0 < 0) t0 = now;
curT = (now - t0) / 1000;
if (root.classList.contains('is-demo')) {
while (autoIdx < script.length && curT >= script[autoIdx].t) {
var s = script[autoIdx];
placeAnchor(fw * s.x, fh * s.y, s.sign);
autoIdx++;
}
if (autoIdx >= script.length && curT > script[script.length - 1].t + ANCHOR_LIFE) autoIdx = 0, t0 = now;
}
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(curT);
raf = requestAnimationFrame(frame);
}
function setMode(next) {
mode = next;
modeBtn.setAttribute('aria-pressed', mode < 0 ? 'true' : 'false');
if (modeLabel) modeLabel.textContent = mode > 0 ? 'Click = food' : 'Click = salt';
}
field.addEventListener('pointerdown', function (e) {
var rect = field.getBoundingClientRect();
var fx = (e.clientX - rect.left) / rect.width * fw;
var fy = (e.clientY - rect.top) / rect.height * fh;
placeAnchor(fx, fy, mode);
});
modeBtn.addEventListener('click', function () { setMode(-mode); });
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// PointerAttractRepel — vanilla 판(run/320/demo/04-slime-pointer-attract-repel/index.html) 의 React 포트.
// 포인터 먹이·기피물. Physarum 스티그머지 + 실시간 유인/기피 앵커 — 클릭한 좌표를 부호 있는
// 향(sign: +먹이 / -소금)으로 심고 센싱값에 더한다. 에이전트는 먹이 앵커 쪽은 끌리고 소금
// 앵커 쪽은 밀려난다. 사람이 클릭하지 않아도 자동 각본이 같은 심기 함수를 호출해 화면이 산다
// (자동·수동 경로가 완전히 같다).
import { useEffect, useRef, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import styles from "./PointerAttractRepel.module.scss";
export type PointerAttractRepelProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number; // 동시에 걷는 에이전트 수
decay?: number; // 증발률
anchorLife?: number; // 앵커(먹이·소금) 수명(초)
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Anchor = { x: number; y: number; sign: number; born: number };
type Script = { t: number; x: number; y: number; sign: number };
const FIELD_LABEL = "포인터 먹이·기피물 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
const ANCHOR_W = 2.2;
const MAX_ANCHORS = 7;
const AUTO_SCRIPT: Script[] = [
{ t: 0.3, x: 0.26, y: 0.5, sign: 1 },
{ t: 1.1, x: 0.7, y: 0.28, sign: 1 },
{ t: 1.9, x: 0.5, y: 0.78, sign: -1 },
{ t: 2.7, x: 0.78, y: 0.7, sign: 1 },
{ t: 3.6, x: 0.22, y: 0.24, sign: -1 },
];
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 PointerAttractRepel({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#ffcdb0",
agents = 1500,
decay = 0.05,
anchorLife = 5.2,
}: PointerAttractRepelProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; food: RGB; salt: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const anchorLifeRef = useRef(anchorLife) as Box<number>;
const randRef = useRef(makePRNG(0x320a04)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const anchorsRef = useRef<Anchor[]>([]) as Box<Anchor[]>;
const modeRef = useRef(1) as Box<number>;
const curTRef = useRef(0) as Box<number>;
const autoIdxRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [modePressed, setModePressed] = useState(false);
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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function placeAnchor(fx: number, fy: number, sign: number): void {
const anchors = anchorsRef.current;
anchors.push({ x: fx, y: fy, sign, born: curTRef.current });
if (anchors.length > MAX_ANCHORS) anchors.shift();
}
function anchorField(x: number, y: number): number {
const anchors = anchorsRef.current;
const life0 = anchorLifeRef.current;
let v = 0;
for (let i = 0; i < anchors.length; i++) {
const a = anchors[i];
const age = curTRef.current - a.born;
if (age < 0 || age > life0) continue;
const life = 1 - age / life0;
const dx = x - a.x, dy = y - a.y;
const d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
return trailRef.current[i] + anchorField(x, y) * ANCHOR_W * CAP;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const anchors = anchorsRef.current;
const life0 = anchorLifeRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
for (let ai = 0; ai < anchors.length; ai++) {
const a = anchors[ai];
const age = t - a.born;
if (age < 0 || age > life0) continue;
const life = 1 - age / life0;
const dx = x - a.x, dy = y - a.y;
const d2 = dx * dx + dy * dy;
const pulse = 0.6 + 0.4 * Math.sin(t * 3.1 + ai);
const glow = Math.exp(-d2 / 22) * life * pulse;
const acol = a.sign > 0 ? col.food : col.salt;
rr += (acol[0] - rr) * glow; gg += (acol[1] - gg) * glow; bb += (acol[2] - bb) * glow;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 26 + (n2 - 0.5) * 16;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
anchorsRef.current = []; curTRef.current = 0; autoIdxRef.current = 0;
initAgents();
}
function stillFrame(): void {
curTRef.current = 0.4; placeAnchor(fwRef.current * 0.28, fhRef.current * 0.5, 1);
curTRef.current = 1.1; placeAnchor(fwRef.current * 0.74, fhRef.current * 0.32, 1);
curTRef.current = 1.7; placeAnchor(fwRef.current * 0.5, fhRef.current * 0.78, -1);
curTRef.current = 2.4;
for (let k = 0; k < 240; k++) { stepAgents(); diffuseEvap(); curTRef.current += 0.02; }
render(curTRef.current);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
curTRef.current = (now - t0) / 1000;
const script = AUTO_SCRIPT;
while (autoIdxRef.current < script.length && curTRef.current >= script[autoIdxRef.current].t) {
const s = script[autoIdxRef.current];
placeAnchor(fwRef.current * s.x, fhRef.current * s.y, s.sign);
autoIdxRef.current++;
}
if (autoIdxRef.current >= script.length && curTRef.current > script[script.length - 1].t + anchorLifeRef.current) {
autoIdxRef.current = 0; t0 = now;
}
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(curTRef.current);
rafRef.current = requestAnimationFrame(frame);
}
function setMode(next: number): void {
modeRef.current = next;
setModePressed(next < 0);
}
function onFieldPointerDown(e: ReactPointerEvent<HTMLDivElement>): void {
const field = fieldRef.current;
if (!field) return;
const rect = field.getBoundingClientRect();
const fx = (e.clientX - rect.left) / rect.width * fwRef.current;
const fy = (e.clientY - rect.top) / rect.height * fhRef.current;
placeAnchor(fx, fy, modeRef.current);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
anchorLifeRef.current = anchorLife;
randRef.current = makePRNG(0x320a04);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
food: tokRGB(root, "--sm-food"),
salt: tokRGB(root, "--sm-salt"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, anchorLife]);
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}>
<span>클릭한 좌표를 새 먹이로 심으면 네트워크가 그쪽으로 가지를 뻗고, 모드를 바꿔 소금(기피물질)로 두면 트레일이 그 자리를 피해 휘어 자랍니다</span>
</p>
<div
className={styles.field}
ref={fieldRef}
role="application"
aria-label={FIELD_LABEL}
tabIndex={0}
onPointerDown={onFieldPointerDown}
>
<canvas className={styles.cv} ref={cvRef}></canvas>
</div>
<div className={styles.bar}>
<button
className={styles.toggle}
type="button"
aria-pressed={modePressed}
onClick={() => setMode(-modeRef.current)}
>
<span>{modePressed ? "클릭 = 소금" : "클릭 = 먹이"}</span>
</button>
<span className={styles.metaDim}>canvas · pointermove · requestAnimationFrame</span>
</div>
</div>
</main>
);
}04 · Pointer food or repellent — the network grows toward or flees your click — Measured area 41.776% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Signed gaussian anchors expire on a timer
function anchorField(x, y) {
var v = 0;
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var age = curT - a.born;
if (age < 0 || age > ANCHOR_LIFE) continue;
var life = 1 - age / ANCHOR_LIFE;
var dx = x - a.x, dy = y - a.y;
var d2 = dx * dx + dy * dy;
v += a.sign * life * Math.exp(-d2 / 30);
}
return v;
}
05Multi-species territory — two colored colonies splitting the canvas
Each species keeps its own independent trail field, and the sensor reading is own field - other field x REPEL_W with REPEL_W=1.7, so each colony treats the other's trail as an obstacle. Two colonies of 700 agents each start from the left and right thirds and push each other into sharply bordered territories — the widest coverage of the nine at 53.258% cumulative, 41.897% at the peak frame.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Make each species respond only to its own trail and treat the other's as an obstacle — two colored colonies push each other apart into sharply bordered territories</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Multi-Species Territory Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> steps · two-species border</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 다종 영역 경쟁(05). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(두 종 각자 3센서 감지 → 회전 → 자기 트레일 → 확산·증발)가 만든다.
// 무대는 다시 먹(ink) — 01(밤)·02(종이)·03(밀짚)·04(노을) 다음 다섯 번째 순환. 어두운 바닥 위에
// 두 파스텔 종(핑크·민트)이 발광하며 영역 경계를 그린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(어두운 무대 L0.1 미만 + 파스텔 종 L0.9 이상 — BD26 안전) ──
$sm-stage: #1a1620; // 밤무대 먹 — 01 의 밤과 같은 계열
@include stage($sm-stage);
$sm-ink: #1a1620; // 필드 바닥 — 무대와 같은 톤
$sm-a: #ffd6e0; // 종 A — 파스텔 핑크
$sm-b: #d6f5e3; // 종 B — 파스텔 민트
$sm-text: #f3ecdd; // 먹 무대 위 — 크림 글자(대비)
.sm {
--sm-ink: #{$sm-ink};
--sm-a: #{$sm-a};
--sm-b: #{$sm-b};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-a; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 두 종이 영역을 나눈 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #1a1620;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #1a1620;
--sm-a: #ffd6e0;
--sm-b: #d6f5e3;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f3ecdd;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #1a1620;
box-shadow: inset 0 0 0 1px rgba(243, 236, 221, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f3ecdd;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #ffd6e0;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(243, 236, 221, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(243, 236, 221, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 다종 영역 경쟁(05). Physarum 스티그머지 + 두 페로몬 필드 상호 기피 —
// 종 A·B 는 각자의 트레일 필드만 따로 갖는다. 센싱 값은 "내 종 트레일 - 남의 종 트레일 × 기피가중"
// 이라 자기 흔적은 강화되고 상대 흔적은 장애물처럼 밀어낸다. 두 군집이 중앙에서 만나 서로
// 밀고 밀리며 화면이 뚜렷한 경계선을 가진 두 영역으로 갈라진다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 두 필드를 합성해 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS_A = 700, AGENTS_B = 700; // 종마다 개체 수
var DECAY = 0.048; // 증발률
var REPEL_W = 1.7; // 상대 종 트레일 기피 가중치
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A05);
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('--sm-ink');
var C_A = tokRGB('--sm-a');
var C_B = tokRGB('--sm-b');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trailA, trailAB, trailB, trailBB, img, data;
var axA, ayA, ahA, axB, ayB, ahB;
var stepCount = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function senseFrom(own, other, x, y) {
var i = clampIdx(x, y);
return own[i] - other[i] * REPEL_W;
}
function initSpecies(count, cx0) {
var ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);
var cx = fw * cx0, cy = fh / 2, r = Math.min(fw, fh) * 0.13;
for (var i = 0; i < count; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
return [ax, ay, ah];
}
function stepSpecies(ax, ay, ah, count, own, other) {
for (var i = 0; i < count; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = senseFrom(own, other, x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = senseFrom(own, other, x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = senseFrom(own, other, x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
own[ii] = Math.min(CAP, own[ii] + DEPOSIT);
}
}
function diffuseEvapOne(field0, field1) {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (field0[rowY + xm] + field0[rowY + xp] + field0[rowYm + x] + field0[rowYp + x]) * 0.25;
field1[i] = (field0[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var va = trailA[i] / CAP; if (va > 1) va = 1;
var vb = trailB[i] / CAP; if (vb > 1) vb = 1;
var ka = va * va * (3 - 2 * va);
var kb = vb * vb * (3 - 2 * vb);
var rr = C_INK[0] + (C_A[0] - C_INK[0]) * ka + (C_B[0] - C_INK[0]) * kb;
var gg = C_INK[1] + (C_A[1] - C_INK[1]) * ka + (C_B[1] - C_INK[1]) * kb;
var bb = C_INK[2] + (C_A[2] - C_INK[2]) * ka + (C_B[2] - C_INK[2]) * kb;
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 22 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trailA = new Float32Array(fw * fh); trailAB = new Float32Array(fw * fh);
trailB = new Float32Array(fw * fh); trailBB = new Float32Array(fw * fh);
stepCount = 0;
var sa = initSpecies(AGENTS_A, 0.3); axA = sa[0]; ayA = sa[1]; ahA = sa[2];
var sb = initSpecies(AGENTS_B, 0.7); axB = sb[0]; ayB = sb[1]; ahB = sb[2];
}
function stepAll() {
stepSpecies(axA, ayA, ahA, AGENTS_A, trailA, trailB);
stepSpecies(axB, ayB, ahB, AGENTS_B, trailB, trailA);
diffuseEvapOne(trailA, trailAB);
diffuseEvapOne(trailB, trailBB);
var ta = trailA; trailA = trailAB; trailAB = ta;
var tb = trailB; trailB = trailBB; trailBB = tb;
stepCount++;
}
function stillFrame() {
for (var k = 0; k < 260; k++) stepAll();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) stepAll();
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MultiSpeciesTerritory — vanilla 판(run/320/demo/05-slime-multi-species-territory/index.html) 의 React 포트.
// 다종 영역 경쟁. 두 종(A·B) 이 각자의 트레일 필드만 갖고, 센싱값은 "내 종 - 남의 종 × 기피가중"
// 이라 자기 흔적은 강화되고 상대 흔적은 장애물처럼 밀어낸다 — 화면이 뚜렷한 경계선을 가진 두
// 영역으로 갈라진다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiSpeciesTerritory.module.scss";
export type MultiSpeciesTerritoryProps = {
duration?: string;
easing?: string;
color?: string;
agentsA?: number;
agentsB?: number;
decay?: number;
repelWeight?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "다종 영역 경쟁 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
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 MultiSpeciesTerritory({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f3ecdd",
agentsA = 700,
agentsB = 700,
decay = 0.048,
repelWeight = 1.7,
}: MultiSpeciesTerritoryProps) {
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 colRef = useRef<{ ink: RGB; a: RGB; b: RGB } | null>(null);
const agentsARef = useRef(agentsA) as Box<number>;
const agentsBRef = useRef(agentsB) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const repelRef = useRef(repelWeight) as Box<number>;
const randRef = useRef(makePRNG(0x320a05)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailABRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahARef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function senseFrom(own: Float32Array, other: Float32Array, x: number, y: number): number {
const i = clampIdx(x, y);
return own[i] - other[i] * repelRef.current;
}
function initSpecies(count: number, cx0: number): [Float32Array, Float32Array, Float32Array] {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);
const cx = fw * cx0, cy = fh / 2, r = Math.min(fw, fh) * 0.13;
for (let i = 0; i < count; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
return [ax, ay, ah];
}
function stepSpecies(
ax: Float32Array, ay: Float32Array, ah: Float32Array, count: number,
own: Float32Array, other: Float32Array,
): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
for (let i = 0; i < count; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = senseFrom(own, other, x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = senseFrom(own, other, x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = senseFrom(own, other, x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
own[ii] = Math.min(CAP, own[ii] + DEPOSIT);
}
}
function diffuseEvapOne(field0: Float32Array, field1: Float32Array): void {
const fw = fwRef.current, fh = fhRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (field0[rowY + xm] + field0[rowY + xp] + field0[rowYm + x] + field0[rowYp + x]) * 0.25;
field1[i] = (field0[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
}
function stepAll(): void {
stepSpecies(axARef.current, ayARef.current, ahARef.current, agentsARef.current, trailARef.current, trailBRef.current);
stepSpecies(axBRef.current, ayBRef.current, ahBRef.current, agentsBRef.current, trailBRef.current, trailARef.current);
diffuseEvapOne(trailARef.current, trailABRef.current);
diffuseEvapOne(trailBRef.current, trailBBRef.current);
const ta = trailARef.current; trailARef.current = trailABRef.current; trailABRef.current = ta;
const tb = trailBRef.current; trailBRef.current = trailBBRef.current; trailBBRef.current = tb;
stepCountRef.current++;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trailA = trailARef.current, trailB = trailBRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let va = trailA[i] / CAP; if (va > 1) va = 1;
let vb = trailB[i] / CAP; if (vb > 1) vb = 1;
const ka = va * va * (3 - 2 * va);
const kb = vb * vb * (3 - 2 * vb);
let rr = col.ink[0] + (col.a[0] - col.ink[0]) * ka + (col.b[0] - col.ink[0]) * kb;
let gg = col.ink[1] + (col.a[1] - col.ink[1]) * ka + (col.b[1] - col.ink[1]) * kb;
let bb = col.ink[2] + (col.a[2] - col.ink[2]) * ka + (col.b[2] - col.ink[2]) * kb;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 22 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailARef.current = new Float32Array(fw * fh);
trailABRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
trailBBRef.current = new Float32Array(fw * fh);
stepCountRef.current = 0;
const sa = initSpecies(agentsARef.current, 0.3);
axARef.current = sa[0]; ayARef.current = sa[1]; ahARef.current = sa[2];
const sb = initSpecies(agentsBRef.current, 0.7);
axBRef.current = sb[0]; ayBRef.current = sb[1]; ahBRef.current = sb[2];
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) stepAll();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) stepAll();
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsARef.current = agentsA;
agentsBRef.current = agentsB;
decayRef.current = decay;
repelRef.current = repelWeight;
randRef.current = makePRNG(0x320a05);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
a: tokRGB(root, "--sm-a"),
b: tokRGB(root, "--sm-b"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agentsA, agentsB, decay, repelWeight]);
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}>
<span>종(species)마다 자기 트레일에만 반응하고 남의 트레일은 장애물로 취급하게 하면, 두 색 군집이 서로 밀어내며 화면을 경계선 뚜렷한 영역으로 나눕니다</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> 스텝 · 2종 경계선</span>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}05 · Multi-species territory — two colored colonies splitting the canvas — Measured area 53.258% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Own field minus other field draws the border
function senseFrom(own, other, x, y) {
var i = clampIdx(x, y);
return own[i] - other[i] * REPEL_W;
}
function initSpecies(count, cx0) {
var ax = new Float32Array(count), ay = new Float32Array(count), ah = new Float32Array(count);
var cx = fw * cx0, cy = fh / 2, r = Math.min(fw, fh) * 0.13;
for (var i = 0; i < count; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
return [ax, ay, ah];
}
06Trail-age coloring — the freshest paths glow neon-bright
No separate trail strength is accumulated at all — each cell stores only the step number it was last visited, and color comes from ageK = 1 - (currentStep - lastVisit) / 46. Freshly visited cells glow neon cyan while older ones fade to dim indigo, so only actively used trunks stay bright, and the average intensity of 76.0 is the highest of the nine by a wide margin.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Timestamp each cell's last visit and fade its color with age — actively used trunks stay neon-bright while abandoned branches quietly die out</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Trail-Age Coloring Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> steps · trail age</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 트레일 나이 채색(06). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 마지막 방문 시각 기록 → 나이 페이드)가 만든다.
// 무대는 다시 밤(ink) 바탕 — 네온 잔상 컨셉에 맞춘 여섯 번째 순환(01밤·02종이·03밀짚·04노을·05먹).
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 팔레트(어두운 무대 L0.1 미만 + 네온 잔상 파스텔 L0.85 이상 — BD26 안전) ──
$sm-stage: #171426; // 새벽 남색 먹 무대
@include stage($sm-stage);
$sm-ink: #171426; // 필드 바닥 — 무대와 같은 톤
$sm-old: #4a4470; // 오래된 트레일 — 죽어가는 흐린 남보라(어두운 배경 위 낮은 채도)
$sm-fresh: #d9f7ff; // 방금 밟힌 트레일 — 파스텔 네온 시안(밝은 계열)
$sm-text: #f0eaf5; // 남색 먹 위 — 크림 라벤더 글자(대비)
.sm {
--sm-ink: #{$sm-ink};
--sm-old: #{$sm-old};
--sm-fresh: #{$sm-fresh};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-ink;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-fresh; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 나이가 섞인 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #171426;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-ink: #171426;
--sm-old: #4a4470;
--sm-fresh: #d9f7ff;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f0eaf5;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #171426;
box-shadow: inset 0 0 0 1px rgba(240, 234, 245, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f0eaf5;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #d9f7ff;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(240, 234, 245, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(240, 234, 245, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 트레일 나이 채색(06). Physarum 스티그머지 + 셀별 마지막 방문 시각 —
// trail 대신 lastVisit Float32Array 에 "이 셀을 마지막으로 밟은 스텝 번호"를 기록한다.
// 렌더는 나이(age = 현재스텝 - 마지막방문)로 색을 정한다: 방금 밟힌 셀은 네온처럼 밝고,
// 나이가 차면 어둡게 죽는다. 여러 먹이 앵커를 두고 트레일이 쓰이는 간선만 살아남는 모습.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 나이 필드를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1400; // 동시에 걷는 에이전트 수
var DECAY_STEPS = 46; // 트레일이 완전히 죽는 데 걸리는 스텝 수(나이 수명)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DIFFUSE = 0.5;
var SUBSTEPS = 3;
var FOOD_W = 2.0;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A06);
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('--sm-ink');
var C_OLD = tokRGB('--sm-old');
var C_FRESH = tokRGB('--sm-fresh');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var lastVisit, trail, trailB, food, img, data;
var ax, ay, ah;
var stepCount = 0;
var foods = [];
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function buildFoods() {
foods = [];
var n = 5;
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.4;
for (var i = 0; i < n; i++) {
var a = (i / n) * Math.PI * 2 + rand() * 0.6;
var rr = r * (0.72 + rand() * 0.28);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
food = new Float32Array(fw * fh);
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var best = 0;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var v = Math.exp(-(dx * dx + dy * dy) / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
}
function sense(x, y) {
var i = clampIdx(x, y);
// 나이 필드를 감지에도 그대로 쓴다: 최근 밟힌 셀 = 진한 트레일
var lv = lastVisit[i];
var ageK = lv < 0 ? 0 : Math.max(0, 1 - (stepCount - lv) / DECAY_STEPS);
return ageK * 4 + food[i] * FOOD_W;
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
lastVisit[ii] = stepCount;
}
stepCount++;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var lv = lastVisit[i];
var k = 0;
if (lv >= 0) {
var age = stepCount - lv;
if (age < DECAY_STEPS) k = 1 - age / DECAY_STEPS;
}
k = k * k * (3 - 2 * k); // 나이 페이드를 부드럽게
var rr = C_INK[0] + (C_OLD[0] - C_INK[0]) * Math.min(1, k * 2.4) + (C_FRESH[0] - C_OLD[0]) * Math.max(0, k - 0.35) * 1.54;
var gg = C_INK[1] + (C_OLD[1] - C_INK[1]) * Math.min(1, k * 2.4) + (C_FRESH[1] - C_OLD[1]) * Math.max(0, k - 0.35) * 1.54;
var bb = C_INK[2] + (C_OLD[2] - C_INK[2]) * Math.min(1, k * 2.4) + (C_FRESH[2] - C_OLD[2]) * Math.max(0, k - 0.35) * 1.54;
for (var p = 0; p < foods.length; p++) {
var dx = x - foods[p][0], dy = y - foods[p][1];
var pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + foods[p][2]);
var glow = Math.exp(-(dx * dx + dy * dy) / 18) * pulse * 0.5;
rr += (C_FRESH[0] - rr) * glow; gg += (C_FRESH[1] - gg) * glow; bb += (C_FRESH[2] - bb) * glow;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
lastVisit = new Float32Array(fw * fh);
for (var i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
stepCount = 0;
buildFoods();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 300; k++) stepAgents();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) stepAgents();
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// TrailDecayColorAge — vanilla 판(run/320/demo/06-slime-trail-decay-color-age/index.html) 의 React 포트.
// 트레일 나이 채색. trail 대신 lastVisit(마지막 방문 스텝)을 기록해 나이(age = 현재스텝 - 마지막방문)로
// 색을 정한다 — 방금 밟힌 셀은 네온처럼 밝고, 나이가 차면 어둡게 죽는다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TrailDecayColorAge.module.scss";
export type TrailDecayColorAgeProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decaySteps?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
type Food = [number, number, number];
const FIELD_LABEL = "트레일 나이 채색 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const SUBSTEPS = 3;
const FOOD_W = 2.0;
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 TrailDecayColorAge({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f0eaf5",
agents = 1400,
decaySteps = 46,
}: TrailDecayColorAgeProps) {
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 colRef = useRef<{ ink: RGB; old: RGB; fresh: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayStepsRef = useRef(decaySteps) as Box<number>;
const randRef = useRef(makePRNG(0x320a06)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const lastVisitRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const foodsRef = useRef<Food[]>([]) as Box<Food[]>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function buildFoods(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const foods: Food[] = [];
const n2 = 5;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.4;
for (let i = 0; i < n2; i++) {
const a = (i / n2) * Math.PI * 2 + rand() * 0.6;
const rr = r * (0.72 + rand() * 0.28);
foods.push([cx + Math.cos(a) * rr, cy + Math.sin(a) * rr, rand() * Math.PI * 2]);
}
foodsRef.current = foods;
const food = new Float32Array(fw * fh);
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
let best = 0;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const v = Math.exp(-(dx * dx + dy * dy) / (fw * 0.9));
if (v > best) best = v;
}
food[y * fw + x] = best;
}
}
foodRef.current = food;
}
function sense(x: number, y: number): number {
const i = clampIdx(x, y);
const lv = lastVisitRef.current[i];
const ageK = lv < 0 ? 0 : Math.max(0, 1 - (stepCountRef.current - lv) / decayStepsRef.current);
return ageK * 4 + foodRef.current[i] * FOOD_W;
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.14;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const lastVisit = lastVisitRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
lastVisit[ii] = stepCountRef.current;
}
stepCountRef.current++;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const lastVisit = lastVisitRef.current;
const decaySteps0 = decayStepsRef.current;
const stepCount = stepCountRef.current;
const foods = foodsRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const lv = lastVisit[i];
let k = 0;
if (lv >= 0) {
const age = stepCount - lv;
if (age < decaySteps0) k = 1 - age / decaySteps0;
}
k = k * k * (3 - 2 * k);
let rr = col.ink[0] + (col.old[0] - col.ink[0]) * Math.min(1, k * 2.4) + (col.fresh[0] - col.old[0]) * Math.max(0, k - 0.35) * 1.54;
let gg = col.ink[1] + (col.old[1] - col.ink[1]) * Math.min(1, k * 2.4) + (col.fresh[1] - col.old[1]) * Math.max(0, k - 0.35) * 1.54;
let bb = col.ink[2] + (col.old[2] - col.ink[2]) * Math.min(1, k * 2.4) + (col.fresh[2] - col.old[2]) * Math.max(0, k - 0.35) * 1.54;
for (let p = 0; p < foods.length; p++) {
const dx = x - foods[p][0], dy = y - foods[p][1];
const pulse = 0.55 + 0.45 * Math.sin(t * 2.2 + foods[p][2]);
const glow = Math.exp(-(dx * dx + dy * dy) / 18) * pulse * 0.5;
rr += (col.fresh[0] - rr) * glow; gg += (col.fresh[1] - gg) * glow; bb += (col.fresh[2] - bb) * glow;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
const lastVisit = new Float32Array(fw * fh);
for (let i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
lastVisitRef.current = lastVisit;
stepCountRef.current = 0;
buildFoods();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 300; k++) stepAgents();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) stepAgents();
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayStepsRef.current = decaySteps;
randRef.current = makePRNG(0x320a06);
colRef.current = {
ink: tokRGB(root, "--sm-ink"),
old: tokRGB(root, "--sm-old"),
fresh: tokRGB(root, "--sm-fresh"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decaySteps]);
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}>
<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>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}06 · Trail-age coloring — the freshest paths glow neon-bright — Measured area 35.206% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
lastVisit is the only value the age field stores, and sense reads it too
function sense(x, y) {
var i = clampIdx(x, y);
// the age field doubles as the scent: a recently visited cell reads as a strong trail
var lv = lastVisit[i];
var ageK = lv < 0 ? 0 : Math.max(0, 1 - (stepCount - lv) / DECAY_STEPS);
return ageK * 4 + food[i] * FOOD_W;
}
07Sensor-angle morph — one angle value turns coral into blood vessels
Two sliders — sensor spread angle from 10 to 90 degrees and sensor distance from 4 to 30 — feed straight into the steering math every frame, so the same rule draws dense coral, sparse vessels, or a grid-like city network. The automatic demo morphs both along sine waves, holding a 49.385% painted area with a 16.321% peak frame across the whole sweep.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Move two sliders — sensor spread angle and distance — and the same rule draws dense coral, sparse vessels, or a grid-like city network</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Sensor-Angle Morph Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<label class="sm__ctl">
<span class="sm__lab" id="smAngLab">Spread 29°</span>
<input class="sm__rng" id="smAng" type="range" min="10" max="90" step="1" value="29" aria-label="Sensor spread angle">
</label>
<label class="sm__ctl">
<span class="sm__lab" id="smDistLab">Distance 15</span>
<input class="sm__rng" id="smDist" type="range" min="4" max="30" step="1" value="15" aria-label="Sensor distance">
</label>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 센서 각도 모프(07). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(슬라이더 파라미터 + 3센서 감지 → 회전 → 트레일 → 확산·증발)가 만든다.
// 무대는 종이(paper) — 일곱 번째 순환(01밤·02종이·03밀짚·04노을·05먹·06남색). 파라미터 실험판에는
// 밝은 종이가 계기판처럼 어울린다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-ink;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.85 이상 — BD26 안전) ──
$sm-stage: #f4f7f2; // 연한 이끼 종이 무대
@include stage($sm-stage);
$sm-floor: #f4f7f2; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #cfe3c8; // 트레일 — 파스텔 세이지
$sm-acct: #f6cfd4; // 파라미터 표시기 — 파스텔 로즈
$sm-text: #2c3326; // 종이 위 — 짙은 이끼 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-acct: #{$sm-acct};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar {
display: flex; align-items: center; justify-content: space-between;
gap: $sp-2; flex-wrap: wrap;
}
.sm__ctl {
display: flex; align-items: center; gap: $sp-1;
min-width: 0;
}
.sm__lab {
font-size: 10px; font-weight: 700; line-height: 1;
color: rgba($sm-text, .72); white-space: nowrap;
}
.sm__rng {
appearance: none; -webkit-appearance: none;
width: clamp(64px, 20vw, 108px); height: 16px;
background: transparent; border: 0; padding: 0; margin: 0;
cursor: pointer;
}
.sm__rng::-webkit-slider-runnable-track {
height: 4px; border-radius: $r-pill;
background: rgba($sm-text, .22);
}
.sm__rng::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: 12px; height: 12px; margin-top: -4px;
border-radius: 50%; border: 0;
background: $sm-trail;
box-shadow: 0 0 0 2px rgba($sm-text, .35);
}
.sm__rng::-moz-range-track {
height: 4px; border-radius: $r-pill;
background: rgba($sm-text, .22);
}
.sm__rng::-moz-range-thumb {
width: 12px; height: 12px; border-radius: 50%; border: 0;
background: $sm-trail;
box-shadow: 0 0 0 2px rgba($sm-text, .35);
}
.sm__rng:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 82px; }
.sm__bar { flex-direction: column; align-items: stretch; gap: $sp-1; }
.sm__ctl { justify-content: space-between; }
.sm__rng { flex: 0 0 auto; }
}
// reduce — rAF 를 돌리지 않고 혈관 모양 파라미터로 자란 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__rng { pointer-events: auto; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #f4f7f2;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #f4f7f2;
--sm-trail: #cfe3c8;
--sm-acct: #f6cfd4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #2c3326;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #f4f7f2;
box-shadow: inset 0 0 0 1px rgba(44, 51, 38, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #2c3326;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.sm__ctl {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.sm__lab {
font-size: 10px;
font-weight: 700;
line-height: 1;
color: rgba(44, 51, 38, 0.72);
white-space: nowrap;
}
.sm__rng {
appearance: none;
-webkit-appearance: none;
width: clamp(64px, 20vw, 108px);
height: 16px;
background: transparent;
border: 0;
padding: 0;
margin: 0;
cursor: pointer;
}
.sm__rng::-webkit-slider-runnable-track {
height: 4px;
border-radius: 999px;
background: rgba(44, 51, 38, 0.22);
}
.sm__rng::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 12px;
height: 12px;
margin-top: -4px;
border-radius: 50%;
border: 0;
background: #cfe3c8;
box-shadow: 0 0 0 2px rgba(44, 51, 38, 0.35);
}
.sm__rng::-moz-range-track {
height: 4px;
border-radius: 999px;
background: rgba(44, 51, 38, 0.22);
}
.sm__rng::-moz-range-thumb {
width: 12px;
height: 12px;
border-radius: 50%;
border: 0;
background: #cfe3c8;
box-shadow: 0 0 0 2px rgba(44, 51, 38, 0.35);
}
.sm__rng:focus-visible {
outline: 2px solid #2c3326;
outline-offset: 2px;
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 82px;
}
.sm__bar {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.sm__ctl {
justify-content: space-between;
}
.sm__rng {
flex: 0 0 auto;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__rng {
pointer-events: auto;
}
}// 센서 각도 모프(07). Physarum 스티그머지 + 실시간 파라미터 모프 —
// Sensor spread angle(도)과 Sensor distance 두 값을 실제 range 슬라이더로 바꾼다. 같은 3센서 규칙이라도
// 좁은 각도+짧은 거리는 촘촘한 산호, 넓은 각도+긴 거리는 성긴 혈관·격자형 도시망을 그린다.
// .is-demo 동안은 슬라이더가 사람 손 없이 스스로 움직여(자동 시연) 형태 변화를 보여주고,
// 사람이 실제로 슬라이더를 잡으면 그 즉시 같은 파라미터가 시뮬레이션에 반영된다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 필드 전체를 매 프레임 새로 계산해 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1500; // 동시에 걷는 에이전트 수
var DECAY = 0.045; // 증발률
// ────────────────────────────────────
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
// 파라미터(슬라이더가 실시간으로 바꾼다)
var sensorAngleDeg = 29; // 도(°)
var sensorDist = 15;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var angRng = document.getElementById('smAng');
var distRng = document.getElementById('smDist');
var angLab = document.getElementById('smAngLab');
var distLab = document.getElementById('smDistLab');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A07);
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_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_ACCT = tokRGB('--sm-acct');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var ax, ay, ah;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
var sa = sensorAngleDeg * Math.PI / 180;
var sd = sensorDist / 3;
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
var sl = sense(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);
var sr = sense(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k;
// 파라미터 표시기 — 슬라이더 값이 화면 구석에 옅게 비친다(파라미터 실험 컨셉)
var meterX = x / fw;
var inAng = y < 5 && meterX < sensorAngleDeg / 90;
var inDist = y >= 6 && y < 11 && meterX < (sensorDist - 4) / 26;
if (inAng || inDist) {
rr += (C_ACCT[0] - rr) * 0.5; gg += (C_ACCT[1] - gg) * 0.5; bb += (C_ACCT[2] - bb) * 0.5;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
initAgents();
}
function setLabels() {
if (angLab) angLab.textContent = 'Spread ' + sensorAngleDeg + '°';
if (distLab) distLab.textContent = 'Distance ' + sensorDist;
if (angRng) angRng.value = String(sensorAngleDeg);
if (distRng) distRng.value = String(sensorDist);
}
function stillFrame() {
sensorAngleDeg = 45; sensorDist = 20; setLabels();
for (var k = 0; k < 280; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
// .is-demo 자동 시연 각본 — 사람이 안 만져도 슬라이더가 스스로 움직여 형태가 모프된다.
// 각 구간은 (Spread 목표, Distance 목표) 쌍. 사인으로 왕복해 끊김 없이 잇는다.
function autoParams(t) {
var ang = 29 + 28 * Math.sin(t * 0.42) + 8 * Math.sin(t * 0.13);
var dist = 15 + 9 * Math.sin(t * 0.31 + 1.2);
if (ang < 10) ang = 10; if (ang > 90) ang = 90;
if (dist < 4) dist = 4; if (dist > 30) dist = 30;
sensorAngleDeg = Math.round(ang);
sensorDist = Math.round(dist);
}
var humanTouch = false;
angRng.addEventListener('input', function () {
humanTouch = true;
sensorAngleDeg = parseInt(angRng.value, 10);
setLabels();
});
distRng.addEventListener('input', function () {
humanTouch = true;
sensorDist = parseInt(distRng.value, 10);
setLabels();
});
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
if (root.classList.contains('is-demo') && !humanTouch) {
autoParams(t);
setLabels();
}
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
setLabels();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// SensorAngleMorph — vanilla 판(run/320/demo/07-slime-sensor-angle-morph/index.html) 의 React 포트.
// 센서 각도 모프. 센서 벌림각(도)과 센서 거리 두 값을 range 슬라이더로 바꾼다 — 좁은 각도+짧은
// 거리는 촘촘한 산호, 넓은 각도+긴 거리는 성긴 혈관·격자형 도시망을 그린다. 사람이 슬라이더를
// 만지기 전까지는 자동 각본이 두 값을 사인파로 왕복시켜 스스로 형태가 모프된다.
import { useEffect, useRef, useState } from "react";
import type { ChangeEvent, CSSProperties } from "react";
import styles from "./SensorAngleMorph.module.scss";
export type SensorAngleMorphProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "센서 각도 모프 점균 실험판";
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
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 SensorAngleMorph({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2c3326",
agents = 1500,
decay = 0.045,
}: SensorAngleMorphProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; acct: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const randRef = useRef(makePRNG(0x320a07)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const sensorAngleDegRef = useRef(29) as Box<number>;
const sensorDistRef = useRef(15) as Box<number>;
const humanTouchRef = useRef(false) as Box<boolean>;
const rafRef = useRef(0) as Box<number>;
const [angLab, setAngLab] = useState("벌림각 29°");
const [distLab, setDistLab] = useState("거리 15");
const [angVal, setAngVal] = useState(29);
const [distVal, setDistVal] = useState(15);
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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const sa = sensorAngleDegRef.current * Math.PI / 180;
const sd = sensorDistRef.current / 3;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
const sl = sense(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);
const sr = sense(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const angDeg = sensorAngleDegRef.current;
const dist = sensorDistRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k;
const meterX = x / fw;
const inAng = y < 5 && meterX < angDeg / 90;
const inDist = y >= 6 && y < 11 && meterX < (dist - 4) / 26;
if (inAng || inDist) {
rr += (col.acct[0] - rr) * 0.5; gg += (col.acct[1] - gg) * 0.5; bb += (col.acct[2] - bb) * 0.5;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 28 + (n2 - 0.5) * 18;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
initAgents();
}
function setLabels(): void {
setAngLab(`벌림각 ${sensorAngleDegRef.current}°`);
setDistLab(`거리 ${sensorDistRef.current}`);
setAngVal(sensorAngleDegRef.current);
setDistVal(sensorDistRef.current);
}
function stillFrame(): void {
sensorAngleDegRef.current = 45; sensorDistRef.current = 20; setLabels();
for (let k = 0; k < 280; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function autoParams(t: number): void {
let ang = 29 + 28 * Math.sin(t * 0.42) + 8 * Math.sin(t * 0.13);
let dist = 15 + 9 * Math.sin(t * 0.31 + 1.2);
if (ang < 10) ang = 10; if (ang > 90) ang = 90;
if (dist < 4) dist = 4; if (dist > 30) dist = 30;
sensorAngleDegRef.current = Math.round(ang);
sensorDistRef.current = Math.round(dist);
}
function onAngInput(e: ChangeEvent<HTMLInputElement>): void {
humanTouchRef.current = true;
sensorAngleDegRef.current = parseInt(e.target.value, 10);
setLabels();
}
function onDistInput(e: ChangeEvent<HTMLInputElement>): void {
humanTouchRef.current = true;
sensorDistRef.current = parseInt(e.target.value, 10);
setLabels();
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
if (!humanTouchRef.current) {
autoParams(t);
setLabels();
}
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
render(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
randRef.current = makePRNG(0x320a07);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
acct: tokRGB(root, "--sm-acct"),
};
buildPerm();
fit();
setLabels();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay]);
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}>
<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}>
<label className={styles.ctl}>
<span className={styles.lab}>{angLab}</span>
<input
className={styles.rng}
type="range"
min={10}
max={90}
step={1}
value={angVal}
aria-label="센서 벌림각"
onChange={onAngInput}
/>
</label>
<label className={styles.ctl}>
<span className={styles.lab}>{distLab}</span>
<input
className={styles.rng}
type="range"
min={4}
max={30}
step={1}
value={distVal}
aria-label="센서 거리"
onChange={onDistInput}
/>
</label>
</div>
</div>
</main>
);
}07 · Sensor-angle morph — one angle value turns coral into blood vessels — Measured area 49.385% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
Two sliders feed the same sense math live
function stepAgents() {
var sa = sensorAngleDeg * Math.PI / 180;
var sd = sensorDist / 3;
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * sd, y + Math.sin(h) * sd);
var sl = sense(x + Math.cos(h + sa) * sd, y + Math.sin(h + sa) * sd);
var sr = sense(x + Math.cos(h - sa) * sd, y + Math.sin(h - sa) * sd);
if (sf >= sl && sf >= sr) {
// straight
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
}
08Organic texture panel — capturing one frame as a card background
The network grows for only 130 steps, then the finished field is frozen into a static snapshot laid down at lowered opacity, and a fresh texture crossfades in over 0.7 seconds at every capture. The frame never sits dead even though each image is frozen — and the numbers say so honestly: 34.819% painted area and 15.5 average intensity, both the lowest of the nine, exactly as a faint static texture should measure.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Freeze the canvas the moment the network finishes growing, lower its opacity, and even a static image reads as a hand-drawn organic texture for cards or sections</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Organic Texture Panel Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
<div class="sm__veil" id="smVeil" aria-hidden="true">
<div class="sm__card">
<p class="sm__card-t">Organic Section</p>
<p class="sm__card-s">hand-drawn texture · slime network</p>
</div>
</div>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> captures · frozen texture</span></span>
<span class="sm__meta sm__meta--dim">canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 유기체 텍스처 패널(08). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(3센서 감지 → 회전 → 트레일 → 확산·증발 → 캡처)가 만든다.
// 무대는 크림 종이 — 여덟 번째 순환(01밤·02종이·03밀짚·04노을·05먹·06남색·07이끼).
// 질감이 UI 배경이 되는 컨셉이라 카드 목업(.sm__veil .sm__card)이 실제로 얹힌다.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 파스텔 팔레트(밝은 계열, L 0.85 이상 — BD26 안전) ──
$sm-stage: #faf3e8; // 크림 아마 종이 무대
@include stage($sm-stage);
$sm-floor: #faf3e8; // 필드 바닥 — 무대와 같은 톤
$sm-trail: #e8d9bd; // 굳은 질감 — 파스텔 아마
$sm-acct: #f2d3c4; // 성장 중 비침 — 파스텔 살구
$sm-card: #fffcf5; // 카드 목업 면 — 종이보다 반톤 밝게
$sm-text: #453524; // 종이 위 — 짙은 갈색 글자(대비)
.sm {
--sm-floor: #{$sm-floor};
--sm-trail: #{$sm-trail};
--sm-acct: #{$sm-acct};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-floor;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__veil {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
padding: $sp-3; pointer-events: none;
}
.sm__card {
max-width: 260px; width: 100%;
padding: $sp-3 $sp-4;
border-radius: $r-card;
background: rgba($sm-card, .82);
box-shadow: 0 6px 12px rgba(69, 53, 36, .18), inset 0 0 0 1px rgba(69, 53, 36, .1);
text-align: center;
}
.sm__card-t {
margin: 0;
font-weight: 700; font-size: clamp(12px, 3.4vw, 15px); line-height: 1.2;
color: $sm-text;
}
.sm__card-s {
margin: 0; margin-top: $sp-1;
font-weight: 500; font-size: clamp(9px, 2.4vw, 10px); line-height: 1.3;
color: rgba($sm-text, .72);
}
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__veil { padding: $sp-2; }
.sm__card { padding: $sp-2 $sp-3; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 캡처 한 장이 카드 배경으로 깔린 모습만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__veil { pointer-events: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #faf3e8;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-floor: #faf3e8;
--sm-trail: #e8d9bd;
--sm-acct: #f2d3c4;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #453524;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #faf3e8;
box-shadow: inset 0 0 0 1px rgba(69, 53, 36, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #453524;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__veil {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 12px;
pointer-events: none;
}
.sm__card {
max-width: 260px;
width: 100%;
padding: 12px 16px;
border-radius: 16px;
background: rgba(255, 252, 245, 0.82);
box-shadow: 0 6px 12px rgba(69, 53, 36, 0.18), inset 0 0 0 1px rgba(69, 53, 36, 0.1);
text-align: center;
}
.sm__card-t {
margin: 0;
font-weight: 700;
font-size: clamp(12px, 3.4vw, 15px);
line-height: 1.2;
color: #453524;
}
.sm__card-s {
margin: 0;
margin-top: 4px;
font-weight: 500;
font-size: clamp(9px, 2.4vw, 10px);
line-height: 1.3;
color: rgba(69, 53, 36, 0.72);
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(69, 53, 36, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(69, 53, 36, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__veil {
padding: 8px;
}
.sm__card {
padding: 8px 12px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still .sm__veil {
pointer-events: none;
}
}// 유기체 텍스처 패널(08). Physarum 스티그머지 + 성장 캡처 → 정지 질감 재사용 —
// 시뮬레이션이 GROW_STEPS 스텝 동안 네트워크를 키운다. 다 자란 순간 필드를 snapshot 배열에
// 통째로 복사해 "정지 이미지"로 굳히고, 이후 렌더는 그 스냅샷을 카드 배경처럼 옅게 깐다.
// 위에 얹은 카드 DOM(.sm__veil .sm__card)이 "이 질감이 실제 UI 배경으로 쓰이는 모습"을 보여준다.
// .is-demo 동안은 캡처→재성장을 주기적으로 반복해 질감이 계속 새로 만들어지듯 보이게 한다.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 스냅샷 위에 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 흔들어 누적면적을 채운다.
// · 캡처 경계마다 질감이 스르르 크로스페이드되며 화면이 죽지 않는다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수
var DECAY = 0.042; // 증발률
var GROW_STEPS = 130; // 질감을 굳히기 전 성장 스텝 수
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var FADE = 0.7; // 캡처 교체 크로스페이드 길이(초)
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A08);
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_FLOOR = tokRGB('--sm-floor');
var C_TRAIL = tokRGB('--sm-trail');
var C_ACCT = tokRGB('--sm-acct');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, img, data;
var snapOld, snapNew; // 직전 질감 · 방금 잡은 질감
var ax, ay, ah;
var stepCount = 0;
var captureCount = 0;
var fadeT = -1; // 크로스페이드 시작 시각(-1 = 페이드 아님)
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade2(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade2(xf), v = fade2(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function capture() {
// 지금 필드를 통째로 굳혀 새 질감으로 삼고, 필드는 비워 다음 성장을 시작한다.
var tmp = snapOld;
snapOld = snapNew;
snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trail);
captureCount++;
if (nOut) nOut.textContent = String(captureCount);
for (var i = 0; i < trail.length; i++) trail[i] = 0;
stepCount = 0;
initAgents();
}
function render(t) {
var mix = 1;
if (fadeT >= 0) {
mix = (t - fadeT) / FADE;
if (mix > 1) mix = 1;
mix = mix * mix * (3 - 2 * mix);
}
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
// 정지 질감 두 장의 크로스페이드 — 카드 배경은 "캡처된" 필드에서 온다
var vOld = snapOld && snapOld.length === trail.length ? snapOld[i] / CAP : 0;
var vNew = snapNew ? snapNew[i] / CAP : 0;
if (vOld > 1) vOld = 1; if (vNew > 1) vNew = 1;
var v = vOld + (vNew - vOld) * mix;
var k = v * v * (3 - 2 * v);
var rr = C_FLOOR[0] + (C_TRAIL[0] - C_FLOOR[0]) * k * 0.5;
var gg = C_FLOOR[1] + (C_TRAIL[1] - C_FLOOR[1]) * k * 0.5;
var bb = C_FLOOR[2] + (C_TRAIL[2] - C_FLOOR[2]) * k * 0.5;
// 성장 중인 필드는 뚜렷하게 함께 비친다(다음 질감이 자라나는 과정)
var vGrow = trail[i] / CAP; if (vGrow > 1) vGrow = 1;
var kGrow = vGrow * vGrow * (3 - 2 * vGrow);
rr += (C_ACCT[0] - rr) * kGrow * 0.5;
gg += (C_ACCT[1] - gg) * kGrow * 0.5;
bb += (C_ACCT[2] - bb) * kGrow * 0.5;
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
snapOld = null; snapNew = null;
stepCount = 0; captureCount = 0; fadeT = -1;
initAgents();
}
function stillFrame() {
for (var k = 0; k < GROW_STEPS; k++) { stepAgents(); diffuseEvap(); }
capture();
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
if (stepCount >= GROW_STEPS) { capture(); fadeT = t; }
if (fadeT >= 0 && t - fadeT > FADE) fadeT = -1;
render(t);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// VeinsTexturePanel — vanilla 판(run/320/demo/08-slime-veins-texture-panel/index.html) 의 React 포트.
// 유기체 텍스처 패널. 시뮬레이션이 GROW_STEPS 스텝 동안 네트워크를 키운 뒤 필드 전체를 snapshot
// 으로 굳혀 "정지 질감"으로 재사용한다 — 위에 얹은 카드 DOM 이 실제 UI 배경으로 쓰이는 모습을
// 보여준다. 캡처 경계마다 새·헌 질감이 크로스페이드된다.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./VeinsTexturePanel.module.scss";
export type VeinsTexturePanelProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
growSteps?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "유기체 텍스처 패널 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
const FADE = 0.7;
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 VeinsTexturePanel({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#453524",
agents = 1300,
decay = 0.042,
growSteps = 130,
}: VeinsTexturePanelProps) {
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 colRef = useRef<{ floor: RGB; trail: RGB; acct: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const growStepsRef = useRef(growSteps) as Box<number>;
const randRef = useRef(makePRNG(0x320a08)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const snapOldRef = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const snapNewRef = useRef<Float32Array | null>(null) as Box<Float32Array | null>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const captureCountRef = useRef(0) as Box<number>;
const fadeTRef = useRef(-1) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade2(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade2(xf), v = fade2(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.15;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function capture(): void {
const fw = fwRef.current, fh = fhRef.current;
const tmp = snapOldRef.current;
snapOldRef.current = snapNewRef.current;
let snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trailRef.current);
snapNewRef.current = snapNew;
captureCountRef.current++;
setN(String(captureCountRef.current));
const trail = trailRef.current;
for (let i = 0; i < trail.length; i++) trail[i] = 0;
stepCountRef.current = 0;
initAgents();
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const snapOld = snapOldRef.current;
const snapNew = snapNewRef.current;
const fadeT = fadeTRef.current;
let mix = 1;
if (fadeT >= 0) {
mix = (t - fadeT) / FADE;
if (mix > 1) mix = 1;
mix = mix * mix * (3 - 2 * mix);
}
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
let vOld = snapOld && snapOld.length === trail.length ? snapOld[i] / CAP : 0;
let vNew = snapNew ? snapNew[i] / CAP : 0;
if (vOld > 1) vOld = 1; if (vNew > 1) vNew = 1;
const v = vOld + (vNew - vOld) * mix;
const k = v * v * (3 - 2 * v);
let rr = col.floor[0] + (col.trail[0] - col.floor[0]) * k * 0.5;
let gg = col.floor[1] + (col.trail[1] - col.floor[1]) * k * 0.5;
let bb = col.floor[2] + (col.trail[2] - col.floor[2]) * k * 0.5;
let vGrow = trail[i] / CAP; if (vGrow > 1) vGrow = 1;
const kGrow = vGrow * vGrow * (3 - 2 * vGrow);
rr += (col.acct[0] - rr) * kGrow * 0.5;
gg += (col.acct[1] - gg) * kGrow * 0.5;
bb += (col.acct[2] - bb) * kGrow * 0.5;
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 30 + (n2 - 0.5) * 20;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
snapOldRef.current = null; snapNewRef.current = null;
stepCountRef.current = 0; captureCountRef.current = 0; fadeTRef.current = -1;
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < growStepsRef.current; k++) { stepAgents(); diffuseEvap(); }
capture();
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let i = 0; i < SUBSTEPS; i++) { stepAgents(); diffuseEvap(); }
if (stepCountRef.current >= growStepsRef.current) { capture(); fadeTRef.current = t; }
if (fadeTRef.current >= 0 && t - fadeTRef.current > FADE) fadeTRef.current = -1;
render(t);
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
growStepsRef.current = growSteps;
randRef.current = makePRNG(0x320a08);
colRef.current = {
floor: tokRGB(root, "--sm-floor"),
trail: tokRGB(root, "--sm-trail"),
acct: tokRGB(root, "--sm-acct"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, growSteps]);
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}>
<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 className={styles.veil} aria-hidden="true">
<div className={styles.card}>
<p className={styles.cardT}>Organic Section</p>
<p className={styles.cardS}>hand-drawn texture · slime network</p>
</div>
</div>
</div>
<div className={styles.bar}>
<span className={styles.meta}>
<b className={styles.val}>{n}</b>
<span> 캡처 · 정지 질감</span>
</span>
<span className={styles.metaDim}>canvas · ImageData · requestAnimationFrame</span>
</div>
</div>
</main>
);
}08 · Organic texture panel — capturing one frame as a card background — Measured area 34.819% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
capture freezes the field and then clears it
function capture() {
// freeze the whole field as the new texture, then clear it and start growing again
var tmp = snapOld;
snapOld = snapNew;
snapNew = tmp;
if (!snapNew) snapNew = new Float32Array(fw * fh);
snapNew.set(trail);
captureCount++;
if (nOut) nOut.textContent = String(captureCount);
for (var i = 0; i < trail.length; i++) trail[i] = 0;
stepCount = 0;
initAgents();
}
09Macro bioluminescent scene — a documentary-lit slime mold on a dark stage
A central light is baked once into a pixel buffer under a black stage, slow evaporation (DECAY 0.04) grows a network that reaches even into the dark edges of the frame, and any cell visited within the last 10 steps gets a pulsing tip glow driven by (1 - age/10) * (0.55 + 0.45 * sin(5t + 0.3x + 0.2y)). The trail tips keep writhing like a macro shot even after growth settles, at 54.7 average intensity with a 28.611% peak frame.

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>Light a dark stage with a radial gradient and grow one large network — it reads like a documentary macro shot, with tendrils at the trail tips still writhing to keep the frame alive</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="Macro Bioluminescent Slime Demo" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<span class="sm__meta"><b class="sm__val" id="smN">0</b><span id="smUnit"> steps · macro lighting</span></span>
<span class="sm__meta sm__meta--dim">canvas · createRadialGradient · requestAnimationFrame</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 매크로 발광 씬(09). @keyframes 를 두지 않는다 —
// 움직임은 전부 캔버스 rAF 루프(createRadialGradient 조명 베이스 + 3센서 감지 → 회전 →
// 트레일 → 확산·증발 → 촉수 발광)가 만든다. 무대는 검은 배경(퀴 요구사항) — 아홉 번째 순환.
$duration: $dur-loop;
$easing: $ease-spring;
$color: $subject-cream;
// ── 이 항목만의 팔레트(검은 무대 L0.06 미만 — BD26 예외 + 발광 파스텔 L0.85 이상) ──
$sm-stage: #0b0a10; // 다큐멘터리 접사용 완전한 검은 무대
@include stage($sm-stage);
$sm-dark: #0b0a10; // 조명 바깥 — 무대와 같은 톤
$sm-light: #3a3448; // 조명 중심 — 은은한 남보라 스포트라이트(중간톤, 조명이라 예외적으로 유지)
$sm-glow: #d8f2ea; // 트레일 발광 — 파스텔 민트 화이트
$sm-tip: #ffe6d8; // 촉수 끝 — 파스텔 피치 글로우
$sm-text: #f3ecdd; // 검은 무대 위 — 크림 글자(대비)
.sm {
--sm-dark: #{$sm-dark};
--sm-light: #{$sm-light};
--sm-glow: #{$sm-glow};
--sm-tip: #{$sm-tip};
display: flex; flex-direction: column; gap: $sp-2;
width: 100%; max-width: 424px;
}
.sm__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: $sm-text;
}
.sm__field {
position: relative; height: 180px;
border-radius: $r-card; overflow: hidden;
background: $sm-dark;
box-shadow: inset 0 0 0 1px rgba($sm-text, .18);
}
.sm__field:focus-visible { outline: 2px solid $sm-text; outline-offset: 2px; }
.sm__cv { display: block; width: 100%; height: 100%; }
.sm__bar { display: flex; align-items: center; justify-content: space-between; gap: $sp-2; }
.sm__val { color: $sm-glow; }
.sm__meta { font-size: 10px; font-weight: 700; color: rgba($sm-text, .72); }
.sm__meta--dim { font-size: 10px; font-weight: 500; color: rgba($sm-text, .72); }
@media (max-width: 340px) {
.sm { gap: $sp-1; }
.sm__field { height: 100px; }
.sm__meta--dim { display: none; }
}
// reduce — rAF 를 돌리지 않고 조명 아래 네트워크가 자란 한 장만 남는다.
@media (prefers-reduced-motion: reduce) {
.sm.is-still { opacity: 1; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #0b0a10;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.sm {
--sm-dark: #0b0a10;
--sm-light: #3a3448;
--sm-glow: #d8f2ea;
--sm-tip: #ffe6d8;
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 424px;
}
.sm__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: #f3ecdd;
}
.sm__field {
position: relative;
height: 180px;
border-radius: 16px;
overflow: hidden;
background: #0b0a10;
box-shadow: inset 0 0 0 1px rgba(243, 236, 221, 0.18);
}
.sm__field:focus-visible {
outline: 2px solid #f3ecdd;
outline-offset: 2px;
}
.sm__cv {
display: block;
width: 100%;
height: 100%;
}
.sm__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.sm__val {
color: #d8f2ea;
}
.sm__meta {
font-size: 10px;
font-weight: 700;
color: rgba(243, 236, 221, 0.72);
}
.sm__meta--dim {
font-size: 10px;
font-weight: 500;
color: rgba(243, 236, 221, 0.72);
}
@media (max-width: 340px) {
.sm {
gap: 4px;
}
.sm__field {
height: 100px;
}
.sm__meta--dim {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.sm.is-still {
opacity: 1;
}
}// 매크로 발광 씬(09). Physarum 스티그머지 + createRadialGradient 조명 베이스 —
// 검은 무대 위에 createRadialGradient 로 만든 중앙 조명 버퍼를 한 번 그려 base 캔버스에
// 굳히고, 매 프레임 base 를 drawImage 로 깔은 뒤 그 위에 트레일·에이전트 발광을 가산 합성한다.
// 트레일 끝(에이전트가 방금 지나간 자리)은 촉수처럼 밝게 꿈틀댄다 — 다큐멘터리 접사 톤.
//
// 화면이 사는 장치 — 077 캔버스 기준의 "매 프레임 계산":
// · 캔버스는 절대 clear 하지 않는다. 조명 베이스 위에 트레일·글로우를 매 프레임 새로 얹는다.
// · 앰비언트 글로우 레이어(두 옥타브 값노이즈)가 전면을 확산 구조로 흔들어 누적면적을 채운다.
(function () {
// ── 변형 상수 블록 ──
var AGENTS = 1300; // 동시에 걷는 에이전트 수
var DECAY = 0.04; // 증발률
var LIGHT_RADIUS = 0.62; // 중앙 조명 반경(필드 짧은 변 대비 비율)
// ────────────────────────────────────
var SENSOR_ANGLE = 0.5;
var SENSOR_DIST = 5.0;
var TURN = 0.5;
var STEP = 1.05;
var DEPOSIT = 4.2;
var DIFFUSE = 0.5;
var CAP = 7;
var SUBSTEPS = 3;
var root = document.getElementById('smRoot');
var field = document.getElementById('smField');
var cv = document.getElementById('smCv');
var ctx = cv.getContext('2d', { alpha: false });
var nOut = document.getElementById('smN');
function makeRand(seed) {
var st = seed >>> 0;
return function () {
st = (st + 0x6D2B79F5) >>> 0;
var t = st;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
var rand = makeRand(0x320A09);
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_DARK = tokRGB('--sm-dark');
var C_LIGHT = tokRGB('--sm-light');
var C_GLOW = tokRGB('--sm-glow');
var C_TIP = tokRGB('--sm-tip');
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var W = 0, H = 0, fw = 0, fh = 0, raf = 0, t0 = -1;
var trail, trailB, lightBase, img, data;
var ax, ay, ah, lastVisit;
var stepCount = 0;
// ── 값노이즈(앰비언트 글로우용) ──
var perm = new Uint8Array(512);
(function () {
var p = [];
for (var i = 0; i < 256; i++) p.push(i);
for (var i2 = 255; i2 > 0; i2--) {
var k2 = Math.floor(rand() * (i2 + 1));
var tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
for (var i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
})();
function fade(k) { return k * k * (3 - 2 * k); }
function vnoise(x, y) {
var xi = Math.floor(x), yi = Math.floor(y);
var xf = x - xi, yf = y - yi;
var a2 = perm[(perm[xi & 255] + yi) & 255];
var b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
var c2 = perm[(perm[xi & 255] + yi + 1) & 255];
var d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
var u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x, y) {
x = x % fw; if (x < 0) x += fw;
y = y % fh; if (y < 0) y += fh;
return (y * fw + x) | 0;
}
function clampIdx(x, y) {
x = Math.max(0, Math.min(fw - 1, Math.round(x)));
y = Math.max(0, Math.min(fh - 1, Math.round(y)));
return y * fw + x;
}
// createRadialGradient 로 만든 중앙 조명 베이스 — 한 번만 만들어 lightBase 에 픽셀로 굳힌다.
function buildLightBase() {
var off = document.createElement('canvas');
off.width = fw; off.height = fh;
var octx = off.getContext('2d');
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * LIGHT_RADIUS;
var g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, 'rgb(' + C_LIGHT[0] + ',' + C_LIGHT[1] + ',' + C_LIGHT[2] + ')');
g.addColorStop(0.55, 'rgb(' + Math.round((C_LIGHT[0] + C_DARK[0]) / 2) + ',' + Math.round((C_LIGHT[1] + C_DARK[1]) / 2) + ',' + Math.round((C_LIGHT[2] + C_DARK[2]) / 2) + ')');
g.addColorStop(1, 'rgb(' + C_DARK[0] + ',' + C_DARK[1] + ',' + C_DARK[2] + ')');
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
var d = octx.getImageData(0, 0, fw, fh).data;
lightBase = new Float32Array(fw * fh * 3);
for (var i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
}
function sense(x, y) {
return trail[clampIdx(x, y)];
}
function initAgents() {
ax = new Float32Array(AGENTS); ay = new Float32Array(AGENTS); ah = new Float32Array(AGENTS);
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (var i = 0; i < AGENTS; i++) {
var a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
}
function stepAgents() {
for (var i = 0; i < AGENTS; i++) {
var x = ax[i], y = ay[i], h = ah[i];
var sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
var sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
var sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
var nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
var ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
lastVisit[ii] = stepCount;
}
stepCount++;
}
function diffuseEvap() {
for (var y = 0; y < fh; y++) {
var ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
var rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (var x = 0; x < fw; x++) {
var xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
var i = rowY + x;
var avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - DECAY);
}
}
var t = trail; trail = trailB; trailB = t;
}
function render(t) {
for (var y = 0; y < fh; y++) {
for (var x = 0; x < fw; x++) {
var i = y * fw + x;
var j = i * 4;
var p = i * 3;
var rr = lightBase[p], gg = lightBase[p + 1], bb = lightBase[p + 2];
var v = trail[i] / CAP; if (v > 1) v = 1;
var k = v * v * (3 - 2 * v);
rr += (C_GLOW[0] - rr) * k; gg += (C_GLOW[1] - gg) * k; bb += (C_GLOW[2] - bb) * k;
// 촉수 끝 — 방금 밟힌(나이 짧은) 셀은 가산 발광으로 더 밝게 꿈틀댄다
var lv = lastVisit[i];
var age = lv < 0 ? 999 : stepCount - lv;
if (age < 10) {
var tipK = (1 - age / 10) * (0.55 + 0.45 * Math.sin(t * 5 + x * 0.3 + y * 0.2));
rr += (C_TIP[0] - rr) * tipK * 0.7;
gg += (C_TIP[1] - gg) * tipK * 0.7;
bb += (C_TIP[2] - bb) * tipK * 0.7;
}
var n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
var n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
var sh = (n1 - 0.5) * 20 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit() {
var r = field.getBoundingClientRect();
W = Math.round(r.width); H = Math.round(r.height);
fw = Math.max(96, Math.min(200, Math.round(W / 3)));
fh = Math.max(52, Math.min(120, Math.round(H / 3)));
cv.width = fw; cv.height = fh;
cv.style.width = '100%'; cv.style.height = '100%';
ctx = cv.getContext('2d', { alpha: false });
img = ctx.createImageData(fw, fh); data = img.data;
trail = new Float32Array(fw * fh); trailB = new Float32Array(fw * fh);
lastVisit = new Float32Array(fw * fh);
for (var i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
stepCount = 0;
buildLightBase();
initAgents();
}
function stillFrame() {
for (var k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
function frame(now) {
if (t0 < 0) t0 = now;
var t = (now - t0) / 1000;
for (var s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
if (nOut) nOut.textContent = String(stepCount);
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', function () {
fit();
if (reduce) stillFrame();
});
fit();
if (reduce) {
stillFrame();
root.classList.add('is-still');
} else {
raf = requestAnimationFrame(frame);
}
})();// MacroBioluminescentScene — vanilla 판(run/320/demo/09-slime-macro-bioluminescent-scene/index.html) 의
// React 포트. createRadialGradient 로 만든 중앙 조명 버퍼를 한 번 그려 lightBase 에 픽셀로 굳히고,
// 매 프레임 그 위에 트레일·촉수 발광을 가산 합성한다 — 다큐멘터리 접사 촬영 톤.
import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MacroBioluminescentScene.module.scss";
export type MacroBioluminescentSceneProps = {
duration?: string;
easing?: string;
color?: string;
agents?: number;
decay?: number;
lightRadius?: number;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
type RGB = [number, number, number];
const FIELD_LABEL = "매크로 발광 점균 실험판";
const SENSOR_ANGLE = 0.5;
const SENSOR_DIST = 5.0;
const TURN = 0.5;
const STEP = 1.05;
const DEPOSIT = 4.2;
const DIFFUSE = 0.5;
const CAP = 7;
const SUBSTEPS = 3;
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 MacroBioluminescentScene({
duration = "2s",
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#f3ecdd",
agents = 1300,
decay = 0.04,
lightRadius = 0.62,
}: MacroBioluminescentSceneProps) {
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 colRef = useRef<{ dark: RGB; light: RGB; glow: RGB; tip: RGB } | null>(null);
const agentsRef = useRef(agents) as Box<number>;
const decayRef = useRef(decay) as Box<number>;
const lightRadiusRef = useRef(lightRadius) as Box<number>;
const randRef = useRef(makePRNG(0x320a09)) as Box<() => number>;
const fwRef = useRef(0) as Box<number>;
const fhRef = useRef(0) as Box<number>;
const trailRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const trailBRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const lightBaseRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const axRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ayRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const ahRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const lastVisitRef = useRef<Float32Array>(new Float32Array(0)) as Box<Float32Array>;
const imgRef = useRef<ImageData | null>(null) as Box<ImageData | null>;
const permRef = useRef<Uint8Array>(new Uint8Array(512)) as Box<Uint8Array>;
const stepCountRef = useRef(0) as Box<number>;
const rafRef = useRef(0) as Box<number>;
const [n, setN] = 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;
}
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 buildPerm(): void {
const rand = randRef.current;
const p: number[] = [];
for (let i = 0; i < 256; i++) p.push(i);
for (let i2 = 255; i2 > 0; i2--) {
const k2 = Math.floor(rand() * (i2 + 1));
const tmp = p[i2]; p[i2] = p[k2]; p[k2] = tmp;
}
const perm = new Uint8Array(512);
for (let i3 = 0; i3 < 512; i3++) perm[i3] = p[i3 & 255];
permRef.current = perm;
}
function fade(k: number): number { return k * k * (3 - 2 * k); }
function vnoise(x: number, y: number): number {
const perm = permRef.current;
const xi = Math.floor(x), yi = Math.floor(y);
const xf = x - xi, yf = y - yi;
const a2 = perm[(perm[xi & 255] + yi) & 255];
const b2 = perm[(perm[(xi + 1) & 255] + yi) & 255];
const c2 = perm[(perm[xi & 255] + yi + 1) & 255];
const d2 = perm[(perm[(xi + 1) & 255] + yi + 1) & 255];
const u = fade(xf), v = fade(yf);
return (a2 + (b2 - a2) * u + (c2 - a2) * v + (a2 - b2 - c2 + d2) * u * v) / 255;
}
function idx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
let xx = x % fw; if (xx < 0) xx += fw;
let yy = y % fh; if (yy < 0) yy += fh;
return (yy * fw + xx) | 0;
}
function clampIdx(x: number, y: number): number {
const fw = fwRef.current, fh = fhRef.current;
const xx = Math.max(0, Math.min(fw - 1, Math.round(x)));
const yy = Math.max(0, Math.min(fh - 1, Math.round(y)));
return yy * fw + xx;
}
// createRadialGradient 로 만든 중앙 조명 베이스 — 한 번만 만들어 lightBase 에 픽셀로 굳힌다.
function buildLightBase(): void {
const fw = fwRef.current, fh = fhRef.current;
const col = colRef.current;
if (!col) return;
const off = document.createElement("canvas");
off.width = fw; off.height = fh;
const octx = off.getContext("2d");
if (!octx) return;
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * lightRadiusRef.current;
const g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, `rgb(${col.light[0]},${col.light[1]},${col.light[2]})`);
g.addColorStop(0.55, `rgb(${Math.round((col.light[0] + col.dark[0]) / 2)},${Math.round((col.light[1] + col.dark[1]) / 2)},${Math.round((col.light[2] + col.dark[2]) / 2)})`);
g.addColorStop(1, `rgb(${col.dark[0]},${col.dark[1]},${col.dark[2]})`);
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
const d = octx.getImageData(0, 0, fw, fh).data;
const lightBase = new Float32Array(fw * fh * 3);
for (let i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
lightBaseRef.current = lightBase;
}
function sense(x: number, y: number): number {
return trailRef.current[clampIdx(x, y)];
}
function initAgents(): void {
const rand = randRef.current;
const fw = fwRef.current, fh = fhRef.current;
const A = agentsRef.current;
const ax = new Float32Array(A), ay = new Float32Array(A), ah = new Float32Array(A);
const cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * 0.16;
for (let i = 0; i < A; i++) {
const a = rand() * Math.PI * 2, rr = rand() * r;
ax[i] = cx + Math.cos(a) * rr;
ay[i] = cy + Math.sin(a) * rr;
ah[i] = rand() * Math.PI * 2;
}
axRef.current = ax; ayRef.current = ay; ahRef.current = ah;
}
function stepAgents(): void {
const rand = randRef.current;
const A = agentsRef.current;
const fw = fwRef.current, fh = fhRef.current;
const ax = axRef.current, ay = ayRef.current, ah = ahRef.current;
const trail = trailRef.current;
const lastVisit = lastVisitRef.current;
for (let i = 0; i < A; i++) {
const x = ax[i], y = ay[i];
let h = ah[i];
const sf = sense(x + Math.cos(h) * SENSOR_DIST, y + Math.sin(h) * SENSOR_DIST);
const sl = sense(x + Math.cos(h + SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h + SENSOR_ANGLE) * SENSOR_DIST);
const sr = sense(x + Math.cos(h - SENSOR_ANGLE) * SENSOR_DIST, y + Math.sin(h - SENSOR_ANGLE) * SENSOR_DIST);
if (sf >= sl && sf >= sr) {
// 직진
} else if (sl > sr) {
h += TURN;
} else if (sr > sl) {
h -= TURN;
} else {
h += (rand() < 0.5 ? 1 : -1) * TURN;
}
h += (rand() - 0.5) * 0.14;
let nx = x + Math.cos(h) * STEP, ny = y + Math.sin(h) * STEP;
nx = ((nx % fw) + fw) % fw; ny = ((ny % fh) + fh) % fh;
ax[i] = nx; ay[i] = ny; ah[i] = h;
const ii = idx(Math.round(nx), Math.round(ny));
trail[ii] = Math.min(CAP, trail[ii] + DEPOSIT);
lastVisit[ii] = stepCountRef.current;
}
stepCountRef.current++;
}
function diffuseEvap(): void {
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current, trailB = trailBRef.current;
const dc = decayRef.current;
for (let y = 0; y < fh; y++) {
const ym = y === 0 ? fh - 1 : y - 1, yp = y === fh - 1 ? 0 : y + 1;
const rowY = y * fw, rowYm = ym * fw, rowYp = yp * fw;
for (let x = 0; x < fw; x++) {
const xm = x === 0 ? fw - 1 : x - 1, xp = x === fw - 1 ? 0 : x + 1;
const i = rowY + x;
const avg = (trail[rowY + xm] + trail[rowY + xp] + trail[rowYm + x] + trail[rowYp + x]) * 0.25;
trailB[i] = (trail[i] * (1 - DIFFUSE) + avg * DIFFUSE) * (1 - dc);
}
}
trailRef.current = trailB; trailBRef.current = trail;
}
function render(t: number): void {
const ctx = ctxRef.current;
const col = colRef.current;
const img = imgRef.current;
if (!ctx || !col || !img) return;
const fw = fwRef.current, fh = fhRef.current;
const trail = trailRef.current;
const lastVisit = lastVisitRef.current;
const lightBase = lightBaseRef.current;
const stepCount = stepCountRef.current;
const data = img.data;
for (let y = 0; y < fh; y++) {
for (let x = 0; x < fw; x++) {
const i = y * fw + x;
const j = i * 4;
const p = i * 3;
let rr = lightBase[p], gg = lightBase[p + 1], bb = lightBase[p + 2];
let v = trail[i] / CAP; if (v > 1) v = 1;
const k = v * v * (3 - 2 * v);
rr += (col.glow[0] - rr) * k; gg += (col.glow[1] - gg) * k; bb += (col.glow[2] - bb) * k;
const lv = lastVisit[i];
const age = lv < 0 ? 999 : stepCount - lv;
if (age < 10) {
const tipK = (1 - age / 10) * (0.55 + 0.45 * Math.sin(t * 5 + x * 0.3 + y * 0.2));
rr += (col.tip[0] - rr) * tipK * 0.7;
gg += (col.tip[1] - gg) * tipK * 0.7;
bb += (col.tip[2] - bb) * tipK * 0.7;
}
const n1 = vnoise(x * 0.09, y * 0.09 + t * 0.9);
const n2 = vnoise(x * 0.03 + 40, y * 0.03 + t * 0.6);
const sh = (n1 - 0.5) * 20 + (n2 - 0.5) * 14;
rr += sh; gg += sh; bb += sh;
data[j] = rr; data[j + 1] = gg; data[j + 2] = bb; data[j + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
}
function fit(): void {
const field = fieldRef.current;
const cv = cvRef.current;
if (!field || !cv) return;
const r = field.getBoundingClientRect();
const W = Math.round(r.width), H = Math.round(r.height);
const fw = Math.max(96, Math.min(200, Math.round(W / 3)));
const fh = Math.max(52, Math.min(120, Math.round(H / 3)));
fwRef.current = fw; fhRef.current = fh;
cv.width = fw; cv.height = fh;
cv.style.width = "100%"; cv.style.height = "100%";
const ctx = cv.getContext("2d", { alpha: false });
if (!ctx) return;
ctxRef.current = ctx;
const img = ctx.createImageData(fw, fh);
imgRef.current = img;
trailRef.current = new Float32Array(fw * fh);
trailBRef.current = new Float32Array(fw * fh);
const lastVisit = new Float32Array(fw * fh);
for (let i = 0; i < lastVisit.length; i++) lastVisit[i] = -9999;
lastVisitRef.current = lastVisit;
stepCountRef.current = 0;
buildLightBase();
initAgents();
}
function stillFrame(): void {
for (let k = 0; k < 260; k++) { stepAgents(); diffuseEvap(); }
render(2.6);
}
let t0 = -1;
function frame(now: number): void {
if (t0 < 0) t0 = now;
const t = (now - t0) / 1000;
for (let s = 0; s < SUBSTEPS; s++) { stepAgents(); diffuseEvap(); }
render(t);
setN(String(stepCountRef.current));
rafRef.current = requestAnimationFrame(frame);
}
useEffect(() => {
const root = rootRef.current;
if (!root) return;
agentsRef.current = agents;
decayRef.current = decay;
lightRadiusRef.current = lightRadius;
randRef.current = makePRNG(0x320a09);
colRef.current = {
dark: tokRGB(root, "--sm-dark"),
light: tokRGB(root, "--sm-light"),
glow: tokRGB(root, "--sm-glow"),
tip: tokRGB(root, "--sm-tip"),
};
buildPerm();
fit();
function onResize(): void {
fit();
if (reduced()) stillFrame();
}
window.addEventListener("resize", onResize);
if (reduced()) {
stillFrame();
setStill(true);
} else {
t0 = -1;
rafRef.current = requestAnimationFrame(frame);
}
return () => {
window.removeEventListener("resize", onResize);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agents, decay, lightRadius]);
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}>
<span>검은 배경에 중앙 조명(radial gradient)을 깔고 네트워크를 크게 키우면 다큐멘터리 접사 촬영 같은 한 장면이 됩니다 — 트레일 끝의 여린 촉수가 계속 꿈틀대며 화면이 살아 있습니다</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>
</span>
<span className={styles.metaDim}>canvas · createRadialGradient · requestAnimationFrame</span>
</div>
</div>
</main>
);
}09 · Macro bioluminescent scene — a documentary-lit slime mold on a dark stage — Measured area 49.253% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password
One radial gradient baked into lightBase pixels
// central light base built once with createRadialGradient, baked into lightBase pixels
function buildLightBase() {
var off = document.createElement('canvas');
off.width = fw; off.height = fh;
var octx = off.getContext('2d');
var cx = fw / 2, cy = fh / 2, r = Math.min(fw, fh) * LIGHT_RADIUS;
var g = octx.createRadialGradient(cx, cy, 0, cx, cy, r);
g.addColorStop(0, 'rgb(' + C_LIGHT[0] + ',' + C_LIGHT[1] + ',' + C_LIGHT[2] + ')');
g.addColorStop(0.55, 'rgb(' + Math.round((C_LIGHT[0] + C_DARK[0]) / 2) + ',' + Math.round((C_LIGHT[1] + C_DARK[1]) / 2) + ',' + Math.round((C_LIGHT[2] + C_DARK[2]) / 2) + ')');
g.addColorStop(1, 'rgb(' + C_DARK[0] + ',' + C_DARK[1] + ',' + C_DARK[2] + ')');
octx.fillStyle = g;
octx.fillRect(0, 0, fw, fh);
var d = octx.getImageData(0, 0, fw, fh).data;
lightBase = new Float32Array(fw * fh * 3);
for (var i = 0, p = 0; i < d.length; i += 4, p += 3) {
lightBase[p] = d[i]; lightBase[p + 1] = d[i + 1]; lightBase[p + 2] = d[i + 2];
}
}
Steering straight at the goal instead of sensing trail is where stigmergy breaks
Steering toward the goal point is not stigmergy
Telling each agent the exit's coordinates and turning it that way solves the maze, but it is gradient descent on a map you drew — the entire claim of the 2008 experiment is that the organism holds no map at all. The sensor may read trail, food, obstacles, or the other colony's field, never the goal's direction. Break that and item 02 stops being a slime mold pathfinding demonstration and becomes an animated arrow.
Deposit without decay thickens everything equally
Shipping the steering and the deposit but skipping the (1 - DECAY) multiply leaves every trail that ever formed still on the field, so trunk and dead end sit at the same cap and the contrast that selects the shortest route never appears. Diffusion without evaporation is just as broken — it spreads the sludge evenly. The two operations must run together, every substep, which is why all nine items run their diffusion-and-evaporation pass between agent steps.
Walls you forget to sense stop being walls
If sense() returns the trail value on an obstacle cell instead of the -1000 sentinel, agents steer straight through maze walls and item 02 degenerates into item 01 with a wall texture behind it. The large negative value is what makes a wall read as the worst possible smell, so the turn-away logic does the rest. Forgetting it is the single fastest way to break the maze scene while every other number still looks healthy.
clearRect throws away the afterglow and the age
Clearing the canvas and redrawing from scratch each frame erases two things the measurements depend on: the evaporation afterglow that lets painted area climb past 34%, and item 06's per-cell age information, which lives in the rendered fade itself. Every item here writes one putImageData per frame and lets the field's own decay (or age) do the fading. That is how the nine items hold their 34.819% to 53.258% painted-area band with zero animation errors.
Why does slime mold converge on the shortest path? — trail reinforcement and evaporation
Deposits make the busy route thicker
Every agent that completes a step adds DEPOSIT to one cell, capped at CAP — so a cell on a route many agents walk fills to its cap almost instantly while a cell on a route nobody walks stays empty. Length matters too: a longer route spends more of its deposit spread over more cells, so per-cell concentration drops even at identical traffic. That is the reinforcement half of any slime mold simulation algorithm, and it needs no agent to know anything about the maze's geometry.
Evaporation is what deletes the wrong paths
The multiply by (1 - DECAY) every substep charges every cell the same rent, so a trail only survives where deposits arrive faster than decay collects. Detours and dead ends stop being re-walked once the food scent at the far end pulls traffic onto the shorter passage, their trails dry out, and the agents that still sample them find nothing worth turning for. This is the same single-value cooling that runs a heat field that fades by one multiply per frame — there it dims embers, here it prunes branches.
Diffusion widens a vein into a corridor
Blending each cell with its neighbors' average by DIFFUSE spreads a thin deposit line into a soft ridge wide enough for sensors 5 cells ahead to notice. Without it, trails stay one cell wide and the three-point sampling mostly misses them; with it, a trunk route becomes the brightest thing in every sensor's fan. It is the same neighbor-mixing that lets a grid where two chemicals react as they spread settle into patterns — except here the chemicals are replaced by agents that carry the reaction along with them.
The shortest path wins without anyone knowing it
Nobody stores the maze, runs a search, or compares route lengths — the shortest path is simply the route whose re-walk rate keeps it above the decay line after every other candidate has dried out. Convergence is an accounting outcome: reinforcement minus evaporation, evaluated cell by cell, thousands of times. All nine items run this one slime mold simulation algorithm with different things added to the sensor reading, which is why the same 24-frame check records 23 of 23 transitioning frames on every one of them.
Three variants that swap sensor angle and decay rate, tuned for reduced-motion
One constant, three different networks
| Variant | Changed value | What it feels like |
|---|---|---|
| Sensor spread angle | 07 sensor angle 22.5° → 45° | The network grows sparser and spreads wider, coral into vessels |
| Evaporation rate | 01 trail DECAY 0.02 → 0.06 | Old branches vanish faster, leaving only the optimal route |
| Agent count | 03 agents 4,000 → 12,000 | The network densifies and converges noticeably sooner |
Nothing here is drawn by hand, so widening one constant reshapes the entire network rather than asking anyone to redraw a frame.
Reduced motion still shows a network, not a blank panel
Every item checks matchMedia('(prefers-reduced-motion: reduce)') before the requestAnimationFrame loop is even started, and skips it entirely for a visitor who has asked for less motion. Instead of a blank canvas, the simulation is run ahead for a few hundred steps and one grown frame is drawn once, tagged with an .is-still class — a mid-growth network sits there, still legible, just not crawling.
One pointer-driven item, eight self-running panels
Item 04's anchors are planted by pointerdown, so they need a pointer — but the mode toggle is a real button, and the scripted anchor timetable runs on its own when no pointer input arrives at all. Nothing above is retyped or trimmed — hv25u6ur unlocks the same nine source files the snippets came from, HTML, CSS, and JS together, inside this page's zip.
Questions before you wire a slime mold network into a page
How is this different from an ant colony or astar pathfinding demo
An ant colony simulation shares the trail-following idea but usually lays pheromone between nest and food along fixed paths, while every item here grows the transport network itself from nothing — no nest, no preset edges. Against an A solver the difference is sharper: A matches a slime mold pathfinding algorithm only in outcome, because it holds the whole graph and scores nodes by design. Here no agent ever sees the graph; the shortest route survives as the only trail whose traffic outpaces evaporation.
Can I paste this code in and use it as-is
Yes — nothing in the nine items reaches out to a library, WebGL, or a CDN, so the canvas markup and script tag lift into any page and grow the same network they grow here. Each item is one self-contained HTML file with its own seeded generator, so the fixed seed means the preview regrows the exact same shape every load. That reproducibility is also why people post these as a slime mold simulation github snippet and the demo still matches the write-up.
Why does item 08 measure the lowest numbers of the nine
Item 08 records both the lowest painted area (34.819%) and the lowest average intensity (15.5) because it deliberately dims its frozen snapshot to texture opacity — a background panel, not a hero. Its peak frame of 5.666% is equally honest: a static image crossfading into the next static image cannot spike the way item 05's 41.897% territory push does. The 23-of-23 transitioning frames confirm the one thing it must not lose — the frame still never repeats its predecessor.
Sources: MDN — CanvasRenderingContext2D · MDN — ImageData · MDN — Window.requestAnimationFrame() · MDN — prefers-reduced-motion · Wikipedia — Physarum polycephalum · Wikipedia — Slime mold.
Rail-map infographics and a clickable hero — where these nine actually get used
A transit or logistics story reaches for item 03 first — a slime mold finding near-optimal connections on its own makes a network-optimization idea readable without the reader ever hearing the word algorithm. A hero area built for visitors who like to click reaches for item 04 instead: a network that grows toward or flees the pointer turns a passive diagram into something a reader steers.
A documentary-style intro or a science site's cover screen is where item 09 belongs — a single lit slime mold on a dark stage is already a finished shot, so a headline is the only thing left to add. All three ship as one self-contained file, so none of them need a bundler to sit inside a static section.









