← Natura·Docs/Modules/Source

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 };
});