265 lines
14 KiB
JavaScript
Executable File
265 lines
14 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'],
|
|
eyes: ['hazel','brown','green','blue','grey'],
|
|
hairStyles: ['cropped','sidepart','wavy','braids','long','shaved','none'],
|
|
hairColors: ['ash','flax','chestnut','auburn','black','silver'],
|
|
facialHair: ['none','stubble','moustache','short_beard','full_beard'],
|
|
garments: ['tunic','workshirt','jerkin','coat'],
|
|
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'],
|
|
}).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]],
|
|
};
|
|
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;
|
|
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];
|
|
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);
|
|
}
|
|
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 mask = style==='moustache' ? moustache : 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;
|
|
});
|
|
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' && (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 (tile==='back' && (style==='coat' && x===4 || style==='jerkin' && y===5)) c=tint(base,-23);
|
|
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];
|
|
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);
|
|
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);
|
|
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]);
|
|
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(`accent_${p.accent}_${p.accentColor}`),name(`hair_${p.hair}_${p.hairColor}`),
|
|
name(`beard_${p.beard}_${p.hairColor}`),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;
|
|
}
|