1
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-11 15:43:32 +08:00
parent f19e482c8f
commit 0981d6ee1b
78 changed files with 1102 additions and 8510 deletions

View File

@@ -5,7 +5,7 @@ import { createPortal } from 'react-dom';
import { AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS } from '../data/affinityLevels';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from '../data/characterPresets';
import {
CUSTOM_WORLD_SCENE_RELATIVE_POSITION_OPTIONS,
@@ -522,18 +522,18 @@ function SceneSparringPreview({ profile }: { profile: CustomWorldProfile }) {
if (candidates.length === 1) {
const firstCandidate = candidates[0];
if (!firstCandidate) {
return PRESET_CHARACTERS.slice(0, 2);
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}
const fallback =
PRESET_CHARACTERS.find(
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id !== firstCandidate.id,
) ??
PRESET_CHARACTERS[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
firstCandidate;
return [firstCandidate, fallback];
}
return PRESET_CHARACTERS.slice(0, 2);
return ROLE_TEMPLATE_CHARACTERS.slice(0, 2);
}, [profile]);
const [leftCharacter, rightCharacter] = sparringCharacters;
@@ -1622,10 +1622,10 @@ function PlayableNpcEditor({
const [draft, setDraft] = useDraft(npc);
const [isAiAssetStudioOpen, setIsAiAssetStudioOpen] = useState(false);
const selectedTemplate =
PRESET_CHARACTERS.find(
ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === draft.templateCharacterId,
) ??
PRESET_CHARACTERS[0] ??
ROLE_TEMPLATE_CHARACTERS[0] ??
null;
return (
@@ -1682,14 +1682,14 @@ function PlayableNpcEditor({
<Field label="外观模板">
<SelectField
value={draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id ?? ''}
value={draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id ?? ''}
onChange={(value) =>
setDraft((current) => ({
...current,
templateCharacterId: value,
}))
}
options={PRESET_CHARACTERS.map((character) => ({
options={ROLE_TEMPLATE_CHARACTERS.map((character) => ({
value: character.id,
label: `${character.name} / ${character.title}`,
}))}
@@ -1838,7 +1838,7 @@ function PlayableNpcEditor({
onSave({
...draft,
templateCharacterId:
draft.templateCharacterId ?? PRESET_CHARACTERS[0]?.id,
draft.templateCharacterId ?? ROLE_TEMPLATE_CHARACTERS[0]?.id,
});
onClose();
}}
@@ -2548,9 +2548,9 @@ function createPlayableNpc(
): CustomWorldPlayableNpc {
const seed = Date.now() + profile.playableNpcs.length;
const template =
PRESET_CHARACTERS[
profile.playableNpcs.length % Math.max(1, PRESET_CHARACTERS.length)
] ?? PRESET_CHARACTERS[0];
ROLE_TEMPLATE_CHARACTERS[
profile.playableNpcs.length % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
] ?? ROLE_TEMPLATE_CHARACTERS[0];
return {
id: createEntryId(

View File

@@ -6,7 +6,7 @@ import {
} from 'lucide-react';
import { type ChangeEvent, type ReactNode, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import {
AnimationState,
type CustomWorldNpc,
@@ -17,7 +17,7 @@ import {
buildAnimationClipFromVideoSource,
type DraftAnimationClip,
readFileAsDataUrl,
} from './preset-editor/characterAssetStudioModel';
} from './asset-studio/characterAssetWorkflowModel';
import {
type CharacterAnimationDraftPayload,
type CharacterAnimationGenerationPayload,
@@ -27,7 +27,7 @@ import {
generateCharacterVisualCandidates,
publishCharacterAnimationAssets,
publishCharacterVisualAsset,
} from './preset-editor/characterAssetStudioPersistence';
} from './asset-studio/characterAssetWorkflowPersistence';
type EditableCustomWorldRole = CustomWorldPlayableNpc | CustomWorldNpc;
@@ -353,7 +353,7 @@ export function CustomWorldRoleAssetStudioModal({
const selectedTemplate =
roleKind === 'playable' && 'templateCharacterId' in role && role.templateCharacterId
? PRESET_CHARACTERS.find(
? ROLE_TEMPLATE_CHARACTERS.find(
(character) => character.id === role.templateCharacterId,
) ?? null
: null;
@@ -661,7 +661,7 @@ export function CustomWorldRoleAssetStudioModal({
value={visualPromptText}
onChange={setVisualPromptText}
rows={6}
placeholder="例如:衣摆更利落、剑柄更明显、整体更像江湖少女剑客。"
placeholder="例如:衣摆更利落、主武器辨识度更高、整体更像边境世界的年轻冒险者。"
/>
</Field>
<Field label="参考图">

View File

@@ -1,908 +0,0 @@
import { type ReactNode, useDeferredValue, useEffect, useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { getInventoryItemValue } from '../data/economy';
import { validateItemOverrides } from '../data/editorValidation';
import { getEquipmentSlotFromItem, getEquipmentSlotLabel } from '../data/equipmentEffects';
import { isInventoryItemUsable, resolveInventoryItemUseEffect } from '../data/inventoryEffects';
import {
applyItemCatalogOverride,
buildItemCatalogFromAssetPaths,
createInventoryItemFromCatalogEntry,
ITEM_CATALOG_API_PATH,
ITEM_CATEGORY_OPTIONS,
} from '../data/itemCatalog';
import {
EDITOR_JSON_RESOURCE_IDS,
fetchEditorJsonResource,
saveEditorJsonResource,
} from '../editor/shared/editorApiClient';
import { fetchJson } from '../editor/shared/jsonClient';
import { SectionCard as Section } from '../editor/shared/SectionCard';
import { type ItemCatalogOverride, type ItemRarity, type TimedBuildBuff,WorldType } from '../types';
import { PixelIcon } from './PixelIcon';
const ITEM_PREVIEW_CHARACTER = PRESET_CHARACTERS[0] ?? null;
const LIST_PREVIEW_LIMIT = 240;
type ItemCatalogAssetResponse = {
assetPaths: string[];
};
const RARITY_OPTIONS: ItemRarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary'];
const RARITY_LABELS: Record<ItemRarity, string> = {
common: '普通',
uncommon: '不普通',
rare: '稀有',
epic: '史诗',
legendary: '传奇',
};
function arraysEqual(left: string[], right: string[]) {
if (left.length !== right.length) return false;
return left.every((value, index) => value === right[index]);
}
function parseTagsInput(value: string) {
return [...new Set(
value
.split(/[\n,]/u)
.map(tag => tag.trim())
.filter(Boolean),
)];
}
function tagsInputValue(tags: string[]) {
return tags.join(', ');
}
function parseBuildBuffLines(
value: string,
sourceType: TimedBuildBuff['sourceType'],
sourceId: string,
) {
return value
.split('\n')
.map(line => line.trim())
.filter(Boolean)
.map((line, index) => {
const [namePart, tagsPart, durationPart] = line.split('|').map(part => part.trim());
const tags = parseTagsInput(tagsPart ?? '');
const durationTurns = Math.max(1, Number(durationPart ?? '1') || 1);
return {
id: `${sourceId}-buff-${index + 1}`,
sourceType,
sourceId,
name: namePart || `${sourceId}-buff-${index + 1}`,
tags,
durationTurns,
} satisfies TimedBuildBuff;
})
.filter(buff => buff.tags.length > 0);
}
function buildBuffLinesValue(buffs: TimedBuildBuff[] | null | undefined) {
return (buffs ?? [])
.map(buff => `${buff.name}|${buff.tags.join(',')}|${buff.durationTurns}`)
.join('\n');
}
function Label({ children }: { children: ReactNode }) {
return <div className="mb-1 text-xs font-medium text-zinc-300">{children}</div>;
}
function TextInput({
value,
onChange,
placeholder,
disabled = false,
}: {
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
}) {
return (
<input
value={value}
onChange={event => onChange(event.target.value)}
placeholder={placeholder}
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"
/>
);
}
function TextArea({
value,
onChange,
rows = 4,
}: {
value: string;
onChange: (value: string) => void;
rows?: number;
}) {
return (
<textarea
rows={rows}
value={value}
onChange={event => onChange(event.target.value)}
className="w-full rounded-lg border border-white/10 bg-black/30 px-3 py-2 text-sm leading-relaxed text-white outline-none transition focus:border-emerald-400/40"
/>
);
}
function Select({
value,
onChange,
options,
}: {
value: string;
onChange: (value: string) => void;
options: Array<{ label: string; value: string }>;
}) {
return (
<select
value={value}
onChange={event => onChange(event.target.value)}
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"
>
{options.map(option => (
<option key={`${option.value}-${option.label}`} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
export function ItemCatalogEditor() {
const [assetPaths, setAssetPaths] = useState<string[]>([]);
const [overrideMap, setOverrideMap] = useState<Record<string, ItemCatalogOverride>>({});
const [selectedItemId, setSelectedItemId] = useState('');
const [searchText, setSearchText] = useState('');
const [categoryFilter, setCategoryFilter] = useState('ALL');
const [rarityFilter, setRarityFilter] = useState<'ALL' | ItemRarity>('ALL');
const [previewWorld, setPreviewWorld] = useState<WorldType>(WorldType.WUXIA);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveMessage, setSaveMessage] = useState<string | null>(null);
const deferredSearchText = useDeferredValue(searchText);
useEffect(() => {
let disposed = false;
const load = async () => {
setIsLoading(true);
setLoadError(null);
try {
const [catalogResponse, overridesResponse] = await Promise.all([
fetchJson<ItemCatalogAssetResponse>(ITEM_CATALOG_API_PATH),
fetchEditorJsonResource<Record<string, ItemCatalogOverride>>(
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
),
]);
if (disposed) return;
const nextAssetPaths = catalogResponse.assetPaths ?? [];
setAssetPaths(nextAssetPaths);
setOverrideMap(overridesResponse ?? {});
setSelectedItemId(current => current || (buildItemCatalogFromAssetPaths(nextAssetPaths)[0]?.id ?? ''));
} catch (error) {
if (disposed) return;
setLoadError(error instanceof Error ? error.message : '物品目录加载失败');
} finally {
if (!disposed) {
setIsLoading(false);
}
}
};
void load();
return () => {
disposed = true;
};
}, []);
const baseItems = useMemo(
() => buildItemCatalogFromAssetPaths(assetPaths),
[assetPaths],
);
const baseItemMap = useMemo(
() => new Map(baseItems.map(item => [item.id, item])),
[baseItems],
);
const effectiveItems = useMemo(
() => baseItems.map(item => applyItemCatalogOverride(item, overrideMap[item.id])),
[baseItems, overrideMap],
);
const filteredItems = useMemo(() => {
const query = deferredSearchText.trim().toLowerCase();
return effectiveItems.filter(item => {
if (categoryFilter !== 'ALL' && item.category !== categoryFilter) return false;
if (rarityFilter !== 'ALL' && item.rarity !== rarityFilter) return false;
if (!query) return true;
const haystack = [
item.name,
item.category,
item.rarity,
item.description,
item.sourcePath,
...item.tags,
].join(' ').toLowerCase();
return haystack.includes(query);
});
}, [categoryFilter, deferredSearchText, effectiveItems, rarityFilter]);
const visibleItems = useMemo(
() => filteredItems.slice(0, LIST_PREVIEW_LIMIT),
[filteredItems],
);
useEffect(() => {
if (!effectiveItems.length) {
setSelectedItemId('');
return;
}
if (!selectedItemId || !baseItemMap.has(selectedItemId)) {
setSelectedItemId(effectiveItems[0]?.id ?? '');
}
}, [baseItemMap, effectiveItems, selectedItemId]);
const selectedBaseItem = selectedItemId ? baseItemMap.get(selectedItemId) ?? null : null;
const selectedItem = selectedBaseItem
? applyItemCatalogOverride(selectedBaseItem, overrideMap[selectedBaseItem.id])
: null;
const selectedOverride = selectedItemId ? overrideMap[selectedItemId] ?? null : null;
const previewInventoryItem = useMemo(
() => selectedItem ? createInventoryItemFromCatalogEntry(selectedItem, 1, previewWorld) : null,
[previewWorld, selectedItem],
);
const worldProfile = selectedItem?.worldProfiles?.[previewWorld] ?? null;
const previewUseEffect = useMemo(
() => (previewInventoryItem && ITEM_PREVIEW_CHARACTER)
? resolveInventoryItemUseEffect(previewInventoryItem, ITEM_PREVIEW_CHARACTER)
: null,
[previewInventoryItem],
);
const previewEquipmentSlot = useMemo(
() => previewInventoryItem
? getEquipmentSlotFromItem(previewInventoryItem)
: null,
[previewInventoryItem],
);
const updateSelectedOverride = <K extends keyof ItemCatalogOverride>(
key: K,
value: ItemCatalogOverride[K],
) => {
if (!selectedBaseItem) return;
setOverrideMap(current => {
const nextOverride = {
...(current[selectedBaseItem.id] ?? {}),
[key]: value,
};
const normalizedOverride: ItemCatalogOverride = {...nextOverride};
if ((normalizedOverride.name ?? selectedBaseItem.name) === selectedBaseItem.name) {
delete normalizedOverride.name;
}
if ((normalizedOverride.category ?? selectedBaseItem.category) === selectedBaseItem.category) {
delete normalizedOverride.category;
}
if ((normalizedOverride.rarity ?? selectedBaseItem.rarity) === selectedBaseItem.rarity) {
delete normalizedOverride.rarity;
}
if ((normalizedOverride.description ?? selectedBaseItem.description) === selectedBaseItem.description) {
delete normalizedOverride.description;
}
if (
normalizedOverride.tags &&
arraysEqual(normalizedOverride.tags, selectedBaseItem.tags)
) {
delete normalizedOverride.tags;
}
const hasOverride = Object.keys(normalizedOverride).length > 0;
if (!hasOverride) {
const { [selectedBaseItem.id]: _removed, ...rest } = current;
return rest;
}
return {
...current,
[selectedBaseItem.id]: normalizedOverride,
};
});
};
const updateSelectedStatProfileField = (
key: 'maxHpBonus' | 'maxManaBonus' | 'outgoingDamageBonus' | 'incomingDamageMultiplier',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.statProfile ?? {}),
[key]: value,
};
updateSelectedOverride('statProfile', nextProfile);
};
const updateSelectedUseProfileField = (
key: 'hpRestore' | 'manaRestore' | 'cooldownReduction',
value: number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
[key]: value,
buildBuffs: selectedItem.useProfile?.buildBuffs ?? [],
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedUseProfileBuffs = (value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.useProfile ?? {}),
hpRestore: selectedItem.useProfile?.hpRestore ?? 0,
manaRestore: selectedItem.useProfile?.manaRestore ?? 0,
cooldownReduction: selectedItem.useProfile?.cooldownReduction ?? 0,
buildBuffs: parseBuildBuffLines(value, 'item', selectedItem.id),
};
updateSelectedOverride('useProfile', nextProfile);
};
const updateSelectedBuildProfileField = (
key: 'role' | 'setId' | 'setName' | 'pieceName' | 'forgeRank',
value: string | number,
) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
[key]: value,
tags: selectedItem.buildProfile?.tags ?? [],
synergy: selectedItem.buildProfile?.synergy ?? [],
craftTags: selectedItem.buildProfile?.craftTags ?? [],
};
updateSelectedOverride('buildProfile', nextProfile);
};
const updateSelectedBuildProfileTags = (key: 'tags' | 'synergy' | 'craftTags', value: string) => {
if (!selectedItem) return;
const nextProfile = {
...(selectedItem.buildProfile ?? { role: '', tags: [], synergy: [], craftTags: [], forgeRank: 0 }),
role: selectedItem.buildProfile?.role ?? '',
tags: key === 'tags' ? parseTagsInput(value) : (selectedItem.buildProfile?.tags ?? []),
synergy: key === 'synergy' ? parseTagsInput(value) : (selectedItem.buildProfile?.synergy ?? []),
craftTags: key === 'craftTags' ? parseTagsInput(value) : (selectedItem.buildProfile?.craftTags ?? []),
forgeRank: selectedItem.buildProfile?.forgeRank ?? 0,
};
updateSelectedOverride('buildProfile', nextProfile);
};
const resetSelectedOverride = () => {
if (!selectedItemId) return;
setOverrideMap(current => {
const { [selectedItemId]: _removed, ...rest } = current;
return rest;
});
};
const handleSave = async () => {
const validationErrors = validateItemOverrides(
overrideMap,
baseItems.map(item => item.id),
);
if (validationErrors.length > 0) {
setSaveMessage(validationErrors.slice(0, 3).join(' | '));
setTimeout(() => setSaveMessage(null), 5000);
return;
}
setIsSaving(true);
setSaveMessage(null);
try {
await saveEditorJsonResource(
EDITOR_JSON_RESOURCE_IDS.itemOverrides,
overrideMap as Record<string, unknown>,
'保存失败',
);
setSaveMessage('物品覆盖已保存到 src/data/itemOverrides.json。');
setTimeout(() => setSaveMessage(null), 5000);
} catch (error) {
setSaveMessage(error instanceof Error ? error.message : '保存失败');
setTimeout(() => setSaveMessage(null), 5000);
} finally {
setIsSaving(false);
}
};
const categoryOptions = [
{ label: '全部分类', value: 'ALL' },
...ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category })),
];
const rarityOptions = [
{ label: '全部稀有度', value: 'ALL' },
...RARITY_OPTIONS.map(rarity => ({ label: rarity, value: rarity })),
];
if (isLoading) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
...
</div>
);
}
if (loadError) {
return (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 p-6 text-sm text-rose-100">
{loadError}
</div>
);
}
return (
<div className="grid gap-6 xl:grid-cols-[360px_1fr_420px]">
<Section title="物品列表" description="基于 public/Icons 下的全部 png 素材自动构建物品目录,可按名称、路径、分类和稀有度筛选。">
<div className="grid gap-3">
<div>
<Label></Label>
<TextInput
value={searchText}
onChange={setSearchText}
placeholder="按名称、路径、标签搜索"
/>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div>
<Label></Label>
<Select value={categoryFilter} onChange={setCategoryFilter} options={categoryOptions} />
</div>
<div>
<Label></Label>
<Select value={rarityFilter} onChange={value => setRarityFilter(value as 'ALL' | ItemRarity)} options={rarityOptions} />
</div>
</div>
</div>
<div className="mt-4 rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{effectiveItems.length} {filteredItems.length} {Math.min(visibleItems.length, LIST_PREVIEW_LIMIT)}
</div>
<div className="mt-4 max-h-[70vh] space-y-2 overflow-y-auto pr-1 scrollbar-hide">
{visibleItems.map(item => {
const selected = item.id === selectedItemId;
const overridden = Boolean(overrideMap[item.id]);
return (
<button
key={item.id}
type="button"
onClick={() => setSelectedItemId(item.id)}
className={`flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition ${
selected
? 'border-emerald-400/40 bg-emerald-500/10'
: 'border-white/8 bg-black/20 hover:border-white/15'
}`}
>
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-white/10 bg-black/35">
<PixelIcon src={item.iconSrc} className="h-9 w-9" />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.name}</div>
<div className="mt-1 truncate text-[10px] text-zinc-500">{item.sourcePath}</div>
<div className="mt-1 flex flex-wrap gap-1.5">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.category}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-0.5 text-[10px] text-zinc-300">
{item.rarity}
</span>
{overridden && (
<span className="rounded-full border border-amber-400/20 bg-amber-500/10 px-2 py-0.5 text-[10px] text-amber-100">
</span>
)}
</div>
</div>
</button>
);
})}
</div>
</Section>
<Section title="物品预览" description="这里会实时预览当前素材构建出的物品效果,包括图标、系统推断结果以及一张背包卡片。">
{selectedItem ? (
<div className="space-y-5">
<div className="grid gap-4 lg:grid-cols-[220px_1fr]">
<div className="flex min-h-[240px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.12),transparent_45%),linear-gradient(180deg,#171a22,#0d1016)] p-6">
<PixelIcon src={selectedItem.iconSrc} className="h-40 w-40" />
</div>
<div className="space-y-3 rounded-2xl border border-white/10 bg-black/20 p-4">
<div>
<div className="text-xs uppercase tracking-[0.2em] text-zinc-500">{selectedItem.category}</div>
<div className="mt-1 text-xl font-semibold text-white">{selectedItem.name}</div>
<div className="mt-1 text-xs text-zinc-500">{selectedItem.sourcePath}</div>
</div>
<div className="max-w-[12rem]">
<Label></Label>
<Select
value={previewWorld}
onChange={value => setPreviewWorld(value as WorldType)}
options={[
{ label: '武侠', value: WorldType.WUXIA },
{ label: '仙侠', value: WorldType.XIANXIA },
]}
/>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {RARITY_LABELS[selectedItem.rarity]}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {getInventoryItemValue(previewInventoryItem!)}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
使: {isInventoryItemUsable(previewInventoryItem!) ? '是' : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {previewEquipmentSlot ? getEquipmentSlotLabel(previewEquipmentSlot) : '否'}
</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2 py-1 text-[11px] text-zinc-200">
: {selectedItem.worldAffinity === 'neutral' ? '中立' : selectedItem.worldAffinity === 'wuxia' ? '武侠' : selectedItem.worldAffinity === 'xianxia' ? '仙侠' : '中立'}
</span>
</div>
<p className="text-sm leading-relaxed text-zinc-300">{selectedItem.description}</p>
{worldProfile && (
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-zinc-500">
{previewWorld === WorldType.WUXIA ? '武侠命名' : '仙侠命名'}
</div>
<div className="mt-1 text-sm font-semibold text-white">{worldProfile.name}</div>
<div className="mt-2 text-sm leading-relaxed text-zinc-300">{worldProfile.description}</div>
</div>
)}
<div className="flex flex-wrap gap-2">
{selectedItem.tags.length > 0 ? selectedItem.tags.map(tag => (
<span
key={`${selectedItem.id}-${tag}`}
className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300"
>
{tag}
</span>
)) : (
<span className="rounded-full border border-white/10 bg-black/25 px-2 py-1 text-[10px] text-zinc-300">
</span>
)}
</div>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.statProfile?.maxHpBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.maxManaBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.outgoingDamageBonus ?? 0}</div>
<div>: {selectedItem.statProfile?.incomingDamageMultiplier ?? 1}</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">使</div>
{selectedItem.useProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.useProfile.hpRestore ?? 0}</div>
<div>: {selectedItem.useProfile.manaRestore ?? 0}</div>
<div>: {selectedItem.useProfile.cooldownReduction ?? 0}</div>
<div>: {(selectedItem.useProfile.buildBuffs ?? []).map(buff => buff.name).join(' / ') || '无'}</div>
</div>
) : (
<div className="text-sm text-zinc-500">使</div>
)}
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"> / </div>
{selectedItem.buildProfile ? (
<div className="space-y-1 text-sm text-zinc-300">
<div>: {selectedItem.buildProfile.role}</div>
<div>: {selectedItem.buildProfile.setName ?? '无'}</div>
<div>: {selectedItem.buildProfile.pieceName ?? '独立'}</div>
<div>{(selectedItem.buildProfile.synergy ?? []).join(' / ') || '无'}</div>
<div>: {(selectedItem.buildProfile.craftTags ?? []).join(' / ') || '无'}</div>
<div>: {selectedItem.buildProfile.forgeRank ?? 0}</div>
</div>
) : (
<div className="text-sm text-zinc-500"></div>
)}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 p-4">
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500"></div>
<div className="flex items-center gap-4 rounded-2xl border border-white/10 bg-black/25 p-4">
<div className="relative flex h-24 w-24 shrink-0 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.04]">
<PixelIcon src={selectedItem.iconSrc} className="h-14 w-14" />
<div className="absolute bottom-2 right-2 rounded-full border border-black/30 bg-black/65 px-1.5 py-0.5 text-[10px] font-semibold text-white">
1
</div>
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="text-base font-semibold text-white">{selectedItem.name}</div>
<div className="text-sm text-zinc-400">{selectedItem.category} / {RARITY_LABELS[selectedItem.rarity]}</div>
{previewUseEffect && (
<div className="text-sm text-zinc-300">
+{previewUseEffect.hpRestore} / +{previewUseEffect.manaRestore} / -{previewUseEffect.cooldownReduction}
</div>
)}
{!previewUseEffect && (
<div className="text-sm text-zinc-400">
使
</div>
)}
</div>
</div>
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
<Section title="物品字段" description="编辑当前物品的覆盖字段。未修改的字段不会写入 override重置后会恢复自动生成值。">
{selectedBaseItem && selectedItem ? (
<div className="space-y-4">
<div>
<Label> ID</Label>
<TextInput value={selectedItem.id} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput value={selectedItem.sourcePath} onChange={() => undefined} disabled />
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.name}
onChange={value => updateSelectedOverride('name', value)}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<Select
value={selectedItem.category}
onChange={value => updateSelectedOverride('category', value)}
options={ITEM_CATEGORY_OPTIONS.map(category => ({ label: category, value: category }))}
/>
</div>
<div>
<Label></Label>
<Select
value={selectedItem.rarity}
onChange={value => updateSelectedOverride('rarity', value as ItemRarity)}
options={RARITY_OPTIONS.map(rarity => ({ label: RARITY_LABELS[rarity], value: rarity }))}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.tags)}
onChange={value => updateSelectedOverride('tags', parseTagsInput(value))}
rows={4}
/>
<div className="mt-1 text-xs text-zinc-500"></div>
</div>
<div>
<Label></Label>
<TextArea
value={selectedItem.description}
onChange={value => updateSelectedOverride('description', value)}
rows={5}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxHpBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxHpBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.maxManaBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('maxManaBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.outgoingDamageBonus ?? 0)}
onChange={value => updateSelectedStatProfileField('outgoingDamageBonus', Number(value) || 0)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.statProfile?.incomingDamageMultiplier ?? 1)}
onChange={value => updateSelectedStatProfileField('incomingDamageMultiplier', Number(value) || 1)}
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.hpRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('hpRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.manaRestore ?? 0)}
onChange={value => updateSelectedUseProfileField('manaRestore', Number(value) || 0)}
/>
</div>
<div>
<Label>使</Label>
<TextInput
value={String(selectedItem.useProfile?.cooldownReduction ?? 0)}
onChange={value => updateSelectedUseProfileField('cooldownReduction', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label>使|1,2|</Label>
<TextArea
value={buildBuffLinesValue(selectedItem.useProfile?.buildBuffs)}
onChange={updateSelectedUseProfileBuffs}
rows={4}
/>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.role ?? ''}
onChange={value => updateSelectedBuildProfileField('role', value)}
/>
</div>
<div>
<Label> ID</Label>
<TextInput
value={selectedItem.buildProfile?.setId ?? ''}
onChange={value => updateSelectedBuildProfileField('setId', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.setName ?? ''}
onChange={value => updateSelectedBuildProfileField('setName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={selectedItem.buildProfile?.pieceName ?? ''}
onChange={value => updateSelectedBuildProfileField('pieceName', value)}
/>
</div>
<div>
<Label></Label>
<TextInput
value={String(selectedItem.buildProfile?.forgeRank ?? 0)}
onChange={value => updateSelectedBuildProfileField('forgeRank', Number(value) || 0)}
/>
</div>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.tags ?? [])}
onChange={value => updateSelectedBuildProfileTags('tags', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.synergy ?? [])}
onChange={value => updateSelectedBuildProfileTags('synergy', value)}
rows={3}
/>
</div>
<div>
<Label></Label>
<TextArea
value={tagsInputValue(selectedItem.buildProfile?.craftTags ?? [])}
onChange={value => updateSelectedBuildProfileTags('craftTags', value)}
rows={3}
/>
</div>
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-3 py-2 text-xs text-zinc-400">
{selectedOverride ? '该物品有覆盖字段,保存后会写入 itemOverrides.json。' : '当前全部字段都在使用自动生成值。'}
</div>
<div className="flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleSave}
disabled={isSaving}
className="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"
>
{isSaving ? '保存中...' : '保存物品覆盖'}
</button>
<button
type="button"
onClick={resetSelectedOverride}
disabled={!selectedOverride}
className={`rounded-lg border px-4 py-2 text-sm transition ${
selectedOverride
? 'border-white/15 bg-black/20 text-white hover:border-white/30'
: 'border-white/8 bg-black/20 text-zinc-500'
}`}
>
</button>
{saveMessage && <div className="text-xs text-zinc-400">{saveMessage}</div>}
</div>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-6 text-sm text-zinc-400">
</div>
)}
</Section>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,110 +0,0 @@
import type { ComponentType, LazyExoticComponent } from 'react';
import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
import { LazyEditorFallback } from './preset-editor/LazyEditorFallback';
import {
EDITOR_TAB_OPTIONS,
type PresetEditorTab,
} from './preset-editor/shared';
const CharacterPresetTab = lazy(
() => import('./preset-editor/CharacterPresetTab'),
);
const CharacterAssetTab = lazy(
() => import('./preset-editor/CharacterAssetTab'),
);
const SceneNpcPresetTab = lazy(
() => import('./preset-editor/SceneNpcPresetTab'),
);
const ScenePresetTab = lazy(() => import('./preset-editor/ScenePresetTab'));
const MonsterPresetTab = lazy(() => import('./preset-editor/MonsterPresetTab'));
const ItemCatalogEditor = lazy(async () => {
const module = await import('./ItemCatalogEditor');
return { default: module.ItemCatalogEditor };
});
const StateFunctionEditor = lazy(async () => {
const module = await import('./StateFunctionEditor');
return { default: module.StateFunctionEditor };
});
const TAB_COMPONENTS: Record<
PresetEditorTab,
LazyExoticComponent<ComponentType>
> = {
assets: CharacterAssetTab,
characters: CharacterPresetTab,
npcs: SceneNpcPresetTab,
scenes: ScenePresetTab,
monsters: MonsterPresetTab,
items: ItemCatalogEditor,
functions: StateFunctionEditor,
};
export type { PresetEditorTab } from './preset-editor/shared';
export function PresetEditor({
initialTab = 'characters',
}: {
initialTab?: PresetEditorTab;
}) {
const [activeTab, setActiveTab] = useState<PresetEditorTab>(initialTab);
const tabLabels = useMemo(
() =>
Object.fromEntries(
EDITOR_TAB_OPTIONS.map((option) => [option.id, option.label]),
) as Record<PresetEditorTab, string>,
[],
);
const ActiveTabPanel = TAB_COMPONENTS[activeTab];
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
return (
<div className="min-h-screen bg-[#0b0d11] text-zinc-100">
<div className="mx-auto max-w-[1600px] px-6 py-8">
<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="mb-6 flex flex-wrap gap-3">
{EDITOR_TAB_OPTIONS.map((option) => {
const Icon = option.icon;
const isActive = option.id === activeTab;
return (
<button
key={option.id}
type="button"
onClick={() => setActiveTab(option.id)}
className={`inline-flex items-center gap-2 rounded-full border px-4 py-2 text-sm transition ${
isActive
? 'border-emerald-400/40 bg-emerald-500/15 text-emerald-100'
: 'border-white/10 bg-black/20 text-zinc-400 hover:border-white/20 hover:text-white'
}`}
>
<Icon className="h-4 w-4" />
<span>{option.label}</span>
</button>
);
})}
</div>
<Suspense
fallback={<LazyEditorFallback label={tabLabels[activeTab]} />}
>
<ActiveTabPanel />
</Suspense>
</div>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { RotateCcw } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import { getCharacterAnimationDurationMs, getSkillCasterAnimation, getSkillDelivery } from '../data/characterCombat';
import { PRESET_CHARACTERS } from '../data/characterPresets';
import { ROLE_TEMPLATE_CHARACTERS } from '../data/characterPresets';
import { createSceneHostileNpcsFromIds } from '../data/hostileNpcs';
import { buildInitialNpcState, createNpcBattleMonster } from '../data/npcInteractions';
import { getScenePreset } from '../data/scenePresets';
@@ -75,7 +75,7 @@ export function SkillEffectPreview({
}: SkillEffectPreviewProps) {
const scenePreset = useMemo(() => getScenePreset(worldType, 0), [worldType]);
const fallbackTargetCharacter = useMemo(
() => targetCharacter ?? PRESET_CHARACTERS.find(candidate => candidate.id !== character.id) ?? PRESET_CHARACTERS[0] ?? character,
() => targetCharacter ?? ROLE_TEMPLATE_CHARACTERS.find(candidate => candidate.id !== character.id) ?? ROLE_TEMPLATE_CHARACTERS[0] ?? character,
[character, targetCharacter],
);

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,7 @@ import {
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
type AuthLoginMethod,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
@@ -15,6 +16,7 @@ import {
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthLoginOptions,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
@@ -50,6 +52,9 @@ const allowDevGuestAutoAuth =
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [availableLoginMethods, setAvailableLoginMethods] = useState<
AuthLoginMethod[]
>([]);
const [error, setError] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
@@ -104,6 +109,16 @@ export function AuthGate({ children }: AuthGateProps) {
};
const hydrate = async () => {
const loadLoginOptions = async () => {
const options = await getAuthLoginOptions();
if (!isActive) {
return null;
}
setAvailableLoginMethods(options.availableLoginMethods);
return options;
};
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
@@ -121,6 +136,20 @@ export function AuthGate({ children }: AuthGateProps) {
}
setUser(null);
try {
await loadLoginOptions();
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
}
setStatus('unauthenticated');
return;
}
@@ -133,11 +162,13 @@ export function AuthGate({ children }: AuthGateProps) {
if (!nextSession.user) {
setUser(null);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus('unauthenticated');
return;
}
setUser(nextSession.user);
setAvailableLoginMethods(nextSession.availableLoginMethods);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
@@ -155,6 +186,20 @@ export function AuthGate({ children }: AuthGateProps) {
}
setUser(null);
try {
await loadLoginOptions();
} catch (optionsError) {
if (!isActive) {
return;
}
setAvailableLoginMethods([]);
setError(
optionsError instanceof Error
? optionsError.message
: '读取登录方式失败,请稍后再试。',
);
}
setStatus('unauthenticated');
}
};
@@ -278,6 +323,7 @@ export function AuthGate({ children }: AuthGateProps) {
if (status === 'unauthenticated') {
return (
<LoginScreen
availableLoginMethods={availableLoginMethods}
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}

View File

@@ -1,9 +1,13 @@
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge } from '../../services/authService';
import type {
AuthCaptchaChallenge,
AuthLoginMethod,
} from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = {
availableLoginMethods: AuthLoginMethod[];
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
@@ -24,6 +28,7 @@ type LoginScreenProps = {
};
export function LoginScreen({
availableLoginMethods,
sendingCode,
loggingIn,
wechatLoading,
@@ -38,6 +43,8 @@ export function LoginScreen({
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
const phoneLoginEnabled = availableLoginMethods.includes('phone');
const wechatLoginEnabled = availableLoginMethods.includes('wechat');
useEffect(() => {
if (cooldownSeconds <= 0) {
@@ -69,7 +76,7 @@ export function LoginScreen({
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
@@ -85,77 +92,90 @@ export function LoginScreen({
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
if (!phoneLoginEnabled) {
return;
}
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
{phoneLoginEnabled ? (
<>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
</>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
</div>
{phoneLoginEnabled || wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
{phoneLoginEnabled && wechatLoginEnabled
? '手机号可直接登录,也可以先用微信。'
: phoneLoginEnabled
? '当前开放手机号登录。'
: '当前开放微信登录。'}
</div>
) : null}
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
@@ -163,24 +183,34 @@ export function LoginScreen({
</div>
) : null}
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
{phoneLoginEnabled ? (
<button
type="submit"
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
) : null}
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
{wechatLoginEnabled ? (
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
) : null}
{!phoneLoginEnabled && !wechatLoginEnabled ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
</div>
) : null}
</form>
</div>
</div>

View File

@@ -1,6 +1,6 @@
import {useEffect, useLayoutEffect, useRef, useState} from 'react';
import {resolveRuleWorldType} from '../../data/customWorldRuntime';
import {resolveCompatibilityTemplateWorldType} from '../../data/customWorldRuntime';
import {MONSTERS_BY_WORLD, PLAYER_BASE_X_METERS} from '../../data/hostileNpcs';
import {AnimationState, WorldType} from '../../types';
import {GameCanvasEffectLayer} from './GameCanvasEffectLayer';
@@ -47,7 +47,9 @@ export function GameCanvasRuntime({
const [backgroundLoadFailed, setBackgroundLoadFailed] = useState(false);
const [sceneTitleSpinToken, setSceneTitleSpinToken] = useState(0);
const previousSceneTitleRef = useRef<string | null>(currentScenePreset?.name ?? null);
const resolvedWorldType = worldType ? resolveRuleWorldType(worldType) ?? WorldType.WUXIA : null;
const resolvedWorldType = worldType
? resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA
: null;
const backgroundSrc = currentScenePreset?.imageSrc
|| (resolvedWorldType === WorldType.WUXIA ? '/scene_bg/45_PixelSky.png' : '/scene_bg/47_PixelSky.png');
const monsters = resolvedWorldType ? MONSTERS_BY_WORLD[resolvedWorldType] : [];

View File

@@ -2,7 +2,7 @@ import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {
buildCustomWorldPlayableCharacters,
PRESET_CHARACTERS,
ROLE_TEMPLATE_CHARACTERS,
} from '../../data/characterPresets';
import {AnimationState, type Character, type CustomWorldProfile, WorldType} from '../../types';
import {getNineSliceStyle, UI_CHROME} from '../../uiAssets';
@@ -160,7 +160,7 @@ export function CharacterSelectionFlow({
onConfirm,
}: CharacterSelectionFlowProps) {
const selectionCharacters = useMemo(
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : PRESET_CHARACTERS),
() => (customWorldProfile ? buildCustomWorldPlayableCharacters(customWorldProfile) : ROLE_TEMPLATE_CHARACTERS),
[customWorldProfile],
);
const [selectedCharacterId, setSelectedCharacterId] = useState(selectionCharacters[0]?.id ?? '');

View File

@@ -34,10 +34,10 @@ import {
type GameState,
} from '../../types';
import {
CUSTOM_WORLD_THEME_ICONS,
CHROME_ICONS,
getNineSliceStyle,
UI_CHROME,
WORLD_SELECT_ICONS,
} from '../../uiAssets';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView';
import { CustomWorldResultView } from '../CustomWorldResultView';
@@ -187,9 +187,9 @@ export function PreGameSelectionFlow({
featurePortrait: leadCharacter?.portrait ?? '',
featureIcon:
themeMode === 'martial'
? WORLD_SELECT_ICONS.wuxia
? CUSTOM_WORLD_THEME_ICONS.martial
: themeMode === 'arcane'
? WORLD_SELECT_ICONS.xianxia
? CUSTOM_WORLD_THEME_ICONS.arcane
: CHROME_ICONS.refreshOptions,
accentLabel: '自定义世界',
};

File diff suppressed because it is too large Load Diff

View File

@@ -1 +0,0 @@
export { CharacterAssetPanel as default } from './CharacterAssetPanel';

View File

@@ -1,808 +0,0 @@
import { Plus, Trash2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import characterOverridesJson from '../../data/characterOverrides.json';
import {
type CharacterPresetOverride,
getCharacterEquipment,
getCharacterNpcSceneIds,
getInventoryItems,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import { validateCharacterOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { getScenePresetsByWorld } from '../../data/scenePresets';
import { cloneValue } from '../../editor/shared/cloneValue';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import {
AnimationState,
type Character,
type CharacterSkillDefinition,
WorldType,
} from '../../types';
import { CharacterAnimator } from '../CharacterAnimator';
import { SkillEffectPreview } from '../SkillEffectPreview';
import {
ANIMATION_OPTIONS,
applyCharacterOverride,
buildBuffsInputValue,
CHARACTER_SKILL_STYLE_OPTIONS,
getAnimationStateLabel,
getCharacterSkillStyleLabel,
isRangedSkill,
listInputValue,
normalizeOptionalSceneId,
parseBuildBuffsInput,
parseListInput,
WORLD_LABELS,
WORLD_OPTIONS,
} from './shared';
export function CharacterPresetPanel() {
const sceneOptionsByWorld = useMemo(
() => ({
[WorldType.WUXIA]: getScenePresetsByWorld(WorldType.WUXIA),
[WorldType.XIANXIA]: getScenePresetsByWorld(WorldType.XIANXIA),
}),
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, CharacterPresetOverride>
>(characterOverridesJson as Record<string, CharacterPresetOverride>);
const [selectedCharacterId, setSelectedCharacterId] = useState(
PRESET_CHARACTERS[0]?.id ?? '',
);
const [previewAnimation, setPreviewAnimation] = useState<AnimationState>(
AnimationState.IDLE,
);
const [inventoryWorld, setInventoryWorld] = useState<WorldType>(
WorldType.WUXIA,
);
const [skillPreviewWorld, setSkillPreviewWorld] = useState<WorldType>(
WorldType.WUXIA,
);
const [selectedSkillPreviewId, setSelectedSkillPreviewId] = useState('');
const [selectedSkillPreviewMonsterId, setSelectedSkillPreviewMonsterId] =
useState(MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA][0]?.id ?? '');
const selectedCharacter =
PRESET_CHARACTERS.find(
(character) => character.id === selectedCharacterId,
) ?? null;
const effectiveCharacter = selectedCharacter
? applyCharacterOverride(
selectedCharacter,
overrideMap[selectedCharacter.id],
)
: null;
const { isSaving, saveMessage, save } = useJsonSave({
resourceId: EDITOR_JSON_RESOURCE_IDS.characterOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateCharacterOverrides(
overrideMap,
PRESET_CHARACTERS,
sceneOptionsByWorld,
),
successMessage: '角色预设覆盖已保存到 src/data/characterOverrides.json。',
errorMessage: '保存角色预设覆盖失败。',
});
const animationEntries = Object.entries(
effectiveCharacter?.animationMap ?? {},
) as Array<
[AnimationState, NonNullable<Character['animationMap']>[AnimationState]]
>;
const previewAnimationOptions = animationEntries.map(([animation]) => ({
label: getAnimationStateLabel(animation),
value: animation,
}));
const rangedSkills = useMemo(
() => effectiveCharacter?.skills.filter(isRangedSkill) ?? [],
[effectiveCharacter],
);
const skillPreviewMonsterOptions = MONSTER_PRESETS_BY_WORLD[
skillPreviewWorld
].map((monster) => ({
label: monster.name,
value: monster.id,
}));
const selectedSkillPreview =
rangedSkills.find((skill) => skill.id === selectedSkillPreviewId) ??
rangedSkills[0] ??
null;
useEffect(() => {
if (
previewAnimationOptions.some(
(option) => option.value === previewAnimation,
)
) {
return;
}
setPreviewAnimation(
(previewAnimationOptions[0]?.value as AnimationState | undefined) ??
AnimationState.IDLE,
);
}, [previewAnimation, previewAnimationOptions]);
useEffect(() => {
if (rangedSkills.some((skill) => skill.id === selectedSkillPreviewId)) {
return;
}
setSelectedSkillPreviewId(rangedSkills[0]?.id ?? '');
}, [rangedSkills, selectedSkillPreviewId]);
useEffect(() => {
if (
skillPreviewMonsterOptions.some(
(option) => option.value === selectedSkillPreviewMonsterId,
)
) {
return;
}
setSelectedSkillPreviewMonsterId(
skillPreviewMonsterOptions[0]?.value ?? '',
);
}, [selectedSkillPreviewMonsterId, skillPreviewMonsterOptions]);
if (!selectedCharacter || !effectiveCharacter) {
return <EditorEmptyState message="没有可用的角色预设。" />;
}
const setCharacterField = <K extends keyof CharacterPresetOverride>(
key: K,
value: CharacterPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
[key]: value,
},
}));
};
const setAttribute = (key: keyof Character['attributes'], value: number) => {
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
attributes: {
...effectiveCharacter.attributes,
...(prev[selectedCharacter.id]?.attributes ?? {}),
[key]: value,
},
},
}));
};
const setAnimationConfig = (
animation: AnimationState,
key: 'folder' | 'prefix' | 'frames' | 'startFrame',
value: string | number,
) => {
const baseConfig = effectiveCharacter.animationMap?.[animation] ?? {
folder: '',
prefix: '',
frames: 1,
};
const currentOverrideConfig =
overrideMap[selectedCharacter.id]?.animationMap?.[animation];
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
animationMap: {
...(prev[selectedCharacter.id]?.animationMap ?? {}),
[animation]: {
...baseConfig,
...currentOverrideConfig,
[key]: value,
},
},
},
}));
};
const setSkills = (skills: CharacterSkillDefinition[]) => {
setCharacterField('skills', skills);
};
const updateSkill = <K extends keyof CharacterSkillDefinition>(
index: number,
key: K,
value: CharacterSkillDefinition[K],
) => {
const nextSkills = cloneValue(effectiveCharacter.skills);
const currentSkill = nextSkills[index];
if (!currentSkill) return;
nextSkills[index] = { ...currentSkill, [key]: value };
setSkills(nextSkills);
};
const addSkill = () => {
setSkills([
...cloneValue(effectiveCharacter.skills),
{
id: `${selectedCharacter.id}-skill-${effectiveCharacter.skills.length + 1}`,
name: '新技能',
animation: AnimationState.SKILL1,
damage: 10,
manaCost: 5,
cooldownTurns: 1,
range: 1.5,
style: 'steady',
},
]);
};
const removeSkill = (index: number) => {
setSkills(
cloneValue(effectiveCharacter.skills).filter(
(_, skillIndex) => skillIndex !== index,
),
);
};
const setSceneBinding = (
worldType: WorldType,
key: 'homeSceneId' | 'npcSceneIds',
value: string | string[],
) => {
const normalizedValue =
key === 'homeSceneId' && typeof value === 'string'
? normalizeOptionalSceneId(value)
: value;
setOverrideMap((prev) => ({
...prev,
[selectedCharacter.id]: {
...(prev[selectedCharacter.id] ?? {}),
sceneBindings: {
...(prev[selectedCharacter.id]?.sceneBindings ?? {}),
[worldType]: {
...(prev[selectedCharacter.id]?.sceneBindings?.[worldType] ?? {}),
[key]: normalizedValue,
},
},
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-3">
{' '}
<EditorSelectionCard
title="角色"
description="浏览角色列表并编辑预设数据。"
selectLabel="角色"
selectValue={selectedCharacter.id}
onSelectChange={setSelectedCharacterId}
selectOptions={PRESET_CHARACTERS.map((character) => {
const optionCharacter = applyCharacterOverride(
character,
overrideMap[character.id],
);
return {
label: `${optionCharacter.name} - ${optionCharacter.title}`,
value: character.id,
};
})}
saveLabel="保存角色覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="flex items-start gap-3">
{' '}
<div className="flex h-20 w-20 shrink-0 items-center justify-center overflow-hidden rounded-xl border border-white/10 bg-black/30">
{' '}
<img
src={effectiveCharacter.portrait}
alt={effectiveCharacter.name}
className="h-full w-full scale-125 object-contain"
style={{ imageRendering: 'pixelated' }}
/>{' '}
</div>{' '}
<div className="min-w-0 flex-1">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveCharacter.name}
</div>{' '}
<div className="mt-1 text-[11px] uppercase tracking-[0.22em] text-zinc-500">
{effectiveCharacter.title}
</div>{' '}
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveCharacter.description}
</div>{' '}
</div>{' '}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<div className="space-y-6">
{' '}
<SectionCard
title="角色详情"
description="编辑核心角色资料和预览配置。"
>
{' '}
<div className="mb-4 grid gap-3 md:grid-cols-2">
{' '}
<SelectField
label="动画"
value={previewAnimation}
onChange={(value) => setPreviewAnimation(value as AnimationState)}
options={previewAnimationOptions}
/>{' '}
<SelectField
label="世界"
value={inventoryWorld}
onChange={(value) => setInventoryWorld(value as WorldType)}
options={WORLD_OPTIONS.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>{' '}
</div>{' '}
<div className="mb-5 flex min-h-[320px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(16,185,129,0.14),transparent_45%),linear-gradient(180deg,#161922,#0c0f15)] p-6">
{' '}
<div className="relative flex h-[260px] w-[220px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
{' '}
<div className="absolute inset-x-0 bottom-0 h-20 bg-[radial-gradient(circle_at_center,rgba(16,185,129,0.16),transparent_65%)]" />{' '}
<CharacterAnimator
state={previewAnimation}
character={effectiveCharacter}
className="h-[210px] w-[210px] scale-[1.15] origin-bottom"
/>{' '}
</div>{' '}
</div>{' '}
<div className="grid gap-4 lg:grid-cols-2">
{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="space-y-2">
{' '}
{getCharacterEquipment(effectiveCharacter).map((item) => (
<div
key={`${item.slot}-${item.item}`}
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
>
{' '}
<div className="text-[11px] text-zinc-500">
{item.slot}
</div>{' '}
<div className="mt-1">{item.item}</div>{' '}
<div className="mt-1 text-[11px] text-amber-200/80">
{item.rarity}
</div>{' '}
</div>
))}{' '}
</div>{' '}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs font-semibold uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="space-y-2">
{' '}
{getInventoryItems(effectiveCharacter, inventoryWorld).map(
(item) => (
<div
key={`${item.category}-${item.name}`}
className="rounded-lg border border-white/5 bg-white/[0.03] px-3 py-2 text-sm text-zinc-200"
>
{' '}
<div className="text-[11px] text-zinc-500">
{item.category}
</div>{' '}
<div className="mt-1">{item.name}</div>{' '}
<div className="mt-1 text-[11px] text-zinc-400">
x{item.quantity}
</div>{' '}
</div>
),
)}{' '}
</div>{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="技能预览"
description="预览当前角色的远程技能效果。"
>
{rangedSkills.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-3">
<SelectField
label="技能"
value={selectedSkillPreview?.id ?? ''}
onChange={setSelectedSkillPreviewId}
options={rangedSkills.map((skill) => ({
label: skill.name,
value: skill.id,
}))}
/>
<SelectField
label="世界"
value={skillPreviewWorld}
onChange={(value) => setSkillPreviewWorld(value as WorldType)}
options={WORLD_OPTIONS.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>
<SelectField
label="预览敌人"
value={selectedSkillPreviewMonsterId}
onChange={setSelectedSkillPreviewMonsterId}
options={skillPreviewMonsterOptions}
/>
</div>
<SkillEffectPreview
mode="player"
worldType={skillPreviewWorld}
character={effectiveCharacter}
skill={selectedSkillPreview}
targetMonsterId={selectedSkillPreviewMonsterId}
/>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
)}
</SectionCard>{' '}
<SectionCard
title="技能配置"
description="编辑当前角色的技能列表。"
>
{' '}
<div className="space-y-4">
{' '}
<div className="flex items-center justify-between">
{' '}
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<button
type="button"
onClick={addSkill}
className="inline-flex items-center gap-2 rounded-lg border border-emerald-400/20 bg-emerald-500/10 px-3 py-1.5 text-xs text-emerald-100 transition hover:bg-emerald-500/20"
>
{' '}
<Plus className="h-3.5 w-3.5" /> <span></span>{' '}
</button>{' '}
</div>{' '}
{effectiveCharacter.skills.map((skill, index) => (
<div
key={`${skill.id}-${index}`}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 flex items-center justify-between gap-3">
{' '}
<div className="text-sm font-semibold text-white">
{skill.name}
</div>{' '}
<button
type="button"
onClick={() => removeSkill(index)}
className="rounded-lg border border-rose-400/20 bg-rose-500/10 p-2 text-rose-100 transition hover:bg-rose-500/20"
>
{' '}
<Trash2 className="h-4 w-4" />{' '}
</button>{' '}
</div>{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<TextField
label="技能 ID"
value={skill.id}
onChange={(value) => updateSkill(index, 'id', value)}
/>{' '}
<TextField
label="名称"
value={skill.name}
onChange={(value) => updateSkill(index, 'name', value)}
/>{' '}
<SelectField
label="动画"
value={skill.animation}
onChange={(value) =>
updateSkill(index, 'animation', value as AnimationState)
}
options={ANIMATION_OPTIONS.map((animation) => ({
label: getAnimationStateLabel(animation),
value: animation,
}))}
/>{' '}
<SelectField
label="风格"
value={skill.style}
onChange={(value) =>
updateSkill(
index,
'style',
value as CharacterSkillDefinition['style'],
)
}
options={CHARACTER_SKILL_STYLE_OPTIONS.map((style) => ({
label: getCharacterSkillStyleLabel(style),
value: style,
}))}
/>{' '}
<NumberField
label="伤害"
value={skill.damage}
onChange={(value) => updateSkill(index, 'damage', value)}
min={0}
/>{' '}
<NumberField
label="法力消耗"
value={skill.manaCost}
onChange={(value) => updateSkill(index, 'manaCost', value)}
min={0}
/>{' '}
<NumberField
label="冷却回合"
value={skill.cooldownTurns}
onChange={(value) =>
updateSkill(index, 'cooldownTurns', value)
}
min={0}
/>{' '}
<NumberField
label="射程"
value={skill.range}
onChange={(value) => updateSkill(index, 'range', value)}
min={0}
step={0.1}
/>{' '}
</div>{' '}
<TextAreaField
label="构筑增益"
value={buildBuffsInputValue(skill.buildBuffs)}
onChange={(value) =>
updateSkill(
index,
'buildBuffs',
parseBuildBuffsInput(
value,
'skill',
skill.id,
) as CharacterSkillDefinition['buildBuffs'],
)
}
rows={3}
/>{' '}
</div>
))}{' '}
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="grid gap-3">
{' '}
{animationEntries.map(([animation, config]) => {
const resolvedConfig = {
folder: '',
prefix: '',
frames: 1,
startFrame: 1,
...config,
};
return (
<div
key={animation}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{getAnimationStateLabel(animation)}
</div>{' '}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
{' '}
<TextField
label="素材目录"
value={resolvedConfig.folder}
onChange={(value) =>
setAnimationConfig(animation, 'folder', value)
}
/>{' '}
<TextField
label="文件前缀"
value={resolvedConfig.prefix}
onChange={(value) =>
setAnimationConfig(animation, 'prefix', value)
}
/>{' '}
<NumberField
label="帧数"
value={resolvedConfig.frames}
onChange={(value) =>
setAnimationConfig(animation, 'frames', value)
}
min={1}
/>{' '}
<NumberField
label="起始帧"
value={resolvedConfig.startFrame ?? 1}
onChange={(value) =>
setAnimationConfig(animation, 'startFrame', value)
}
min={1}
/>{' '}
</div>{' '}
</div>
);
})}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
<div className="space-y-6">
{' '}
<SectionCard title="基础信息" description="编辑角色基础资料。">
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="角色 ID"
value={effectiveCharacter.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveCharacter.name}
onChange={(value) => setCharacterField('name', value)}
/>{' '}
<TextField
label="称号"
value={effectiveCharacter.title}
onChange={(value) => setCharacterField('title', value)}
/>{' '}
<TextField
label="头像"
value={effectiveCharacter.avatar}
onChange={(value) => setCharacterField('avatar', value)}
/>{' '}
<TextField
label="立绘"
value={effectiveCharacter.portrait}
onChange={(value) => setCharacterField('portrait', value)}
/>{' '}
<TextField
label="资源目录"
value={effectiveCharacter.assetFolder}
onChange={(value) => setCharacterField('assetFolder', value)}
/>{' '}
<TextField
label="资源变体"
value={effectiveCharacter.assetVariant}
onChange={(value) => setCharacterField('assetVariant', value)}
/>{' '}
<NumberField
label="地面偏移 Y"
value={effectiveCharacter.groundOffsetY ?? 0}
onChange={(value) => setCharacterField('groundOffsetY', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveCharacter.description}
onChange={(value) => setCharacterField('description', value)}
rows={4}
/>{' '}
<TextAreaField
label="性格"
value={effectiveCharacter.personality}
onChange={(value) => setCharacterField('personality', value)}
rows={3}
/>{' '}
<TextAreaField
label="战斗标签"
value={listInputValue(effectiveCharacter.combatTags ?? [])}
onChange={(value) =>
setCharacterField('combatTags', parseListInput(value))
}
rows={3}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="属性"
description="调整角色的核心属性。"
>
{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<NumberField
label="力量"
value={effectiveCharacter.attributes.strength}
onChange={(value) => setAttribute('strength', value)}
min={0}
/>{' '}
<NumberField
label="敏捷"
value={effectiveCharacter.attributes.agility}
onChange={(value) => setAttribute('agility', value)}
min={0}
/>{' '}
<NumberField
label="悟性"
value={effectiveCharacter.attributes.intelligence}
onChange={(value) => setAttribute('intelligence', value)}
min={0}
/>{' '}
<NumberField
label="灵性"
value={effectiveCharacter.attributes.spirit}
onChange={(value) => setAttribute('spirit', value)}
min={0}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="场景绑定" description="编辑角色在不同世界中的场景绑定。">
{' '}
<div className="space-y-4">
{' '}
{WORLD_OPTIONS.map((worldType) => (
<div
key={worldType}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{WORLD_LABELS[worldType]}
</div>{' '}
<div className="grid gap-3">
{' '}
<SelectField
label="主场景"
value={
overrideMap[selectedCharacter.id]?.sceneBindings?.[
worldType
]?.homeSceneId ?? ''
}
onChange={(value) =>
setSceneBinding(worldType, 'homeSceneId', value)
}
options={[
{ label: '未设置', value: '' },
...sceneOptionsByWorld[worldType].map((scene) => ({
label: scene.name,
value: scene.id,
})),
]}
/>{' '}
<TextAreaField
label="角色场景"
value={listInputValue(
overrideMap[selectedCharacter.id]?.sceneBindings?.[
worldType
]?.npcSceneIds ??
getCharacterNpcSceneIds(
worldType,
selectedCharacter.id,
),
)}
onChange={(value) =>
setSceneBinding(
worldType,
'npcSceneIds',
parseListInput(value),
)
}
rows={4}
placeholder={'scene-id-1\nscene-id-2'}
/>{' '}
</div>{' '}
</div>
))}{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -1 +0,0 @@
export { CharacterPresetPanel as default } from './CharacterPresetPanel';

View File

@@ -1,7 +0,0 @@
export function LazyEditorFallback({ label }: { label: string }) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-400">
{label}...
</div>
);
}

View File

@@ -1,362 +0,0 @@
import { useMemo, useState } from 'react';
import { validateMonsterOverrides } from '../../data/editorValidation';
import {
MONSTER_PRESETS_BY_WORLD,
type MonsterPreset,
type MonsterPresetOverride,
} from '../../data/hostileNpcPresets';
import monsterOverridesJson from '../../data/monsterOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { WorldType } from '../../types';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import {
applyMonsterOverride,
getMonsterAnimationLabel,
listInputValue,
MONSTER_ANIMATION_OPTIONS,
parseListInput,
WORLD_LABELS,
} from './shared';
export function MonsterPresetPanel() {
const allMonsters = useMemo(
() => [
...MONSTER_PRESETS_BY_WORLD[WorldType.WUXIA],
...MONSTER_PRESETS_BY_WORLD[WorldType.XIANXIA],
],
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, MonsterPresetOverride>
>(monsterOverridesJson as Record<string, MonsterPresetOverride>);
const [selectedMonsterId, setSelectedMonsterId] = useState(
allMonsters[0]?.id ?? '',
);
const [previewAnimation, setPreviewAnimation] =
useState<(typeof MONSTER_ANIMATION_OPTIONS)[number]>('idle');
const { isSaving, saveMessage, save } = useJsonSave({
resourceId: EDITOR_JSON_RESOURCE_IDS.monsterOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () => validateMonsterOverrides(overrideMap, allMonsters),
successMessage: '敌人预设覆盖已保存到 src/data/monsterOverrides.json。',
errorMessage: '保存敌人预设覆盖失败。',
});
const selectedMonster =
allMonsters.find((monster) => monster.id === selectedMonsterId) ??
allMonsters[0];
if (!selectedMonster) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
</div>
);
}
const effectiveMonster = applyMonsterOverride(
selectedMonster,
overrideMap[selectedMonster.id],
);
const setMonsterField = <K extends keyof MonsterPresetOverride>(
key: K,
value: MonsterPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
[key]: value,
},
}));
};
const setMonsterBaseStat = (
key: keyof MonsterPreset['baseStats'],
value: number,
) => {
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
baseStats: {
...effectiveMonster.baseStats,
...(prev[selectedMonster.id]?.baseStats ?? {}),
[key]: value,
},
},
}));
};
const setMonsterAnimation = (
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
key: 'start' | 'frames' | 'fps',
value: number,
) => {
const baseConfig = effectiveMonster.animations[animation] ?? {
start: 0,
frames: 1,
fps: 12,
};
setOverrideMap((prev) => ({
...prev,
[selectedMonster.id]: {
...(prev[selectedMonster.id] ?? {}),
animations: {
...(prev[selectedMonster.id]?.animations ?? {}),
[animation]: {
...baseConfig,
...(prev[selectedMonster.id]?.animations?.[animation] ?? {}),
[key]: value,
},
},
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
{' '}
<EditorSelectionCard
title="敌人预设"
description="浏览并选择一个敌人预设。"
selectLabel="敌人"
selectValue={selectedMonster.id}
onSelectChange={setSelectedMonsterId}
selectOptions={allMonsters.map((monster) => {
const optionMonster = applyMonsterOverride(
monster,
overrideMap[monster.id],
);
return {
label: `${WORLD_LABELS[monster.worldType]} · ${optionMonster.name}`,
value: monster.id,
};
})}
saveLabel="保存敌人覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveMonster.name}
</div>{' '}
<div className="mt-1 text-xs text-zinc-400">
{WORLD_LABELS[effectiveMonster.worldType]}
</div>{' '}
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveMonster.description}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<SectionCard
title="敌人预览"
description="预览当前敌人的外观与基础属性。"
>
{' '}
<div className="mb-4">
{' '}
<SelectField
label="预览动画"
value={previewAnimation}
onChange={(value) =>
setPreviewAnimation(
value as (typeof MONSTER_ANIMATION_OPTIONS)[number],
)
}
options={MONSTER_ANIMATION_OPTIONS.filter(
(animation) =>
effectiveMonster.animations[animation] || animation === 'idle',
).map((animation) => ({
label: getMonsterAnimationLabel(animation),
value: animation,
}))}
/>{' '}
</div>{' '}
<div className="flex min-h-[360px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(250,204,21,0.12),transparent_40%),linear-gradient(180deg,#1a1711,#0f0d09)] p-6">
{' '}
<div className="flex h-[240px] w-[240px] items-end justify-center rounded-2xl border border-white/5 bg-black/20">
{' '}
<HostileNpcAnimator
hostileNpc={effectiveMonster}
animation={previewAnimation}
className="scale-[2.5] origin-bottom"
/>{' '}
</div>{' '}
</div>{' '}
<div className="mt-4 grid gap-3 md:grid-cols-2">
{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.attackRange}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.speed}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.hp}
</div>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
{effectiveMonster.baseStats.maxHp}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<div className="space-y-6">
{' '}
<SectionCard title="基础信息" description="编辑当前敌人的基础资料。">
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="敌人 ID"
value={effectiveMonster.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveMonster.name}
onChange={(value) => setMonsterField('name', value)}
/>{' '}
<TextField
label="素材路径"
value={effectiveMonster.src}
onChange={(value) => setMonsterField('src', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveMonster.description}
onChange={(value) => setMonsterField('description', value)}
rows={4}
/>{' '}
<TextAreaField
label="出场动作"
value={effectiveMonster.introAction}
onChange={(value) => setMonsterField('introAction', value)}
rows={3}
/>{' '}
<TextAreaField
label="栖息标签"
value={listInputValue(effectiveMonster.habitatTags)}
onChange={(value) =>
setMonsterField('habitatTags', parseListInput(value))
}
rows={4}
/>{' '}
<TextAreaField
label="战斗标签"
value={listInputValue(effectiveMonster.combatTags ?? [])}
onChange={(value) =>
setMonsterField('combatTags', parseListInput(value))
}
rows={3}
/>{' '}
<NumberField
label="帧宽"
value={effectiveMonster.frameWidth}
onChange={(value) => setMonsterField('frameWidth', value)}
min={1}
/>{' '}
<NumberField
label="帧高"
value={effectiveMonster.frameHeight}
onChange={(value) => setMonsterField('frameHeight', value)}
min={1}
/>{' '}
<NumberField
label="图集宽度"
value={effectiveMonster.sheetWidth}
onChange={(value) => setMonsterField('sheetWidth', value)}
min={1}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="基础数值" description="调整当前敌人的基础属性。">
{' '}
<div className="grid gap-3 md:grid-cols-2">
{' '}
<NumberField
label="攻击距离"
value={effectiveMonster.baseStats.attackRange}
onChange={(value) => setMonsterBaseStat('attackRange', value)}
min={0}
step={0.1}
/>{' '}
<NumberField
label="速度"
value={effectiveMonster.baseStats.speed}
onChange={(value) => setMonsterBaseStat('speed', value)}
min={0}
step={0.1}
/>{' '}
<NumberField
label="生命值"
value={effectiveMonster.baseStats.hp}
onChange={(value) => setMonsterBaseStat('hp', value)}
min={1}
/>{' '}
<NumberField
label="生命上限"
value={effectiveMonster.baseStats.maxHp}
onChange={(value) => setMonsterBaseStat('maxHp', value)}
min={1}
/>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard title="动画配置" description="调整当前敌人的动画参数。">
{' '}
<div className="space-y-3">
{' '}
{MONSTER_ANIMATION_OPTIONS.filter(
(animation) => effectiveMonster.animations[animation],
).map((animation) => {
const config = effectiveMonster.animations[animation]!;
return (
<div
key={animation}
className="rounded-xl border border-white/10 bg-black/20 p-4"
>
{' '}
<div className="mb-3 text-sm font-semibold text-white">
{getMonsterAnimationLabel(animation)}
</div>{' '}
<div className="grid gap-3 md:grid-cols-3">
{' '}
<NumberField
label="起始帧"
value={config.start}
onChange={(value) =>
setMonsterAnimation(animation, 'start', value)
}
min={0}
/>{' '}
<NumberField
label="帧数"
value={config.frames}
onChange={(value) =>
setMonsterAnimation(animation, 'frames', value)
}
min={1}
/>{' '}
<NumberField
label="帧率"
value={config.fps ?? 12}
onChange={(value) =>
setMonsterAnimation(animation, 'fps', value)
}
min={1}
/>{' '}
</div>{' '}
</div>
);
})}{' '}
</div>{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -1 +0,0 @@
export { MonsterPresetPanel as default } from './MonsterPresetPanel';

View File

@@ -1,4 +0,0 @@
export { CharacterPresetPanel } from './CharacterPresetPanel';
export { MonsterPresetPanel } from './MonsterPresetPanel';
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
export { ScenePresetPanel } from './ScenePresetPanel';

View File

@@ -1,400 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import {
getCharacterById,
PRESET_CHARACTERS,
} from '../../data/characterPresets';
import { validateSceneNpcOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import sceneNpcOverridesJson from '../../data/sceneNpcOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import {
getScenePresetsByWorld,
type SceneNpcPresetOverride,
} from '../../data/scenePresets';
import { EditorEmptyState } from '../../editor/shared/EditorEmptyState';
import { EditorSelectionCard } from '../../editor/shared/EditorSelectionCard';
import {
NumberField,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { type Encounter, type SceneNpc, WorldType } from '../../types';
import { HostileNpcAnimator } from '../HostileNpcAnimator';
import { MedievalNpcAnimator } from '../MedievalNpcAnimator';
import { NpcVisualEditor } from '../NpcVisualEditor';
import { SkillEffectPreview } from '../SkillEffectPreview';
import {
applySceneNpcOverride,
isRangedSkill,
WORLD_LABELS,
WORLD_OPTIONS,
} from './shared';
export function SceneNpcPresetPanel() {
const npcCatalog = useMemo(() => {
const map = new Map<
string,
{
npc: SceneNpc;
worldTypes: WorldType[];
sceneIds: string[];
sceneNames: string[];
}
>();
for (const worldType of WORLD_OPTIONS) {
for (const scene of getScenePresetsByWorld(worldType)) {
for (const npc of scene.npcs) {
const existing = map.get(npc.id);
if (existing) {
if (!existing.sceneIds.includes(scene.id)) {
existing.sceneIds.push(scene.id);
existing.sceneNames.push(scene.name);
}
if (!existing.worldTypes.includes(worldType)) {
existing.worldTypes.push(worldType);
}
continue;
}
map.set(npc.id, {
npc,
worldTypes: [worldType],
sceneIds: [scene.id],
sceneNames: [scene.name],
});
}
}
}
return [...map.values()].sort((a, b) =>
a.npc.name.localeCompare(b.npc.name, 'zh-Hans-CN'),
);
}, []);
const [overrideMap, setOverrideMap] = useState<
Record<string, SceneNpcPresetOverride>
>(sceneNpcOverridesJson as Record<string, SceneNpcPresetOverride>);
const [selectedNpcId, setSelectedNpcId] = useState(
npcCatalog[0]?.npc.id ?? '',
);
const [npcSkillPreviewWorld, setNpcSkillPreviewWorld] = useState<WorldType>(
npcCatalog[0]?.worldTypes[0] ?? WorldType.WUXIA,
);
const [selectedNpcSkillPreviewId, setSelectedNpcSkillPreviewId] =
useState('');
const selectedNpcEntry =
npcCatalog.find((item) => item.npc.id === selectedNpcId) ?? null;
const effectiveNpc = selectedNpcEntry
? applySceneNpcOverride(
selectedNpcEntry.npc,
overrideMap[selectedNpcEntry.npc.id],
)
: null;
const linkedNpcCharacter = effectiveNpc?.characterId
? getCharacterById(effectiveNpc.characterId)
: null;
const rangedNpcSkills = useMemo(
() => linkedNpcCharacter?.skills.filter(isRangedSkill) ?? [],
[linkedNpcCharacter],
);
const selectedNpcSkillPreview =
rangedNpcSkills.find((skill) => skill.id === selectedNpcSkillPreviewId) ??
rangedNpcSkills[0] ??
null;
const selectedNpcWorldTypes = useMemo(
() => selectedNpcEntry?.worldTypes ?? [],
[selectedNpcEntry],
);
const hostileNpcWorldType = selectedNpcWorldTypes[0] ?? WorldType.WUXIA;
const hostileNpcPreset = effectiveNpc?.monsterPresetId
? (MONSTER_PRESETS_BY_WORLD[hostileNpcWorldType].find(
(monster) => monster.id === effectiveNpc.monsterPresetId,
) ?? null)
: null;
const isHostileNpcEntry = Boolean(
effectiveNpc?.monsterPresetId ||
effectiveNpc?.hostile ||
(effectiveNpc?.initialAffinity ?? 0) < 0,
);
const { isSaving, saveMessage, save } = useJsonSave({
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneNpcOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneNpcOverrides(
overrideMap,
npcCatalog.map((item) => item.npc.id),
PRESET_CHARACTERS,
),
successMessage: '角色覆盖已保存。',
errorMessage: '保存角色覆盖失败。',
});
const previewEncounter: Encounter | null = effectiveNpc
? {
id: effectiveNpc.id,
kind: 'npc',
characterId: effectiveNpc.characterId,
monsterPresetId: effectiveNpc.monsterPresetId,
npcName: effectiveNpc.name,
npcDescription: effectiveNpc.description,
npcAvatar: effectiveNpc.avatar,
context: effectiveNpc.role,
initialAffinity: effectiveNpc.initialAffinity,
hostile: isHostileNpcEntry,
}
: null;
useEffect(() => {
if (selectedNpcWorldTypes.includes(npcSkillPreviewWorld)) {
return;
}
setNpcSkillPreviewWorld(selectedNpcWorldTypes[0] ?? WorldType.WUXIA);
}, [npcSkillPreviewWorld, selectedNpcWorldTypes]);
useEffect(() => {
if (
rangedNpcSkills.some((skill) => skill.id === selectedNpcSkillPreviewId)
) {
return;
}
setSelectedNpcSkillPreviewId(rangedNpcSkills[0]?.id ?? '');
}, [rangedNpcSkills, selectedNpcSkillPreviewId]);
if (!selectedNpcEntry || !effectiveNpc || !previewEncounter) {
return <EditorEmptyState message="当前没有可用的角色预设。" />;
}
const setNpcField = <K extends keyof SceneNpcPresetOverride>(
key: K,
value: SceneNpcPresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedNpcEntry.npc.id]: {
...(prev[selectedNpcEntry.npc.id] ?? {}),
[key]: value,
},
}));
};
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
{' '}
<EditorSelectionCard
title="角色库"
description="浏览并选择一个角色预设。"
selectLabel="角色 ID"
selectValue={selectedNpcEntry.npc.id}
onSelectChange={setSelectedNpcId}
selectOptions={npcCatalog.map((item) => {
const optionNpc = applySceneNpcOverride(
item.npc,
overrideMap[item.npc.id],
);
return {
label: `${optionNpc.name} (${item.sceneNames.join(' / ')})`,
value: item.npc.id,
};
})}
saveLabel="保存角色覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
{' '}
<div className="text-sm font-semibold text-white">
{effectiveNpc.name}
</div>{' '}
<div className="mt-1 text-xs text-zinc-400">{effectiveNpc.role}</div>{' '}
<div className="mt-3 flex flex-wrap gap-2">
{' '}
{selectedNpcEntry.worldTypes.map((worldType) => (
<span
key={worldType}
className="rounded-full border border-white/10 bg-black/20 px-2 py-1 text-[11px] text-zinc-300"
>
{' '}
{WORLD_LABELS[worldType]}{' '}
</span>
))}{' '}
</div>{' '}
<div className="mt-3 text-xs leading-relaxed text-zinc-400">
{effectiveNpc.description}
</div>{' '}
</div>{' '}
</EditorSelectionCard>{' '}
<SectionCard
title="技能预览"
description="预览关联角色的远程技能。"
>
{linkedNpcCharacter && rangedNpcSkills.length > 0 ? (
<div className="space-y-4">
<div className="grid gap-3 md:grid-cols-2">
<SelectField
label="技能"
value={selectedNpcSkillPreview?.id ?? ''}
onChange={setSelectedNpcSkillPreviewId}
options={rangedNpcSkills.map((skill) => ({
label: skill.name,
value: skill.id,
}))}
/>
<SelectField
label="世界"
value={npcSkillPreviewWorld}
onChange={(value) =>
setNpcSkillPreviewWorld(value as WorldType)
}
options={selectedNpcEntry.worldTypes.map((worldType) => ({
label: WORLD_LABELS[worldType],
value: worldType,
}))}
/>
</div>
<SkillEffectPreview
mode="npc"
worldType={npcSkillPreviewWorld}
character={linkedNpcCharacter}
skill={selectedNpcSkillPreview}
npcEncounter={previewEncounter}
/>
</div>
) : (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
)}
</SectionCard>{' '}
<SectionCard
title="形象预览"
description={
isHostileNpcEntry
? '敌对角色使用敌人预设,无法预览内嵌角色形象。'
: '叙事角色可以在这里预览绑定形象与技能效果。'
}
>
{' '}
<div className="flex min-h-[420px] items-center justify-center rounded-2xl border border-white/10 bg-[radial-gradient(circle_at_top,rgba(244,63,94,0.16),transparent_45%),linear-gradient(180deg,#17131a,#0d0a0f)] p-6">
{' '}
<div className="relative flex h-[340px] w-[260px] items-end justify-center overflow-hidden rounded-2xl border border-white/5 bg-black/20">
{' '}
<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]" />{' '}
<div className="mb-8 drop-shadow-[0_18px_24px_rgba(0,0,0,0.45)]">
{' '}
{hostileNpcPreset ? (
<HostileNpcAnimator
hostileNpc={hostileNpcPreset}
className="scale-[2.4] origin-bottom"
/>
) : (
<MedievalNpcAnimator encounter={previewEncounter} />
)}{' '}
</div>{' '}
</div>{' '}
</div>{' '}
<div className="mt-4 rounded-xl border border-white/10 bg-black/20 p-4">
{' '}
<div className="mb-3 text-xs uppercase tracking-[0.22em] text-zinc-500">
</div>{' '}
<div className="flex flex-wrap gap-2">
{' '}
{selectedNpcEntry.sceneNames.map((sceneName) => (
<span
key={sceneName}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
>
{' '}
{sceneName}{' '}
</span>
))}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<SectionCard
title="角色详情"
description="编辑当前选中的角色预设。"
>
{' '}
<div className="grid gap-3">
{' '}
<TextField
label="角色 ID"
value={effectiveNpc.id}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="名称"
value={effectiveNpc.name}
onChange={(value) => setNpcField('name', value)}
/>{' '}
<TextField
label="身份"
value={effectiveNpc.role}
onChange={(value) => setNpcField('role', value)}
/>{' '}
<TextField
label="头像"
value={effectiveNpc.avatar}
onChange={(value) => setNpcField('avatar', value)}
/>{' '}
<TextField
label="关联角色 ID"
value={effectiveNpc.characterId ?? ''}
onChange={() => undefined}
disabled
/>{' '}
<TextField
label="敌人预设 ID"
value={effectiveNpc.monsterPresetId ?? ''}
onChange={(value) =>
setNpcField('monsterPresetId', value || undefined)
}
/>{' '}
<NumberField
label="初始好感"
value={effectiveNpc.initialAffinity ?? 0}
onChange={(value) => setNpcField('initialAffinity', value)}
/>{' '}
<TextAreaField
label="描述"
value={effectiveNpc.description}
onChange={(value) => setNpcField('description', value)}
rows={5}
/>{' '}
<div className="rounded-xl border border-white/10 bg-black/20 px-3 py-3 text-xs leading-relaxed text-zinc-400">
{' '}
{previewEncounter.npcName} / {previewEncounter.context}{' '}
</div>{' '}
</div>{' '}
</SectionCard>{' '}
<div className="xl:col-span-3">
{' '}
<SectionCard
title="形象编辑器"
description={
isHostileNpcEntry
? '敌对角色不能使用形象编辑器,请切换到叙事角色或清空敌人预设 ID。'
: '叙事角色的形象覆盖可以在这里预览与调整。'
}
className="p-6"
>
{' '}
{isHostileNpcEntry ? (
<div className="rounded-xl border border-white/10 bg-black/20 px-4 py-4 text-sm text-zinc-400">
</div>
) : (
<NpcVisualEditor
embedded
selectedNpcId={selectedNpcEntry.npc.id}
hideNpcSelector
/>
)}{' '}
</SectionCard>{' '}
</div>{' '}
</div>
);
}

View File

@@ -1 +0,0 @@
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';

View File

@@ -1,318 +0,0 @@
import { useMemo, useState } from 'react';
import { PRESET_CHARACTERS } from '../../data/characterPresets';
import { validateSceneOverrides } from '../../data/editorValidation';
import { MONSTER_PRESETS_BY_WORLD } from '../../data/hostileNpcPresets';
import { createSceneHostileNpcsFromIds } from '../../data/hostileNpcs';
import sceneOverridesJson from '../../data/sceneOverrides.json';
import { EDITOR_JSON_RESOURCE_IDS } from '../../editor/shared/editorApiClient';
import {
getSceneHostileNpcPresetIds,
getSceneHostileNpcs,
getScenePresetsByWorld,
type ScenePresetOverride,
} from '../../data/scenePresets';
import {
SaveBar,
SelectField,
TextAreaField,
TextField,
} from '../../editor/shared/FormFields';
import { SectionCard } from '../../editor/shared/SectionCard';
import { useJsonSave } from '../../editor/shared/useJsonSave';
import { AnimationState, type Encounter, WorldType } from '../../types';
import { GameCanvas } from '../GameCanvas';
import {
applySceneOverride,
listInputValue,
parseListInput,
WORLD_LABELS,
} from './shared';
type PreviewMode = 'monster' | 'npc' | 'treasure' | 'empty';
export function ScenePresetPanel() {
const allScenes = useMemo(
() => [
...getScenePresetsByWorld(WorldType.WUXIA),
...getScenePresetsByWorld(WorldType.XIANXIA),
],
[],
);
const [overrideMap, setOverrideMap] = useState<
Record<string, ScenePresetOverride>
>(sceneOverridesJson as Record<string, ScenePresetOverride>);
const [selectedSceneId, setSelectedSceneId] = useState(
allScenes[0]?.id ?? '',
);
const [previewMode, setPreviewMode] = useState<PreviewMode>('monster');
const { isSaving, saveMessage, save } = useJsonSave({
resourceId: EDITOR_JSON_RESOURCE_IDS.sceneOverrides,
payload: overrideMap as Record<string, unknown>,
validate: () =>
validateSceneOverrides(overrideMap, allScenes, MONSTER_PRESETS_BY_WORLD),
successMessage: '场景覆盖已保存。',
errorMessage: '保存场景覆盖失败。',
});
const selectedScene =
allScenes.find((scene) => scene.id === selectedSceneId) ?? allScenes[0];
if (!selectedScene) {
return (
<div className="rounded-2xl border border-white/10 bg-black/20 p-6 text-sm text-zinc-300">
</div>
);
}
const effectiveScene = applySceneOverride(
selectedScene,
overrideMap[selectedScene.id],
);
const hostileSceneNpcs = getSceneHostileNpcs(effectiveScene);
const hostileScenePresetIds = getSceneHostileNpcPresetIds(effectiveScene);
const previewCharacter = PRESET_CHARACTERS[0] ?? null;
const previewMonsters =
previewMode === 'monster' && hostileSceneNpcs.length > 0
? createSceneHostileNpcsFromIds(
effectiveScene.worldType,
hostileScenePresetIds.slice(0, 1),
0,
)
: [];
const previewNpc =
previewMode === 'npc'
? (effectiveScene.npcs.find((npc) => !npc.monsterPresetId) ??
effectiveScene.npcs[0])
: null;
const previewEncounter: Encounter | null =
previewMode === 'npc' && previewNpc
? {
id: previewNpc.id,
kind: 'npc',
characterId: previewNpc.characterId,
npcName: previewNpc.name,
npcDescription: previewNpc.description,
npcAvatar: previewNpc.avatar,
context: previewNpc.role,
}
: previewMode === 'treasure' && effectiveScene.treasureHints[0]
? {
id: `${effectiveScene.id}-treasure`,
kind: 'treasure',
npcName: '前方宝藏',
npcDescription: effectiveScene.treasureHints[0],
npcAvatar: '宝',
context: '宝藏',
}
: null;
const setSceneField = <K extends keyof ScenePresetOverride>(
key: K,
value: ScenePresetOverride[K],
) => {
setOverrideMap((prev) => ({
...prev,
[selectedScene.id]: {
...(prev[selectedScene.id] ?? {}),
[key]: value,
},
}));
};
const sceneOptions = allScenes
.filter((scene) => scene.worldType === effectiveScene.worldType)
.map((scene) => ({ label: scene.name, value: scene.id }));
return (
<div className="grid gap-6 xl:grid-cols-[340px_1fr_400px]">
<SectionCard
title="场景库"
description="浏览并选择一个场景预设。"
>
<SelectField
label="场景"
value={selectedScene.id}
onChange={setSelectedSceneId}
options={allScenes.map((scene) => {
const optionScene = applySceneOverride(
scene,
overrideMap[scene.id],
);
return {
label: `${WORLD_LABELS[scene.worldType]} - ${optionScene.name}`,
value: scene.id,
};
})}
/>
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-sm font-semibold text-white">
{effectiveScene.name}
</div>
<div className="mt-1 text-xs text-zinc-400">
{WORLD_LABELS[effectiveScene.worldType]}
</div>
<div className="mt-2 text-xs leading-relaxed text-zinc-400">
{effectiveScene.description}
</div>
</div>
<SaveBar
saveLabel="保存场景覆盖"
onSave={save}
isSaving={isSaving}
saveMessage={saveMessage}
/>
</SectionCard>
<SectionCard
title="场景预览"
description="预览当前场景中的敌人、角色和宝藏表现。"
>
<div className="mb-4">
<SelectField
label="预览模式"
value={previewMode}
onChange={(value) => setPreviewMode(value as PreviewMode)}
options={[
{ label: '敌人预览', value: 'monster' },
{ label: '角色预览', value: 'npc' },
{ label: '宝藏预览', value: 'treasure' },
{ label: '空场景', value: 'empty' },
]}
/>
</div>
<div className="h-[420px] overflow-hidden rounded-2xl border border-white/10 bg-black">
<GameCanvas
scrollWorld={false}
animationState={AnimationState.IDLE}
playerCharacter={previewCharacter}
encounter={previewEncounter}
currentScenePreset={effectiveScene}
worldType={effectiveScene.worldType}
sceneHostileNpcs={previewMonsters}
playerX={0}
playerOffsetY={0}
playerFacing="right"
inBattle={previewMode === 'monster'}
playerHp={180}
playerMaxHp={180}
onSceneNameClick={null}
/>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-2">
{hostileSceneNpcs.map((npc) => npc.name).join('、') || '无'}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
</div>
<div className="mt-2">
{effectiveScene.npcs.map((npc) => npc.name).join(' / ') || '无'}
</div>
</div>
<div className="rounded-xl border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
<div className="text-[11px] uppercase tracking-[0.22em] text-zinc-500">
线
</div>
<div className="mt-2">
{effectiveScene.treasureHints[0] || '无'}
</div>
</div>
</div>
</SectionCard>
<SectionCard
title="场景详情"
description="编辑当前选中的场景预设。"
>
<div className="grid gap-3">
<TextField
label="场景 ID"
value={effectiveScene.id}
onChange={() => undefined}
disabled
/>
<TextField
label="世界"
value={WORLD_LABELS[effectiveScene.worldType]}
onChange={() => undefined}
disabled
/>
<TextField
label="名称"
value={effectiveScene.name}
onChange={(value) => setSceneField('name', value)}
/>
<TextAreaField
label="描述"
value={effectiveScene.description}
onChange={(value) => setSceneField('description', value)}
rows={5}
/>
<TextField
label="图片资源"
value={effectiveScene.imageSrc}
onChange={(value) => setSceneField('imageSrc', value)}
/>
<SelectField
label="前进场景"
value={effectiveScene.forwardSceneId ?? ''}
onChange={(value) =>
setSceneField('forwardSceneId', value || undefined)
}
options={[{ label: '未设置', value: '' }, ...sceneOptions]}
/>
<TextAreaField
label="连接场景 ID"
value={listInputValue(effectiveScene.connectedSceneIds)}
onChange={(value) =>
setSceneField('connectedSceneIds', parseListInput(value))
}
rows={4}
/>
<TextAreaField
label="敌对预设 ID由场景 NPC 自动推导)"
value={listInputValue(hostileScenePresetIds)}
onChange={() => undefined}
rows={4}
disabled
/>
<div className="-mt-1 rounded-xl border border-amber-400/15 bg-amber-500/8 px-3 py-2 text-xs leading-6 text-amber-100/80">
NPC NPC hostile visual/combat preset
</div>
<TextAreaField
label="宝藏线索"
value={listInputValue(effectiveScene.treasureHints)}
onChange={(value) =>
setSceneField('treasureHints', parseListInput(value))
}
rows={4}
/>
<div className="rounded-xl border border-white/10 bg-black/20 p-3">
<div className="mb-2 text-xs font-medium text-zinc-300">
</div>
<div className="flex flex-wrap gap-2">
{effectiveScene.npcs.map((npc) => (
<span
key={npc.id}
className="rounded-full border border-white/10 bg-white/[0.03] px-3 py-1 text-xs text-zinc-200"
>
{npc.name}
</span>
))}
</div>
</div>
</div>
</SectionCard>
</div>
);
}

View File

@@ -1 +0,0 @@
export { ScenePresetPanel as default } from './ScenePresetPanel';

View File

@@ -1,259 +0,0 @@
import {
Braces,
Map as MapIcon,
Package,
Sparkles,
Sword,
User,
Users,
} from 'lucide-react';
import type { ComponentType } from 'react';
import type { CharacterPresetOverride } from '../../data/characterPresets';
import type {
MonsterPreset,
MonsterPresetOverride,
} from '../../data/hostileNpcPresets';
import type {
SceneNpcPresetOverride,
ScenePreset,
ScenePresetOverride,
} from '../../data/scenePresets';
import {
AnimationState,
type Character,
type CharacterSkillDefinition,
type SceneNpc,
WorldType,
} from '../../types';
export type PresetEditorTab =
| 'assets'
| 'characters'
| 'npcs'
| 'scenes'
| 'monsters'
| 'items'
| 'functions';
export const PRESET_EDITOR_TABS: Array<{
id: PresetEditorTab;
label: string;
icon: ComponentType<{ className?: string }>;
}> = [
{ id: 'assets', label: '资产', icon: Sparkles },
{ id: 'characters', label: '角色', icon: User },
{ id: 'npcs', label: '角色', icon: Users },
{ id: 'scenes', label: '场景', icon: MapIcon },
{ id: 'monsters', label: '敌人', icon: Sword },
{ id: 'items', label: '物品', icon: Package },
{ id: 'functions', label: '函数', icon: Braces },
];
export const EDITOR_TAB_OPTIONS = PRESET_EDITOR_TABS;
export const WORLD_OPTIONS = [WorldType.WUXIA, WorldType.XIANXIA] as const;
export const WORLD_LABELS: Record<WorldType, string> = {
[WorldType.WUXIA]: '武侠',
[WorldType.XIANXIA]: '仙侠',
[WorldType.CUSTOM]: '自定义世界',
};
export const ANIMATION_OPTIONS = Object.values(AnimationState);
export const ANIMATION_LABELS: Record<AnimationState, string> = {
[AnimationState.IDLE]: '待机',
[AnimationState.ACQUIRE]: '拾取',
[AnimationState.ATTACK]: '攻击',
[AnimationState.RUN]: '奔跑',
[AnimationState.JUMP]: '跳跃',
[AnimationState.DOUBLE_JUMP]: '二段跳',
[AnimationState.JUMP_ATTACK]: '跳斩',
[AnimationState.DASH]: '冲刺',
[AnimationState.HURT]: '受击',
[AnimationState.DIE]: '倒下',
[AnimationState.CLIMB]: '攀爬',
[AnimationState.SKILL1]: '技能 1',
[AnimationState.SKILL1_JUMP]: '技能 1 跃击',
[AnimationState.SKILL1_BULLET]: '技能 1 弹道',
[AnimationState.SKILL1_BULLET_FX]: '技能 1 特效',
[AnimationState.SKILL2]: '技能 2',
[AnimationState.SKILL2_JUMP]: '技能 2 跃击',
[AnimationState.SKILL3]: '技能 3',
[AnimationState.SKILL3_JUMP]: '技能 3 跃击',
[AnimationState.SKILL3_BULLET]: '技能 3 弹道',
[AnimationState.SKILL3_BULLET_FX]: '技能 3 特效',
[AnimationState.SKILL4]: '技能 4',
[AnimationState.WALL_SLIDE]: '贴墙滑行',
};
export const MONSTER_ANIMATION_OPTIONS = [
'idle',
'move',
'attack',
'die',
] as const;
export const MONSTER_ANIMATION_LABELS: Record<
(typeof MONSTER_ANIMATION_OPTIONS)[number],
string
> = {
idle: '待机',
move: '移动',
attack: '攻击',
die: '倒下',
};
export const CHARACTER_SKILL_STYLE_OPTIONS = [
'steady',
'burst',
'mobility',
'finisher',
'projectile',
] as const;
export const CHARACTER_SKILL_STYLE_LABELS: Record<
(typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
string
> = {
steady: '稳扎稳打',
burst: '爆发',
mobility: '机动',
finisher: '终结',
projectile: '投射',
};
export function getAnimationStateLabel(animation: AnimationState) {
return ANIMATION_LABELS[animation] ?? animation;
}
export function getMonsterAnimationLabel(
animation: (typeof MONSTER_ANIMATION_OPTIONS)[number],
) {
return MONSTER_ANIMATION_LABELS[animation] ?? animation;
}
export function getCharacterSkillStyleLabel(
style: (typeof CHARACTER_SKILL_STYLE_OPTIONS)[number],
) {
return CHARACTER_SKILL_STYLE_LABELS[style] ?? style;
}
export function isRangedSkill(skill: CharacterSkillDefinition) {
return skill.delivery === 'ranged' || skill.style === 'projectile';
}
export function parseListInput(value: string) {
return value
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
}
export function listInputValue(items: string[]) {
return items.join('\n');
}
export function parseBuildBuffsInput(
value: string,
sourceType: 'skill' | 'item' | 'forge',
sourceId: string,
) {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line, index) => {
const [namePart, tagsPart, durationPart] = line
.split('|')
.map((part) => part.trim());
const tags = tagsPart
? tagsPart
.split(',')
.map((tag) => tag.trim())
.filter(Boolean)
: [];
return {
id: `${sourceId}-buff-${index + 1}`,
sourceType,
sourceId,
name: namePart || `${sourceId}-buff-${index + 1}`,
tags,
durationTurns: Math.max(1, Number(durationPart ?? '1') || 1),
};
})
.filter((buff) => buff.tags.length > 0);
}
export function buildBuffsInputValue(
buffs: CharacterSkillDefinition['buildBuffs'] | undefined,
) {
return (buffs ?? [])
.map(
(buff) =>
`${buff.name}|${(buff.tags ?? []).join(',')}|${buff.durationTurns}`,
)
.join('\n');
}
export function normalizeOptionalSceneId(value: string) {
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
export function applyCharacterOverride(
baseCharacter: Character,
override?: CharacterPresetOverride | null,
): Character {
if (!override) {
return baseCharacter;
}
return {
...baseCharacter,
...override,
attributes: { ...baseCharacter.attributes, ...(override.attributes ?? {}) },
animationMap: override.animationMap
? { ...(baseCharacter.animationMap ?? {}), ...override.animationMap }
: baseCharacter.animationMap,
skills: override.skills ?? baseCharacter.skills,
};
}
export function applyMonsterOverride(
baseMonster: MonsterPreset,
override?: MonsterPresetOverride | null,
): MonsterPreset {
if (!override) {
return baseMonster;
}
return {
...baseMonster,
...override,
animations: { ...baseMonster.animations, ...(override.animations ?? {}) },
baseStats: { ...baseMonster.baseStats, ...(override.baseStats ?? {}) },
habitatTags: override.habitatTags ?? baseMonster.habitatTags,
};
}
export function applySceneOverride(
baseScene: ScenePreset,
override?: ScenePresetOverride | null,
): ScenePreset {
if (!override) {
return baseScene;
}
return { ...baseScene, ...override };
}
export function applySceneNpcOverride(
baseNpc: SceneNpc,
override?: SceneNpcPresetOverride | null,
): SceneNpc {
if (!override) {
return baseNpc;
}
return { ...baseNpc, ...override };
}