// Original MIT woodland-traveler pixel drawing. Pure, bounded, offline RGBA; // the shared 64px atlas and named bones remain the runtime attachment contract. 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','lined'], eyes: ['hazel','brown','green','blue','grey'], 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','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','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) .map(([key, value])=>[key,Object.freeze(value)]))); const SKIN = palette({porcelain:[225,190,166],sand:[199,157,120],honey:[177,126,79], olive:[141,119,88],umber:[114,78,56],ebony:[72,49,42]}); const HAIR = palette({ash:[106,99,87],flax:[177,150,95],chestnut:[94,60,43], auburn:[131,66,43],black:[35,33,32],silver:[174,177,168]}); const CLOTH = palette({sage:[101,119,88],teal:[57,100,98],slate:[87,101,117], mauve:[125,94,113],ochre:[164,128,64],rust:[149,78,53],linen:[187,174,144],charcoal:[59,63,64]}); const PANTS = palette({earth:[99,80,61],slate:[74,83,94],moss:[78,88,60],charcoal:[48,50,53]}); const BOOTS = palette({walnut:[79,53,37],black:[37,35,34],tan:[147,109,66]}); const EYES = palette({hazel:[49,59,35],brown:[55,37,29],green:[47,77,53],blue:[52,81,104],grey:[92,100,100]}); const INK = [74,56,48], THREAD = [184,166,126], LEATHER = [85,61,40]; const tint = (color, amount) => color.map(n=>Math.max(0,Math.min(255,n+amount))); const image = () => ({width:64,height:64,data:new Uint8Array(64*64*4)}); function dot(out, tile, x, y, color) { const [tx,ty] = ATLAS[tile]; out.data.set([...color,255],((ty*8+y)*64+tx*8+x)*4); } function paint(out, tile, draw) { for (let y=0;y<8;y++) for (let x=0;x<8;x++) { const color = draw(x,y); if (color) dot(out,tile,x,y,color); } } // Deliberate planes, seam highlights and broken weave, not global colorization. function fabric(color,x,y) { return tint(color,(x===0 || x===7 ? -13 : x===1 ? 7 : 0) + ((y===2 || y===5) && x%3===1 ? 4 : 0)); } function skinLayer(id) { const out = image(); for (let y=0;y<64;y++) for (let x=0;x<64;x++) out.data.set([57,88,92,255],(y*64+x)*4); for (const tile of ['face','hair','side','skin']) paint(out,tile,(x,y)=> tint(SKIN[id],(x===0 || x===7 ? -9 : 0)+(y===7 ? -6 : y===1 ? 4 : 0))); for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=>fabric(CLOTH.linen,x,y)); paint(out,'trousers',(x,y)=>fabric(PANTS.earth,x,y)); paint(out,'boot',(x,y)=>fabric(BOOTS.walnut,x,y)); // Byte-exact former tool swatches: shared held OBJ UVs must not change. for (const [tile,color] of [['metal',[177,195,191]],['wood',[146,100,59]],['wrap',[201,169,116]]]) paint(out,tile,(x,y)=>tint(color,(((ATLAS[tile][0]*8+x)*13+(ATLAS[tile][1]*8+y)*7)%5)-2)); return out; } function faceLayer(id) { const out = image(); // Skin-neutral linework: never a light painted face over darker skin. const marks = { calm: [[2,2],[5,2],[4,4],[3,6],[4,6]], 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; } function eyeLayer(id) { const out = image(); for (const x of [2,5]) dot(out,'face',x,3,EYES[id]); return out; } function hairLayer(style, color) { const out = image(); if (style==='none') return out; const base = HAIR[color]; const strand = (x,y)=>tint(base,(x+y*2)%4===0 ? 13 : x%3===0 ? -9 : 0); const fringe = (x,y) => { if (style==='shaved') return y===0 && x%2===0; if (style==='cropped') return y===0 || (y===1 && x%3!==1); 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,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); }); if (style==='long' || style==='braids') { paint(out,'back',(x,y)=>y<4 && (style==='long' ? x>0 && x<7 : x===1 || x===6) ? strand(x,y) : null); 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) { const out = image(); if (style==='none') return out; paint(out,'face',(x,y)=> { const moustache = y===5 && x>=2 && x<=5; const lower = y>=6 && x>=1 && x<=6; 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],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; } function garmentLayer(style,color) { const out = image(), base = CLOTH[color]; for (const tile of ['tunic','back','sleeve']) paint(out,tile,(x,y)=> { let c = fabric(tint(base,tile==='back' ? -8 : 0),x,y); if (y===7 || tile==='sleeve' && y===6) c=tint(base,-21); // hem/cuff if (tile==='tunic') { if (y===0 && (x===2 || x===5) || y===1 && (x===3 || x===4)) c=THREAD; if (style==='tunic' && y===6) c=LEATHER; if (style==='workshirt' && x===4 && y>1 && y<6) c=y%2 ? THREAD : tint(base,-22); 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' || 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'||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; }); return out; } function trousersLayer(id) { const out=image(); paint(out,'trousers',(x,y)=> y===0 ? LEATHER : x===2 && y>3 ? tint(PANTS[id],-17) : fabric(PANTS[id],x,y)); return out; } function bootsLayer(id) { const out=image(); paint(out,'boot',(x,y)=>y===7 ? [30,28,26] : y===1 ? tint(BOOTS[id],18) : x===3 && y>1 && y<5 ? THREAD : fabric(BOOTS[id],x,y)); return out; } 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; } const definitions = [ ['skin','skin',null,skinLayer], ['face','faces',null,faceLayer], ['eyes','eyes',null,eyeLayer], ['hair','hairStyles','hairColors',hairLayer], ['beard','facialHair','hairColors',beardLayer], ['garment','garments','clothColors',garmentLayer], ['trousers','trousers',null,trousersLayer], ['boots','boots',null,bootsLayer], ['accent','accents','clothColors',accentLayer], ['detail','details',null,detailLayer], ]; export const CHARACTER_ART_MANIFEST = Object.freeze(definitions.flatMap(([kind,choices,colors])=> CHARACTER_OPTIONS[choices].flatMap(id=>(colors ? CHARACTER_OPTIONS[colors] : [null]).map(color=> Object.freeze({filename:`hearth_char_${kind}_${id}${color ? '_'+color : ''}.png`,kind,id,color, width:64,height:64,empty:id==='none',license:'MIT',author:'The Last Hearth original procedural art'}))))); const records = new Map(CHARACTER_ART_MANIFEST.map(record=>[record.filename,record])); export function characterLayer(filename) { const record=records.get(filename); if (!record) throw new Error('Unknown character layer.'); const draw=definitions.find(([kind])=>kind===record.kind)[3]; return draw(record.id,record.color); } export function characterArt() { return new Map(CHARACTER_ART_MANIFEST.map(({filename})=>[filename,characterLayer(filename)])); } export const DEFAULT_CHARACTER = Object.freeze({body:'balanced',skin:'sand',face:'calm',eyes:'hazel', hair:'sidepart',hairColor:'chestnut',beard:'none',garment:'tunic',cloth:'teal', trousers:'earth',boots:'walnut',accent:'scarf',accentColor:'sage',detail:'none'}); const fields = Object.freeze({body:'bodies',skin:'skin',face:'faces',eyes:'eyes',hair:'hairStyles', hairColor:'hairColors',beard:'facialHair',garment:'garments',cloth:'clothColors',trousers:'trousers', boots:'boots',accent:'accents',accentColor:'clothColors',detail:'details'}); function selection(options) { if (!options || typeof options!=='object' || Array.isArray(options) || ![Object.prototype,null].includes(Object.getPrototypeOf(options))) throw new Error('Invalid character options.'); for (const key of Reflect.ownKeys(options)) if (!Object.hasOwn(fields,key)) throw new Error('Unknown character option.'); const result={...DEFAULT_CHARACTER,...options}; for (const [field,group] of Object.entries(fields)) if (!CHARACTER_OPTIONS[group].includes(result[field])) throw new Error('Unknown character choice.'); return result; } export function characterLayers(options = {}) { const p=selection(options), name=stem=>`hearth_char_${stem}.png`; // 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(`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') { if (!CHARACTER_OPTIONS.bodies.includes(body)) throw new Error('Unknown character body profile.'); return `hearth_person${body==='balanced' ? '' : '_'+body}.b3d`; } // Straight RGBA source-over simplifies to a pixel copy: all layers are 0/255. // Fresh output every call, no writes, caches, global tint, downloads or entities. export function compositeCharacter(options = {}) { const out=image(); for (const filename of characterLayers(options)) { const layer=characterLayer(filename); for (let i=0;iObject.freeze({...p}))); export function portraitSheet() { const out={width:512,height:64,data:new Uint8Array(512*64*4)}; for (let i=0;i { const atlas=compositeCharacter(options); // Front-facing assembled paper-doll portraits, NOT unwrapped atlas squares // or an engine/3D-render claim. Nearest-neighbor sampling of actual layers. const blit=(tile,dx,dy,w,h)=> { const [tx,ty]=ATLAS[tile]; for (let y=0;y