feat: add PortraitCompositor for layered NPC portrait images

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
root
2026-03-07 04:35:20 +00:00
parent 0cf48ded61
commit 1bbbba477b
+109
View File
@@ -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<string, string>();
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<HTMLImageElement> {
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<string> {
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();
}
}