This commit is contained in:
2026-11-05 09:57:11 -05:00
parent d34388bf2c
commit 89e392cc2e
81 changed files with 12410 additions and 1135 deletions
+10 -1
View File
@@ -7,6 +7,7 @@ import { BODY_PROFILES, playerB3D, soundOGG, SOUND_PROFILES, TOOL_NAMES, toolOBJ
import { ANIMAL_NAMES, BOTANY_CATALOGUE, animalOBJ, contactSheet, legacyPixel, worldArt } from './world-art.mjs';
import { FLORA_NAMES, TREE_SEASONS, SPECIES_ATLAS_NAMES, floraOBJ, treeAtlas } from './flora-art.mjs';
import { CHARACTER_ART_MANIFEST, CHARACTER_OPTIONS, characterArt, characterMesh, compositeCharacter, portraitSheet } from './character-art.mjs';
import { FAUNA_NAMES, FAUNA_ANIMATIONS, FAUNA_ART_MANIFEST, faunaB3D, faunaTexture, faunaPortraitSheet } from './fauna-art.mjs';
function crc32(data) {
let crc = 0xffffffff;
@@ -119,7 +120,9 @@ export async function generateAssets(destination, source) {
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
}
for (const name of SPECIES_ATLAS_NAMES) {
const [species, , season='summer'] = name.split('_'), trees=treeAtlas(art,season,species);
const match=/^([a-z]+(?:_[a-z]+)*)_atlas(?:_(spring|autumn|winter))?$/.exec(name);
if(!match) throw new Error('Invalid species atlas name');
const [,species,season='summer']=match, trees=treeAtlas(art,season,species);
await writeAsset(textures, `hearth_${name}.png`, png(trees.width,
(x,y)=>trees.data.subarray((y*trees.width+x)*4,(y*trees.width+x+1)*4), trees.height));
}
@@ -135,6 +138,11 @@ export async function generateAssets(destination, source) {
}
const encode = image => png(image.width,
(x,y)=>image.data.subarray((y*image.width+x)*4,(y*image.width+x+1)*4),image.height);
for(const id of FAUNA_NAMES) await writeAsset(textures,`hearth_fauna_${id}.png`,encode(faunaTexture(id)));
await writeAsset(textures,'hearth_fauna_contact_sheet.png',encode(faunaPortraitSheet()));
await writeAsset(destination,'fauna-art.json',JSON.stringify({format:1,animations:FAUNA_ANIMATIONS,
animals:FAUNA_ART_MANIFEST,portraitSheet:{filename:'hearth_fauna_contact_sheet.png',width:512,height:768,
frame:9,columns:4,cellSize:128,order:FAUNA_NAMES}},null,2)+'\n');
for (const [name,image] of characterArt()) await writeAsset(textures,name,encode(image));
await writeAsset(textures,'hearth_person.png',encode(compositeCharacter()));
await writeAsset(textures,'hearth_character_contact_sheet.png',encode(portraitSheet()));
@@ -146,6 +154,7 @@ export async function generateAssets(destination, source) {
await ensureDirectory(models);
for (const name of FLORA_NAMES) await writeAsset(models, `hearth_${name}.obj`, floraOBJ(name));
for (const name of ANIMAL_NAMES) await writeAsset(models, `hearth_${name}.obj`, animalOBJ(name));
for (const id of FAUNA_NAMES) await writeAsset(models,`hearth_fauna_${id}.b3d`,faunaB3D(id));
let vertices = 0;
const obj = ['# Original Last Hearth block character', 'vt 0 0','vt 1 0','vt 1 1','vt 0 1'];
function box(x0,y0,z0,x1,y1,z1) {
+42 -15
View File
@@ -5,16 +5,16 @@ import { ATLAS, BODY_PROFILES } from './media.mjs';
export const CHARACTER_OPTIONS = Object.freeze(Object.fromEntries(Object.entries({
bodies: BODY_PROFILES,
skin: ['porcelain','sand','honey','olive','umber','ebony'],
faces: ['calm','angular','soft','weathered'],
faces: ['calm','angular','soft','weathered','lined'],
eyes: ['hazel','brown','green','blue','grey'],
hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none'],
hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none','curly','topknot','undercut'],
hairColors: ['ash','flax','chestnut','auburn','black','silver'],
facialHair: ['none','stubble','moustache','short_beard','full_beard'],
garments: ['tunic','workshirt','jerkin','coat'],
facialHair: ['none','stubble','moustache','short_beard','full_beard','goatee','braided_beard'],
garments: ['tunic','workshirt','jerkin','coat','hooded_cloak','travel_coat','wool_mantle'],
clothColors: ['sage','teal','slate','mauve','ochre','rust','linen','charcoal'],
trousers: ['earth','slate','moss','charcoal'], boots: ['walnut','black','tan'],
accents: ['none','scarf','apron','sash','vest'],
details: ['none','freckles','scar','freckles_scar'],
accents: ['none','scarf','apron','sash','vest','satchel','tool_belt','pauldrons','hood'],
details: ['none','freckles','scar','freckles_scar','birthmark'],
}).map(([key, values])=>[key,Object.freeze([...values])])));
const palette = entries => Object.freeze(Object.fromEntries(Object.entries(entries)
@@ -66,6 +66,7 @@ function faceLayer(id) {
angular: [[1,2],[2,2],[5,2],[6,1],[4,4],[4,5],[2,6],[3,6]],
soft: [[2,2],[5,2],[3,5],[4,5],[3,6]],
weathered: [[1,2],[2,2],[5,2],[6,2],[1,4],[6,4],[4,4],[2,5],[5,5],[3,6],[4,6]],
lined: [[1,1],[2,2],[5,2],[6,1],[1,3],[6,3],[2,4],[5,4],[4,4],[3,6],[4,6]],
};
for (const [x,y] of marks[id]) dot(out,'face',x,y,y===6 ? [112,70,58] : INK);
return out;
@@ -85,12 +86,15 @@ function hairLayer(style, color) {
if (style==='sidepart') return y<1 || (y===1 && x<6) || (y===2 && x<2);
if (style==='wavy') return y===0 || (y===1 && x%3!==0) || (y===2 && x===0);
if (style==='braids') return y<1 || (x===0 || x===7) && y<6;
if (style==='curly') return y===0 || (y===1 && (x+y)%3!==0) || (y===2 && x%3===0);
if (style==='topknot') return y===0 && x>=2 && x<=5 || y===1 && x>=3 && x<=4 || y===2 && x<2;
if (style==='undercut') return y===0 || y===1 && x>4 || y===2 && x===7;
return y<1 || (x<1 || x>6) && y<7;
};
paint(out,'face',(x,y)=>fringe(x,y) ? strand(x,y) : null);
paint(out,'hair',(x,y)=>style==='shaved' && (x+y)%2 ? null : strand(x,y));
paint(out,'side',(x,y)=> {
const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1}[style];
const limit = {cropped:2,sidepart:3,wavy:4,braids:7,long:8,shaved:1,curly:3,topknot:2,undercut:3}[style];
if (y>=limit || (y>=3 && x>=3 && x<=5)) return null; // exposed ear
if (style==='shaved' && x%2) return null;
return strand(x,y);
@@ -101,6 +105,7 @@ function hairLayer(style, color) {
paint(out,'sleeve',(x,y)=>y<2 && (x===0 || x===7) ? strand(x,y) : null);
if (style==='braids') for (const x of [0,7]) dot(out,'face',x,5,THREAD);
}
if (style==='topknot') paint(out,'hair',(x,y)=>x>=2&&x<=5&&y<2?strand(x,y):null);
return out;
}
function beardLayer(style,color) {
@@ -108,10 +113,11 @@ function beardLayer(style,color) {
paint(out,'face',(x,y)=> {
const moustache = y===5 && x>=2 && x<=5;
const lower = y>=6 && x>=1 && x<=6;
const mask = style==='moustache' ? moustache : style==='stubble' ?
const chin = (y===6 && x>=3 && x<=4) || (y===7 && x===3);
const mask = style==='moustache' ? moustache : style==='goatee' ? moustache || chin : style==='braided_beard' ? moustache || lower || y===4&&x>=2&&x<=5 : style==='stubble' ?
(lower && (x+y)%2===0) : style==='short_beard' ? moustache || (lower && x>=2 && x<=5) :
moustache || lower || (y===4 && (x===1 || x===6));
return mask ? tint(HAIR[color],(x+y)%3===0 ? 9 : -4) : null;
return mask ? tint(HAIR[color],style==='braided_beard'&&y>=6&&(y%2===0)?15:(x+y)%3===0 ? 9 : -4) : null;
});
if (style==='full_beard') paint(out,'side',(x,y)=>y>=5 && x<3 ? HAIR[color] : null);
return out;
@@ -128,11 +134,18 @@ function garmentLayer(style,color) {
if (style==='workshirt' && y===3 && x>=5 && x<=6) c=tint(base,-25);
if (style==='jerkin' && (x===1 || x===6 || y===5)) c=LEATHER;
if (style==='jerkin' && y===2 && (x===3 || x===4)) c=THREAD;
if (style==='coat' && (x===3 || x===4)) c=tint(base,-28);
if (style==='coat' && x===5 && [2,4,6].includes(y)) c=THREAD;
if (style==='coat' && y===0 && (x===1 || x===6)) c=tint(base,17);
if (style==='coat' || style==='travel_coat' || style==='hooded_cloak') {
if (x===3 || x===4) c=tint(base,-28);
if (x===5 && [2,4,6].includes(y)) c=THREAD;
if (y===0 && (x===1 || x===6)) c=tint(base,17);
}
if (style==='hooded_cloak' && y>=3 && (x===0 || x===7)) c=tint(base,-32);
if (style==='travel_coat' && y>=3 && (x===1 || x===6)) c=LEATHER;
if (style==='wool_mantle' && y<3) c=tint(base,(x+y)%2?12:-14);
}
if (tile==='back' && (style==='coat' && x===4 || style==='jerkin' && y===5)) c=tint(base,-23);
if (tile==='back' && ((style==='coat'||style==='travel_coat'||style==='hooded_cloak') && (x===3||x===4) || style==='jerkin' && y===5)) c=tint(base,-23);
if (tile==='back' && style==='hooded_cloak' && y<6) c=tint(base,-35);
if (tile==='back' && style==='wool_mantle' && y<4) c=tint(base,(x+y)%2?8:-10);
if (tile==='sleeve' && style==='jerkin' && y>=3) c=fabric(CLOTH.linen,x,y);
if (tile==='sleeve' && style==='workshirt' && y===5) c=THREAD;
return c;
@@ -152,22 +165,35 @@ function bootsLayer(id) {
function accentLayer(style,color) {
const out=image(); if (style==='none') return out;
const base=CLOTH[color];
if (style==='hood') {
for (const tile of ['face','hair','side']) paint(out,tile,(x,y)=>
tile==='face' ? (y<2||x<2||x>5?fabric(base,x,y):null) :
tile==='hair' ? (y<5?fabric(base,x,y):null) : (x<2||x>5)&&y<5?fabric(base,x,y):null);
return out;
}
for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> {
let mask=false;
if (style==='scarf') mask=y===0 || tile==='tunic' && x===2 && y<4;
if (style==='apron') mask=tile==='tunic' ? x>=1 && x<=6 && y>=2 : tile==='back' && y===3;
if (style==='sash') mask=tile!=='sleeve' && (x===y || x===y+1);
if (style==='vest') mask=tile!=='sleeve' && (tile==='back' || x<=2 || x>=5);
if (style==='tool_belt') mask=tile==='tunic' && (y===5 || y===6) || tile==='sleeve'&&y===6;
if (style==='pauldrons') mask=tile==='sleeve'&&y<3 || tile==='tunic'&&y<1;
if (style==='satchel') mask=tile==='back'&&x>=4&&y>=2 || tile==='sleeve'&&x>=5&&y>=2;
return mask ? fabric(base,x,y) : null;
});
if (style==='apron') for (const x of [3,4,5]) dot(out,'tunic',x,5,tint(base,-23));
if (style==='vest') for (const y of [2,4]) dot(out,'tunic',5,y,THREAD);
if (style==='tool_belt') for(const x of [1,2,5,6]) dot(out,'tunic',x,5,LEATHER);
if (style==='satchel') for(const [x,y] of [[4,3],[5,3],[4,4],[5,4],[4,5]]) dot(out,'back',x,y,THREAD);
if (style==='pauldrons') for(const tile of ['sleeve']) for(const x of [1,6]) dot(out,tile,x,1,THREAD);
return out;
}
function detailLayer(id) {
const out=image();
if (id.includes('freckles')) for (const [x,y] of [[1,4],[2,5],[5,5],[6,4]]) dot(out,'face',x,y,[119,76,51]);
if (id.includes('scar')) for (const [x,y] of [[6,2],[6,3],[7,4]]) dot(out,'face',x,y,[158,109,91]);
if (id==='birthmark') for(const [x,y] of [[6,4],[7,4],[6,5]]) dot(out,'face',x,y,[143,82,75]);
return out;
}
@@ -213,8 +239,9 @@ export function characterLayers(options = {}) {
// Clothing precedes long hair: shoulder locks must not be erased by tunics.
return [name(`skin_${p.skin}`),name(`face_${p.face}`),name(`eyes_${p.eyes}`),
name(`garment_${p.garment}_${p.cloth}`),name(`trousers_${p.trousers}`),name(`boots_${p.boots}`),
name(`accent_${p.accent}_${p.accentColor}`),name(`hair_${p.hair}_${p.hairColor}`),
name(`beard_${p.beard}_${p.hairColor}`),name(`detail_${p.detail}`)];
name(`hair_${p.hair}_${p.hairColor}`),name(`beard_${p.beard}_${p.hairColor}`),
name(`accent_${p.accent}_${p.accentColor}`),
name(`detail_${p.detail}`)];
}
export function characterTexture(options = {}) { return characterLayers(options).join('^'); }
export function characterMesh(body = 'balanced') {
+318
View File
@@ -0,0 +1,318 @@
// 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<height;y++) for(let x=0;x<width;x++) data.set([...PALETTE[(Math.floor(x/8)+Math.floor(y/8))%2?'outline':'ink'],255],(y*width+x)*4);
FAUNA_NAMES.forEach((id,index)=> {
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(z<depth[local]) continue; depth[local]=z;
const uv=[6,7].map(k=>ids.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};
}
+2 -1
View File
@@ -234,7 +234,8 @@ export function treeAtlas(art=worldArt(), season='summer', species=null) {
const prefix=species?`${species}_`:'', leaves=`${prefix}leaves${season==='summer'?'':`_${season}`}`;
const underpaint=season==='summer' && species?
{oak:'leafDark',birch:'leaf',pine:'pineShade',willow:'willowShade',alder:'leafDark',
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade'}[species]:
beech:'leafDark',aspen:'leafDark',rowan:'leafDark',hazel:'leafDark',spruce:'pineShade',
crab_apple:'leafDark',wild_cherry:'leafDark',field_maple:'leafDark'}[species]:
{summer:'leafDark',spring:'springDark',autumn:'autumnDark',winter:'barkDark'}[season];
for(const [half,name] of [`${prefix}log_side`,leaves].entries()) {
const image=art.get(`hearth_${name}.png`);
+14 -2
View File
@@ -114,14 +114,26 @@ function pose(name, frame) {
cx*sy*cz + sx*cy*sz, cx*cy*sz - sx*sy*cz]};
}
export const BODY_PROFILES = Object.freeze(['compact', 'balanced', 'broad']);
export const BODY_PROFILES = Object.freeze(['compact', 'lean', 'balanced', 'broad', 'sturdy']);
export function playerB3D(profile = 'balanced') {
if (!BODY_PROFILES.includes(profile)) throw new Error('Unknown character body profile.');
const width = {compact: .92, balanced: 1, broad: 1.08}[profile];
const width = {compact: .86, lean: .93, balanced: 1, broad: 1.08, sturdy: 1.16}[profile];
const vertices = [], triangles = [], weights = Object.fromEntries(skeleton.map(b => [b.name, []]));
box(vertices, triangles, weights, 'Body', [-2.15*width,8,-1.18,2.15*width,13.5,1.18],
['back','tunic','tunic','tunic','tunic','tunic'], [profile === 'broad' ? .9 : .86, .86]);
box(vertices, triangles, weights, 'Head', [-1.6,13.5,-1.4,1.6,PLAYER_HEIGHT,1.4], ['hair','face','skin','hair','side','side']);
// Original layered fieldwear: separate low-poly mantle, belt and satchel
// keep the survivor readable at a distance without changing their hitbox.
box(vertices, triangles, weights, 'Body', [-2.15*width,1.2,-1.58,2.15*width,11.8,-1.22],
['back','back','back','back','back','back'], [profile === 'broad' ? .94 : .88, .92]);
box(vertices, triangles, weights, 'Body', [-2.2*width,8.05,-1.25,2.2*width,9.05,1.25],
['wrap','wrap','wrap','wrap','wrap','wrap']);
box(vertices, triangles, weights, 'Body', [1.35*width,3.8,-1.9,3.35*width,7.9,-1.43],
['wood','wrap','wood','wood','wood','wrap'], [.9,.92]);
box(vertices, triangles, weights, 'Head', [-.32,14.2,1.2,.32,15.1,1.5],
['skin','skin','skin','skin','skin','skin'], [.68,.7]);
for (const side of [-1,1]) box(vertices, triangles, weights, 'Head',
[side<0?-1.6:1.34,14.6,-.15,side<0?-1.34:1.6,15.5,.75], 'skin', [.7,.8]);
for (const [side, sign] of [['Right', -1], ['Left', 1]]) {
const x = sign * 3.1;
box(vertices, triangles, weights, `Arm_${side}`, [x-.78*width,8.6,-.84,x+.78*width,13,.84], 'sleeve', [.9,.95]);
+24 -2
View File
@@ -7,6 +7,9 @@ import { generateAssets } from './assets.mjs';
import { absolute, atomicDirectory, copyTree, disjoint, exists, json, relativeName, safePath, verifyManifest, writeManifest } from './files.mjs';
export const SOURCE = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const WORLDGEN_REPO = path.resolve(SOURCE, '..', 'luanti-mapgenerator-epic');
const WORLDGEN_SOURCE = path.join(WORLDGEN_REPO, 'mods', 'epic_terrain');
const BUNDLED_WORLDGEN = path.basename(SOURCE) === 'last_hearth' && path.dirname(WORLDGEN_REPO) === path.dirname(SOURCE);
export async function readRelease(source = SOURCE) {
await safePath(path.join(absolute(source), 'release.json'));
const release = JSON.parse(await fs.readFile(path.join(source, 'release.json'), 'utf8'));
@@ -21,6 +24,11 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
// dist/ within the repository is supported; no payload source may overlap it.
for (const entry of ['mods', 'fixtures', 'tests', 'scripts']) disjoint(path.join(source, entry), destination);
const release = await readRelease(source);
if (source === SOURCE && BUNDLED_WORLDGEN) {
disjoint(WORLDGEN_SOURCE, destination);
const config = await safePath(path.join(WORLDGEN_SOURCE, 'mod.conf'));
if (!(await fs.lstat(config)).isFile()) throw new Error('Epic-scale mapgen mod is missing from the workspace.');
}
return atomicDirectory(destination, async stage => {
for (const name of ['game.conf', 'minetest.conf', 'settingtypes.txt', 'LICENSE.txt']) {
const filename = await safePath(path.join(source, name));
@@ -30,6 +38,9 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
await copyTree(path.join(source, 'mods'), path.join(stage, 'mods'), {
exclude: new Set(['hearth/textures', 'hearth/models']),
});
if (source === SOURCE && BUNDLED_WORLDGEN) {
await copyTree(WORLDGEN_SOURCE, path.join(stage, 'mods/epic_terrain'));
}
for (const name of ['init', 'model', 'runtime', 'content', 'encounters']) {
if (!await exists(path.join(stage, 'mods/hearth', name + '.lua'))) throw new Error('Game payload is incomplete.');
}
@@ -51,12 +62,23 @@ export async function packageGame(destination, { source = SOURCE } = {}) {
}
await fs.writeFile(path.join(stage, 'release.json'), json(release), { flag: 'wx' });
await writeManifest(stage, 'game');
await verifyPackage(stage);
await verifyPackage(stage, {requireBundled: source === SOURCE && BUNDLED_WORLDGEN});
});
}
export async function verifyPackage(directory) {
export async function verifyPackage(directory, {requireBundled} = {}) {
const manifest = await verifyManifest(directory, 'game');
await readRelease(directory);
if (requireBundled === undefined) {
const configPath = await safePath(path.join(absolute(directory), 'mods/hearth/mod.conf'));
const config = await fs.readFile(configPath, 'utf8');
requireBundled = /^\s*depends\s*=.*(?:^|[,\s])creatura(?:$|[,\s])/m.test(config);
}
if (requireBundled && BUNDLED_WORLDGEN && !manifest.entries.some(entry => entry.path === 'mods/epic_terrain/init.lua')) {
throw new Error('Packaged Last Hearth is missing its bundled epic_terrain generator.');
}
if (requireBundled && !manifest.entries.some(entry => entry.path === 'mods/creatura/LICENSE')) {
throw new Error('Packaged Last Hearth is missing Creatura license attribution.');
}
return manifest;
}
+2 -1
View File
@@ -178,7 +178,8 @@ export async function writeWorld(world, config, location, extra = [], environmen
'movement_speed_jump = 6.5', 'secure.enable_security = true',
...graphics,
...terrainSettings(newWorld),
...settings.filter(line => newWorld || !isTerrainSetting(line)), '',
...settings.filter(line => newWorld || !isTerrainSetting(line)),
...(newWorld ? [] : ['epic_terrain_enable = false']), '',
].join('\n'), { flag: 'wx', mode: 0o600 });
}
+11 -9
View File
@@ -1,22 +1,24 @@
// Original v7 preset: creation-time inputs, never a saved-world migration.
// Keep in sync with game defaults; map_meta.txt belongs exclusively to Luanti.
// Original epic_terrain mod uses v7 only as a cave/ore substrate. Its switch,
// seed and geology are creation-time inputs, never a saved-world migration.
// Keep in sync with the game's bundled mapgen defaults.
const NEW_WORLD = Object.freeze([
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 1',
'mg_name = v7', 'fixed_map_seed = 731104', 'water_level = 32',
'mapgen_limit = 4096',
'mg_flags = caves,nodungeons,light,decorations,biomes,ores',
'mg_flags = caves,nodungeons,light,biomes,ores',
'mgv7_spflags = mountains,ridges,nofloatlands,caverns',
'mgv7_mount_zero_level = 0',
'mgv7_np_terrain_base = 12, 45, (384, 384, 384), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = 12, 28, (384, 384, 384), 5934, 5, 0.6, 2.0',
'mgv7_np_terrain_base = 12, 45, (900, 384, 900), 82341, 5, 0.6, 2.0',
'mgv7_np_terrain_alt = -8, 24, (900, 384, 900), 5934, 5, 0.6, 2.0',
'mgv7_np_terrain_persist = 0.6, 0.1, (1200, 1200, 1200), 539, 3, 0.6, 2.0',
'mgv7_np_height_select = -8, 16, (320, 320, 320), 4213, 6, 0.7, 2.0',
'mgv7_np_height_select = -4, 14, (1400, 320, 1400), 4213, 6, 0.7, 2.0',
'mgv7_np_filler_depth = 0, 1.2, (150, 150, 150), 261, 3, 0.7, 2.0',
'mgv7_np_mount_height = 250, 100, (700, 700, 700), 72449, 3, 0.6, 2.0',
'mgv7_np_mountain = -0.6, 1, (180, 280, 180), 5333, 5, 0.63, 2.0',
'mgv7_np_mountain = -0.6, 1, (320, 280, 320), 5333, 5, 0.63, 2.0',
'mgv7_np_ridge_uwater = 0, 1, (700, 700, 700), 85039, 5, 0.6, 2.0',
'mgv7_np_ridge = 0, 1, (100, 100, 100), 6467, 4, 0.75, 2.0',
'mg_biome_np_heat = 50, 50, (256, 256, 256), 5349, 3, 0.5, 2.0',
'mg_biome_np_humidity = 50, 50, (256, 256, 256), 842, 3, 0.5, 2.0',
'epic_terrain_enable = true',
]);
export function terrainSettings(newWorld) {
@@ -25,5 +27,5 @@ export function terrainSettings(newWorld) {
}
export function isTerrainSetting(line) {
return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit)\s*=/.test(line);
return /^\s*(?:mg\w*|fixed_map_seed|water_level|mapgen_limit|epic_terrain_enable)\s*=/.test(line);
}
+6 -6
View File
@@ -8,7 +8,7 @@ import { safePath } from './files.mjs';
const ROOT = fileURLToPath(new URL('../', import.meta.url));
const CATALOGUE = path.join(ROOT, 'mods/hearth/botany.lua');
const OUTPUT = path.join(ROOT, 'mods/hearth/wiki_articles.lua');
export const LIMITS = Object.freeze({ species: 80, batch: 10, concurrency: 2,
export const LIMITS = Object.freeze({ species: 100, batch: 10, concurrency: 2,
responseBytes: 2 * 1024 * 1024, catalogueBytes: 128 * 1024,
characters: 8000, requestMs: 15000, totalMs: 90000, redirects: 2 });
export const USER_AGENT = 'LastHearthSpeciesBundle/1.0 (offline educational game references; explicit manual maintenance)';
@@ -30,7 +30,7 @@ function latinName(value) {
return value;
}
function validateCatalogue(records) {
requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..80.');
requireValue(Array.isArray(records) && records.length > 0 && records.length <= LIMITS.species, 'Catalogue scope must be 1..100.');
const ids = new Set(), names = new Set();
for (const r of records) {
requireValue(r && typeof r.id === 'string' && /^[a-z][a-z_]{0,39}$/.test(r.id) &&
@@ -51,7 +51,7 @@ export function parseCatalogue(source) {
requireValue(m, 'Unsupported catalogue literal.');
return { id: m[1], latin: m[2] };
});
requireValue(records.length === LIMITS.species, 'Expected exactly 80 botany catalogue rows.');
requireValue(records.length === LIMITS.species, 'Expected exactly 100 botany catalogue rows.');
return validateCatalogue(records);
}
@@ -312,7 +312,7 @@ export async function main(args = process.argv.slice(2)) {
requireValue(args.length === 1 && ['--refresh', '--check', '--help'].includes(args[0]),
'Usage: node scripts/wiki-species.mjs --refresh | --check | --help (no URLs/output paths accepted)');
if (args[0] === '--help') {
console.log('--refresh: query all 80 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.');
console.log('--refresh: query all 100 species; replace the fixed Lua cache with verified intros or explicit missing-page notes after full validation.\n--check: verify fixed offline cache/catalogue without network.');
return;
}
const records = await readCatalogue();
@@ -336,7 +336,7 @@ export async function main(args = process.argv.slice(2)) {
e.history_url === `${ORIGIN}/w/index.php?title=${wikiTitle(e.title)}&action=history` &&
e.attribution === `Wikipedia contributors, "${e.title}"` && Array.from(e.text).length <= LIMITS.characters, 'Invalid cached attribution/excerpt.');
}
console.log(`Verified ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes; network requests: 0.`);
console.log(`Verified ${entries.length}/${LIMITS.species} species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes; network requests: 0.`);
return;
}
const entries = await fetchEntries(records);
@@ -347,7 +347,7 @@ export async function main(args = process.argv.slice(2)) {
await safePath(OUTPUT);
const handle = await fs.open(OUTPUT, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | (constants.O_NOFOLLOW || 0), 0o644);
try { await handle.writeFile(source, 'utf8'); } finally { await handle.close(); }
console.log(`Cached ${entries.length}/80 species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`);
console.log(`Cached ${entries.length}/${LIMITS.species} species: ${entries.filter(e => e.kind === 'wikipedia_intro').length} Wikipedia introductions, ${entries.filter(e => e.kind === 'original_notes').length} original availability notes (${entries.filter(e => e.truncated).length} shortened); ${Math.ceil(records.length / LIMITS.batch)} API batches, concurrency <=2.`);
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
+228 -8
View File
@@ -160,7 +160,8 @@ function planks(d,vertical=false,species='oak') {
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];
spruce:['strawLight','wood','cream'],crab_apple:['wood','earthLight','cream'],
wild_cherry:['clay','bark','peach'],field_maple:['strawLight','barkLight','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);
@@ -174,8 +175,19 @@ function bark(d,species='oak') {
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];
hazel:['barkLight','loamShade','willow'],spruce:['earth','humus','rust'],
crab_apple:['earth','barkDark','willow'],wild_cherry:['rust','bark','woodLight'],
field_maple:['barkLight','earthDark','straw']}[species];
d.rect(0,0,32,32,base);
if(['crab_apple','wild_cherry','field_maple'].includes(species)) {
for(const [i,[x,y]] of GRAINS.entries()) {
if(species==='wild_cherry') d.line(x,y,x+4+i%3,y,dark).line(x+1,y-1,x+4,y-1,light);
else if(species==='field_maple') d.poly([[x,y-3],[x+2,y],[x,y+5],[x-2,y+1]],dark)
.line(x,y-2,x,y+2,light);
else d.line(x,y,x+1,y+4,dark).line(x+2,y+1,x+2,y+3,light);
}
return;
}
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()) {
@@ -213,7 +225,8 @@ function endgrain(d,species='oak') {
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];
spruce:['strawLight','wood','cream'],crab_apple:['wood','earthLight','cream'],
wild_cherry:['clay','bark','peach'],field_maple:['strawLight','barkLight','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);
@@ -392,7 +405,8 @@ function plant(d,name) {
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 SPECIES_NAMES = Object.freeze(['oak','birch','pine','willow','alder','beech','aspen','rowan','hazel','spruce',
'crab_apple','wild_cherry','field_maple']);
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']);
@@ -444,6 +458,16 @@ function speciesLeaves(d,species,season='summer') {
} 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 if(species==='field_maple') {
d.poly([[x,y+4],[x-2,y+1],[x-5,y],[x-3,y-2],[x-4,y-5],[x-1,y-3],
[x,y-7],[x+2,y-3],[x+5,y-4],[x+3,y-1],[x+5,y+1],[x+1,y+2]],dark)
.line(x,y+2,x,y-4,base).line(x,y,x+3,y-2,light).line(x,y,x-3,y-2,base);
} else if(species==='crab_apple'||species==='wild_cherry') {
const cherry=species==='wild_cherry';
d.poly([[x,y+4],[x-4,y],[x-3,y-3],[x+(cherry?2:0),y-6],[x+4,y-2],[x+3,y+1]],dark)
.line(x,y+2,x+1,y-3,base,2).line(x,y,x-2,y-2,light);
if(spring&&i%2===0) flower(d,x+3,y-3,cherry?'cream':'bud',5,1.5);
if(autumn&&i===1) d.oval(x+3,y+3,1,1,cherry?'wine':'ochre');
} 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);
@@ -595,8 +619,8 @@ function habitatTile(d,name) {
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 speciesMatch=name.match(/^([a-z]+(?:_[a-z]+)*)_(log_top|log_side|planks|leaves)(?:_(spring|autumn|winter))?$/);
if(speciesMatch && SPECIES_NAMES.includes(speciesMatch[1])) {
const [,species,part,season]=speciesMatch;
if(part==='leaves') speciesLeaves(d,species,season);
else if(part==='log_top') endgrain(d,species);
@@ -989,6 +1013,9 @@ const BOTANY_GROUPS = {
['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'],
['crab_apple','Malus sylvestris','oval serrate leaves; pink white blossom; small pomes'],
['wild_cherry','Prunus avium','pointed serrate leaves; paired cherries; horizontal lenticels'],
['field_maple','Acer campestre','five blunt leaf lobes; paired winged samaras'],
],
shrubs: [
['bilberry','Vaccinium myrtillus','angular green twigs; small ovals; solitary blue berries'],
@@ -1001,6 +1028,10 @@ const BOTANY_GROUPS = {
['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'],
['honeysuckle','Lonicera periclymenum','twining stems; paired leaves; long curved honey tubes'],
['ivy','Hedera helix','climbing rootlets; lobed leaves; globular autumn umbels'],
['gorse','Ulex europaeus','dense branching green spines; golden pea flowers'],
['broom','Cytisus scoparius','ridged green switches; sparse trifoliate leaves; pea flowers'],
],
grasses: [
['sedge','Carex paniculata','triangular culms; dense branched panicle; tussock'],
@@ -1025,6 +1056,18 @@ const BOTANY_GROUPS = {
['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'],
['dandelion','Taraxacum officinale','toothed basal rosette; hollow scape; golden ligulate head'],
['daisy','Bellis perennis','spatulate rosette; short scape; white rays and gold disc'],
['red_clover','Trifolium pratense','upright trifoliate leaves; pale chevrons; red globose head'],
['birdsfoot_trefoil','Lotus corniculatus','five leaflets; yellow pea-flower umbel; radiating pods'],
['ribwort_plantain','Plantago lanceolata','ribbed lance rosette; brown spike with pale anther ring'],
['selfheal','Prunella vulgaris','opposite oval leaves; square stem; violet stacked flower whorls'],
['knapweed','Centaurea nigra','dark involucral bracts; ragged purple floret head'],
['oxeye_daisy','Leucanthemum vulgare','tall branched stem; large white rays; golden disc'],
['field_scabious','Knautia arvensis','divided leaves; long peduncle; lilac pincushion head'],
['teasel','Dipsacus fullonum','prickly ovoid head; curved bracts; opposite clasping leaf cups'],
['cow_parsley','Anthriscus sylvestris','finely divided leaves; branched compound white umbels'],
['garlic_mustard','Alliaria petiolata','heart-shaped toothed leaves; four white petals; upright siliques'],
],
ferns_mosses: [
['bracken','Pteridium aquilinum','three-part triangular divided frond'],
@@ -1037,6 +1080,7 @@ const BOTANY_GROUPS = {
['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'],
['holly_fern','Polystichum setiferum','arching bipinnate fronds; bristle-tipped pinnules; forked pinnae'],
],
fungi: [
['fly_agaric','Amanita muscaria','red white-warted cap; ring; bulbous base'],
@@ -1113,6 +1157,166 @@ function compound(d,x,y,length=10,side=1) {
d.line(x+side*j,y-j*.7,x+side*(j+3),y-j*.7+1,'leaf',2);
}
}
// New catalogue specimens have authored diagnostic reproductive structures.
// Dormancy draws spent stems/pods, never a recoloured copy of an open flower.
function botanicalAddition(d,id,dormant) {
const stem=dormant?'bark':'leafDark',leafColor=dormant?'straw':'sage';
const stalk=(x,y,bx=16)=>d.line(bx,29,x,y,stem);
const rosette=(shape='lance')=> {
for(const [x,y,s] of [[6,29,-1],[11,28,-1],[19,29,1],[25,28,1]])
specimenLeaf(d,x,y,shape,s,leafColor);
};
const pea=(x,y)=>d.oval(x,y-2,3,2,'gold').poly([[x-3,y],[x,y+3],[x+3,y]],'ochre').dot(x,y,'strawLight');
if(id==='honeysuckle'||id==='ivy') {
d.line(13,29,12,4,'bark',2);
d.line(17,29,20,23,stem).line(20,23,13,16,stem).line(13,16,20,9,stem).line(20,9,17,3,stem);
for(const [x,y,s] of [[9,25,-1],[24,22,1],[8,16,-1],[25,12,1]]) {
if(!dormant) specimenLeaf(d,x,y,id==='ivy'?'lobe':'oval',s);
else d.line(x,y,x-s*3,y-3,'barkLight');
}
if(id==='ivy') {
for(let y=8;y<28;y+=4) d.line(12,y,9,y+2,'barkLight');
if(!dormant) for(const [x,y] of [[23,6],[24,26]]) {
for(let j=0;j<5;j++) {
const a=j*Math.PI*2/5;
d.line(x,y,x+Math.cos(a)*3,y+Math.sin(a)*3,'sage').oval(x+Math.cos(a)*3,y+Math.sin(a)*3,1,1,'ochre');
}
}
} else for(const [x,y,s] of [[20,6,1],[11,13,-1],[22,21,1]]) {
if(dormant) { d.oval(x,y,1,2,'barkLight'); continue; }
// Narrow curved tubes and splayed lips, not round flower icons.
d.line(x,y+3,x+s*3,y,'rose',2).line(x+s*3,y,x+s*5,y-1,'cream')
.line(x+s*5,y-1,x+s*6,y-3,'gold').line(x+s*5,y-1,x+s*7,y,'cream');
}
return;
}
if(id==='gorse'||id==='broom') {
for(let i=0;i<5;i++) {
const x=5+i*5,y=6+(i%3)*3; stalk(x,y,15+i%2);
for(let j=0;j<4;j++) {
const yy=13+j*4,xx=x+(16-x)*(yy-y)/(29-y);
if(id==='gorse') d.line(xx,yy,xx-3,yy-4,leafColor).line(xx,yy,xx+3,yy-3,stem);
else d.line(xx,yy,xx+(i%2?3:-3),yy-5,leafColor);
}
if(!dormant) pea(x,y+3);
else d.line(x,y+1,x+2,y+5,'barkDark',2);
}
if(id==='broom'&&!dormant) for(const x of [7,15,23]) {
d.oval(x,23,2,1,'leaf').oval(x-2,21,1,2,'sage').oval(x+2,21,1,2,'leafLight');
}
return;
}
if(id==='holly_fern') {
for(const [i,[tx,ty]] of [[4,17],[9,9],[18,4],[27,13]].entries()) {
stalk(tx,ty,15);
for(let j=1;j<=6;j++) {
const x=tx+(15-tx)*j/7,y=ty+(29-ty)*j/7,w=Math.min(j,7-j)+1;
for(const s of [-1,1]) {
d.line(x,y,x+s*w,y-2,leafColor,2).line(x+s*w,y-2,x+s*(w+1),y-4,stem);
d.line(x+s*(w-1),y-1,x+s*(w+1),y,'leafLight');
if(!dormant&&i%2===0) d.dot(x+s*2,y-1,'ochre');
}
}
}
return;
}
if(['dandelion','daisy','oxeye_daisy'].includes(id)) {
const tall=id==='oxeye_daisy', small=id==='daisy',x=small?12:tall?19:16,y=small?16:tall?7:10;
stalk(x,y,small?11:16); rosette(id==='dandelion'?'lobe':small?'oval':'lance');
if(tall) { d.line(18,19,25,14,stem); specimenLeaf(d,11,23,'diamond'); }
if(dormant) {
d.oval(x,y,small?2:3,2,'barkLight');
if(id==='dandelion') for(let j=-2;j<=2;j++) d.line(x,y,x+j*2,y-3,'strawLight');
} else if(id==='dandelion') {
flower(d,x,y,'ochre',12,4); flower(d,x,y,'gold',8,2); d.dot(x,y,'strawLight');
} else {
flower(d,x,y,'cream',small?8:12,small?3:5); d.oval(x,y,small?1:2,small?1:2,'gold');
if(tall) flower(d,25,13,'cream',8,3);
}
return;
}
if(id==='red_clover'||id==='birdsfoot_trefoil') {
const trefoil=id==='birdsfoot_trefoil';
for(const [i,[x,y]] of [[8,17],[19,8],[25,19]].entries()) {
stalk(x,y,14);
for(const s of [-1,1]) {
const xx=x+s*3,yy=24-i*3;
d.line(x,yy+3,xx,yy,stem).oval(xx,yy-2,2,3,leafColor).oval(xx+s*3,yy,2,2,'leaf');
if(!dormant) d.line(xx-1,yy-2,xx+1,yy-3,'leafLight');
}
if(dormant) {
if(trefoil) for(let j=-1;j<3;j++) d.line(x,y,x+j*2,y+5,'barkDark');
else d.oval(x,y,2,3,'barkLight');
} else if(trefoil) { pea(x-2,y); pea(x+3,y+2); }
else {
d.oval(x,y,3,4,'wine');
for(let j=0;j<4;j++) d.line(x-2,y-2+j*2,x+2,y-3+j*2,j%2?'rose':'mauve');
}
}
return;
}
if(id==='ribwort_plantain') {
rosette();
for(const [x,y] of [[10,8],[18,5],[25,13]]) {
stalk(x,y,16); d.oval(x,y,1.5,4,'bark');
if(!dormant) d.line(x-2,y+1,x+2,y+1,'cream').dot(x-2,y,'gold');
}
return;
}
if(id==='selfheal') {
for(const [i,[x,y]] of [[8,17],[16,8],[25,15]].entries()) {
stalk(x,y,14+i);
for(let j=0;j<3;j++) {
const yy=25-j*5;
d.oval(x-3,yy,3,1.5,leafColor).oval(x+3,yy,3,1.5,'leaf');
d.rect(x-2,y+j*3,5,2,dormant?'bark':'wine');
if(!dormant) d.line(x-2,y+j*3,x,y+j*3-1,'mauve');
}
}
return;
}
if(id==='cow_parsley'||id==='garlic_mustard') {
stalk(17,6);
if(id==='cow_parsley') {
for(const [x,y] of [[7,12],[17,6],[26,13]]) {
d.line(16,21,x,y+3,stem);
for(let j=-2;j<=2;j++) {
d.line(x,y+3,x+j*2,y,stem);
if(!dormant) flower(d,x+j*2,y,'cream',4,1);
else d.line(x+j*2,y,x+j*2,y+2,'barkLight');
}
}
compound(d,15,25,10,-1); compound(d,17,23,10,1);
} else {
for(const [x,y,s] of [[8,27,-1],[24,24,1],[10,19,-1],[22,15,1]]) specimenLeaf(d,x,y,'heart',s,leafColor);
for(let j=0;j<4;j++) {
const y=6+j*4,x=17+(j%2?3:-2); d.line(17,y+3,x,y,stem);
if(!dormant&&j<2) flower(d,x,y,'cream',4,1.5);
else d.line(x,y,x+(j%2?1:-1),y-4,'straw');
}
}
return;
}
// Ragged knapweed florets, a pincushion scabious, and prickly teasel cones.
const teasel=id==='teasel', scabious=id==='field_scabious';
for(const [i,[x,y]] of (teasel?[[16,9],[26,18]]:scabious?[[8,11],[22,7]]:[[12,9],[25,16]]).entries()) {
stalk(x,y+3,16);
if(teasel) {
d.oval(x,y,3,6,dormant?'bark':'strawDark');
for(let j=0;j<5;j++) d.line(x-3,y-4+j*2,x+3,y-5+j*2,'wood');
for(const s of [-1,1]) d.line(x,y+5,x+s*5,y+1,stem).line(x+s*5,y+1,x+s*4,y-2,stem);
if(!dormant) d.line(x-3,y,x+3,y,'mauve',2);
} else {
d.oval(x,y+2,3,2,'barkDark');
if(!dormant) for(let j=-3;j<=3;j++) d.line(x+j,y+1,x+j+(j%2),y-(scabious?2:4),scabious?'mauve':'wine')
.dot(x+j+(j%2),y-(scabious?3:4),'rose');
else d.line(x-3,y,x+3,y-1,'barkLight');
}
if(teasel) d.poly([[10,24],[16,26],[22,24],[18,28],[14,28]],leafColor);
else if(scabious) compound(d,16,27,8,i%2?-1:1);
else specimenLeaf(d,8+i*14,27,'lance',i%2?-1:1,leafColor);
}
}
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';
@@ -1140,13 +1344,17 @@ function botanicalTree(d,id,dormant) {
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]],
crab_apple:[[6,19],[11,26],[22,17],[26,25],[14,9]],
wild_cherry:[[7,15],[10,24],[22,12],[26,23],[16,8]],
field_maple:[[6,16],[9,25],[23,14],[26,24],[15,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];
const shape={oak:'lobe',birch:'diamond',willow:'lance',alder:'round',beech:'oval',aspen:'round',hazel:'heart',
crab_apple:'oval',wild_cherry:'diamond',field_maple:'lobe'}[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');
@@ -1155,7 +1363,12 @@ function botanicalTree(d,id,dormant) {
}
}
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');
if(id==='crab_apple') { flower(d,23,9,'bud',5,2); d.oval(24,27,2,2,'ochre').dot(24,24,'bark'); }
else if(id==='wild_cherry') d.line(23,21,21,26,'bark').line(23,21,26,26,'bark')
.oval(21,27,1.5,1.5,'wine').oval(26,27,1.5,1.5,'rust');
else if(id==='field_maple') d.poly([[21,25],[17,22],[16,24],[21,27],[26,24],[27,22]],'wood')
.line(21,25,21,28,'barkLight');
else 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');
@@ -1165,6 +1378,9 @@ function botanicalTree(d,id,dormant) {
}
}
function botanicalShrub(d,id,dormant) {
if(['honeysuckle','ivy','gorse','broom'].includes(id)) {
botanicalAddition(d,id,dormant); return;
}
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]];
@@ -1225,6 +1441,9 @@ function botanicalGrass(d,id,dormant) {
}
}
function botanicalHerb(d,id,dormant) {
if(BOTANY_GROUPS.herbs.findIndex(r=>r[0]===id)>=10) {
botanicalAddition(d,id,dormant); return;
}
if(['clover','nettle','heather','bluebell'].includes(id)) {
understory(d,id+(dormant?'_dormant':''));
// Catalogue specimen includes diagnostic stolon/flower strings/scale sprays.
@@ -1266,6 +1485,7 @@ function botanicalHerb(d,id,dormant) {
if(dormant) d.line(x-1,7,x+2,10,'straw');
}
function botanicalFernMoss(d,id,dormant) {
if(id==='holly_fern') { botanicalAddition(d,id,dormant); return; }
const k=BOTANY_GROUPS.ferns_mosses.findIndex(r=>r[0]===id);
if(k<5) {
if(id==='harts_tongue') {