import { Move, RotateCcw, Save } from 'lucide-react'; import React, { useEffect, useMemo, useState } from 'react'; import { buildMedievalNpcVisual, getMedievalAtlasAsset, getMedievalAtlasOptions, getMedievalHeadOptions, getMedievalPoseOptions, getNpcVisualOverrideById, 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, MedievalNpcVisualOverride, MedievalNpcVisualSpec, MedievalRace, } from '../data/medievalNpcVisuals'; import npcVisualOverridesJson from '../data/npcVisualOverrides.json'; import { getScenePresetsByWorld } from '../data/scenePresets'; import { EditorNotice } from '../editor/shared/EditorNotice'; import { fetchJson } from '../editor/shared/jsonClient'; import { Encounter, WorldType } from '../types'; import { MedievalNpcAnimator } from './MedievalNpcAnimator'; import { buildOverrideFromEditorState, type EditableNpcVisualState, type EditorNpcOption, type GearSourceType, getDefaultFileForType, getDefaultFrameForSelection, isNpcLayoutConfig, isRecord, sanitizeFrameSelection, } from './npcVisualEditorModel'; import { NPC_LAYOUT_CONFIG_API_PATH, NPC_VISUAL_OVERRIDES_API_PATH, persistNpcLayoutConfig, persistNpcVisualOverrides, } from './npcVisualEditorPersistence'; import { cloneNpcLayoutConfig, DEFAULT_NPC_LAYOUT_CONFIG, type NpcLayoutConfig, type NpcLayoutPart, } from './npcVisualShared'; const INITIAL_OVERRIDES = npcVisualOverridesJson as Record< string, MedievalNpcVisualOverride >; const INITIAL_LAYOUT = cloneNpcLayoutConfig(DEFAULT_NPC_LAYOUT_CONFIG); const PART_LABELS: Record = { body: '身体', head: '头部', facialHair: '面部毛发', hair: '发型', headgear: '头饰', hand: '手部', mainHand: '主手', offHand: '副手', }; function flattenNpcOptions() { const sceneGroups = [ ...getScenePresetsByWorld(WorldType.WUXIA), ...getScenePresetsByWorld(WorldType.XIANXIA), ]; const npcMap = new Map(); for (const scene of sceneGroups) { for (const npc of scene.npcs ?? []) { const existing = npcMap.get(npc.id); if (existing) { existing.sceneNames.push(scene.name); continue; } npcMap.set(npc.id, { encounter: { id: npc.id, kind: 'npc', npcName: npc.name, npcDescription: npc.description, npcAvatar: npc.avatar, context: npc.role, characterId: npc.characterId, }, sceneNames: [scene.name], }); } } return [...npcMap.values()].sort((a, b) => a.encounter.npcName.localeCompare(b.encounter.npcName, 'zh-Hans-CN'), ); } function inferSourceType(src: string | undefined): GearSourceType { if (!src) return 'none'; if (src.includes('/wardrobe/cloth/')) return 'cloth'; if (src.includes('/wardrobe/leather/')) return 'leather'; if (src.includes('/wardrobe/metal/')) return 'metal'; if (src.includes('/weapons/melee weapons/')) return 'melee'; if (src.includes('/weapons/magic weapons/')) return 'magic'; if (src.includes('/weapons/ranged weapons/')) return 'ranged'; return 'none'; } function parseStateFromSpec( spec: MedievalNpcVisualSpec, ): EditableNpcVisualState { const race = spec.race; const bodyColor = spec.bodySrc.match(/body_(.+)\.png$/u)?.[1] ?? 'black'; const headIndex = Number(spec.headSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1'); const hairColorIndex = Number( spec.hairSrc.match(/_(\d+)\.png$/u)?.[1] ?? '1', ); const facialHairColorIndex = Number( spec.facialHairSrc?.match(/_(\d+)\.png$/u)?.[1] ?? '1', ); const headgearType = inferSourceType(spec.headgear?.src); const mainHandType = inferSourceType(spec.mainHand?.src); const offHandType = inferSourceType(spec.offHand?.src); const headgearFile = spec.headgear?.src.split('/').pop() ?? getDefaultFileForType(headgearType); const mainHandFile = spec.mainHand?.src.split('/').pop() ?? getDefaultFileForType(mainHandType); const offHandFile = spec.offHand?.src.split('/').pop() ?? 'shield.png'; return { race, bodyColor, headIndex, hairColorIndex, hairStyleFrame: spec.hairFrame, facialHairEnabled: !!spec.facialHairSrc, facialHairColorIndex, facialHairStyleFrame: spec.facialHairFrame ?? 0, headgearType, headgearFile, headgearFrame: sanitizeFrameSelection( headgearType, headgearFile, spec.headgear?.frameIndex ?? 0, 'headgear', ), mainHandType, mainHandFile, mainHandFrame: sanitizeFrameSelection( mainHandType, mainHandFile, spec.mainHand?.frameIndex ?? 0, 'mainHand', ), offHandType, offHandFile, offHandFrame: sanitizeFrameSelection( offHandType, offHandFile, spec.offHand?.frameIndex ?? 0, 'offHand', ), }; } function buildPreviewSpec( encounter: Encounter, editorState: EditableNpcVisualState, ) { return { ...buildMedievalNpcVisual(encounter), ...buildOverrideFromEditorState(editorState), } as MedievalNpcVisualSpec; } function SelectField({ label, value, onChange, options, disabled = false, }: { label: string; value: string | number; onChange: (next: string) => void; options: Array<{ label: string; value: string | number }>; disabled?: boolean; }) { return ( ); } export function NpcVisualEditor({ embedded = false, selectedNpcId: controlledNpcId, hideNpcSelector = false, }: { embedded?: boolean; selectedNpcId?: string; hideNpcSelector?: boolean; }) { const npcOptions = useMemo(() => flattenNpcOptions(), []); const [internalSelectedNpcId, setInternalSelectedNpcId] = useState( controlledNpcId ?? npcOptions[0]?.encounter.id ?? '', ); const [overrideMap, setOverrideMap] = useState>(INITIAL_OVERRIDES); const [layoutDraft, setLayoutDraft] = useState(INITIAL_LAYOUT); const [layoutHistory, setLayoutHistory] = useState([]); const [editorState, setEditorState] = useState( null, ); const [loadMessage, setLoadMessage] = useState(null); const [saveMessage, setSaveMessage] = useState(null); const [isSaving, setIsSaving] = useState(false); const [isSavingLayout, setIsSavingLayout] = useState(false); const [selectedPart, setSelectedPart] = useState('head'); const [dragState, setDragState] = useState<{ part: NpcLayoutPart; startX: number; startY: number; originX: number; originY: number; } | null>(null); const effectiveNpcId = controlledNpcId ?? internalSelectedNpcId; const selectedNpc = npcOptions.find((option) => option.encounter.id === effectiveNpcId) ?? npcOptions[0]; useEffect(() => { let disposed = false; const loadRemoteConfig = async () => { const [overridesResult, layoutResult] = await Promise.allSettled([ fetchJson( NPC_VISUAL_OVERRIDES_API_PATH, '加载角色形象覆盖配置失败', ), fetchJson( NPC_LAYOUT_CONFIG_API_PATH, '加载角色布局配置失败', ), ]); if (disposed) { return; } const messages: string[] = []; if (overridesResult.status === 'fulfilled') { if (isRecord(overridesResult.value)) { setOverrideMap( overridesResult.value as Record, ); } else { messages.push( '角色形象覆盖配置响应无效,使用内置默认值。', ); } } else { messages.push( overridesResult.reason instanceof Error ? `${overridesResult.reason.message},使用内置角色形象覆盖配置。` : '加载角色形象覆盖配置失败,使用内置默认值。', ); } if (layoutResult.status === 'fulfilled') { if (isNpcLayoutConfig(layoutResult.value)) { setLayoutDraft(cloneNpcLayoutConfig(layoutResult.value)); setLayoutHistory([]); } else { messages.push( '角色布局配置响应无效,使用内置默认值。', ); } } else { messages.push( layoutResult.reason instanceof Error ? `${layoutResult.reason.message},使用内置角色布局配置。` : '加载角色布局配置失败,使用内置默认值。', ); } setLoadMessage(messages.length > 0 ? messages.join(' ') : null); }; void loadRemoteConfig(); return () => { disposed = true; }; }, []); useEffect(() => { if (controlledNpcId) { setInternalSelectedNpcId(controlledNpcId); } }, [controlledNpcId]); useEffect(() => { if (!selectedNpc) return; const encounterId = selectedNpc.encounter.id ?? ''; const override = encounterId ? (overrideMap[encounterId] ?? getNpcVisualOverrideById(encounterId) ?? undefined) : undefined; const spec = override ? ({ ...buildMedievalNpcVisual(selectedNpc.encounter), ...override, } as MedievalNpcVisualSpec) : buildMedievalNpcVisual(selectedNpc.encounter); setEditorState(parseStateFromSpec(spec)); }, [overrideMap, selectedNpc]); useEffect(() => { if (!editorState) return; const nextHeadgearFrame = sanitizeFrameSelection( editorState.headgearType, editorState.headgearFile, editorState.headgearFrame, 'headgear', ); const nextMainHandFrame = sanitizeFrameSelection( editorState.mainHandType, editorState.mainHandFile, editorState.mainHandFrame, 'mainHand', ); const nextOffHandFrame = sanitizeFrameSelection( editorState.offHandType, editorState.offHandFile, editorState.offHandFrame, 'offHand', ); if ( nextHeadgearFrame === editorState.headgearFrame && nextMainHandFrame === editorState.mainHandFrame && nextOffHandFrame === editorState.offHandFrame ) { return; } setEditorState((prev) => prev ? { ...prev, headgearFrame: nextHeadgearFrame, mainHandFrame: nextMainHandFrame, offHandFrame: nextOffHandFrame, } : prev, ); }, [ editorState, editorState?.headgearType, editorState?.headgearFile, editorState?.headgearFrame, editorState?.mainHandType, editorState?.mainHandFile, editorState?.mainHandFrame, editorState?.offHandType, editorState?.offHandFile, editorState?.offHandFrame, ]); useEffect(() => { if (!dragState) return; const handlePointerMove = (event: PointerEvent) => { const dx = Math.round((event.clientX - dragState.startX) / 2.4); const dy = Math.round((event.clientY - dragState.startY) / 2.4); setLayoutDraft((prev) => ({ ...prev, [dragState.part]: { x: dragState.originX + dx, y: dragState.originY + dy, }, })); }; const handlePointerUp = () => { setDragState(null); }; window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp, { once: true }); return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; }, [dragState]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { const target = event.target as HTMLElement | null; const tagName = target?.tagName ?? ''; if ( tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT' ) { return; } let dx = 0; let dy = 0; if (event.key === 'ArrowLeft') dx = -1; if (event.key === 'ArrowRight') dx = 1; if (event.key === 'ArrowUp') dy = -1; if (event.key === 'ArrowDown') dy = 1; if (dx === 0 && dy === 0) return; event.preventDefault(); const step = event.shiftKey ? 5 : 1; setLayoutHistory((prev) => [...prev, cloneNpcLayoutConfig(layoutDraft)]); setLayoutDraft((prev) => ({ ...prev, [selectedPart]: { x: prev[selectedPart].x + dx * step, y: prev[selectedPart].y + dy * step, }, })); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [layoutDraft, selectedPart]); if (!selectedNpc || !editorState) { return (
请先选择一个场景角色进行编辑
); } const raceCounts = getRaceSpriteCounts(editorState.race); const previewSpec = buildPreviewSpec(selectedNpc.encounter, editorState); const headgearAssets = editorState.headgearType === 'none' ? [] : getMedievalAtlasOptions(editorState.headgearType); const mainHandAssets = editorState.mainHandType === 'none' ? [] : getMedievalAtlasOptions(editorState.mainHandType); const offHandAssets = editorState.offHandType === 'none' ? [] : getMedievalAtlasOptions(editorState.offHandType); const headgearPoseOptions = editorState.headgearType === 'none' ? [] : getMedievalPoseOptions( editorState.headgearType, editorState.headgearFile, 'headgear', ); const mainHandPoseOptions = editorState.mainHandType === 'none' ? [] : getMedievalPoseOptions( editorState.mainHandType, editorState.mainHandFile, 'mainHand', ); const offHandPoseOptions = editorState.offHandType === 'none' ? [] : getMedievalPoseOptions( editorState.offHandType, editorState.offHandFile, 'offHand', ); const saveOverrides = async () => { setIsSaving(true); setSaveMessage(null); try { const { nextOverrideMap, saveMessage: nextSaveMessage } = await persistNpcVisualOverrides({ overrideMap, npcId: selectedNpc.encounter.id!, editorState, }); setOverrideMap(nextOverrideMap); setSaveMessage(nextSaveMessage); } catch (error) { setSaveMessage(error instanceof Error ? error.message : '保存失败'); } finally { setIsSaving(false); } }; const saveLayout = async () => { setIsSavingLayout(true); setSaveMessage(null); try { const { saveMessage: nextSaveMessage } = await persistNpcLayoutConfig({ layoutDraft, }); setSaveMessage(nextSaveMessage); setLayoutHistory([]); } catch (error) { setSaveMessage(error instanceof Error ? error.message : '保存失败'); } finally { setIsSavingLayout(false); } }; const rollbackLayout = () => { setLayoutHistory((prev) => { if (prev.length === 0) return prev; const next = [...prev]; const previousLayout = next.pop()!; setLayoutDraft(previousLayout); setSaveMessage('布局已重置'); return next; }); }; const setField = ( key: K, value: EditableNpcVisualState[K], ) => { setEditorState((prev) => (prev ? { ...prev, [key]: value } : prev)); }; const updateGearType = ( key: 'headgear' | 'mainHand' | 'offHand', nextType: GearSourceType, ) => { if (key === 'headgear') { const nextFile = getDefaultFileForType(nextType); setEditorState((prev) => prev ? { ...prev, headgearType: nextType, headgearFile: nextFile, headgearFrame: getDefaultFrameForSelection( nextType, nextFile, 'headgear', ), } : prev, ); return; } if (key === 'mainHand') { const nextFile = getDefaultFileForType(nextType); setEditorState((prev) => prev ? { ...prev, mainHandType: nextType, mainHandFile: nextFile, mainHandFrame: getDefaultFrameForSelection( nextType, nextFile, 'mainHand', ), } : prev, ); return; } const nextFile = nextType === 'none' ? '' : 'shield.png'; setEditorState((prev) => prev ? { ...prev, offHandType: nextType, offHandFile: nextFile, offHandFrame: getDefaultFrameForSelection( nextType, nextFile, 'offHand', ), } : prev, ); }; const updateGearFile = ( key: 'headgear' | 'mainHand' | 'offHand', nextFile: string, ) => { if (key === 'headgear') { setEditorState((prev) => prev ? { ...prev, headgearFile: nextFile, headgearFrame: getDefaultFrameForSelection( prev.headgearType, nextFile, 'headgear', ), } : prev, ); return; } if (key === 'mainHand') { setEditorState((prev) => prev ? { ...prev, mainHandFile: nextFile, mainHandFrame: getDefaultFrameForSelection( prev.mainHandType, nextFile, 'mainHand', ), } : prev, ); return; } setEditorState((prev) => prev ? { ...prev, offHandFile: nextFile, offHandFrame: getDefaultFrameForSelection( prev.offHandType, nextFile, 'offHand', ), } : prev, ); }; const handlePartPointerDown = ( part: NpcLayoutPart, event: React.PointerEvent, ) => { event.preventDefault(); event.stopPropagation(); setSelectedPart(part); setLayoutHistory((prev) => [...prev, cloneNpcLayoutConfig(layoutDraft)]); setDragState({ part, startX: event.clientX, startY: event.clientY, originX: layoutDraft[part].x, originY: layoutDraft[part].y, }); }; return (
{!embedded && (
场景角色形象编辑器

场景角色形象编辑器

选择并编辑场景角色的外观,支持调整种族、肤色、发型、装备等多种视觉元素。

)}
{!hideNpcSelector && (
setInternalSelectedNpcId(value)} options={npcOptions.map((option) => ({ value: option.encounter.id ?? '', label: `${option.encounter.npcName} (${option.sceneNames.join(' / ')})`, }))} />
)}
{selectedNpc.encounter.npcName}
{selectedNpc.encounter.context}
{selectedNpc.encounter.npcDescription}
setField('race', value as MedievalRace)} options={( ['human', 'elf', 'orc', 'goblin'] as MedievalRace[] ).map((value) => ({ value, label: MEDIEVAL_RACE_LABELS[value], }))} /> setField('bodyColor', value)} options={MEDIEVAL_BODY_COLORS.map((value) => ({ value, label: MEDIEVAL_BODY_COLOR_LABELS[value] ?? value, }))} /> setField('headIndex', Number(value))} options={getMedievalHeadOptions(editorState.race)} /> setField('hairStyleFrame', Number(value))} options={MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => ({ value: index, label, }))} /> setField('hairColorIndex', Number(value))} options={Array.from( { length: raceCounts.hair }, (_, index) => ({ value: index + 1, label: MEDIEVAL_HAIR_COLOR_LABELS[index] ?? '自定义发色', }), )} /> { const next = Number(value); setField('facialHairEnabled', next > 0); setField('facialHairStyleFrame', next > 0 ? next - 1 : 0); }} options={[ { value: 0, label: '隐藏面部毛发' }, ...MEDIEVAL_FACIAL_HAIR_STYLE_LABELS.map((label, index) => ({ value: index + 1, label, })), ]} /> {editorState.facialHairEnabled && ( setField('facialHairColorIndex', Number(value)) } options={Array.from( { length: raceCounts.facialHair }, (_, index) => ({ value: index + 1, label: MEDIEVAL_FACIAL_HAIR_COLOR_LABELS[index] ?? '面部毛发颜色', }), )} /> )} updateGearType('headgear', value as GearSourceType) } options={[ { value: 'none', label: '无头饰' }, { value: 'cloth', label: '布制' }, { value: 'leather', label: '皮革' }, { value: 'metal', label: '金属' }, ]} /> {editorState.headgearType !== 'none' && ( <> updateGearFile('headgear', value)} options={headgearAssets.map((asset) => ({ value: asset.file, label: asset.label, }))} /> setField('headgearFrame', Number(value)) } options={headgearPoseOptions.map((option) => ({ value: option.value, label: option.label, }))} /> )} updateGearType('mainHand', value as GearSourceType) } options={[ { value: 'none', label: '无主手' }, { value: 'melee', label: '近战' }, { value: 'magic', label: '魔法' }, { value: 'ranged', label: '远程' }, ]} /> {editorState.mainHandType !== 'none' && ( <> updateGearFile('mainHand', value)} options={mainHandAssets.map((asset) => ({ value: asset.file, label: asset.label, }))} /> setField('mainHandFrame', Number(value)) } options={mainHandPoseOptions.map((option) => ({ value: option.value, label: option.label, }))} /> )} updateGearType('offHand', value as GearSourceType) } options={[ { value: 'none', label: '无副手' }, { value: 'melee', label: '近战' }, ]} /> {editorState.offHandType !== 'none' && ( <> updateGearFile('offHand', value)} options={offHandAssets.map((asset) => ({ value: asset.file, label: asset.label, }))} /> setField('offHandFrame', Number(value)) } options={offHandPoseOptions.map((option) => ({ value: option.value, label: option.label, }))} /> )}
{loadMessage && ( )} {saveMessage && }
布局预览
拖动标记微调场景角色的预览布局。
移动时按住换挡键可按 0.5 步长微调。
当前选中: {PART_LABELS[selectedPart]}
{(Object.keys(PART_LABELS) as NpcLayoutPart[]).map((part) => ( ))}
{(Object.keys(PART_LABELS) as NpcLayoutPart[]).map((part) => ( ))}
Current loadout: {editorState.headgearType !== 'none' ? (getMedievalAtlasAsset( editorState.headgearType, editorState.headgearFile, )?.label ?? '未知头饰') : '无头饰'} / {editorState.mainHandType !== 'none' ? (getMedievalAtlasAsset( editorState.mainHandType, editorState.mainHandFile, )?.label ?? '未知主手武器') : '无主手武器'} / {editorState.offHandType !== 'none' ? (getMedievalAtlasAsset( editorState.offHandType, editorState.offHandFile, )?.label ?? '未知副手武器') : '无副手武器'}
); }