feat: add random appearance generator for NPC spawning

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 18:38:30 -05:00
parent 83660609fc
commit a8e18b9535
2 changed files with 106 additions and 0 deletions
@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { generateRandomAppearance, SKIN_OPTIONS, ACCESSORY_OPTIONS } from '../appearanceGenerator.js';
import { ACCESSORY_SLOTS } from '@dflike/shared';
describe('generateRandomAppearance', () => {
it('returns a valid skinId', () => {
const appearance = generateRandomAppearance();
expect(SKIN_OPTIONS).toContain(appearance.skinId);
});
it('only uses valid accessory slots', () => {
const appearance = generateRandomAppearance();
for (const slot of Object.keys(appearance.accessories)) {
expect(ACCESSORY_SLOTS).toContain(slot);
}
});
it('generates different appearances on multiple calls', () => {
const appearances = new Set<string>();
for (let i = 0; i < 20; i++) {
appearances.add(JSON.stringify(generateRandomAppearance()));
}
expect(appearances.size).toBeGreaterThan(1);
});
});
+81
View File
@@ -0,0 +1,81 @@
import type { Appearance, AccessorySlot } from '@dflike/shared';
import { ACCESSORY_SLOTS } from '@dflike/shared';
// Skin options (from chars/baseman/accessories/sprites/skins/)
export const SKIN_OPTIONS = [
'shape00_skin00', 'shape00_skin01', 'shape00_skin02',
'shape00_skin03', 'shape00_skin04', 'shape00_skin05', 'shape00_skin06',
];
// Accessory options per slot: array of filenames (without .png)
export const ACCESSORY_OPTIONS: Record<AccessorySlot, string[]> = {
arm: [
'00_c00', '00_c01', '00_c02', '00_c03',
'02_c00', '02_c01', '02_c02', '02_c03',
'03_c00', '03_c01', '03_c02', '03_c03',
],
back: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
'02_c00', '02_c01', '02_c02', '02_c03',
],
bottom: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
'02_c00', '02_c01', '02_c02', '02_c03',
],
chest: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
'02_c00', '02_c01', '02_c02', '02_c03',
'03_c00', '03_c01', '03_c02', '03_c03',
],
facialHair: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
],
feet: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
],
haircut: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
'02_c00', '02_c01', '02_c02', '02_c03',
'03_c00', '03_c01', '03_c02', '03_c03',
],
hat: [
'00_c00', '00_c01', '00_c02',
'01_c00', '01_c01', '01_c02',
],
shoulder: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
],
waist: [
'00_c00', '00_c01', '00_c02', '00_c03',
'01_c00', '01_c01', '01_c02', '01_c03',
],
};
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function generateRandomAppearance(): Appearance {
const skinId = pick(SKIN_OPTIONS);
const accessories: Partial<Record<AccessorySlot, string>> = {};
for (const slot of ACCESSORY_SLOTS) {
// Always include haircut, chest, and bottom for a reasonable look
const alwaysInclude = slot === 'haircut' || slot === 'chest' || slot === 'bottom';
if (alwaysInclude || Math.random() < 0.4) {
const options = ACCESSORY_OPTIONS[slot];
if (options && options.length > 0) {
accessories[slot] = pick(options);
}
}
}
return { skinId, accessories };
}