1061 lines
35 KiB
TypeScript
1061 lines
35 KiB
TypeScript
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>
|
||
);
|
||
}
|