Files
2026-11-05 09:57:11 -05:00

292 lines
17 KiB
JavaScript
Executable File

// 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;i<out.data.length;i+=4) if (layer.data[i+3]) out.data.set(layer.data.subarray(i,i+4),i);
}
return out;
}
export const CHARACTER_PORTRAITS = Object.freeze([
{}, {skin:'porcelain',hair:'braids',hairColor:'flax',garment:'workshirt',cloth:'sage',accent:'apron',accentColor:'linen',detail:'freckles'},
{skin:'honey',hair:'wavy',hairColor:'auburn',garment:'jerkin',cloth:'ochre',beard:'stubble',accent:'sash',accentColor:'mauve'},
{skin:'olive',face:'angular',hair:'long',hairColor:'black',garment:'coat',cloth:'slate',accent:'vest',accentColor:'rust'},
{skin:'umber',face:'weathered',hair:'cropped',hairColor:'silver',garment:'workshirt',cloth:'linen',beard:'full_beard',accent:'none'},
{skin:'ebony',face:'soft',eyes:'blue',hair:'braids',hairColor:'black',garment:'tunic',cloth:'rust',accent:'scarf',accentColor:'ochre'},
{skin:'sand',hair:'shaved',hairColor:'ash',garment:'coat',cloth:'charcoal',beard:'moustache',accent:'sash',accentColor:'teal',detail:'scar'},
{skin:'umber',hair:'none',hairColor:'chestnut',garment:'jerkin',cloth:'mauve',beard:'short_beard',accent:'vest',accentColor:'sage',detail:'freckles_scar'},
].map(p=>Object.freeze({...p})));
export function portraitSheet() {
const out={width:512,height:64,data:new Uint8Array(512*64*4)};
for (let i=0;i<out.data.length;i+=4) out.data.set([30,34,32,255],i);
CHARACTER_PORTRAITS.forEach((options,index)=> {
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<h;y++) for (let x=0;x<w;x++) {
const from=((ty*8+Math.floor(y*8/h))*64+tx*8+Math.floor(x*8/w))*4;
out.data.set(atlas.data.subarray(from,from+4),((dy+y)*out.width+index*64+dx+x)*4);
}
};
blit('face',24,3,16,18); blit('tunic',21,21,22,22);
blit('sleeve',14,22,7,18); blit('sleeve',43,22,7,18);
blit('skin',14,40,7,4); blit('skin',43,40,7,4);
blit('trousers',22,43,9,13); blit('trousers',33,43,9,13);
blit('boot',21,56,10,6); blit('boot',33,56,10,6);
});
return out;
}