creatures/pond.html
123 lines · in natura-src-1.0.0.zip as natura/creatures/pond.html · creature demo: render3d.js and voice.js together (a wetland at dusk)
<!doctype html>
<html><head><meta charset="utf-8"><title>natura -- a wetland at dusk</title>
<style>html,body{margin:0;height:100%;background:#14121c;overflow:hidden}canvas{display:block;width:100vw;height:100vh}</style></head>
<body>
<canvas id="c" width="1280" height="760"></canvas>
<div id="snd" style="position:fixed;left:50%;bottom:22px;transform:translateX(-50%);color:#e8dcc8;font:14px system-ui,sans-serif;background:rgba(20,18,28,0.55);padding:7px 15px;border-radius:20px;letter-spacing:0.3px;pointer-events:none">click the pond for the dusk sound · then click a creature to hear it</div>
<script src="render3d.js"></script>
<script src="voice.js"></script>
<script>
const cv=document.getElementById('c'), ctx=cv.getContext('2d'), C3=window.Creatures3D||Creatures3D;
const {ellipsoid,tube}=C3.prims, place=C3.place;
const GREEN=[74,98,60], GRASS=[92,120,66], WATER=[44,88,118], WATER2=[54,104,132], PAD=[70,124,74], BLOOM=[228,150,182], REED=[96,132,70], CAT=[126,86,54], ROCK=[120,116,110], EARTH=[86,70,52];
function reed(out, x, z, h, lean, cattail){
const path=[{x,y:0,z},{x:x+lean*0.3,y:h*0.55,z:z+lean*0.1},{x:x+lean,y:h,z:z+lean*0.2}];
tube(out, path, [0.05,0.035,0.02], 5, REED, true);
if(cattail) ellipsoid(out, {x:x+lean,y:h,z:z+lean*0.2}, {x:0.06,y:0.16,z:0.06}, 7,6, CAT);
}
function lily(out, x, z, r, flower){
ellipsoid(out, {x,y:0.07,z}, {x:r,y:0.03,z:r}, 12,3, PAD);
if(flower){ for(let i=0;i<6;i++){ const a=i/6*6.283; ellipsoid(out, {x:x+Math.cos(a)*0.12,y:0.12,z:z+Math.sin(a)*0.12}, {x:0.07,y:0.05,z:0.03}, 5,4, BLOOM);} ellipsoid(out,{x,y:0.13,z},{x:0.06,y:0.05,z:0.06},6,4,[240,220,120]); }
}
function grass(out, x, z, n, h){ for(let i=0;i<n;i++){ const a=i/n*6.283+x*3, dx=Math.cos(a)*0.05, dz=Math.sin(a)*0.05, hh=h*(0.65+0.35*((i*7)%5)/5); tube(out,[{x,y:0,z},{x:x+dx*1.6,y:hh*0.6,z:z+dz*1.6},{x:x+dx*3.2,y:hh,z:z+dz*3.2}],[0.022,0.014,0.004],3,GRASS); } }
function dragonfly(out, cx, cy, cz, t, ph, yaw){ const c=Math.cos(yaw),s=Math.sin(yaw), ax=(dx,dz)=>({x:cx+dx*c+dz*s, y:cy, z:cz-dx*s+dz*c});
const A=ax(-0.22,0), B=ax(0.24,0); tube(out,[A,ax(0,0),B],[0.018,0.03,0.012],4,[58,150,150],true); // slim iridescent body
ellipsoid(out, ax(0.26,0), {x:0.045,y:0.045,z:0.045},5,4,[42,120,120]); // head
const flap=Math.sin(t*22+ph)*0.28; // wing beat
const WING=[196,214,224];
for(const sgn of [-1,1]) for(const fx of [0.10,-0.04]){ const root=ax(fx,sgn*0.03); const tip=ax(fx,sgn*0.34);
C3.prims.sheet(out, root, {x:tip.x-root.x,y:0.02+flap*sgn*0.2,z:tip.z-root.z}, {x:0.10*c,y:0,z:-0.10*s}, 0.02, {x:0,y:1,z:0}, 4,3, WING); }
}
function buildScene(t){
const out=[];
ellipsoid(out, {x:0,y:-0.55,z:-0.2}, {x:5.6,y:0.66,z:4.4}, 22,10, GREEN, (P)=>{ P.y += Math.sin(P.x*1.3)*Math.cos(P.z*1.1)*0.10; return P; }); // the bank, gently undulating
ellipsoid(out, {x:0.3,y:0.02,z:1.5}, {x:2.9,y:0.05,z:2.0}, 26,5, WATER, (P)=>{ const d=Math.hypot(P.x-0.3,P.z-1.5); P.y += Math.sin(d*3-t*1.6)*0.02; return P; }); // water, faint ripples
// shoreline earth rim + a rock
ellipsoid(out, {x:-2.6,y:0.05,z:-0.4}, {x:0.7,y:0.3,z:0.6}, 9,6, ROCK);
ellipsoid(out, {x:2.9,y:0.02,z:0.0}, {x:0.5,y:0.22,z:0.5}, 8,6, EARTH);
// reeds along the far bank + a clump right
reed(out,-1.7,0.0,1.9,0.18,true); reed(out,-1.4,-0.3,1.6,-0.12,false); reed(out,-2.0,0.3,1.7,0.10,true);
reed(out,1.9,0.9,2.0,0.14,true); reed(out,2.2,0.6,1.6,-0.10,false); reed(out,2.05,1.2,1.8,0.16,false);
// lily pads on the water
lily(out,-0.3,1.2,0.55,true); lily(out,0.9,2.0,0.5,false); lily(out,1.4,1.1,0.42,false); lily(out,-0.9,2.2,0.4,false);
// grass tufts around the bank + at the water's edge
grass(out,-3.4,-1.2,7,0.7); grass(out,3.3,-1.4,7,0.65); grass(out,-3.0,1.6,6,0.6); grass(out,2.7,2.4,7,0.7); grass(out,0.2,-2.4,8,0.8); grass(out,-1.9,-1.9,6,0.6);
// water glints -- dusk light catching the ripples (twinkle in and out)
for(let i=0;i<10;i++){ const gx=-1.4+i*0.42, gz=0.9+1.6*Math.abs(Math.sin(i*1.7)), tw=0.5+0.5*Math.sin(t*3+i*2.1); if(tw>0.55) ellipsoid(out,{x:gx,y:0.075,z:gz},{x:0.06*tw,y:0.01,z:0.05*tw},4,3,[210,200,180]); }
// dragonflies skimming the water at dusk
dragonfly(out, 0.2+Math.sin(t*0.5)*1.3, 0.55+0.1*Math.sin(t*2.2), 1.6+Math.cos(t*0.5)*0.7, t, 0, t*0.5+1.6);
dragonfly(out, 1.6+Math.cos(t*0.42)*0.9, 0.7+0.12*Math.sin(t*1.9+1), 0.9+Math.sin(t*0.42)*0.8, t, 2.1, -t*0.42);
// a fish leaps: a low arc near the surface + a little splash ring where it broke through
const ly=0.42+0.18*Math.abs(Math.sin(t*0.9)); // bobs the leap a touch
for(let i=0;i<7;i++){ const a=i/7*6.283, rr=0.16+0.05*Math.sin(t*4+i); ellipsoid(out,{x:1.2+Math.cos(a)*rr,y:0.06,z:1.7+Math.sin(a)*rr},{x:0.05,y:0.02,z:0.05},4,3,WATER2); }
// FIREFLIES at dusk -- a few warm motes drifting over the water
for(let i=0;i<9;i++){ const fx=-1.6+i*0.5+Math.sin(t*0.6+i)*0.25, fy=0.7+0.5*Math.sin(t*0.5+i*1.7), fz=0.6+1.3*Math.abs(Math.sin(t*0.3+i)); ellipsoid(out,{x:fx,y:fy,z:fz},{x:0.035,y:0.035,z:0.035},4,3,[255,238,150]); }
// CREATURES, alive, placed in their element
const frog = place(C3.buildTreeFrog({t,detail:0.82,call:callOf('frog',t)}), -0.3, 0.10, 1.2, 0.42, 2.5); // on the flowered pad -- throat balloons when it croaks
const bird = place(C3.buildBird({t,detail:0.82,call:callOf('bird',t)}), -2.6, 0.35, -0.4, 0.40, -1.1); // perched on the rock -- lifts head + gapes to sing
const fish = place(C3.buildFish({t,detail:0.82,call:callOf('fish',t)}), 1.2, ly, 1.7, 0.52, 0.6, -0.7); // leaping -- flares its fins
return out.concat(frog, bird, fish);
}
function sky(t){ const W=cv.width,H=cv.height; const g=ctx.createLinearGradient(0,0,0,H); g.addColorStop(0,'#20223e'); g.addColorStop(0.45,'#4a3a55'); g.addColorStop(0.72,'#8a5a58'); g.addColorStop(1,'#c07a4e'); ctx.fillStyle=g; ctx.fillRect(0,0,W,H);
const mx=W*0.80, my=H*0.19;
ctx.fillStyle='rgba(255,246,225,0.14)'; ctx.beginPath(); ctx.arc(mx,my,60,0,6.283); ctx.fill(); // moon halo
ctx.fillStyle='rgba(252,244,224,0.95)'; ctx.beginPath(); ctx.arc(mx,my,32,0,6.283); ctx.fill(); // the moon
ctx.fillStyle='#8a5a58'; ctx.beginPath(); ctx.arc(mx-13,my-6,30,0,6.283); ctx.fill(); // a soft gibbous bite (sky-coloured)
for(let i=0;i<46;i++){ const sx=(i*151.3)%W, sy=(i*97.7)%(H*0.46), tw=0.35+0.4*(0.5+0.5*Math.sin(t*1.3+i*1.9)); ctx.fillStyle='rgba(255,255,238,'+tw.toFixed(2)+')'; ctx.fillRect(sx,sy,1.6,1.6); } // first stars, faint twinkle
}
// SOUND: a dusk-marsh soundscape (cricket + reed bed) -- starts on first click (browsers need a gesture).
// The creatures you SEE are the source of what you HEAR: each calls on its own gentle clock, and when the
// sound is on, the visible call fires that creature's real voice at the same instant (seen == heard).
let AC=null, soundOn=false, masterGain=null;
const clocks={ frog:{next:1.6,last:-9,dur:1.15}, bird:{next:3.4,last:-9,dur:1.0}, fish:{next:2.6,last:-9,dur:1.1} };
function fireVoice(name){ if(!soundOn||!AC||!window.CreatureVoice) return; const dest=masterGain||AC.destination, now=AC.currentTime+0.02;
if(name==='frog') CreatureVoice.play(AC,dest,'treefrog',now,{});
else if(name==='bird') CreatureVoice.play(AC,dest,'songbird',now,{variant:['robin','blackbird','chaffinch'][Math.floor(Math.random()*3)]}); }
function callOf(name,t){ const c=clocks[name]; // 0->1->0 envelope on this creature's own rhythm
if(t>=c.next){ c.last=t; const period={frog:3.1,bird:5.4,fish:4.3}[name]; c.next=t+period*(0.75+Math.random()*0.5); fireVoice(name); }
const dt=t-c.last; return (dt>=0&&dt<c.dur)?Math.sin(Math.PI*dt/c.dur):0; }
function startSound(){ if(soundOn) return; soundOn=true; const ac=new (window.AudioContext||window.webkitAudioContext)(); AC=ac; const S=window.CreatureVoice&&CreatureVoice.SCENES&&CreatureVoice.SCENES.marshNight; if(!S) return;
const master=ac.createGain(); master.gain.value=0.7; master.connect(ac.destination); masterGain=master;
S.beds(ac, master, ac.currentTime, {dur:3600}); // continuous cricket + reed texture
setInterval(()=>{ if(ac.state!=='suspended') S.tick(ac, master, ac.currentTime); }, 4200); // UNSEEN frogs in the reeds answer, behind the one you can see
const h=document.getElementById('snd'); if(h){ h.textContent='now click any creature to hear it call'; setTimeout(()=>{ h.style.transition='opacity 1.5s'; h.style.opacity='0'; }, 6500); }
}
let t0=performance.now(), raf=0;
// PLAY: the pond is a world you can POKE, not only watch. Click a creature and it calls on demand -- the frog
// croaks (throat balloons + its voice), the robin sings (head-lift + gape), the reef fish flares its fins.
// (The first click also wakes the ambient dusk sound.) Each creature's world anchor is projected through the
// SAME camera transform as the render, so a click lands on the body you see -- defined BEFORE the loop so the
// first frame's call-flourishes already have them.
const ANCH={ frog:{x:-0.3,y:0.30,z:1.2}, bird:{x:-2.6,y:0.55,z:-0.4}, fish:{x:1.2,y:0.6,z:1.7} };
function project(p,t){ const az=0.55+0.16*Math.sin(t*0.10), el=0.20, W=cv.width,H=cv.height, scale=Math.min(W,H)*0.135, ox=W/2, oy=H*0.66, yc=0.55;
const ca=Math.cos(az),sa=Math.sin(az),ce=Math.cos(el),se=Math.sin(el);
const x=p.x*ca+p.z*sa, z=-p.x*sa+p.z*ca, y=p.y*ce-z*se;
return { X: ox+x*scale, Y: oy-(y-yc)*scale }; }
function pokeAt(cx,cy){ const t=(performance.now()-t0)/1000; let best=null,bd=1e9;
for(const n in ANCH){ const s=project(ANCH[n],t), d=Math.hypot(s.X-cx,s.Y-cy); if(d<bd){bd=d;best=n;} }
if(best && bd<115){ clocks[best].last=t; fireVoice(best); return best; } return null; } // wake this creature's call NOW
// call-FLOURISH: a light 2D over-paint keyed to each creature's live call envelope, so a poke (or an autonomous
// call) visibly REGISTERS even at the pond's wide scale -- ripple rings for the croak, note-heads for the song, a
// sparkle-splash for the fin-flare. The response you can see makes the interaction feel alive.
function drawFlourishes(t){ for(const n in ANCH){ const c=clocks[n], dt=t-c.last; if(dt<0||dt>=c.dur) continue;
const p=dt/c.dur, fade=1-p, s=project(ANCH[n],t); ctx.save();
if(n==='frog'){ for(let r=0;r<3;r++){ const rr=6+p*42+r*15; ctx.strokeStyle='rgba(205,232,240,'+(fade*0.42*(1-r*0.3)).toFixed(3)+')'; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.ellipse(s.X, s.Y+16, rr, rr*0.38, 0,0,6.283); ctx.stroke(); } }
else if(n==='bird'){ for(let k=0;k<3;k++){ const rise=p*36+k*11, mx=s.X+7+k*8, my=s.Y-22-rise, a=fade*(1-k*0.2)*0.9;
ctx.fillStyle='rgba(255,242,186,'+a.toFixed(3)+')'; ctx.beginPath(); ctx.arc(mx,my,2.3,0,6.283); ctx.fill(); ctx.fillRect(mx+1.7,my-7,1.2,7); } } // note-heads rising
else if(n==='fish'){ for(let k=0;k<6;k++){ const ang=k/6*6.283, rad=6+p*20;
ctx.fillStyle='rgba(215,238,248,'+(fade*0.6).toFixed(3)+')'; ctx.beginPath(); ctx.arc(s.X+Math.cos(ang)*rad, s.Y-4+Math.sin(ang)*rad*0.55, 1.7,0,6.283); ctx.fill(); } }
ctx.restore(); } }
function frame(){ const t=(performance.now()-t0)/1000, W=cv.width,H=cv.height;
sky(t);
C3.render(ctx, buildScene(t), { az: 0.55+0.16*Math.sin(t*0.10), el: 0.20, scale: Math.min(W,H)*0.135, ox: W/2, oy: H*0.66, yc: 0.55, shadow:false });
drawFlourishes(t);
raf=requestAnimationFrame(frame); }
frame();
cv.style.cursor='pointer';
cv.addEventListener('click', ev=>{ const r=cv.getBoundingClientRect(), cx=(ev.clientX-r.left)*(cv.width/r.width), cy=(ev.clientY-r.top)*(cv.height/r.height);
startSound(); pokeAt(cx,cy); });
window.__p={ start:startSound, poke:pokeAt, projOf:(n)=>project(ANCH[n],(performance.now()-t0)/1000), faces:()=>buildScene(0).length };
</script>
</body></html>