feat: add PortraitCompositor for layered NPC portrait images
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user