04Pointer food or repellent — the network grows toward or flees your click
Click to plant a new food source and branches reach toward it — switch modes to drop salt (a repellent) and the trail bends away instead.
Best forInteractive promos, participatory community backgrounds
pointermoveImageDatarequestAnimationFrame

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