Files
last-hearth/scripts/world-art.mjs
T
2026-11-03 12:41:09 -05:00

1526 lines
80 KiB
JavaScript
Executable File

// Original pixel drawings: moonlit botanical field journal. No random/hash noise,
// external art, fonts, network access, or file-system writes in this module.
export const SIZE = 32;
export const PALETTE = Object.freeze(Object.fromEntries(Object.entries({
ink: '#1e1e2e', deep: '#181825', outline: '#313244', slate: '#45475a',
stone: '#747782', stoneLight: '#9b9b9c', moon: '#cdd6f4', mauve: '#cba6f7',
teal: '#94e2d5', gold: '#f9e2af', peach: '#fab387', rose: '#b67f96',
earthDark: '#443a38', earth: '#665044', earthLight: '#876957', clay: '#ad7e68',
barkDark: '#433b36', bark: '#685346', barkLight: '#92765a', wood: '#ad916b',
woodLight: '#c6ad83', strawDark: '#766848', straw: '#a39061', strawLight: '#c6b581',
leafDark: '#384c43', leaf: '#556e55', sage: '#7d9570', leafLight: '#a4b28b',
springDark: '#526b50', springLeaf: '#8fa67c', springLight: '#bac59a', bud: '#c19caa',
autumnDark: '#64454c', copper: '#a26c49', ochre: '#b59458', wine: '#845365',
waterDark: '#334952', water: '#425e66', waterLight: '#638187', glass: '#78959a',
berry: '#92627f', berryLight: '#c393aa', rust: '#9a7660', iron: '#909f9f',
fur: '#92816d', cream: '#d1c5a5', fox: '#b3815d', deer: '#93755a',
mineral: '#777980', mineralShade: '#696c73', mineralGlow: '#898b90', feldspar: '#a79792',
basalt: '#4d5358', basaltShade: '#42494e', basaltGlow: '#60686b',
limestone: '#a19c88', limeShade: '#8e8a79', limeGlow: '#b4af99',
slateBlue: '#697681', slateShade: '#58636e', slateGlow: '#82909a',
sand: '#b1a181', sandShade: '#a39476', sandGlow: '#c0b292',
loam: '#655347', loamShade: '#57473e', loamGlow: '#766052', humus: '#49413a',
turf: '#637653', turfShade: '#536649', turfGlow: '#778764',
birch: '#bbb8a6', birchShade: '#9e9d8e', birchGlow: '#d0ccba',
pine: '#405c51', pineShade: '#344b44', pineGlow: '#648074',
willow: '#81907a', willowShade: '#5e7567', willowGlow: '#a2ad8b',
snow: '#bfcbd1', snowShade: '#acbbc4', snowGlow: '#d5dfe0',
copperMetal: '#ba8560', copperShade: '#8c634d', copperGlow: '#d5a77d', verdigris: '#638d7b',
}).map(([name, hex]) => [name, Object.freeze([1,3,5].map(i => parseInt(hex.slice(i,i+2),16)))])));
const rgba = (color, alpha = 255) => [...(PALETTE[color] || color), alpha];
class Drawing {
constructor(background) {
this.width = SIZE; this.height = SIZE; this.data = new Uint8Array(SIZE*SIZE*4);
if (background) this.rect(0,0,32,32,background);
}
dot(x,y,color,alpha=255) {
x = Math.round(x); y = Math.round(y);
if (x>=0 && y>=0 && x<32 && y<32) this.data.set(rgba(color,alpha),(y*32+x)*4);
return this;
}
rect(x,y,w,h,color,alpha=255) {
for (let j=y;j<y+h;j++) for(let i=x;i<x+w;i++) this.dot(i,j,color,alpha);
return this;
}
line(x0,y0,x1,y1,color,width=1) {
const steps = Math.max(Math.abs(x1-x0),Math.abs(y1-y0),1);
for(let i=0;i<=steps;i++) this.rect(Math.round(x0+(x1-x0)*i/steps),Math.round(y0+(y1-y0)*i/steps),width,width,color);
return this;
}
poly(points,color) {
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
let hit=false;
for(let i=0,j=points.length-1;i<points.length;j=i++) {
const [a,b]=points[i], [c,d]=points[j];
if((b>y+.5)!==(d>y+.5) && x+.5<(c-a)*(y+.5-b)/(d-b)+a) hit=!hit;
}
if(hit) this.dot(x,y,color);
}
return this;
}
oval(cx,cy,rx,ry,color) {
for(let y=Math.floor(cy-ry);y<=cy+ry;y++) for(let x=Math.floor(cx-rx);x<=cx+rx;x++)
if(((x-cx)/rx)**2+((y-cy)/ry)**2<=1) this.dot(x,y,color);
return this;
}
}
// Authored clustered marks wrap as whole shapes, not individual random pixels.
function pebbles(d, dark='earthDark', light='earthLight') {
for(const [x,y,w] of [[3,5,4],[19,3,3],[11,14,5],[25,20,4],[2,27,3],[19,30,4]]) {
d.rect(x,y,w,2,dark).rect(x+1,y,w-1,1,light);
}
}
function earth(d) {
d.rect(0,0,32,32,'loam');
// Discontinuous lenses of compacted aggregates, never full-width cartoon bands.
for(const [x,y,w,h] of [[2,2,6,3],[15,0,5,2],[24,5,7,3],[8,9,8,2],[19,12,5,3],
[0,17,5,2],[10,20,7,3],[23,23,8,2],[3,28,8,3],[18,30,4,2]])
d.oval(x,y,w/2,h,'loamShade');
for(const [i,[x,y]] of GRAINS.entries()) {
d.rect(x,y,i%3===0?3:2,i%4===0?2:1,i%3===0?'loamGlow':'loamShade');
if(i%5===0) d.dot(x+1,y-1,'earthLight');
}
d.line(6,14,9,16,'humus').line(23,28,25,30,'humus');
}
function grass(d,side=false) {
if(side) {
earth(d);
for(let x=0;x<32;x++) {
const depth=Math.round(5+1.6*Math.sin(x*.43)+Math.sin(x*.91));
d.rect(x,0,1,depth,'turfShade');
if(x%3!==0) d.line(x,depth-1,x-1,depth+2+(x%4),'humus');
if(x%5===1) d.line(x,depth+2,x+2,depth+6,'loamGlow');
}
for(const [i,[x,y]] of GRAINS.entries()) if(y<8)
d.rect(x,y/2,2,1,i%2?'turf':'turfGlow');
return;
}
d.rect(0,0,32,32,'turf');
for(const [i,[x,y]] of GRAINS.entries()) {
d.poly([[x,y],[x+3,y-1],[x+4,y+1],[x+1,y+2]],i%3?'turfShade':'leaf');
d.rect(x+1,y-1,2,1,'turfGlow');
const u=(x+13)%32,v=(y+7)%32;
d.line(u,v,u+(i%2?1:-1),v+2,i%4?'turfShade':'sage');
}
}
const GRAINS = [[1,1],[9,3],[18,2],[27,1],[4,6],[14,7],[23,5],[30,8],[1,11],[8,13],
[17,11],[26,12],[4,17],[12,16],[22,18],[29,16],[0,22],[8,24],[17,22],[25,24],
[4,28],[13,30],[21,28],[29,30],[6,2],[20,8],[15,26],[30,21],[10,19],[2,31],
[24,14],[11,10],[18,17],[5,21],[27,27],[15,5]];
function mineral(d,name='stone') {
const colors={stone:['mineral','mineralShade','mineralGlow'],granite:['mineral','mineralShade','feldspar'],
basalt:['basalt','basaltShade','basaltGlow'],limestone:['limestone','limeShade','limeGlow'],
slate:['slateBlue','slateShade','slateGlow'],sandstone:['sand','sandShade','sandGlow']}[name];
const [base,dark,light]=colors; d.rect(0,0,32,32,base);
if(name==='slate' || name==='sandstone') {
for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0) {
const length=name==='slate'?7:5, slope=name==='slate'?-2:1;
d.line(x,y,x+length,y+slope,dark).line(x+1,y-1,x+length-2,y+slope-1,light);
}
if(name==='slate') d.line(3,29,12,24,dark).line(12,24,16,25,dark);
if(name==='sandstone') GRAINS.forEach(([x,y],i)=> {
d.rect((x+11)%31,y,2,1,i%3?dark:light);
if(i%4===0) d.dot((x+14)%31,y+1,light);
});
} else {
// Unjointed microfacets sit inside continuous parent rock, not grout cells.
GRAINS.forEach(([x,y],i)=> {
const w=name==='granite'?2:3+(i%3), h=name==='basalt'?2:3;
d.poly([[x,y],[x+w-1,y-1],[x+w,y+1],[x+1,y+h]],i%3?dark:light);
if(i%4===0) d.line(x,y,x+1,y-1,light);
});
if(name==='limestone') {
d.line(2,20,7,18,dark).line(7,18,10,19,dark);
d.line(25,2,24,7,dark).line(24,7,27,10,dark);
d.oval(19,25,3,2,'limeShade').line(17,24,20,24,'limeGlow');
}
if(name==='granite') for(const [x,y] of [[6,4],[22,10],[12,27],[29,19]])
d.rect(x,y,2,2,'stoneLight').dot(x+2,y+2,'slate');
if(name==='basalt') for(const [x,y] of [[8,7],[23,19],[14,28]])
d.oval(x,y,2,1,'basaltShade').dot(x-1,y-1,'basaltGlow');
}
}
function masonry(d,foundation=false) {
d.rect(0,0,32,32,'outline');
for(let row=0;row<3;row++) {
const y=row*11-1, shift=row%2 ? -7 : 0;
for(let x=shift;x<32;x+=16) {
d.poly([[x+2,y+1],[x+13,y+1],[x+15,y+4],[x+14,y+10],[x+1,y+10],[x,y+6]],foundation?'earthLight':'stone');
d.line(x+3,y+2,x+11,y+2,foundation?'barkLight':'stoneLight');
d.line(x+2,y+9,x+12,y+9,'slate');
}
}
if(!foundation) d.line(8,4,10,7,'slate').line(10,7,9,10,'slate');
}
function planks(d,vertical=false,species='oak') {
const [base,dark,light]={oak:['wood','barkLight','woodLight'],birch:['birch','birchShade','birchGlow'],
pine:['straw','strawDark','strawLight'],willow:['willow','willowShade','willowGlow'],
alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'],
rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'],
spruce:['strawLight','wood','cream']}[species];
const put=(x,y,c)=>d.dot(vertical?y:x,vertical?x:y,c);
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const board=Math.floor(y/8), grain=Math.sin(x*.31+board*2+y*1.6)+.45*Math.sin(x*.73-y);
put(x,y,y%8===0?dark:(grain>1.05?light:base));
}
for(const [x,y] of [[11,1],[25,17]]) for(let j=0;j<6;j++) put(x,y+j,dark);
for(const [x,y] of [[3,4],[22,20]]) { put(x,y,dark); put(x+1,y,light); }
}
function bark(d,species='oak') {
const [base,dark,light]={oak:['bark','barkDark','barkLight'],birch:['birch','birchShade','birchGlow'],
pine:['barkLight','bark','wood'],willow:['humus','barkDark','loamGlow'],
alder:['loamShade','humus','earthLight'],beech:['mineralShade','slateShade','mineralGlow'],
aspen:['birchShade','willowShade','birchGlow'],rowan:['earthLight','bark','birchShade'],
hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust']}[species];
d.rect(0,0,32,32,base);
if(['beech','aspen','rowan','hazel','spruce'].includes(species)) {
// Smooth beech, diamond-scarred aspen, lenticellate rowan/hazel and scaly spruce.
for(const [i,[x,y]] of GRAINS.entries()) {
if(species==='beech') {
if(i%3===0) d.line(x,y,x+1,y+5,dark).line(x+2,y+1,x+2,y+4,light);
} else if(species==='aspen') {
if(i%3===0) d.poly([[x,y-2],[x+3,y],[x,y+2],[x-2,y]],dark).dot(x,y-1,light);
} else if(species==='spruce') {
d.poly([[x,y-2],[x+3,y-1],[x+2,y+3],[x-1,y+2]],dark).line(x,y-1,x+2,y-1,light);
} else if(i%2===0) d.line(x,y,x+(species==='hazel'?4:2),y,dark).dot(x+1,y-1,light);
}
if(species==='beech') d.oval(20,18,3,5,dark).oval(20,17,2,3,base).line(18,14,21,12,light);
return;
}
if(species==='birch') {
for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0)
d.line(x,y,x+2+i%4,y,i%3?dark:'barkDark').line(x+1,y-1,x+2,y-1,light);
d.poly([[24,0],[27,0],[26,9],[24,12],[25,4]],dark);
d.line(25,2,25,7,'bark'); return;
}
const fissures=species==='pine'?[[2,1],[12,4],[23,-2],[30,3]]:
species==='willow'?[[3,0],[9,2],[16,-3],[22,1],[29,5]]:[[1,0],[8,4],[18,-3],[27,1]];
for(const [i,[x,phase]] of fissures.entries()) for(let y=0;y<32;y++) {
const u=x+Math.round(Math.sin((y+phase)*.24+i)*1.5+Math.sin(y*.57)*.5);
if((y+phase+32)%13<11) d.dot(u,y,dark).dot(u+1,y,dark);
if((y+i)%9<6) d.dot(u+2,y,light);
if(y===11+i*3) d.line(u,y,u-2,y-4,dark);
}
if(species==='pine') for(const [x,y] of [[6,9],[17,21],[29,4]])
d.line(x,y,x+4,y-1,'bark').line(x+1,y-2,x+3,y-2,'wood');
else d.oval(14,19,2,4,dark).line(14,16,14,20,light).dot(15,21,base);
}
function endgrain(d,species='oak') {
const [base,ring,light]={oak:['wood','barkLight','woodLight'],birch:['birch','birchShade','birchGlow'],
pine:['straw','barkLight','strawLight'],willow:['willow','willowShade','willowGlow'],
alder:['clay','barkLight','wood'],beech:['woodLight','clay','cream'],aspen:['birchGlow','wood','cream'],
rowan:['clay','earthLight','woodLight'],hazel:['wood','earthLight','strawLight'],
spruce:['strawLight','wood','cream']}[species];
const phase=SPECIES_NAMES.indexOf(species)*.7;
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const a=Math.atan2(y-16,x-14), r=Math.hypot((x-14)*1.01,(y-16)*.96);
const warped=r+.6*Math.sin(a*3+phase)+.35*Math.sin(a*5+r*.4);
const band=(warped+32)%3.7;
d.dot(x,y,r>14.3+.5*Math.sin(a*7)?'barkDark':r>13.1?'bark':band<.7?ring:band<1.3?light:base);
}
d.line(14,16,17,22,ring).line(17,22,21,28,ring);
d.line(4,8,7,10,'bark').line(7,10,9,13,ring);
}
function straw(d) {
d.rect(0,0,32,32,'strawDark');
for(let y=-8;y<32;y+=8) for(let x=-4;x<36;x+=6) {
d.poly([[x,y],[x+5,y],[x+8,y+8],[x+2,y+8]],'straw');
d.line(x+1,y+1,x+4,y+6,'strawLight').line(x+5,y+3,x+6,y+6,'strawDark');
}
d.line(0,15,31,15,'bark').line(0,16,31,16,'wood');
}
function leaf(d,x,y,flip=false,color='sage') {
const s=flip?-1:1;
d.poly([[x,y],[x+s*7,y-4],[x+s*9,y-3],[x+s*7,y],[x+s*2,y+2]],'leafDark');
d.line(x+s*2,y,x+s*7,y-2,color);
}
function twig(d,x=16,y=29,height=21) {
d.line(x,y,x-1,y-height,'bark',2).line(x,y-9,x-7,y-15,'bark');
d.line(x,y-14,x+7,y-19,'barkLight');
}
function seasonalPlant(d,name) {
if(name.startsWith('leaves_')) {
if(name==='leaves_winter') {
// Open, interlacing woody forks: no green fill or snow-painted foliage.
for(const [x,y,s] of [[5,27,1],[19,20,-1],[25,30,1]]) {
d.line(x,y,x+s*3,y-17,'bark').line(x+s*2,y-9,x-s*5,y-15,'barkLight');
d.line(x+s*3,y-17,x+s*7,y-22,'barkDark');
d.line(x-s*2,y-12,x-s*3,y-19,'bark');
}
return;
}
const spring=name==='leaves_spring';
for(const [x,y] of [[4,7],[18,4],[28,10],[10,19],[24,24],[0,28]]) {
d.oval(x,y,6,4,spring?'springDark':'autumnDark')
.oval(x-1,y-1,5,3,spring?'springLeaf':'copper');
d.line(x-2,y,x+3,y-2,spring?'springLight':'ochre');
if(spring) d.rect(x+3,y-4,2,2,'bud').dot(x+3,y-4,'cream');
else d.line(x-3,y+1,x,y+2,'wine');
}
return;
}
if(name==='spring_greens' || name==='spring_greens_dormant') {
const dormant=name.endsWith('_dormant');
d.line(16,30,15,20,dormant?'bark':'springDark');
for(const [x,y,tipX,tipY] of [[14,26,4,17],[16,23,11,8],[17,25,26,13],[15,28,25,24]]) {
// Broad, uneven basal blades with a central vein, not grass needles.
const ty=dormant?tipY+9:tipY;
d.poly([[x,y],[tipX-3,ty+2],[tipX,ty],[tipX+3,ty+3],[x+2,y]],dormant?'strawDark':'springDark');
d.line(x,y,tipX,ty+2,dormant?'straw':'springLeaf');
if(!dormant) d.line(x,y-1,tipX,ty+3,'springLight');
}
return;
}
if(name==='autumn_mushrooms' || name==='autumn_mushrooms_dormant') {
const dormant=name.endsWith('_dormant');
if(dormant) {
// Occupied litter/mycelial trace, conspicuously without harvestable caps.
d.line(5,28,24,25,'earthDark',2).line(9,27,16,24,'barkLight');
d.line(16,26,21,29,'strawDark').line(18,26,26,23,'bark');
d.line(10,28,7,25,'wood').line(22,25,24,28,'earthLight');
} else for(const [x,y,r] of [[10,18,6],[23,23,5],[17,12,4]]) {
d.rect(x-1,y,2,28-y,'cream').line(x+1,y+1,x+1,27,'barkLight');
d.oval(x,y,r,2,'autumnDark');
d.poly([[x-r,y-1],[x-r+2,y-4],[x,y-5],[x+r-1,y-3],[x+r,y-1]],'copper');
d.line(x-r+2,y-2,x,y-4,'ochre').line(x-2,y+1,x+2,y+1,'woodLight');
}
return;
}
if(name==='fern_dormant') {
for(const [x,y,s] of [[5,23,1],[26,22,-1],[10,17,1]]) {
d.line(16,30,x,y,'strawDark').line(x,y,x+s*2,y-4,'barkLight');
for(let j=0;j<3;j++) d.line(x+s*j,y+j,x+s*(j+3),y+j-2,'straw');
}
return;
}
for(const [x,top,bend] of (name==='wildflowers_dormant'?[[8,15,-3],[17,12,2],[24,20,3]]:
[[5,20,-2],[11,12,-4],[17,17,2],[23,14,4],[27,23,2]])) {
d.line(x,30,x+bend,top,'strawDark').line(x,27,x-3,22,'barkLight');
if(name==='wildflowers_dormant') {
d.line(x+bend,top,x+bend-2,top+2,'bark');
d.dot(x+bend,top,'earthLight'); // Spent seed head, never a bloom.
} else d.line(x,29,x+bend+2,top+3,'straw');
}
}
function plant(d,name) {
if(SEASONAL_PLANT_NAMES.includes(name)) { seasonalPlant(d,name); return; }
if(name==='leaves') {
for(const [x,y] of [[4,7],[18,4],[28,10],[10,19],[24,24],[0,28]]) {
d.oval(x,y,6,4,'leafDark').oval(x-1,y-1,5,3,'leaf'); leaf(d,x-3,y,false);
}
return;
}
if(name==='berry_bush' || name==='berry_bush_bare') {
twig(d); d.line(16,26,24,17,'bark',2);
for(const [x,y] of [[8,18],[10,10],[21,9],[25,18],[16,21]]) {
d.oval(x,y,5,4,'leafDark').oval(x-1,y-1,4,3,'leaf'); leaf(d,x,y,true);
}
if(name==='berry_bush') for(const [x,y] of [[7,16],[19,8],[24,18],[14,22]])
d.oval(x,y,2,2,'berry').dot(x-1,y-1,'berryLight');
return;
}
if(['sapling','tree_seedling','young_tree','mature_tree'].includes(name)) {
const mature=name==='mature_tree', young=name==='young_tree', seedling=name==='tree_seedling';
if(seedling) {
d.line(16,30,15,20,'barkLight'); leaf(d,15,23,true); leaf(d,15,21,false,'leafLight');
return;
}
twig(d,16,30,mature?24:young?22:14);
if(mature || young) for(const [x,y] of [[10,10],[22,11],[16,6],[7,17],[24,18]])
d.oval(x,y,mature?7:5,5,'leafDark').oval(x-1,y-1,mature?6:4,3,'leaf');
for(const [x,y,f] of [[14,20,true],[16,14,false],[14,11,true]]) leaf(d,x,y,f);
return;
}
if(name==='fern') {
for(const direction of [-1,1]) for(let i=0;i<4;i++) {
const x=16+direction*(i*3), y=26-i*5;
d.line(16,29,x,y-4,'sage');
for(let j=0;j<3;j++) {
d.line(x,y-j*2,x+direction*4,y-j*2-3,'leaf');
d.line(x,y-j*2,x-direction*2,y-j*2-2,'sage');
}
}
return;
}
if(name==='fungi') {
for(const [x,y,r] of [[10,19,7],[23,24,5]]) {
d.rect(x-1,y,3,9,'cream').line(x+2,y+1,x+2,y+7,'barkLight');
d.oval(x,y,r,3,'barkDark').poly([[x-r,y],[x-r+2,y-4],[x,y-6],[x+r-2,y-3],[x+r,y]],'clay');
d.line(x-r+3,y-3,x,y-4,'woodLight');
}
return;
}
if(name==='litter' || name==='dung' || name==='deadwood') {
if(name==='deadwood') {
d.poly([[4,24],[6,19],[25,13],[29,17],[27,23],[9,29]],'barkDark');
d.line(7,22,25,16,'barkLight',3).line(11,26,27,20,'bark');
d.oval(7,24,3,4,'wood').oval(7,24,1,2,'bark'); d.line(18,18,16,10,'bark',2);
} else if(name==='dung') {
d.oval(16,26,10,3,'earthDark').oval(16,23,7,4,'earth').oval(15,20,4,3,'earthLight');
d.line(12,24,17,24,'barkDark');
} else for(const [x,y,f] of [[5,23,false],[19,27,false],[24,17,true],[12,16,true]]) {
leaf(d,x,y,f,'straw'); d.line(x,y,x+(f?-5:5),y-2,'barkLight');
}
return;
}
const crop=name==='grain_patch'?4:Number(name.match(/^crop_([1-4])$/)?.[1] || 0);
for(const [x,top,bend] of [[7,9,-3],[15,4,1],[24,7,3],[19,15,-2]]) {
const y=crop ? 28-crop*5+(top%4) : top;
d.line(x,30,x+bend,y,crop>=3?'straw':'leaf',1);
if(name==='reeds') {
d.line(x+bend,y+4,x+bend,y-3,'bark',2).line(x+2,28,x+5,16,'sage');
} else {
d.line(x,28,x-4,20,'sage').line(x,26,x+4,18,'leaf');
if(crop>=3) for(let i=0;i<4;i++) {
d.line(x+bend,y+i*2,x+bend-2,y+i*2-2,crop===4?'strawLight':'sage');
d.line(x+bend,y+i*2+1,x+bend+2,y+i*2-1,crop===4?'straw':'leafLight');
}
if(name==='wildflowers') {
d.oval(x+bend,y,3,1,'rose').oval(x+bend,y,1,3,'mauve').dot(x+bend,y,'gold');
}
}
}
if(name==='grain_patch') {
d.line(3,30,2,23,'strawDark').line(28,30,29,25,'straw');
d.line(8,10,5,4,'strawLight').line(16,9,18,3,'strawLight');
}
}
export const SEASONAL_PLANT_NAMES = Object.freeze(['leaves_spring','leaves_autumn','leaves_winter',
'meadow_grass_dormant','fern_dormant','wildflowers_dormant','spring_greens','spring_greens_dormant',
'autumn_mushrooms','autumn_mushrooms_dormant']);
export const SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce']);
export const EVERGREEN_NAMES = Object.freeze(['pine','spruce']);
export const ROCK_NAMES = Object.freeze(['granite','basalt','limestone','slate','sandstone']);
export const UNDERSTORY_NAMES = Object.freeze(['clover','nettle','heather','bluebell','sedge','bulrush','moss','lichen']);
export const DORMANT_UNDERSTORY_NAMES = Object.freeze(UNDERSTORY_NAMES.slice(0,6).map(n=>`${n}_dormant`));
// Explicit expansion of the biome/diversity contracts; no source-scanner guesses.
export const BIOME_WORLD_NAMES = Object.freeze([...ROCK_NAMES,...ROCK_NAMES.map(n=>`${n}_bricks`),
'forest_floor','podzol','alluvium','peat','alpine_turf','gravel','scree','snow',
'quartz_vein','copper_ore_vein','coal_ore_vein',
...SPECIES_NAMES.flatMap(s=>['log_top','log_side','leaves','planks'].map(n=>`${s}_${n}`))]);
export const SPECIES_SEASONAL_NAMES = Object.freeze(SPECIES_NAMES.filter(s=>!EVERGREEN_NAMES.includes(s))
.flatMap(s=>['spring','autumn','winter'].map(season=>`${s}_leaves_${season}`)));
export const PLANT_NAMES = Object.freeze(['leaves','reeds','berry_bush','berry_bush_bare','sapling',
'grain_patch','crop_1','crop_2','crop_3','crop_4','meadow_grass','wildflowers','fern','litter',
'fungi','deadwood','tree_seedling','young_tree','mature_tree','dung',...SEASONAL_PLANT_NAMES,
...SPECIES_NAMES.map(s=>`${s}_leaves`),...SPECIES_SEASONAL_NAMES,...UNDERSTORY_NAMES,...DORMANT_UNDERSTORY_NAMES]);
export const WORLD_NAMES = Object.freeze(['stone','soil','grass_top','grass_side','sand','log_top','log_side',
'leaves','planks','wall','roof','thatch','foundation','frame','stairs','door','window','vent','storage','pantry',
'reeds','berry_bush','berry_bush_bare','sapling','grain_patch','crop_1','crop_2','crop_3','crop_4',
'clay_bank','iron_ore_vein','water','fire','fire_lit','torch','sticks','loose_stone','bedroll',
'drying_rack','fish_trap','weir_stone',...PLANT_NAMES.slice(10,30),...BIOME_WORLD_NAMES,
...SPECIES_SEASONAL_NAMES,...UNDERSTORY_NAMES,...DORMANT_UNDERSTORY_NAMES]);
export const ITEM_NAMES = Object.freeze(['berries','grain','grain_seed','raw_food','cooked_food','fish_bait',
'raw_fish','cooked_fish','fish_drying_charge','dried_fish','iron_ore','iron_ingot','charcoal',
'charcoal_charge','smelt_charge','clay','clay_jar','clay_jar_charge','dried_food','dried_food_charge',
'cloak','vessel','water_raw','water_clean','firestarter','iron_axe','spring_greens','autumn_mushrooms',
'quartz','copper_ore','coal','copper_ingot','copper_charge','copper_pick']);
function speciesLeaves(d,species,season='summer') {
const index=SPECIES_NAMES.indexOf(species);
if(index>=5) {
const winter=season==='winter', spring=season==='spring', autumn=season==='autumn';
const dark=winter?'barkDark':spring?'springDark':autumn?'autumnDark':'leafDark';
const base=winter?'bark':spring?'springLeaf':autumn?'copper':species==='spruce'?'pine':'sage';
const light=winter?'barkLight':spring?'springLight':autumn?'ochre':'leafLight';
for(const [i,[x,y]] of [[5,7],[20,5],[28,19],[12,21],[2,30]].entries()) {
d.line(x-3,y+4,x+3,y-4,winter?'bark':'barkLight');
if(winter) {
d.line(x,y,x-3-index%4,y-4-index%3,dark).line(x+2,y-3,x+index-5,y-7-index%4,light);
continue;
}
if(species==='spruce') {
for(let j=0;j<6;j++) d.line(x-3+j,y+3-j,x-6+j,y-j,base)
.line(x-2+j,y+2-j,x+2+j,y+3-j,j%2?light:dark);
} else if(species==='rowan') {
for(let j=0;j<4;j++) d.line(x-2+j,y+3-j,x-5+j,y+2-j,dark,2)
.line(x-1+j,y+2-j,x+2+j,y+3-j,base,2).dot(x+2+j,y+3-j,light);
} else if(species==='aspen') {
d.oval(x,y,4,4,dark).oval(x-1,y-1,3,3,base).line(x-2,y+1,x+2,y-2,light);
} else if(species==='hazel') {
d.poly([[x,y+4],[x-5,y],[x-4,y-4],[x-1,y-4],[x,y-2],[x+3,y-4],[x+5,y],[x,y+4]],dark)
.line(x,y+2,x-2,y-2,base,2).line(x,y,x+3,y-2,light);
} else {
d.poly([[x-4,y+2],[x-3,y-2],[x,y-5],[x+4,y-2],[x+3,y+2],[x,y+4]],dark)
.line(x-2,y+1,x+1,y-3,base,2).line(x-1,y,x+2,y-1,light);
}
if(spring && i%2===0) d.dot(x+3,y-4,'bud');
if(autumn) d.line(x-2,y+3,x,y+3,'wine');
}
return;
}
if(season==='winter') {
// Species-dependent open tracery. No leaf polygons on a winter canopy.
for(let i=0;i<3;i++) {
const x=6+i*9, y=29-i*3, s=i%2?-1:1, bend=index-2;
d.line(x,y,x+bend,y-15,'bark').line(x+bend,y-15,x+s*5,y-23,'barkLight');
d.line(x,y-7,x-s*5,y-14-index,'barkDark');
d.line(x-s*3,y-11,x-s*4,y-18,'bark');
}
return;
}
const colors=season==='spring'?['springDark','springLeaf','springLight']:
season==='autumn'?['autumnDark',index%2?'ochre':'copper',index%2?'copper':'ochre']:
{oak:['leafDark','leaf','sage'],birch:['leaf','sage','leafLight'],pine:['pineShade','pine','pineGlow'],
willow:['willowShade','willow','willowGlow'],alder:['leafDark','turfShade','turfGlow']}[species];
const [dark,base,light]=colors;
for(const [i,[x0,y0]] of [[4,7],[16,5],[27,9],[7,20],[22,22],[1,30]].entries()) {
const x=x0+(index%2),y=y0+(i%2?index%3:0);
if(species==='pine') {
d.line(x-4,y+3,x+4,y-4,'bark');
for(let j=0;j<5;j++) d.line(x-3+j,y+1-j,x-6+j,y-1-j,base)
.line(x-2+j,y+1-j,x+1+j,y+3-j,j%2?light:dark);
} else if(species==='willow') {
d.line(x,y-5,x-1,y+6,'barkLight');
for(let j=0;j<3;j++) d.poly([[x,y-3+j*3],[x+5,y-4+j*3],[x+3,y+j*3]],base)
.line(x,y-3+j*3,x+3,y-3+j*3,light);
d.line(x-1,y,x-5,y+5,dark);
} else if(species==='birch') {
for(const [a,b] of [[-2,0],[3,-3],[3,4]])
d.poly([[x+a-3,y+b],[x+a,y+b-3],[x+a+3,y+b],[x+a,y+b+3]],dark)
.line(x+a-1,y+b,x+a+1,y+b-1,light).dot(x+a,y+b+1,base);
} else {
d.oval(x,y,species==='oak'?5:4,3,dark).oval(x-1,y-1,4,2,base);
d.line(x-3,y,x+2,y-2,light);
if(species==='oak') d.rect(x+3,y-2,2,2,base).rect(x-4,y+1,2,2,base);
else d.line(x-1,y-2,x+1,y-2,light);
}
if(season==='spring' && i%2===0) d.rect(x+2,y-4,2,2,'bud');
if(season==='autumn') d.line(x-2,y+2,x,y+2,'wine');
}
}
function understory(d,name) {
const dormant=name.endsWith('_dormant'), kind=name.replace(/_dormant$/,''),
stem=dormant?'strawDark':'leafDark', mid=dormant?'straw':'sage', light=dormant?'barkLight':'leafLight';
if(kind==='moss' || kind==='lichen') {
for(const [i,[x,y]] of [[6,26],[12,23],[20,25],[26,28],[16,29]].entries()) {
if(kind==='moss') {
d.oval(x,y,4,2,'leafDark');
for(let j=0;j<3;j++) d.line(x-2+j*2,y,x-1+j*2,y-3-i%2,j%2?'sage':'leaf');
} else {
d.line(x-2,y,x+2,y-3,'stoneLight',2).line(x,y-2,x-2,y-5,'willow');
d.line(x+2,y-3,x+4,y-2,'cream').dot(x-3,y-4,'sage');
}
}
return;
}
if(kind==='clover') {
for(const [i,[x,y]] of [[7,24],[14,17],[24,22],[18,26]].entries()) {
d.line(16,30,x,dormant?y+3:y,stem);
if(dormant) { d.line(x,y+3,x+3,y+4,mid); continue; }
for(const [a,b] of [[-2,0],[1,-2],[3,1]]) d.oval(x+a,y+b,2,2,'leaf').dot(x+a,y+b-1,'sage');
if(i===1) d.oval(x,y-5,2,2,'cream').dot(x,y-6,'rose');
}
} else if(kind==='nettle') {
for(const [x,top] of [[10,12],[19,5],[25,17]]) {
d.line(x,30,x-1,dormant?top+9:top,stem);
for(let y=dormant?23:top+4;y<29;y+=5) {
if(dormant) d.line(x,y,x+3,y-2,mid);
else for(const s of [-1,1]) d.poly([[x,y],[x+s*5,y-4],[x+s*4,y-1],[x+s*6,y],[x+s*2,y+2]],'leaf')
.line(x,y,x+s*4,y-2,light);
}
}
} else if(kind==='heather') {
for(const [x,y,bend] of [[6,17,-2],[12,10,-3],[18,8,2],[25,15,3]]) {
d.line(16,30,x,y+5,stem).line(x,y+5,x+bend,dormant?y+3:y-4,stem);
for(let j=0;j<(dormant?2:5);j++) {
d.line(x,y+j*2,x-2,y+j*2+1,mid);
if(!dormant) d.rect(x+bend,y-4+j*2,2,2,j%2?'rose':'wine').dot(x+bend,y-4+j*2,'bud');
}
}
} else if(kind==='bluebell') {
for(const [x,y,s] of [[9,11,1],[19,5,1],[24,17,-1]]) {
d.line(x,30,x,dormant?y+13:y+2,stem);
d.line(x,28,x-4,dormant?25:19,mid);
if(!dormant) {
d.line(x,y+2,x+s*4,y,mid).line(x+s*4,y,x+s*6,y+3,mid);
for(let j=0;j<3;j++) d.poly([[x+s*5,y+3+j*5],[x+s*8,y+3+j*5],[x+s*9,y+7+j*5],
[x+s*4,y+7+j*5]],'slateBlue').line(x+s*5,y+4+j*5,x+s*5,y+6+j*5,'mauve');
} else d.dot(x,dormant?y+13:y,'bark');
}
} else { // Sedge fans versus erect, cylindrical bulrush heads.
for(const [i,[x,top,bend]] of [[6,12,-3],[12,6,-2],[17,9,2],[23,4,1],[27,17,2]].entries()) {
const tip=dormant?top+10:top;
d.line(x,30,x+bend,tip,stem).line(x+1,29,x+bend+1,tip+2,mid);
if(kind==='bulrush' && i%2===1) {
d.line(x+bend,tip+2,x+bend,tip-3,'bark',2).dot(x+bend,tip-3,light);
if(dormant) d.line(x+bend,tip-1,x+bend+3,tip+1,'earthLight');
} else if(!dormant && i%2===0) d.line(x+bend,tip,x+bend+2,tip+3,'leafLight');
}
}
}
function habitatTile(d,name) {
if(name==='snow') {
d.rect(0,0,32,32,'snow');
for(const [i,[x,y]] of GRAINS.entries()) if(i%3===0)
d.line(x,y,x+5,y-1,'snowShade').line(x+1,y-1,x+4,y-2,'snowGlow');
return;
}
if(name==='gravel' || name==='scree') {
d.rect(0,0,32,32,name==='gravel'?'loamShade':'slateShade');
GRAINS.forEach(([x,y],i)=> {
if(name==='gravel') d.oval(x,y,2+i%2,1.5,'mineralShade').line(x-1,y-1,x+1,y-1,i%3?'mineralGlow':'sand');
else d.poly([[x-2,y+2],[x,y-3],[x+4,y-2],[x+2,y+2]],i%2?'slateBlue':'mineral')
.line(x,y-2,x+2,y-2,'slateGlow');
}); return;
}
earth(d);
if(name==='alpine_turf') {
grass(d);
for(const [i,[x,y]] of GRAINS.entries()) if(i%3===0)
d.oval(x,y,2,1,'slateShade').line(x-1,y-1,x+1,y-1,'mineralGlow');
return;
}
if(name==='alluvium') {
for(const [i,[x,y]] of GRAINS.entries()) if(i%2===0)
d.line(x,y,x+4,y-1,'earthLight').line(x+1,y+1,x+3,y,'loamGlow');
d.oval(23,16,3,2,'mineralShade').line(22,15,24,15,'sand'); return;
}
if(name==='peat') d.rect(0,0,32,32,'humus');
for(const [i,[x,y]] of GRAINS.entries()) {
if(name==='podzol') d.line(x,y,x+3,y+2,i%3?'bark':'strawDark').line(x+1,y,x+3,y+1,'loamGlow');
else if(name==='peat') d.line(x,y,x+2,y-2,i%3?'loamShade':'bark').dot(x+3,y-3,'loamGlow');
else { // Forest floor: fragmentary fallen leaves in humus, no giant symbols.
d.poly([[x-1,y],[x+2,y-2],[x+4,y],[x+1,y+2]],i%3?'humus':'barkLight');
if(i%3===0) d.line(x,y,x+2,y-1,'strawDark');
}
}
}
function worldTile(name) {
const d=new Drawing();
const speciesMatch=name.match(/^(oak|birch|pine|willow|alder|beech|aspen|rowan|hazel|spruce)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/);
if(speciesMatch) {
const [,species,part,season]=speciesMatch;
if(part==='leaves') speciesLeaves(d,species,season);
else if(part==='log_top') endgrain(d,species);
else if(part==='log_side') bark(d,species);
else planks(d,false,species);
if(species==='oak' && part!=='leaves') {
if(part==='log_top') d.line(21,5,20,10,'barkLight');
else if(part==='log_side') d.line(23,14,24,18,'barkDark').dot(25,15,'barkLight');
else d.oval(20,12,2,1,'barkLight').dot(20,12,'wood');
}
return d;
}
if(UNDERSTORY_NAMES.includes(name.replace(/_dormant$/,''))) { understory(d,name); return d; }
if(ROCK_NAMES.includes(name)) { mineral(d,name); return d; }
if(name.endsWith('_bricks') && ROCK_NAMES.includes(name.slice(0,-7))) {
const rock=name.slice(0,-7); mineral(d,rock);
const joint={granite:'mineralShade',basalt:'basaltShade',limestone:'limeShade',slate:'slateShade',sandstone:'sandShade'}[rock];
const edge={granite:'mineralGlow',basalt:'basaltGlow',limestone:'limeGlow',slate:'slateGlow',sandstone:'sandGlow'}[rock];
for(const [row,y] of [0,11,22].entries()) {
d.line(0,y,31,y,joint).line(1,y+1,30,y+1,edge);
for(const x of row%2?[8,25]:[0,17]) d.line(x,y,x,y+10,joint);
}
return d;
}
if(['forest_floor','podzol','alluvium','peat','alpine_turf','gravel','scree','snow'].includes(name)) {
habitatTile(d,name); return d;
}
if(['quartz_vein','copper_ore_vein','coal_ore_vein'].includes(name)) {
mineral(d,name==='quartz_vein'?'granite':name==='coal_ore_vein'?'sandstone':'basalt');
if(name==='quartz_vein') {
d.line(1,28,12,16,'stoneLight',2).line(12,16,19,14,'stoneLight',2).line(19,14,30,1,'stoneLight',2);
for(const [x,y] of [[5,25],[13,16],[22,10],[28,4]])
d.poly([[x-1,y],[x,y-4],[x+3,y-3],[x+3,y+1]],'cream').line(x,y-3,x+1,y,'moon');
} else if(name==='copper_ore_vein') for(const [x,y] of [[4,7],[10,11],[15,17],[20,20],[27,27]])
d.poly([[x-2,y],[x,y-3],[x+3,y-2],[x+2,y+2]],'copperShade').line(x,y-2,x+2,y-2,'copperMetal')
.line(x-1,y+1,x+1,y+1,'verdigris');
else {
d.poly([[0,11],[7,10],[14,14],[23,13],[32,17],[32,21],[24,18],[14,19],[6,14],[0,15]],'ink');
d.line(2,12,9,13,'slate').line(16,16,22,15,'slate').line(26,18,30,19,'slate');
}
return d;
}
if(PLANT_NAMES.includes(name)) { plant(d,name); return d; }
switch(name) {
case 'stone': mineral(d); break;
case 'foundation': case 'weir_stone':
masonry(d,name==='foundation');
if(name==='weir_stone') d.line(0,25,31,25,'waterLight').line(2,27,12,27,'water');
break;
case 'soil': earth(d); break;
case 'grass_top': grass(d); break;
case 'grass_side': grass(d,true); break;
case 'sand':
d.rect(0,0,32,32,'sand');
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const u=x===31?0:x, ripple=y+1.4*Math.sin(u*Math.PI*2/31)+.45*Math.sin(u*Math.PI*4/31);
const phase=(ripple+32)%8;
if(phase<.8) d.dot(x,y,'sandShade');
else if(phase>6.9) d.dot(x,y,'sandGlow');
}
for(const [x,y] of [[8,11],[21,25],[15,4],[27,17]]) d.rect(x,y,2,1,'sandShade');
break;
case 'log_top': endgrain(d); break;
case 'log_side': bark(d); break;
case 'planks': planks(d); break;
case 'wall':
d.rect(0,0,32,32,'clay');
for(const y of [4,14,25]) d.line(1,y,11,y,'earthLight').line(20,y+2,29,y+2,'wood');
d.rect(0,0,3,32,'bark').rect(29,0,3,32,'bark').rect(3,27,26,3,'wood');
d.line(8,8,11,11,'earth').line(11,11,9,16,'earth'); break;
case 'roof': case 'thatch': straw(d); if(name==='thatch') d.line(0,30,31,30,'strawLight'); break;
case 'frame':
d.rect(0,0,32,32,'earthDark').rect(4,4,24,24,'clay');
for(const x of [0,27]) d.rect(x,0,5,32,'bark').line(x+1,1,x+1,30,'wood');
for(const y of [0,27]) d.rect(0,y,32,5,'barkLight').line(2,y+1,29,y+1,'woodLight');
d.line(5,26,26,5,'bark',3).line(6,26,26,6,'wood'); break;
case 'stairs':
planks(d); for(const y of [0,8,16,24]) d.rect(0,y,32,2,'barkDark').line(0,y+2,31,y+2,'woodLight'); break;
case 'door':
planks(d,true); d.rect(1,2,30,3,'barkDark').rect(1,26,30,3,'barkDark');
d.line(3,24,27,7,'bark',3).line(4,24,28,7,'wood');
d.rect(24,16,3,4,'outline').dot(25,17,'gold'); break;
case 'window':
d.rect(3,3,26,26,'glass',95).line(7,13,12,8,'moon').line(19,24,24,19,'waterLight');
for(const x of [1,15,29]) d.rect(x,1,2,30,'barkLight');
for(const y of [1,15,29]) d.rect(1,y,30,2,'wood'); break;
case 'vent':
planks(d); d.rect(5,5,22,22,'barkDark');
for(const y of [6,12,18,24]) d.rect(6,y,20,2,'wood').line(7,y,25,y,'woodLight'); break;
case 'storage': case 'pantry':
planks(d,true); d.rect(1,2,30,4,'barkDark').rect(1,25,30,5,'barkDark');
d.line(2,3,29,3,'woodLight').rect(14,12,5,9,'outline').rect(16,14,1,3,'gold');
if(name==='pantry') {
d.rect(3,7,26,16,'earthDark');
for(const x of [7,15,23]) d.oval(x,16,3,5,'clay').rect(x-2,10,4,2,'wood');
d.line(3,22,28,22,'woodLight');
}
break;
case 'clay_bank':
d.rect(0,0,32,32,'clay');
for(const y of [3,13,24]) d.poly([[0,y],[10,y+2],[20,y],[32,y],[32,y+3],[20,y+3],[10,y+5],[0,y+3]],'earthLight');
d.line(4,8,10,9,'wood').line(21,19,28,18,'wood'); break;
case 'iron_ore_vein':
mineral(d); d.poly([[1,27],[8,18],[12,19],[18,10],[23,11],[29,2],[31,6],[25,15],[21,15],[15,24],[9,23],[4,30]],'earthDark');
for(const [x,y] of [[5,25],[11,20],[18,14],[24,9],[28,4]])
d.poly([[x-2,y],[x,y-3],[x+3,y-2],[x+2,y+2]],'rust').line(x,y-2,x+2,y-2,'iron'); break;
case 'water':
d.rect(0,0,32,32,'water');
// Opposite edge pixels agree; small, separated horizontal wavelets.
for(const [x,y,w] of [[4,5,8],[19,13,9],[7,23,11]])
d.line(x,y,x+w,y,'waterLight').line(x+2,y+1,x+w+2,y+1,'waterDark');
break;
case 'fire': case 'fire_lit':
d.oval(16,22,14,8,'outline').oval(16,21,11,5,'earthDark');
for(const [x,y] of [[5,19],[10,26],[22,26],[27,19],[16,15]]) d.oval(x,y,3,2,'stone').dot(x-1,y-1,'stoneLight');
d.line(9,24,24,18,'bark',3).line(8,18,23,24,'barkLight',2);
if(name==='fire_lit') flame(d,16,20,1); break;
case 'torch':
d.line(13,29,18,12,'barkDark',3).line(14,28,19,12,'wood');
d.line(16,15,21,16,'straw',2); flame(d,19,12,.65); break;
case 'sticks':
d.line(5,25,24,6,'barkDark',3).line(6,25,25,6,'wood').line(11,11,25,26,'bark',3).line(12,11,26,26,'barkLight');
d.line(14,17,9,12,'bark',2); break;
case 'loose_stone':
d.poly([[5,24],[7,16],[16,12],[25,17],[28,24],[22,28],[10,28]],'slate');
d.poly([[7,20],[10,16],[16,14],[23,17],[20,23],[12,24]],'stone').line(11,17,16,16,'stoneLight'); break;
case 'bedroll':
d.poly([[7,7],[25,7],[28,27],[4,27]],'outline').rect(7,8,18,17,'leaf');
d.rect(8,10,16,3,'sage').line(9,16,23,16,'leafDark').line(9,22,23,22,'leafDark');
d.oval(16,7,9,4,'barkLight').oval(16,6,8,3,'wood').line(6,27,26,27,'wood'); break;
case 'drying_rack':
d.line(5,30,8,4,'bark',3).line(24,4,27,30,'bark',3).line(6,5,25,5,'wood',2);
for(const x of [11,17,23]) { d.line(x,6,x,12,'straw'); fish(d,x,18,.45,'wood'); } break;
case 'fish_trap':
d.poly([[4,11],[18,5],[28,13],[28,24],[14,29],[4,22]],'barkDark');
for(let x=5;x<29;x+=4) d.line(x,12,x,24,'straw');
for(const y of [12,16,20,24]) d.line(5,y,27,y,'wood');
d.oval(15,10,10,5,'wood').oval(15,10,7,3,'earthDark').oval(15,10,3,2,'bark'); break;
default: throw new Error('Unknown world art name.');
}
return d;
}
function flame(d,x,y,s) {
const points=[[0,2],[-7,-1],[-6,-7],[-2,-10],[0,-19],[4,-12],[4,-6],[8,-9],[7,-2],[3,2]];
d.poly(points.map(([a,b])=>[x+a*s,y+b*s]),'clay');
d.poly([[-4,0],[-3,-7],[0,-12],[2,-5],[4,-2],[2,1]].map(([a,b])=>[x+a*s,y+b*s]),'peach');
d.poly([[-2,0],[0,-7],[2,-1],[1,1]].map(([a,b])=>[x+a*s,y+b*s]),'gold');
}
function fish(d,x=16,y=16,s=1,color='iron') {
d.poly([[x-9*s,y],[x-14*s,y-5*s],[x-14*s,y+5*s]],'waterLight');
d.oval(x,y,10*s,5*s,'outline').oval(x,y-s,9*s,4*s,color);
d.line(x-4*s,y-3*s,x+4*s,y-3*s,color==='iron'?'moon':'woodLight');
d.dot(x+6*s,y-s,'ink').line(x-s,y+2*s,x+2*s,y+2*s,'bark');
}
function jar(d,water=null) {
d.poly([[11,8],[21,8],[21,13],[25,18],[24,27],[8,27],[7,18],[11,13]],'earthDark');
d.poly([[12,9],[20,9],[19,14],[23,19],[22,25],[10,25],[9,19],[13,14]],'clay');
d.line(11,18,11,23,'woodLight').rect(10,7,12,3,'barkLight').rect(12,7,8,1,'earthDark');
if(water) d.rect(12,18,8,5,water).line(13,18,18,18,'waterLight');
}
function lump(d,color='clay') {
d.poly([[5,22],[8,13],[16,9],[25,14],[28,23],[20,28],[9,27]],'outline');
d.poly([[7,21],[10,14],[16,11],[24,15],[25,22],[19,25],[10,25]],color);
d.poly([[10,14],[16,11],[22,15],[16,18],[9,19]],color==='clay'?'wood':'slate');
}
function food(d,cooked=false,dried=false) {
d.poly([[5,21],[8,11],[17,7],[26,14],[25,24],[17,28],[8,26]],'earthDark');
d.poly([[7,21],[10,12],[17,10],[24,15],[23,23],[16,25],[9,24]],cooked?'barkLight':'rose');
d.line(12,14,19,20,dried?'straw':'cream',2).line(11,20,15,23,cooked?'earth':'berry');
if(dried) for(const x of [10,15,20]) d.line(x,16,x+2,21,'barkDark');
}
function itemTile(name) {
const d=new Drawing();
if(name.endsWith('_charge')) {
const base={fish_drying_charge:'raw_fish',charcoal_charge:'charcoal',smelt_charge:'iron_ore',
clay_jar_charge:'clay_jar',dried_food_charge:'raw_food',copper_charge:'copper_ore'}[name];
d.data.set(itemTile(base).data);
d.line(6,25,25,7,'straw',2).line(7,7,25,25,'woodLight');
d.rect(20,22,9,7,'outline').rect(21,23,7,5,'gold').line(22,24,26,24,'bark');
return d;
}
switch(name) {
case 'quartz':
d.poly([[7,25],[6,14],[10,9],[14,12],[16,5],[21,3],[25,8],[24,24],[17,28]],'slateBlue');
d.poly([[8,23],[8,14],[10,11],[13,14],[14,24]],'cream');
d.poly([[15,24],[17,6],[21,4],[22,9],[21,25]],'moon');
d.poly([[22,9],[24,9],[23,23],[21,25]],'stoneLight');
d.line(18,9,17,21,'snowGlow'); break;
case 'copper_ore':
lump(d,'basalt');
for(const [x,y] of [[11,18],[18,14],[21,23]]) d.poly([[x-2,y],[x,y-3],[x+3,y-1],[x+1,y+2]],'copperShade')
.line(x-1,y-1,x+1,y-2,'copperMetal').dot(x+2,y+1,'verdigris'); break;
case 'coal':
lump(d,'ink');
d.poly([[8,21],[14,13],[17,19],[13,25]],'basaltShade').line(14,13,16,16,'slateBlue');
d.line(19,13,22,17,'mineralShade').line(20,23,23,21,'basaltGlow'); break;
case 'copper_ingot':
d.poly([[5,16],[20,10],[28,15],[25,23],[10,28],[5,24]],'copperShade');
d.poly([[5,16],[20,10],[28,15],[11,22]],'copperMetal').line(7,16,20,11,'copperGlow');
d.line(12,24,24,19,'copperMetal'); break;
case 'copper_pick':
d.line(8,27,22,9,'barkDark',3).line(9,27,23,9,'wood');
d.poly([[4,12],[8,7],[15,5],[22,7],[28,12],[23,11],[16,9],[10,10]],'copperShade');
d.line(7,10,14,7,'copperGlow').line(14,7,21,8,'copperMetal').line(21,8,26,10,'copperMetal');
d.line(10,24,13,26,'straw'); break;
case 'spring_greens':
seasonalPlant(d,name);
d.line(12,26,21,25,'straw',2).line(14,29,20,28,'wood'); break;
case 'autumn_mushrooms':
seasonalPlant(d,name);
d.line(9,28,24,28,'bark').line(12,29,21,29,'wood'); break;
case 'berries':
d.line(8,14,20,7,'barkLight').line(20,7,23,13,'bark'); leaf(d,17,10,false);
for(const [x,y] of [[9,18],[18,16],[23,23],[13,25]])
d.oval(x,y,5,5,'outline').oval(x,y-1,4,4,'berry').rect(x-2,y-3,2,2,'berryLight'); break;
case 'grain':
for(const x of [10,16,22]) {
d.line(15,28,x,6,'strawDark');
for(let y=7;y<18;y+=3) d.line(x,y,x-3,y-2,'straw',2).line(x+1,y+1,x+3,y-1,'strawLight');
}
d.line(11,23,20,22,'bark',2); break;
case 'grain_seed':
for(const [x,y] of [[10,12],[20,10],[15,19],[8,25],[24,23]])
d.oval(x,y,3,2,'strawDark').line(x-1,y-1,x+1,y-1,'strawLight'); break;
case 'raw_food': food(d); break;
case 'cooked_food': food(d,true); break;
case 'dried_food': food(d,true,true); break;
case 'fish_bait':
d.line(7,21,11,25,'earthDark',4).line(11,25,17,20,'clay',3).line(17,20,17,13,'clay',3);
d.line(17,13,23,10,'rose',3).line(23,10,26,14,'rose',2).dot(25,12,'woodLight'); break;
case 'raw_fish': fish(d); break;
case 'cooked_fish': fish(d,16,16,1,'clay'); d.line(12,13,14,18,'earth').line(17,13,19,18,'earth'); break;
case 'dried_fish': fish(d,16,16,1,'straw'); for(const x of [11,16,21]) d.line(x,13,x,18,'bark'); break;
case 'iron_ore': lump(d,'stone'); d.line(10,23,22,13,'rust',3).line(12,23,23,14,'iron'); break;
case 'iron_ingot':
d.poly([[5,16],[20,10],[28,15],[25,23],[10,28],[5,24]],'slate');
d.poly([[5,16],[20,10],[28,15],[11,22]],'iron').line(7,16,20,11,'moon');
d.poly([[11,22],[28,15],[25,23],[10,28]],'waterLight'); break;
case 'charcoal': lump(d,'ink'); d.line(11,15,16,18,'slate').line(18,13,22,20,'barkDark'); break;
case 'clay': lump(d); break;
case 'clay_jar': jar(d); break;
case 'vessel':
d.oval(25,17,5,6,'barkLight').oval(25,17,2,3,'outline');
d.poly([[7,10],[23,10],[22,26],[9,26]],'bark').rect(9,11,12,13,'wood');
d.oval(15,10,8,3,'woodLight').oval(15,10,6,1,'earthDark'); break;
case 'water_raw': jar(d,'water'); d.dot(14,21,'sage').dot(18,20,'earth'); break;
case 'water_clean': jar(d,'waterLight'); d.line(16,15,16,21,'moon').line(13,18,19,18,'moon'); break;
case 'cloak':
d.poly([[12,4],[20,4],[23,11],[29,27],[20,26],[16,29],[12,26],[3,27],[9,11]],'outline');
d.poly([[13,5],[19,5],[21,12],[26,25],[19,24],[16,27],[12,24],[6,25],[11,12]],'leaf');
d.line(12,13,8,23,'sage').line(18,14,21,23,'leafDark').oval(16,8,3,3,'barkDark').dot(17,12,'gold'); break;
case 'firestarter':
d.poly([[5,24],[7,14],[13,10],[18,16],[15,25]],'slate').line(8,16,13,12,'stoneLight');
d.line(17,26,25,8,'bark',3).line(18,26,26,8,'wood');
d.line(18,7,18,11,'gold').line(13,5,15,7,'peach'); break;
case 'iron_axe':
d.line(9,26,22,8,'barkDark',4).line(10,26,23,8,'wood',2);
d.poly([[8,6],[16,3],[25,8],[24,15],[17,17],[13,12],[8,12]],'outline');
d.poly([[9,7],[16,5],[23,9],[22,13],[17,15],[14,10],[9,10]],'iron').line(9,7,16,5,'moon');
d.line(12,23,15,25,'straw',2); break;
default: throw new Error('Unknown item art name.');
}
return d;
}
function uiTile(name) {
const d=new Drawing();
if(name==='crosshair') {
for(const [x,y,w,h] of [[15,9,3,5],[15,19,3,5],[9,15,5,3],[19,15,5,3]])
d.rect(x,y,w,h,'deep');
d.line(16,10,16,12,'cream').line(16,20,16,22,'cream').line(10,16,12,16,'cream').line(20,16,22,16,'cream');
} else {
if(name==='hotbar') d.rect(1,1,30,30,'deep',220).rect(3,3,26,26,'ink',200);
for(const [x,y,w,h] of [[1,1,30,1],[1,30,30,1],[1,1,1,30],[30,1,1,30]])
d.rect(x,y,w,h,name==='selected'?'gold':'slate');
if(name==='selected') for(const [x,y] of [[3,3],[25,3],[3,25],[25,25]])
d.line(x,y,x+3,y,'mauve').line(x,y,x,y+3,'mauve');
else d.line(4,3,27,3,'stone').line(3,4,3,27,'slate');
}
return d;
}
// Atlas patches are 8x8, UV-inset by half a pixel. Every face has a semantic
// patch, never an unstable automatically-unwrapped coordinate or external MTL.
const FUR_PATCHES = ['coat','belly','dark','nose','eye','ear','horn','hoof'];
function animalTexture(species) {
const d=new Drawing('outline'), coat={hare:'fur',fox:'fox',deer:'deer'}[species];
FUR_PATCHES.forEach((patch,i)=> {
const x=(i%4)*8, y=Math.floor(i/4)*8;
const base={coat,belly:'cream',dark:'barkDark',nose:'ink',eye:'ink',ear:'rose',horn:'wood',hoof:'barkDark'}[patch];
d.rect(x,y,8,8,base);
if(patch==='coat') d.rect(x+1,y+1,5,2,species==='fox'?'clay':'barkLight').rect(x+4,y+5,3,2,'bark');
if(patch==='belly') d.line(x+1,y+6,x+6,y+6,'woodLight');
if(patch==='eye') d.rect(x+1,y+1,2,2,'cream');
if(patch==='ear') d.rect(x,y,2,8,coat).line(x+6,y,x+6,y+7,coat);
if(patch==='horn') d.line(x+2,y,x+2,y+7,'woodLight');
});
return d;
}
export const ANIMAL_NAMES = Object.freeze(['hare','fox','deer']);
// All model coordinates use Luanti's BS=10 convention. Origin is ground level,
// forward +Z, visual_size={x=1,y=1}; bounds below are in actual node units.
function animalParts(species) {
const parts=[];
const add=(name,center,size,patch='coat',tilt=0,taper=1)=>parts.push({name,center,size,patch,tilt,taper});
if(species==='hare') {
add('haunch',[0,2.4,-.7],[3.3,3.8,4.2]); add('chest',[0,2.5,1.3],[2.5,2.8,2.4]);
add('head',[0,4,2.4],[2.6,2.4,2.5]); add('muzzle',[0,3.5,3.65],[1.5,1,1.2],'belly',0,.65);
add('ear_left',[-.8,6.5,2],[.7,3.5,.75],'ear',-.12,.6); add('ear_right',[.8,6.6,2],[.7,3.7,.75],'ear',.12,.6);
add('tail',[0,2.7,-3],[1.4,1.4,1.4],'belly');
for(const s of [-1,1]) {
add(`hind_foot_${s}`,[s*1.25,.4,-.6],[1.2,.8,2.7],'belly');
add(`foreleg_${s}`,[s*.85,.8,2],[.65,1.6,.8]);
add(`eye_${s}`,[s*1.32,4.35,2.8],[.16,.5,.5],'eye');
}
add('nose',[0,3.8,4.22],[.5,.4,.15],'nose');
} else if(species==='fox') {
add('body',[0,3.2,0],[3.1,3.2,6]); add('bib',[0,3.2,2.2],[2.6,2.7,1.7],'belly');
add('neck',[0,4.4,2.4],[2.4,2.3,2]); add('head',[0,5.2,3.4],[2.7,2.1,2.4]);
add('muzzle',[0,4.8,4.9],[1.7,1.3,1.8],'belly',0,.4);
for(const s of [-1,1]) {
add(`ear_${s}`,[s*.85,6.9,3.2],[1,1.8,.85],'dark',s*.2,.2);
add(`eye_${s}`,[s*1.37,5.5,3.85],[.16,.45,.6],'eye');
for(const z of [-2,1.9]) add(`leg_${s}_${z}`,[s*1.05,1.1,z],[.65,2.2,.8],'dark');
}
add('tail',[0,3,-4.8],[2.4,2.3,4.2],'coat',-.28,.6);
add('tail_tip',[0,2.2,-7.2],[1.8,1.7,1.6],'belly',-.28,.4);
add('nose',[0,4.8,5.83],[.7,.55,.18],'nose');
} else if(species==='deer') {
add('body',[0,6,0],[4,4,7.5]); add('belly',[0,4.6,.3],[3.3,1.5,6],'belly');
add('neck',[0,8.5,3.2],[2,5.2,2.4],'coat',.28,.7);
add('head',[0,11,4.4],[2,2.1,3.4],'coat',0,.7);
add('muzzle',[0,10.5,6],[1.4,1.2,1.5],'dark',0,.8);
add('tail',[0,6.7,-4.1],[1.1,1.8,1.1],'belly',-.35);
for(const s of [-1,1]) {
for(const z of [-2.7,2.4]) {
add(`leg_${s}_${z}`,[s*1.35,2.4,z],[.65,4.8,.8]);
add(`hoof_${s}_${z}`,[s*1.35,.35,z+.1],[.8,.7,1],'hoof');
}
add(`ear_${s}`,[s*1.7,11.8,3.9],[2.2,.65,1.1],'ear',s*.2,.6);
add(`eye_${s}`,[s*1.02,11.3,5],[.14,.45,.55],'eye');
add(`antler_${s}`,[s*.85,13.3,3.9],[.35,3.2,.4],'horn',s*.15,.7);
add(`tine_${s}`,[s*1.4,13.8,4.1],[1.4,.3,.35],'horn');
add(`point_${s}`,[s*2,14.4,4.1],[.25,1.4,.3],'horn');
}
} else throw new Error('Unknown animal art name.');
return parts;
}
function partVertices({center:[cx,cy,cz],size:[w,h,l],tilt,taper}) {
return [[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]].map(([x,y,z])=> {
const a=x*w/2*(y>0?taper:1), b=y*h/2, c=z*l/2;
return [cx+a,cy+b*Math.cos(tilt)-c*Math.sin(tilt),cz+b*Math.sin(tilt)+c*Math.cos(tilt)];
});
}
export function animalOBJ(species) {
const lines=[`# Original Last Hearth ${species}; BS=10; +Z forward; feet Y=0`];
let count=0;
for(const part of animalParts(species)) {
lines.push(`g ${part.name}`);
for(const v of partVertices(part)) lines.push(`v ${v.map(n=>Number(n.toFixed(6))).join(' ')}`);
const tile=FUR_PATCHES.indexOf(part.patch), x=(tile%4)*8, y=Math.floor(tile/4)*8;
for(const [u,v] of [[.5,7.5],[7.5,7.5],[7.5,.5],[.5,.5]]) lines.push(`vt ${(x+u)/32} ${1-(y+v)/32}`);
const faces=[[1,4,3,2],[5,6,7,8],[1,2,6,5],[4,8,7,3],[1,5,8,4],[2,3,7,6]];
const uv=count/8*4;
for(const f of faces) for(const tri of [[0,1,2],[0,2,3]])
lines.push(`f ${tri.map(i=>`${count+f[i]}/${uv+i+1}`).join(' ')}`);
count+=8;
}
return lines.join('\n')+'\n';
}
export function animalBounds(species) {
const vertices=animalParts(species).flatMap(partVertices);
return Object.freeze({min: Object.freeze([0,1,2].map(i=>Math.min(...vertices.map(v=>v[i]))/10)),
max: Object.freeze([0,1,2].map(i=>Math.max(...vertices.map(v=>v[i]))/10)),
visualSize: Object.freeze({x:1,y:1})});
}
// Transcribed from the actual botany.lua catalogue, not guessed from common
// names. Latin identity and diagnostic morphology travel with every sprite.
const BOTANY_GROUPS = {
trees: [
['oak','Quercus robur','rounded leaf lobes; stalked acorns'],
['birch','Betula pendula','serrate diamonds; pendant catkins'],
['pine','Pinus sylvestris','paired long needles; rounded cones'],
['willow','Salix alba','pendant lanceolate leaves; catkins'],
['alder','Alnus glutinosa','notched round leaves; woody conelets'],
['beech','Fagus sylvatica','oval parallel-veined leaves; pointed buds'],
['aspen','Populus tremula','scalloped round leaves; long petioles'],
['rowan','Sorbus aucuparia','pinnate leaflets; clustered red pomes'],
['hazel','Corylus avellana','heart-shaped serrate leaves; husked nuts'],
['spruce','Picea abies','short radial needles; pendant long cones'],
],
shrubs: [
['bilberry','Vaccinium myrtillus','angular green twigs; small ovals; solitary blue berries'],
['blackberry','Rubus plicatus','arching prickly cane; palmate leaves; dark aggregate fruit'],
['raspberry','Rubus idaeus','upright canes; pale pinnate leaves; red aggregate fruit'],
['elder','Sambucus nigra','opposite pinnate leaves; broad dark fruit corymb'],
['blackthorn','Prunus spinosa','rigid spines; small leaves; blue sloes'],
['hawthorn','Crataegus monogyna','short thorns; deeply lobed leaves; red haws'],
['dog_rose','Rosa canina','curved prickles; five petals; elongated hips'],
['holly','Ilex aquifolium','spiny glossy evergreen leaves; red berries'],
['juniper','Juniperus communis','needle whorls of three; blue berry-like cones'],
['guelder_rose','Viburnum opulus','three-lobed opposite leaves; translucent red fruit cluster'],
],
grasses: [
['sedge','Carex paniculata','triangular culms; dense branched panicle; tussock'],
['bulrush','Typha latifolia','flat strap leaves; cylindrical brown reedmace'],
['meadow_fescue','Schedonorus pratensis','narrow blades; open nodding panicle'],
['cocksfoot','Dactylis glomerata','one-sided clustered panicle; folded blades'],
['timothy','Phleum pratense','long tight cylindrical green flower spike'],
['sweet_vernal_grass','Anthoxanthum odoratum','short golden ovoid spike; sparse leaves'],
['yorkshire_fog','Holcus lanatus','soft broad blades; dense pink-tinged panicle'],
['tufted_hairgrass','Deschampsia cespitosa','fine radiating airy panicle; dense basal tuft'],
['common_reed','Phragmites australis','tall jointed culm; broad leaves; feathery plume'],
['soft_rush','Juncus effusus','smooth round stems; lateral brown inflorescence'],
],
herbs: [
['clover','Trifolium repens','creeping stolon; trifoliate leaves; round white head'],
['nettle','Urtica dioica','opposite toothed leaves; drooping green flower strings'],
['heather','Calluna vulgaris','woody branching sprays; tiny scale leaves; pink bells'],
['bluebell','Hyacinthoides non-scripta','arching one-sided raceme; drooping blue bells'],
['yarrow','Achillea millefolium','finely divided leaves; flat cream corymb'],
['foxglove','Digitalis purpurea','basal rosette; tall one-sided tubular purple bells'],
['meadowsweet','Filipendula ulmaria','pinnate serrate leaves; frothy cream plume'],
['wild_garlic','Allium ursinum','broad basal lance leaves; white six-tepal star umbel'],
['cowslip','Primula veris','wrinkled basal rosette; nodding yellow tubular umbel'],
['wood_anemone','Anemone nemorosa','whorl of divided leaves; single pale six-tepal flower'],
],
ferns_mosses: [
['bracken','Pteridium aquilinum','three-part triangular divided frond'],
['lady_fern','Athyrium filix-femina','delicate tapering bipinnate frond'],
['male_fern','Dryopteris filix-mas','robust lanceolate pinnate frond; basal crown'],
['harts_tongue','Asplenium scolopendrium','undivided glossy strap fronds; parallel sori'],
['polypody','Polypodium vulgare','deeply lobed pinnate frond; round gold sori'],
['sphagnum','Sphagnum palustre','starry capitula; dangling branch fascicles'],
['haircap_moss','Polytrichum commune','upright leafy shoots; tall reddish capsule setae'],
['moss','Hypnum cupressiforme','creeping feather-like plaits; hooked tips'],
['fork_moss','Dicranum scoparium','cushion of swept sickle-shaped leaves'],
['star_moss','Mnium hornum','upright shoots; broad oval leaves; nodding capsules'],
],
fungi: [
['fly_agaric','Amanita muscaria','red white-warted cap; ring; bulbous base'],
['birch_bolete','Leccinum scabrum','brown convex cap; pale scabrous stem'],
['bay_bolete','Imleria badia','chestnut cushion cap; stout buff stem'],
['chanterelle','Cantharellus cibarius','wavy golden funnel; decurrent folds'],
['wood_blewit','Collybia nuda','lilac broad cap; stout lilac stem'],
['shaggy_inkcap','Coprinus comatus','tall shaggy cylindrical cap; black deliquescent rim'],
['turkey_tail','Trametes versicolor','thin overlapping banded fan brackets'],
['oyster_fungus','Pleurotus ostreatus','lateral overlapping oyster caps; decurrent gills'],
['honey_fungus','Armillaria mellea','tufted honey caps; rings; shared woody base'],
['birch_polypore','Fomitopsis betulina','thick pale kidney bracket; dark attachment'],
],
aquatic: [
['duckweed','Lemna minor','tiny floating paired oval fronds; single dangling roots'],
['yellow_waterlily','Nuphar lutea','notched oval floating pads; small yellow cup'],
['white_waterlily','Nymphaea alba','round notched pads; many white pointed tepals'],
['water_crowfoot','Ranunculus aquatilis','lobed floating leaves; divided submerged leaves; five white petals'],
['water_mint','Mentha aquatica','opposite serrate leaves; terminal lilac globe'],
['water_forgetmenot','Myosotis scorpioides','scorpioid cyme; tiny blue five-lobed flowers'],
['brooklime','Veronica beccabunga','fleshy opposite oval leaves; lateral blue racemes'],
['water_starwort','Callitriche stagnalis','opposite narrow submerged leaves; oval terminal rosette'],
['hornwort','Ceratophyllum demersum','rootless submerged whorls of forked needle leaves'],
['pondweed','Potamogeton natans','elongated floating ovals; long petioles; emergent spike'],
],
lichens: [
['reindeer_lichen','Cladonia rangiferina','pale repeatedly forked shrubby fruticose tufts'],
['oakmoss','Evernia prunastri','flattened branching straps with pale undersides'],
['old_mans_beard','Usnea subfloridana','hanging hair-fine branching filaments'],
['lichen','Rhizocarpon geographicum','flat yellow-green crust islands divided by black map lines'],
['dog_lichen','Peltigera canina','broad grey lobes; raised brown margins; underside rhizines'],
['lungwort_lichen','Lobaria pulmonaria','large lobed green thallus; ridged reticulate surface'],
['sunburst_lichen','Xanthoria parietina','orange foliose rosette; orange disc apothecia'],
['hammered_shield','Parmelia sulcata','grey foliose shield; pale hammered ridge network'],
['cup_lichen','Cladonia chlorophaea','slender mealy stalks; flared shallow pixie cups'],
['pixie_cup','Cladonia pyxidata','stout pebbled stalks; broad cups; basal squamules'],
],
};
export const BOTANY_CATALOGUE = Object.freeze(Object.entries(BOTANY_GROUPS).flatMap(([group,rows])=>
rows.map(([id,latin,morphology])=>Object.freeze({id,latin,group,morphology}))));
export const BOTANY_ART_NAMES = Object.freeze(BOTANY_CATALOGUE.map(r=>r.id));
export const BOTANY_SPRITE_NAMES = Object.freeze(BOTANY_ART_NAMES.flatMap(id=>[`botany_${id}`,`botany_${id}_dormant`]));
// A specimen grammar, not generic coloured markers. Each taxon specifies its
// own leaf outline, branching, reproductive structure and habitat growth form.
function specimenLeaf(d,x,y,shape='oval',s=1,color='sage') {
const p=(a,b)=>[x+a*s,y+b];
if(shape==='needle') {
d.line(x,y,x+s*5,y-6,'leafDark').line(x,y,x+s*2,y-7,color); return;
}
const points={
oval:[[0,1],[-3,-1],[-3,-4],[0,-6],[3,-4],[3,-1]],
round:[[0,2],[-4,0],[-5,-3],[-3,-6],[1,-7],[4,-4],[4,-1]],
lance:[[0,1],[-2,-3],[0,-9],[2,-3]],
diamond:[[0,1],[-4,-3],[0,-8],[3,-4],[2,-2]],
heart:[[0,2],[-5,-2],[-4,-6],[-1,-6],[0,-4],[2,-6],[5,-4],[4,-1]],
lobe:[[0,2],[-3,0],[-2,-2],[-5,-3],[-4,-5],[-2,-5],[-3,-7],[0,-9],[2,-7],[1,-5],[4,-5],[4,-3],[2,-2],[3,0]],
thorn:[[0,2],[-3,0],[-2,-2],[-5,-3],[-2,-4],[-3,-7],[0,-6],[2,-8],[3,-4],[5,-3],[2,-1]],
}[shape];
d.poly(points.map(([a,b])=>p(a,b)),'leafDark').line(x,y,x,y-(shape==='lance'?6:4),color);
if(shape!=='lance') d.line(x,y-2,x+s*2,y-4,'leafLight');
}
function flower(d,x,y,color='cream',petals=5,r=3) {
for(let j=0;j<petals;j++) {
const a=j*Math.PI*2/petals-Math.PI/2;
d.oval(x+Math.cos(a)*r,y+Math.sin(a)*r,1.5,1.5,color);
}
d.dot(x,y,'ochre');
}
function compound(d,x,y,length=10,side=1) {
d.line(x,y,x+side*length,y-7,'leafDark');
for(let j=2;j<length;j+=3) {
d.line(x+side*j,y-j*.7,x+side*(j-2),y-j*.7-3,'sage',2);
d.line(x+side*j,y-j*.7,x+side*(j+3),y-j*.7+1,'leaf',2);
}
}
function botanicalTree(d,id,dormant) {
const k=SPECIES_NAMES.indexOf(id), conifer=EVERGREEN_NAMES.includes(id);
const stem=id==='birch'?'birch':id==='beech'?'mineralGlow':id==='aspen'?'birchShade':'bark';
d.line(16,29,15,7,stem,2).line(16,24,7,15,stem).line(16,19,24,10,stem);
if(id==='hazel') d.line(13,29,8,7,'bark').line(18,29,25,16,'barkLight');
if(conifer) {
for(let i=0;i<4;i++) {
const y=9+i*5,w=id==='spruce'?3+i*2:6+i;
d.line(16,y,16-w,y+3,'bark').line(16,y,16+w,y+2,'bark');
for(let j=-w;j<=w;j+=id==='pine'?3:2) {
d.line(16+j,y+3,14+j,y-(id==='pine'?4:1),'pine');
d.line(16+j,y+3,18+j,y-(id==='pine'?3:0),'pineGlow');
}
}
if(!dormant) d.oval(id==='spruce'?23:9,22,id==='spruce'?2:3,id==='spruce'?5:3,'barkLight')
.line(id==='spruce'?23:9,20,id==='spruce'?23:9,24,'wood');
return;
}
const positions={
oak:[[6,17],[10,25],[23,16],[25,24],[15,8]],
birch:[[8,13],[11,22],[22,11],[24,22],[16,7]],
willow:[[5,20],[9,27],[24,19],[27,27],[17,11]],
alder:[[7,17],[10,24],[23,14],[26,22],[14,9]],
beech:[[6,16],[10,23],[22,13],[26,21],[15,7]],
aspen:[[7,14],[9,24],[23,12],[25,24],[17,8]],
rowan:[[7,18],[10,26],[21,13],[26,23],[14,8]],
hazel:[[5,17],[10,25],[22,16],[26,24],[12,9]],
}[id];
for(const [i,[x,y]] of positions.entries()) {
d.line(16,24-i*3,x,y,'barkLight');
if(dormant) { d.line(x,y,x+(i%2?2:-2),y-3,'bark').dot(x,y-3,'wood'); continue; }
if(id==='rowan') compound(d,x-4,y+1,9,i%2?-1:1);
else {
const shape={oak:'lobe',birch:'diamond',willow:'lance',alder:'round',beech:'oval',aspen:'round',hazel:'heart'}[id];
specimenLeaf(d,x,y,shape,i%2?-1:1);
if(id==='beech') for(let j=1;j<4;j++) d.line(x,y-j,x+2,y-j-2,'woodLight');
if(id==='alder') d.rect(x-1,y-7,2,2,'leafLight');
if(id==='aspen') d.line(x,y+3,x,y+1,'wood');
if(id==='willow') d.line(x,y,x-1,y+5,'willow');
}
}
if(!dormant) {
if(id==='rowan') for(const [x,y] of [[22,6],[25,7],[23,9],[26,10]]) d.oval(x,y,1,1,'rust');
else if(id==='oak' || id==='hazel') {
d.oval(23,26,2,3,'wood').line(21,24,24,24,id==='oak'?'bark':'sage');
if(id==='hazel') d.oval(27,25,2,3,'straw').line(25,23,28,23,'leaf');
} else if(id==='alder') d.oval(24,27,2,2,'barkDark').dot(24,26,'barkLight');
else if(id==='birch' || id==='willow') d.line(5,20,5,26,id==='birch'?'straw':'cream',2);
else d.line(25,10,27,6,k===5?'copper':'birchShade');
}
}
function botanicalShrub(d,id,dormant) {
const k=BOTANY_GROUPS.shrubs.findIndex(r=>r[0]===id), arch=['blackberry','dog_rose'].includes(id);
const tips=arch?[[5,18],[10,id==='dog_rose'?12:9],[22,10],[28,id==='dog_rose'?22:18]]:
[[6,13+k%4],[14,6+k%3],[23,11+k%2],[27,20+k%5]];
d.line(13,29,18,19,'bark',2);
tips.forEach(([x,y],i)=> {
d.line(15,28,x,y,id==='bilberry'?'leafDark':'bark').line(x,y,x+(i%2?3:-2),y-2,'barkLight');
if(['blackthorn','hawthorn','blackberry','dog_rose'].includes(id)) d.line(x,y+5,x-3,y+3,'barkDark');
if(dormant && !['holly','juniper'].includes(id)) return;
if(['elder','raspberry','dog_rose'].includes(id)) compound(d,x,y+8,id==='elder'?9:6,i%2?-1:1);
else if(id==='juniper') for(let j=0;j<3;j++) {
d.line(x,y+9-j*3,x-4,y+4-j*3,'pineGlow').line(x,y+9-j*3,x+4,y+5-j*3,'pine');
d.line(x,y+9-j*3,x,y+3-j*3,'sage');
}
else specimenLeaf(d,x,y+8,{holly:'thorn',hawthorn:'lobe',guelder_rose:'lobe',blackberry:'diamond',
blackthorn:'oval',bilberry:'oval'}[id],i%2?-1:1);
if(dormant) return;
if(id==='dog_rose') { if(i===1) flower(d,x,y,'rose',5,3); else d.oval(x,y+1,1,2,'rust'); }
else if(id==='elder' || id==='guelder_rose') {
for(let j=-2;j<=2;j++) d.line(x,y+3,x+j*2,y,'barkLight').oval(x+j*2,y,1,1,id==='elder'?'slate':'rust');
} else if(id==='blackberry' || id==='raspberry') {
for(const [a,b] of [[0,0],[2,0],[1,2],[0,3]]) d.dot(x+a,y+b,id==='blackberry'?'slate':'wine');
d.dot(x+1,y,'berryLight');
} else d.oval(x,y,1.5,1.5,['holly','hawthorn'].includes(id)?'rust':id==='bilberry'?'slateBlue':'berry');
});
}
function botanicalGrass(d,id,dormant) {
const k=BOTANY_GROUPS.grasses.findIndex(r=>r[0]===id), stem=dormant?'strawDark':'leafDark';
for(let i=0;i<7;i++) {
const x=5+i*3,tip=17+(i*3+k)%9;
d.line(16,29,x,tip,stem).line(16,29,x+1,tip+2,dormant?'straw':'sage');
}
for(let i=0;i<3;i++) {
const x=9+i*7,y=5+(i*3+k)%7;
d.line(x,29,x-1,y,stem);
if(id==='sedge') {
// Three lit faces on an angular culm and a branching tussock panicle.
d.poly([[x-1,28],[x-2,y+5],[x,y+3],[x+1,28]],'sage').line(x,y+3,x,27,'leafLight');
for(let j=0;j<4;j++) d.line(x,y+7-j*2,x+(j%2?4:-3),y+3-j,'strawDark',2);
} else if(id==='bulrush') d.rect(x-2,y,3,8,dormant?'earthLight':'bark').line(x-1,y-2,x-1,y,'straw');
else if(id==='timothy' || id==='sweet_vernal_grass') {
const h=id==='timothy'?9:5;
d.oval(x-1,y+3,1.5,h/2,id==='timothy'?'sage':'straw').line(x-2,y+1,x-2,y+4,'strawLight');
} else if(id==='soft_rush') {
d.line(x-1,y,x-1,y+5,'sage');
for(let j=0;j<3;j++) d.line(x,y+8,x+2+j,y+6+j,'strawDark').dot(x+3+j,y+6+j,'barkLight');
} else {
const wide=id==='tufted_hairgrass'?7:id==='common_reed'?5:4;
for(let j=0;j<5;j++) {
const s=(j%2?-1:1), xx=x+s*(wide-j/2), yy=y+2+j*2;
d.line(x-1,yy+3,xx,yy,'strawDark');
if(id==='cocksfoot') d.oval(xx+1,yy,2,1.5,'sage');
else if(id==='yorkshire_fog') d.oval(xx,yy,2,2,'rose').dot(xx-1,yy-1,'cream');
else if(id==='common_reed') d.line(xx,yy,xx+s*2,yy-3,'barkLight').line(xx,yy,xx+s*2,yy+2,'straw');
else d.line(xx,yy,xx+1,yy-2,id==='meadow_fescue'?'sage':'strawLight');
}
}
if(id==='common_reed') d.poly([[x,24],[x+7,18],[x+5,23]],'sage').line(x,20,x-5,16,'leafLight');
}
}
function botanicalHerb(d,id,dormant) {
if(['clover','nettle','heather','bluebell'].includes(id)) {
understory(d,id+(dormant?'_dormant':''));
// Catalogue specimen includes diagnostic stolon/flower strings/scale sprays.
if(id==='clover') d.line(6,29,25,28,'leafDark');
if(id==='nettle' && !dormant) for(let j=0;j<4;j++) d.line(19,12+j,23+j,15+j,'sage');
if(id==='heather') d.line(3,28,9,24,'barkLight');
if(id==='bluebell') d.line(4,29,7,18,'sage');
return;
}
const k=BOTANY_GROUPS.herbs.findIndex(r=>r[0]===id), x=15+k%3;
d.line(x,29,x-1,7,'leafDark');
if(id==='yarrow' || id==='meadowsweet') {
for(let y=19;y<28;y+=4) compound(d,x,y,id==='yarrow'?6:9,y%2?-1:1);
if(!dormant) for(let j=-3;j<=3;j++) {
const y=id==='yarrow'?9:6+Math.abs(j)*2;
d.line(x,17,x+j*3,y,'leafDark'); flower(d,x+j*3,y,'cream',4,id==='yarrow'?1:2);
}
} else if(id==='foxglove') {
for(const [a,b] of [[-5,28],[5,26],[-7,25],[7,29]]) specimenLeaf(d,x+a,b,'oval');
if(!dormant) for(let j=0;j<5;j++) {
const y=5+j*4,w=2+j%2;
d.line(x-1,y,x+3,y+1,'sage').poly([[x+2,y],[x+2+w,y],[x+4+w,y+4],[x+2,y+4]],'rose')
.line(x+3,y+3,x+3+w,y+3,'mauve').dot(x+3,y+1,'wine');
}
} else if(id==='wild_garlic') {
specimenLeaf(d,10,29,'lance'); specimenLeaf(d,21,28,'lance');
d.poly([[11,28],[5,17],[8,14],[13,22]],'sage');
if(!dormant) for(const [a,b] of [[-5,1],[-2,-3],[3,-3],[6,1],[1,4]]) {
d.line(x,14,x+a,8+b,'sage'); flower(d,x+a,8+b,'cream',6,2);
}
} else if(id==='cowslip') {
for(const [a,b] of [[-6,29],[-3,27],[4,28],[8,30]]) specimenLeaf(d,x+a,b,'oval');
if(!dormant) for(let j=-2;j<=2;j++) d.line(x,7,x+j*3,8+Math.abs(j),'sage')
.oval(x+j*3,11+Math.abs(j),1.5,3,'ochre').dot(x+j*3,13+Math.abs(j),'gold');
} else { // Anemone: one flower above a whorl of three divided leaves.
for(const s of [-1,0,1]) specimenLeaf(d,x+s*7,23+(s===0?2:0),'lobe',s||1);
if(!dormant) flower(d,x-1,8,'cream',6,4);
}
if(dormant) d.line(x-1,7,x+2,10,'straw');
}
function botanicalFernMoss(d,id,dormant) {
const k=BOTANY_GROUPS.ferns_mosses.findIndex(r=>r[0]===id);
if(k<5) {
if(id==='harts_tongue') {
for(const [x,y,s] of [[7,28,-1],[13,29,1],[20,28,1],[25,29,-1]]) {
d.poly([[x,y],[x-3,y-11],[x-2,y-20],[x+1,y-22],[x+3,y-18],[x+2,y-9]],'leaf')
.line(x,y-2,x,y-19,'leafLight');
for(let j=5;j<18;j+=3) d.line(x,y-j,x+s*2,y-j-1,'barkLight');
}
} else {
const tips=id==='bracken'?[[5,10],[16,3],[27,12]]:id==='lady_fern'?[[6,13],[15,4],[25,9]]:
id==='male_fern'?[[5,17],[12,7],[20,5],[27,18]]:[[7,19],[15,9],[25,14]];
for(const [i,[x,y]] of tips.entries()) {
d.line(16,29,x,y,'leafDark');
const n=id==='lady_fern'?7:5;
for(let j=1;j<n;j++) {
const xx=x+(16-x)*j/n,yy=y+(29-y)*j/n, w=id==='bracken'?j+1:Math.min(j+1,n-j+1);
for(const s of [-1,1]) {
d.line(xx,yy,xx+s*w,yy-2,id==='male_fern'?'leaf':'sage',id==='male_fern'?2:1);
if(id==='bracken' || id==='lady_fern') d.line(xx+s*w,yy-2,xx+s*w,yy-4,'leafLight');
if(id==='polypody' && !dormant) d.dot(xx+s*(w-1),yy-1,'ochre');
}
}
if(i===0 && id==='male_fern') d.oval(16,28,3,1,'barkLight');
}
}
} else {
const centers=[[5,26],[11,23],[18,25],[25,24],[16,29]];
for(const [i,[x,y]] of centers.entries()) {
d.oval(x,y,4,2,'leafDark');
if(id==='sphagnum') for(let j=0;j<6;j++) {
const a=j*Math.PI/3;
d.line(x,y,x+Math.cos(a)*4,y+Math.sin(a)*3,'sage').line(x,y,x+Math.cos(a)*3,y+5,'leaf');
} else if(id==='haircap_moss') {
d.line(x,y,x,y-9,'leaf');
for(let j=2;j<9;j+=2) d.line(x,y-j,x-2,y-j-2,'sage').line(x,y-j,x+2,y-j-2,'sage');
if(!dormant && i%2===0) d.line(x+1,y-6,x+2,y-17,'copper').rect(x+1,y-18,3,2,'barkLight');
} else if(id==='moss') {
d.line(x-3,y+1,x+4,y-4,'leaf');
for(let j=0;j<5;j++) d.line(x-2+j,y-j,x-4+j,y-j-2,'sage').line(x-2+j,y-j,x+1+j,y-j,'leafLight');
} else if(id==='fork_moss') {
for(let j=-2;j<3;j++) d.line(x+j,y,x+j+1,y-5,'leaf').line(x+j+1,y-5,x+j+4,y-8,'sage');
} else {
d.line(x,y,x,y-10,'leafDark');
for(let j=2;j<10;j+=3) d.oval(x-2,y-j,2,1,'sage').oval(x+2,y-j-1,2,1,'leaf');
if(i===2 && !dormant) d.line(x,y-9,x+2,y-17,'barkLight').line(x+2,y-17,x+5,y-15,'copper',2);
}
}
}
}
function botanicalFungus(d,id,dormant) {
const k=BOTANY_GROUPS.fungi.findIndex(r=>r[0]===id);
if(['turkey_tail','oyster_fungus','birch_polypore'].includes(id)) {
d.line(7,28,8,9,'bark',3).line(8,24,25,26,'barkDark',2);
const tiers=id==='birch_polypore'?[[15,17,9,5]]:id==='turkey_tail'?[[15,23,8,4],[20,18,8,4],[13,12,7,3]]:
[[15,23,10,4],[20,17,8,4],[15,10,7,3]];
for(const [x,y,rx,ry] of tiers) {
d.oval(x,y,rx,dormant?1:ry,id==='oyster_fungus'?'stone':'barkLight');
if(!dormant) {
d.oval(x,y-1,rx-2,ry-1,id==='turkey_tail'?'cream':'birchGlow');
if(id==='turkey_tail') d.oval(x,y-2,rx-4,1,'earth').line(x-4,y,x+4,y,'rust');
else if(id==='oyster_fungus') for(let j=-4;j<5;j+=2) d.line(8,y+1,x+j,y+1,'slateShade');
else d.line(x-5,y+3,x+5,y+3,'cream');
}
}
} else {
const cluster=id==='honey_fungus'?[[8,21,5],[16,14,6],[24,20,5]]:[[16,13,k===5?5:k===2?10:9]];
for(const [x,y,w] of cluster) {
d.line(x,29,x,y+3,k===4?'rose':'cream',k===2?4:3);
if(dormant) {
d.oval(x,y+9+k%3,w/2,1+k%2,'earthLight').line(x-3,29,x+4,28,'barkLight');
if(id==='fly_agaric') d.oval(x,28,3,2,'birchGlow');
if(id==='birch_bolete') for(let j=0;j<4;j++) d.dot(x+j%2,20+j*2,'barkDark');
continue;
}
const color={fly_agaric:'rust',birch_bolete:'bark',bay_bolete:'earth',chanterelle:'ochre',wood_blewit:'rose',
shaggy_inkcap:'cream',honey_fungus:'straw'}[id];
if(id==='shaggy_inkcap') {
d.oval(x,12,5,10,'cream').rect(x-5,18,11,3,'slate');
for(let j=0;j<4;j++) d.line(x-3+j*2,6+j%2,x-4+j*2,15+j%3,'barkLight');
d.line(x+4,20,x+4,25,'ink');
} else if(id==='chanterelle') {
d.poly([[x-10,y-2],[x-6,y],[x-2,y-2],[x+3,y],[x+9,y-3],[x+7,y+3],[x+2,y+7],[x-2,y+7],[x-7,y+2]],color)
.line(x-6,y+1,x,y+8,'strawLight').line(x+5,y+1,x+1,y+8,'strawDark');
} else {
d.oval(x,y,w,k===4?3:5,color).line(x-w+1,y+(k===4?2:3),x+w-1,y+(k===4?2:3),k===4?'berryLight':'woodLight');
if(id==='fly_agaric') {
for(const [a,b] of [[-5,0],[-2,-3],[2,-2],[5,1]]) d.rect(x+a,y+b,2,1,'cream');
d.line(x-3,21,x+4,21,'cream').oval(x,28,3,2,'birchGlow');
}
if(id==='birch_bolete') for(let j=0;j<5;j++) d.dot(x+j%2,20+j*2,'barkDark');
if(id==='honey_fungus') d.line(x-2,y+8,x+3,y+8,'wood');
}
}
}
}
function botanicalAquatic(d,id,dormant) {
if(id==='yellow_waterlily' || id==='white_waterlily') {
// True floating round/oval notched pads, never horns or lotus seed pods.
for(const [x,y,rx,ry] of (id==='white_waterlily'?[[10,23,7,6],[23,16,6,5]]:[[10,23,8,5],[23,16,6,4]])) {
d.oval(x,y,rx,ry,'leafDark').oval(x-1,y-1,rx-1,ry-1,'sage');
for(let j=0;j<5;j++) for(let a=0;a<=j/2;a++) d.dot(x+a,y+j,[0,0,0],0);
d.line(x-4,y-1,x-1,y-2,'leafLight');
}
if(!dormant) {
if(id==='white_waterlily') flower(d,12,13,'cream',8,5);
else d.oval(13,13,3,3,'ochre').oval(13,12,2,1,'gold').line(13,16,13,20,'leaf');
}
} else if(id==='duckweed') {
for(const [i,[x,y]] of [[5,10],[15,6],[25,13],[10,21],[23,24]].entries()) {
d.oval(x,y,3,2,'leaf').oval(x+3,y+2,2,2,'sage').dot(x-1,y,'leafLight');
d.line(x,y+2,x+(i%2?1:-1),y+6,'willowShade');
}
} else if(id==='hornwort' || id==='water_starwort') {
d.line(12,28,19,5,'leafDark');
if(id==='hornwort') {
for(let j=0;j<5;j++) for(const s of [-1,1]) {
const x=13+j,y=25-j*4;
d.line(x,y,x+s*7,y-4,'sage').line(x+s*4,y-2,x+s*4,y-5,'leaf')
.line(x+s*7,y-4,x+s*8,y-7,'leafLight');
}
} else {
for(let j=0;j<5;j++) d.line(13+j,26-j*4,7+j,24-j*4,'sage')
.line(13+j,26-j*4,21+j,25-j*4,'leaf');
for(const [x,y] of [[15,7],[20,8],[18,12],[23,11]]) d.oval(x,y,2,3,'sage').dot(x,y-1,'leafLight');
}
} else if(id==='pondweed') {
d.line(15,29,17,8,'barkLight');
for(const [x,y] of [[7,23],[24,19],[10,12]]) d.line(16,y+5,x,y,'leafDark')
.oval(x,y,5,2,'leaf').line(x-3,y,x+3,y,'leafLight');
if(!dormant) d.line(18,14,19,5,'leafDark').oval(19,5,1.5,4,'strawDark');
} else if(id==='water_crowfoot') {
d.line(14,29,17,10,'leafDark');
for(const [x,y] of [[6,23],[23,25],[10,16]]) {
d.line(15,26,x,y,'sage');
for(const s of [-1,0,1]) d.line(x,y,x+s*4,y-4,'leafLight').line(x+s*4,y-4,x+s*5,y-6,'leaf');
}
if(!dormant) flower(d,18,9,'cream',5,3);
} else {
const x=id==='brooklime'?14:17;
d.line(x,29,x,9,'leafDark');
for(let j=0;j<3;j++) for(const s of [-1,1]) specimenLeaf(d,x+s*(4+j%2),28-j*5,
id==='water_mint'?'diamond':'oval',s);
if(!dormant) {
if(id==='water_mint') d.oval(x,8,4,3,'rose').line(x-2,7,x+2,7,'mauve').dot(x,9,'cream');
else {
d.line(x,18,x+7,8,'sage');
for(let j=0;j<4;j++) flower(d,x+6+(j%2?2:0),8+j*4,'slateGlow',5,1);
if(id==='water_forgetmenot') d.line(x+7,8,x+5,5,'sage').oval(x+4,5,1,1,'slateBlue');
}
}
}
}
function botanicalLichen(d,id,dormant) {
const k=BOTANY_GROUPS.lichens.findIndex(r=>r[0]===id);
if(k<3) {
if(id==='old_mans_beard') {
d.line(5,5,27,8,'barkLight');
for(let j=0;j<6;j++) {
const x=6+j*4;
d.line(x,6,x-2,16+j%3,'willow').line(x-2,16+j%3,x+1,26-j%2,'sage')
.line(x-1,13,x+3,20,'leafLight').line(x-1,19,x-4,25,'willow');
}
} else for(let j=0;j<5;j++) {
const x=5+j*5,y=28-j%2*3;
d.line(16,29,x,y-8,'willow',id==='oakmoss'?2:1);
for(const s of [-1,1]) {
d.line(x,y-8,x+s*4,y-15,'leafLight',id==='oakmoss'?2:1);
d.line(x+s*3,y-13,x+s*6,y-16-j%3,'sage').line(x+s*3,y-13,x+s*2,y-19,'cream');
if(id==='reindeer_lichen') d.line(x+s*2,y-17,x+s*5,y-21,'willow');
}
}
} else if(id==='cup_lichen' || id==='pixie_cup') {
for(const [i,[x,y]] of [[7,21],[15,13],[24,18]].entries()) {
const w=id==='pixie_cup'?4:3;
d.line(x,29,x,y+3,'willow',id==='pixie_cup'?3:2)
.poly([[x-w,y],[x+w,y],[x+1,y+5],[x-1,y+5]],'sage')
.line(x-w,y,x+w,y,'cream').line(x-w+1,y+1,x+w-1,y+1,'leafDark');
if(id==='pixie_cup') for(let j=0;j<3;j++) d.dot(x-1+j%2,y+6+j*2,'cream');
d.oval(x+(i%2?3:-2),28,3,1,'willow');
}
} else {
const crust=id==='lichen', color=id==='sunburst_lichen'?'ochre':id==='lungwort_lichen'?'leaf':'willow';
for(const [i,[x,y]] of [[7,15],[15,10],[24,16],[11,24],[22,25]].entries()) {
const r=crust?4:id==='dog_lichen'?6:id==='sunburst_lichen'?4:5;
d.poly([[x-r,y],[x-2,y-r],[x+2,y-r+1],[x+r,y-1],[x+r-1,y+3],[x+1,y+r-1],[x-3,y+3]],
crust?'barkDark':'leafDark');
d.oval(x,y,r-1,crust?2:3,color);
if(crust) d.line(x-2,y,x+2,y-1,'sage').dot(x+2,y+1,'straw');
else if(id==='sunburst_lichen') d.oval(x,y,2,2,'copper').dot(x,y,'ochre');
else if(id==='dog_lichen') d.line(x-3,y+2,x,y+3,'earth').line(x,y+3,x-1,y+5,'cream');
else d.line(x-3,y,x+2,y-2,'leafLight').line(x,y-1,x+2,y+2,'leafLight')
.line(x+2,y+2,x-1,y+3,id==='hammered_shield'?'cream':'sage');
if(id==='lungwort_lichen') d.poly([[x+2,y+1],[x+5,y+4],[x+3,y+7],[x,y+5]],'leaf').line(x+2,y+2,x+2,y+5,'sage');
if(id==='hammered_shield') d.rect(x-4,y-3,3,2,'willow').dot(x-4,y-3,'cream');
if(dormant && i===2) d.dot(x,y,'barkLight');
}
}
}
function botanySprite(record,dormant=false) {
const d=new Drawing();
({trees:botanicalTree,shrubs:botanicalShrub,grasses:botanicalGrass,herbs:botanicalHerb,
ferns_mosses:botanicalFernMoss,fungi:botanicalFungus,aquatic:botanicalAquatic,lichens:botanicalLichen})[record.group](d,record.id,dormant);
// Cutouts have one-pixel journal padding. Inactive specimens lose bloom/fruit
// above and settle to a reduced dry silhouette, not a tinted active sprite.
const out=new Drawing();
for(let y=1;y<31;y++) for(let x=1;x<31;x++) {
const p=d.data.subarray((y*32+x)*4,(y*32+x+1)*4);
if(!p[3]) continue;
if(dormant) {
const luminance=p[0]+p[1]+p[2];
const dry=luminance>470?'strawLight':luminance>350 || (p[1]>p[0] && luminance>310)?'straw':'barkLight';
out.dot(16+Math.round((x-16)*.86),29-Math.round((29-y)*.72),dry);
} else out.dot(x,y,[...p.subarray(0,3)]);
}
return out;
}
export function worldArt() {
const images=new Map();
for(const name of WORLD_NAMES) images.set(`hearth_${name}.png`,worldTile(name));
for(const name of ITEM_NAMES) images.set(`hearth_item_${name}.png`,itemTile(name));
for(const name of ['hotbar','selected','crosshair']) images.set(`hearth_${name}.png`,uiTile(name));
for(const name of ANIMAL_NAMES) images.set(`hearth_${name}.png`,animalTexture(name));
for(const r of BOTANY_CATALOGUE) for(const dormant of [false,true])
images.set(`hearth_botany_${r.id}${dormant?'_dormant':''}.png`,botanySprite(r,dormant));
return images;
}
// Sorted filename order is also the contact sheet's legend: zero-based row-major,
// 16 columns, unscaled 32px cells. Alpha composited over journal checker paper.
export function contactSheet(images=worldArt()) {
if(!(images instanceof Map) || images.size<1 || images.size>512 || [...images.values()].some(image=>
image?.width!==32 || image?.height!==32 || !(image.data instanceof Uint8Array) || image.data.length!==4096))
throw new Error('Contact sheet requires 1..512 RGBA 32px tiles.');
const columns=16, width=columns*32, height=Math.ceil(images.size/columns)*32;
const data=new Uint8Array(width*height*4);
for(let y=0;y<height;y++) for(let x=0;x<width;x++) {
const color=(Math.floor(x/8)+Math.floor(y/8))%2?'outline':'ink';
data.set(rgba(color),(y*width+x)*4);
}
[...images.entries()].sort(([a],[b])=>String(a)<String(b)?-1:String(a)>String(b)?1:0).forEach(([,image],i)=> {
for(let y=0;y<32;y++) for(let x=0;x<32;x++) {
const from=(y*32+x)*4, to=((Math.floor(i/columns)*32+y)*width+(i%columns)*32+x)*4;
const alpha=image.data[from+3]/255;
for(let c=0;c<3;c++) data[to+c]=Math.round(image.data[from+c]*alpha+data[to+c]*(1-alpha));
}
});
return {width,height,data};
}
// Compatibility swatches are intentionally quiet clustered soil, not neon/hash
// noise. Named materials above are the integration target; no Lua parsing needed.
export function legacyPixel(hex,x,y) {
const rgb=[0,2,4].map(i=>parseInt(hex.slice(i,i+2),16));
const shade=(y>=19 && y<=22)?-8:((x>=5 && x<=10 && y>=6 && y<=8)?8:0);
return [...rgb.map((v,i)=>Math.round(v*.35+PALETTE.earth[i]*.65)+shade),255];
}