粘菌 迷路 9種 — 最短経路を自ら見つけるキャンバス
粘菌 迷路シミュレーションとは、迷路の絵に道を塗る作業ではなく、点が匂いを嗅いで曲がる規則の反復だけで最短経路がひとりでに浮かぶ計算です。ライブラリ・WebGLなしで、画面を這う点の一つ一つが前・左・右の匂いを嗅ぎ濃い方へ曲がる規則を繰り返すだけで、迷路の最短経路を自ら見つける粘菌ネットワークが育ちます。
デモ
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
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 基本の3センサーエージェント — 前・左・右の匂いを嗅いで曲がる粘菌一粒
- 02 迷路の最短経路 — イグノーベル賞実験をキャンバスで再現
- 03 複数餌ネットワーク — 鉄道網のように最適な接続線を描く
- 04 ポインタの餌・忌避物 — クリックした場所へ育つ、または避ける
- 05 多種の縄張り競争 — 色の違う2つの群れが画面を分け合う
- 06 トレイルの年齢彩色 — 最近通った道ほど明るく光るネオン残像
- 07 センサー角度モーフ — 角度1つでサンゴから血管へ形が変わる
- 08 有機的テクスチャパネル — 1フレームを切り出しカード背景に敷く
- 09 マクロ発光シーン — 暗い舞台にドキュメンタリー照明の粘菌
センサー角度ひとつでサンゴから血管まで分けた九つの段階
並び順の基準は派手さではなくトレイル場への一手がどこに加わるかです。01の基本3センサー操舵は場を何も足さない原形で、02が障害物と餌の匂いを場に混ぜ、03は餌を6つに増やし、04はクリックした座標を寿命つきの符号付きアンカーとして足します。05は場そのものを種ごとに2枚に分けて縄張り競争を起こし、06は積み上げる値を最後に踏まれたステップ番号に置き換え、07はセンサー角度と距離を毎フレーム動かし、08は育ちきった場を静止画として固め、09は黒い舞台に照明を焼いて締めくくります。粘菌 シミュレーションで検索してすぐ試せる9盤は、中身がどれも同じ3点センサーを土台に、01〜05・07〜09は拡散・蒸発の2行を足し、06だけは蒸発の代わりに「最後に踏まれたステップ番号」で古さを読む構成です。群れの規則で形が決まるボイドの9盤や縞が自ら育つ反応拡散の9盤と同じく、複雑な網は1つの単純な規則の繰り返しから生まれます。
3センサー操舵という1つの軸が9盤を貫く
01から09まで、すべての盤で各エージェントは毎ステップ、前方・左(+SENSOR_ANGLE)・右(-SENSOR_ANGLE)の3点の濃度だけを読み、最も濃い方へTURNだけ曲がってSTEPだけ進みます。迷路も鉄道網も縄張りも、この読み取りの対象に何を足すかの違いだけで生まれています。
項目別コード
項目ごとに、成長を左右する定数の名前をそのまま示し、実際に動いているデモの関数を丸ごと引用します。切り詰めた断片ではなく動いているコードそのものなので、この画面から直接自分のページへ貼って動かせます。
01基本の3センサーエージェント — 前・左・右の匂いを嗅いで曲がる粘菌一粒
各エージェントが前方3点のフェロモン濃度を読み、最も濃い方へ曲がって一歩進み、着いたマスにトレイルを積むだけのひと層です。1300体が中央の円形の種から出発して血管のようなネットワークを育て、場は上下左右がつながるトーラスなので境界で途切れません。24フレーム中23フレームが動き続け、累積面積は51.385%まで育ちます。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>各エージェントは前方3点(正面・左・右)のフェロモン濃度を読み、最も濃い方へわずかに曲がって一歩進み、通った跡にトレイルを残します。それを数千体重ねると血管のようなネットワークが自ら育ちます</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="3センサー粘菌エージェント実験版" 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"> ステップ・基本3センサー</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; // 센서가 앞을 내다보는 距離(필드 칸)
// ────────────────────────────────────────────────────
var SENSOR_ANGLE = 0.5; // センサー開き角(라디안)
var TURN = 0.5; // 한 스텝에 도는 각(라디안)
var STEP = 1.05; // 한 스텝 이동 距離(필드 칸)
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 · 基本の3センサーエージェント — 前・左・右の匂いを嗅いで曲がる粘菌一粒 — 計測 累積面積 51.385% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
直進・左折・右折の分岐だけで操舵が決まる
3点の濃度を比較して同じifの連鎖に流すだけです。正面が最も濃ければ直進、左が濃ければ+TURN、右が濃ければ-TURN、同点なら乱数でどちらかに振ります。最後の(rand() - 0.5) * 0.14が向きを揺らして、一度決まった道に閉じ込められるのを防ぎます。
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++;
}
02迷路の最短経路 — イグノーベル賞実験をキャンバスで再現
13×7の迷路を深さ優先探索で掘り、中央の行の左右端に餌を2つ置くと、壁はセンサー値-1000で塞がれ、行き止まりのトレイルから先に蒸発で枯れて最短の通路だけが太く残ります。本物の粘菌が2008年にイグノーベル認知科学賞で認められた迷路実験と同じ理屈です。24フレーム中23フレームが動き、累積面積は43.166%、フレーム最大面積は2.37%と小さいのは13×7の迷路のマス自体が狭いためです。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>迷路の壁を障害物フィールドとして敷き、入口と出口に餌を置くと、行き止まりのトレイルから先に枯れて消え、最短の通路だけが太くなり、迷路を自ら解いていきます</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="迷路最短経路 粘菌実験版" 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"> 探索ステップ・迷路解き</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 · 迷路の最短経路 — イグノーベル賞実験をキャンバスで再現 — 計測 累積面積 43.166% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
壁マスが-1000を返すことが迷路成立の条件
sense()が障害物マスで大きな負の値を返すので、3センサーは壁の方を決して濃いと読みません。餌はexp(-距離²/幅)でにじむ匂い場になってセンサー値に加わり、入口と出口だけが広く強い引力になります。
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;
}
03複数餌ネットワーク — 鉄道網のように最適な接続線を描く
餌6つを放射状にまき、センサー値に餌の匂いを足すと、粘菌がすべての点を結ぶ太い幹線と細い支線を自ら区別して、放射状の輸送網を描きます。蒸発を01より遅くしたDECAY 0.038のおかげで支線がすぐ枯れず、太さの差が画面に残ります。24フレーム中23フレームが動き、累積面積は36.237%です。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>画面に餌の点を複数まくと、粘菌がすべての点を結ぶ太い幹線と細い支線を自ら区別し、効率的な放射状の輸送網を描き出します</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="複数餌 粘菌ネットワーク実験版" 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"> エージェント・放射状輸送網</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 · 複数餌ネットワーク — 鉄道網のように最適な接続線を描く — 計測 累積面積 36.237% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
餌6つの匂い場を1枚の配列に焼く
餌の座標を決め、全マスから各餌への距離二乗にexpを掛けた値の最大値を匂いとして敷き詰めます。このfood配列がtrailに加算されることで、複数の目標の間を最短で結ぶ枝の選別が始まります。
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;
}
}
}
04ポインタの餌・忌避物 — クリックした場所へ育つ、または避ける
クリック(タップ)した座標に符号付きアンカー(+1=餌・-1=塩)を植えると、センサー値に全アンカーの符号×残り寿命×exp(-距離²/30)の合計が加わり、餌には枝が伸び、塩は避けて曲がりながら育ちます。アンカーはANCHOR_LIFE=5.2秒後に消え、ポインター入力のない自動再生では、餌と塩が混ざった5つのアンカーを同じ時刻表で並べたscriptが同じplaceAnchor()を呼んで再現されます。24フレーム中23フレームが動き、累積面積は41.776%です。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>クリックした座標を新しい餌として植えると、ネットワークがそちらへ枝を伸ばします。モードを切り替えて塩(忌避物質)を置くと、トレイルはその場所を避けて曲がりながら育ちます</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="ポインター餌・忌避物 粘菌実験版" 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">クリック = 餌</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 ? 'クリック = 餌' : 'クリック = 塩';
}
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 · ポインタの餌・忌避物 — クリックした場所へ育つ、または避ける — 計測 累積面積 41.776% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
符号付きアンカーの寿命つき場をその場で足す
アンカーは配列に積まれ、anchorField()が現在時刻との差で寿命を計り、距離の二乗でにじんだ値に符号を掛けて足し合わせます。正負が同じ1本の式で引力と斥力の両方が生まれます。
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;
}
05多種の縄張り競争 — 色の違う2つの群れが画面を分け合う
種ごとに独立したトレイル場(trailA・trailB)を持ち、センサー値を「自分の場 − 相手の場×REPEL_W」(REPEL_W=1.7)で計算すると、互いのトレイルを障害物のように避け合い、左端と右端の3割の位置から出発した2色の群れが押し合って境界のはっきりした縄張りに画面を分けます。9種中最大の累積面積53.258%・フレーム最大41.897%で、最も広く塗る盤です。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>種(species)ごとに自分のトレイルにだけ反応し、他種のトレイルは障害物として扱わせると、2色の群れが互いを押し合い、境界のはっきりした縄張りに画面を分けます</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="多種 縄張り競争 粘菌実験版" 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"> ステップ・2種の境界線</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 · 多種の縄張り競争 — 色の違う2つの群れが画面を分け合う — 計測 累積面積 53.258% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
相手の場を1.7倍して引くだけで縄張りが生まれる
stepSpecies()は自分の配列ownに積み、相手の配列otherを引いて読むだけで、中身は01と同じ3センサー分岐です。2つの種が同じstepAll()で同じ回数進むため、押し合いが対称のまま画面の分割まで進みます。
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);
}
}
06トレイルの年齢彩色 — 最近通った道ほど明るく光るネオン残像
トレイル値を積む代わりに、各マスに「最後に踏まれたステップ番号」(lastVisit)だけを記録し、年齢で色を読むと、踏まれたばかりのマスだけがネオンシアンに明るく、古いマスは藍色に沈みます。蒸発を色で表すのではなく、年齢フィールドそのものがセンサーの値になる構成です。餌5つも混ざり、9種中最高の強度平均76.0で光ります。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>各セルに最後に踏まれた時刻を記録し、古いほど色を落とすと、活発に使われる幹線だけがネオンのように明るく残り、使われない支線は自然に消えていきます</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="トレイル年齢彩色 粘菌実験版" 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"> ステップ・トレイル年齢</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 · トレイルの年齢彩色 — 最近通った道ほど明るく光るネオン残像 — 計測 累積面積 35.206% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
年齢フィードがそのままセンサーの値になる
lastVisitに現在のステップ番号だけを書き込み、DECAY_STEPS=46ステップ以内の若さを0〜1に正規化してageKとし、これに4を掛けた値をセンサー値として返します。新しいマスほど濃いと読まれるので、操舵と彩色が同じ1本の配列から生まれます。
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;
}
07センサー角度モーフ — 角度1つでサンゴから血管へ形が変わる
センサー開き角(10〜90度、既定29度)とセンサー距離(4〜30、既定15)の2つのライブスライダーがそのまま毎フレームの操舵計算に入り、同じ規則が密なサンゴ状からまばらな血管状、格子状の都市網まで描き分けます。自動デモは両方の値を正弦波で絶え間なく変化させ、24フレーム中23フレームが動き続けます。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>センサーの開き角と距離という2つの数値をスライダーで動かすだけで、同じ規則が密なサンゴ状、まばらな血管状、格子状の都市網まで、まったく違うネットワークを描き出します</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="センサー角度モーフ 粘菌実験版" tabindex="0">
<canvas class="sm__cv" id="smCv"></canvas>
</div>
<div class="sm__bar">
<label class="sm__ctl">
<span class="sm__lab" id="smAngLab">開き角 29°</span>
<input class="sm__rng" id="smAng" type="range" min="10" max="90" step="1" value="29" aria-label="センサー開き角">
</label>
<label class="sm__ctl">
<span class="sm__lab" id="smDistLab">距離 15</span>
<input class="sm__rng" id="smDist" type="range" min="4" max="30" step="1" value="15" aria-label="センサー距離">
</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 스티그머지 + 실시간 파라미터 모프 —
// センサー開き角(도)과 センサー距離 두 값을 실제 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 = '開き角 ' + sensorAngleDeg + '°';
if (distLab) distLab.textContent = '距離 ' + 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 자동 시연 각본 — 사람이 안 만져도 슬라이더가 스스로 움직여 형태가 모프된다.
// 각 구간은 (開き角 목표, 距離 목표) 쌍. 사인으로 왕복해 끊김 없이 잇는다.
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 · センサー角度モーフ — 角度1つでサンゴから血管へ形が変わる — 計測 累積面積 49.385% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
2本の正弦波が角度と距離を刻々と動かす
autoParams()が角度29度を中心に±28度、距離15を中心に±9で毎秒揺らし、範囲からはみ出た値は切り詰めます。人がスライダーを触ると自動モーフは止まり、その値で同じ計算が走ります。
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);
}
08有機的テクスチャパネル — 1フレームを切り出しカード背景に敷く
GROW_STEPS=130ステップだけネットワークを育て、育ちきった場を静止画として固め、薄い不透明度で敷きます。キャプチャの境界ごとにFADE=0.7秒のクロスフェードで新しいテクスチャへ滑らかに切り替わるので、静止画を並べただけでも画面が死にません。累積面積34.819%・強度平均15.5は9種中最も低い数字ですが、わざと薄いテクスチャとして敷く項目の性格上妥当です。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>ネットワークが育ちきった瞬間のキャンバスを静止画として固め、薄い透明度で敷くと、動かなくても手描きのような有機的な質感がカードやセクションの背景になります</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="有機的テクスチャパネル 粘菌実験版" 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"> キャプチャ・静止テクスチャ</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 · 有機的テクスチャパネル — 1フレームを切り出しカード背景に敷く — 計測 累積面積 34.819% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
育ちきった場を丸ごとスナップショットへ固める
capture()は場の配列を2つのスナップショットの古い方と入れ替え、現在のtrailを新しいスナップに写してから場をゼロに戻し、種の再配置から次の成長を始めます。レンダリングはこの2枚のスナップをmix係数で混ぜるので、切り替わりが瞬間ではなく0.7秒のフェードになります。
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();
}
09マクロ発光シーン — 暗い舞台にドキュメンタリー照明の粘菌
createRadialGradientで作った中央照明(LIGHT_RADIUS=0.62)をlightBaseバッファに一度だけ焼いて黒い舞台に敷き、DECAY=0.04の緩やかな蒸発でネットワークを大きく育てます。直近10ステップ以内に踏まれたマスはsinで脈打つ先端色への補間で明るくなり、トレイルの先端が接写のように絶えずうごめきます。24フレーム中23フレームが動き、累積面積は49.253%です。

