import { buildBodyPath, buildMedievalAtlasSpec, buildRaceAssetPath, clampMedievalAtlasFrame, getMedievalAtlasOptions, getMedievalPoseOptions, MEDIEVAL_BODY_COLORS, type MedievalAtlasSourceType, type MedievalNpcVisualOverride, type MedievalRace, } from '../data/medievalNpcVisuals'; import type { Encounter } from '../types'; import { type NpcLayoutConfig, type NpcLayoutPart } from './npcVisualShared'; export type GearSourceType = 'none' | MedievalAtlasSourceType; export type EditableNpcVisualState = { race: MedievalRace; bodyColor: string; headIndex: number; hairColorIndex: number; hairStyleFrame: number; facialHairEnabled: boolean; facialHairColorIndex: number; facialHairStyleFrame: number; headgearType: GearSourceType; headgearFile: string; headgearFrame: number; mainHandType: GearSourceType; mainHandFile: string; mainHandFrame: number; offHandType: GearSourceType; offHandFile: string; offHandFrame: number; }; export type EditorNpcOption = { encounter: Encounter; sceneNames: string[]; }; const NPC_LAYOUT_PARTS: NpcLayoutPart[] = [ 'body', 'head', 'facialHair', 'hair', 'headgear', 'hand', 'mainHand', 'offHand', ]; export function sanitizeFrameSelection( type: GearSourceType, file: string, frame: number, usage: 'headgear' | 'mainHand' | 'offHand', ) { if (type === 'none' || !file) return 0; const poseOptions = getMedievalPoseOptions(type, file, usage); if (poseOptions.length === 0) return 0; if (poseOptions.some(option => option.value === frame)) { return clampMedievalAtlasFrame(type, file, frame); } const firstOption = poseOptions[0]; return firstOption ? firstOption.value : 0; } export function getDefaultFileForType(type: GearSourceType) { if (type === 'none') return ''; return getMedievalAtlasOptions(type)[0]?.file ?? ''; } export function getDefaultFrameForSelection( type: GearSourceType, file: string, usage: 'headgear' | 'mainHand' | 'offHand', ) { if (type === 'none' || !file) return 0; return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0; } export function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } export function isNpcLayoutConfig(value: unknown): value is NpcLayoutConfig { return ( isRecord(value) && NPC_LAYOUT_PARTS.every(part => { const coordinate = value[part]; return ( isRecord(coordinate) && typeof coordinate.x === 'number' && Number.isFinite(coordinate.x) && typeof coordinate.y === 'number' && Number.isFinite(coordinate.y) ); }) ); } export function buildOverrideFromEditorState( state: EditableNpcVisualState, ): MedievalNpcVisualOverride { return { race: state.race, bodySrc: buildBodyPath( state.bodyColor as (typeof MEDIEVAL_BODY_COLORS)[number], ), headSrc: buildRaceAssetPath(state.race, 'head', state.headIndex), hairSrc: buildRaceAssetPath(state.race, 'hair', state.hairColorIndex), handSrc: buildRaceAssetPath(state.race, 'hand', 1), facialHairSrc: state.facialHairEnabled ? buildRaceAssetPath(state.race, 'facialHair', state.facialHairColorIndex) : undefined, headgear: state.headgearType === 'none' ? undefined : buildMedievalAtlasSpec( state.headgearType, state.headgearFile, sanitizeFrameSelection( state.headgearType, state.headgearFile, state.headgearFrame, 'headgear', ), ), mainHand: state.mainHandType === 'none' ? undefined : buildMedievalAtlasSpec( state.mainHandType, state.mainHandFile, sanitizeFrameSelection( state.mainHandType, state.mainHandFile, state.mainHandFrame, 'mainHand', ), ), offHand: state.offHandType === 'none' ? undefined : buildMedievalAtlasSpec( state.offHandType, state.offHandFile, sanitizeFrameSelection( state.offHandType, state.offHandFile, state.offHandFrame, 'offHand', ), ), bodyFrames: [0, 1, 2, 3], headFrame: 0, hairFrame: state.hairStyleFrame, handFrame: 0, facialHairFrame: state.facialHairEnabled ? state.facialHairStyleFrame : undefined, }; } export function buildNpcVisualSavePayload( overrideMap: Record, npcId: string, editorState: EditableNpcVisualState, ) { return { ...overrideMap, [npcId]: buildOverrideFromEditorState(editorState), }; }