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>