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