<main class="stage">
<div class="sm is-demo" id="smRoot">
<p class="sm__hint">
<span>黒い背景に中央照明(放射グラデーション)を敷きネットワークを大きく育てると、ドキュメンタリーの接写のような一場面になります — トレイルの先端の触手が絶えずうごめき、画面が生きています</span>
</p>
<div class="sm__field" id="smField" role="application" aria-label="マクロ発光 粘菌実験版" 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"> ステップ・マクロ照明</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 · マクロ発光シーン — 暗い舞台にドキュメンタリー照明の粘菌 — 計測 累積面積 49.253% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
照明を1度だけ焼いて色の足し算で脈打たせる
照明グラデーションをオフスクリーンcanvasに描いてFloat32Arrayへ読み込み、以後の毎フレームはこのlightBaseを土台にトレイル値で発光色へ補間し、新しいマスにはsinの位相を掛けた明るさを足します。照明の計算が1回で済むぶん、毎フレームは足し算だけになります。
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];
}
}
鉄道網インフォグラフィックとクリック可能なヒーロー — この9枚を実際どこで使うか
交通・物流のデータを説明したいなら03 複数餌ネットワークが一番近道です。粘菌が自力で最適な接続線を見つけていく過程が、鉄道網最適化アルゴリズムを知らなくても目で納得できる絵になります。訪問者に直接クリックしてほしいヒーロー領域には04 ポインタの餌・忌避物を置くと、クリックした場所へネットワークが育つ、または避ける反応がそのまま操作感になります。
ドキュメンタリー風のイントロや科学系サイトの表紙画面には09 マクロ発光シーンが向いています。暗い舞台の上、照明の下で1粒の粘菌が育つ場面がすでに完成しているので、見出しを重ねるだけで済みます。3枚ともファイル1つで完結しているので、追加のライブラリなしで静的セクションにそのまま組み込めます。
粘菌はなぜ最短経路に収束するのか — トレイル強化と蒸発の計算
最短経路が浮かぶ理由は、経路を比べる計算がどこにも無いことです。各エージェントは自分の周囲3点の濃度しか知らず、長い道と短い道の区別も迷路の全体像も見ていません。それでも短い経路が太るのは、拡散と蒸発の2行が毎ステップ全マスに掛かるからです(06はこの2行の代わりに年齢フィールドで同じ減衰を表現します)。
だれも経路を比べていないのに答えが出る
diffuseEvap()は隣接4マスの平均と自分の値をDIFFUSEの比率で混ぜ、その結果に(1-DECAY)を掛けます。短い経路は同じ数のエージェントが短い周期で踏み返すので補充が蒸発に勝ち、長い回り道や行き止まりは補充が追いつかずに薄れていきます。強化と減衰の釣り合いだけが答えを選ぶので、地図を持たない点の集まりが最短経路に収束します。
迷路では行き止まりから順に枯れる
02の迷路ではこの差が最もはっきり出ます。行き止まりの枝は踏み返す頻度が最も低いので最初に枯れ、入口と出口を結ぶ複数の通路のうち短いものが最後まで濃度を保って太く残ります。03の複数餌でも同じ選別が幹線と支線の太さの差として現れます。
センサー角度と減衰率を変える3つの変種とreduced-motion
| 名前 | 変更する値 | 変わる感触 |
|---|---|---|
| センサー開き角 | 07の角度 22.5度 → 45度 | ネットワークがより疎に広く広がる |
| 蒸発速度 | 01のDECAY 0.02 → 0.06 | 古い枝が早く消え、最適経路だけが残る |
| エージェント数 | 03のAGENTS 4,000 → 12,000 | ネットワークがより密になり、速く収束する |
定数ひとつで変わる3つのつまみ
角度・蒸発・個体数のどれも、書き換えるのはソース先頭の変数ブロックの1行だけです。角度は網の疎密、蒸発は記憶の長さ、個体数は収束の速さという、それぞれ別の性格のつまみです。
動きを減らす設定で残る静止フレーム
9盤すべてにprefers-reduced-motion: reduceの分岐が組み込まれており、ループを回さず固定した1フレームだけを描いて.is-stillクラスを付けます。迷路の02も縄張りの05も静止フレームの選び方は同じで、初期化直後ではなく十分に育った段階の1枚を残すので、動きを止めてもそれぞれ迷路の解けかけと縄張りの境界が見えます。
拡散と蒸発を両方落とすと画面が厚くなるだけの落とし穴
実測で壊れた場面を3つ挙げます。どれも「場の2行を省くと塗り絵に戻る」と同じ根っこです。
拡散と蒸発を両方落とすとただ厚くなるだけ
DIFFUSEとDECAYを0にして積み上げるだけにすると、行き止まりが枯れず画面がただ厚くなり続けて、幹線と支線の区別が死にます。2つの行は常に一緒でなければなりません。
毎フレームのクリアが残光と年齢を消す
毎フレームclearRectで描き直すと、蒸発が勝手に薄める残光も06の年齢情報も消えるので、累積面積30%以上・23フレーム遷移という数値条件が両方壊れました。描画はputImageData1枚に任せます。
壁マスの-1000を漏らすと迷路が迷路でなくなる
02の壁をセンサー計算から漏らすと、点は壁を突き抜けてしまいます。sense()が障害物マスに大きな負の値を返すことが、迷路成立の条件です。
01のDECAYや05のREPEL_Wを自分の値でいじって壊し方を確かめたくなったら、9つのソース一式が入ったzipを開くことになり、解凍のパスワードはhv25u6urです。
粘菌ネットワークを組み込む前によくある質問
粘菌 アルゴリズムはどうやって最短経路を見つけていますか?
経路どうしを比べる処理はどこにもありません。各点は前・左・右の3点のフェロモン濃度だけを読み、濃い方へ曲がり、着いたマスに濃度を積みます。短い経路は踏み返す頻度が高いので補充が蒸発に勝ち、長い経路は負けます。この強化と減衰の釣り合いだけで、地図を持たない点の集まりが結果として最短経路を選びます。
毎回同じ迷路と同じネットワークになりますか?
なります。乱数はすべてmulberry32系でシード固定しており、項目ごとに0x320A01から0x320A09の固定シードを与えています。プレビューを開き直せば同じ迷路の配置から同じ順序で同じネットワークが育ちます。Math.random()に置き換えると毎回違う形に育ち、再現の確認ができなくなります。
ライブラリなしで本当にこの9種は動きますか?
動きます。使っているのはHTMLファイル1つの中のgetContext('2d')とputImageData、requestAnimationFrame、配列の四則演算だけです。WebGLも外部CDNもフォントも読み込みません。zipを展開してindex.htmlを開けば、そのままブラウザで9盤全部が動きます。









