Files
Genarrative/src/components/NpcVisualEditor.tsx
高物 c49c64896a
Some checks failed
CI / verify (push) Has been cancelled
初始仓库迁移
2026-04-04 23:57:06 +08:00

1061 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<NpcLayoutPart, string> = {
body: '身体',
head: '头部',
facialHair: '面部毛发',
hair: '发型',
headgear: '头饰',
hand: '手部',
mainHand: '主手',
offHand: '副手',
};
function flattenNpcOptions() {
const sceneGroups = [
...getScenePresetsByWorld(WorldType.WUXIA),
...getScenePresetsByWorld(WorldType.XIANXIA),
];
const npcMap = new Map<string, EditorNpcOption>();
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 (
<label className="block">
<div className="mb-1 text-xs font-medium text-zinc-300">{label}</div>
<select
value={String(value)}
onChange={(event) => onChange(event.target.value)}
disabled={disabled}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm text-white outline-none transition focus:border-emerald-400/40 disabled:cursor-not-allowed disabled:opacity-60"
>
{options.map((option) => (
<option key={`${label}-${option.value}`} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</label>
);
}
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<Record<string, MedievalNpcVisualOverride>>(INITIAL_OVERRIDES);
const [layoutDraft, setLayoutDraft] =
useState<NpcLayoutConfig>(INITIAL_LAYOUT);
const [layoutHistory, setLayoutHistory] = useState<NpcLayoutConfig[]>([]);
const [editorState, setEditorState] = useState<EditableNpcVisualState | null>(
null,
);
const [loadMessage, setLoadMessage] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState(false);
const [isSavingLayout, setIsSavingLayout] = useState(false);
const [selectedPart, setSelectedPart] = useState<NpcLayoutPart>('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<unknown>(
NPC_VISUAL_OVERRIDES_API_PATH,
'加载角色形象覆盖配置失败',
),
fetchJson<unknown>(
NPC_LAYOUT_CONFIG_API_PATH,
'加载角色布局配置失败',
),
]);
if (disposed) {
return;
}
const messages: string[] = [];
if (overridesResult.status === 'fulfilled') {
if (isRecord(overridesResult.value)) {
setOverrideMap(
overridesResult.value as Record<string, MedievalNpcVisualOverride>,
);
} 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 (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
</div>
);
}
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 = <K extends keyof EditableNpcVisualState>(
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<HTMLDivElement>,
) => {
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 (
<div
className={
embedded ? 'text-zinc-100' : 'min-h-screen bg-[#0b0d11] text-zinc-100'
}
>
<div className={embedded ? '' : 'mx-auto max-w-7xl px-6 py-8'}>
{!embedded && (
<div className="mb-8">
<div className="text-xs uppercase tracking-[0.3em] text-emerald-400/70">
</div>
<h1 className="mt-2 text-3xl font-semibold text-white">
</h1>
<p className="mt-2 max-w-4xl text-sm leading-relaxed text-zinc-400">
</p>
</div>
)}
<div className="grid gap-6 lg:grid-cols-[360px_1fr]">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
{!hideNpcSelector && (
<div className="mb-4">
<SelectField
label="当前场景角色"
value={selectedNpc.encounter.id ?? ''}
onChange={(value) => setInternalSelectedNpcId(value)}
options={npcOptions.map((option) => ({
value: option.encounter.id ?? '',
label: `${option.encounter.npcName} (${option.sceneNames.join(' / ')})`,
}))}
/>
</div>
)}
<div className="mb-4 rounded-xl border border-white/10 bg-black/20 px-4 py-3">
<div className="text-sm font-semibold text-white">
{selectedNpc.encounter.npcName}
</div>
<div className="mt-1 text-xs text-zinc-400">
{selectedNpc.encounter.context}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{selectedNpc.encounter.npcDescription}
</div>
</div>
<div className="grid gap-3">
<SelectField
label="种族"
value={editorState.race}
onChange={(value) => setField('race', value as MedievalRace)}
options={(
['human', 'elf', 'orc', 'goblin'] as MedievalRace[]
).map((value) => ({
value,
label: MEDIEVAL_RACE_LABELS[value],
}))}
/>
<SelectField
label="肤色"
value={editorState.bodyColor}
onChange={(value) => setField('bodyColor', value)}
options={MEDIEVAL_BODY_COLORS.map((value) => ({
value,
label: MEDIEVAL_BODY_COLOR_LABELS[value] ?? value,
}))}
/>
<SelectField
label="头部"
value={editorState.headIndex}
onChange={(value) => setField('headIndex', Number(value))}
options={getMedievalHeadOptions(editorState.race)}
/>
<SelectField
label="发型"
value={editorState.hairStyleFrame}
onChange={(value) => setField('hairStyleFrame', Number(value))}
options={MEDIEVAL_HAIR_STYLE_LABELS.map((label, index) => ({
value: index,
label,
}))}
/>
<SelectField
label="发色"
value={editorState.hairColorIndex}
onChange={(value) => setField('hairColorIndex', Number(value))}
options={Array.from(
{ length: raceCounts.hair },
(_, index) => ({
value: index + 1,
label: MEDIEVAL_HAIR_COLOR_LABELS[index] ?? '自定义发色',
}),
)}
/>
<SelectField
label="面部毛发"
value={
editorState.facialHairEnabled
? editorState.facialHairStyleFrame + 1
: 0
}
onChange={(value) => {
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 && (
<SelectField
label="面部毛发颜色"
value={editorState.facialHairColorIndex}
onChange={(value) =>
setField('facialHairColorIndex', Number(value))
}
options={Array.from(
{ length: raceCounts.facialHair },
(_, index) => ({
value: index + 1,
label:
MEDIEVAL_FACIAL_HAIR_COLOR_LABELS[index] ?? '面部毛发颜色',
}),
)}
/>
)}
<SelectField
label="头饰类型"
value={editorState.headgearType}
onChange={(value) =>
updateGearType('headgear', value as GearSourceType)
}
options={[
{ value: 'none', label: '无头饰' },
{ value: 'cloth', label: '布制' },
{ value: 'leather', label: '皮革' },
{ value: 'metal', label: '金属' },
]}
/>
{editorState.headgearType !== 'none' && (
<>
<SelectField
label="头饰文件"
value={editorState.headgearFile}
onChange={(value) => updateGearFile('headgear', value)}
options={headgearAssets.map((asset) => ({
value: asset.file,
label: asset.label,
}))}
/>
<SelectField
label="头饰帧"
value={editorState.headgearFrame}
onChange={(value) =>
setField('headgearFrame', Number(value))
}
options={headgearPoseOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</>
)}
<SelectField
label="主手类型"
value={editorState.mainHandType}
onChange={(value) =>
updateGearType('mainHand', value as GearSourceType)
}
options={[
{ value: 'none', label: '无主手' },
{ value: 'melee', label: '近战' },
{ value: 'magic', label: '魔法' },
{ value: 'ranged', label: '远程' },
]}
/>
{editorState.mainHandType !== 'none' && (
<>
<SelectField
label="主手文件"
value={editorState.mainHandFile}
onChange={(value) => updateGearFile('mainHand', value)}
options={mainHandAssets.map((asset) => ({
value: asset.file,
label: asset.label,
}))}
/>
<SelectField
label="主手帧"
value={editorState.mainHandFrame}
onChange={(value) =>
setField('mainHandFrame', Number(value))
}
options={mainHandPoseOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</>
)}
<SelectField
label="副手类型"
value={editorState.offHandType}
onChange={(value) =>
updateGearType('offHand', value as GearSourceType)
}
options={[
{ value: 'none', label: '无副手' },
{ value: 'melee', label: '近战' },
]}
/>
{editorState.offHandType !== 'none' && (
<>
<SelectField
label="副手文件"
value={editorState.offHandFile}
onChange={(value) => updateGearFile('offHand', value)}
options={offHandAssets.map((asset) => ({
value: asset.file,
label: asset.label,
}))}
/>
<SelectField
label="副手帧"
value={editorState.offHandFrame}
onChange={(value) =>
setField('offHandFrame', Number(value))
}
options={offHandPoseOptions.map((option) => ({
value: option.value,
label: option.label,
}))}
/>
</>
)}
</div>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
onClick={saveOverrides}
disabled={isSaving}
className="inline-flex items-center gap-2 rounded-lg bg-emerald-500 px-4 py-2 text-sm font-medium text-black transition hover:bg-emerald-400 disabled:cursor-not-allowed disabled:opacity-60"
>
<Save className="h-4 w-4" />
<span>{isSaving ? '正在保存覆盖...' : '保存覆盖'}</span>
</button>
<button
onClick={saveLayout}
disabled={isSavingLayout}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/30 bg-emerald-500/10 px-4 py-2 text-sm font-medium text-emerald-100 transition hover:bg-emerald-500/20 disabled:cursor-not-allowed disabled:opacity-60"
>
<Move className="h-4 w-4" />
<span>{isSavingLayout ? '正在保存布局...' : '保存布局'}</span>
</button>
<button
onClick={rollbackLayout}
disabled={layoutHistory.length === 0}
className="inline-flex items-center gap-2 rounded-lg border border-white/10 bg-black/20 px-4 py-2 text-sm font-medium text-zinc-200 transition hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<RotateCcw className="h-4 w-4" />
<span></span>
</button>
{loadMessage && (
<EditorNotice message={loadMessage} tone="warning" />
)}
{saveMessage && <EditorNotice message={saveMessage} />}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.16),transparent_40%),linear-gradient(180deg,#10131a,#090b0f)] p-6">
<div className="mb-4 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white"></div>
<div className="mt-1 text-xs text-zinc-400">
<br />
0.5 </div>
</div>
<div className="rounded-lg border border-white/10 bg-black/20 px-3 py-2 text-xs text-zinc-300">
<span className="ml-1 text-emerald-300">
{PART_LABELS[selectedPart]}
</span>
</div>
</div>
<div className="mb-4 flex flex-wrap gap-2">
{(Object.keys(PART_LABELS) as NpcLayoutPart[]).map((part) => (
<button
key={part}
onClick={() => setSelectedPart(part)}
className={`rounded-full border px-3 py-1 text-xs transition ${selectedPart === part ? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-100' : 'border-white/10 bg-black/20 text-zinc-400 hover:text-white'}`}
>
{PART_LABELS[part]}
</button>
))}
</div>
<div className="flex min-h-[520px] items-center justify-center rounded-2xl border border-white/10 bg-[linear-gradient(180deg,#21242a,#111317)] p-6">
<div className="relative flex h-[420px] w-[320px] items-end justify-center overflow-hidden rounded-xl bg-[linear-gradient(180deg,#2a2f39,#16191f)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.04)]">
<div className="absolute inset-0 opacity-10 [background-image:linear-gradient(rgba(255,255,255,0.16)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,0.16)_1px,transparent_1px)] [background-size:20px_20px]" />
<MedievalNpcAnimator
visualSpec={previewSpec}
layoutConfig={layoutDraft}
selectedPart={selectedPart}
onPartPointerDown={handlePartPointerDown}
className="mb-10 drop-shadow-[0_18px_24px_rgba(0,0,0,0.45)]"
/>
</div>
</div>
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-4">
{(Object.keys(PART_LABELS) as NpcLayoutPart[]).map((part) => (
<button
key={part}
onClick={() => setSelectedPart(part)}
className={`rounded-xl border px-3 py-2 text-left text-xs transition ${selectedPart === part ? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-100' : 'border-white/10 bg-black/20 text-zinc-400 hover:border-white/20 hover:text-white'}`}
>
<div>{PART_LABELS[part]}</div>
<div className="mt-1 font-mono">
x {layoutDraft[part].x} / y {layoutDraft[part].y}
</div>
</button>
))}
</div>
<div className="mt-4 rounded-xl border border-white/10 bg-black/20 px-4 py-3 text-xs leading-relaxed text-zinc-400">
Current loadout:<span className="ml-2 text-zinc-200">
{editorState.headgearType !== 'none'
? (getMedievalAtlasAsset(
editorState.headgearType,
editorState.headgearFile,
)?.label ?? '未知头饰')
: '无头饰'}
</span>
<span className="mx-2 text-zinc-600">/</span>
<span className="text-zinc-200">
{editorState.mainHandType !== 'none'
? (getMedievalAtlasAsset(
editorState.mainHandType,
editorState.mainHandFile,
)?.label ?? '未知主手武器')
: '无主手武器'}
</span>
<span className="mx-2 text-zinc-600">/</span>
<span className="text-zinc-200">
{editorState.offHandType !== 'none'
? (getMedievalAtlasAsset(
editorState.offHandType,
editorState.offHandFile,
)?.label ?? '未知副手武器')
: '无副手武器'}
</span>
</div>
</div>
</div>
</div>
</div>
);
}