import type { ReactNode } from 'react'; import { resolveCustomWorldNpcMonsterPreset } from '../data/customWorldNpcMonsters'; import { buildBodyPath, buildMedievalNpcVisual, buildMedievalNpcVisualOverrideFromCustomWorldVisual, buildRaceAssetPath, getMedievalAtlasAsset, getMedievalAtlasOptions, getMedievalHeadOptions, getMedievalPoseOptions, getRaceSpriteCounts, MEDIEVAL_BODY_COLOR_LABELS, MEDIEVAL_BODY_COLORS, MEDIEVAL_FACIAL_HAIR_COLOR_LABELS, MEDIEVAL_FACIAL_HAIR_STYLE_LABELS, MEDIEVAL_HAIR_COLOR_LABELS, MEDIEVAL_HAIR_STYLE_LABELS, MEDIEVAL_RACE_LABELS, type MedievalAtlasSourceType, type MedievalAtlasUsage, type MedievalRace, sanitizeCustomWorldNpcVisual, } from '../data/medievalNpcVisuals'; import { type CustomWorldNpc, type CustomWorldNpcVisual, type CustomWorldProfile, } from '../types'; import { buildDefaultCustomWorldNpcVisual } from './customWorldNpcVisualDefaults'; import { HostileNpcAnimator } from './HostileNpcAnimator'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; import { ResolvedAssetImage } from './ResolvedAssetImage'; type EditableNpcSource = Pick< CustomWorldNpc, 'id' | 'name' | 'role' | 'description' | 'imageSrc' > & Partial< Pick< CustomWorldNpc, | 'title' | 'backstory' | 'personality' | 'motivation' | 'combatStyle' | 'initialAffinity' | 'relationshipHooks' | 'tags' > >; type GearSlot = 'headgear' | 'mainHand' | 'offHand'; function buildCustomWorldNpcEncounter(npc: EditableNpcSource) { return { id: npc.id, kind: 'npc' as const, npcName: npc.name, npcDescription: npc.description, npcAvatar: npc.name.slice(0, 1) || '角', context: npc.role, }; } function buildPreviewSpec(npc: EditableNpcSource, visual?: CustomWorldNpcVisual) { const encounter = buildCustomWorldNpcEncounter(npc); const baseSpec = buildMedievalNpcVisual(encounter); if (!visual) { return baseSpec; } return { ...baseSpec, ...buildMedievalNpcVisualOverrideFromCustomWorldVisual(visual), }; } function getGearUsage(slot: GearSlot): MedievalAtlasUsage { if (slot === 'headgear') return 'headgear'; if (slot === 'mainHand') return 'mainHand'; return 'offHand'; } function getDefaultFileForType(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) { const assets = getMedievalAtlasOptions(type); if (usage === 'offHand' && type === 'melee') { return assets.find(asset => asset.file === 'shield.png')?.file ?? assets[0]?.file ?? ''; } return assets[0]?.file ?? ''; } function getDefaultFrameForSelection(type: MedievalAtlasSourceType, file: string, usage: MedievalAtlasUsage) { return getMedievalPoseOptions(type, file, usage)[0]?.value ?? 0; } function buildDefaultGear(type: MedievalAtlasSourceType, usage: MedievalAtlasUsage) { const file = getDefaultFileForType(type, usage); if (!file) return null; return { type, file, frameIndex: getDefaultFrameForSelection(type, file, usage), }; } function getGearSummary(visual: CustomWorldNpcVisual) { return [ visual.headgear ? getMedievalAtlasAsset(visual.headgear.type, visual.headgear.file)?.label ?? '头饰' : '无头饰', visual.mainHand ? getMedievalAtlasAsset(visual.mainHand.type, visual.mainHand.file)?.label ?? '主手' : '无主手', visual.offHand ? getMedievalAtlasAsset(visual.offHand.type, visual.offHand.file)?.label ?? '副手' : '无副手', ].join(' / '); } function PreviewFrame({ children, className = '', }: { children: ReactNode; className?: string; }) { return (
{children}
); } function SpriteFramePreview({ src, frameIndex = 0, tileSize = 32, scale = 1, }: { src: string; frameIndex?: number; tileSize?: number; scale?: number; }) { return (
); } function AtlasFramePreview({ type, file, frameIndex, }: { type: MedievalAtlasSourceType; file: string; frameIndex: number; }) { const asset = getMedievalAtlasAsset(type, file); if (!asset) { return
; } const col = frameIndex % asset.columns; const row = Math.floor(frameIndex / asset.columns); return (
32 || asset.tileHeight > 32 ? 'scale(0.75)' : undefined, transformOrigin: 'center', }} /> ); } function EmptyPreview({ label }: { label: string }) { return (
{label}
); } function PortraitOptionPreview({ npc, visual, }: { npc: EditableNpcSource; visual: CustomWorldNpcVisual; }) { return ( ); } function OptionCard({ label, selected, onClick, preview, }: { key?: string; label: string; selected: boolean; onClick: () => void; preview: ReactNode; }) { return ( ); } function OptionSection({ title, subtitle, children, }: { title: string; subtitle?: string; children: ReactNode; }) { return (
{title}
{subtitle ?
{subtitle}
: null}
{children}
); } function ActionButton({ label, onClick, tone = 'default', }: { label: string; onClick: () => void; tone?: 'default' | 'sky'; }) { return ( ); } export function CustomWorldNpcPortrait({ npc, profile, visual, className = '', contentClassName = 'min-h-[7rem] p-3', scale = 2.05, preferImageSrc = false, }: { npc: EditableNpcSource; profile?: CustomWorldProfile | null; visual?: CustomWorldNpcVisual; className?: string; contentClassName?: string; scale?: number; preferImageSrc?: boolean; }) { const previewSpec = buildPreviewSpec(npc, visual ? sanitizeCustomWorldNpcVisual(visual) : undefined); const monsterPreset = visual ? null : resolveCustomWorldNpcMonsterPreset(npc, undefined, profile ?? null); const preferredImageSrc = preferImageSrc && npc.imageSrc?.trim() ? npc.imageSrc.trim() : ''; return (
{preferredImageSrc ? ( ) : monsterPreset ? (
) : ( )}
); } export function CustomWorldNpcVisualEditor({ npc, profile, value, onChange, onAiGenerate, }: { npc: EditableNpcSource; profile?: CustomWorldProfile | null; value?: CustomWorldNpcVisual; onChange: (value: CustomWorldNpcVisual) => void; onAiGenerate: () => void; }) { const effectiveVisual = sanitizeCustomWorldNpcVisual(value ?? buildDefaultCustomWorldNpcVisual(npc)); const spriteCounts = getRaceSpriteCounts(effectiveVisual.race); const headOptions = getMedievalHeadOptions(effectiveVisual.race); const headgearAssets = effectiveVisual.headgear ? getMedievalAtlasOptions(effectiveVisual.headgear.type) : []; const mainHandAssets = effectiveVisual.mainHand ? getMedievalAtlasOptions(effectiveVisual.mainHand.type) : []; const offHandAssets = effectiveVisual.offHand ? getMedievalAtlasOptions(effectiveVisual.offHand.type) : []; const headgearPoseOptions = effectiveVisual.headgear ? getMedievalPoseOptions(effectiveVisual.headgear.type, effectiveVisual.headgear.file, 'headgear') : []; const mainHandPoseOptions = effectiveVisual.mainHand ? getMedievalPoseOptions(effectiveVisual.mainHand.type, effectiveVisual.mainHand.file, 'mainHand') : []; const offHandPoseOptions = effectiveVisual.offHand ? getMedievalPoseOptions(effectiveVisual.offHand.type, effectiveVisual.offHand.file, 'offHand') : []; const updateVisual = (nextVisual: CustomWorldNpcVisual) => { onChange(sanitizeCustomWorldNpcVisual(nextVisual)); }; const buildPatchedVisual = (patch: Partial) => ( sanitizeCustomWorldNpcVisual({ ...effectiveVisual, ...patch, }) ); const updateGearType = (slot: GearSlot, nextType: MedievalAtlasSourceType | 'none') => { if (nextType === 'none') { updateVisual({ ...effectiveVisual, [slot]: null, }); return; } updateVisual({ ...effectiveVisual, [slot]: buildDefaultGear(nextType, getGearUsage(slot)), }); }; const updateGearFile = (slot: GearSlot, nextFile: string) => { const currentGear = effectiveVisual[slot]; if (!currentGear) return; updateVisual({ ...effectiveVisual, [slot]: { ...currentGear, file: nextFile, frameIndex: getDefaultFrameForSelection(currentGear.type, nextFile, getGearUsage(slot)), }, }); }; const updateGearFrame = (slot: GearSlot, nextFrameIndex: number) => { const currentGear = effectiveVisual[slot]; if (!currentGear) return; updateVisual({ ...effectiveVisual, [slot]: { ...currentGear, frameIndex: nextFrameIndex, }, }); }; return (
{getGearSummary(effectiveVisual)}
onChange(buildDefaultCustomWorldNpcVisual(npc))} />
{(Object.entries(MEDIEVAL_RACE_LABELS) as Array<[MedievalRace, string]>).map(([race, label]) => { const previewVisual = buildPatchedVisual({ race }); return ( updateVisual(previewVisual)} preview={} /> ); })} {MEDIEVAL_BODY_COLORS.map(color => ( updateVisual(buildPatchedVisual({ bodyColor: color }))} preview={( )} /> ))} {headOptions.map(option => ( updateVisual(buildPatchedVisual({ headIndex: option.value }))} preview={( )} /> ))} {MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => ( updateVisual(buildPatchedVisual({ hairStyleFrame: index }))} preview={( )} /> ))} {Array.from({ length: spriteCounts.hair }, (_, index) => { const value = index + 1; return ( updateVisual(buildPatchedVisual({ hairColorIndex: value }))} preview={( )} /> ); })} updateVisual(buildPatchedVisual({ facialHairEnabled: false, facialHairStyleFrame: 0 }))} preview={( )} /> {MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => ( updateVisual(buildPatchedVisual({ facialHairEnabled: true, facialHairStyleFrame: index }))} preview={( )} /> ))} {effectiveVisual.facialHairEnabled ? ( {Array.from({ length: spriteCounts.facialHair }, (_, index) => { const value = index + 1; return ( updateVisual(buildPatchedVisual({ facialHairColorIndex: value }))} preview={( )} /> ); })} ) : null} updateGearType('headgear', 'none')} preview={( )} /> {([ ['cloth', '布帽'], ['leather', '皮具'], ['metal', '金属头盔'], ] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => { const gear = buildDefaultGear(type, 'headgear'); return ( updateGearType('headgear', type)} preview={( {gear ? : } )} /> ); })} {effectiveVisual.headgear ? ( <> {headgearAssets.map(asset => ( updateGearFile('headgear', asset.file)} preview={( )} /> ))} {headgearPoseOptions.map(option => ( updateGearFrame('headgear', option.value)} preview={( )} /> ))} ) : null} updateGearType('mainHand', 'none')} preview={( )} /> {([ ['melee', '近战武器'], ['magic', '法器'], ['ranged', '远程武器'], ] as Array<[MedievalAtlasSourceType, string]>).map(([type, label]) => { const gear = buildDefaultGear(type, 'mainHand'); return ( updateGearType('mainHand', type)} preview={( {gear ? : } )} /> ); })} {effectiveVisual.mainHand ? ( <> {mainHandAssets.map(asset => ( updateGearFile('mainHand', asset.file)} preview={( )} /> ))} {mainHandPoseOptions.map(option => ( updateGearFrame('mainHand', option.value)} preview={( )} /> ))} ) : null} updateGearType('offHand', 'none')} preview={( )} /> {(() => { const gear = buildDefaultGear('melee', 'offHand'); return ( updateGearType('offHand', 'melee')} preview={( {gear ? : } )} /> ); })()} {effectiveVisual.offHand ? ( <> {offHandAssets.map(asset => ( updateGearFile('offHand', asset.file)} preview={( )} /> ))} {offHandPoseOptions.map(option => ( updateGearFrame('offHand', option.value)} preview={( )} /> ))} ) : null}
); }