verbs/school.js
80 lines · in natura-src-1.0.0.zip as natura/verbs/school.js · a module · its entry in the module reference · its test: verbs/school-test.js
// verbs/school.js -- ONE life ontology verb, one concern: move WITH your neighbours (shoaling / flocking / herding).
// Pure and RENDER-FREE. The third steering verb beside forage (toward a resource) and flee (from a threat): here the
// pull is toward the GROUP itself. Reynolds' three rules -- SEPARATION (don't crowd a close neighbour), ALIGNMENT
// (steer the way the group steers), COHESION (drift toward the group's centre) -- which together make a leaderless
// crowd move as one body. Universal: a shoal of reef fish, a flock of starlings, a herd on the move, a swarm of
// midges (weight the rules differently and the same verb gives each its character). Proven inline in "As One" (the
// dense murmuration); lifted OUT here so every life system shares it instead of re-rolling it, exactly as flee was
// lifted from ecosystem.js. Supports METRIC neighbours (within a radius) OR TOPOLOGICAL (the k nearest by COUNT --
// the Cavagna result: a real flock minds a fixed NUMBER of neighbours, not everything within a distance, which is why
// it stays cohesive however far it stretches). Geometry-agnostic via a delta hook (a wrapped/toroidal world passes
// its own metric). UMD -> window.NaturaVerbs.school / module.exports.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else (root.NaturaVerbs = root.NaturaVerbs || {}).school = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// accept pos/vel as [x,y] OR {x,y} (organisms.js uses arrays; forage/flee use {x,y}) -- one verb, either shape.
const PX = p => (p == null ? 0 : (p.x != null ? p.x : p[0])) || 0;
const PY = p => (p == null ? 0 : (p.y != null ? p.y : p[1])) || 0;
// school(self, others, opts) -> { sx, sy, sep:[x,y], ali:[x,y], coh:[x,y], n, near }
// self = { pos, vel } -- pos required; vel optional (only alignment needs it)
// others = [ { pos, vel } ] -- the population (self may be included; it is skipped by identity)
// opts = {
// perceive: 0.12 -- METRIC neighbourhood radius (used when k is not set)
// k: null -- TOPOLOGICAL: if set, use the k NEAREST others (by count) as the neighbourhood instead
// sepRadius: perceive*0.45 -- separation acts only inside this closer radius (collision-avoid is inherently near)
// sep: 1.5, ali: 1.0, coh: 1.0 -- rule weights (a shoal ~ balanced; a midge-swarm ~ high coh, low ali)
// delta:(a,b)->[dx,dy] -- vector a-b; default euclidean. A toroidal world passes its own wrapped metric.
// }
// Returns a UNIT-ISH steering vector {sx,sy} (the weighted sum of the three unit rule-vectors) for the caller to
// add to its velocity: vel += steer * accel * dt, then clamp speed. Components are exposed for tuning/telemetry.
function school(self, others, opts) {
opts = opts || {};
const perceive = opts.perceive != null ? opts.perceive : 0.12;
const k = opts.k != null ? (opts.k | 0) : null;
const sepR = opts.sepRadius != null ? opts.sepRadius : perceive * 0.45;
const wSep = opts.sep != null ? opts.sep : 1.5;
const wAli = opts.ali != null ? opts.ali : 1.0;
const wCoh = opts.coh != null ? opts.coh : 1.0;
const delta = opts.delta || euclid;
const sx0 = PX(self.pos), sy0 = PY(self.pos);
const p2 = perceive * perceive, sr2 = sepR * sepR;
// build the neighbour set: euclidean-ish distances via the delta hook, skip self by identity.
const cand = [];
for (let i = 0; i < others.length; i++) {
const o = others[i]; if (o === self) continue;
const d = delta({ x: sx0, y: sy0 }, o.pos), d2 = d[0] * d[0] + d[1] * d[1];
cand.push({ o: o, dx: d[0], dy: d[1], d2: d2 }); // dx,dy = self - other (away vector, for separation)
}
let hood;
if (k != null) { cand.sort((a, b) => a.d2 - b.d2); hood = cand.slice(0, k); } // topological: k nearest
else { hood = []; for (let i = 0; i < cand.length; i++) if (cand[i].d2 < p2) hood.push(cand[i]); } // metric radius
let sepx = 0, sepy = 0, alix = 0, aliy = 0, cohx = 0, cohy = 0, nCoh = 0, nSep = 0;
for (let i = 0; i < hood.length; i++) {
const c = hood[i], o = c.o;
// COHESION: mean position -> steer toward the centroid. accumulate the neighbour's own offset from self.
cohx += (-c.dx); cohy += (-c.dy); nCoh++; // (other - self) = -(self - other)
// ALIGNMENT: mean heading. sum unit velocities (skip the still).
if (o.vel) { const vx = PX(o.vel), vy = PY(o.vel), m = Math.hypot(vx, vy); if (m > 1e-9) { alix += vx / m; aliy += vy / m; } }
// SEPARATION: push off neighbours inside the close radius, weighted 1/d (nearer pushes harder).
if (c.d2 < sr2 && c.d2 > 1e-12) { const d = Math.sqrt(c.d2); sepx += c.dx / d / d; sepy += c.dy / d / d; nSep++; }
}
// normalize each rule to a unit direction (so weights, not neighbour counts, set the balance)
const sep = unit(sepx, sepy), ali = unit(alix, aliy);
let coh = [0, 0];
if (nCoh) { coh = unit(cohx / nCoh, cohy / nCoh); } // toward the mean offset (the centroid)
const sx = sep[0] * wSep + ali[0] * wAli + coh[0] * wCoh;
const sy = sep[1] * wSep + ali[1] * wAli + coh[1] * wCoh;
return { sx: sx, sy: sy, sep: sep, ali: ali, coh: coh, n: hood.length, near: nSep };
}
function unit(x, y) { const m = Math.hypot(x, y); return m > 1e-9 ? [x / m, y / m] : [0, 0]; }
function euclid(a, b) { return [a.x - PX(b), a.y - PY(b)]; } // a is {x,y}; b is a pos ([x,y] or {x,y})
return { school: school };
});