// Original articulated woodland sculptures. Pure offline geometry and pixel art; // no imported assets, filesystem, fonts, random state or engine-side fake motion. import { PALETTE } from './world-art.mjs'; export const FAUNA_NAMES = Object.freeze('hare deer fox rabbit red_squirrel bank_vole wood_mouse hedgehog badger roe_deer stoat weasel otter robin blackbird blue_tit barn_owl mallard common_frog smooth_newt brown_trout bumblebee earthworm'.split(' ')); export const FAUNA_ANIMATIONS = Object.freeze(Object.fromEntries( ['idle','locomotion','feed','flight_swim','rest'].map((id,i)=>[id,Object.freeze([i*20,i*20+19,24])]))); export const FAUNA_MATERIALS = Object.freeze(['coat','belly','face','ear','foot','tail','wing','eye', 'nose','stripe','feather','beak','fin','spot','horn','membrane']); // width, shoulder height, body length, head size; model units, +Z forward. const FORMS = Object.freeze({ hare:[2.5,4.1,5,1.8], deer:[5,10,10,2.7], fox:[3,4.6,7,2], rabbit:[2.8,2.8,3.8,1.7], red_squirrel:[1.5,2.5,3.2,1.2], bank_vole:[1.4,1.1,2.2,.9], wood_mouse:[1.2,1.5,2,.85], hedgehog:[2.2,1.5,3.1,1], badger:[3.8,3,6.6,1.9], roe_deer:[3.5,7.8,7.2,2.1], stoat:[1.2,1.5,4.5,.95], weasel:[.85,1.1,3.1,.7], otter:[2.5,2.3,6.2,1.6], robin:[1.4,2.2,2,1.15], blackbird:[1.6,2.5,2.8,1.2], blue_tit:[1,1.9,1.6,.9], barn_owl:[2.6,3.8,2.5,2], mallard:[2.5,2.8,4,1.4], common_frog:[1.8,1,2.2,1.4], smooth_newt:[.8,.65,2.5,.65], brown_trout:[1.1,1.4,4.8,1], bumblebee:[.8,.7,1.3,.55], earthworm:[.4,.4,3.8,.35], }); const BIRDS = new Set(['robin','blackbird','blue_tit','barn_owl','mallard']); const AQUATIC = new Set(['otter','mallard','common_frog','smooth_newt','brown_trout']); const COLORS = { hare:['fur','cream','barkLight'],deer:['deer','cream','bark'],fox:['fox','cream','barkDark'], rabbit:['earthLight','cream','fur'],red_squirrel:['copper','cream','rust'], bank_vole:['earthLight','strawLight','bark'],wood_mouse:['fur','cream','rose'], hedgehog:['barkDark','fur','wood'],badger:['slate','cream','ink'], roe_deer:['rust','cream','bark'],stoat:['earthLight','cream','ink'],weasel:['barkLight','cream','earth'], otter:['bark','cream','earthDark'],robin:['barkLight','rust','cream'], blackbird:['ink','slate','ochre'],blue_tit:['slateBlue','gold','cream'], barn_owl:['wood','cream','barkLight'],mallard:['earthLight','cream','pine'], common_frog:['leaf','ochre','leafDark'],smooth_newt:['earth','ochre','barkDark'], brown_trout:['willow','cream','rust'],bumblebee:['ochre','ink','cream'],earthworm:['rose','clay','wine'], }; function valid(id) { if(!FAUNA_NAMES.includes(id)) throw new Error('Unknown fauna species'); } const sub=(a,b)=>a.map((v,i)=>v-b[i]); const cross=(a,b)=>[a[1]*b[2]-a[2]*b[1],a[2]*b[0]-a[0]*b[2],a[0]*b[1]-a[1]*b[0]]; const unit=a=>a.map(v=>v/Math.hypot(...a)); const faces=[[0,3,2,1],[4,5,6,7],[0,1,5,4],[3,7,6,2],[0,4,7,3],[1,2,6,5]]; class Rig { bones=[]; vertices=[]; triangles=[]; bone(name,parent,pivot,role='still',phase=0) { const b={name,parent,pivot,role,phase,weights:[]}; this.bones.push(b); return b; } box(b,center,size,material='coat',taper=1) { const [x,y,z]=center, [w,h,l]=size.map(v=>v/2); const corners=[[-w,-h,-l],[w,-h,-l],[w,h,-l],[-w,h,-l],[-w,-h,l],[w,-h,l],[w,h,l],[-w,h,l]] .map(([a,c,d])=>[x+a*(c<0?taper:1),y+c,z+d*(c<0?taper:1)]); const materials=Array.isArray(material)?material:Array(6).fill(material); faces.forEach((f,j)=> { const normal=unit(cross(sub(corners[f[1]],corners[f[0]]),sub(corners[f[2]],corners[f[0]]))); const tile=FAUNA_MATERIALS.indexOf(materials[j]); if(tile<0) throw new Error('Unknown fauna material'); const u=(tile%4)*16,v=Math.floor(tile/4)*16, first=this.vertices.length; f.forEach((index,k)=> { const [a,c]=[[.5,15.5],[15.5,15.5],[15.5,.5],[.5,.5]][k]; this.vertices.push([...corners[index],...normal,(u+a)/64,(v+c)/64]); b.weights.push(first+k); }); this.triangles.push([first,first+1,first+2],[first,first+2,first+3]); }); } } function sculpture(id) { valid(id); const m=new Rig(), [w,h,l,hs]=FORMS[id]; const root=m.bone('Root',null,[0,0,0]); const body=m.bone('Body',root,[0,h,0],'breathe'); const put=(b,c,s,mat='coat',t=1)=>m.box(b,c,s,mat,t); if(id==='earthworm') { let parent=body; for(let i=0;i<12;i++) { const z=-l/2+(i+.5)*l/12, b=m.bone(`Segment_${i}`,parent,[0,h/2,z],'segment',i*.65); put(b,[0,h/2,z],[w*(i===0||i===11?.65:1),h,l/12*1.08],i===7||i===8?'belly':'coat',.85); parent=b; } return m; } if(id==='brown_trout') { put(body,[0,h,0],[w,h*1.6,l*.7],['coat','face','belly','coat','spot','spot'],.55); const head=m.bone('Head',body,[0,h,l*.25],'head'); put(head,[0,h,l*.35],[w*.8,h*1.1,l*.3],'face',.65); for(const s of [-1,1]) put(head,[s*w*.42,h*1.2,l*.41],[.09,.18,.18],'eye'); const tail=m.bone('Tail',body,[0,h,-l*.3],'swim_tail'); put(tail,[0,h,-l*.46],[w*.4,h*.7,l*.3],'coat',.8); const tip=m.bone('Tail_Tip',tail,[0,h,-l*.55],'swim_tail',.8); put(tip,[0,h,-l*.63],[w*.22,h*2,l*.22],'fin',.65); put(body,[0,h*1.85,-l*.07],[w*.24,h*.65,l*.3],'fin',.5); for(const s of [-1,1]) { const fin=m.bone(`Pectoral_${s}`,body,[s*w*.4,h*.8,l*.16],'fin',s); put(fin,[s*w*.65,h*.65,l*.06],[w*.6,h*.2,l*.27],'fin',.5); } return m; } if(id==='bumblebee') { put(body,[0,h,0],[w,h,l],['coat','face','belly','stripe','stripe','stripe'],.8); const head=m.bone('Head',body,[0,h,l*.45],'head'); put(head,[0,h,l*.55],[hs,hs,hs],'face'); for(const s of [-1,1]) { put(head,[s*hs*.4,h+.1,l*.78],[.12,.18,.1],'eye'); put(head,[s*.18,h+.35,l*.64],[.07,.35,.08],'foot'); const wing=m.bone(`Wing_${s}`,body,[s*w*.35,h*1.5,0],'wing',s); put(wing,[s*w,h*1.5,-.05],[w*1.6,.045,l*.65],'membrane',.65); for(let j=0;j<3;j++) { const leg=m.bone(`Leg_${s}_${j}`,body,[s*w*.4,h*.7,(j-1)*.35],'leg',s+j); put(leg,[s*w*.6,h*.25,(j-1)*.35],[.08,h*.5,.1],'foot'); } } return m; } const bird=BIRDS.has(id), frog=id==='common_frog', newt=id==='smooth_newt'; const legHeight=bird?h*.6:h*.75; put(body,[0,h,0],[w,h*(bird?1.05:.65),l],['coat','face','belly','coat','coat','coat'],.78); const neckY=bird?h*1.43:h*1.16, neckZ=l*(bird?.28:.39); const neck=m.bone('Neck',body,[0,h,neckZ],'neck'); if(['deer','roe_deer','mallard'].includes(id)) put(neck,[0,h*1.25,neckZ],[hs*.65,h*.65,hs*.8],'coat',.8); const head=m.bone('Head',neck,[0,neckY,neckZ],'head'); put(head,[0,neckY,neckZ+hs*.3],[hs,hs*(frog?.45:bird?1:.8),hs],bird?['coat','face','belly','coat','face','face']:'face',.85); const muzzle=bird?hs*.45:['fox','badger','hedgehog'].includes(id)?hs*.65:hs*.3; put(head,[0,neckY-hs*.12,neckZ+hs*.8],[hs*(bird?.35:.6),hs*.3,muzzle],bird?'beak':'belly',.6); if(!bird) put(head,[0,neckY-hs*.1,neckZ+hs*.8+muzzle*.51],[hs*.23,hs*.16,.07],'nose'); for(const s of [-1,1]) { put(head,[s*hs*.5,neckY+hs*.13,neckZ+hs*.48],[hs*.08,hs*.16,hs*.16],'eye'); if(bird) continue; if(!frog&&!newt) { const long=id==='hare'?1.5:id==='rabbit'?.95:id==='red_squirrel'?.65:.38; const ear=m.bone(`Ear_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ],'ear',s); put(ear,[s*hs*.38,neckY+hs*(.38+long/2),neckZ],[hs*.28,hs*long,hs*.22],'ear',.65); } if(id==='deer'||id==='roe_deer') { const antler=m.bone(`Antler_${s}`,head,[s*hs*.35,neckY+hs*.3,neckZ]); for(let j=0;j<(id==='deer'?3:2);j++) { put(antler,[s*(hs*.4+j*.38),neckY+hs*(.7+j*.28),neckZ-j*.27],[.22,hs*.7,.22],'horn'); put(antler,[s*(hs*.6+j*.4),neckY+hs*(.6+j*.28),neckZ-.3],[hs*.55,.2,.22],'horn'); } } } if(bird) { for(const s of [-1,1]) { const wing=m.bone(`Wing_${s}`,body,[s*w*.42,h*1.2,l*.15],'wing',s); put(wing,[s*w*.65,h,-l*.08],[w*.48,h*.65,l*.85],'wing',.6); const tip=m.bone(`Wing_Tip_${s}`,wing,[s*w*.82,h*.9,-l*.25],'wing_tip',s); for(let j=0;j<3;j++) put(tip,[s*(w*.88+j*.13),h*.75,-l*(.25+j*.09)], [w*.22,h*.45,l*.35],'feather',.6); } if(id==='barn_owl') { // Pale facial discs separated by a narrow brow; not a recoloured songbird. for(const s of [-1,1]) put(head,[s*hs*.24,neckY,neckZ+hs*.83],[hs*.45,hs*.77,.08],'belly',.75); for(const s of [-1,1]) put(head,[s*hs*.23,neckY+.08,neckZ+hs*.89],[.17,.23,.05],'eye'); } } for(const s of [-1,1]) for(const j of (bird?[1]:[-1,1])) { const x=s*w*.35,z=j*l*.32, phase=s*(j===1?1:-1); const upper=m.bone(`Leg_${s}_${j}`,body,[x,legHeight,z],'leg',phase); const hind=(id==='hare'||id==='rabbit'||frog)&&j===-1; put(upper,[x,legHeight*.72,z],[w*(hind?.4:.18),legHeight*.56,w*.23],'coat',.8); const lower=m.bone(`Shin_${s}_${j}`,upper,[x,legHeight*.45,z],'shin',phase); put(lower,[x,legHeight*.24,z],[w*.14,legHeight*.46,w*.17],'foot'); put(lower,[x,legHeight*.07,z+w*.15],[w*(id==='mallard'?.48:.23),legHeight*.14,w*(hind?.8:.52)],bird?'beak':'foot'); } const tailLength={fox:l*.8,red_squirrel:l*1.05,wood_mouse:l*1.15,bank_vole:l*.3, stoat:l*.55,weasel:l*.4,otter:l*.7,smooth_newt:l*.95}[id] || (bird?l*.65:l*.2); const tail=m.bone('Tail',body,[0,h*.95,-l*.45],AQUATIC.has(id)?'swim_tail':'tail'); const bushy=['fox','red_squirrel'].includes(id), thick=bushy?w*.7:newt?w*.6:w*.3; const squirrel=id==='red_squirrel', tailY=squirrel?h*1.4:h*.95; put(tail,[0,tailY,-l*.5-tailLength*.22],[thick,squirrel?tailLength*.65:thick,tailLength*.58],bird?'feather':'tail',.7); const tip=m.bone('Tail_Tip',tail,[0,tailY,-l*.5-tailLength*.45],AQUATIC.has(id)?'swim_tail':'tail',.8); put(tip,[0,squirrel?h*2:tailY,-l*.5-tailLength*.68],[thick*.65,squirrel?tailLength*.55:thick*.6,tailLength*.47],id==='stoat'||id==='fox'?'stripe':bird?'feather':'tail',.6); if(id==='hedgehog') for(let j=0;j<4;j++) for(const s of [-1,0,1]) put(body,[s*w*.3,h*1.4,-l*.3+j*l*.2],[w*.25,h*.5,l*.2],'stripe',.35); return m; } function rotation(x,y,z) { const [a,b,c]=[x,y,z].map(v=>Math.cos(v/2)),[d,e,f]=[x,y,z].map(v=>Math.sin(v/2)); return [a*b*c+d*e*f,d*b*c-a*e*f,a*e*c+d*b*f,a*b*f-d*e*c]; } function pose(id,b,frame) { const clip=Math.floor(frame/20),t=(frame%20)/19, wave=Math.sin(t*Math.PI*2), swell=1-Math.cos(t*Math.PI*2); let x=0,y=0,z=0,lift=0; const moving=clip===1, swimming=clip===3&&AQUATIC.has(id), flying=clip===3&&(BIRDS.has(id)||id==='bumblebee'); if(b.role==='breathe') lift=FORMS[id][1]*.008*swell; if(b.role==='head') { y=.07*wave; if(clip===2) x=.42+.17*swell; if(clip===4) x=.16; } if(b.role==='neck' && clip===2) x=.3+.13*swell; if(b.role==='ear' && clip===0) z=b.phase*.08*wave; if(b.role==='leg' && (moving||swimming)) x=.6*wave*b.phase*(swimming?.55:1); if(b.role==='shin' && moving) x=.35*Math.max(0,-wave*b.phase); if((b.role==='wing'||b.role==='wing_tip') && flying) { z=b.phase*(b.role==='wing'?1.15:.5)*wave; x=.12*wave; } if(b.role==='tail') { y=(moving?.23:.07)*wave; if(clip===4) y=.25; } if(b.role==='swim_tail') y=(moving||swimming?.42:.06)*Math.sin(t*Math.PI*2+b.phase); if(b.role==='fin') z=.25*wave*b.phase; if(b.role==='segment') { y=(moving?.23:clip===2?.07:.025)*Math.sin(t*Math.PI*2+b.phase); } // Ground animals have an alert/paddle reserve clip, never fictitious wings. if(clip===3&&!swimming&&!flying&&b.role==='head') y=.22*wave; if(clip===4 && b.role==='leg') x=.12*b.phase; return {lift,rotation:rotation(x,y,z)}; } const i32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeInt32LE(n,i*4));return b; }; const f32=(...values)=>{ const b=Buffer.alloc(values.length*4);values.forEach((n,i)=>b.writeFloatLE(n,i*4));return b; }; const str=s=>Buffer.from(s+'\0','ascii'); const chunk=(tag,...parts)=>{const b=Buffer.concat(parts);return Buffer.concat([Buffer.from(tag),i32(b.length),b]);}; export function faunaB3D(id) { const m=sculpture(id); const mesh=chunk('MESH',i32(0),chunk('VRTS',i32(1,1,2),...m.vertices.map(v=>f32(...v))), chunk('TRIS',i32(0),...m.triangles.map(t=>i32(...t)))); function node(b) { const local=b.parent?sub(b.pivot,b.parent.pivot):b.pivot, keys=[]; for(let frame=0;frame<100;frame++) { const p=pose(id,b,frame); // Irrlicht reads file keys 1..100 as runtime frames 0..99. keys.push(i32(frame+1),f32(local[0],local[1]+p.lift,local[2],...p.rotation)); } return chunk('NODE',str(b.name),f32(...local,1,1,1,1,0,0,0), ...(b.parent?[]:[mesh,chunk('ANIM',i32(0,100),f32(24))]), ...(b.weights.length?[chunk('BONE',...b.weights.map(v=>Buffer.concat([i32(v),f32(1)])))]:[]), chunk('KEYS',i32(5),...keys),...m.bones.filter(c=>c.parent===b).map(node)); } return chunk('BB3D',i32(1),chunk('TEXS',str(`hearth_fauna_${id}.png`),i32(1,2),f32(0,0,1,1,0)), chunk('BRUS',i32(1),str('Original fauna semantic atlas'),f32(1,1,1,1,0),i32(1,0,0)),node(m.bones[0])); } export function faunaBounds(id) { const {vertices}=sculpture(id); return {min:[0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10), max:[0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)}; } export function faunaTexture(id) { valid(id); const data=new Uint8Array(64*64*4), [coat,belly,accent]=COLORS[id]; const bases=[coat,belly,coat,'rose',accent,coat,coat,'ink','barkDark',accent,accent,'ochre',accent,coat,'woodLight','moon']; for(let tile=0;tile<16;tile++) for(let y=0;y<16;y++) for(let x=0;x<16;x++) { const mat=FAUNA_MATERIALS[tile]; let color=bases[tile]; // Broad directional clusters, never hash noise. Material-specific landmarks. if(y===3 && x>2 && x<13 && !['eye','nose','membrane'].includes(mat)) color=belly; if(mat==='coat' && y>10 && x%5<2) color=accent; if(mat==='face') { if(id==='badger') color=x<4||x>11?'ink':'cream'; else if(id==='mallard') color=y>11?'cream':'pine'; else if(id==='blue_tit') color=y<5?'slateBlue':x<3||x>12?'ink':'cream'; else if(id==='barn_owl') color=x<2||x>13?'wood':'cream'; else if(id==='robin') color=y>5?'rust':coat; else if(y>9) color=belly; } if(mat==='ear') color=x<3||x>12?coat:y>4?'rose':belly; if(mat==='tail'&&id==='red_squirrel' && (x+y)%7<2) color='copper'; if(mat==='stripe'&&id==='bumblebee') color=y%8<4?'ink':'ochre'; if(mat==='stripe'&&id==='fox') color='cream'; if(mat==='stripe'&&id==='stoat') color='ink'; if(mat==='stripe'&&id==='hedgehog') color=x%4<2?'wood':'barkDark'; if(mat==='wing'||mat==='feather'||mat==='fin') { if(x%4===0&&y>5) color=belly; if(id==='mallard'&&mat==='wing'&&y>5&&y<11) color=x<3||x>12?'cream':'slateBlue'; if(id==='blue_tit'&&mat==='wing') color=y%6===0?'cream':'slateBlue'; } if(mat==='spot'&&((x>=3&&x<=5&&y>=4&&y<=6)||(x>=10&&x<=12&&y>=11&&y<=13))) color='rust'; if(mat==='eye'&&x>=3&&x<6&&y>=2&&y<5) color='cream'; if(mat==='membrane') color=x%6===0||y===8?'slateBlue':'moon'; data.set([...PALETTE[color],255],((Math.floor(tile/4)*16+y)*64+(tile%4)*16+x)*4); } return {width:64,height:64,data}; } export const FAUNA_ART_MANIFEST = Object.freeze(FAUNA_NAMES.map(id=>Object.freeze({ id,mesh:`hearth_fauna_${id}.b3d`,texture:`hearth_fauna_${id}.png`,width:64,height:64, unitsPerNode:10,visualSize:1,bounds:Object.freeze(Object.fromEntries(Object.entries(faunaBounds(id)).map(([k,v])=>[k,Object.freeze(v)]))), posture:BIRDS.has(id)?'perched; articulated folded wings':id==='brown_trout'?'horizontal swimming': id==='bumblebee'?'six-legged; wings at thorax':id==='earthworm'?'grounded segmented body':'grounded quadruped', clips:FAUNA_ANIMATIONS,license:'MIT',author:'The Last Hearth original procedural art', }))); function rotate(q,v) { const a=q.slice(1), t=cross(a,v).map(n=>n*2),c=cross(a,t); return v.map((n,i)=>n+q[0]*t[i]+c[i]); } // Actual skinned mesh portrait, CPU triangle rasterisation, no font/engine. Each // row-major 128px cell fits that animal's posed bounds, not a scale comparison. export function faunaPortraitSheet(frame=9) { if(!Number.isInteger(frame)||frame<0||frame>99) throw new Error('Unknown fauna frame'); const width=512,height=768,data=new Uint8Array(width*height*4); for(let y=0;y { const m=sculpture(id),atlas=faunaTexture(id), posed=[]; // Apply each ancestor's local rotation around its bind pivot, leaf to root. for(const b of m.bones) for(const vi of b.weights) { let v=m.vertices[vi].slice(0,3), n=m.vertices[vi].slice(3,6); for(let p=b;p;p=p.parent) { const {rotation:q,lift}=pose(id,p,frame); v=rotate(q,sub(v,p.pivot)).map((a,i)=>a+p.pivot[i]+(i===1?lift:0)); n=rotate(q,n); } posed[vi]={v,n}; } // Front three-quarter view: +Z muzzle toward the camera. Use a matching // depth basis, otherwise a perfectly valid depth buffer shows only backs. const project=([x,y,z])=>[x*.8-z*.6,x*.18-y*.954+z*.24,x*.572+y*.3+z*.763]; const ps=posed.map(p=>project(p.v)), bounds=[0,1].map(i=>[Math.min(...ps.map(p=>p[i])),Math.max(...ps.map(p=>p[i]))]); const scale=104/Math.max(...bounds.map(([a,b])=>b-a)), ox=index%4*128+64,oy=Math.floor(index/4)*128+64; const points=ps.map(p=>[(p[0]-(bounds[0][0]+bounds[0][1])/2)*scale+ox,(p[1]-(bounds[1][0]+bounds[1][1])/2)*scale+oy,p[2]]); const depth=new Float64Array(128*128).fill(-Infinity); for(const ids of m.triangles) { const [a,b,c]=ids.map(i=>points[i]), area=(b[0]-a[0])*(c[1]-a[1])-(b[1]-a[1])*(c[0]-a[0]); if(Math.abs(area)<1e-8) continue; const normal=posed[ids[0]].n, light=.68+.32*Math.max(0,normal[0]*-.3+normal[1]*.8+normal[2]*.5); const x0=Math.max(ox-64,Math.floor(Math.min(a[0],b[0],c[0]))),x1=Math.min(ox+63,Math.ceil(Math.max(a[0],b[0],c[0]))); const y0=Math.max(oy-64,Math.floor(Math.min(a[1],b[1],c[1]))),y1=Math.min(oy+63,Math.ceil(Math.max(a[1],b[1],c[1]))); for(let y=y0;y<=y1;y++) for(let x=x0;x<=x1;x++) { const u=((b[0]-x-.5)*(c[1]-y-.5)-(b[1]-y-.5)*(c[0]-x-.5))/area; const v=((c[0]-x-.5)*(a[1]-y-.5)-(c[1]-y-.5)*(a[0]-x-.5))/area,w=1-u-v; if(Math.min(u,v,w)<-1e-7) continue; const z=u*a[2]+v*b[2]+w*c[2],local=(y-(oy-64))*128+x-(ox-64); if(zids.reduce((sum,id,j)=>sum+m.vertices[id][k]*[u,v,w][j],0)); const tex=(Math.min(63,Math.floor(uv[1]*64))*64+Math.min(63,Math.floor(uv[0]*64)))*4; data.set([0,1,2].map(k=>Math.round(atlas.data[tex+k]*light)).concat(255),(y*width+x)*4); } } }); return {width,height,data}; }