@@ -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(
|
||||
|
||||
@@ -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="参考图">
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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] : [];
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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
@@ -1 +0,0 @@
|
||||
export { CharacterAssetPanel as default } from './CharacterAssetPanel';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { CharacterPresetPanel as default } from './CharacterPresetPanel';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { MonsterPresetPanel as default } from './MonsterPresetPanel';
|
||||
@@ -1,4 +0,0 @@
|
||||
export { CharacterPresetPanel } from './CharacterPresetPanel';
|
||||
export { MonsterPresetPanel } from './MonsterPresetPanel';
|
||||
export { SceneNpcPresetPanel } from './SceneNpcPresetPanel';
|
||||
export { ScenePresetPanel } from './ScenePresetPanel';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { SceneNpcPresetPanel as default } from './SceneNpcPresetPanel';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export { ScenePresetPanel as default } from './ScenePresetPanel';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from './buildDamage';
|
||||
import { getCharacterCombatTags } from './buildTags';
|
||||
import { getCharacterById } from './characterPresets';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function requireCharacter(characterId: string) {
|
||||
const character = getCharacterById(characterId);
|
||||
@@ -37,8 +37,8 @@ function cloneCharacter(
|
||||
},
|
||||
} satisfies Character;
|
||||
|
||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(
|
||||
nextCharacter,
|
||||
wuxiaSchema,
|
||||
@@ -150,7 +150,7 @@ describe('buildDamage', () => {
|
||||
it('decomposes every active tag into per-attribute fit and modifier contributions', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const breakdown = getCompanionBuildDamageBreakdown(character);
|
||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
|
||||
expect(breakdown.rows.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -381,7 +381,7 @@ describe('buildDamage', () => {
|
||||
|
||||
it('does not allow resource attributes to enter tag bonus rows', () => {
|
||||
const character = requireCharacter('sword-princess');
|
||||
const schema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const schema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
const mpBreakdown = getPlayerBuildDamageBreakdown(
|
||||
buildGameState({
|
||||
weapon: buildEquipmentItem({
|
||||
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
buildCustomWorldStarterInventoryItems,
|
||||
} from './customWorldCharacterLoadout';
|
||||
import { getRuntimeCustomWorldProfile, isCustomWorldType } from './customWorldRuntime';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
function defineSkill(skill: CharacterSkillDefinition): CharacterSkillDefinition {
|
||||
return skill;
|
||||
@@ -289,8 +289,8 @@ function hydrateCharacterRoleData(
|
||||
customRole?: CustomWorldRuntimeRole | null;
|
||||
} = {},
|
||||
) {
|
||||
const wuxiaSchema = getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaSchema = getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
const xianxiaSchema = getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||
const wuxiaProfile = buildCharacterAttributeProfile(character, wuxiaSchema);
|
||||
const xianxiaProfile = buildCharacterAttributeProfile(character, xianxiaSchema);
|
||||
const customProfile = options.customWorldProfile
|
||||
@@ -520,7 +520,7 @@ export function getInventoryItems(character: Character, worldType: WorldType | n
|
||||
];
|
||||
}
|
||||
|
||||
const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
|
||||
const BASE_ROLE_TEMPLATE_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attributeProfiles' | 'resourceProfile'>> = [
|
||||
{
|
||||
id: 'sword-princess',
|
||||
name: '剑之公主',
|
||||
@@ -563,7 +563,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.WUXIA]: opening({
|
||||
reason: '追查失落王庭誓剑流入江湖的踪迹',
|
||||
goal: '在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人',
|
||||
monologue: '你来到这个武侠世界,是为追查失落王庭誓剑流入江湖的踪迹。此行最重要的目标,是在诸门派与野心家之前找回誓剑,并逼出宫变幕后之人。',
|
||||
monologue: '你来到这片旧桥与边城交错的地界,是为追查失落王庭誓剑流入各方势力的踪迹。此行最重要的目标,是在野心家之前找回誓剑,并逼出宫变幕后之人。',
|
||||
surfaceHook: '我追着一件不该流落在外的王庭旧物而来。',
|
||||
immediateConcern: '前面盯着这条线的人不止一拨,走错一步就会被人截住。',
|
||||
guardedMotive: '我来这里不是巡游散心,有件旧账必须先查清。',
|
||||
@@ -571,7 +571,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.XIANXIA]: opening({
|
||||
reason: '王庭圣印坠入云海裂隙,你循着残光闯入了仙域',
|
||||
goal: '寻回圣印,截断借它开启天门禁制的野心',
|
||||
monologue: '你来到这个仙侠世界,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
|
||||
monologue: '你来到这片灵潮翻涌的高空异境,是因为王庭圣印坠入了云海裂隙。此行最重要的目标,是寻回圣印,截断那些企图借它开启天门禁制的野心。',
|
||||
surfaceHook: '我循着一道王庭残光追到了这里。',
|
||||
immediateConcern: '云海里的局势已经被人搅乱,圣印不会等你慢慢摸索。',
|
||||
guardedMotive: '我来这里是为收回一件必须回到我手里的东西。',
|
||||
@@ -720,7 +720,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.WUXIA]: opening({
|
||||
reason: '追着一份指向边军叛徒的密图进入江湖',
|
||||
goal: '找出贩卖军情的人,并截回被转移的军械账册',
|
||||
monologue: '你来到这个武侠世界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
|
||||
monologue: '你来到这片边城动荡未平的地界,是为追着一份指向边军叛徒的密图继续追查。此行最重要的目标,是找出贩卖军情的人,并截回那份被转移的军械账册。',
|
||||
surfaceHook: '我追着一份旧军密图走到了这片江湖。',
|
||||
immediateConcern: '这条线上的人都擅长放假风声,前路不只一层埋伏。',
|
||||
guardedMotive: '我在追一条和边军旧案有关的线,但还不到全说的时候。',
|
||||
@@ -728,7 +728,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.XIANXIA]: opening({
|
||||
reason: '星舟坠毁后,你顺着碎裂航迹漂进了仙域云海',
|
||||
goal: '找回星图核心,查清是谁击落了你的船队',
|
||||
monologue: '你来到这个仙侠世界,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
|
||||
monologue: '你来到这片灵潮与云海交错的异境,是因为星舟坠毁后,你只能顺着碎裂航迹漂进云海。此行最重要的目标,是找回星图核心,查清究竟是谁击落了你的船队。',
|
||||
surfaceHook: '我是顺着一段断掉的航迹飘进来的。',
|
||||
immediateConcern: '云海里残留的痕迹还没散干净,说明对方离得不远。',
|
||||
guardedMotive: '我在追查一场坠毁后的尾线,暂时只确认到这里。',
|
||||
@@ -939,7 +939,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.WUXIA]: opening({
|
||||
reason: '追着偷走密信的人潜入了这片雨夜江湖',
|
||||
goal: '夺回密信,查清究竟是谁把你推上了被追杀的路',
|
||||
monologue: '你来到这个武侠世界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
|
||||
monologue: '你来到这片雨夜与旧案交错的地界,是为追着偷走密信的人继续潜行。此行最重要的目标,是夺回那封密信,查清究竟是谁把你推上了被追杀的路。',
|
||||
surfaceHook: '我追着一个偷走东西的人摸进了这里。',
|
||||
immediateConcern: '前面这条路像是专门给人设的套,闯快了只会替别人探雷。',
|
||||
guardedMotive: '我来这儿是为了追一封信,也顺便追查是谁想让我闭嘴。',
|
||||
@@ -947,7 +947,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.XIANXIA]: opening({
|
||||
reason: '密信指向一座只会在月湖现身的仙门残阵',
|
||||
goal: '找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁',
|
||||
monologue: '你来到这个仙侠世界,是因为那封密信把你引向了一座只会在月湖现身的仙门残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
|
||||
monologue: '你来到这片月湖与残阵交错的异境,是因为那封密信把你引向了一座只会在月湖现身的残阵。此行最重要的目标,是找到残阵核心,并弄明白信里提到的“第二个你”究竟是谁。',
|
||||
surfaceHook: '有封信把我一路引到了月湖这一带。',
|
||||
immediateConcern: '残阵现身的时间很短,再慢一点就只剩空壳。',
|
||||
guardedMotive: '我来这里不只是找阵眼,还想弄明白一件跟我自己有关的怪事。',
|
||||
@@ -1034,7 +1034,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.WUXIA]: opening({
|
||||
reason: '循着毁掉拳馆的凶手线索来到了这片江湖',
|
||||
goal: '找到凶手首领,让拳馆遗物和弟子名册不再被人践踏',
|
||||
monologue: '你来到这个武侠世界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
|
||||
monologue: '你来到这片拳馆旧怨未平的地界,是为循着毁掉拳馆的凶手线索继续追下去。此行最重要的目标,是找到凶手首领,让拳馆遗物和弟子名册不再被人践踏。',
|
||||
surfaceHook: '我追着一帮砸了拳馆的人一路追到了这里。',
|
||||
immediateConcern: '前面那股气味不对,像是有人刚动过手脚。',
|
||||
guardedMotive: '我来这里是为了算账,也为了把该带回去的东西带回去。',
|
||||
@@ -1042,7 +1042,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.XIANXIA]: opening({
|
||||
reason: '师门遗物在灵火裂隙里传来回应,你一路追进了熔境',
|
||||
goal: '夺回遗物中的真传拳谱,阻止它被人炼成杀器',
|
||||
monologue: '你来到这个仙侠世界,是因为师门遗物在灵火裂隙里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
|
||||
monologue: '你来到这片灵火裂隙仍在回响的异境,是因为师门遗物在那里传来了回应。此行最重要的目标,是夺回遗物中的真传拳谱,阻止它被人炼成新的杀器。',
|
||||
surfaceHook: '我顺着师门遗物的回应一路追到了熔境。',
|
||||
immediateConcern: '灵火裂隙正往外吐东西,再拖下去只会更难收拾。',
|
||||
guardedMotive: '我来这里是为了把师门留下的东西抢回来,别的以后再细说。',
|
||||
@@ -1222,7 +1222,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.WUXIA]: opening({
|
||||
reason: '奉旧部最后一道军令,独自赶来守住山门防线',
|
||||
goal: '找回失散军旗,重新拼起已经溃散的同袍',
|
||||
monologue: '你来到这个武侠世界,是奉着旧部最后一道军令赶来守住山门防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
|
||||
monologue: '你来到这片山门与防线都在失守边缘的地界,是奉着旧部最后一道军令赶来守住防线。此行最重要的目标,是找回失散军旗,重新拼起那支已经溃散的同袍队伍。',
|
||||
surfaceHook: '我奉着一条没法搁下的旧军令守在这里。',
|
||||
immediateConcern: '山门前的防线已经松了,再往前走的人很可能被卷进去。',
|
||||
guardedMotive: '我来这里不是巡查,是在补一段还没补上的旧阵线。',
|
||||
@@ -1230,7 +1230,7 @@ const BASE_PRESET_CHARACTERS: Array<Omit<Character, 'attributeProfile' | 'attrib
|
||||
[WorldType.XIANXIA]: opening({
|
||||
reason: '雷坛异动引发旧式甲胄共鸣,你被迫一路追进了仙域',
|
||||
goal: '封住失控雷坛,避免整支旧军的甲魂被拿去驱使',
|
||||
monologue: '你来到这个仙侠世界,是因为雷坛异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
|
||||
monologue: '你来到这片雷坛异动不断放大的异境,是因为那场异动让旧式甲胄一齐共鸣。此行最重要的目标,是封住失控雷坛,避免整支旧军残留下来的甲魂被人拿去驱使。',
|
||||
surfaceHook: '我顺着旧甲的共鸣一路追到了雷坛附近。',
|
||||
immediateConcern: '这里的雷势还在往上走,再晚一点就不是一两个人能压住的事。',
|
||||
guardedMotive: '我来这里是为了封住一处失控源头,也为了不让旧军的东西落到错的人手里。',
|
||||
@@ -1502,7 +1502,8 @@ function mergeCharacterPreset(baseCharacter: Character): Character {
|
||||
});
|
||||
}
|
||||
|
||||
export const PRESET_CHARACTERS: Character[] = BASE_PRESET_CHARACTERS.map(mergeCharacterPreset);
|
||||
export const ROLE_TEMPLATE_CHARACTERS: Character[] =
|
||||
BASE_ROLE_TEMPLATE_CHARACTERS.map(mergeCharacterPreset);
|
||||
const runtimeCharacterOverrides = new Map<string, Character>();
|
||||
let runtimeCustomWorldCharacters: Character[] = [];
|
||||
|
||||
@@ -1668,15 +1669,15 @@ function pickCustomWorldRoleTemplateCharacter(
|
||||
fallbackIndex: number,
|
||||
profile?: CustomWorldProfile | null,
|
||||
) {
|
||||
const fallbackTemplateCharacter = PRESET_CHARACTERS[
|
||||
fallbackIndex % Math.max(1, PRESET_CHARACTERS.length)
|
||||
] ?? PRESET_CHARACTERS[0];
|
||||
const fallbackTemplateCharacter = ROLE_TEMPLATE_CHARACTERS[
|
||||
fallbackIndex % Math.max(1, ROLE_TEMPLATE_CHARACTERS.length)
|
||||
] ?? ROLE_TEMPLATE_CHARACTERS[0];
|
||||
if (!fallbackTemplateCharacter) {
|
||||
throw new Error('Missing preset characters for custom world generation');
|
||||
}
|
||||
|
||||
const explicitTemplateCharacter = role.templateCharacterId
|
||||
? PRESET_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === role.templateCharacterId) ?? null
|
||||
: null;
|
||||
if (explicitTemplateCharacter) {
|
||||
return explicitTemplateCharacter;
|
||||
@@ -1696,7 +1697,7 @@ function pickCustomWorldRoleTemplateCharacter(
|
||||
},
|
||||
);
|
||||
const referenceTemplateCharacter = referenceTemplateCharacterId
|
||||
? PRESET_CHARACTERS.find(
|
||||
? ROLE_TEMPLATE_CHARACTERS.find(
|
||||
(character) => character.id === referenceTemplateCharacterId,
|
||||
) ?? null
|
||||
: null;
|
||||
@@ -1704,7 +1705,7 @@ function pickCustomWorldRoleTemplateCharacter(
|
||||
return referenceTemplateCharacter;
|
||||
}
|
||||
|
||||
const heuristicTemplateCharacter = PRESET_CHARACTERS.find(
|
||||
const heuristicTemplateCharacter = ROLE_TEMPLATE_CHARACTERS.find(
|
||||
character =>
|
||||
character.id === resolveFallbackRecruitTemplateCharacterId([
|
||||
role.role,
|
||||
@@ -1722,11 +1723,11 @@ function pickCustomWorldRoleTemplateCharacter(
|
||||
|
||||
export function buildCustomWorldPlayableCharacters(profile: CustomWorldProfile | null) {
|
||||
if (!profile) {
|
||||
return PRESET_CHARACTERS;
|
||||
return ROLE_TEMPLATE_CHARACTERS;
|
||||
}
|
||||
|
||||
if (profile.playableNpcs.length === 0) {
|
||||
return PRESET_CHARACTERS;
|
||||
return ROLE_TEMPLATE_CHARACTERS;
|
||||
}
|
||||
|
||||
return profile.playableNpcs.map((role, index) => {
|
||||
@@ -1780,7 +1781,7 @@ export function setRuntimeCharacterOverrides(characters: Character[] | null) {
|
||||
|
||||
export function getCharacterById(characterId: string) {
|
||||
return runtimeCharacterOverrides.get(characterId)
|
||||
?? PRESET_CHARACTERS.find(character => character.id === characterId)
|
||||
?? ROLE_TEMPLATE_CHARACTERS.find(character => character.id === characterId)
|
||||
?? null;
|
||||
}
|
||||
|
||||
|
||||
@@ -642,9 +642,15 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
const settingText = toText(value.settingText, toText(value.summary, name));
|
||||
if (!name) return null;
|
||||
|
||||
const templateWorldType = value.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
const compatibilityTemplateWorldType =
|
||||
value.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: value.compatibilityTemplateWorldType === WorldType.WUXIA
|
||||
? WorldType.WUXIA
|
||||
: value.templateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: WorldType.WUXIA;
|
||||
const templateWorldType = compatibilityTemplateWorldType;
|
||||
const subtitle = toText(value.subtitle);
|
||||
const summary = toText(value.summary);
|
||||
const tone = toText(value.tone);
|
||||
@@ -687,6 +693,7 @@ function normalizeProfile(value: unknown): CustomWorldProfile | null {
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary || playerGoal || settingText || name],
|
||||
attributeSchema: coerceWorldAttributeSchema(value.attributeSchema, generatedAttributeSchema),
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type CustomWorldProfile,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
||||
import {
|
||||
getMonsterPresetsByWorld,
|
||||
type HostileNpcPreset,
|
||||
@@ -181,7 +182,10 @@ function scoreMonsterPresetWithArchetype(
|
||||
}
|
||||
|
||||
export function getCustomWorldMonsterPresetPool(
|
||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
const presets = getAllMonsterPresets();
|
||||
const creatureArchetypes =
|
||||
@@ -191,7 +195,9 @@ export function getCustomWorldMonsterPresetPool(
|
||||
return presets;
|
||||
}
|
||||
|
||||
const preferredWorldType = profile?.templateWorldType ?? null;
|
||||
const preferredWorldType = profile
|
||||
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
||||
: null;
|
||||
const scoredPresets = presets
|
||||
.map((preset) => {
|
||||
const archetypeScore = creatureArchetypes.reduce((bestScore, archetype) => {
|
||||
@@ -223,7 +229,10 @@ export function getCustomWorldMonsterPresetPool(
|
||||
export function resolveCustomWorldNpcMonsterPreset(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
const sourceText = buildMonsterSourceText(npc);
|
||||
if (!sourceText || !MONSTER_SIGNAL_PATTERN.test(sourceText)) {
|
||||
@@ -235,7 +244,9 @@ export function resolveCustomWorldNpcMonsterPreset(
|
||||
return null;
|
||||
}
|
||||
|
||||
const preferredWorldType = profile?.templateWorldType ?? worldType ?? null;
|
||||
const preferredWorldType = profile
|
||||
? resolveCustomWorldCompatibilityTemplateWorldType(profile)
|
||||
: worldType ?? null;
|
||||
const referenceArchetype = resolveCreatureArchetypeForSource(
|
||||
profile as CustomWorldProfile | null | undefined,
|
||||
npc,
|
||||
@@ -271,7 +282,10 @@ export function resolveCustomWorldNpcMonsterPreset(
|
||||
export function resolveCustomWorldNpcMonsterPresetId(
|
||||
npc: CustomWorldMonsterSource,
|
||||
worldType?: WorldType | null,
|
||||
profile?: Pick<CustomWorldProfile, 'ownedSettingLayers' | 'templateWorldType'> | null,
|
||||
profile?: Pick<
|
||||
CustomWorldProfile,
|
||||
'ownedSettingLayers' | 'templateWorldType' | 'compatibilityTemplateWorldType'
|
||||
> | null,
|
||||
) {
|
||||
return resolveCustomWorldNpcMonsterPreset(npc, worldType, profile)?.id ?? null;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
resolveCustomWorldAnchorWorldType,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||
} from '../services/customWorldTheme';
|
||||
import { CustomWorldItem, CustomWorldProfile, InventoryItem, WorldTemplateType, WorldType } from '../types';
|
||||
|
||||
@@ -15,13 +15,15 @@ export function getRuntimeCustomWorldProfile() {
|
||||
return runtimeCustomWorldProfile;
|
||||
}
|
||||
|
||||
export function resolveRuleWorldType(
|
||||
export function resolveCompatibilityTemplateWorldType(
|
||||
worldType: WorldType | null | undefined,
|
||||
customWorldProfile: CustomWorldProfile | null | undefined = runtimeCustomWorldProfile,
|
||||
): WorldTemplateType | null {
|
||||
if (!worldType) return null;
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return customWorldProfile ? resolveCustomWorldAnchorWorldType(customWorldProfile) : WorldType.WUXIA;
|
||||
return customWorldProfile
|
||||
? resolveCustomWorldCompatibilityTemplateWorldType(customWorldProfile)
|
||||
: WorldType.WUXIA;
|
||||
}
|
||||
return worldType;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type WorldTemplateType,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveCustomWorldCompatibilityTemplateWorldType } from '../services/customWorldTheme';
|
||||
|
||||
const CUSTOM_WORLD_NPC_IMAGE_POOL = [
|
||||
'/character/Sword%20Princess/Original/Hero/idle/Idle01.png',
|
||||
@@ -80,7 +81,7 @@ const SCENE_MATCH_STOP_CHARS = new Set([
|
||||
'桥',
|
||||
]);
|
||||
|
||||
const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
const MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
{
|
||||
name: '山门石阶',
|
||||
keywords: ['山门', '石阶', '门派', '山路', '石门', '宗门'],
|
||||
@@ -131,7 +132,7 @@ const WUXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
const ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
{
|
||||
name: '云海仙门',
|
||||
keywords: ['云海', '仙门', '山门', '灵门', '云阶', '门阙'],
|
||||
@@ -182,12 +183,12 @@ const XIANXIA_SCENE_IMAGE_REFERENCES: SceneImageReference[] = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
const WORLD_SCENE_IMAGE_REFERENCES: Record<
|
||||
const COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES: Record<
|
||||
WorldTemplateType,
|
||||
readonly SceneImageReference[]
|
||||
> = {
|
||||
[WorldType.WUXIA]: WUXIA_SCENE_IMAGE_REFERENCES,
|
||||
[WorldType.XIANXIA]: XIANXIA_SCENE_IMAGE_REFERENCES,
|
||||
[WorldType.WUXIA]: MARTIAL_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
||||
[WorldType.XIANXIA]: ARCANE_TEMPLATE_SCENE_IMAGE_REFERENCES,
|
||||
};
|
||||
|
||||
type CustomWorldSceneImageMatchOptions = {
|
||||
@@ -259,7 +260,7 @@ function uniqueStrings(values: Array<string | null | undefined>) {
|
||||
|
||||
function buildSceneReferencePool(worldType: WorldTemplateType) {
|
||||
const pool = collectWorldSceneImagePool(worldType);
|
||||
const references = WORLD_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
||||
const references = COMPATIBILITY_TEMPLATE_SCENE_IMAGE_REFERENCES[worldType] ?? [];
|
||||
|
||||
return references.map((reference, index) => ({
|
||||
...reference,
|
||||
@@ -488,6 +489,7 @@ export function resolveCustomWorldLandmarkImage(
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
landmark: Pick<CustomWorldLandmark, 'id' | 'name' | 'description' | 'dangerLevel' | 'imageSrc'>,
|
||||
@@ -502,7 +504,7 @@ export function resolveCustomWorldLandmarkImage(
|
||||
return getDefaultCustomWorldSceneImage(
|
||||
profile.id || profile.name,
|
||||
index,
|
||||
profile.templateWorldType,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||
{
|
||||
profile,
|
||||
landmark,
|
||||
@@ -521,6 +523,7 @@ export function resolveCustomWorldLandmarkImageMap(
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'landmarks'
|
||||
| 'camp'
|
||||
| 'ownedSettingLayers'
|
||||
@@ -559,6 +562,7 @@ export function resolveCustomWorldCampSceneImage(
|
||||
| 'playerGoal'
|
||||
| 'settingText'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'landmarks'
|
||||
| 'camp'
|
||||
| 'ownedSettingLayers'
|
||||
@@ -575,7 +579,7 @@ export function resolveCustomWorldCampSceneImage(
|
||||
return getDefaultCustomWorldSceneImage(
|
||||
profile.id || profile.name,
|
||||
-1,
|
||||
profile.templateWorldType,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||
{
|
||||
profile,
|
||||
landmark: {
|
||||
|
||||
@@ -4,7 +4,10 @@ import { GameState, InventoryItem, MonsterLootEntry, RoleActionDefinition, Runti
|
||||
import { buildMonsterAttributeProfile } from './attributeProfileGenerator';
|
||||
import { buildDefaultAxisVector } from './attributeResolver';
|
||||
import {normalizeBuildTags} from './buildTags';
|
||||
import { buildRuntimeCustomWorldInventoryItems, resolveRuleWorldType } from './customWorldRuntime';
|
||||
import {
|
||||
buildRuntimeCustomWorldInventoryItems,
|
||||
resolveCompatibilityTemplateWorldType,
|
||||
} from './customWorldRuntime';
|
||||
import hostileNpcOverridesJson from './hostileNpcOverrides.json';
|
||||
import { buildRuntimeItemGenerationContext } from './runtimeItemContext';
|
||||
import { generateDirectedRuntimeReward } from './runtimeItemDirector';
|
||||
@@ -13,7 +16,7 @@ import {
|
||||
buildRuntimeItemAiIntent,
|
||||
flattenDirectedRuntimeRewardItems,
|
||||
} from './runtimeItemNarrative';
|
||||
import { getPresetWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
import { getTemplateWorldAttributeSchema } from './worldAttributeSchemas';
|
||||
|
||||
export interface HostileNpcPreset extends HostileNpcSpriteConfig {
|
||||
worldType: WorldType;
|
||||
@@ -930,7 +933,7 @@ function buildHostileNpcBehaviorVectors(preset: {
|
||||
function hydrateHostileNpcPresetRoleData(
|
||||
preset: Omit<HostileNpcPreset, 'attributeProfile' | 'behaviorVectors'>,
|
||||
): HostileNpcPreset {
|
||||
const schema = getPresetWorldAttributeSchema(
|
||||
const schema = getTemplateWorldAttributeSchema(
|
||||
preset.worldType === WorldType.XIANXIA ? WorldType.XIANXIA : WorldType.WUXIA,
|
||||
);
|
||||
|
||||
@@ -955,7 +958,8 @@ export function getHostileNpcPresetById(worldType: WorldType, monsterId: string)
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM].find(monster => monster.id === monsterId) ?? null;
|
||||
}
|
||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const resolvedWorldType =
|
||||
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType].find(monster => monster.id === monsterId) ?? null;
|
||||
}
|
||||
|
||||
@@ -965,7 +969,8 @@ export function getHostileNpcPresetsByWorld(worldType: WorldType) {
|
||||
if (worldType === WorldType.CUSTOM) {
|
||||
return HOSTILE_NPC_PRESETS_BY_WORLD[WorldType.CUSTOM];
|
||||
}
|
||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const resolvedWorldType =
|
||||
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||
return HOSTILE_NPC_PRESETS_BY_WORLD[resolvedWorldType];
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { resolveRoleCombatStats } from './attributeCombat';
|
||||
import { resolveRuleWorldType } from './customWorldRuntime';
|
||||
import { resolveCompatibilityTemplateWorldType } from './customWorldRuntime';
|
||||
import { getHostileNpcPresetById, getHostileNpcPresetsByWorld, HOSTILE_NPC_PRESETS_BY_WORLD } from './hostileNpcPresets';
|
||||
|
||||
export const METERS_TO_PIXELS = 48;
|
||||
@@ -78,7 +78,8 @@ function getHostileNpcFormationSlots(
|
||||
worldType: WorldType,
|
||||
monsterCount: number,
|
||||
): HostileNpcFormationSlot[] {
|
||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const resolvedWorldType =
|
||||
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const frontX = FRONT_HOSTILE_NPC_ANCHOR_X[resolvedWorldType];
|
||||
const centerSlot = { xMeters: frontX, yOffset: 0 };
|
||||
const lowerBackSlot = {
|
||||
|
||||
@@ -471,14 +471,14 @@ function buildLegacyDesign(
|
||||
category,
|
||||
rarity,
|
||||
tags: dedupe(tags),
|
||||
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两个世界中以不同风格登场,适合作为${category}基础模板继续扩展。`,
|
||||
description: `${wuxiaName} / ${xianxiaName} 这件图标物资可在两套兼容模板中以不同风格登场,适合作为${category}基础模板继续扩展。`,
|
||||
worldAffinity: "neutral",
|
||||
equipmentSlotId: slot,
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName},适用于武侠世界的基础${category}条目。`,
|
||||
`${xianxiaName},适用于仙侠世界的基础${category}条目。`,
|
||||
`${wuxiaName},适用于边城模板的基础${category}条目。`,
|
||||
`${xianxiaName},适用于灵潮模板的基础${category}条目。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile,
|
||||
@@ -544,8 +544,8 @@ function buildArmoryDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的武侠 build。`,
|
||||
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑仙侠战法。`,
|
||||
`${wuxiaName}来自${theme.setWuxia}套装,偏向${theme.synergy.join("、")}的边城模板 build。`,
|
||||
`${xianxiaName}属于${theme.setXianxia}套装,围绕${theme.synergy.join("、")}构筑灵潮模板战法。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile: null,
|
||||
@@ -633,8 +633,8 @@ function buildPotionDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
"药瓶",
|
||||
"灵瓶",
|
||||
"武侠世界常见的炼药容器。",
|
||||
"仙侠世界常用的盛装灵液器皿。",
|
||||
"边城模板里常见的炼药容器。",
|
||||
"灵潮模板里常用的盛装灵液器皿。",
|
||||
),
|
||||
statProfile: null,
|
||||
useProfile: null,
|
||||
@@ -708,8 +708,8 @@ function buildPotionDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}常见于江湖行囊,用于快速续战或调息。`,
|
||||
`${xianxiaName}多用于洞府与试炼前后,负责补元、聚灵与压缩冷却。`,
|
||||
`${wuxiaName}常见于边城模板的远行行囊,用于快速续战或调息。`,
|
||||
`${xianxiaName}多用于灵潮模板的据点与试炼前后,负责补元、聚灵与压缩冷却。`,
|
||||
),
|
||||
statProfile: null,
|
||||
useProfile,
|
||||
@@ -757,8 +757,8 @@ function buildGemDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}偏向江湖匠造、镶嵌与兵刃锻造。`,
|
||||
`${xianxiaName}更适合灵器镶嵌与灵力 build 的核心堆叠。`,
|
||||
`${wuxiaName}偏向边城模板里的匠造、镶嵌与兵刃锻造。`,
|
||||
`${xianxiaName}更适合灵潮模板里的灵器镶嵌与灵力 build 核心堆叠。`,
|
||||
),
|
||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||
useProfile: null,
|
||||
@@ -830,7 +830,7 @@ function buildSkillRelicDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName,
|
||||
xianxiaName,
|
||||
`${wuxiaName}适合在武侠世界里解释为武学秘卷、战术符印或绝招凭证。`,
|
||||
`${wuxiaName}适合在边城模板里解释为武学秘卷、战术符印或绝招凭证。`,
|
||||
`${xianxiaName}可作为功法玉简、灵术法印或灵能强化器,用于构筑法修流派。`,
|
||||
),
|
||||
statProfile: category === labels.relic ? buildRelicStats(rarity, role) : null,
|
||||
@@ -916,8 +916,8 @@ function buildUtilityDesign(
|
||||
worldProfiles: buildWorldProfiles(
|
||||
wuxiaName || readable,
|
||||
xianxiaName || readable,
|
||||
`${wuxiaName || readable}更适合武侠世界的江湖使用语境。`,
|
||||
`${xianxiaName || readable}更适合仙侠世界的灵物/法器语境。`,
|
||||
`${wuxiaName || readable}更适合边城模板的在地使用语境。`,
|
||||
`${xianxiaName || readable}更适合灵潮模板的灵物/法器语境。`,
|
||||
),
|
||||
statProfile,
|
||||
useProfile,
|
||||
|
||||
@@ -11,8 +11,8 @@ const STRUCTURAL_TAG_LABELS: Record<string, string> = {
|
||||
healing: '疗伤',
|
||||
mana: '法力',
|
||||
rare: '稀有',
|
||||
wuxia: '武侠',
|
||||
xianxia: '仙侠',
|
||||
wuxia: '边城模板',
|
||||
xianxia: '灵潮模板',
|
||||
neutral: '中性',
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ function resolvePublicAssetPath(assetPath: string) {
|
||||
}
|
||||
|
||||
describe('scene background assets', () => {
|
||||
it('ships background files for every wuxia and xianxia scene preset', () => {
|
||||
it('ships background files for every compatibility template scene preset', () => {
|
||||
const scenes = [
|
||||
...getScenePresetsByWorld(WorldType.WUXIA),
|
||||
...getScenePresetsByWorld(WorldType.XIANXIA),
|
||||
@@ -27,7 +27,7 @@ describe('scene background assets', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('returns existing default custom world backgrounds for both anchor worlds', () => {
|
||||
it('returns existing default custom world backgrounds for both compatibility templates', () => {
|
||||
const wuxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.WUXIA);
|
||||
const xianxiaImage = getDefaultCustomWorldSceneImage('seed', 0, WorldType.XIANXIA);
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('scene background assets', () => {
|
||||
'/generated-custom-world-scenes/test-world/generated-ruins.png';
|
||||
const profile: CustomWorldProfile = {
|
||||
id: 'custom-world-test',
|
||||
settingText: '荒城断碑与边关旧营并存的武侠世界',
|
||||
settingText: '荒城断碑与边关旧营并存的边城地界',
|
||||
name: '断碑边城',
|
||||
subtitle: '烽烟未熄',
|
||||
summary: '边关旧营与残城废墟彼此相望,玩家要追查旧案余烬。',
|
||||
|
||||
@@ -22,11 +22,14 @@ import {
|
||||
buildCustomWorldPlayableCharacters,
|
||||
getCharacterHomeSceneId,
|
||||
getCharacterNpcSceneIds,
|
||||
PRESET_CHARACTERS,
|
||||
ROLE_TEMPLATE_CHARACTERS,
|
||||
} from './characterPresets';
|
||||
import { resolveCustomWorldNpcMonsterPreset } from './customWorldNpcMonsters';
|
||||
import { getCustomWorldMonsterPresetPool } from './customWorldNpcMonsters';
|
||||
import { getRuntimeCustomWorldProfile, resolveRuleWorldType } from './customWorldRuntime';
|
||||
import {
|
||||
getRuntimeCustomWorldProfile,
|
||||
resolveCompatibilityTemplateWorldType,
|
||||
} from './customWorldRuntime';
|
||||
import {
|
||||
resolveCustomWorldCampSceneImage,
|
||||
resolveCustomWorldLandmarkImageMap,
|
||||
@@ -111,7 +114,8 @@ function buildImagePath(packName: string, imageNumber: number) {
|
||||
}
|
||||
|
||||
function collectWorldImagePool(worldType: WorldType, requiredCount: number) {
|
||||
const resolvedWorldType = resolveRuleWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const resolvedWorldType =
|
||||
resolveCompatibilityTemplateWorldType(worldType) ?? WorldType.WUXIA;
|
||||
const refs: string[] = [];
|
||||
let globalIndex = 0;
|
||||
|
||||
@@ -571,7 +575,7 @@ function resolveSceneNpcGender(
|
||||
function buildCharacterNpcPool(sceneId: string, worldType: WorldType) {
|
||||
const npcs: SceneNpc[] = [];
|
||||
|
||||
for (const character of PRESET_CHARACTERS) {
|
||||
for (const character of ROLE_TEMPLATE_CHARACTERS) {
|
||||
const characterId = character.id;
|
||||
const sceneIds = getCharacterNpcSceneIds(worldType, characterId);
|
||||
if (sceneIds.includes(sceneId)) {
|
||||
|
||||
@@ -2,7 +2,10 @@ import { resolveCustomWorldRuleProfile } from '../services/customWorldOwnedSetti
|
||||
import type {CustomWorldProfile, WorldAttributeSchema} from '../types';
|
||||
import {WorldType} from '../types';
|
||||
|
||||
export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType.CUSTOM>, WorldAttributeSchema> = {
|
||||
export const WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS: Record<
|
||||
Exclude<WorldType, WorldType.CUSTOM>,
|
||||
WorldAttributeSchema
|
||||
> = {
|
||||
[WorldType.WUXIA]: {
|
||||
id: 'schema:wuxia:v1',
|
||||
worldId: WorldType.WUXIA,
|
||||
@@ -155,8 +158,10 @@ export const PRESET_WORLD_ATTRIBUTE_SCHEMAS: Record<Exclude<WorldType, WorldType
|
||||
},
|
||||
};
|
||||
|
||||
export function getPresetWorldAttributeSchema(worldType: Exclude<WorldType, WorldType.CUSTOM>) {
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[worldType];
|
||||
export function getTemplateWorldAttributeSchema(
|
||||
worldType: Exclude<WorldType, WorldType.CUSTOM>,
|
||||
) {
|
||||
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[worldType];
|
||||
}
|
||||
|
||||
export function getWorldAttributeSchema(
|
||||
@@ -171,8 +176,8 @@ export function getWorldAttributeSchema(
|
||||
}
|
||||
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
|
||||
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.XIANXIA];
|
||||
}
|
||||
|
||||
return PRESET_WORLD_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
|
||||
return WORLD_TEMPLATE_ATTRIBUTE_SCHEMAS[WorldType.WUXIA];
|
||||
}
|
||||
|
||||
@@ -9,24 +9,15 @@ describe('matchAppRoute', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('routes item editor paths to the preset editor items tab', () => {
|
||||
it('routes deprecated editor paths back to the main game', () => {
|
||||
expect(matchAppRoute('/item-editor/tools')).toEqual({
|
||||
kind: 'preset-editor',
|
||||
initialTab: 'items',
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('routes behavior editor paths to the functions tab', () => {
|
||||
expect(matchAppRoute('/behavior-editor')).toEqual({
|
||||
kind: 'preset-editor',
|
||||
initialTab: 'functions',
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts nested preset editor paths with trailing slashes', () => {
|
||||
expect(matchAppRoute('/NPC-EDITOR/profiles/')).toEqual({
|
||||
kind: 'preset-editor',
|
||||
initialTab: 'npcs',
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,4 +26,10 @@ describe('matchAppRoute', () => {
|
||||
kind: 'game',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the sprite tool route', () => {
|
||||
expect(matchAppRoute('/sprite-tool')).toEqual({
|
||||
kind: 'qwen-sprite-tool',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import { type ComponentType, lazy, type LazyExoticComponent } from 'react';
|
||||
|
||||
import type { PresetEditorTab } from '../components/PresetEditor';
|
||||
|
||||
type AppRouteComponent = LazyExoticComponent<
|
||||
ComponentType<Record<string, unknown>>
|
||||
>;
|
||||
@@ -12,10 +10,6 @@ export type AppRouteMatch =
|
||||
| {
|
||||
kind: 'game';
|
||||
}
|
||||
| {
|
||||
kind: 'preset-editor';
|
||||
initialTab: PresetEditorTab;
|
||||
}
|
||||
| {
|
||||
kind: 'qwen-sprite-tool';
|
||||
};
|
||||
@@ -29,43 +23,10 @@ export type ResolvedAppRoute = {
|
||||
};
|
||||
|
||||
const GameApp = lazy(() => import('../AuthenticatedApp')) as AppRouteComponent;
|
||||
const PresetEditorApp = lazy(async () => {
|
||||
const module = await import('../components/PresetEditor');
|
||||
|
||||
return {
|
||||
default: module.PresetEditor,
|
||||
};
|
||||
}) as AppRouteComponent;
|
||||
const QwenSpriteToolApp = lazy(
|
||||
() => import('../tools/QwenSpriteSheetTool'),
|
||||
) as AppRouteComponent;
|
||||
|
||||
const PRESET_EDITOR_ROUTES: Array<{
|
||||
prefixes: string[];
|
||||
initialTab: PresetEditorTab;
|
||||
}> = [
|
||||
{
|
||||
prefixes: ['/character-asset-studio', '/asset-studio'],
|
||||
initialTab: 'assets',
|
||||
},
|
||||
{
|
||||
prefixes: ['/function-editor', '/behavior-editor'],
|
||||
initialTab: 'functions',
|
||||
},
|
||||
{
|
||||
prefixes: ['/item-editor'],
|
||||
initialTab: 'items',
|
||||
},
|
||||
{
|
||||
prefixes: ['/npc-editor'],
|
||||
initialTab: 'npcs',
|
||||
},
|
||||
{
|
||||
prefixes: ['/preset-editor'],
|
||||
initialTab: 'characters',
|
||||
},
|
||||
];
|
||||
|
||||
const QWEN_SPRITE_TOOL_PREFIXES = [
|
||||
'/qwen-sprite-tool',
|
||||
'/sprite-tool',
|
||||
@@ -102,19 +63,6 @@ export function matchAppRoute(pathname: string): AppRouteMatch {
|
||||
};
|
||||
}
|
||||
|
||||
const presetRoute = PRESET_EDITOR_ROUTES.find((route) =>
|
||||
route.prefixes.some((prefix) =>
|
||||
matchesRoutePrefix(normalizedPathname, prefix),
|
||||
),
|
||||
);
|
||||
|
||||
if (presetRoute) {
|
||||
return {
|
||||
kind: 'preset-editor',
|
||||
initialTab: presetRoute.initialTab,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
};
|
||||
@@ -132,18 +80,6 @@ export function resolveAppRoute(pathname: string): ResolvedAppRoute {
|
||||
};
|
||||
}
|
||||
|
||||
if (matchedRoute.kind === 'preset-editor') {
|
||||
return {
|
||||
kind: matchedRoute.kind,
|
||||
loadingEyebrow: '正在载入编辑器',
|
||||
loadingText: '正在载入编辑器...',
|
||||
Component: PresetEditorApp,
|
||||
componentProps: {
|
||||
initialTab: matchedRoute.initialTab,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'game',
|
||||
loadingEyebrow: '正在载入游戏',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {validateWorldAttributeSchema} from '../data/attributeValidation';
|
||||
import {getPresetWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
||||
import {getTemplateWorldAttributeSchema} from '../data/worldAttributeSchemas';
|
||||
import type {
|
||||
AttributeSchemaGenerationInput,
|
||||
WorldAttributeSchema,
|
||||
@@ -96,17 +96,17 @@ function buildCustomThemeSlots(input: AttributeSchemaGenerationInput) {
|
||||
|
||||
return {
|
||||
schemaName: '叙境六维',
|
||||
slots: getPresetWorldAttributeSchema(WorldType.WUXIA).slots,
|
||||
slots: getTemplateWorldAttributeSchema(WorldType.WUXIA).slots,
|
||||
};
|
||||
}
|
||||
|
||||
export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInput) {
|
||||
if (input.worldType === WorldType.WUXIA) {
|
||||
return getPresetWorldAttributeSchema(WorldType.WUXIA);
|
||||
return getTemplateWorldAttributeSchema(WorldType.WUXIA);
|
||||
}
|
||||
|
||||
if (input.worldType === WorldType.XIANXIA) {
|
||||
return getPresetWorldAttributeSchema(WorldType.XIANXIA);
|
||||
return getTemplateWorldAttributeSchema(WorldType.XIANXIA);
|
||||
}
|
||||
|
||||
const generated = buildCustomThemeSlots(input);
|
||||
@@ -116,7 +116,7 @@ export function generateWorldAttributeSchema(input: AttributeSchemaGenerationInp
|
||||
if (issues.length > 0) {
|
||||
const fallbackWorldType = /仙|灵|宗门|秘境|裂界/u.test(input.settingText) ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
return {
|
||||
...getPresetWorldAttributeSchema(fallbackWorldType),
|
||||
...getTemplateWorldAttributeSchema(fallbackWorldType),
|
||||
id: `schema:custom-fallback:${input.worldName}`,
|
||||
worldId: `custom:${input.worldName}`,
|
||||
generatedFrom: {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
createAutoAuthCredentials,
|
||||
ensureAutoAuthUser,
|
||||
getAuthAuditLogs,
|
||||
getAuthLoginOptions,
|
||||
getAuthRiskBlocks,
|
||||
getAuthSessions,
|
||||
getCaptchaChallengeFromError,
|
||||
@@ -339,6 +340,23 @@ describe('authService auto auth', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('loads available login methods for the unauthenticated login screen', async () => {
|
||||
requestJsonMock.mockResolvedValue({
|
||||
availableLoginMethods: ['phone', 'wechat'],
|
||||
});
|
||||
|
||||
const result = await getAuthLoginOptions();
|
||||
|
||||
expect(result.availableLoginMethods).toEqual(['phone', 'wechat']);
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/auth/login-options',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
}),
|
||||
'读取登录方式失败',
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes auth callback hash and stores token', () => {
|
||||
const replaceStateMock = vi.fn();
|
||||
vi.stubGlobal('window', {
|
||||
|
||||
@@ -3,8 +3,9 @@ import type {
|
||||
AuthAuditLogsResponse,
|
||||
AuthCaptchaChallenge,
|
||||
AuthEntryResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLoginMethod,
|
||||
AuthLoginOptionsResponse,
|
||||
AuthLiftRiskBlockResponse,
|
||||
AuthLogoutAllResponse,
|
||||
AuthMeResponse,
|
||||
AuthPhoneChangeResponse,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
} from './apiClient';
|
||||
|
||||
export type { AuthUser } from '../../packages/shared/src/contracts/auth';
|
||||
export type { AuthLoginMethod } from '../../packages/shared/src/contracts/auth';
|
||||
|
||||
export type AutoAuthCredentials = {
|
||||
username: string;
|
||||
@@ -207,6 +209,16 @@ export async function startWechatLogin() {
|
||||
window.location.assign(response.authorizationUrl);
|
||||
}
|
||||
|
||||
export async function getAuthLoginOptions() {
|
||||
return requestJson<AuthLoginOptionsResponse>(
|
||||
'/api/auth/login-options',
|
||||
{
|
||||
method: 'GET',
|
||||
},
|
||||
'读取登录方式失败',
|
||||
);
|
||||
}
|
||||
|
||||
export async function authEntry(username: string, password: string) {
|
||||
const credentials = normalizeCredentials({ username, password });
|
||||
const response = await requestJson<AuthEntryResponse>(
|
||||
|
||||
@@ -59,8 +59,8 @@ export const CHARACTER_PANEL_CHAT_SUMMARY_SYSTEM_PROMPT = `总结玩家与这名
|
||||
包含当前关系气氛、态度变化,以及最近聊天里最重要的新信息、承诺、担忧或线索。`;
|
||||
|
||||
function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '武侠';
|
||||
if (world === WorldType.XIANXIA) return '仙侠';
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ export interface CustomWorldGenerationFramework {
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: WorldType;
|
||||
compatibilityTemplateWorldType: WorldType;
|
||||
majorFactions: string[];
|
||||
coreConflicts: string[];
|
||||
camp: CustomWorldGenerationCampOutline;
|
||||
@@ -619,6 +620,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary],
|
||||
attributeSchema: generateWorldAttributeSchema({
|
||||
@@ -674,6 +676,8 @@ export function normalizeCustomWorldGenerationFramework(
|
||||
tone: fallback.tone,
|
||||
playerGoal: fallback.playerGoal,
|
||||
templateWorldType: fallback.templateWorldType,
|
||||
compatibilityTemplateWorldType:
|
||||
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [fallback.summary],
|
||||
camp: {
|
||||
@@ -710,6 +714,7 @@ export function normalizeCustomWorldGenerationFramework(
|
||||
tone: toText(item.tone) || fallback.tone,
|
||||
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
|
||||
camp: normalizeCampOutline(item.camp, {
|
||||
@@ -744,6 +749,7 @@ export function buildCustomWorldRawProfileFromFramework(
|
||||
tone: framework.tone,
|
||||
playerGoal: framework.playerGoal,
|
||||
templateWorldType: framework.templateWorldType,
|
||||
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
|
||||
majorFactions: framework.majorFactions,
|
||||
coreConflicts: framework.coreConflicts,
|
||||
camp: {
|
||||
@@ -1136,6 +1142,7 @@ export function normalizeCustomWorldProfile(
|
||||
tone,
|
||||
playerGoal,
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||
attributeSchema: coerceWorldAttributeSchema(
|
||||
|
||||
@@ -8,6 +8,7 @@ import { normalizeCustomWorldLandmarks } from '../data/customWorldSceneGraph';
|
||||
import { CustomWorldProfile, WorldType } from '../types';
|
||||
import { normalizeCustomWorldProfile } from './customWorld';
|
||||
import { normalizeCustomWorldOwnedSettingLayers } from './customWorldOwnedSettingLayers';
|
||||
import { resolveCustomWorldCompatibilityTemplateWorldType } from './customWorldTheme';
|
||||
import {
|
||||
buildFallbackActorNarrativeProfile,
|
||||
normalizeActorNarrativeProfile,
|
||||
@@ -161,7 +162,9 @@ export function buildExpandedCustomWorldProfile(
|
||||
description: clampText(landmark.description, 96),
|
||||
dangerLevel:
|
||||
landmark.dangerLevel ||
|
||||
(profile.templateWorldType === WorldType.XIANXIA ? 'high' : 'medium'),
|
||||
(resolveCustomWorldCompatibilityTemplateWorldType(profile) === WorldType.XIANXIA
|
||||
? 'high'
|
||||
: 'medium'),
|
||||
}));
|
||||
const landmarkIdByReference = new Map<string, string>();
|
||||
landmarkDrafts.forEach((landmark) => {
|
||||
|
||||
@@ -12,7 +12,11 @@ import {
|
||||
type SceneArchetypeBucket,
|
||||
WorldType,
|
||||
} from '../types';
|
||||
import { type CustomWorldThemeMode, detectCustomWorldThemeMode } from './customWorldTheme';
|
||||
import {
|
||||
type CustomWorldThemeMode,
|
||||
detectCustomWorldThemeMode,
|
||||
resolveCustomWorldCompatibilityTemplateWorldType,
|
||||
} from './customWorldTheme';
|
||||
import {
|
||||
buildThemePackFromWorldProfile,
|
||||
normalizeThemePack,
|
||||
@@ -407,7 +411,7 @@ function buildThemePackSeed(profile: CustomWorldProfile) {
|
||||
summary: profile.summary,
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
templateWorldType: resolveCustomWorldCompatibilityTemplateWorldType(profile),
|
||||
majorFactions: profile.majorFactions,
|
||||
coreConflicts: profile.coreConflicts,
|
||||
ownedSettingLayers: null,
|
||||
@@ -502,8 +506,12 @@ function compileReferenceProfile(
|
||||
}
|
||||
|
||||
function compileCompatibilityProfile(profile: CustomWorldProfile) {
|
||||
const compatibilityTemplateWorldType =
|
||||
resolveCustomWorldCompatibilityTemplateWorldType(profile);
|
||||
|
||||
return {
|
||||
legacyTemplateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType,
|
||||
legacyTemplateWorldType: compatibilityTemplateWorldType,
|
||||
migrationVersion: OWNED_SETTING_LAYER_MIGRATION_VERSION,
|
||||
} satisfies CustomWorldCompatibilityProfile;
|
||||
}
|
||||
@@ -629,6 +637,8 @@ export function compileOwnedSettingLayersFromLegacyTemplate(
|
||||
tone: profile.tone,
|
||||
playerGoal: profile.playerGoal,
|
||||
templateWorldType: profile.templateWorldType,
|
||||
compatibilityTemplateWorldType:
|
||||
profile.compatibilityTemplateWorldType ?? profile.templateWorldType,
|
||||
ownedSettingLayers: null,
|
||||
});
|
||||
const semanticAnchor = compileSemanticAnchor(profile, mode);
|
||||
@@ -920,6 +930,12 @@ export function normalizeCustomWorldOwnedSettingLayers(
|
||||
),
|
||||
},
|
||||
compatibilityProfile: {
|
||||
compatibilityTemplateWorldType:
|
||||
compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
: compatibilityProfileItem.compatibilityTemplateWorldType === WorldType.WUXIA
|
||||
? WorldType.WUXIA
|
||||
: fallback.compatibilityProfile?.compatibilityTemplateWorldType ?? null,
|
||||
legacyTemplateWorldType:
|
||||
compatibilityProfileItem.legacyTemplateWorldType === WorldType.XIANXIA
|
||||
? WorldType.XIANXIA
|
||||
|
||||
@@ -16,6 +16,7 @@ export function detectCustomWorldThemeMode(
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
): CustomWorldThemeMode {
|
||||
@@ -45,17 +46,36 @@ export function detectCustomWorldThemeMode(
|
||||
return 'mythic';
|
||||
}
|
||||
|
||||
export function resolveCustomWorldAnchorWorldType(
|
||||
export function resolveCustomWorldCompatibilityTemplateWorldType(
|
||||
profile: Pick<
|
||||
CustomWorldProfile,
|
||||
| 'settingText'
|
||||
| 'summary'
|
||||
| 'tone'
|
||||
| 'playerGoal'
|
||||
| 'templateWorldType'
|
||||
| 'compatibilityTemplateWorldType'
|
||||
| 'ownedSettingLayers'
|
||||
>,
|
||||
> &
|
||||
Partial<
|
||||
Pick<
|
||||
CustomWorldProfile,
|
||||
'settingText' | 'summary' | 'tone' | 'playerGoal'
|
||||
>
|
||||
>,
|
||||
): WorldTemplateType {
|
||||
if (
|
||||
profile.compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||
profile.compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return profile.compatibilityTemplateWorldType;
|
||||
}
|
||||
|
||||
const compatibilityTemplateWorldType =
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.compatibilityTemplateWorldType;
|
||||
if (
|
||||
compatibilityTemplateWorldType === WorldType.WUXIA ||
|
||||
compatibilityTemplateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return compatibilityTemplateWorldType;
|
||||
}
|
||||
|
||||
const legacyTemplateWorldType =
|
||||
profile.ownedSettingLayers?.compatibilityProfile?.legacyTemplateWorldType;
|
||||
|
||||
@@ -66,6 +86,24 @@ export function resolveCustomWorldAnchorWorldType(
|
||||
return legacyTemplateWorldType;
|
||||
}
|
||||
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
if (
|
||||
profile.templateWorldType === WorldType.WUXIA ||
|
||||
profile.templateWorldType === WorldType.XIANXIA
|
||||
) {
|
||||
return profile.templateWorldType;
|
||||
}
|
||||
|
||||
const themeMode = detectCustomWorldThemeMode({
|
||||
settingText: profile.settingText ?? '',
|
||||
summary: profile.summary ?? '',
|
||||
tone: profile.tone ?? '',
|
||||
playerGoal: profile.playerGoal ?? '',
|
||||
templateWorldType: profile.templateWorldType ?? WorldType.WUXIA,
|
||||
compatibilityTemplateWorldType: profile.compatibilityTemplateWorldType,
|
||||
ownedSettingLayers: profile.ownedSettingLayers,
|
||||
});
|
||||
return themeMode === 'arcane' ? WorldType.XIANXIA : WorldType.WUXIA;
|
||||
}
|
||||
|
||||
export const resolveCustomWorldAnchorWorldType =
|
||||
resolveCustomWorldCompatibilityTemplateWorldType;
|
||||
|
||||
@@ -469,8 +469,8 @@ function describeAnimationLabel(animation: string | null | undefined) {
|
||||
}
|
||||
|
||||
export function describeWorld(world: WorldType) {
|
||||
if (world === WorldType.WUXIA) return '武侠';
|
||||
if (world === WorldType.XIANXIA) return '仙侠';
|
||||
if (world === WorldType.WUXIA) return '边城模板';
|
||||
if (world === WorldType.XIANXIA) return '灵潮模板';
|
||||
return '自定义世界';
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import { buildQuestVisibilitySlice } from './storyEngine/visibilityEngine';
|
||||
function describeWorld(worldType: QuestGenerationContext['worldType']) {
|
||||
switch (worldType) {
|
||||
case 'WUXIA':
|
||||
return '武侠';
|
||||
return '边城模板';
|
||||
case 'XIANXIA':
|
||||
return '仙侠';
|
||||
return '灵潮模板';
|
||||
case 'CUSTOM':
|
||||
return '自定义世界';
|
||||
default:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,8 +16,8 @@ import {
|
||||
buildAnimationClipFromVideoSource,
|
||||
GENERATED_FRAME_HEIGHT,
|
||||
GENERATED_FRAME_WIDTH,
|
||||
} from '../components/preset-editor/characterAssetStudioModel';
|
||||
import { generateCharacterAnimationDraft } from '../components/preset-editor/characterAssetStudioPersistence';
|
||||
} from '../components/asset-studio/characterAssetWorkflowModel';
|
||||
import { generateCharacterAnimationDraft } from '../components/asset-studio/characterAssetWorkflowPersistence';
|
||||
import {
|
||||
NumberField,
|
||||
SelectField,
|
||||
|
||||
@@ -170,6 +170,7 @@ export interface CustomWorldExpressionProfile {
|
||||
}
|
||||
|
||||
export interface CustomWorldCompatibilityProfile {
|
||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||
legacyTemplateWorldType?: WorldTemplateType | null;
|
||||
migrationVersion: string;
|
||||
}
|
||||
@@ -319,6 +320,7 @@ export interface CustomWorldProfile {
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
templateWorldType: WorldTemplateType;
|
||||
compatibilityTemplateWorldType?: WorldTemplateType | null;
|
||||
majorFactions: string[];
|
||||
coreConflicts: string[];
|
||||
attributeSchema: WorldAttributeSchema;
|
||||
|
||||
@@ -46,9 +46,9 @@ export function getNineSliceStyle(
|
||||
return style;
|
||||
}
|
||||
|
||||
export const WORLD_SELECT_ICONS = {
|
||||
wuxia: '/Icons/38_sword.png',
|
||||
xianxia: '/Icons/72_magic.png',
|
||||
export const CUSTOM_WORLD_THEME_ICONS = {
|
||||
martial: '/Icons/38_sword.png',
|
||||
arcane: '/Icons/72_magic.png',
|
||||
} as const;
|
||||
|
||||
export const BRAND_ASSETS = {
|
||||
@@ -58,18 +58,6 @@ export const BRAND_ASSETS = {
|
||||
export const UI_CHROME = {
|
||||
appBackground: '/UI/Background_fill.png',
|
||||
// 图源 125×28:上下 slice 之和必须 < 28,否则中间行高度为 0,border-image fill 失效(见 UI_CODING_STANDARD.md)
|
||||
worldButtonWuxia: {
|
||||
src: '/UI/1_orange_button.png',
|
||||
slice: { top: 9, right: 14, bottom: 9, left: 14 },
|
||||
padding: { x: 18, y: 12 },
|
||||
repeat: 'stretch',
|
||||
},
|
||||
worldButtonXianxia: {
|
||||
src: '/UI/1_violet_button.png',
|
||||
slice: { top: 9, right: 14, bottom: 9, left: 14 },
|
||||
padding: { x: 18, y: 12 },
|
||||
repeat: 'stretch',
|
||||
},
|
||||
characterCardFrame: {
|
||||
src: '/UI/pick_hero_frame.png',
|
||||
slice: { top: 18, right: 18, bottom: 18, left: 18 },
|
||||
|
||||
Reference in New Issue
Block a user