← Natura·Docs/Modules/Source

creatures.html

464 lines · in natura-src-1.0.0.zip as natura/creatures.html · the 2D creature bench (?montage=NAME, ?zoom=NAME) that creatures/ARCHITECTURE.md points to

<meta charset="utf-8"><title>First Light</title>
<style>html,body{margin:0;background:#b9c9d2;overflow:hidden}canvas{display:block}</style>
<canvas id="cv"></canvas>
<!-- the shared creature factory (see creatures/ARCHITECTURE.md): data -> render -> behaviour, composed by the facade.
     This page is ONE consumer of it (the First Light meadow); the Natura world can include the same modules. -->
<script src="creatures/species.js"></script>
<script src="creatures/render.js"></script>
<script src="creatures/behaviour.js"></script>
<script src="creatures/creatures.js"></script>
<script src="scene-meadow.js"></script>
<script>
'use strict';
const cv=document.getElementById('cv'), g=cv.getContext('2d');
function fit(){cv.width=innerWidth;cv.height=innerHeight;} addEventListener('resize',fit); fit();
const {SPEC, draw, BEH, GALLOP, targetOf, mulberry, isBird, isSnake} = Creatures;
const setLight = Creatures.setLight || (window.CreatureRender && window.CreatureRender.setLight) || function(){};   // robust to a stale-cached facade: fall back to the render module, never crash the whole scene
const TOD = null;   // TIME OF DAY: null = dawn (First Light). The Night Meadow deploy flips THIS line to a night value.
const NIGHT = (TOD!=null) || /[?&]tod=night/.test(location.search);        // ?tod=night forces night for local preview
const TODV = NIGHT ? (TOD!=null?TOD:0.02) : null;                          // the numeric tod handed to the backdrop (deep night)
if(NIGHT) setLight('moon');                                               // cool moonlight rim on the creatures, not warm sun

// --- CAMERA: scroll to zoom into the meadow (a far silhouette resolves into a full model as you push
// in -- the dot->model made interactive), drag to pan. Clamped so the view stays inside the scene. ---
const cam={zoom:1, ox:0, oy:0};
function clampCam(){ cam.ox=Math.min(0, Math.max(cv.width*(1-cam.zoom), cam.ox)); cam.oy=Math.min(0, Math.max(cv.height*(1-cam.zoom), cam.oy)); }
addEventListener('wheel', ev=>{ ev.preventDefault(); const z0=cam.zoom, z1=Math.min(6, Math.max(1, z0*(ev.deltaY<0?1.15:1/1.15)));
  cam.ox=ev.clientX - z1*(ev.clientX-cam.ox)/z0; cam.oy=ev.clientY - z1*(ev.clientY-cam.oy)/z0; cam.zoom=z1; clampCam(); }, {passive:false});
let drag=null;
addEventListener('mousedown', ev=>{ if(ev.clientX>=sndBtn.x&&ev.clientX<=sndBtn.x+sndBtn.w&&ev.clientY>=sndBtn.y&&ev.clientY<=sndBtn.y+sndBtn.h){ toggleSound(); return; } drag={x:ev.clientX,y:ev.clientY,ox:cam.ox,oy:cam.oy}; cv.style.cursor='grabbing'; });
addEventListener('keydown', ev=>{ if(ev.key==='m'||ev.key==='M') toggleSound(); });
addEventListener('mousemove', ev=>{ if(drag){ cam.ox=drag.ox+(ev.clientX-drag.x); cam.oy=drag.oy+(ev.clientY-drag.y); clampCam(); } });
addEventListener('mouseup', ()=>{ drag=null; cv.style.cursor=cam.zoom>1?'grab':'default'; });
// deep-link / test hook: ?z=ZOOM,centerXfrac,centerYfrac opens already zoomed into a spot
const zp=/[?&]z=([\d.]+)(?:,([\d.]+),([\d.]+))?/.exec(location.search);
if(zp){ cam.zoom=Math.min(6,Math.max(1,parseFloat(zp[1])||1));
  const cxf=zp[2]!=null?parseFloat(zp[2]):0.5, cyf=zp[3]!=null?parseFloat(zp[3]):0.5;
  cam.ox=cv.width*0.5 - cam.zoom*cxf*cv.width; cam.oy=cv.height*0.5 - cam.zoom*cyf*cv.height; clampCam(); }

// --- SOUND: a generative dawn CHORUS for the meadow (gesture-gated, autoplay-safe). Birdsong is
//     whistled pitch-glides in syllables over a low pad + a bandpassed breeze; the pond plinks; a
//     coiling snake breathes a bandpassed HISS. Off until you click 'sound'. (making-sound craft.)
let AC=null, aMaster=null, aOn=false, aMuted=false, birdTimer=1.6;
const sndBtn={x:0,y:0,w:0,h:0};
function makeIR(sec){ const len=Math.ceil(AC.sampleRate*sec), b=AC.createBuffer(2,len,AC.sampleRate);  // a decaying-noise impulse -> outdoor air
  for(let ch=0;ch<2;ch++){ const d=b.getChannelData(ch); for(let i=0;i<len;i++){ const u=i/len; d[i]=(Math.random()*2-1)*Math.pow(1-u,1.7)*(0.55+0.45*Math.random()); } }
  return b; }
function audioStart(){
  if(AC){ if(AC.state==='suspended') AC.resume(); return; }
  try{ AC=new (window.AudioContext||window.webkitAudioContext)(); }catch(e){ return; }
  aMaster=AC.createGain(); aMaster.gain.value=0.0001;
  const lp=AC.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=6200;
  const comp=AC.createDynamicsCompressor(); comp.threshold.value=-12; comp.ratio.value=4; comp.attack.value=0.003; comp.release.value=0.18;   // a gentle limiter so stacked calls never clip
  aMaster.connect(lp); lp.connect(comp); comp.connect(AC.destination);
  const conv=AC.createConvolver(); conv.buffer=makeIR(1.7);                                      // REVERB: a little outdoor air under the dry chorus
  const revLp=AC.createBiquadFilter(); revLp.type='lowpass'; revLp.frequency.value=3200;         // soft + distant
  const wet=AC.createGain(); wet.gain.value=0.34;
  aMaster.connect(conv); conv.connect(revLp); revLp.connect(wet); wet.connect(comp);
  aMaster.gain.exponentialRampToValueAtTime(0.85, AC.currentTime+1.8);                 // ease the dawn in
  [55,82.4,110].forEach(function(f,i){ const o=AC.createOscillator(), gg=AC.createGain();  // a low soft PAD (SINE, not triangle -- triangle's harmonics muddied 100-500Hz and buried the birdsong; the spectrogram showed it)
    o.type='sine'; o.frequency.value=f*(1+(i-1)*0.003); gg.gain.value=[0.018,0.013,0.009][i];   // subliminal warmth, not a drone (the spectrogram showed the pad still saturating the low end) -- let the breeze + birds carry the dawn
    const lfo=AC.createOscillator(), lg=AC.createGain(); lfo.frequency.value=0.05+i*0.02; lg.gain.value=gg.gain.value*0.5;
    lfo.connect(lg); lg.connect(gg.gain); lfo.start(); o.connect(gg); gg.connect(aMaster); o.start(); });
  const nb=AC.createBuffer(1, AC.sampleRate*3, AC.sampleRate), nd=nb.getChannelData(0);              // BREEZE
  for(let i=0;i<nd.length;i++) nd[i]=Math.random()*2-1;
  const bn=AC.createBufferSource(); bn.buffer=nb; bn.loop=true;
  const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=500; bp.Q.value=0.7;
  const bg=AC.createGain(); bg.gain.value=0.03;                                   // a gentle air, not a wash (spectrogram-judged)
  const blfo=AC.createOscillator(), blg=AC.createGain(); blfo.frequency.value=0.07; blg.gain.value=0.02; blfo.connect(blg); blg.connect(bg.gain); blfo.start();
  bn.connect(bp); bp.connect(bg); bg.connect(aMaster); bn.start();
  if(NIGHT) startCrickets();                                                      // the night's cricket bed under the hoots
  aOn=true;
}
function toggleSound(){ if(!AC){ audioStart(); } else { aMuted=!aMuted; if(aMaster) aMaster.gain.setTargetAtTime(aMuted?0.0001:0.85, AC.currentTime, 0.1); if(AC.state==='suspended') AC.resume(); } }
function aGain(){ return (aOn && !aMuted && AC) ? 1 : 0; }   // AC-null-safe: a headless render (no gesture -> no AudioContext) never enters an audio path
const BIRDS={ robin:[0.15,[2100,2760,2300,3180,2500,2900],0.10], wren:[0.12,[3000,3600,3200,3800,3300],0.06], distant:[0.06,[1500,1780,1360,1620],0.15] };
function birdSong(kind,dest){ if(!aGain()) return; const P=BIRDS[kind]||BIRDS.robin, base=P[0], notes=P[1], nd=P[2]; let t=AC.currentTime+0.03;
  for(let k=0;k<notes.length;k++){ const f=notes[k]*(0.98+Math.random()*0.05), o=AC.createOscillator(), gg=AC.createGain();
    o.type='sine'; o.frequency.setValueAtTime(f*0.7,t); o.frequency.exponentialRampToValueAtTime(f,t+nd*0.35); o.frequency.exponentialRampToValueAtTime(f*0.9,t+nd);
    const vib=AC.createOscillator(), vg=AC.createGain(); vib.frequency.value=26; vg.gain.value=f*0.018; vib.connect(vg); vg.connect(o.frequency); vib.start(t); vib.stop(t+nd+0.02);
    gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(base*(0.6+Math.random()*0.5),t+0.012); gg.gain.exponentialRampToValueAtTime(0.0006,t+nd);
    o.connect(gg); gg.connect(dest||aMaster); o.start(t); o.stop(t+nd+0.03); t += nd*(0.7+Math.random()*0.9); } }
function waterPlink(dest){ if(!aGain()) return; const t=AC.currentTime+0.03, f=680+Math.random()*520, o=AC.createOscillator(), gg=AC.createGain();
  o.type='sine'; o.frequency.setValueAtTime(f,t); o.frequency.exponentialRampToValueAtTime(f*0.5,t+0.13);
  gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(0.04,t+0.005); gg.gain.exponentialRampToValueAtTime(0.0006,t+0.2);
  o.connect(gg); gg.connect(dest||aMaster); o.start(t); o.stop(t+0.22); }
function snakeHiss(dest){ if(!aGain()) return; const t=AC.currentTime+0.01, dur=0.45+Math.random()*0.35;
  const nb=AC.createBuffer(1, Math.ceil(AC.sampleRate*dur), AC.sampleRate), nd=nb.getChannelData(0);
  for(let i=0;i<nd.length;i++) nd[i]=Math.random()*2-1;
  const src=AC.createBufferSource(); src.buffer=nb;
  const hp=AC.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=2800;
  const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=5400; bp.Q.value=1.2;
  const gg=AC.createGain(); gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(0.11,t+0.1); gg.gain.setValueAtTime(0.1,t+dur*0.55); gg.gain.exponentialRampToValueAtTime(0.0005,t+dur);
  src.connect(hp); hp.connect(bp); bp.connect(gg); gg.connect(dest||aMaster); src.start(t); src.stop(t+dur); }
// a CROW caws -- a harsh rasping corvid tone (sawtooth + a tremolo rasp + a lowpass), 1-2 caws
function crowCaw(dest){ if(!aGain()) return; const t0=AC.currentTime+0.02, n=1+(Math.random()<0.4?1:0);
  for(let c=0;c<n;c++){ const t=t0+c*0.33, f=560+Math.random()*120, o=AC.createOscillator(), gg=AC.createGain();
    o.type='sawtooth'; o.frequency.setValueAtTime(f*1.15,t); o.frequency.exponentialRampToValueAtTime(f*0.8,t+0.22);
    const trem=AC.createOscillator(), tg=AC.createGain(); trem.frequency.value=55; tg.gain.value=0.03; trem.connect(tg); tg.connect(gg.gain); trem.start(t); trem.stop(t+0.28);  // a SUBTLE rasp on the gain, not a blow-up
    const lp=AC.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=2200;
    gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(0.085,t+0.02); gg.gain.setValueAtTime(0.075,t+0.16); gg.gain.exponentialRampToValueAtTime(0.0006,t+0.26);
    o.connect(lp); lp.connect(gg); gg.connect(dest||aMaster); o.start(t); o.stop(t+0.28); } }
// a DUCK quacks -- a nasal buzzy burst (sawtooth through a formant bandpass), 1-2 quacks
function duckQuack(dest){ if(!aGain()) return; const t0=AC.currentTime+0.02, n=1+((Math.random()*2)|0);
  for(let q=0;q<n;q++){ const t=t0+q*0.18, f=300+Math.random()*80, o=AC.createOscillator(), gg=AC.createGain();
    o.type='sawtooth'; o.frequency.setValueAtTime(f*1.2,t); o.frequency.exponentialRampToValueAtTime(f,t+0.12);
    const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=920; bp.Q.value=3;
    gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(0.075,t+0.01); gg.gain.exponentialRampToValueAtTime(0.0006,t+0.14);
    o.connect(bp); bp.connect(gg); gg.connect(dest||aMaster); o.start(t); o.stop(t+0.16); } }
// an OWL calls at night -- a tawny's soft quavering HOOT ("hoo... hu-hooooo"), or a barn owl's eerie raspy SHRIEK
function owlHoot(dest, isBarn){ if(!aGain()) return; const t0=AC.currentTime+0.03;
  if(isBarn){ const dur=0.45+Math.random()*0.3;                                   // barn owl: a rasped shriek falling in pitch
    const nb=AC.createBuffer(1,Math.ceil(AC.sampleRate*dur),AC.sampleRate), nd=nb.getChannelData(0);
    for(let i=0;i<nd.length;i++) nd[i]=Math.random()*2-1;
    const src=AC.createBufferSource(); src.buffer=nb;
    const bp=AC.createBiquadFilter(); bp.type='bandpass'; bp.frequency.setValueAtTime(1700,t0); bp.frequency.linearRampToValueAtTime(1050,t0+dur); bp.Q.value=3.2;
    const gg=AC.createGain(); gg.gain.setValueAtTime(0,t0); gg.gain.linearRampToValueAtTime(0.08,t0+0.05); gg.gain.setValueAtTime(0.07,t0+dur*0.6); gg.gain.exponentialRampToValueAtTime(0.0005,t0+dur);
    src.connect(bp); bp.connect(gg); gg.connect(dest||aMaster); src.start(t0); src.stop(t0+dur);
  } else { const base=376+Math.random()*44, parts=[[0,0.26,1.0],[0.62,0.9,0.985]];   // tawny: a short "hoo", a pause, then a long quaver
    for(const p of parts){ const t=t0+p[0], dur=p[1], f=base*p[2];
      const o=AC.createOscillator(); o.type='sine'; o.frequency.setValueAtTime(f*1.015,t); o.frequency.linearRampToValueAtTime(f*0.97,t+dur);
      const vib=AC.createOscillator(), vg=AC.createGain(); vib.frequency.value=(dur>0.4?11:6); vg.gain.value=f*0.028; vib.connect(vg); vg.connect(o.frequency); vib.start(t); vib.stop(t+dur+0.02);
      const gg=AC.createGain(); gg.gain.setValueAtTime(0,t); gg.gain.linearRampToValueAtTime(0.12,t+dur*0.22); gg.gain.setValueAtTime(0.1,t+dur*0.72); gg.gain.exponentialRampToValueAtTime(0.0006,t+dur);
      o.connect(gg); gg.connect(dest||aMaster); o.start(t); o.stop(t+dur+0.03); } } }
// a CRICKET BED: several continuous stridulators -- a carrier tone gated by a fast pulse-train inside a slow chirp.
// Each cricket has a POSITION in the meadow and can be DUCKED silent when the hunting owl passes over it.
let nightCrickets=[];
function startCrickets(){ if(!AC) return; nightCrickets=[];
  const N=7;
  for(let i=0;i<N;i++){ const carrier=3900+i*230+Math.random()*160, x=0.08+ (i+Math.random()*0.5)/N*0.84;
    const o=AC.createOscillator(); o.type='sine'; o.frequency.value=carrier;
    const pulse=AC.createOscillator(); pulse.type='square'; pulse.frequency.value=25+i*3;        // the buzz within a chirp
    const pd=AC.createGain(); pd.gain.value=0.5; pulse.connect(pd);
    const pg=AC.createGain(); pg.gain.value=0.5; pd.connect(pg.gain);                            // base 0.5 + square(+-0.5) -> 0..1
    const chirp=AC.createOscillator(); chirp.type='square'; chirp.frequency.value=1.7+i*0.28;    // chirp on/off ~2/sec
    const cd=AC.createGain(); cd.gain.value=0.5; chirp.connect(cd);
    const cg=AC.createGain(); cg.gain.value=0.5; cd.connect(cg.gain);
    const base=0.012, lvl=AC.createGain(); lvl.gain.value=base;                                   // the per-cricket level -- DUCKED as the owl passes
    const pan=AC.createStereoPanner?AC.createStereoPanner():null; if(pan) pan.pan.value=(x*2-1)*0.92;
    o.connect(pg); pg.connect(cg); cg.connect(lvl); lvl.connect(pan||aMaster); if(pan) pan.connect(aMaster);
    o.start(); pulse.start(); chirp.start();
    nightCrickets.push({lvl:lvl, x:x, base:base, cur:base}); }
}
// a per-creature SPATIAL bus: PAN by screen-x, and quieter + duller with DISTANCE (higher up / farther) --
// so a call comes from where the creature stands, the audio twin of the visual depth-scaling
function spatialBus(c){
  if(!AC) return null;                                         // no context (headless) -> no bus; every caller tolerates a null dest
  const px=((c.px!=null?c.px:c.x)%1+1)%1, pan=Math.max(-0.92,Math.min(0.92, px*2-1));
  const depth=Math.max(0,Math.min(1,(c.y-0.42)/0.5));                                        // 0 far(top) .. 1 near(bottom)
  const g=AC.createGain(); g.gain.value=0.4+depth*0.6;                                        // far = quieter
  const lp=AC.createBiquadFilter(); lp.type='lowpass'; lp.frequency.value=1800+depth*5200;    // far = duller
  let tail=lp; g.connect(lp);
  if(AC.createStereoPanner){ const p=AC.createStereoPanner(); p.pan.value=pan; lp.connect(p); tail=p; }
  tail.connect(aMaster); return g;
}

// --- FIRST LIGHT: a small morning meadow the creatures LIVE in -- each runs a slow behaviour loop ---
let t0=null, last=null;
const dayCast=[ {sp:'deer', x:0.14,y:0.46,dir:1, sc:1.15,v:0.028, seed:0.10, beh:BEH.flee},   // grazes, then startles and bolts
             {sp:'heron',x:0.65,y:0.52,dir:-1,sc:1.05,v:0.010, seed:0.58, beh:BEH.wade, pond:1, sy:0.63},   // tall, mid, fishing IN the pond (sy sorts it onto the water)
             {sp:'wolf', x:0.40,y:0.63,dir:1, sc:1.25,v:0.05,  seed:0.30, beh:BEH.stalk},  // mid, padding through
             {sp:'fox',  x:0.58,y:0.74,dir:-1,sc:1.10,v:0.05,  seed:0.15, beh:BEH.stalk},  // mid-front, hunting the grass
             {sp:'robin',x:0.88,y:0.82,dir:-1,sc:1.45,v:0.05,  seed:0.0,  beh:BEH.forage}, // front, hop-foraging
             {sp:'crow', x:0.47,y:0.85,dir:1, sc:1.2, v:0.03,  seed:0.25, beh:BEH.forage}, // front, strutting + pecking
             {sp:'fawn', x:0.30,y:0.51,dir:1, sc:0.92,v:0.016, seed:0.62, beh:BEH.graze},  // a spotted fawn nibbling in the herd
             {sp:'fawn', x:0.44,y:0.49,dir:-1,sc:0.86,v:0.016, seed:0.18, beh:BEH.freeze}, // another, standing skittish
             {sp:'boar', x:0.80,y:0.69,dir:-1,sc:1.15,v:0.02,  seed:0.5,  beh:BEH.graze},  // rooting at the ground, head low
             {sp:'piglet',x:0.87,y:0.71,dir:-1,sc:1.1, v:0.024,seed:0.44, beh:BEH.graze},  // a striped piglet rooting beside the sow
             {sp:'piglet',x:0.73,y:0.72,dir:1, sc:1.0, v:0.03, seed:0.88, beh:BEH.graze},  // a second piglet nosing the grass
             {sp:'hare', x:0.20,y:0.88,dir:1, sc:1.05,v:0.06,  seed:0.42, beh:BEH.freeze}, // front, freeze/bound
             {sp:'bear', x:0.09,y:0.44,dir:1, sc:0.82,v:0.014, seed:0.7,  beh:BEH.stalk},  // a bear lumbering along the far treeline
             {sp:'badger',x:0.33,y:0.80,dir:1, sc:1.12,v:0.02, seed:0.37, beh:BEH.dig}, // a badger digging low in the dawn grass, striped face down, flinging earth
             {sp:'hedgehog',x:0.62,y:0.86,dir:-1,sc:1.35,v:0.018,seed:0.5, beh:BEH.forage}, // a hedgehog snuffling through the front grass, its spiny back a low dome
             {sp:'squirrel',x:0.24,y:0.83,dir:1, sc:1.25,v:0.03, seed:0.48,beh:BEH.sitforage}, // a red squirrel: bounds a bit, then SITS UP to eat, tail arched high over its back
             {sp:'squirrel',x:0.50,y:0.55,dir:-1,sc:0.9, v:0.026,seed:0.71,beh:BEH.sitforage}, // a second squirrel sitting up to eat near the treeline
             {sp:'duck', x:0.74,y:0.605,dir:1,sc:1.0, v:0.012, seed:0.33, beh:BEH.forage, pond:1, sy:0.63}, // a mallard drake dabbling ON the pond (sy sorts it onto the water)
             {sp:'grass',x:0.17,y:0.91,dir:1, sc:1.3, v:0.045, seed:0.70, beh:BEH.stalk},  // a grass snake gliding low through the front grass, tongue flicking
             {sp:'adder',x:0.53,y:0.785,dir:-1,sc:1.0, v:0.028, seed:0.40, beh:BEH.stalk}, // an adder easing across the mid-grass
             {sp:'corn', x:0.70,y:0.88,dir:-1,sc:1.15,v:0.04,  seed:0.55, beh:BEH.stalk}, // a corn snake gliding the front-right grass, saddle-banded
             {sp:'owl',  x:0.08,y:0.62,dir:1, sc:0.95,v:0.0,   seed:0.20, beh:BEH.roost}, // a short-eared owl perched low in the dawn grass, head slowly swiveling to watch the meadow
             {sp:'barnowl',x:0.955,y:0.60,dir:-1,sc:0.82,v:0.0,seed:0.62, beh:BEH.roost} ]; // a ghost-pale barn owl perched at the far-right edge, heart face turning to watch, back from the night's hunt
// at NIGHT the meadow belongs to the nocturnal set -- the owls hunt, the badger digs, the hedgehog snuffles, the
// fox prowls, a hare freezes, a deer grazes in the moonlight; the day's waders/herd/basking-snakes are gone to bed.
const nightCast=[ {sp:'owl',   x:0.12,y:0.60,dir:1, sc:1.05,v:0.0,  seed:0.20, beh:BEH.roost},   // a tawny owl perched, head swiveling to hunt by ear
             {sp:'badger', x:0.30,y:0.82,dir:1, sc:1.18,v:0.02, seed:0.37, beh:BEH.dig},         // digging in the moonlit grass, earth flung back
             {sp:'hedgehog',x:0.60,y:0.86,dir:-1,sc:1.4, v:0.018,seed:0.5,  beh:BEH.forage},     // snuffling low through the front grass
             {sp:'fox',    x:0.66,y:0.72,dir:-1,sc:1.15,v:0.05, seed:0.15, beh:BEH.stalk},        // prowling, ears pricked
             {sp:'hare',   x:0.22,y:0.9, dir:1, sc:1.1, v:0.06, seed:0.42, beh:BEH.freeze},       // frozen bolt-still, listening
             {sp:'deer',   x:0.84,y:0.5, dir:-1,sc:1.0, v:0.01, seed:0.6,  beh:BEH.graze},        // one deer grazing in the moonlight
             {sp:'fox',    x:0.44,y:0.6, dir:1, sc:0.9, v:0.04, seed:0.82, beh:BEH.stalk},        // a second fox, mid-field
             {sp:'owl',    x:0.90,y:0.64,dir:-1,sc:0.9, v:0.0,  seed:0.5,  beh:BEH.roost},        // a second owl perched right, watching
             {sp:'mouse',  x:0.40,y:0.80,dir:1, sc:0.72,v:0.05, seed:0.1,  beh:BEH.forage},        // field mice scurrying the grass -- the owl's prey
             {sp:'mouse',  x:0.70,y:0.86,dir:-1,sc:0.74,v:0.06, seed:0.5,  beh:BEH.forage},
             {sp:'mouse',  x:0.55,y:0.72,dir:1, sc:0.68,v:0.055,seed:0.83, beh:BEH.forage} ];
const cast = NIGHT ? nightCast : dayCast;
// a scattered deer herd grazing in the middle distance -- depth sets scale, so the LOD draws far ones as silhouettes
const herd=[]; if(!NIGHT){ const h=mulberry(20260803);   // the big basking day-herd beds down at night -- the night meadow is the nocturnal cast's
  for(let i=0;i<8;i++){ const depth=h();
    herd.push({sp:'deer', x:0.04+h()*0.92, y:0.40+depth*0.15, dir:h()<0.5?-1:1,
      sc:0.42+depth*depth*0.62, v:0.008+h()*0.016, seed:h(), beh: h()<0.72?BEH.graze:BEH.stalk}); } }
const scene=[...herd, ...cast].sort((a,b)=>(a.sy!=null?a.sy:a.y)-(b.sy!=null?b.sy:b.y));   // draw far (higher up) first; sy overrides depth so pond waders sort onto the water
let flyOwl ={ x:0.12, y:0.33, dir:1, state:'glide', t:0, huntT:3+Math.random()*4, target:null, tuck:0, strike:0, kind:'barnowl', band:0,    phase:0 };   // the barn owl -- glides day+night, HUNTS at night: glide -> stoop -> strike -> lift
let flyOwl2={ x:0.62, y:0.40, dir:-1,state:'glide', t:0, huntT:2+Math.random()*4, target:null, tuck:0, strike:0, kind:'owl',     band:0.08, phase:2.1 };   // a SECOND hunter (tawny owl), NIGHT only -- quarters the lower band so the night meadow has a hunting PAIR
const PRED=new Set(['wolf','fox','bear','cougar']);                 // for ear-swivel: hunters attend to prey, prey to hunters
const isPreyM=sp=>sp==='deer'||sp==='fawn'||sp==='hare';           // eared mammals that read as prey (not the head-down rooting boar family)
// a still POND in the mid-meadow where the heron fishes -- a reflective pool holding the pale morning sky, a warm
// sun-glint, and slow drifting ripples. Drawn on the ground before the creatures, so a wader stands at its edge.
function drawPond(g, W, Hn, e, cx, cy, rw, rh){
  g.save();
  const grd=g.createLinearGradient(0, cy-rh, 0, cy+rh);                 // sky-reflecting: paler at the far edge
  if(NIGHT){ grd.addColorStop(0,'#93a4b6'); grd.addColorStop(0.5,'#63768a'); grd.addColorStop(1,'#3f4e60'); }   // moonlit water -- cool, the brightest thing in the dark
  else { grd.addColorStop(0,'#b2c2c8'); grd.addColorStop(0.5,'#95aaad'); grd.addColorStop(1,'#7f9597'); }
  g.fillStyle=grd; g.beginPath(); g.ellipse(cx, cy, rw, rh, 0, 0, 7); g.fill();
  g.save(); g.beginPath(); g.ellipse(cx, cy, rw, rh, 0,0,7); g.clip();   // reflections + ripples clipped to the pool
  // the pool reflects its SURROUNDINGS: a dark treeline mirrored in the far band, and the warm horizon glow above it
  const trG=g.createLinearGradient(0, cy-rh, 0, cy-rh*0.05);
  trG.addColorStop(0,'rgba(56,72,58,0.55)'); trG.addColorStop(0.55,'rgba(72,88,72,0.2)'); trG.addColorStop(1,'rgba(72,88,72,0)');
  g.fillStyle=trG; g.fillRect(cx-rw, cy-rh, rw*2, rh*0.95);
  const glowC = NIGHT ? '210,224,244' : '255,240,210';                     // horizon glow above the treeline: warm dawn / cool moon
  const glG=g.createLinearGradient(0, cy-rh*0.85, 0, cy-rh*0.05);
  glG.addColorStop(0,'rgba('+glowC+',0.17)'); glG.addColorStop(1,'rgba('+glowC+',0)');
  g.fillStyle=glG; g.fillRect(cx-rw, cy-rh*0.85, rw*2, rh*0.8);
  const gx=cx-rw*0.32, glintC = NIGHT ? '228,238,252' : '255,240,206';     // the glint down the water: the low SUN, or the MOON
  const sunG=g.createLinearGradient(gx, cy-rh, gx, cy+rh);
  sunG.addColorStop(0,'rgba('+glintC+',0)'); sunG.addColorStop(0.5,'rgba('+glintC+','+(NIGHT?0.42:0.55)+')'); sunG.addColorStop(1,'rgba('+glintC+',0)');
  g.fillStyle=sunG; g.fillRect(gx-rw*0.09, cy-rh, rw*0.18, rh*2);
  if(NIGHT){ const my=cy-rh*0.5 + Math.sin(e*0.8)*rh*0.04;                  // the MOON's bright reflection near the far edge, wavering
    const mg=g.createRadialGradient(gx,my,0,gx,my,rw*0.16);
    mg.addColorStop(0,'rgba(242,247,255,0.72)'); mg.addColorStop(0.45,'rgba(220,232,250,0.32)'); mg.addColorStop(1,'rgba(220,232,250,0)');
    g.fillStyle=mg; g.beginPath(); g.ellipse(gx,my,rw*0.15,rh*0.11,0,0,7); g.fill(); }
  for(let i=0;i<8;i++){ const t=i/8, yy=cy-rh*0.82 + t*rh*1.64, drift=Math.sin(e*0.7 + i*1.3)*rw*0.05;   // drifting shimmer lines
    g.strokeStyle='rgba(232,240,242,'+(0.09+0.09*Math.sin(e*1.1+i)).toFixed(2)+')'; g.lineWidth=1;
    g.beginPath(); g.ellipse(cx+drift, yy, rw*(0.72-t*0.34), Math.max(0.8,rh*0.06), 0, 0, 7); g.stroke(); }
  g.restore();
  g.strokeStyle='rgba(48,56,42,0.32)'; g.lineWidth=2.4; g.beginPath(); g.ellipse(cx, cy, rw, rh, 0,0,7); g.stroke();   // wet-mud rim
  g.restore();
}
function frame(t){
  if(t0===null){t0=t; last=t;} const e=(t-t0)/1000; let dt=(t-last)/1000; last=t; if(dt>0.05)dt=0.05;
  if(aGain()){ birdTimer-=dt; if(birdTimer<=0){
    if(NIGHT){ birdTimer=3.4+Math.random()*5.5;                                       // NIGHT: sparse owl calls over the cricket bed -- the hush is the point
      const owls=scene.filter(function(c){return c.sp==='owl'||c.sp==='barnowl';});
      if(owls.length && Math.random()<0.7){ const b=owls[(Math.random()*owls.length)|0]; owlHoot(spatialBus(b), b.sp==='barnowl'); }
      if(Math.random()<0.1){ const pond=scene.find(function(c){return c.pond;}); waterPlink(pond?spatialBus(pond):null); }
    } else { birdTimer=0.9+Math.random()*2.4;                                         // DAY: the actual birds on screen sing their own voices
      const birds=scene.filter(function(c){return isBird(c.sp);});
      if(birds.length && Math.random()<0.72){ const b=birds[(Math.random()*birds.length)|0], bus=spatialBus(b);
        if(b.sp==='crow') crowCaw(bus); else if(b.sp==='duck') duckQuack(bus); else if(b.sp==='robin') birdSong('robin',bus); else birdSong('wren',bus); }
      else birdSong('distant');                                                        // else a distant unseen bird (centred ambience)
      if(Math.random()<0.18){ const pond=scene.find(function(c){return c.pond;}); waterPlink(pond?spatialBus(pond):null); } } } }
  const W=cv.width, Hn=cv.height, horizon=MorningMeadow.horizon(Hn);
  g.save(); g.translate(cam.ox,cam.oy); g.scale(cam.zoom,cam.zoom);   // camera: everything below is drawn in world space (identity at zoom 1)
  MorningMeadow.draw(g, W, Hn, e, TODV);                              // the shared meadow backdrop (scene-meadow.js) -- dawn, or the moonlit night
  const pCX=W*0.65, pCY=Hn*0.61, pRW=W*0.19, pRH=Hn*0.055; let pondDrawn=false;   // the pond is DEPTH-SORTED into the scene below (drawn when the near creatures begin), so far animals go behind it
  // is a deer bolting in panic, and where? nearby grazers will catch the alarm (herd behaviour)
  let threatX=null;
  { const f=cast[0]; if(f.px!=null && targetOf(f,e).gait===GALLOP) threatX=((f.px%1)+1)%1; }
  // CREATURES, each living its own behaviour -- poses eased so heads raise and legs plant smoothly
  // PASS 1 -- update each creature's behaviour + posture, then its DEFLECTED draw position and a depth key taken
  // from where its FEET land. The old bug: the scene sorted by BODY y but the pond-deflection moved the FEET, so a
  // deer pushed to the near shore still sorted as if behind the water and the pond painted over its legs. Now the
  // sort key and the deflection AGREE (both use the deflected foot position), so a deer in front of the pond is in front.
  for(const c of scene){
    if(!c.p) c.p={neck:0,ear:0,peck:0,adv:1,stand:0,dig:0,bristle:0,raise:0,coil:0,strike:0,siteat:0};   // dig/bristle/raise/coil/strike/siteat MUST start at 0, not undefined, or the ease makes NaN -> ||0 -> the channel silently never fires
    let tg=targetOf(c,e); const k=1-Math.pow(0.0016, dt);       // exponential ease toward the target posture
    if(threatX!=null && c.beh===BEH.graze){                     // a grazing herd deer near the bolt throws its head up
      const px0=((c.px==null?c.x:c.px)%1+1)%1, d=Math.abs(px0-threatX);
      if(Math.min(d,1-d) < 0.20) tg={adv:0, neck:-0.45, ear:0.7, stand:1}; // alarmed: head up, ears forward, frozen
    }
    // a BADGER stops digging and turns defensive when a predator is CLOSE: hunch, hold ground, hackles up
    if(c.sp==='badger'||c.sp==='hedgehog'){ let nd=9; const myP=((c.px==null?c.x:c.px)%1+1)%1;
      for(const o of scene){ if(o===c||!PRED.has(o.sp)) continue; const oP=(((o.px!=null?o.px:o.x)%1)+1)%1; let dpx=oP-myP; if(dpx>0.5)dpx-=1; else if(dpx<-0.5)dpx+=1;
        const dy=o.y-c.y, d2=dpx*dpx*2.6+dy*dy; if(d2<nd)nd=d2; }
      const near=Math.sqrt(nd);
      if(c.sp==='hedgehog'){
        if(near<0.16) tg={adv:0, neck:0.55, ear:0.2, stand:1, dig:0, bristle:1, curl:1};       // VERY close: curl into a spiny BALL
        else if(near<0.28) tg={adv:0, neck:0.55, ear:0.25, stand:1, dig:0, bristle:1, curl:0}; // near: freeze + tuck + spines ERECT
      } else if(near < 0.26) tg={adv:0, neck:-0.05, ear:0.72, stand:1, dig:0, bristle:1}; }     // BADGER: freeze + face it + hackles up
    // a SNAKE's three-stage threat response off predator proximity (the deer-startle/badger pattern): glide when
    // clear -> freeze + rear the head (alert) when one nears -> coil into a striking S, and STRIKE when it is right on it
    if(isSnake(c.sp)){ let nd=9, ndpx=0; const myP=((c.px==null?c.x:c.px)%1+1)%1;
      for(const o of scene){ if(o===c||!PRED.has(o.sp)) continue; const oP=(((o.px!=null?o.px:o.x)%1)+1)%1; let dpx=oP-myP; if(dpx>0.5)dpx-=1; else if(dpx<-0.5)dpx+=1;
        const dy=o.y-c.y, d2=dpx*dpx*2.6+dy*dy; if(d2<nd){nd=d2; ndpx=dpx;} }
      const near=Math.sqrt(nd);
      if(near<0.42) c.dir = (ndpx>=0?1:-1);                                             // TURN TO FACE the threat, so the rear/coil/strike points AT it, not away
      if(near<0.13) tg={adv:0, raise:0.35, coil:1, strike:(Math.sin(e*7)>0.55?1:0)};   // right on it: coiled + striking
      else if(near<0.24) tg={adv:0, raise:0.5, coil:1, strike:0};                        // close: rear into the striking S
      else if(near<0.42) tg={adv:0, raise:0.75, coil:0, strike:0}; }                     // medium: freeze + rear the head, alert
    // a SQUIRREL is prey: it DROPS its meal and reacts when a predator nears (the badger/hedgehog proximity read) --
    // freeze bolt-upright + alert when one comes close, then BOLT away (down off its haunches) when it is right on it
    if(c.sp==='squirrel'){ let nd=9, ndpx=0; const myP=((c.px==null?c.x:c.px)%1+1)%1;
      for(const o of scene){ if(o===c||!PRED.has(o.sp)) continue; const oP=(((o.px!=null?o.px:o.x)%1)+1)%1; let dpx=oP-myP; if(dpx>0.5)dpx-=1; else if(dpx<-0.5)dpx+=1;
        const dy=o.y-c.y, d2=dpx*dpx*2.6+dy*dy; if(d2<nd){nd=d2; ndpx=dpx;} }
      const near=Math.sqrt(nd);
      if(near<0.30){ c.dir=(ndpx>=0?-1:1);                                               // turn AWAY from the threat (flee facing)
        tg = near<0.15 ? {adv:1.9, neck:-0.1, ear:0.5, stand:0, siteat:0}                 // right on it: BOLT off (down off its haunches, meal abandoned)
                       : {adv:0,   neck:-0.38,ear:0.8, stand:1, siteat:0}; } }            // near: freeze bolt-upright, alert, meal dropped
    for(const key of ['neck','ear','peck','adv','stand','dig','bristle','curl','raise','coil','strike','siteat']) c.p[key]=(c.p[key]||0)+((tg[key]||0)-(c.p[key]||0))*k;
    if(isSnake(c.sp)){ if(c.p.coil>0.5 && !c._hissed){ snakeHiss(spatialBus(c)); c._hissed=true; } else if(c.p.coil<0.25) c._hissed=false; }   // a coiling snake HISSES from where it is (rising edge)   // ||0 both sides: a newly-added channel can NEVER NaN-out even if c.p was not seeded (the trap that silenced dig+bristle)
    if(c.px==null)c.px=c.x;
    c.px += dt*c.v*c.dir*c.p.adv; const px=((c.px%1)+1)%1;      // drift across only while moving (adv>1 = a dash)
    c.gp = (c.gp||0) + dt*(c.v*22)*c.p.adv;    // cadence tracks the actual GROUND movement (not the gait type)
    // EAR-SWIVEL: ears turn toward the nearest of the OTHER alignment (prey<->predator) -- the herd ATTENDS, not just grazes.
    let earT=0; if(isPreyM(c.sp) || PRED.has(c.sp)){ const mePred=PRED.has(c.sp);
      let bestDpx=null, bd=9;
      for(const o of scene){ if(o===c) continue; if(!(mePred?isPreyM(o.sp):PRED.has(o.sp))) continue;
        const oP=(((o.px!=null?o.px:o.x)%1)+1)%1; let dpx=oP-px; if(dpx>0.5)dpx-=1; else if(dpx<-0.5)dpx+=1;
        const dy=o.y-c.y, d2=dpx*dpx*3+dy*dy; if(d2<bd){ bd=d2; bestDpx=dpx; } }
      if(bestDpx!=null){ const dist=Math.sqrt(bd), range=mePred?0.6:0.52;
        if(dist<range){ earT=(Math.sign(bestDpx*c.dir)||1)*Math.min(1,(range-dist)/range); } } }
    c.p.earTurn=(c.p.earTurn||0)+(earT-(c.p.earTurn||0))*k;
    // HEAD-SWIVEL (any owl): a slow idle gaze-sweep that snaps to WATCH the nearest creature that passes close.
    if(SPEC(c.sp).facialDisc){ let fT=Math.sin(e*0.45 + c.seed*7)*0.6;
      let bd=9, bDx=null; for(const o of scene){ if(o===c) continue;
        const oP=(((o.px!=null?o.px:o.x)%1)+1)%1; let dpx=oP-px; if(dpx>0.5)dpx-=1; else if(dpx<-0.5)dpx+=1;
        const dy=o.y-c.y, d2=dpx*dpx*2+dy*dy; if(d2<bd){ bd=d2; bDx=dpx; } }
      if(bDx!=null && Math.sqrt(bd)<0.34) fT=(Math.sign(bDx*c.dir)||1)*0.95;   // locks its stare onto the passer
      c.p.faceTurn=(c.p.faceTurn||0)+(fT-(c.p.faceTurn||0))*k*0.5; }
    const pose={neck:c.p.neck, ear:c.p.ear, stand:c.p.stand, earTurn:c.p.earTurn, faceTurn:c.p.faceTurn||0, dig:c.p.dig||0, bristle:c.p.bristle||0, curl:c.p.curl||0, raise:c.p.raise||0, coil:c.p.coil||0, strike:c.p.strike||0, siteat:c.p.siteat||0, clock:e,   // clock -> panting quiver; dig -> digging badger; bristle -> raised hackles/spines; curl -> hedgehog ball; raise/coil/strike -> snake alert/coil/strike; faceTurn -> owl head-swivel
      lod: Math.min(1, Math.max(0, (c.sc*cam.zoom-0.26)/0.52))}; // detail tracks ON-SCREEN size, so zoom resolves the LOD
    if(tg.gait) pose.gait=tg.gait;                             // discrete gait swap (walk -> gallop on a bolt)
    // EXERTION -> a moving PREDATOR runs warm and PANTS (mouth open, tongue lolling): exert tracks the dash effort
    // and decays ~8x slower than it rises, so the tongue lingers after the animal stops. Render gates on canid/feline.
    { const exT=Math.max(0,Math.min(1,(c.p.adv-0.6)*0.85));
      c.p.exert=(c.p.exert||0)+(exT-(c.p.exert||0))*(exT>(c.p.exert||0)?k:k*0.12);
      if(PRED.has(c.sp)) pose.pant=c.p.exert; }
    if(c.beh===BEH.wade) pose.peck=c.p.peck;                    // only the heron drives an explicit strike
    // DEFLECT a non-pond creature RADIALLY around the pond -- push the WHOLE animal (body + feet) clear of the water.
    const Sd=SPEC(c.sp), gyd=(isSnake(c.sp)? 0 : isBird(c.sp)? Sd.leg+Sd.bodyH*0.42 : Sd.H*0.5+Sd.leg)*c.sc;
    let dxDraw = px*W, dyDraw = Hn*c.y;
    if(!c.pond){ const nx=(px*W-pCX)/pRW, ny=(Hn*c.y+gyd-pCY)/pRH, d2=nx*nx+ny*ny, M=1.4;   // M = how far past the shore to keep the feet
      if(d2 < M*M){ const d=Math.sqrt(Math.max(1e-4,d2)), push=M/d;
        dxDraw = pCX + nx*pRW*push; dyDraw = (pCY + ny*pRH*push) - gyd; } }
    c.__px=px; c.__dx=dxDraw; c.__dy=dyDraw; c.__gyd=gyd; c.__pose=pose;
    c.__sortY = c.pond ? (c.sy!=null?c.sy:c.y) : (dyDraw+gyd)/Hn;   // depth from the DEFLECTED feet -> pond-front deer sort in front of the water
  }
  // PASS 2 -- depth-sort by the deflected foot position, then draw, interleaving the pond at its waterline.
  const order = scene.slice().sort((a,b)=>a.__sortY-b.__sortY);
  const pondLine = pCY/Hn;
  for(const c of order){
    const px=c.__px, dxDraw=c.__dx, dyDraw=c.__dy, gyd=c.__gyd, pose=c.__pose;
    if(!pondDrawn && c.__sortY >= pondLine){ drawPond(g, W, Hn, e, pCX, pCY, pRW, pRH); pondDrawn=true; }  // the water, at its depth: behind animals drawn, in-front ones still to come
    if(c.__caught) continue;                                    // a mouse the owl has taken -- carried off, not drawn
    // REFLECTION on the pond: a flipped, faded, gently shimmering copy of a pond creature, clipped to the water
    if(c.pond){ const S2r=SPEC(c.sp), footY=Hn*c.y + (isBird(c.sp)? S2r.leg+S2r.bodyH*0.42 : S2r.H*0.5+S2r.leg)*c.sc;
      g.save();
      g.beginPath(); g.ellipse(W*0.65, Hn*0.61, W*0.19, Hn*0.055, 0,0,7); g.clip();    // only where there is water
      g.globalAlpha=0.26; g.translate(Math.sin(e*1.4+c.seed*6)*2.2, 0);                // gentle horizontal shimmer
      g.translate(0, footY); g.scale(1,-0.86); g.translate(0,-footY);                  // mirror about the waterline, slightly squashed
      draw(g, c.sp, px*W, Hn*c.y, c.dir, c.gp, c.sc, pose);
      g.restore();
      g.save(); g.beginPath(); g.ellipse(W*0.65, Hn*0.61, W*0.19, Hn*0.055, 0,0,7); g.clip();   // ripple rings where the legs pierce the surface
      for(let r=0;r<2;r++){ const rp2=((e*0.22 + r*0.5 + c.seed)%1);
        g.strokeStyle='rgba(232,240,242,'+(0.20*(1-rp2)).toFixed(2)+')'; g.lineWidth=1;
        g.beginPath(); g.ellipse(px*W, footY, (5+rp2*18)*c.sc, (2+rp2*6)*c.sc, 0,0,7); g.stroke(); }
      g.restore();
    }
    // a long soft shadow cast rightward from the low morning sun (on the left) -- sits the animal in the light
    { const S2=SPEC(c.sp), gyc=(isSnake(c.sp)? 0 : isBird(c.sp)? S2.leg+S2.bodyH*0.42 : S2.H*0.5+S2.leg)*c.sc, wide=(S2.L||S2.bodyL||34)*c.sc*0.5;
      g.save(); g.fillStyle='rgba(38,42,28,0.15)';
      g.beginPath(); g.ellipse(dxDraw + wide*0.85, dyDraw + gyc, wide*1.7, 4.2, 0, 0, 7); g.fill(); g.restore(); }
    draw(g, c.sp, dxDraw, dyDraw, c.dir, c.gp, c.sc, pose);
  }
  if(!pondDrawn) drawPond(g, W, Hn, e, pCX, pCY, pRW, pRH);   // fallback: no near creature triggered it, so draw the pond over everything
  // the HUNTER: a barn owl quartering the meadow on silent wings, and at night STOOPING on a mouse
  // the HUNTERS: at night a barn owl AND a tawny owl quarter the meadow on silent wings, each stooping on mice
  { const hunters = NIGHT ? [flyOwl, flyOwl2] : [flyOwl];
    for(const F of hunters){ F.t+=dt; const gY=0.30+(F.band||0)+0.045*Math.sin(e*0.55+(F.phase||0));
      if(F.state==='glide'){
        F.x += dt*0.05*F.dir; if(F.x>1.18) F.x=-0.18; else if(F.x<-0.18) F.x=1.18;
        F.y += (gY-F.y)*0.08; F.tuck+=(0-F.tuck)*0.1; F.strike+=(0-F.strike)*0.1; F.huntT-=dt;
        if(F.huntT<=0 && NIGHT){                                                       // pick a mouse roughly under this owl's path and drop on it
          let best=null,bd=0.34; for(const m of scene){ if(m.sp!=='mouse'||m.__caught||m.__hunted) continue;   // don't both dive the same mouse
            const mp=((m.px!=null?m.px:m.x)%1+1)%1, dd=Math.abs(mp-F.x); if(dd<bd){ bd=dd; best=m; } }
          if(best){ F.target=best; best.__hunted=true; F.state='stoop'; F.t=0; } else F.huntT=2+Math.random()*3; }
      } else if(F.state==='stoop'){                                                    // fold the wings and dive to just above the mouse
        const m=F.target, mp=m?((m.px!=null?m.px:m.x)%1+1)%1:F.x, my=m?m.y:0.8;
        F.x += (mp-F.x)*0.13; F.y += (my-0.03-F.y)*0.11; F.tuck+=(1-F.tuck)*0.14; F.strike+=(0-F.strike)*0.2;
        if(!m||m.__caught){ F.state='lift'; F.t=0; } else if(Math.abs(F.y-(my-0.03))<0.02){ F.state='strike'; F.t=0; }
      } else if(F.state==='strike'){                                                   // flare up, talons down, GRAB
        F.strike+=(1-F.strike)*0.3; const m=F.target; F.y += ((m?m.y:0.8)-F.y)*0.2;
        if(F.t>0.16 && m && !m.__caught){ m.__caught=true; }                            // got it -- the mouse vanishes, carried off
        if(F.t>0.5){ F.state='lift'; F.t=0; }
      } else {                                                                         // lift: rise back to the glide, the catch carried away
        F.y += (gY-F.y)*0.06; F.x += dt*0.04*F.dir; F.strike+=(0-F.strike)*0.12; F.tuck+=(0-F.tuck)*0.12;
        if(Math.abs(F.y-gY)<0.02){ if(F.target){ F.target.__caught=false; F.target.__hunted=false; F.target.px=Math.random(); F.target.x=F.target.px; F.target=null; }   // a new mouse ventures out elsewhere
          F.state='glide'; F.huntT=4+Math.random()*5; } }
    }
    // THE HUSH THAT FOLLOWS THE HUNTER: crickets fall silent under whichever owl is nearest, deepest where it stoops --
    // the writing "What the Silence Is Made Of" made audible: you hear the owls by the pockets of quiet they drag.
    if(NIGHT && aGain() && nightCrickets.length){
      for(const cr of nightCrickets){ let duck=0;
        for(const F of hunters){ const groundness=Math.max(0,Math.min(1,(F.y-0.33)/0.42)), radius=0.10+groundness*0.16;
          const prox=Math.max(0,1-Math.abs(cr.x-F.x)/radius), d=prox*(0.45+groundness*0.55); if(d>duck) duck=d; }
        const tgt=cr.base*(1-duck*0.95);
        if(Math.abs(tgt-cr.cur)>cr.base*0.02){ cr.cur=tgt; cr.lvl.gain.setTargetAtTime(tgt, AC.currentTime, duck>0.3?0.06:0.35); } } }  // duck fast, recover slow
    for(const F of hunters){ const ox=F.x*W, oy=Hn*F.y, osc=1.05, oph=e*1.6;
      g.save(); g.fillStyle='rgba(38,42,28,'+(0.12*(1-F.tuck*0.4)).toFixed(2)+')';      // shadow tightens as it dives
      g.beginPath(); g.ellipse(ox+26, Hn*0.6, 30*osc*(1-F.tuck*0.3), 4.5, 0,0,7); g.fill(); g.restore();
      if(F.strike>0.4){ g.save(); g.fillStyle='rgba(190,198,178,'+(0.28*F.strike).toFixed(2)+')';   // a small dust puff at the pounce
        for(let i=0;i<6;i++){ const a=i/6*6.283; g.beginPath(); g.arc(ox+Math.cos(a)*9*F.strike, Hn*F.y+7+Math.sin(a)*4*F.strike, 1.6,0,7); g.fill(); } g.restore(); }
      draw(g, F.kind, ox, oy, F.dir, oph, osc, {flying:true, lod:0.82, tuck:F.tuck, strike:F.strike}); } }
  g.restore();                                                 // end camera
  if(NIGHT){
    // FIREFLIES: a low scatter of green-gold glimmers, each drifting and pulsing on its own slow rhythm (the night's signature)
    const ff=mulberry(7130);
    for(let i=0;i<18;i++){ const bx=ff(), by=ff(), ph=ff()*6.2832, dr=0.35+by*0.5;
      const x=((bx*W + Math.sin(e*0.3+i*1.6)*28 + e*(3+by*7))%(W+40)+W+40)%(W+40) - 20;
      const y=Hn*(0.5+by*0.44) + Math.sin(e*0.5+i*2.1)*9;
      const p=Math.max(0, Math.sin(e*1.5 + ph)), a=p*p*(0.45+by*0.4);
      if(a>0.02){ g.fillStyle='rgba(188,238,138,'+(a*0.28).toFixed(2)+')'; g.beginPath(); g.arc(x,y,(3+by*2.4),0,7); g.fill();   // soft halo
        g.fillStyle='rgba(228,255,182,'+a.toFixed(2)+')'; g.beginPath(); g.arc(x,y,(0.9+dr),0,7); g.fill(); } }               // glowing core
  } else {
    // MOTES: backlit pollen + seed-fluff adrift on the morning breeze (screen-space foreground), catching the low sun -- the air made visible
    const mt=mulberry(4242);
    for(let i=0;i<44;i++){ const depth=mt(), r=0.6+depth*1.8;
      const x=((mt()*W + e*(7+depth*17))%(W+40)+W+40)%(W+40) - 20, y=Hn*(0.30+mt()*0.66) + Math.sin(e*0.6+i*1.7)*(5+depth*7);
      if(depth>0.72){ g.fillStyle='rgba(255,240,206,0.16)'; g.beginPath(); g.arc(x,y,r*2.4,0,7); g.fill(); }   // a soft halo on the nearest, brightest motes
      g.fillStyle='rgba(255,247,224,'+(0.12+depth*0.33).toFixed(2)+')'; g.beginPath(); g.arc(x,y,r,0,7); g.fill(); } }
  MorningMeadow.lightTint(g, W, Hn, e, TODV);    // a passing cloud dims the WHOLE scene -- meadow AND creatures -- as one system
  // a faint hint, in screen space (only while at rest at full-out zoom)
  if(cam.zoom<1.02){ g.fillStyle='rgba(40,52,40,0.34)'; g.font='12px system-ui,sans-serif';
    g.fillText('scroll to zoom in  ·  drag to pan', 16, Hn-16); }
  { g.font='12px system-ui,sans-serif'; const lbl=(!AC)?'♪ sound':(aMuted?'♪ sound: off':'♪ sound: on'), tw=g.measureText(lbl).width;
    sndBtn.x=W-tw-18; sndBtn.y=Hn-30; sndBtn.w=tw+10; sndBtn.h=24;
    g.fillStyle=(AC&&!aMuted)?'rgba(96,124,84,0.9)':'rgba(60,74,54,0.55)'; g.fillText(lbl, W-tw-14, Hn-16); }
  requestAnimationFrame(frame);
}
// MONTAGE mode (?montage=deer|robin|...) -- one species across its 8 gait phases, to judge the whole walk at once
const mm=/[?&]montage=(\w+)/.exec(location.search);
// ZOOM mode (?zoom=deer|fox|...) -- one species up a scale ladder: a far dot resolves into the full detailed model
const zm=/[?&]zoom=(\w+)/.exec(location.search);
if(mm && SPEC(mm[1])){
  const sp=mm[1], S=SPEC(sp);
  cv.width=1240; cv.height=640; const cols=4, cw=cv.width/cols, ch=cv.height/2;
  g.fillStyle='#171b22'; g.fillRect(0,0,cv.width,cv.height);
  const gpose = /[?&]gallop/.test(location.search) ? {gait:GALLOP} : undefined;   // ?montage=deer&gallop to judge the gallop
  const swiv = /[?&]swivel/.test(location.search);                                // ?montage=owl&swivel to judge the head-swivel across its arc
  const fly = /[?&]fly/.test(location.search);                                     // ?montage=owl&fly to judge the wingbeat across its cycle
  for(let i=0;i<8;i++){ const col=i%cols, row=(i/cols)|0, cx=col*cw+cw*0.5, cy=row*ch+ch*0.5;
    g.strokeStyle='rgba(120,150,110,0.10)'; g.lineWidth=1; g.beginPath();
    g.moveTo(col*cw, cy+S.leg*1.55); g.lineTo(col*cw+cw, cy+S.leg*1.55); g.stroke();
    let fp = swiv ? Object.assign({}, gpose, {faceTurn:(i/7-0.5)*1.5}) : gpose;   // sweep the face from over one shoulder to the other
    if(fly){ fp = Object.assign({}, fp, {flying:true});
      if(/[?&]hunt/.test(location.search)){ const u=i/7; fp.tuck = u<0.6? u/0.6 : Math.max(0,0.3-(u-0.6)); fp.strike = u<0.6? 0 : (u-0.6)/0.4; } }  // frames 0-4 fold into a stoop, 5-7 flare the strike
    if(/[?&]sit/.test(location.search)){ fp = Object.assign({}, fp||{}, {siteat:1, clock:i*0.9}); }   // ?montage=mouse&sit -> judge the sit-and-nibble form (squirrel/mouse)
    draw(g, sp, cx, cy, 1, i/8, 1.55, fp);
    g.fillStyle='rgba(200,210,220,0.45)'; g.font='11px monospace'; g.fillText(sp+(fly?' fly':swiv?' swivel '+((i/7-0.5)*1.5).toFixed(2):(gpose?' gallop':''))+' '+(i/8).toFixed(3), col*cw+8, row*ch+16);
  }
} else if(zm && SPEC(zm[1])){
  const sp=zm[1], bird=isBird(sp);
  cv.width=1240; cv.height=540; g.fillStyle='#171b22'; g.fillRect(0,0,cv.width,cv.height);
  const gyOf = s => isSnake(s) ? 0 : bird ? (SPEC(s).leg + SPEC(s).bodyH*0.42) : (SPEC(s).H*0.5 + SPEC(s).leg);
  const groundY=cv.height*0.72, scales=[0.12,0.2,0.32,0.5,0.8,1.25,1.9];
  g.strokeStyle='rgba(120,150,110,0.12)'; g.lineWidth=1; g.beginPath(); g.moveTo(30,groundY); g.lineTo(cv.width-20,groundY); g.stroke();
  let x=70;
  for(const sc of scales){
    draw(g, sp, x, groundY - gyOf(sp)*sc, 1, 0.12, sc);          // feet aligned on the common ground line
    g.fillStyle='rgba(200,210,220,0.42)'; g.font='11px monospace'; g.fillText('x'+sc, x-8, cv.height-18);
    x += 60 + sc*135;                                            // spacing grows with the creature
  }
  g.fillStyle='rgba(200,210,220,0.5)'; g.font='13px monospace';
  g.fillText(sp+' -- level of detail: a far dot, resolving into the full model', 30, 28);
} else { requestAnimationFrame(frame); }
</script>