creatures/render.js
1162 lines · in natura-src-1.0.0.zip as natura/creatures/render.js · a module · its entry in the module reference
// creatures/render.js -- CreatureRender: the DRAWING layer.
//
// Turns a species def (from creatures/species.js) plus a pose (from creatures/behaviour.js, eased by
// the caller) into pixels on a 2D canvas context. It knows nothing about WHICH animals exist or HOW
// they decide to move -- it takes a resolved def and a name (the name only seeds stable per-animal
// texture) and draws. Two body plans, two builders: drawCreature (quadruped) and drawBird (biped).
//
// Level of detail: `det` (0 far silhouette .. 1 full model), derived from on-screen scale or forced
// via pose.lod, gates the fine work (form-shading, markings, fur, feathers, catchlight) so a far
// animal is a cheap flat shape that resolves into fur and an eye as it nears -- the dot->model idea.
//
// The operator's note (2026-08-03): the render itself is not the priority and a better pipeline will
// come one day; these canvas creatures have their own charm. So this module stays deliberately simple
// (immediate-mode 2D, no scene graph), and the value lives in the data + behaviour it renders.
//
// UMD (node test / window.CreatureRender). No dependencies; math helpers are internal.
(function (root, factory) {
if (typeof module !== 'undefined' && module.exports) module.exports = factory();
else root.CreatureRender = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// ---- LIGHT: the scene picks the light source so the same creature reads warm at dawn or COOL under the moon.
// rim = the form-defining highlight over the back; dir = the directional flank warmth. A night scene calls
// setLight('moon') for a cool blue-white rim (moonlight), so nocturnal creatures read without a warm-haze clash.
let LIGHT = { rim:'255,240,206', dir:'255,234,196', night:false }; // a warm low sun (the default)
function setLight(mode){
LIGHT = (mode==='moon') ? { rim:'212,224,244', dir:'190,206,236', night:true } // cool moonlight
: { rim:'255,240,206', dir:'255,234,196', night:false }; // warm sun
}
// ---- internal math / texture helpers -------------------------------------------------------
const clamp = (v,a,b)=>v<a?a:v>b?b:v;
// 2-bone IK: knee/elbow point given hip, foot, segment lengths, and a bend sign
function ik(hx,hy,fx,fy,l1,l2,bend){
let dx=fx-hx, dy=fy-hy, d=Math.hypot(dx,dy);
d=clamp(d, Math.abs(l1-l2)+0.001, l1+l2-0.001);
const base=Math.atan2(dy,dx);
const ca=clamp((l1*l1+d*d-l2*l2)/(2*l1*d),-1,1);
const ang=base+bend*Math.acos(ca);
return [hx+Math.cos(ang)*l1, hy+Math.sin(ang)*l1];
}
// foot position in leg-local coords over the gait cycle (x fore/aft, y lift up = negative)
function foot(phase, off, stride, lift, duty){
let p=(phase+off)%1; if(p<0)p+=1;
if(p<duty){ const s=p/duty; return [stride*0.5 - s*stride, 0]; } // stance: planted, slides back
const s=(p-duty)/(1-duty); return [-stride*0.5 + s*stride, -lift*Math.sin(s*Math.PI)]; // swing: lifts, forward
}
// deterministic seeded RNG (mulberry32): mulberry(seed) returns fr, and fr() yields [0,1)
function mulberry(seed){ let a=seed>>>0; return function(){ a=(a+0x6D2B79F5)|0; let t=Math.imul(a^(a>>>15), 1|a); t=(t+Math.imul(t^(t>>>7), 61|t))^t; return ((t^(t>>>14))>>>0)/4294967296; }; }
// hex colour + alpha -> rgba string, for soft coat markings
function hexa(h,a){ const n=parseInt(h.slice(1),16); return 'rgba('+(n>>16&255)+','+(n>>8&255)+','+(n&255)+','+a+')'; }
// ============================================================================================
// QUADRUPED -- build the animal from proportion def S (name sp seeds stable fur/spots).
// ============================================================================================
// a HEDGEHOG CURLED into a spiny BALL -- head and legs tucked away, the body a sphere, spines radiating outward in
// every direction (skipping the ground contact). The signature defence; drawn instead of the standing form when curled.
function drawCurledBall(ctx, S, gy, pose){
const L=S.L, H=S.H, sp=S.spines, ballR=L*0.42, cy=gy-ballR, ck=pose.clock||0;
ctx.save(); ctx.translate(0,gy+2); ctx.scale(1,0.16); const shR=ballR*0.98; // soft contact shadow
const shG=ctx.createRadialGradient(0,0,0,0,0,shR); shG.addColorStop(0,'rgba(0,0,0,0.34)'); shG.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=shG; ctx.beginPath(); ctx.arc(0,0,shR,0,7); ctx.fill(); ctx.restore();
const gr=ctx.createRadialGradient(-ballR*0.35,cy-ballR*0.35,ballR*0.2, 0,cy,ballR*1.15); // form-shaded body sphere
gr.addColorStop(0,S.col[0]); gr.addColorStop(0.6,S.col[1]); gr.addColorStop(1,S.col[2]);
ctx.fillStyle=gr; ctx.beginPath(); ctx.arc(0,cy,ballR,0,7); ctx.fill();
const hh=(i,o)=>{ const s=Math.sin((i+1)*12.9898+o*78.233)*43758.5; return s-Math.floor(s); };
ctx.lineCap='round';
for(let i=0;i<sp.n;i++){ const a=hh(i,0)*Math.PI*2; if(Math.sin(a)>0.55) continue; // radial spines, skipping the ground sector
const rr=ballR*(0.9+0.08*hh(i,1)), len=sp.len*(0.72+0.5*hh(i,2))*(1+0.04*Math.sin(ck*9+i)), ca=Math.cos(a), sa=Math.sin(a);
const bx=ca*rr, by=cy+sa*rr, tx=ca*(rr+len), ty=cy+sa*(rr+len), mx=(bx+tx)/2, my=(by+ty)/2;
ctx.strokeStyle=hexa(S.col[2],0.92); ctx.lineWidth=Math.max(0.6,H*0.06); ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(mx,my); ctx.stroke();
ctx.strokeStyle=sp.band||'#e8dcc2'; ctx.lineWidth=Math.max(0.5,H*0.045); ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(tx,ty); ctx.stroke(); }
}
// a SQUIRREL sitting UP on its haunches, holding food in its forepaws to its mouth and nibbling -- its own
// upright FORM (like the hedgehog's curled ball), swapped in by pose.siteat. The tail arches up behind for balance.
function drawSquirrelSit(ctx, S, sp, gy, det, pose){
const L=S.L, H=S.H, ck=pose.clock||0, nib=Math.max(0,Math.sin(ck*7))*0.5; // nibble: a small head/food dip
ctx.save(); ctx.translate(0,gy+2); ctx.scale(1,0.16); const shR=L*0.5; // contact shadow
const shG=ctx.createRadialGradient(0,0,0,0,0,shR); shG.addColorStop(0,'rgba(0,0,0,0.32)'); shG.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=shG; ctx.beginPath(); ctx.arc(0,0,shR,0,7); ctx.fill(); ctx.restore();
// ---- TAIL behind the body (drawn first, body overlaps its base): a squirrel's PLUME, or a mouse's thin NAKED tail ----
const perp=(a,i)=>{ const p=a[Math.max(0,i-1)],q=a[Math.min(a.length-1,i+1)],dx=q[0]-p[0],dy=q[1]-p[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
if(S.nakedTail){
// a MOUSE/RAT seated: the long thin naked tail trails DOWN off the haunch and back low along the ground, curling
ctx.strokeStyle=S.tailNaked||S.belly; ctx.lineCap='round';
const q=(t)=>{ const mt=1-t, bx=-L*0.04,by=gy-H*0.25, cx=-L*0.55,cy=gy+H*0.02, ex=-L*0.92,ey=gy-H*0.02;
return [mt*mt*bx+2*mt*t*cx+t*t*ex, mt*mt*by+2*mt*t*cy+t*t*ey]; };
for(let i=0;i<10;i++){ const a=q(i/10), b=q((i+1)/10); ctx.lineWidth=2.1*(1-0.55*i/10);
ctx.beginPath(); ctx.moveTo(a[0],a[1]); ctx.lineTo(b[0],b[1]); ctx.stroke(); }
} else {
const tbx=-L*0.06,tby=gy-H*0.4, tcx=-L*0.06-H*0.5,tcy=gy-H*1.4, ttx=-L*0.02,tty=gy-H*2.5, bw=S.bushy||10;
const seg=[]; for(let i=0;i<=9;i++){ const t=i/9, mt=1-t;
seg.push([mt*mt*tbx+2*mt*t*tcx+t*t*ttx, mt*mt*tby+2*mt*t*tcy+t*t*tty, bw*(0.5+0.8*Math.sin(t*Math.PI))*(1.15-0.45*t)]); }
ctx.fillStyle=S.col[1]; ctx.beginPath();
for(let i=0;i<seg.length;i++){ const [px,py]=perp(seg,i),w=seg[i][2]; const X=seg[i][0]+px*w,Y=seg[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=seg.length-1;i>=0;i--){ const [px,py]=perp(seg,i),w=seg[i][2]; ctx.lineTo(seg[i][0]-px*w,seg[i][1]-py*w); }
ctx.closePath(); ctx.fill();
if(det>0.4){ ctx.strokeStyle=hexa(S.col[2],0.32); ctx.lineWidth=0.7; ctx.lineCap='round';
const tf=mulberry(sp.length*13+7); for(let i=0;i<12;i++){ const t=0.15+tf()*0.8, k=Math.round(t*9), b=seg[k], [px,py]=perp(seg,k);
ctx.beginPath(); ctx.moveTo(b[0],b[1]); ctx.lineTo(b[0]-px*bw*(0.7+tf()*0.7), b[1]-py*bw*(0.7+tf()*0.7)); ctx.stroke(); } }
}
// ---- BODY: a plump seated spindle from the haunch up to the chest (belly bulging forward) ----
const P_seat=[-L*0.03,gy], ctrl=[L*0.10,gy-H*0.85], P_sh=[L*0.05,gy-H*1.62];
const NB=10, bs=[]; for(let i=0;i<=NB;i++){ const t=i/NB, mt=1-t;
bs.push([mt*mt*P_seat[0]+2*mt*t*ctrl[0]+t*t*P_sh[0], mt*mt*P_seat[1]+2*mt*t*ctrl[1]+t*t*P_sh[1], H*(0.72-0.30*t)*(0.92+0.16*Math.sin(t*Math.PI))]); }
const bodyPath=()=>{ ctx.beginPath();
for(let i=0;i<=NB;i++){ const [px,py]=perp(bs,i),w=bs[i][2]; const X=bs[i][0]+px*w,Y=bs[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=NB;i>=0;i--){ const [px,py]=perp(bs,i),w=bs[i][2]; ctx.lineTo(bs[i][0]-px*w,bs[i][1]-py*w); } ctx.closePath(); };
const bg=ctx.createLinearGradient(P_seat[0]-H*0.7,0,P_seat[0]+H*0.7,0); // back(-x) dark -> belly(+x) light
bg.addColorStop(0,S.col[2]); bg.addColorStop(0.5,S.col[1]); bg.addColorStop(1,S.col[0]);
bodyPath(); ctx.fillStyle=bg; ctx.fill();
ctx.save(); bodyPath(); ctx.clip();
const cg=ctx.createLinearGradient(0,gy,0,gy-H*1.3); // pale belly, forward+low
cg.addColorStop(0,hexa(S.belly||'#efe7d6',0.85)); cg.addColorStop(1,hexa(S.belly||'#efe7d6',0));
ctx.fillStyle=cg; ctx.fillRect(P_seat[0]-H*0.1,gy-H*1.6,H*1.5,H*1.7);
if(det>0.5){ const ff=mulberry(sp.length*29+3), fn=S.fur?S.fur.n:80; ctx.strokeStyle=hexa(S.col[2],0.26); ctx.lineWidth=0.6; ctx.lineCap='round';
for(let i=0;i<fn;i++){ const t=ff(), k=Math.min(NB,Math.round(t*NB)), c=bs[k], [px,py]=perp(bs,k), off=(ff()*2-1)*c[2];
const bx=c[0]+px*off, by=c[1]+py*off, ln=(S.fur?S.fur.len:3)*(0.6+0.5*ff());
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx-(off<0?-0.3:0.3), by+ln); ctx.stroke(); } }
ctx.restore();
if(det>0.5){ ctx.save(); bodyPath(); ctx.clip(); ctx.strokeStyle=hexa('#ffffff',0.15); ctx.lineWidth=2.2; // back rim light
ctx.beginPath(); for(let i=0;i<=NB;i++){ const [px,py]=perp(bs,i),w=bs[i][2]; const X=bs[i][0]-px*w,Y=bs[i][1]-py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y);} ctx.stroke(); ctx.restore(); }
// ---- a folded HIND FOOT forward at the base ----
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.ellipse(L*0.11,gy-2,H*0.28,H*0.12,0,0,7); ctx.fill();
// ---- HEAD atop the chest, tipped toward the paws ----
const hx=L*0.10, hy=gy-H*1.78+nib*H*0.06, hr=S.head*0.95;
if(S.earRound){ ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.arc(hx-hr*0.12,hy-hr*1.12,hr*0.74,0,7); ctx.fill(); // a big ROUND ear (mouse)
if(S.earInner){ ctx.fillStyle=hexa(S.earInner,0.85); ctx.beginPath(); ctx.arc(hx-hr*0.10,hy-hr*1.05,hr*0.46,0,7); ctx.fill(); } }
else { ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.moveTo(hx-hr*0.5,hy-hr*0.7); ctx.lineTo(hx-hr*0.1,hy-hr*1.7); ctx.lineTo(hx+hr*0.35,hy-hr*0.8); ctx.closePath(); ctx.fill(); // pointed ear (squirrel)
if(S.earInner){ ctx.fillStyle=hexa(S.earInner,0.8); ctx.beginPath(); ctx.moveTo(hx-hr*0.3,hy-hr*0.85); ctx.lineTo(hx-hr*0.05,hy-hr*1.45); ctx.lineTo(hx+hr*0.2,hy-hr*0.9); ctx.closePath(); ctx.fill(); } }
if(S.earTip && det>0.4){ ctx.strokeStyle=S.earTip; ctx.lineWidth=1.4; ctx.lineCap='round'; ctx.beginPath(); ctx.moveTo(hx-hr*0.1,hy-hr*1.66); ctx.lineTo(hx-hr*0.12,hy-hr*2.06); ctx.stroke(); } // ear tuft
const hg=ctx.createRadialGradient(hx-hr*0.3,hy-hr*0.3,hr*0.2,hx,hy,hr*1.2); hg.addColorStop(0,S.col[0]); hg.addColorStop(1,S.col[1]);
ctx.fillStyle=hg; ctx.beginPath(); ctx.arc(hx,hy,hr,0,7); ctx.fill();
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.ellipse(hx+hr*0.7,hy+hr*0.5,hr*0.62,hr*0.42,0.5,0,7); ctx.fill(); // muzzle toward the food
if(S.nose){ ctx.fillStyle=S.nose; ctx.beginPath(); ctx.arc(hx+hr*1.12,hy+hr*0.78,hr*0.16,0,7); ctx.fill(); }
const ex=hx+hr*0.42, ey=hy-hr*0.12, er=hr*0.34; // big dark eye + catchlight
ctx.fillStyle='#160d08'; ctx.beginPath(); ctx.arc(ex,ey,er,0,7); ctx.fill();
if(det>0.4){ ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(ex+er*0.3,ey-er*0.3,er*0.3,0,7); ctx.fill(); }
if(det>0.55){ ctx.strokeStyle=hexa('#efe7d6',0.5); ctx.lineWidth=0.6; ctx.lineCap='round'; // whiskers forward
for(let i=0;i<4;i++){ const a=-0.2+i*0.18; ctx.beginPath(); ctx.moveTo(hx+hr*1.0,hy+hr*0.5);
ctx.quadraticCurveTo(hx+hr*1.9,hy+hr*0.5+a*hr*3, hx+hr*2.6,hy+hr*0.6+a*hr*5); ctx.stroke(); } }
// ---- FORELIMBS holding the FOOD up to the mouth ----
const paw=[hx+hr*1.15, hy+hr*1.6+nib*H*0.05];
ctx.strokeStyle=S.col[2]; ctx.lineWidth=Math.max(1.6,H*0.11); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(P_sh[0]+H*0.02,P_sh[1]+H*0.3); ctx.quadraticCurveTo(hx+hr*1.3,hy+hr*1.4, paw[0]-1,paw[1]+1); ctx.stroke(); // far foreleg
ctx.strokeStyle=S.col[1]; ctx.lineWidth=Math.max(2,H*0.14);
ctx.beginPath(); ctx.moveTo(P_sh[0]+H*0.16,P_sh[1]+H*0.22); ctx.quadraticCurveTo(hx+hr*1.55,hy+hr*1.2, paw[0],paw[1]); ctx.stroke(); // near foreleg
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.arc(paw[0],paw[1],H*0.13,0,7); ctx.fill();
if(det>0.3){ if(S.nakedTail){ ctx.fillStyle='#e6d8b0'; ctx.beginPath(); ctx.ellipse(paw[0]+H*0.04,paw[1]-H*0.04,H*0.10,H*0.15,0.4,0,7); ctx.fill(); } // a pale SEED (mouse)
else { ctx.fillStyle='#8a6a3a'; ctx.beginPath(); ctx.ellipse(paw[0]+H*0.05,paw[1]-H*0.05,H*0.13,H*0.18,0.3,0,7); ctx.fill(); // an acorn (squirrel)
ctx.fillStyle='#5a4326'; ctx.beginPath(); ctx.ellipse(paw[0]+H*0.05,paw[1]-H*0.2,H*0.11,H*0.07,0.3,0,7); ctx.fill(); } }
}
// the UPRIGHT-PEER form -- a creature reared onto its hind feet to look around: the otter's periscope AND
// the meerkat's sentinel stand are the same posture, so ONE form serves both, keyed on species params (the
// tail thickness S.tailW, the snout length S.snout -> a broad otter muzzle vs a pointed meerkat one, and an
// optional dark S.eyeMask). Tail a tripod-prop behind, forepaws clasped at the chest, head up and scanning.
// Swapped in by pose.siteat for any species flagged periscope.
function drawUprightPeer(ctx, S, sp, gy, det, pose){
const L=S.L, H=S.H, ck=pose.clock||0, look=Math.sin(ck*0.8)*0.14;
const perp=(a,i)=>{ const p=a[Math.max(0,i-1)],q=a[Math.min(a.length-1,i+1)],dx=q[0]-p[0],dy=q[1]-p[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
ctx.save(); ctx.translate(0,gy+2); ctx.scale(1,0.16); const shR=L*0.32;
const shG=ctx.createRadialGradient(0,0,0,0,0,shR); shG.addColorStop(0,'rgba(0,0,0,0.34)'); shG.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=shG; ctx.beginPath(); ctx.arc(0,0,shR,0,7); ctx.fill(); ctx.restore();
// TAIL: a tapering prop from the low rump to the floor (the tripod's third leg) -- SCALED by the creature's real
// tail, so a long-tailed otter/meerkat braces a full prop while a stub-tailed BEAR balances on its bulk alone
const tp=Math.min(1,(S.tail||0)/24); // 0 = stub (bear) .. 1 = full prop (otter/meerkat)
const tb=[-L*0.03,gy-H*0.55], tc=[-L*(0.08+0.26*tp),gy-H*0.2], tt=[-L*(0.14+0.42*tp),gy-1], tw0=H*0.6*(S.tailW||1.0)*0.6*(0.35+0.65*tp);
const ts=[]; for(let i=0;i<=8;i++){ const t=i/8, mt=1-t; ts.push([mt*mt*tb[0]+2*mt*t*tc[0]+t*t*tt[0], mt*mt*tb[1]+2*mt*t*tc[1]+t*t*tt[1], tw0*(1-0.8*t)]); }
ctx.fillStyle=S.col[1]; ctx.beginPath();
for(let i=0;i<ts.length;i++){ const [px,py]=perp(ts,i),w=ts[i][2],X=ts[i][0]+px*w,Y=ts[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=ts.length-1;i>=0;i--){ const [px,py]=perp(ts,i),w=ts[i][2]; ctx.lineTo(ts[i][0]-px*w,ts[i][1]-py*w); } ctx.closePath(); ctx.fill();
// BODY: a tall upright chunky spindle (seat -> chest)
const P_seat=[0,gy], ctrl=[L*0.07,gy-L*0.33], P_sh=[-L*0.01,gy-L*0.64];
const NB=10, bs=[]; for(let i=0;i<=NB;i++){ const t=i/NB, mt=1-t;
bs.push([mt*mt*P_seat[0]+2*mt*t*ctrl[0]+t*t*P_sh[0], mt*mt*P_seat[1]+2*mt*t*ctrl[1]+t*t*P_sh[1], H*(0.92-0.34*t)*(0.9+0.18*Math.sin(t*Math.PI))]); }
const bodyPath=()=>{ ctx.beginPath();
for(let i=0;i<=NB;i++){ const [px,py]=perp(bs,i),w=bs[i][2]; const X=bs[i][0]+px*w,Y=bs[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=NB;i>=0;i--){ const [px,py]=perp(bs,i),w=bs[i][2]; ctx.lineTo(bs[i][0]-px*w,bs[i][1]-py*w); } ctx.closePath(); };
const bg=ctx.createLinearGradient(-H,0,H,0); bg.addColorStop(0,S.col[2]); bg.addColorStop(0.5,S.col[1]); bg.addColorStop(1,S.col[0]);
bodyPath(); ctx.fillStyle=bg; ctx.fill();
ctx.save(); bodyPath(); ctx.clip();
const cg=ctx.createLinearGradient(0,gy,0,gy-L*0.6); cg.addColorStop(0,hexa(S.belly||'#c2b399',0.92)); cg.addColorStop(1,hexa(S.belly||'#c2b399',0)); // pale throat down the front
ctx.fillStyle=cg; ctx.fillRect(P_sh[0]-H*0.4,gy-L*0.62,H*0.95,L*0.62);
if(det>0.5){ const ff=mulberry(sp.length*29+5), fn=S.fur?S.fur.n:80; ctx.strokeStyle=hexa(S.col[2],0.2); ctx.lineWidth=0.6; ctx.lineCap='round';
for(let i=0;i<fn;i++){ const t=ff(), k=Math.min(NB,Math.round(t*NB)), c=bs[k], [px,py]=perp(bs,k), off=(ff()*2-1)*c[2];
ctx.beginPath(); ctx.moveTo(c[0]+px*off,c[1]+py*off); ctx.lineTo(c[0]+px*off-(off<0?-0.3:0.3),c[1]+py*off+(S.fur?S.fur.len:3)*(0.5+0.4*ff())); ctx.stroke(); } }
ctx.restore();
// HIND FEET planted at the base
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.ellipse(L*0.07,gy-1,H*0.32,H*0.13,0,0,7); ctx.fill();
ctx.beginPath(); ctx.ellipse(-L*0.06,gy-1,H*0.26,H*0.11,0,0,7); ctx.fill();
// HEAD up top, slowly scanning
const hx=P_sh[0]+H*0.1+look*H*1.4, hy=gy-L*0.64-S.head*0.9, hr=S.head*1.0;
ctx.fillStyle=S.col[1]; for(const s of [-0.55,0.55]){ ctx.beginPath(); ctx.arc(hx+s*hr*0.85, hy-hr*0.78, hr*0.32,0,7); ctx.fill(); } // small round ears
if(S.earInner){ ctx.fillStyle=hexa(S.earInner,0.85); for(const s of [-0.55,0.55]){ ctx.beginPath(); ctx.arc(hx+s*hr*0.85, hy-hr*0.74, hr*0.17,0,7); ctx.fill(); } }
const hg=ctx.createRadialGradient(hx-hr*0.3,hy-hr*0.3,hr*0.2,hx,hy,hr*1.2); hg.addColorStop(0,S.col[0]); hg.addColorStop(1,S.col[1]);
ctx.fillStyle=hg; ctx.beginPath(); ctx.arc(hx,hy,hr,0,7); ctx.fill();
const ms=(S.snout||7)/(S.head||9); // snout:head ratio -> a broad otter muzzle (~0.8) vs a long pointed meerkat one (~1.4)
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.ellipse(hx+hr*(0.35+ms*0.4),hy+hr*0.3,hr*(0.35+ms*0.35),hr*(0.56-ms*0.16),0,0,7); ctx.fill(); // muzzle: longer + narrower as the snout lengthens
if(S.belly){ ctx.fillStyle=hexa(S.belly,0.55); ctx.beginPath(); ctx.ellipse(hx+hr*0.18,hy+hr*0.55,hr*0.5,hr*0.32,0,0,7); ctx.fill(); } // pale chin/throat
if(S.nose){ ctx.fillStyle=S.nose; ctx.beginPath(); ctx.ellipse(hx+hr*(0.35+ms*0.82),hy+hr*0.24,hr*0.22,hr*0.16,0,0,7); ctx.fill(); } // nose at the snout tip
const ex=hx+hr*0.42, ey=hy-hr*0.2, er=hr*0.3;
if(S.eyeMask){ ctx.fillStyle=hexa(S.eyeMask,0.9); ctx.beginPath(); ctx.ellipse(ex+hr*0.05,ey,er*2.0,er*1.55,0.15,0,7); ctx.fill(); } // the meerkat's dark eye-mask (behind the eye)
ctx.fillStyle='#160d08'; ctx.beginPath(); ctx.arc(ex,ey,er,0,7); ctx.fill();
if(det>0.4){ ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(ex+er*0.3,ey-er*0.3,er*0.3,0,7); ctx.fill(); }
if(det>0.5 && S.whiskers){ ctx.strokeStyle=hexa('#efe7d6',0.5); ctx.lineWidth=0.6; ctx.lineCap='round';
for(let i=0;i<4;i++){ const a=-0.2+i*0.15; ctx.beginPath(); ctx.moveTo(hx+hr*0.95,hy+hr*0.38);
ctx.quadraticCurveTo(hx+hr*1.8,hy+hr*0.4+a*hr*3, hx+hr*2.4,hy+hr*0.5+a*hr*5); ctx.stroke(); } }
// FOREPAWS: two short forelegs hanging DOWN the chest to a pair of paws clasped together at the belly
// (the otter's signature "hands-together" periscope look), a touch forward of the body centreline
const paw=[P_sh[0]+H*0.16, gy-L*0.34];
ctx.strokeStyle=S.col[2]; ctx.lineWidth=Math.max(1.8,H*0.11); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(P_sh[0]-H*0.02,gy-L*0.56); ctx.quadraticCurveTo(P_sh[0]+H*0.06,gy-L*0.44, paw[0]-H*0.1,paw[1]); ctx.stroke(); // far foreleg
ctx.strokeStyle=S.col[1]; ctx.lineWidth=Math.max(2,H*0.14);
ctx.beginPath(); ctx.moveTo(P_sh[0]+H*0.16,gy-L*0.55); ctx.quadraticCurveTo(P_sh[0]+H*0.2,gy-L*0.43, paw[0]+H*0.04,paw[1]); ctx.stroke(); // near foreleg
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.arc(paw[0]-H*0.1,paw[1],H*0.12,0,7); ctx.fill(); // the two clasped paws
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.arc(paw[0]+H*0.06,paw[1],H*0.13,0,7); ctx.fill();
}
function drawCreature(ctx, S, sp, x, y, dir, phase, scale, pose){
pose = pose || {}; // {neck,ear,stand(0..1),...} posture from behaviour
ctx.save(); ctx.translate(x,y); ctx.scale(dir*scale, scale);
const det = clamp(pose.lod!=null ? pose.lod : (scale-0.26)/0.52, 0, 1); // level of detail: 0 far silhouette .. 1 full model
const L=S.L, H=S.H, leg=S.leg, l1=leg*S.thigh, l2=leg-l1, gy=H*0.5+leg; // ground line (feet)
const rest=clamp(pose.rest||0,0,1); // 0 standing .. 1 lying down / bedded (asleep at night) -- body sinks, legs fold, head lowers
if((pose.curl||0)>0.5 && S.spines){ drawCurledBall(ctx, S, gy, pose); ctx.restore(); return; } // fully curled -> the spiny ball instead of the standing form
if((pose.siteat||0)>0.5 && (S.archTail||S.sitNibble)){ drawSquirrelSit(ctx, S, sp, gy, det, pose); ctx.restore(); return; } // sitting up to eat -> the upright sit-nibble form (squirrel plume / mouse naked tail)
if((pose.siteat||0)>0.5 && S.periscope){ drawUprightPeer(ctx, S, sp, gy, det, pose); ctx.restore(); return; } // rearing up to peer -> the shared upright form (otter periscope / meerkat sentinel)
// hips along the underside of the body
const bh=H*0.30, backHip=[-L*0.40, bh], frontHip=[L*0.42, bh]; // hips carried UP in the haunch/shoulder (not at the belly's bottom edge), so the legs pivot high on the body
const gt = pose.gait || S.gait, ph=phase; // a behaviour can swap the gait (e.g. bolt -> gallop)
// per-leg phase offsets: walk is a diagonal 4-beat; hop/gallop bring the pairs together with a suspension
const O = gt.gallop ? {bf:0.08, ff:0.46, bn:0.0, fn:0.40} // hinds land together, then fores, then all airborne
: gt.trot ? {bf:0.0, ff:0.5, bn:0.5, fn:0.0} // TROT: diagonal pairs (fore-near+hind-far, then fore-far+hind-near) move together, a 2-beat with a light float
: gt.hop ? {bf:0.5, ff:0.5, bn:0.0, fn:0.0}
: {bf:0.25, ff:0.75, bn:0.0, fn:0.5};
// soft contact shadow, grounds the animal
ctx.save(); ctx.translate(0, gy+2); const shR=L*(0.55+rest*0.32); ctx.scale(1, 5.2/shR); // a SOFT contact shadow (radial fade); WIDER when the animal lies down (more of it on the ground)
const shG=ctx.createRadialGradient(0,0,0, 0,0,shR);
shG.addColorStop(0,'rgba(0,0,0,0.28)'); shG.addColorStop(0.55,'rgba(0,0,0,0.13)'); shG.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=shG; ctx.beginPath(); ctx.arc(0,0,shR,0,7); ctx.fill(); ctx.restore();
if(rest>0) ctx.translate(0, rest*leg*0.86); // the body SINKS toward the ground as it lies down (the shadow above stays put)
// leg render: a real 2-segment limb, tapered, knee bending the anatomically-natural way
// (front leg 'elbow' folds toward the REAR, hind 'stifle' toward the FRONT), near pair bright, far pair dim
function limb(a,b,wa,wb){ // a filled TAPERED segment (real leg, not a stick)
const dx=b[0]-a[0],dy=b[1]-a[1],len=Math.hypot(dx,dy)||1,nx=-dy/len,ny=dx/len;
ctx.beginPath(); ctx.moveTo(a[0]+nx*wa,a[1]+ny*wa); ctx.lineTo(b[0]+nx*wb,b[1]+ny*wb);
ctx.lineTo(b[0]-nx*wb,b[1]-ny*wb); ctx.lineTo(a[0]-nx*wa,a[1]-ny*wa); ctx.closePath(); ctx.fill();
}
function drawLeg(hip, off, far, front){
const st=pose.stand||0; // 0 walking .. 1 planted stance
const [fx,fy]=foot(ph, off, gt.stride, gt.lift, gt.duty);
const gaitX=hip[0]+fx, gaitY=gy+fy;
const standX=hip[0]+(front?leg*0.06:-leg*0.06), standY=gy; // planted: front feet a touch fwd, hind back
let footX=gaitX+(standX-gaitX)*st, footY=gaitY+(standY-gaitY)*st;
if(rest>0){ const rfx=hip[0]+(front?leg*0.12:-leg*0.12), rfy=hip[1]+leg*0.15; // RESTING: the leg folds UP under the body, foot tucked near the hip -- a couched limb, not an extended one
footX=footX+(rfx-footX)*rest; footY=footY+(rfy-footY)*rest; }
const w = far?0.82:1; // far legs a touch thinner
ctx.fillStyle = far ? S.col[2] : S.col[1];
const sock = (S.sock && !far) ? S.sock : null; // dark "stockings" on the lower leg (fox)
const drop=footY-hip[1]; // hip-to-foot span (larger now that the hip sits high)
if(S.columnar){
// GRAVIPORTAL pillar leg: an elephant carries its mass on near-straight columns, not a sprung zigzag.
// A thick, barely-tapering post from the high hip to a broad foot, with only a soft knee break -- the
// single most iconic elephant silhouette feature. Both fore and hind read as pillars.
const knee=[hip[0]+(front?leg*0.03:-leg*0.03), hip[1]+drop*0.50];
const ankle=[footX+(front?-leg*0.015:leg*0.015), hip[1]+drop*0.85];
limb(hip, knee, 8.4*w, 7.2*w); // upper column (heavy, near-vertical)
limb(knee, ankle, 7.2*w, 6.1*w); // lower column
limb(ankle,[footX,footY], 6.1*w, 5.4*w); // down to the broad foot -- still thick
ctx.beginPath(); ctx.arc(knee[0],knee[1],6.8*w,0,7); ctx.fill(); // soft joint rounding
ctx.beginPath(); ctx.arc(ankle[0],ankle[1],5.7*w,0,7); ctx.fill();
} else if(front){
// FRONT leg: humerus down-and-BACK to a high elbow, then forearm + cannon fairly STRAIGHT down to the hoof
// -- a columnar front leg (the wrist barely breaks the line), clearly DIFFERENT from the hind's zigzag.
const elbow=[hip[0]-leg*0.10, hip[1]+drop*0.34], carpus=[footX+leg*0.02, hip[1]+drop*0.72];
limb(hip, elbow, 4.8*w, 3.1*w); // humerus
limb(elbow, carpus, 3.1*w, 1.9*w); // forearm (radius/ulna)
if(sock) ctx.fillStyle=sock; limb(carpus, [footX,footY], 1.9*w, 1.3*w); // cannon (metacarpus), socked
ctx.beginPath(); ctx.arc(elbow[0],elbow[1],2.4*w,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(carpus[0],carpus[1],1.6*w,0,7); ctx.fill();
} else {
// HIND leg: femur down-and-FORWARD to a HIGH stifle (up near the belly), gaskin down-and-BACK to the hock
// (carried well up the leg), then the cannon ~vertical to the hoof -- the muscled quadruped zigzag.
const stifle=[hip[0]+leg*0.15, hip[1]+drop*0.25], hock=[footX-leg*0.02, hip[1]+drop*0.46]; // stifle HIGH near the belly, hock in the UPPER-mid of the leg -- both joints carried high, a long slender cannon below (as the operator asked)
limb(hip, stifle, 5.3*w, 3.4*w); // femur, heavily muscled thigh
limb(stifle, hock, 3.4*w, 2.0*w); // gaskin (tibia)
if(sock) ctx.fillStyle=sock; limb(hock, [footX,footY], 2.0*w, 1.3*w); // cannon (metatarsus), socked
ctx.beginPath(); ctx.arc(stifle[0],stifle[1],2.7*w,0,7); ctx.fill();
ctx.beginPath(); ctx.arc(hock[0],hock[1],1.9*w,0,7); ctx.fill();
}
// FOOT keyed to the animal: a cloven HOOF (deer/boar), a padded PAW (canid/feline/hare), or the
// bear's long flat clawed foot. The fine shape resolves only up close; a far silhouette is a dark blob.
const ftype = S.columnar ? 'column' : S.hoof ? 'hoof' : S.earRound ? 'flat' : 'paw';
ctx.fillStyle = S.col[2];
if(det < 0.5){
ctx.beginPath(); ctx.ellipse(footX,footY,2.6*w,2.0*w,0,0,7); ctx.fill(); // far: a simple dark foot
} else if(ftype==='hoof'){ // cloven: two dark toes, front one lower + forward
ctx.beginPath(); ctx.ellipse(footX+1.0*w, footY+0.3, 1.7*w, 2.3*w, 0.14, 0,7); ctx.fill();
ctx.beginPath(); ctx.ellipse(footX-1.1*w, footY-0.1, 1.5*w, 2.0*w, -0.06, 0,7); ctx.fill();
} else if(ftype==='flat'){ // bear: a long plantigrade foot + 3 claws at the front
ctx.beginPath(); ctx.ellipse(footX+1.0*w, footY-0.3, 4.0*w, 2.0*w, 0, 0,7); ctx.fill();
ctx.lineWidth=0.9*w; ctx.strokeStyle=S.col[2]; ctx.lineCap='round';
for(let k=-1;k<=1;k++){ const cx2=footX+4.0*w+k*1.2*w; ctx.beginPath(); ctx.moveTo(cx2,footY-0.8); ctx.lineTo(cx2+0.9*w,footY+1.4); ctx.stroke(); }
} else if(ftype==='column'){ // elephant: a broad rounded foot pad, wider than tall, with pale toenail crescents at the front
ctx.beginPath(); ctx.ellipse(footX, footY-1.4*w, 6.0*w, 3.2*w, 0, 0,7); ctx.fill();
if(det>0.5){ ctx.fillStyle='#d8ccb6';
for(let k=0;k<3;k++){ ctx.beginPath(); ctx.ellipse(footX+(1.8+k*1.9)*w, footY-1.6*w, 1.0*w, 1.5*w, 0, 0,7); ctx.fill(); } }
} else { // paw: a rounded pad with 3 toe bumps at the front
ctx.beginPath(); ctx.ellipse(footX, footY, 2.7*w, 1.9*w, 0, 0,7); ctx.fill();
for(let k=0;k<3;k++){ ctx.beginPath(); ctx.arc(footX+(0.8+k*0.95)*w, footY-0.2, 1.0*w, 0,7); ctx.fill(); }
// CLAWS: a small dark keratin hook at the front of each toe -- the dot->model detail on the foot (LOD-gated).
// A canid's are longer + non-retractable; a cat's shorter (semi-sheathed). Bear already has its own (flat foot).
if(det>0.55 && (S.canid||S.feline)){ ctx.fillStyle='#231710'; const clen=(S.feline?1.0:1.7)*w;
for(let k=0;k<3;k++){ const tx=footX+(0.9+k*0.95)*w, ty=footY+0.9*w;
ctx.beginPath(); ctx.moveTo(tx-0.45*w, ty-0.3*w); ctx.quadraticCurveTo(tx+0.7*w, ty+clen*0.3, tx+0.15*w, ty+clen);
ctx.quadraticCurveTo(tx-0.2*w, ty+clen*0.4, tx-0.45*w, ty-0.3*w); ctx.closePath(); ctx.fill(); } }
}
}
// FAR legs first (behind the body), offset from their near partners
drawLeg(backHip, O.bf, true, false); drawLeg(frontHip, O.ff, true, true);
// BODY: a smooth outline from rear haunch, over the back (with a slight shoulder hump), to the neck base
const bob = (1-(pose.stand||0)) * (1-rest) * Math.sin(ph*2*Math.PI* ((gt.hop||gt.gallop)?1:2))* (gt.gallop?9:gt.hop?6:gt.trot?4:2) // bob with the gait; a big loft at the gallop, a light float at the trot, stills when planted or bedded
+ (pose.dig||0)*Math.sin((pose.clock||0)*16)*H*0.05; // a digging badger HEAVES -- fast forequarter effort even while planted
ctx.translate(0,bob);
ctx.beginPath();
const topY=-H*0.5, botY=H*0.5;
// back-line heights: a foreHump beast (bison) slopes from a LOW rear haunch up to a TOWERING shoulder; else a
// uniform hump. Shared by the fill + fur-clip + fur-root beziers so the outline, its shading and its fur stay in step.
const bkH=topY*(1+(S.foreHump?S.hump*0.28:S.hump)), bkM=topY*(1+(S.foreHump?S.hump*0.98:S.hump*0.6)),
bk0=topY*(1+(S.foreHump?S.hump*1.55:S.hump)), bkS=topY*(1+(S.foreHump?S.hump*1.82:S.hump*1.2));
ctx.moveTo(-L*0.5, botY*0.4);
ctx.bezierCurveTo(-L*0.58, topY*0.6, -L*0.42, bkH, -L*0.2, bkM); // haunch up to back
ctx.bezierCurveTo(0, bk0, L*0.34, bkS, L*0.5, topY*0.7); // shoulder hump to neck base
ctx.bezierCurveTo(L*0.56, topY*0.2, L*0.55, botY*0.5, L*0.44, botY); // down the chest
ctx.bezierCurveTo(L*0.15, botY*1.05, -L*0.2, botY*1.05, -L*0.42, botY*0.9); // belly
ctx.bezierCurveTo(-L*0.52, botY*0.7, -L*0.54, botY*0.6, -L*0.5, botY*0.4); // back to haunch
ctx.closePath();
const grd=ctx.createLinearGradient(0,topY*1.2,0,botY*1.1);
grd.addColorStop(0,S.col[0]); grd.addColorStop(0.6,S.col[0]); grd.addColorStop(1,S.belly);
ctx.fillStyle=grd; ctx.fill();
ctx.save(); ctx.clip(); // form-shading + fur, all clipped to the body
if(det>0.28){ // fine form-shading resolves in only as you push in
const hi=ctx.createLinearGradient(0,topY*1.4,0,topY*0.1); // the light source down the back (warm sun / cool moon)
hi.addColorStop(0,'rgba('+LIGHT.rim+',0.34)'); hi.addColorStop(1,'rgba('+LIGHT.rim+',0)');
ctx.fillStyle=hi; ctx.fillRect(-L,topY*1.6,L*2,H*1.4);
const dl=ctx.createLinearGradient(0,topY*1.6,0,topY*0.2); // COUNTERSHADING: darker along the spine (animals are dark on top)
dl.addColorStop(0, S.dark||'rgba(40,26,14,0.34)'); dl.addColorStop(1,'rgba(40,26,14,0)');
ctx.fillStyle=dl; ctx.fillRect(-L,topY*1.6,L*2,H*0.75);
const sh=ctx.createLinearGradient(0,botY*0.1,0,botY*1.2); // belly shadow
sh.addColorStop(0,'rgba(20,12,6,0)'); sh.addColorStop(1,'rgba(20,12,6,0.24)');
ctx.fillStyle=sh; ctx.fillRect(-L,botY*0.1,L*2,H*1.4);
// DIRECTIONAL low-sun light: the sunward (world-left) flank warmly lit, the far flank cooler -- gives the body form
const sunL=-dir, wg=ctx.createLinearGradient(sunL*L,0, -sunL*L,0);
wg.addColorStop(0,'rgba('+LIGHT.dir+',0.16)'); wg.addColorStop(0.48,'rgba('+LIGHT.dir+',0)');
wg.addColorStop(0.54,'rgba(28,32,44,0)'); wg.addColorStop(1,'rgba(28,32,44,0.15)');
ctx.fillStyle=wg; ctx.fillRect(-L,topY*1.6,L*2,H*1.7);
}
// COAT MARKINGS: soft species patches (pale rump, dark saddle, agouti dorsal), under the fur
if(det>0.42 && S.patches) for(const p of S.patches){
const px=p.x*L, py=p.y*H, pr=Math.max(p.rx*L, p.ry*H)||1;
const rg=ctx.createRadialGradient(px,py,0, px,py,pr);
rg.addColorStop(0,hexa(p.c,p.a)); rg.addColorStop(0.62,hexa(p.c,p.a*0.5)); rg.addColorStop(1,hexa(p.c,0));
ctx.fillStyle=rg; ctx.save(); ctx.translate(px,py); ctx.scale(p.rx*L/pr, p.ry*H/pr);
ctx.beginPath(); ctx.arc(0,0,pr,0,7); ctx.fill(); ctx.restore();
}
// FAWN DAPPLE: rows of soft pale spots along the upper flank (juvenile coat), under the fur
if(det>0.4 && S.spots){ const S2=S.spots, fr2=mulberry(sp.length*131+17); ctx.fillStyle=hexa(S2.c, S2.a);
for(let r=0;r<S2.rows;r++){ const ry=topY*(0.82-r*0.42);
for(let cc=0;cc<S2.cols;cc++){ const rx=-L*0.32 + cc*(L*0.64/(S2.cols-1)) + (r&1?L*0.05:0) + (fr2()-0.5)*3.5;
ctx.beginPath(); ctx.ellipse(rx, ry+(fr2()-0.5)*3, S2.r, S2.r*0.78, 0,0,7); ctx.fill(); } } }
// PIGLET STRIPES: pale longitudinal bands running head-to-tail along the flank (the juvenile wild-boar coat), under the fur
if(det>0.36 && S.stripes){ const S5=S.stripes; ctx.fillStyle=hexa(S5.c, S5.a);
for(let r=0;r<S5.n;r++){ const sy=topY*0.5 + r*(H*0.2);
ctx.beginPath(); ctx.ellipse(-L*0.03, sy, L*0.42, Math.max(1.0,H*0.05), 0, 0,7); ctx.fill(); } }
// FUR: short directional strokes flowing back-and-down over the coat, light + dark for real texture (stable seed)
// per-species coat character: F.n density, F.len length, F.w stroke weight (a wolf shaggy+long, a deer sleek+short, a boar coarse)
const F=S.fur||{n:130,len:5,w:0.8};
const fr=mulberry(sp.length*911 + sp.charCodeAt(0)*77 + sp.charCodeAt(sp.length-1)*29 + 5);
ctx.lineCap='round';
for(let i=0,nFur=Math.round((F.n||130)*det*det);i<nFur;i++){ // fur thins with distance, gone at far zoom
const fx=-L*0.56+fr()*L*1.14, fy=topY*1.25+fr()*(H*1.55), len=F.len*0.5+fr()*F.len, down=(fy>0?0.4:-0.1);
ctx.strokeStyle = fr()<0.5 ? 'rgba(255,250,238,'+(0.04+fr()*0.06).toFixed(3)+')' : 'rgba(26,17,8,'+(0.05+fr()*0.07).toFixed(3)+')';
ctx.lineWidth=F.w||0.8; ctx.beginPath(); ctx.moveTo(fx,fy); ctx.lineTo(fx-len*0.9, fy+down*len+(fr()-0.5)*1.4); ctx.stroke();
}
// RIM LIGHT: skylight catches the very topline of the coat -- a thin bright edge, still clipped to the body so it
// HUGS the back contour and lifts the animal off itself (the crisp form cue the soft countershading can't give).
// A horizontal gradient fades it at the haunch and neck so it reads as light grazing the back, not a drawn outline.
if(det>0.5){ ctx.beginPath();
ctx.moveTo(-L*0.5, botY*0.4);
ctx.bezierCurveTo(-L*0.58, topY*0.6, -L*0.42, bkH, -L*0.2, bkM); // haunch up over the back
ctx.bezierCurveTo(0, bk0, L*0.34, bkS, L*0.5, topY*0.7); // shoulder hump to the neck base
const rg=ctx.createLinearGradient(-L*0.5,0, L*0.5,0);
rg.addColorStop(0,'rgba(255,249,228,0)'); rg.addColorStop(0.26,'rgba(255,249,228,0.55)'); rg.addColorStop(0.72,'rgba(255,249,228,0.5)'); rg.addColorStop(1,'rgba(255,249,228,0.07)');
ctx.strokeStyle=rg; ctx.lineWidth=Math.max(1.0, H*0.045); ctx.lineJoin='round'; ctx.lineCap='round'; ctx.stroke();
}
ctx.restore();
// RAISED HACKLES (piloerection): a threatened animal's dorsal coat stands UP in a spiky ridge along the spine -- a
// nervous quivering row of hairs rooted on the back topline, pointing up-and-back. Drawn UNCLIPPED so they break the
// body outline. Driven by pose.bristle (0..1). A general capability; the badger raises them when a predator is near.
if((pose.bristle||0)>0.05 && !S.spines){ const br=pose.bristle, ck=pose.clock||0; // a SPINED creature (hedgehog) bristles its SPINES instead of hackles
const bz=(P,t)=>{ const u=1-t,a=u*u*u,b=3*u*u*t,c=3*u*t*t,d=t*t*t; return [a*P[0][0]+b*P[1][0]+c*P[2][0]+d*P[3][0], a*P[0][1]+b*P[1][1]+c*P[2][1]+d*P[3][1]]; };
const A=[[-L*0.5,botY*0.4],[-L*0.58,topY*0.6],[-L*0.42,bkH],[-L*0.2,bkM]];
const B=[[-L*0.2,bkM],[0,bk0],[L*0.34,bkS],[L*0.5,topY*0.7]];
const roots=[]; for(let i=2;i<=6;i++) roots.push(bz(A,i/7)); for(let i=0;i<=7;i++) roots.push(bz(B,i/7));
ctx.strokeStyle=hexa(S.col[2],0.92); ctx.lineWidth=Math.max(0.7,H*0.05); ctx.lineCap='round';
for(let i=0;i<roots.length;i++){ const [rx,ry]=roots[i], len=H*(0.30+0.18*((i*5)%3)/2)*br, sway=Math.sin(ck*12+i*1.4)*len*0.16*br;
ctx.beginPath(); ctx.moveTo(rx,ry); ctx.quadraticCurveTo(rx-len*0.16+sway*0.5, ry-len*0.55, rx-len*0.30+sway, ry-len); ctx.stroke(); } }
// SPINE COAT (hedgehog): the whole back dome is overlapping pale-banded QUILLS -- lie back when calm, stand ERECT when
// threatened (the same 'bristle' piloerection channel). Dark base + pale tip. Drawn UNCLIPPED so the dome bristles.
if(S.spines){ const sp=S.spines, br=pose.bristle||0, ck=pose.clock||0, hh=(i,o)=>{ const s=Math.sin((i+1)*12.9898+o*78.233)*43758.5; return s-Math.floor(s); };
ctx.lineCap='round';
for(let i=0;i<sp.n;i++){ const u=hh(i,0), v=hh(i,1), w2=hh(i,2);
const rx=-L*0.47 + u*L*0.88, ry=topY*(1.15 - v*v*1.35); // roots over the whole upper dome; v*v biases them toward the high back (spines thickest there, thinning down the sides)
const len=sp.len*(0.7+0.5*w2), lay=-0.78 + br*0.64 + (w2-0.5)*0.5, ang=lay + Math.sin(ck*11+i)*0.05*br; // calm: leaned back; threatened: nearer straight up; +spread + a quiver
const mx=rx+Math.sin(ang)*len*0.5, my=ry-Math.cos(ang)*len*0.5, tx=rx+Math.sin(ang)*len, ty=ry-Math.cos(ang)*len;
ctx.strokeStyle=hexa(S.col[2],0.92); ctx.lineWidth=Math.max(0.6,H*0.055); ctx.beginPath(); ctx.moveTo(rx,ry); ctx.lineTo(mx,my); ctx.stroke(); // dark base
ctx.strokeStyle=sp.band||'#e8dcc2'; ctx.lineWidth=Math.max(0.5,H*0.04); ctx.beginPath(); ctx.moveTo(mx,my); ctx.lineTo(tx,ty); ctx.stroke(); } } // pale tip
// BRISTLY MANE (boar): a dorsal crest of stiff bristles rising from the back line, tallest over the shoulder hump
if(det>0.3 && S.mane){ const fm=mulberry(sp.length*53+9); ctx.strokeStyle=hexa(S.col[2],0.92); ctx.lineCap='round';
for(let i=0;i<20;i++){ const t=i/19, x=-L*0.42+t*L*0.86;
const backY = S.foreHump ? topY*(1 + S.hump*(0.28 + 1.54*t)) // bison: ride the SLOPED back, low haunch -> towering shoulder
: topY*(1 + S.hump*(0.35+0.65*Math.sin(Math.min(1,t*1.05)*Math.PI))); // ride the (uniform-hump) back line
const h=(3+9*Math.sin(Math.min(1,t*0.95)*Math.PI))*(0.7+fm()*0.6); // tallest over the shoulder
ctx.lineWidth=1+fm()*0.8; ctx.beginPath(); ctx.moveTo(x,backY); ctx.lineTo(x+2.4-fm()*1.4, backY-h); ctx.stroke(); }
}
// TAIL -- a thin flick for deer/hare, or a fat furry brush for a wolf (S.bushy).
// A bolting deer FLAGS its white tail straight up over the rump (the flash of white as it flees).
const flag = (gt.gallop && S.tailTip && !S.bushy) ? 1 : 0;
const tbx=-L*0.5, tby=-H*0.15;
let ttx=(-L*0.5-S.tail)*(1-flag) + (tbx+S.tail*0.12 + Math.sin(ph*6.283)*S.tail*0.16)*flag; // a bolting flag flicks side to side with the stride
let tty=(-H*0.15+S.tailAng*S.tail*2+(gt.hop?-4:0))*(1-flag) + (tby-S.tail*1.15)*flag;
let tcx=(-L*0.5-S.tail*0.7)*(1-flag) + (tbx+S.tail*0.02)*flag;
let tcy=(-H*0.15+S.tailAng*S.tail)*(1-flag) + (tby-S.tail*0.6)*flag;
if(S.archTail){ // a SQUIRREL's plume: sweep UP off the rump and curl forward OVER the back (a big question-mark), with a live quiver
const qv=Math.sin((pose.clock||ph)*3.1)*S.tail*0.06;
tcx=-L*0.5-S.tail*0.46; tcy=-H*0.15-S.tail*0.66; // bows out behind the rump and rises
ttx=-L*0.30+qv; tty=-H*0.15-S.tail*1.32; // tip carried high and forward, arched over the body
}
if(S.bushy){
const tp=[]; for(let i=0;i<=8;i++){ const t=i/8, mt=1-t; // sample the tail curve
const w = S.archTail ? S.bushy*(0.5+0.78*Math.sin(t*Math.PI))*(1.12-0.42*t) // squirrel PLUME: fat mid, tapered to a fluffy tip
: S.bushy*(0.55+0.9*Math.sin(Math.min(1,t*1.15)*Math.PI*0.7)); // brush: fat through the middle
tp.push([mt*mt*tbx+2*mt*t*tcx+t*t*ttx, mt*mt*tby+2*mt*t*tcy+t*t*tty, w]); }
const pp=(i)=>{ const a=tp[Math.max(0,i-1)],b=tp[Math.min(tp.length-1,i+1)],dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
ctx.fillStyle=S.col[1]; ctx.beginPath();
for(let i=0;i<tp.length;i++){ const [px,py]=pp(i),w=tp[i][2],X=tp[i][0]+px*w,Y=tp[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=tp.length-1;i>=0;i--){ const [px,py]=pp(i),w=tp[i][2]; ctx.lineTo(tp[i][0]-px*w,tp[i][1]-py*w); }
ctx.closePath(); ctx.fill();
if(S.tailTip){ ctx.fillStyle=S.tailTip; ctx.beginPath(); ctx.arc(ttx,tty,S.bushy*0.85,0,7); ctx.fill(); } // dark brush tip
ctx.strokeStyle=hexa(S.col[2],0.4); ctx.lineWidth=0.7; ctx.lineCap='round'; // a few fur strands along the brush
const tf=mulberry(99); for(let i=0;i<10;i++){ const t=0.2+tf()*0.75, mt=1-t;
const bx=mt*mt*tbx+2*mt*t*tcx+t*t*ttx, by=mt*mt*tby+2*mt*t*tcy+t*t*tty, [px,py]=pp(Math.round(t*8));
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx-px*S.bushy*(0.6+tf()*0.7), by-py*S.bushy*(0.6+tf()*0.7)); ctx.stroke(); }
} else {
// a non-bushy tail with REAL form: sample the base->tip curve once, fill it as a tapered ribbon
// (not a stick + ball). Its shape tells the species: a raised white FAN when a deer flags in flight,
// a coat-coloured flat tail with a pale under-margin at rest, a fluffy white PUFF for a cottontail.
const NT=8, tsm=[]; for(let i=0;i<=NT;i++){ const t=i/NT, mt=1-t;
tsm.push([mt*mt*tbx+2*mt*t*tcx+t*t*ttx, mt*mt*tby+2*mt*t*tcy+t*t*tty, t]); }
const tno=(i)=>{ const a=tsm[Math.max(0,i-1)],b=tsm[Math.min(NT,i+1)],dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
const tribbon=(wfn,fill)=>{ ctx.fillStyle=fill; ctx.beginPath();
for(let i=0;i<=NT;i++){ const [px,py]=tno(i),w=wfn(tsm[i][2]),X=tsm[i][0]+px*w,Y=tsm[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=NT;i>=0;i--){ const [px,py]=tno(i),w=wfn(tsm[i][2]); ctx.lineTo(tsm[i][0]-px*w,tsm[i][1]-py*w); }
ctx.closePath(); ctx.fill(); };
if(flag){ // FLEE FLAG: a broad white fan raised over the rump, widest near the tip (the underside flashing)
tribbon(t=> (S.tail>12?3.5:2.6)*(0.4+1.4*t), S.tailTip);
ctx.strokeStyle=hexa(S.col[2],0.3); ctx.lineWidth=0.8; ctx.beginPath(); ctx.moveTo(tbx,tby); ctx.quadraticCurveTo(tcx,tcy,ttx,tty); ctx.stroke(); // faint coat edge so it doesn't float
} else if(S.puffTail){ // COTTONTAIL: a round fluffy white puff (a small cluster of soft tufts), not one hard ball
const pf=mulberry(sp.length*17+3); const pr=Math.max(3,S.tail*0.7);
ctx.fillStyle=hexa(S.col[2],0.5); ctx.beginPath(); ctx.arc(ttx,tty+0.6,pr*1.05,0,7); ctx.fill(); // a soft shadow under the puff
ctx.fillStyle=S.tailTip||'#f4efe6'; for(let i=0;i<7;i++){ const a=pf()*6.283; ctx.beginPath(); ctx.arc(ttx+Math.cos(a)*pr*0.5, tty+Math.sin(a)*pr*0.5, pr*0.62, 0,7); ctx.fill(); }
} else if(S.nakedTail){ // a MOUSE/RAT tail: a thin BARE stroke, near-uniform + pinkish-grey (a new reusable capability, cf S.tailW)
ctx.strokeStyle=S.tailNaked||S.belly; ctx.lineCap='round';
for(let i=0;i<10;i++){ const t0=i/10, t1=(i+1)/10, m0=1-t0, m1=1-t1;
const x0=m0*m0*tbx+2*m0*t0*tcx+t0*t0*ttx, y0=m0*m0*tby+2*m0*t0*tcy+t0*t0*tty;
const x1=m1*m1*tbx+2*m1*t1*tcx+t1*t1*ttx, y1=m1*m1*tby+2*m1*t1*tcy+t1*t1*tty;
ctx.lineWidth=(S.tail>12?2.0:1.4)*(1-0.5*t0);
ctx.beginPath(); ctx.moveTo(x0,y0); ctx.lineTo(x1,y1); ctx.stroke(); }
} else { // a flat tapered tail hanging at rest, coat-coloured
tribbon(t=> (S.tail>12?4:2.6)*(1-0.68*t)*(S.tailW||1), S.col[1]); // S.tailW fattens the base (a thick muscular otter tail)
if(S.flagTail){ ctx.strokeStyle=hexa(S.tailTip,0.7); ctx.lineWidth=1.2; ctx.lineCap='round'; // white underside margin, visible even at rest
ctx.beginPath(); ctx.moveTo(tbx-0.5,tby+1.4); ctx.quadraticCurveTo(tcx,tcy+1.6, ttx,tty+0.8); ctx.stroke(); }
else if(S.tailTip){ ctx.fillStyle=S.tailTip; ctx.beginPath(); ctx.arc(ttx,tty,2.1,0,7); ctx.fill(); } // a small tuft tip (boar etc.)
}
}
// NECK + HEAD -- posture-driven: neckAng swings the head UP (alert) or DOWN (graze)
const neckAng = S.neckAng + (pose.neck||0);
const nb=[L*0.46, topY*0.5]; // neck base
const hd=[nb[0]+Math.cos(neckAng)*S.neck, nb[1]+Math.sin(neckAng)*S.neck];
{ // a tapered, gently curved neck (throat-line bowed) sampled + offset-filled, thick at the shoulder to thin at the skull
const mcx=(nb[0]+hd[0])/2, mcy=(nb[1]+hd[1])/2 - S.neck*0.16;
const nw0=H*0.30, nw1=Math.max(3,H*0.15), np=[];
for(let i=0;i<=8;i++){ const t=i/8, mt=1-t;
np.push([mt*mt*nb[0]+2*mt*t*mcx+t*t*hd[0], mt*mt*nb[1]+2*mt*t*mcy+t*t*hd[1], nw0+(nw1-nw0)*t]); }
const npp=(i)=>{ const a=np[Math.max(0,i-1)],b=np[Math.min(np.length-1,i+1)],dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
ctx.fillStyle=S.col[0]; ctx.beginPath();
for(let i=0;i<np.length;i++){ const [px,py]=npp(i),w=np[i][2],X=np[i][0]+px*w,Y=np[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=np.length-1;i>=0;i--){ const [px,py]=npp(i),w=np[i][2]; ctx.lineTo(np[i][0]-px*w,np[i][1]-py*w); }
ctx.closePath(); ctx.fill(); }
// HEAD -- a real muzzle: skull at the back, tapering to a nose, jaw beneath, two ears, an eye, a nostril
ctx.save(); ctx.translate(hd[0],hd[1]); ctx.rotate(neckAng+0.5);
const hr=S.head, sn=S.snout, er=(pose.ear||0) + (pose.earTurn||0)*0.6; // er rotates the ears: pose.ear is the alertness tilt; earTurn SWIVELS them toward a threat/target (+ ahead, - behind) even while the head grazes
const eo=(pose.earTurn||0)*hr*0.4; // and SHIFTS the ear base forward/back so the tip visibly SWEEPS toward the threat (rotation alone reads too subtly on a near-symmetric ear)
// far ear first (behind the skull, dimmer) -- triangular + erect for a canid, else a rounded ellipse
if(S.bigEar){ const flap=Math.sin((pose.clock||0)*1.7)*0.13; ctx.fillStyle=S.col[1]; // ELEPHANT: a huge fanning ear behind the head, slowly flapping
ctx.beginPath(); ctx.moveTo(-hr*0.15,-hr*0.72);
ctx.quadraticCurveTo(-hr*1.5-flap*hr,-hr*0.7, -hr*1.35-flap*hr*0.6, hr*0.55);
ctx.quadraticCurveTo(-hr*0.7, hr*1.05, hr*0.05, hr*0.5);
ctx.quadraticCurveTo(-hr*0.35,-hr*0.1, -hr*0.15,-hr*0.72); ctx.closePath(); ctx.fill();
ctx.strokeStyle=hexa(S.col[2],0.35); ctx.lineWidth=Math.max(0.8,hr*0.05); ctx.stroke(); }
else if(S.canid||S.feline){ ctx.save(); ctx.translate(-hr*0.5,-hr*0.6); ctx.rotate(-0.3+er); ctx.fillStyle=S.col[2];
ctx.beginPath(); ctx.moveTo(-S.ear*0.5,S.ear*0.1); ctx.lineTo(-S.ear*0.05,-S.ear*1.45); ctx.lineTo(S.ear*0.5,S.ear*0.05); ctx.closePath(); ctx.fill(); ctx.restore(); }
else if(S.earRound){ ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.ellipse(-hr*0.66,-hr*0.6,S.ear*0.5,S.ear*0.5,0,0,7); ctx.fill(); } // small round ear (bear)
else { ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.ellipse(-hr*0.55,-hr*0.75,S.ear*0.3,S.ear*0.92,-0.35+er,0,7); ctx.fill(); }
// skull + muzzle -- a canid gets a domed skull, a brow stop and a blunt nose; a cervid/lagomorph a fine taper
ctx.fillStyle=S.headCol||S.col[0]; ctx.beginPath(); // headCol lets the head differ from the body (a badger's white face on a grey body)
if(S.canid){
ctx.moveTo(-hr*0.78,-hr*0.32); // nape / back of skull
ctx.quadraticCurveTo(-hr*0.45,-hr*1.02, hr*0.12,-hr*0.82); // high domed forehead
ctx.quadraticCurveTo(sn*0.55,-hr*0.66, sn*1.14,-hr*0.46); // a slight stop, then the muzzle top out to the nose
ctx.quadraticCurveTo(sn*1.54,-hr*0.22, sn*1.46, hr*0.16); // blunt nose, near-vertical front
ctx.quadraticCurveTo(sn*1.34, hr*0.5, sn*0.84, hr*0.48); // lip under the nose
ctx.quadraticCurveTo(sn*0.28, hr*0.66, -hr*0.36, hr*0.6); // lower jaw back
ctx.quadraticCurveTo(-hr*0.92, hr*0.42, -hr*0.78,-hr*0.32); // jowl up to the skull
} else if(S.feline){ // a CAT: a domed forehead, a short rounded muzzle
ctx.moveTo(-hr*0.8,-hr*0.28);
ctx.quadraticCurveTo(-hr*0.5,-hr*1.02, hr*0.18,-hr*0.74); // rounded domed forehead
ctx.quadraticCurveTo(sn*0.7,-hr*0.55, sn*1.15,-hr*0.06); // short muzzle top out to the nose
ctx.quadraticCurveTo(sn*1.34, hr*0.22, sn*1.12, hr*0.44); // small rounded nose front
ctx.quadraticCurveTo(sn*0.5, hr*0.62, -hr*0.42, hr*0.54); // short jaw
ctx.quadraticCurveTo(-hr*0.92, hr*0.34, -hr*0.8,-hr*0.28); // cheek up to the forehead
} else {
ctx.moveTo(-hr*0.7,-hr*0.5); // top of skull
ctx.quadraticCurveTo(sn*0.9,-hr*0.85, sn*1.55,-hr*0.15); // brow down the nose bridge to the nose tip
ctx.quadraticCurveTo(sn*1.7, hr*0.25, sn*1.35, hr*0.5); // nose tip round under
ctx.quadraticCurveTo(sn*0.4, hr*0.8, -hr*0.6, hr*0.6); // jaw underside back to the throat
ctx.quadraticCurveTo(-hr*0.95, 0, -hr*0.7,-hr*0.5);
}
ctx.closePath(); ctx.fill();
// BOVID HORNS (bison/cattle): short curved horns sweeping out from the skull crown and hooking UP, with pale
// sharp tips -- one far (offset, dimmer), one near. Distinct from S.horns, which draws a cervid ANTLER rack.
if(S.bovidHorns){
const horn=(sx, col, tip)=>{ ctx.strokeStyle=col; ctx.lineCap='round'; ctx.lineWidth=hr*0.30;
ctx.beginPath(); ctx.moveTo(-hr*0.30+sx,-hr*0.42); ctx.quadraticCurveTo(-hr*0.92+sx,-hr*0.46, -hr*1.02+sx,-hr*1.02); ctx.stroke();
ctx.strokeStyle=tip; ctx.lineWidth=hr*0.15; ctx.beginPath(); ctx.moveTo(-hr*0.99+sx,-hr*0.76); ctx.quadraticCurveTo(-hr*1.05+sx,-hr*0.98,-hr*1.02+sx,-hr*1.13); ctx.stroke(); };
horn(hr*0.34, S.col[2], '#a89a7e'); horn(0, '#241a12', '#d8cfb6');
}
// BEARD (bison): a shaggy dark tuft hanging from the lower jaw + throat
if(S.beard){ ctx.strokeStyle=hexa(S.col[2],0.9); ctx.lineWidth=Math.max(1,hr*0.10); ctx.lineCap='round';
const bf=mulberry(sp.length*41+5);
for(let i=0;i<10;i++){ const bx=-hr*0.55+bf()*sn*1.0, by=hr*0.46+bf()*hr*0.22;
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx-hr*0.08*(bf()-0.3), by+hr*(0.55+bf()*0.8)); ctx.stroke(); } }
// FACE STRIPE (badger): a bold black band on the white face, running from the nose back THROUGH the eye up to the ear
if(S.faceStripe){ ctx.strokeStyle=S.faceStripe; ctx.lineWidth=hr*0.5; ctx.lineCap='round'; ctx.lineJoin='round';
ctx.beginPath(); ctx.moveTo(sn*1.46,-hr*0.04); ctx.quadraticCurveTo(sn*0.4,-hr*0.16, -hr*0.28,-hr*0.72); ctx.stroke(); }
// a touch of shading under the jaw (fine detail)
if(det>0.4){ ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.moveTo(-hr*0.4,hr*0.45); ctx.quadraticCurveTo(sn*0.5,hr*0.7,sn*1.2,hr*0.42);
ctx.quadraticCurveTo(sn*0.5,hr*0.62,-hr*0.4,hr*0.6); ctx.closePath(); ctx.fill(); }
// MOUTH: a fine lip line where the lips close, along the lower muzzle -- completes the face. Front point keyed
// to the muzzle type (a canid's long jaw reaches further forward than a cat's short one); scales with sn/hr.
if(det>0.45){ ctx.strokeStyle=hexa(S.col[2],0.6); ctx.lineWidth=Math.max(0.6,hr*0.075); ctx.lineCap='round';
const mx0 = S.feline? sn*1.0 : S.canid? sn*1.32 : sn*1.34, mxb = S.canid? -hr*0.06 : sn*0.1; // front lip corner .. back corner
ctx.beginPath(); ctx.moveTo(mx0, hr*0.30); ctx.quadraticCurveTo(sn*0.5, hr*0.52, mxb, hr*0.42); ctx.stroke();
if(S.canid||S.feline){ ctx.beginPath(); ctx.arc(mxb, hr*0.42, ctx.lineWidth*0.7, 0, 7); ctx.fillStyle=hexa(S.col[2],0.5); ctx.fill(); } } // a soft mouth corner on predators
// PANTING TONGUE (canid/feline): when hot or blown after a run the mouth opens and the tongue LOLLS out -- a pink
// SPATULATE lobe (narrow at the mouth, widening to a rounded spade tip) drooping down-forward, a dark open-mouth
// gap behind it, a central groove. It QUIVERS with the pant breath (pose.clock, ~2.5Hz) -- length pulses and the
// tip sways, so the tongue is alive, not a fixed shape. Driven by pose.pant (0..1); LOD-gated. The expressive face
// is now ears-swivel + whiskers-protract + eye + a BREATHING panting mouth.
if((pose.pant||0)>0 && det>0.4 && (S.canid||S.feline)){
const pp=clamp(pose.pant,0,1), qv=Math.sin((pose.clock||0)*(10.5+8*pp)); // pant-breath quiver: FASTER when just-blown (pp high), slowing as it recovers; 0 when no clock -> static
const bx=(S.feline?sn*0.98:sn*1.18), by=hr*0.36;
const tlen=hr*(0.5+0.55*pp)*(1+0.10*qv*pp), twB=hr*0.24, twT=hr*0.40; // SHORTER than v1 (the cougar over-hung); a spade tip; length breathes
const sway=qv*hr*0.42*pp, tipx=bx+tlen*0.30+sway, tipy=by+tlen; // tip droops forward + sways with each breath
ctx.fillStyle='#3a1712'; ctx.beginPath(); ctx.ellipse(bx-hr*0.12, by+hr*0.03, hr*0.30, hr*0.21, 0.3, 0,7); ctx.fill(); // dark open mouth
ctx.fillStyle='#d16b73'; // the tongue: narrow base -> wide rounded spade tip
ctx.beginPath(); ctx.moveTo(bx-twB, by-hr*0.02);
ctx.quadraticCurveTo(bx+twB*0.6, by+tlen*0.42, tipx+twT*0.5, tipy-twT*0.45);
ctx.quadraticCurveTo(tipx+twT*0.62, tipy+twT*0.28, tipx, tipy+twT*0.5);
ctx.quadraticCurveTo(tipx-twT*0.62, tipy+twT*0.28, tipx-twT*0.5, tipy-twT*0.45);
ctx.quadraticCurveTo(bx-twB*1.5, by+tlen*0.42, bx-twB, by-hr*0.02); ctx.closePath(); ctx.fill();
ctx.strokeStyle='#e79aa0'; ctx.lineWidth=Math.max(0.6,twB*0.4); ctx.lineCap='round'; // central groove down the tongue
ctx.beginPath(); ctx.moveTo(bx, by+tlen*0.15); ctx.quadraticCurveTo((bx+tipx)/2, by+tlen*0.6, tipx, tipy-twT*0.2); ctx.stroke();
}
// near ear (up and forward, with an inner) -- erect triangle for a canid, else a rounded ellipse
if(S.canid||S.feline){ ctx.save(); ctx.translate(-hr*0.16+eo,-hr*0.64); ctx.rotate(-0.12+er);
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.moveTo(-S.ear*0.55,S.ear*0.1); ctx.lineTo(0,-S.ear*1.6); ctx.lineTo(S.ear*0.55,S.ear*0.05); ctx.closePath(); ctx.fill();
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.moveTo(-S.ear*0.30,-S.ear*0.08); ctx.lineTo(0,-S.ear*1.18); ctx.lineTo(S.ear*0.30,-S.ear*0.10); ctx.closePath(); ctx.fill(); ctx.restore(); }
else if(S.earRound){ ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.ellipse(-hr*0.34+eo,-hr*0.68,S.ear*0.55,S.ear*0.55,0,0,7); ctx.fill(); // round bear ear + inner
ctx.fillStyle=S.col[2]; ctx.beginPath(); ctx.ellipse(-hr*0.32+eo,-hr*0.64,S.ear*0.28,S.ear*0.28,0,0,7); ctx.fill(); }
else {
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.ellipse(-hr*0.25+eo,-hr*0.85,S.ear*0.34,S.ear,-0.45+er,0,7); ctx.fill();
ctx.fillStyle=S.earInner||S.col[2]; ctx.beginPath(); ctx.ellipse(-hr*0.22+eo,-hr*0.8,S.ear*0.16,S.ear*0.6,-0.45+er,0,7); ctx.fill(); // pale inner concha for prey (earInner)
}
if(S.earTip){ ctx.fillStyle=S.earTip; // dark ear tips (hare) -- follow the same swivel offset so they stay on the ears
ctx.beginPath(); ctx.ellipse(-hr*0.25-S.ear*0.30+eo,-hr*0.85-S.ear*0.62,S.ear*0.26,S.ear*0.34,-0.45+er,0,7); ctx.fill();
ctx.beginPath(); ctx.ellipse(-hr*0.55-S.ear*0.24+eo,-hr*0.75-S.ear*0.56,S.ear*0.20,S.ear*0.28,-0.35+er,0,7); ctx.fill(); }
if(S.nose){ ctx.fillStyle=S.nose;
if(S.snoutDisc){ // a PIG's flat rooting disc at the muzzle tip: a tall forward-facing pad with two nostrils
ctx.beginPath(); ctx.ellipse(sn*1.5, hr*0.13, hr*0.26, hr*0.52, 0.12, 0,7); ctx.fill();
if(det>0.45){ ctx.fillStyle='#0b0805';
ctx.beginPath(); ctx.ellipse(sn*1.58, hr*0.0, hr*0.06, hr*0.13, 0.1, 0,7); ctx.fill(); // upper nostril
ctx.beginPath(); ctx.ellipse(sn*1.58, hr*0.27, hr*0.06, hr*0.13, 0.1, 0,7); ctx.fill(); } // lower nostril
} else { ctx.beginPath(); ctx.ellipse(sn*1.46, hr*0.14, hr*0.30, hr*0.36, 0.3, 0,7); ctx.fill(); } // dark nose pad (canid)
}
// EYE -- a living eye: a dark liquid orb, a warm iris + pupil, a heavy coat-coloured upper LID that gives
// expression, and a wet catchlight. Reads even small; the iris + lid resolve up close. Predators run keener (eyeCol).
const ex=sn*0.35, ey=-hr*0.12, erad=Math.max(1.6, hr*0.24);
ctx.fillStyle='#0c0906'; ctx.beginPath(); ctx.arc(ex,ey,erad,0,7); ctx.fill(); // the eye
if(det>0.4){
ctx.fillStyle=S.eyeCol||'#3a2413'; ctx.beginPath(); ctx.arc(ex,ey,erad*0.8,0,7); ctx.fill(); // warm iris
ctx.fillStyle='#070504'; ctx.beginPath(); ctx.arc(ex,ey,erad*0.46,0,7); ctx.fill(); // pupil
ctx.fillStyle=hexa(S.col[2],0.92); ctx.beginPath(); // heavy upper lid (coat) -> expression
ctx.ellipse(ex, ey-erad*0.92, erad*1.3, erad*0.82, 0, 0,7); ctx.fill();
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(ex+erad*0.32, ey-erad*0.3, erad*0.26, 0,7); ctx.fill(); // wet catchlight
ctx.fillStyle='#0e0a07'; ctx.beginPath(); ctx.ellipse(sn*1.4,hr*0.08,1.3,1.9,0.3,0,7); ctx.fill(); } // nostril
// WHISKERS (vibrissae): a LIVING whisker system, not a static fan. Fine pale hairs on a mystacial PAD beside the
// nose, each starting near-level then curving to a drooping tip (gravity on a flexible hair). They RESPOND: an
// alert animal PROTRACTS them forward and stiffens them (less droop), a calm one lets them relax back -- driven by
// the same ear-alertness the ears already use (pose.ear + earTurn). Plus a faint FAR-side set behind the muzzle,
// VARIED lengths (long macro-feelers among shorter ones), and superciliary BROW whiskers above the eye. LOD-gated
// (fine, close-up only) so a far muzzle is bare -- the dot->model rule on the face. Canid/feline/hare (S.whiskers).
if(det>0.55 && (S.canid||S.feline||S.whiskers)){
ctx.lineCap='round';
const fwd = clamp((pose.ear||0)*1.3 + Math.abs(pose.earTurn||0)*0.4, 0, 1); // 0 relaxed .. 1 protracted (alert = ears forward)
const baseLen = sn*(S.feline?1.2:S.canid?0.95:1.05)*(1+0.12*fwd); // reach a touch further when forward
const sag = 0.15*(1-0.78*fwd), lenK=[0.82,1.20,0.92,1.24]; // stiffen forward when alert; long macro-feelers at rows 1 & 3
const whisk=(ox,oy,a,len,lw,al)=>{ ctx.strokeStyle='rgba(240,238,230,'+al.toFixed(2)+')'; ctx.lineWidth=Math.max(0.4,lw);
const tx=ox+len*Math.cos(a), ty=oy+len*Math.sin(a)+len*sag, cx=ox+len*0.55*Math.cos(a*0.4), cy=oy+len*0.55*Math.sin(a*0.4);
ctx.beginPath(); ctx.moveTo(ox,oy); ctx.quadraticCurveTo(cx,cy,tx,ty); ctx.stroke(); };
const mAng=[-0.34,-0.13,0.08,0.29];
for(let wI=0;wI<3;wI++) // FAR-side set: dimmer, drawn first, peeking over from the other cheek
whisk(sn*1.02-wI*sn*0.04, -hr*0.05+wI*hr*0.08, mAng[wI]-0.08-0.30*fwd, baseLen*lenK[wI]*0.8, hr*0.04, 0.13);
for(let wI=0;wI<4;wI++) // NEAR-side mystacial fan: protracts forward + up with alertness
whisk(sn*1.06-wI*sn*0.05, wI*hr*0.085, mAng[wI]-0.30*fwd, baseLen*lenK[wI], hr*0.055*(1-wI*0.10), 0.30+0.10*(wI%2));
if(S.canid||S.feline) // SUPERCILIARY (brow) whiskers, arcing forward-up over the eye: 2 fine feelers
for(let wI=0;wI<2;wI++) whisk(sn*0.24-wI*sn*0.14, -hr*0.44-wI*hr*0.05, -0.5-0.2*wI-0.15*fwd, baseLen*0.68, hr*0.04, 0.20);
}
// IVORY TUSKS (elephant): a long tusk sweeping FORWARD and down from beside the trunk root, then hooking up at
// the tip -- a tapered ivory shape (not the boar's little up-curved jaw tush). The FAR tusk is dimmer and drawn
// BEFORE the trunk (so the trunk hangs over it); the NEAR bright tusk is drawn AFTER, in front of the trunk.
function drawTusk(bx,by,cxp,cyp,tx,ty,w0,col){
const N=8, pt=[];
for(let i=0;i<=N;i++){ const u=i/N,m=1-u;
pt.push([ m*m*bx+2*m*u*cxp+u*u*tx, m*m*by+2*m*u*cyp+u*u*ty, w0*(1-0.90*u) ]); }
const nrm=(i)=>{ const a=pt[Math.max(0,i-1)],b=pt[Math.min(N,i+1)],dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
ctx.fillStyle=col; ctx.beginPath();
for(let i=0;i<=N;i++){ const [nx,ny]=nrm(i),w=pt[i][2]; i?ctx.lineTo(pt[i][0]+nx*w,pt[i][1]+ny*w):ctx.moveTo(pt[i][0]+nx*w,pt[i][1]+ny*w); }
for(let i=N;i>=0;i--){ const [nx,ny]=nrm(i),w=pt[i][2]; ctx.lineTo(pt[i][0]-nx*w,pt[i][1]-ny*w); }
ctx.closePath(); ctx.fill();
ctx.beginPath(); ctx.arc(pt[0][0],pt[0][1],w0,0,7); ctx.fill(); // rounded root
ctx.beginPath(); ctx.arc(pt[N][0],pt[N][1],w0*0.10,0,7); ctx.fill(); // rounded point
}
if(S.ivory) drawTusk(sn*1.05, hr*0.40, sn*1.95, hr*0.88, sn*2.30, hr*0.34, hr*0.15, '#b9b09a'); // FAR tusk (dim), behind the trunk
// TRUNK (elephant): a long muscular prehensile nose hanging from the face, tapering to two finger-lips at the tip,
// ridged across, gently swaying at idle (or curling up with pose.trunk). Its own new render capability.
if(S.trunk){
const sway=Math.sin((pose.clock||0)*1.25)*0.5 - (pose.trunk||0)*2.2;
const P0=[sn*1.0,-hr*0.04], P1=[sn*2.1+hr*0.3, hr*1.15], P2=[sn*2.05+sway*hr, hr*2.4], P3=[sn*1.3+sway*hr*2.3, hr*3.1];
const NB=11, tp=[];
for(let i=0;i<=NB;i++){ const u=i/NB,m=1-u;
tp.push([ m*m*m*P0[0]+3*m*m*u*P1[0]+3*m*u*u*P2[0]+u*u*u*P3[0],
m*m*m*P0[1]+3*m*m*u*P1[1]+3*m*u*u*P2[1]+u*u*u*P3[1], hr*0.44*(1-0.8*u) ]); }
const trn=(i)=>{ const a=tp[Math.max(0,i-1)],b=tp[Math.min(NB,i+1)],dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl,dx/dl]; };
ctx.fillStyle=S.col[0]; ctx.beginPath();
for(let i=0;i<=NB;i++){ const [px,py]=trn(i),w=tp[i][2]; i?ctx.lineTo(tp[i][0]+px*w,tp[i][1]+py*w):ctx.moveTo(tp[i][0]+px*w,tp[i][1]+py*w); }
for(let i=NB;i>=0;i--){ const [px,py]=trn(i),w=tp[i][2]; ctx.lineTo(tp[i][0]-px*w,tp[i][1]-py*w); }
ctx.closePath(); ctx.fill();
if(det>0.4){ ctx.strokeStyle=hexa(S.col[2],0.45); ctx.lineWidth=Math.max(0.6,hr*0.045); // ridges across the trunk
for(let i=2;i<NB;i++){ const [px,py]=trn(i),w=tp[i][2]*0.8; ctx.beginPath(); ctx.moveTo(tp[i][0]-px*w,tp[i][1]-py*w); ctx.lineTo(tp[i][0]+px*w,tp[i][1]+py*w); ctx.stroke(); } }
ctx.fillStyle=S.col[1]; ctx.beginPath(); ctx.arc(tp[NB][0]-hr*0.07,tp[NB][1],hr*0.055,0,7); ctx.arc(tp[NB][0]+hr*0.07,tp[NB][1],hr*0.055,0,7); ctx.fill(); // two finger-lips
}
if(S.ivory){ drawTusk(sn*1.18, hr*0.50, sn*2.15, hr*1.00, sn*2.55, hr*0.40, hr*0.17, '#efe9d6'); // NEAR tusk (bright ivory), in front of the trunk
if(det>0.45){ ctx.strokeStyle='rgba(120,110,86,0.35)'; ctx.lineWidth=Math.max(0.5,hr*0.02); // a faint growth-line down the near tusk
ctx.beginPath(); ctx.moveTo(sn*1.2,hr*0.5); ctx.quadraticCurveTo(sn*2.1,hr*0.98, sn*2.5,hr*0.42); ctx.stroke(); } }
// TUSKS (boar): two ivory tushes curving up from the lower jaw at the muzzle
if(S.tusks){ ctx.lineCap='round';
ctx.strokeStyle='#eae3d0'; ctx.lineWidth=hr*0.17;
ctx.beginPath(); ctx.moveTo(sn*1.16, hr*0.44); ctx.quadraticCurveTo(sn*1.52, hr*0.16, sn*1.55,-hr*0.24); ctx.stroke(); // near tush
ctx.strokeStyle='#d3ccb8'; ctx.lineWidth=hr*0.12;
ctx.beginPath(); ctx.moveTo(sn*1.04, hr*0.46); ctx.quadraticCurveTo(sn*1.34, hr*0.2, sn*1.38,-hr*0.12); ctx.stroke(); } // far tush
// ANTLERS (deer): a branched rack with real mass -- a knobby burr, a beam tapering from a thick base to a
// fine tip through a natural up-back-then-forward sweep, a forward brow tine + two upper tines, polished pale
// tips. The far antler is dimmer and offset (like the far legs). Drawn last so it sits above the skull.
if(S.horns){
const rack=(off, beamCol, tipCol)=>{
const P=(x,y)=>[-hr*0.3+off + x, -hr*0.5 + y]; // relative to the pedicle at the skull top-back
const p0=P(0,0), p1=P(-hr*0.25,-hr*0.55), p2=P(-hr*0.4,-hr*1.2), p3=P(-hr*0.2,-hr*1.85), p4=P(hr*0.18,-hr*2.2);
ctx.fillStyle=beamCol;
ctx.beginPath(); ctx.arc(p0[0],p0[1],hr*0.17,0,7); ctx.fill(); // burr knob
limb(p0,p1, hr*0.20, hr*0.15); // main beam, tapering up the sweep
limb(p1,p2, hr*0.15, hr*0.11);
limb(p2,p3, hr*0.11, hr*0.07);
limb(p3,p4, hr*0.07, hr*0.04);
const brow=P(hr*0.55,-hr*0.5), t1=P(-hr*1.05,-hr*0.8), t2=P(-hr*0.95,-hr*1.6);
limb(P(-hr*0.05,-hr*0.28), brow, hr*0.085, hr*0.025); // brow tine, forward over the face
limb(p1, t1, hr*0.09, hr*0.025); // upper tine 1
limb(p2, t2, hr*0.08, hr*0.025); // upper tine 2
if(det>0.55){ ctx.fillStyle=tipCol; for(const t of [p4,brow,t1,t2]){ ctx.beginPath(); ctx.arc(t[0],t[1],hr*0.055,0,7); ctx.fill(); } } // polished pale tips, up close
};
rack(hr*0.2, '#5f4d31', '#8a7a56'); // FAR antler (dimmer, offset)
rack(0, '#7a6748', '#b0a079'); // NEAR antler (brighter)
}
ctx.restore();
// NEAR legs (in front of body)
ctx.translate(0,-bob);
drawLeg(backHip, O.bn, false); drawLeg(frontHip, O.fn, false);
// SOIL flung back from the digging front paws -- a stateless spray, each speck cycling on the clock (no per-particle state)
if((pose.dig||0)>0.4){ const dg=pose.dig, fpx=L*0.34, cl2=pose.clock||0;
for(let i=0;i<10;i++){ const ph=((cl2*2.4 + i*0.37)%1+1)%1; if(ph>0.88) continue;
const vv=((i*7)%3)/2, sx=fpx - ph*L*0.5*(0.7+0.5*vv), sy=gy - Math.sin(ph*Math.PI)*H*(0.33+0.28*vv) - ph*3, r=1.5*(1-ph*0.55)*dg;
ctx.fillStyle = i%3? '#5a4632':'#6e5844'; ctx.beginPath(); ctx.ellipse(sx, sy, r, r*0.82, 0, 0,7); ctx.fill(); } }
ctx.restore();
}
// ============================================================================================
// BIRD -- a biped body plan: an ovoid body over two backward-bending legs, a folded wing with
// primaries, a beak, a fanned tail. Build from bird def B (name sp seeds the feather stipple).
// ============================================================================================
// a bird's visible leg: the ankle (the "backward knee") sits BEHIND and above the foot,
// the hip forward and above the ankle, so the leg reads as a rear-pointing Z.
function birdLeg(ctx, hip, ft, B, w){
const l1=B.leg*0.55, l2=B.leg*0.66, lc=B.legCol||'#3b3128', lc2=B.legCol2||B.legCol||'#332c23';
const ank=ik(hip[0],hip[1],ft[0],ft[1], l1, l2, 1);
ctx.strokeStyle=lc; ctx.lineCap='round'; ctx.lineJoin='round';
ctx.lineWidth=B.legW*2.0*w; // feathered tibia, thicker near the body
ctx.beginPath(); ctx.moveTo(hip[0],hip[1]); ctx.lineTo(ank[0],ank[1]); ctx.stroke();
ctx.lineWidth=B.legW*1.0*w; // bare scaly tarsus, thin
ctx.beginPath(); ctx.moveTo(ank[0],ank[1]); ctx.lineTo(ft[0],ft[1]); ctx.stroke();
ctx.fillStyle=lc; ctx.beginPath(); ctx.arc(ank[0],ank[1],B.legW*0.9*w,0,7); ctx.fill(); // ankle knob
// foot on the ground: three toes forward, one back
ctx.lineWidth=B.legW*0.85*w; ctx.strokeStyle=lc2;
for(const a of [-0.35,0.02,0.42].slice(0,B.foreToes)){ ctx.beginPath(); ctx.moveTo(ft[0],ft[1]);
ctx.lineTo(ft[0]+B.toe*w*Math.cos(a), ft[1]+2+B.toe*0.3*w*Math.abs(Math.sin(a))); ctx.stroke(); }
ctx.beginPath(); ctx.moveTo(ft[0],ft[1]); ctx.lineTo(ft[0]-B.toe*0.75*w, ft[1]+2); ctx.stroke(); // hind toe
}
// an OWL in silent GLIDING flight -- its own form (like the hedgehog's curled ball), swapped in by pose.flying.
// Drawn in the readable "from below/behind" view: BROAD rounded wings spread wide as a gull-curve, sweeping
// slow and deep with the beat, a round head forward, a short fanned tail, slotted primary "fingers". Head at +x.
function drawBirdFlight(ctx, B, sp, det, phase, pose){
const tuck=pose.tuck||0, strike=pose.strike||0; // tuck = fold the wings for a diving STOOP; strike = flare up + reach talons
const s=B.wing*0.62; // a size unit off the wingspan
const up=Math.sin(phase*6.2832)*0.6*(1-tuck) + strike*0.85; // strike FLARES the wings up; tuck stills them
const wy=-up*s*0.7;
const wtip=(1.7 - tuck*0.95)*s; // tuck folds the broad wings IN for the dive
// WINGS: a filled broad gull-curve from the far tip, over the body, to the near tip (leading edge arched up)
ctx.fillStyle=B.back; ctx.strokeStyle=hexa(B.col[2],0.6); ctx.lineWidth=Math.max(0.8,s*0.05); ctx.lineJoin='round'; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-wtip, wy);
ctx.quadraticCurveTo(-s*0.35, wy*0.4 - s*0.34, -s*0.06, -s*0.06); // far wing leading edge to the body
ctx.quadraticCurveTo( s*0.35, wy*0.4 - s*0.34, wtip, wy); // over to the near wingtip
ctx.quadraticCurveTo( s*0.42, wy*0.4 + s*0.10, s*0.10, s*0.14); // near trailing edge back
ctx.quadraticCurveTo(-s*0.42, wy*0.4 + s*0.10, -wtip, wy); ctx.closePath(); ctx.fill();
if(det>0.34){ ctx.fillStyle=B.col[2]; // slotted primary FINGERS at each wingtip
for(const sgn of [-1,1]) for(let i=0;i<4;i++){ const u=i/3, tx=sgn*(wtip - u*s*0.30), ty=wy + Math.abs(up)*s*0.05 + u*s*0.13;
ctx.beginPath(); ctx.moveTo(tx,ty); ctx.lineTo(tx+sgn*s*0.16, ty-s*0.03); ctx.lineTo(tx, ty+s*0.11); ctx.closePath(); ctx.fill(); } }
ctx.fillStyle=B.col[1]; ctx.beginPath(); ctx.moveTo(-s*0.16,s*0.10); ctx.lineTo(-s*0.5,s*0.03); ctx.lineTo(-s*0.16,s*0.24); ctx.closePath(); ctx.fill(); // short tail fan (behind = -x)
ctx.fillStyle=B.col[0]; ctx.beginPath(); ctx.ellipse(0, s*0.05, s*0.34, s*0.2, 0,0,7); ctx.fill(); // body
if(det>0.42 && B.speckle){ ctx.fillStyle=hexa(B.speckle,0.4); const sr=mulberry(sp.length*7+3);
for(let i=0;i<16;i++){ ctx.beginPath(); ctx.arc(-s*0.28+sr()*s*0.56, -s*0.06+sr()*s*0.22, 0.5,0,7); ctx.fill(); } }
const hx=s*0.4, hr=s*0.2; // round head, forward
ctx.fillStyle=B.facialDisc||B.back; ctx.beginPath(); ctx.arc(hx, 0, hr, 0,7); ctx.fill();
if(det>0.4 && B.forwardEyes){ for(const ey of [-hr*0.4, hr*0.4]){ ctx.fillStyle=B.eyeDark?'#0a0603':'#0b0805'; // both forward eyes, seen head-on
ctx.beginPath(); ctx.arc(hx+hr*0.35, ey, B.eye*0.6,0,7); ctx.fill();
if(!B.eyeDark){ ctx.fillStyle=B.eyeCol||'#e2a726'; ctx.beginPath(); ctx.arc(hx+hr*0.35, ey, B.eye*0.36,0,7); ctx.fill(); } } }
ctx.fillStyle=B.beakCol||'#3b3128'; ctx.beginPath(); ctx.arc(hx+hr*0.7, 0, hr*0.22, 0,7); ctx.fill(); // small beak between the eyes
if(strike>0.2){ ctx.strokeStyle=B.legCol||'#c9b48c'; ctx.lineWidth=Math.max(1,B.legW*strike); ctx.lineCap='round'; // talons thrust DOWN for the grab
for(const sgn of [-1,1]){ ctx.beginPath(); ctx.moveTo(sgn*s*0.12, s*0.12); ctx.lineTo(sgn*s*0.17, s*(0.28+0.4*strike)); ctx.stroke(); } }
}
// a ROOSTING bird -- the day/night REST verb in the biped body plan (a bird does not lie flat, it settles into a
// fluffed ball, legs tucked into the belly feathers, head turned back with the beak buried over the wing, eye shut).
function drawBirdRoost(ctx, B, sp, dir, det){
const gy=B.leg + B.bodyH*0.42, fluff=1.16, aL=B.bodyL*0.5*fluff, aH=B.bodyH*0.5*fluff, cy=gy-aH*0.92; // sit low, belly near the ground
ctx.save(); ctx.translate(0, gy+3); const shR=B.bodyL*0.5; ctx.scale(1, 4.4/Math.max(1,shR)); // soft wide contact shadow
const sg=ctx.createRadialGradient(0,0,0, 0,0,Math.max(1,shR)); sg.addColorStop(0,'rgba(0,0,0,0.24)'); sg.addColorStop(0.55,'rgba(0,0,0,0.11)'); sg.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=sg; ctx.beginPath(); ctx.arc(0,0,Math.max(1,shR),0,7); ctx.fill(); ctx.restore();
ctx.save(); ctx.translate(0, cy);
ctx.beginPath(); // a rounder, PUFFED body (roosting birds fluff into a ball)
ctx.moveTo(-aL*0.95, aH*0.05);
ctx.bezierCurveTo(-aL*0.95,-aH*1.0, aL*0.6,-aH*1.12, aL*1.0,-aH*0.2);
ctx.bezierCurveTo(aL*1.12, aH*0.5, aL*0.72, aH*1.02, aL*0.1, aH*1.02);
ctx.bezierCurveTo(-aL*0.5, aH*1.06, -aL*0.95, aH*0.7, -aL*0.95, aH*0.05);
ctx.closePath();
const grd=ctx.createLinearGradient(0,-aH,0,aH); grd.addColorStop(0,B.back); grd.addColorStop(0.45,B.col[0]); grd.addColorStop(0.7,B.breast); grd.addColorStop(1,B.belly);
ctx.fillStyle=grd; ctx.fill();
ctx.save(); ctx.clip();
if(det>0.28){ let hi=ctx.createLinearGradient(0,-aH*1.2,0,-aH*0.1); hi.addColorStop(0,'rgba('+LIGHT.rim+',0.3)'); hi.addColorStop(1,'rgba('+LIGHT.rim+',0)'); ctx.fillStyle=hi; ctx.fillRect(-aL*1.3,-aH*1.3,aL*2.6,aH*1.3);
let sh=ctx.createLinearGradient(0,aH*0.2,0,aH*1.2); sh.addColorStop(0,'rgba(20,12,6,0)'); sh.addColorStop(1,'rgba(20,12,6,0.24)'); ctx.fillStyle=sh; ctx.fillRect(-aL*1.3,aH*0.2,aL*2.6,aH*1.2); }
const fr=mulberry(sp.length*613+7); for(let i=0,nf=Math.round(90*det*det);i<nf;i++){ const fx=-aL*0.9+fr()*aL*1.8, fy=-aH*0.9+fr()*aH*1.8; // fluffed feather stipple
ctx.strokeStyle=fr()<0.5?'rgba(255,250,238,'+(0.03+fr()*0.05).toFixed(3)+')':'rgba(26,17,8,'+(0.04+fr()*0.06).toFixed(3)+')'; ctx.lineWidth=0.7; ctx.beginPath(); ctx.moveTo(fx,fy); ctx.lineTo(fx-1.6-fr()*1.4, fy+0.8); ctx.stroke(); }
ctx.restore();
// HEAD turned back onto the shoulders, beak buried in the back/wing feathers -- the sleeping-bird silhouette
const hx=-aL*0.46, hy=-aH*0.5, hr=(B.head||8)*0.66*fluff;
ctx.fillStyle=B.col[0]; ctx.beginPath(); ctx.arc(hx,hy,hr,0,7); ctx.fill();
if(det>0.3){ ctx.fillStyle=B.col[2]; ctx.beginPath(); ctx.moveTo(hx+hr*0.1,hy-hr*0.1); ctx.lineTo(hx+hr*1.15,hy+hr*0.45); ctx.lineTo(hx+hr*0.05,hy+hr*0.45); ctx.closePath(); ctx.fill(); } // beak tucked back-down into the feathers
if(det>0.45){ ctx.strokeStyle='rgba(20,14,8,0.72)'; ctx.lineWidth=Math.max(0.6,hr*0.13); ctx.beginPath(); ctx.arc(hx-hr*0.05,hy-hr*0.02,hr*0.34,0.2,1.25); ctx.stroke(); } // a closed eye (asleep)
ctx.restore();
}
function drawBird(ctx, B, sp, x, y, dir, phase, scale, pose){
pose = pose || {}; // {neck,stand(0..1),peck(0..1 override)}
ctx.save(); ctx.translate(x,y); ctx.scale(dir*scale, scale);
const det = clamp(pose.lod!=null ? pose.lod : (scale-0.26)/0.52, 0, 1); // 0 far silhouette .. 1 full model
if(pose.flying){ drawBirdFlight(ctx, B, sp, det, phase, pose); ctx.restore(); return; } // an owl on the wing -> the gliding form
if((pose.rest||0)>0.55){ drawBirdRoost(ctx, B, sp, dir, det); ctx.restore(); return; } // bedded at night -> the fluffed, head-tucked roost
const st=pose.stand||0;
const gy=B.leg + B.bodyH*0.42, gt=B.gait, ph=phase;
// hop = both feet together and the body lofts; walk = feet alternate, a small bob
const legOff = gt.hop ? [0.03, 0.0] : [0.5, 0.0];
const airborne = (gt.hop && st<1) ? -foot(ph,0,gt.stride,gt.lift,gt.duty)[1]*(1-st) : 0;
const bob = (1-st) * (gt.hop ? -airborne*0.7 : Math.sin(ph*2*Math.PI)*1.4);
// contact shadow shrinks as the bird lofts
ctx.save(); ctx.translate(0, gy+3); const bshR=B.bodyL*0.4*(1-airborne/gt.lift*0.5); ctx.scale(1, 4.4/Math.max(1,bshR)); // soft radial contact shadow; shrinks as the bird lofts
const bshG=ctx.createRadialGradient(0,0,0, 0,0,Math.max(1,bshR));
bshG.addColorStop(0,'rgba(0,0,0,0.24)'); bshG.addColorStop(0.55,'rgba(0,0,0,0.11)'); bshG.addColorStop(1,'rgba(0,0,0,0)');
ctx.fillStyle=bshG; ctx.beginPath(); ctx.arc(0,0,Math.max(1,bshR),0,7); ctx.fill(); ctx.restore();
// hips on the body underside; both legs from near there, slightly staggered
const hipY=B.bodyH*0.30;
const legPairs=[ {hip:[-B.bodyL*0.05, hipY], off:legOff[0], far:true },
{hip:[ B.bodyL*0.03, hipY], off:legOff[1], far:false} ];
function footFor(off, splay){ const f=foot(ph, off, gt.stride, gt.lift, gt.duty);
return [ f[0]*(1-st) + splay*st, gy + f[1]*(1-st) ]; } // st blends the gaited foot toward a planted splay
// FAR leg first (behind body)
ctx.save(); ctx.translate(0,bob);
birdLeg(ctx, legPairs[0].hip, footFor(legPairs[0].off, -B.legW*2.2), B, 0.82);
ctx.restore();
ctx.translate(0,bob);
// ---- BODY: an egg tilted head-up, drawn in a rotated frame ----
ctx.save(); ctx.rotate(B.bodyAng);
const aL=B.bodyL*0.5, aH=B.bodyH*0.5;
ctx.beginPath();
ctx.moveTo(-aL*0.95, aH*0.1); // tail base (narrow rear)
ctx.bezierCurveTo(-aL*0.7,-aH*0.9, aL*0.2,-aH*1.05, aL*0.85,-aH*0.35); // over the back to the upper chest
ctx.bezierCurveTo(aL*1.12, aH*0.15, aL*0.9, aH*0.9, aL*0.35, aH*0.95); // round the full breast
ctx.bezierCurveTo(-aL*0.2, aH*1.05, -aL*0.7, aH*0.7, -aL*0.95, aH*0.1); // belly back to the tail base
ctx.closePath();
const grd=ctx.createLinearGradient(0,-aH,0,aH);
grd.addColorStop(0, B.back); grd.addColorStop(0.45, B.col[0]); grd.addColorStop(0.62, B.breast);
grd.addColorStop(0.85, B.breast); grd.addColorStop(1, B.belly);
ctx.fillStyle=grd; ctx.fill();
ctx.save(); ctx.clip();
if(det>0.28){ // form shading (fine): rim light over the back, shadow under the belly
let hi=ctx.createLinearGradient(0,-aH*1.2,0,-aH*0.1); hi.addColorStop(0,'rgba('+LIGHT.rim+',0.32)'); hi.addColorStop(1,'rgba('+LIGHT.rim+',0)');
ctx.fillStyle=hi; ctx.fillRect(-aL*1.3,-aH*1.3,aL*2.6,aH*1.3);
if(B.sheen){ let ir=ctx.createLinearGradient(0,-aH,0,aH*0.4); // iridescent blue-black sheen (corvid)
ir.addColorStop(0,hexa(B.sheen,0)); ir.addColorStop(0.5,hexa(B.sheen,0.45)); ir.addColorStop(1,hexa(B.sheen,0));
ctx.fillStyle=ir; ctx.fillRect(-aL*1.3,-aH,aL*2.6,aH*1.4); }
let sh=ctx.createLinearGradient(0,aH*0.2,0,aH*1.2); sh.addColorStop(0,'rgba(20,12,6,0)'); sh.addColorStop(1,'rgba(20,12,6,0.22)');
ctx.fillStyle=sh; ctx.fillRect(-aL*1.3,aH*0.2,aL*2.6,aH*1.2);
const sunL=-dir, wg=ctx.createLinearGradient(sunL*aL,0,-sunL*aL,0); // directional low-sun warmth
wg.addColorStop(0,'rgba('+LIGHT.dir+',0.14)'); wg.addColorStop(0.5,'rgba('+LIGHT.dir+',0)');
wg.addColorStop(0.56,'rgba(28,32,44,0)'); wg.addColorStop(1,'rgba(28,32,44,0.13)');
ctx.fillStyle=wg; ctx.fillRect(-aL*1.3,-aH*1.3,aL*2.6,aH*2.6);
}
// fine feather stipple (seed-stable), thins out with distance
const fr=mulberry(sp.length*613 + sp.charCodeAt(0)*53 + 7);
for(let i=0,nf=Math.round(110*det*det);i<nf;i++){ const fx=-aL*0.95+fr()*aL*1.9, fy=-aH*0.95+fr()*aH*1.9;
ctx.strokeStyle = fr()<0.5 ? 'rgba(255,250,238,'+(0.03+fr()*0.05).toFixed(3)+')' : 'rgba(26,17,8,'+(0.04+fr()*0.06).toFixed(3)+')';
ctx.lineWidth=0.7; ctx.beginPath(); ctx.moveTo(fx,fy); ctx.lineTo(fx-1.8-fr()*1.6, fy+0.8); ctx.stroke(); }
if(B.barred && det>0.4){ ctx.strokeStyle=hexa(B.barred,0.5); ctx.lineWidth=1; // cryptic BARRING across the underparts (tawny owl)
for(let i=0;i<8;i++){ const yy=-aH*0.35+i*aH*0.2; ctx.beginPath();
for(let xx=-aL*0.85; xx<aL*0.9; xx+=3){ const yj=yy+Math.sin(xx*0.35+i*1.3)*1.3; xx<=-aL*0.85?ctx.moveTo(xx,yj):ctx.lineTo(xx,yj); } ctx.stroke(); } }
if(B.speckle && det>0.42){ ctx.fillStyle=hexa(B.speckle,0.5); // fine dark SPECKLING on white underparts (barn owl)
const sr2=mulberry(sp.length*211+9);
for(let i=0,ns=Math.round(66*det);i<ns;i++){ const sx2=-aL*0.55+sr2()*aL*1.25, sy2=-aH*0.05+sr2()*aH*1.0;
ctx.beginPath(); ctx.arc(sx2,sy2,0.55+sr2()*0.7,0,7); ctx.fill(); } }
ctx.restore();
ctx.restore(); // un-rotate
// ---- TAIL: fanned feathers projecting back-and-down from the tail base ----
const tb=[B.tailBase[0]*B.bodyL, B.tailBase[1]*B.bodyH];
ctx.save(); ctx.translate(tb[0],tb[1]); ctx.rotate(B.bodyAng);
for(let i=0;i<B.tailN;i++){ const t=(i/(B.tailN-1)-0.5)*B.tailFan;
const ex=-Math.cos(t)*B.tail, ey=Math.sin(t)*B.tail + B.tail*B.tailAng*0.5; // feather tip
const cx=ex*0.5, cy=ey*0.4-2, L=Math.hypot(ex,ey)||1, nx=-ey/L, ny=ex/L; // curve control + perpendicular for width
const w0=0.7, w1=1.9; // a SHAPED rectrix (not a stick): base + mid half-widths, tapering to a point
ctx.fillStyle = i%2? B.col[1] : B.col[2];
ctx.beginPath(); ctx.moveTo(nx*w0, ny*w0);
ctx.quadraticCurveTo(cx+nx*w1, cy+ny*w1, ex, ey); // one edge out to the tip
ctx.quadraticCurveTo(cx-nx*w1, cy-ny*w1, -nx*w0, -ny*w0); // and back the other edge
ctx.closePath(); ctx.fill();
ctx.strokeStyle=hexa(B.col[2],0.5); ctx.lineWidth=0.5; ctx.stroke();
if(det>0.55){ ctx.strokeStyle=hexa(B.back,0.55); ctx.lineWidth=0.4; ctx.beginPath(); ctx.moveTo(0,0); ctx.quadraticCurveTo(cx,cy,ex,ey); ctx.stroke(); } } // shaft, up close
ctx.restore();
// ---- WING: folded, layered feathers -- long primaries fanning back, coverts over the shoulder ----
ctx.save(); ctx.rotate(B.bodyAng);
ctx.lineJoin='round';
const wrist=[-aL*0.16, -aH*0.24]; // the wrist, where the primaries spring from
// base membrane under the feathers
ctx.fillStyle=B.col[2]; ctx.beginPath();
ctx.moveTo(aL*0.48,-aH*0.34); ctx.quadraticCurveTo(-aL*0.2,-aH*0.5, -aL*0.98, aH*0.03);
ctx.quadraticCurveTo(-aL*0.3, aH*0.22, aL*0.44, aH*0.02); ctx.closePath(); ctx.fill();
// PRIMARIES: individual flight feathers (fine); far away the wing stays a simple membrane blob
if(det>0.32) for(let i=0;i<B.prim;i++){ const u=i/(B.prim-1);
const bx=wrist[0]+u*aL*0.10, by=wrist[1]+u*aH*0.20; // feather root along the wrist
const tx=-aL*(0.72+u*0.28), ty=aH*(-0.02+u*0.16); // feather tip, fans back and down
ctx.fillStyle = i%2 ? B.col[1] : B.col[2];
ctx.beginPath(); ctx.moveTo(bx,by);
ctx.quadraticCurveTo((bx+tx)*0.5, (by+ty)*0.5-aH*0.05, tx,ty); // upper edge
ctx.quadraticCurveTo((bx+tx)*0.5+aL*0.03,(by+ty)*0.5+aH*0.05, bx+aL*0.055,by+aH*0.05); // lower edge back
ctx.closePath(); ctx.fill();
ctx.strokeStyle=hexa(B.col[2],0.55); ctx.lineWidth=0.5; ctx.stroke();
if(det>0.62){ ctx.strokeStyle=hexa(B.back,0.5); ctx.lineWidth=0.4; // RACHIS: the central shaft down each primary
ctx.beginPath(); ctx.moveTo(bx,by); ctx.quadraticCurveTo((bx+tx)*0.5,(by+ty)*0.5-aH*0.02, tx,ty); ctx.stroke(); }
}
// SECONDARIES: the shorter inner flight feathers, rooting along the forearm inboard of the primaries (fill the trailing edge)
if(det>0.36){ const elbow=[aL*0.16,-aH*0.26], nS=Math.max(3,B.prim-1);
for(let i=0;i<nS;i++){ const u=i/(nS-1);
const bx=elbow[0]+u*(wrist[0]-elbow[0]), by=elbow[1]+u*(wrist[1]-elbow[1]); // root along the forearm
const tx=-aL*(0.34+u*0.34), ty=aH*(0.02+u*0.13); // shorter tips, back and down
ctx.fillStyle = i%2 ? B.col[0] : B.col[1];
ctx.beginPath(); ctx.moveTo(bx,by);
ctx.quadraticCurveTo((bx+tx)*0.5,(by+ty)*0.5-aH*0.04, tx,ty);
ctx.quadraticCurveTo((bx+tx)*0.5+aL*0.02,(by+ty)*0.5+aH*0.04, bx+aL*0.05,by+aH*0.04);
ctx.closePath(); ctx.fill();
ctx.strokeStyle=hexa(B.col[2],0.45); ctx.lineWidth=0.45; ctx.stroke(); }
}
// SPECULUM: the iridescent wing patch (a mallard's blue), white-bordered, over the inner secondaries
if(B.speculum && det>0.38){ ctx.fillStyle=hexa(B.speculum,0.92);
ctx.beginPath(); ctx.ellipse(-aL*0.12,-aH*0.02, aL*0.2, aH*0.13, -0.08, 0,7); ctx.fill();
ctx.strokeStyle='rgba(244,244,240,0.7)'; ctx.lineWidth=0.9; ctx.stroke(); }
// COVERTS: a rounded, slightly paler group over the shoulder, with a few feather edges
ctx.fillStyle=B.back; ctx.beginPath();
ctx.moveTo(aL*0.48,-aH*0.34); ctx.quadraticCurveTo(-aL*0.02,-aH*0.48, -aL*0.34,-aH*0.08);
ctx.quadraticCurveTo(-aL*0.02,-aH*0.0, aL*0.44,-aH*0.08); ctx.closePath(); ctx.fill();
if(det>0.45){ ctx.strokeStyle=hexa(B.col[2],0.4); ctx.lineWidth=0.6; ctx.lineCap='round';
for(let i=0;i<3;i++){ const v=i/2; ctx.beginPath();
ctx.moveTo(aL*(0.34-v*0.46),-aH*(0.28-v*0.04));
ctx.quadraticCurveTo(-aL*(0.02+v*0.18),-aH*0.18, -aL*(0.1+v*0.24),-aH*0.04); ctx.stroke(); } }
ctx.restore();
// ---- NECK + HEAD + BEAK + EYE ----
const headbob = (1-st)*Math.sin(ph*2*Math.PI)*B.gait.headbob; // a live little head bob, stills when planted
// peck: behaviour can drive a deliberate strike (pose.peck 0..1); else the gait's own forage dip
const peck = (pose.peck!=null) ? pose.peck*(B.gait.peck||1) : (B.gait.peck ? (ph<0.42 ? Math.sin(ph/0.42*Math.PI) : 0)*B.gait.peck : 0);
const neckAng = B.neckAng + (pose.neck||0); // posture can raise the head (alert)
const nb=[B.bodyL*0.30, -B.bodyH*0.40]; // neck base at the upper front of the body
const hd=[nb[0]+Math.cos(neckAng)*B.neck + peck*B.neck*0.32, // head center, dipping down-and-forward to peck
nb[1]+Math.sin(neckAng)*B.neck - headbob + peck*B.neck*0.92];
const ndx=hd[0]-nb[0], ndy=hd[1]-nb[1], nlen=Math.hypot(ndx,ndy)||1; // curve the neck into an S / hunch
const cp=[(nb[0]+hd[0])/2 - ndy/nlen*B.neckCurve*B.neck, (nb[1]+hd[1])/2 + ndx/nlen*B.neckCurve*B.neck];
// tapered neck: sample the curve and offset by a width that shrinks base-to-head, so it thins toward the skull
const nw0=(B.neckW||B.bodyH*0.42)*0.55, nw1=B.head*0.26, npts=[];
for(let i=0;i<=9;i++){ const t=i/9, mt=1-t;
npts.push([mt*mt*nb[0]+2*mt*t*cp[0]+t*t*hd[0], mt*mt*nb[1]+2*mt*t*cp[1]+t*t*hd[1], nw0+(nw1-nw0)*t]); }
ctx.fillStyle=B.back; ctx.beginPath();
const perp=(i)=>{ const a=npts[Math.max(0,i-1)], b=npts[Math.min(npts.length-1,i+1)], dx=b[0]-a[0],dy=b[1]-a[1],dl=Math.hypot(dx,dy)||1; return [-dy/dl, dx/dl]; };
for(let i=0;i<npts.length;i++){ const [px,py]=perp(i), w=npts[i][2]; const X=npts[i][0]+px*w, Y=npts[i][1]+py*w; i?ctx.lineTo(X,Y):ctx.moveTo(X,Y); }
for(let i=npts.length-1;i>=0;i--){ const [px,py]=perp(i), w=npts[i][2]; ctx.lineTo(npts[i][0]-px*w, npts[i][1]-py*w); }
ctx.closePath(); ctx.fill();
// head
ctx.fillStyle=B.headCol||B.back; ctx.beginPath(); ctx.arc(hd[0],hd[1],B.head*0.5,0,7); ctx.fill(); // a keyed headCol (a mallard's iridescent green) else the back colour
if(det>0.4 && !B.facialDisc){
if(B.headSheen){ ctx.fillStyle=hexa(B.headSheen,0.55); ctx.beginPath(); ctx.arc(hd[0]-B.head*0.1,hd[1]-B.head*0.17,B.head*0.3,0,7); ctx.fill(); } // iridescent sheen on the crown
else { ctx.fillStyle=B.cheek; ctx.beginPath(); ctx.arc(hd[0]+B.head*0.14,hd[1]+B.head*0.14,B.head*0.32,0,7); ctx.fill(); } // pale cheek
}
if(B.neckRing && det>0.35){ const rt=0.66, rp=[nb[0]+(hd[0]-nb[0])*rt, nb[1]+(hd[1]-nb[1])*rt]; // a white collar where the head meets the neck (mallard)
ctx.save(); ctx.translate(rp[0],rp[1]); ctx.rotate(Math.atan2(hd[1]-nb[1], hd[0]-nb[0]));
ctx.fillStyle=B.neckRing; ctx.beginPath(); ctx.ellipse(0,0, B.head*0.15, (B.neckW||B.bodyH*0.42)*0.62, 0, 0,7); ctx.fill(); ctx.restore(); }
if(B.crown && det>0.4){ ctx.fillStyle=B.crown; ctx.beginPath(); ctx.ellipse(hd[0]-B.head*0.08, hd[1]-B.head*0.2, B.head*0.42, B.head*0.26, -0.2, 0,7); ctx.fill(); } // a dark CROWN cap (heron)
if(B.plume && det>0.36){ ctx.strokeStyle=B.plume; ctx.lineCap='round'; // long NAPE PLUMES trailing back off the crown (heron)
for(let i=0;i<2;i++){ ctx.lineWidth=1.5-i*0.5; const sx=hd[0]-B.head*0.34, sy=hd[1]-B.head*(0.22-i*0.1);
ctx.beginPath(); ctx.moveTo(sx,sy); ctx.quadraticCurveTo(sx-B.head*0.95, sy-B.head*0.08, sx-B.head*1.8, sy+B.head*(0.18+i*0.16)); ctx.stroke(); } }
if(B.facialDisc){
// ---- OWL face: ear tufts, a rimmed FACIAL DISC, FORWARD-facing paired eyes, a small HOOKED beak.
// Drawn as one group rotated by pose.faceTurn -- the owl's signature head-SWIVEL (it turns its whole face). ----
ctx.save(); ctx.translate(hd[0],hd[1]); ctx.rotate(pose.faceTurn||0); const Hh=B.head;
if(B.earTufts){ ctx.fillStyle=B.col[1]; // two feathered horns off the crown
for(const s of [0.30,-0.12]){ ctx.beginPath(); ctx.moveTo(Hh*(s-0.06),-Hh*0.28);
ctx.quadraticCurveTo(Hh*(s+0.12),-Hh*0.74, Hh*(s+0.22),-Hh*0.34); ctx.closePath(); ctx.fill(); }
if(det>0.5){ ctx.strokeStyle=hexa(B.col[2],0.5); ctx.lineWidth=0.6;
for(const s of [0.30,-0.12]){ ctx.beginPath(); ctx.moveTo(Hh*(s+0.02),-Hh*0.4); ctx.lineTo(Hh*(s+0.13),-Hh*0.64); ctx.stroke(); } } }
const dcx=Hh*0.14, dcy=Hh*0.05, dr=Hh*0.52, heart=(B.discShape==='heart'); // the facial disc: a round tawny ruff, or a barn owl's HEART
const discPath=(rs)=>{ ctx.beginPath();
if(heart){ const w=dr*1.02*rs, h=dr*1.24*rs, cy2=dcy-dr*0.12; // two top lobes tapering to a chin point
ctx.moveTo(dcx, cy2+h*0.6);
ctx.bezierCurveTo(dcx-w, cy2+h*0.06, dcx-w*0.52, cy2-h*0.52, dcx, cy2-h*0.06);
ctx.bezierCurveTo(dcx+w*0.52, cy2-h*0.52, dcx+w, cy2+h*0.06, dcx, cy2+h*0.6); ctx.closePath();
} else ctx.arc(dcx,dcy,dr*rs,0,7); };
ctx.fillStyle=B.facialDisc; discPath(1); ctx.fill();
if(det>0.3){ ctx.strokeStyle=B.discRim||'#5a4c38'; ctx.lineWidth=Math.max(1,Hh*0.05); discPath(0.97); ctx.stroke(); }
if(det>0.5){ ctx.save(); discPath(1); ctx.clip(); ctx.strokeStyle=hexa(B.discRim||'#5a4c38',0.4); ctx.lineWidth=0.6; // radial ruff, clipped to the disc shape
for(let i=0;i<22;i++){ const a=i/22*7; ctx.beginPath(); ctx.moveTo(dcx+Math.cos(a)*dr*0.72,dcy+Math.sin(a)*dr*0.72); ctx.lineTo(dcx+Math.cos(a)*dr*1.12,dcy+Math.sin(a)*dr*1.12); ctx.stroke(); } ctx.restore(); }
if(B.brow && det>0.45){ ctx.strokeStyle=hexa(B.brow,0.6); ctx.lineWidth=Math.max(0.8,Hh*0.035); // pale brow line between the eyes
ctx.beginPath(); ctx.moveTo(dcx,dcy-dr*0.5); ctx.lineTo(dcx,dcy+dr*0.14); ctx.stroke(); }
const eyes = heart ? [[dcx+dr*0.24,dcy-dr*0.02,B.eye*0.98],[dcx-dr*0.20,dcy-dr*0.02,B.eye*0.8]] // barn owl: small, close-set, dark
: [[dcx+dr*0.40,dcy-dr*0.05,B.eye*1.06],[dcx-dr*0.36,dcy-dr*0.04,B.eye*0.82]]; // tawny: big, wide, amber
for(const e of eyes){ const ex=e[0],ey=e[1],er=e[2];
ctx.fillStyle='#0b0805'; ctx.beginPath(); ctx.arc(ex,ey,er*(B.eyeDark?1.08:1.3),0,7); ctx.fill(); // dark socket (tight on a dark-eyed barn owl)
ctx.fillStyle=B.eyeCol||'#e2a726'; ctx.beginPath(); ctx.arc(ex,ey,er,0,7); ctx.fill(); // iris (amber, or near-black for the barn owl)
if(det>0.35 && !B.eyeDark){ ctx.fillStyle='#0a0603'; ctx.beginPath(); ctx.arc(ex,ey,er*0.52,0,7); ctx.fill(); } // a pupil only shows on the light iris
if(LIGHT.night && det>0.3){ // EYESHINE: the eye catches the moon and GLEAMS (tapetum) -- the owl's night stare
const eg=ctx.createRadialGradient(ex,ey,0,ex,ey,er*2.3);
eg.addColorStop(0,'rgba(255,236,188,0.5)'); eg.addColorStop(0.42,'rgba(255,214,140,0.24)'); eg.addColorStop(1,'rgba(255,214,140,0)');
ctx.fillStyle=eg; ctx.beginPath(); ctx.arc(ex,ey,er*2.3,0,7); ctx.fill();
ctx.fillStyle='rgba(255,244,206,0.92)'; ctx.beginPath(); ctx.arc(ex,ey,er*0.5,0,7); ctx.fill(); } // bright reflective core
if(det>0.4){ ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.arc(ex+er*0.3,ey-er*0.34,er*0.24,0,7); ctx.fill(); } } // catchlight
const kx=dcx+dr*0.02, ky=dcy+dr*0.15; // the small down-hooked beak, nested in the disc
ctx.fillStyle=B.beakCol||'#3b3128'; ctx.beginPath();
ctx.moveTo(kx-Hh*0.06,ky); ctx.quadraticCurveTo(kx+Hh*0.10,ky-Hh*0.02, kx+Hh*0.11,ky+Hh*0.06);
ctx.quadraticCurveTo(kx+Hh*0.08,ky+Hh*0.17, kx+Hh*0.02,ky+Hh*0.12);
ctx.quadraticCurveTo(kx-Hh*0.02,ky+Hh*0.06, kx-Hh*0.06,ky); ctx.closePath(); ctx.fill();
ctx.restore();
} else {
// beak: a cone (finch/robin) projecting forward from the head front
const bx=hd[0]+B.head*0.44, by=hd[1]+B.head*0.03;
ctx.fillStyle=B.beakCol||'#d8b24e'; // warm beak by default; the corvid's is dark
if(B.beakType==='bill'){ // a broad flat spatulate DUCK bill
ctx.beginPath(); ctx.moveTo(bx-1, by-B.beakH*0.45);
ctx.quadraticCurveTo(bx+B.beak*0.9, by-B.beakH*0.5, bx+B.beak, by-B.beakH*0.02);
ctx.quadraticCurveTo(bx+B.beak*1.04, by+B.beakH*0.5, bx+B.beak*0.62, by+B.beakH*0.52);
ctx.quadraticCurveTo(bx+B.beak*0.2, by+B.beakH*0.5, bx-1, by+B.beakH*0.45); ctx.closePath(); ctx.fill();
if(det>0.4){ ctx.fillStyle='rgba(20,14,6,0.5)'; ctx.beginPath(); ctx.arc(bx+B.beak*0.96, by+B.beakH*0.02, 0.9,0,7); ctx.fill(); } // the nail at the bill tip
} else if(B.beakType==='hook'){ // a RAPTOR's hooked bill (side view): a deep base, the upper mandible curving down past a shorter lower into a sharp point, with a fleshy CERE at the base
const bh=B.beakH;
if(B.cere){ ctx.fillStyle=B.cere; ctx.beginPath(); ctx.ellipse(bx-B.beak*0.06, by-bh*0.04, B.beak*0.26, bh*0.5, 0,0,7); ctx.fill(); }
ctx.fillStyle=B.beakCol||'#3a3026'; ctx.beginPath();
ctx.moveTo(bx-1, by-bh*0.5);
ctx.quadraticCurveTo(bx+B.beak*0.9, by-bh*0.5, bx+B.beak*1.02, by+bh*0.04); // upper culmen, forward then hooking down
ctx.quadraticCurveTo(bx+B.beak*1.0, by+bh*0.62, bx+B.beak*0.7, by+bh*0.6); // the tip drops below the lower edge
ctx.quadraticCurveTo(bx+B.beak*0.52, by+bh*0.42, bx+B.beak*0.36, by+bh*0.34); // shorter lower mandible
ctx.quadraticCurveTo(bx+B.beak*0.12, by+bh*0.3, bx-1, by+bh*0.42); ctx.closePath(); ctx.fill();
} else { // a cone (finch) or dagger (heron/crow)
ctx.beginPath();
ctx.moveTo(bx, by-B.beakH*0.55);
ctx.lineTo(bx+B.beak, by+B.beakH*0.05); // the tip
ctx.lineTo(bx, by+B.beakH*0.55); ctx.closePath(); ctx.fill();
if(det>0.4){ ctx.strokeStyle='rgba(20,14,6,0.4)'; ctx.lineWidth=0.6; // gape line (fine)
ctx.beginPath(); ctx.moveTo(bx,by); ctx.lineTo(bx+B.beak, by+B.beakH*0.05); ctx.stroke(); }
}
// EYE -- a glossy bird eye: a dark orb, an optional keyed iris + pupil (a heron's pale eye), a bright wet catchlight
const bex=hd[0]+B.head*0.16, bey=hd[1]-B.head*0.05, ber=B.eye;
ctx.fillStyle='#0b0805'; ctx.beginPath(); ctx.arc(bex,bey,ber,0,7); ctx.fill();
if(det>0.4){
if(B.eyeCol){ ctx.fillStyle=B.eyeCol; ctx.beginPath(); ctx.arc(bex,bey,ber*0.82,0,7); ctx.fill();
ctx.fillStyle='#070504'; ctx.beginPath(); ctx.arc(bex,bey,ber*0.5,0,7); ctx.fill(); } // iris + pupil
ctx.fillStyle='rgba(255,255,255,0.9)'; ctx.beginPath(); ctx.arc(bex+ber*0.34, bey-ber*0.34, ber*0.34,0,7); ctx.fill(); } // glossy wet catchlight
}
if(B.malar && det>0.3){ ctx.strokeStyle=hexa(B.malar,0.8); ctx.lineWidth=Math.max(1.5,B.head*0.14); ctx.lineCap='round'; // the falcon MOUSTACHE: a dark bar dropping from below the eye down the cheek
ctx.beginPath(); ctx.moveTo(bex-B.head*0.03, bey+ber*1.2); ctx.quadraticCurveTo(bex+B.head*0.03, bey+B.head*0.24, bex-B.head*0.02, bey+B.head*0.44); ctx.stroke(); }
// NEAR leg (in front of the body)
ctx.translate(0,-bob);
ctx.save(); ctx.translate(0,bob);
birdLeg(ctx, legPairs[1].hip, footFor(legPairs[1].off, B.legW*2.2), B, 1.0);
ctx.restore();
ctx.restore();
}
// SNAKE -- a limbless body plan. The whole body is a tapering ribbon sampled along a spine that
// carries a TRAVELING undulation (phase drives the slither); a viper wedge head with an eye and an
// intermittent forked TONGUE; per-species dorsal markings (adder zigzag, grass-snake collar). LOD:
// a bare undulating spindle when far, resolving markings + eye + tongue + ventral scutes as it nears.
function drawSnake(ctx, S, sp, x, y, dir, phase, scale, pose){
pose = pose || {};
ctx.save(); ctx.translate(x,y); ctx.scale(dir*scale, scale);
const det = clamp(pose.lod!=null ? pose.lod : (scale-0.26)/0.52, 0, 1);
const L=S.L, girth=S.girth, ph=phase, raise=pose.raise||0, coil=pose.coil||0, strike=pose.strike||0;
const N=58, waves=S.waves||2.3, amp=(S.amp||0.14)*L;
const by=-(girth+amp); // baseline: lift the spine so the body's lowest point rests on y=0 (the ground)
// girth along s (tail 0 .. head 1): a body spindle (thin tail, fat front-third) then a viper wedge head
function girthAt(s){
if(s<0.84) return girth*Math.pow(Math.sin(Math.PI*clamp(Math.pow(s/0.92,0.82),0,1)),0.5);
const t=(s-0.84)/0.16, neckW=girth*0.5, cheekW=girth*0.6*(S.headW||1.2), snoutW=girth*0.06;
return t<0.42 ? neckW+(cheekW-neckW)*(t/0.42) : cheekW+(snoutW-cheekW)*((t-0.42)/0.58);
}
// spine points, per-point normal, per-point half-width
const P=[], NM=[], WD=[];
for(let i=0;i<N;i++){
const s=i/(N-1), env=Math.sin(Math.PI*s);
let px=-L*0.5+s*L;
let py=by + env*amp*Math.sin(waves*2*Math.PI*s - ph*2*Math.PI);
if(raise>0 && s>0.68){ const u=(s-0.68)/0.32; py -= raise*girth*5.5*u*u; } // alert: front third rears up
if(coil>0 && s>0.5){ const u=(s-0.5)/0.5; py -= coil*girth*8*u*u; py += coil*girth*3.2*Math.sin(u*Math.PI); } // rear into a striking S (belly-out kink reads as a coil, not a stick)
if(strike>0 && s>0.55){ const u=(s-0.55)/0.45; px += strike*L*0.36*u; py += strike*girth*7*u*u; } // the head LUNGES forward + down
P.push([px,py]); WD.push(girthAt(s));
}
for(let i=0;i<N;i++){ const a=P[Math.max(0,i-1)], b=P[Math.min(N-1,i+1)];
let tx=b[0]-a[0], ty=b[1]-a[1], tl=Math.hypot(tx,ty)||1; NM.push([-ty/tl, tx/tl]); }
// outline edges
const top=[], bot=[];
for(let i=0;i<N;i++){ const p=P[i], nm=NM[i], w=WD[i];
top.push([p[0]+nm[0]*w, p[1]+nm[1]*w]); bot.push([p[0]-nm[0]*w, p[1]-nm[1]*w]); }
function bodyPath(ox,oy){ ctx.beginPath(); ctx.moveTo(top[0][0]+ox, top[0][1]+oy);
for(let i=1;i<N;i++) ctx.lineTo(top[i][0]+ox, top[i][1]+oy);
for(let i=N-1;i>=0;i--) ctx.lineTo(bot[i][0]+ox, bot[i][1]+oy); ctx.closePath(); }
// soft contact shadow (a low, offset copy of the body, on the ground under it)
ctx.save(); ctx.fillStyle='rgba(0,0,0,0.16)'; bodyPath(2, girth*0.75); ctx.fill(); ctx.restore();
// body fill: vertical countershading (dark dorsal -> base -> pale belly)
let minY=1e9,maxY=-1e9; for(const p of top){ if(p[1]<minY)minY=p[1]; } for(const p of bot){ if(p[1]>maxY)maxY=p[1]; }
const g=ctx.createLinearGradient(0,minY,0,maxY);
g.addColorStop(0,S.col[1]); g.addColorStop(0.48,S.col[0]); g.addColorStop(1,S.belly);
bodyPath(0,0); ctx.fillStyle=g; ctx.fill();
ctx.strokeStyle=hexa(S.col[2]||'#333',0.5); ctx.lineWidth=Math.max(0.4,girth*0.05); ctx.lineJoin='round'; bodyPath(0,0); ctx.stroke();
// rim light on the upper edge, resolving in with detail
if(det>0.28){ ctx.strokeStyle=hexa('#ffffff',0.14*det); ctx.lineWidth=Math.max(0.5,girth*0.16); ctx.lineJoin='round'; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(top[0][0],top[0][1]); for(let i=1;i<Math.round(N*0.9);i++) ctx.lineTo(top[i][0],top[i][1]); ctx.stroke(); }
// ADDER zigzag: a sharp dark chevron band down the dorsal midline (clipped to the body)
if(det>0.4 && S.zigzag){ const Z=S.zigzag; ctx.save(); bodyPath(0,0); ctx.clip();
ctx.strokeStyle=hexa(Z.c,Z.a); ctx.lineWidth=Math.max(1,girth*0.5); ctx.lineJoin='round'; ctx.lineCap='round';
ctx.beginPath();
for(let i=0;i<N;i++){ const s=i/(N-1); const tw=2*Math.abs(2*(((Z.n*s)%1))-1)-1; // triangle wave -> sharp zigzag
const p=P[i], nm=NM[i], w=WD[i], off=tw*Z.amp*w;
const xx=p[0]+nm[0]*(off - w*0.12), yy=p[1]+nm[1]*(off - w*0.12);
if(i===0) ctx.moveTo(xx,yy); else ctx.lineTo(xx,yy); }
ctx.stroke(); ctx.restore(); }
// GRASS-SNAKE collar: a bright band + a dark band across the nape (clipped)
if(det>0.38 && S.collar){ const C=S.collar, k=Math.round(0.86*(N-1)), k2=Math.round(0.815*(N-1));
ctx.save(); bodyPath(0,0); ctx.clip(); ctx.lineCap='butt';
ctx.strokeStyle=hexa(C.c,C.a); ctx.lineWidth=Math.max(1,girth*0.55); ctx.beginPath(); ctx.moveTo(top[k][0],top[k][1]); ctx.lineTo(bot[k][0],bot[k][1]); ctx.stroke();
ctx.strokeStyle=hexa(C.c2||'#20220f',C.a); ctx.lineWidth=Math.max(0.6,girth*0.3); ctx.beginPath(); ctx.moveTo(top[k2][0],top[k2][1]); ctx.lineTo(bot[k2][0],bot[k2][1]); ctx.stroke();
ctx.restore(); }
// CORN-SNAKE saddles: dark-edged dorsal blotches spaced evenly down the back (clipped)
if(det>0.4 && S.saddles){ const Sa=S.saddles; ctx.save(); bodyPath(0,0); ctx.clip(); ctx.lineCap='round';
for(let k=0;k<Sa.n;k++){ const s=0.06+(k/(Sa.n-1))*0.76, i=Math.round(s*(N-1)), t=top[i], p=P[i], nm=NM[i], w=WD[i];
const mx=p[0]-nm[0]*w*0.12, my=p[1]-nm[1]*w*0.12; // top edge down to ~mid-body
if(Sa.edge){ ctx.strokeStyle=hexa(Sa.edge,Sa.a); ctx.lineWidth=Math.max(1.4,girth*0.6); ctx.beginPath(); ctx.moveTo(t[0],t[1]); ctx.lineTo(mx,my); ctx.stroke(); }
ctx.strokeStyle=hexa(Sa.c,Sa.a); ctx.lineWidth=Math.max(1,girth*0.44); ctx.beginPath(); ctx.moveTo(t[0],t[1]); ctx.lineTo(mx,my); ctx.stroke(); }
ctx.restore(); }
// ventral SCUTES: faint transverse belly plates along the lower edge (clipped)
if(det>0.52){ ctx.save(); bodyPath(0,0); ctx.clip(); ctx.strokeStyle=hexa(S.belly,0.5); ctx.lineWidth=Math.max(0.4,girth*0.05);
for(let i=3;i<Math.round(N*0.84);i+=2){ const b=bot[i], p=P[i], nm=NM[i]; ctx.beginPath(); ctx.moveTo(b[0],b[1]); ctx.lineTo(p[0]-nm[0]*WD[i]*0.45, p[1]-nm[1]*WD[i]*0.45); ctx.stroke(); }
ctx.restore(); }
// dorsal SCALE stipple: the snake's analog of the mammals' fur / birds' feather stipple -- a fine
// imbricated texture, smooth far and scaled up close (det-gated, dorsal-biased, thinning to the belly)
if(det>0.5){ const frs=mulberry(sp.length*61+7); ctx.save(); bodyPath(0,0); ctx.clip(); ctx.lineCap='round';
const nSc=Math.round(150*det*det);
for(let j=0;j<nSc;j++){ const s=0.02+frs()*0.94, i=Math.round(s*(N-1)), p=P[i], nm=NM[i], w=WD[i];
const off=(frs()-0.5)*1.7*w, sx=p[0]+nm[0]*off, sy=p[1]+nm[1]*off, r=Math.max(0.5,w*0.13);
ctx.strokeStyle = off>-w*0.15 ? hexa(S.col[2]||'#332',0.12) : hexa('#241a10',0.06); // darker dorsal, faint ventral
ctx.lineWidth=Math.max(0.3,girth*0.045);
ctx.beginPath(); ctx.arc(sx, sy, r, 0.15*Math.PI, 0.85*Math.PI); ctx.stroke(); } // an imbricated scale-edge arc
ctx.restore(); }
// ---- head details ----
if(det>0.36){
const ci=Math.round(0.93*(N-1)), cp=P[ci], cn=NM[ci], cw=WD[ci];
const ex=cp[0]+cn[0]*cw*0.34, ey=cp[1]+cn[1]*cw*0.42, er=Math.max(1.1,girth*0.26);
// mouth line along the lower jaw
if(det>0.42){ ctx.strokeStyle=hexa(S.col[2]||'#2a2018',0.55); ctx.lineWidth=Math.max(0.5,girth*0.09); ctx.lineCap='round';
ctx.beginPath(); const a=Math.round(0.855*(N-1)); ctx.moveTo(bot[a][0],bot[a][1]);
for(let i=a+1;i<N;i++) ctx.lineTo(bot[i][0],bot[i][1]); ctx.stroke(); }
ctx.fillStyle=S.eyeCol||'#3a3020'; ctx.beginPath(); ctx.arc(ex,ey,er,0,7); ctx.fill(); // iris
ctx.fillStyle='#0a0805';
if(S.pupil==='vert'){ ctx.save(); ctx.translate(ex,ey); ctx.scale(0.32,1); ctx.beginPath(); ctx.arc(0,0,er*0.8,0,7); ctx.fill(); ctx.restore(); }
else { ctx.beginPath(); ctx.arc(ex,ey,er*0.55,0,7); ctx.fill(); }
if(det>0.5){ ctx.fillStyle='rgba(255,255,255,0.85)'; ctx.beginPath(); ctx.arc(ex-er*0.3,ey-er*0.32,er*0.28,0,7); ctx.fill(); } // catchlight
if(det>0.45){ ctx.fillStyle=hexa(S.col[2]||'#333',0.7); const np=P[Math.round(0.985*(N-1))]; ctx.beginPath(); ctx.arc(np[0],np[1]-cw*0.12,Math.max(0.5,girth*0.07),0,7); ctx.fill(); } // nostril
// forked TONGUE, flicking intermittently (pose.tongue overrides; else a slow clock)
const flick = pose.tongue!=null ? pose.tongue : ((Math.sin((pose.clock||0)*4.3)*0.5+0.5) > 0.8 ? 1 : 0);
if(det>0.44 && flick>0){ const hp=P[N-1], q=P[N-3], t=[hp[0]-q[0],hp[1]-q[1]], tl=Math.hypot(t[0],t[1])||1, ux=t[0]/tl, uy=t[1]/tl;
const len=girth*1.8*flick, base=[hp[0]+ux*girth*0.15, hp[1]+uy*girth*0.15], mid=[base[0]+ux*len*0.55, base[1]+uy*len*0.55];
ctx.strokeStyle='#c23a44'; ctx.lineWidth=Math.max(0.5,girth*0.08); ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(base[0],base[1]); ctx.lineTo(mid[0],mid[1]); ctx.stroke();
const px=-uy, py=ux; for(const s of [-1,1]){ ctx.beginPath(); ctx.moveTo(mid[0],mid[1]); ctx.lineTo(mid[0]+ux*len*0.42+px*s*len*0.3, mid[1]+uy*len*0.42+py*s*len*0.3); ctx.stroke(); } }
}
ctx.restore();
}
return { drawCreature, drawBird, drawSnake, birdLeg, setLight, util: { clamp, ik, foot, mulberry, hexa } };
});