diff --git a/client/src/sprites/PortraitCompositor.ts b/client/src/sprites/PortraitCompositor.ts new file mode 100644 index 0000000..eb17001 --- /dev/null +++ b/client/src/sprites/PortraitCompositor.ts @@ -0,0 +1,109 @@ +import type { Appearance } from '@dflike/shared'; +import { PORTRAIT_SLOTS, SPRITE_TO_PORTRAIT_FOLDER } from '@dflike/shared'; + +const PORTRAIT_CACHE = new Map(); + +function portraitKey(appearance: Appearance): string { + const accKeys = Object.entries(appearance.accessories) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([slot, id]) => `${slot}:${id}`) + .join('|'); + const featKeys = Object.entries(appearance.portraitFeatures ?? {}) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([slot, id]) => `${slot}:${id}`) + .join('|'); + return `portrait_${appearance.skinId}_${accKeys}_${featKeys}`; +} + +function getSkinIndex(skinId: string): number { + const match = skinId.match(/skin(\d+)$/); + return match ? parseInt(match[1], 10) : 0; +} + +function skinFolderName(skinId: string): string { + const idx = getSkinIndex(skinId); + return `skin${String(idx + 1).padStart(2, '0')}`; +} + +function skinFileName(skinId: string): string { + const idx = getSkinIndex(skinId); + return `skin${String(idx).padStart(2, '0')}`; +} + +function loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`Failed to load: ${src}`)); + img.src = src; + }); +} + +export class PortraitCompositor { + private basePath: string; + + constructor(basePath = 'assets/chars/baseman/accessories/portraits') { + this.basePath = basePath; + } + + async compositePortrait(appearance: Appearance): Promise { + const key = portraitKey(appearance); + const cached = PORTRAIT_CACHE.get(key); + if (cached) return cached; + + const folder = skinFolderName(appearance.skinId); + const skinFile = skinFileName(appearance.skinId); + + const baseImg = await loadImage(`${this.basePath}/${folder}/${skinFile}.png`); + + const canvas = document.createElement('canvas'); + canvas.width = baseImg.width; + canvas.height = baseImg.height; + const ctx = canvas.getContext('2d')!; + ctx.drawImage(baseImg, 0, 0); + + const preFeatureSlots = ['bottom', 'feet', 'chest', 'arm', 'shoulder', 'waist', 'back', 'facialHair']; + const postFeatureSlots = ['haircut', 'hat']; + + const drawAccessory = async (slot: string) => { + const fileId = appearance.accessories[slot as keyof typeof appearance.accessories]; + if (!fileId) return; + const portraitFolder = SPRITE_TO_PORTRAIT_FOLDER[slot]; + if (!portraitFolder) return; + try { + const img = await loadImage(`${this.basePath}/${folder}/${portraitFolder}/${fileId}.png`); + ctx.drawImage(img, 0, 0); + } catch { /* skip missing layer */ } + }; + + // 1. Pre-feature accessories + for (const slot of preFeatureSlots) await drawAccessory(slot); + + // 2. Portrait-only features (brows, eyes, mouths) + if (appearance.portraitFeatures) { + for (const slot of PORTRAIT_SLOTS) { + const featureId = appearance.portraitFeatures[slot]; + if (!featureId) continue; + try { + const img = await loadImage(`${this.basePath}/${folder}/${slot}/${featureId}.png`); + ctx.drawImage(img, 0, 0); + } catch { /* skip missing */ } + } + } + + // 3. Post-feature accessories (haircut, hat on top) + for (const slot of postFeatureSlots) await drawAccessory(slot); + + const dataUrl = canvas.toDataURL('image/png'); + PORTRAIT_CACHE.set(key, dataUrl); + return dataUrl; + } + + getKey(appearance: Appearance): string { + return portraitKey(appearance); + } + + clearCache(): void { + PORTRAIT_CACHE.clear(); + } +}