diff --git a/.gitignore b/.gitignore index 60b1c10..1ca1b3d 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ Thumbs.db # Local Claude settings .claude/settings.local.json +test-src/ \ No newline at end of file diff --git a/ActionData.json b/ActionData.json index c0c3562..b366b90 100644 --- a/ActionData.json +++ b/ActionData.json @@ -562,5 +562,53 @@ } } ] + }, + { + "id": "Fuck_u", + "name": "发Q", + "command": "Q", + "animStateName": "Fuck_u", + "animBegin": "0", + "animEnd": "1", + "animSpeed": 1, + "dirChangeable": false, + "TimelineDatas": [ + { + "$type": "MoveStateData, Assembly-CSharp", + "timingBegin": 0, + "timingEnd": 1, + "useGhostLayer": true, + "useGravity": true, + "useCommand": false, + "moveVelMultiAddition": 0, + "useRootMotion": false + }, + { + "$type": "JointCollData, Assembly-CSharp", + "timingBegin": 0, + "timingEnd": 1, + "joints": [], + "battleData": { + "damage": 0, + "damageInterval": 1, + "damageDamping": 0, + "criticalRateEx": 0, + "makeBreak": false, + "impartType": 0 + } + } + ], + "nextActionId": "Fuck_u", + "derivations": [ + { + "priority": 0, + "checkPeriod": { + "min": 0, + "max": 1 + }, + "fastExitTime": 0, + "nextActionId": "" + } + ] } ] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d731f69..56adfe7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,8 +1,57 @@ +use serde::Serialize; +use std::fs; +use std::path::Path; + +#[derive(Serialize)] +struct SchemaSource { + path: String, + contents: String, +} + +fn is_schema_file_name(file_name: &str) -> bool { + file_name == "ActionData.cs" || (file_name.ends_with("Data.cs") && !file_name.ends_with(".Designer.cs")) +} + +#[tauri::command] +fn load_timeline_schema_sources(schema_directory_path: String) -> Result, String> { + let directory = Path::new(&schema_directory_path); + + let mut schema_paths = fs::read_dir(directory) + .map_err(|error| format!("Failed to read schema directory: {error}"))? + .filter_map(|entry| entry.ok()) + .filter_map(|entry| { + let file_name = entry.file_name(); + let file_name = file_name.to_str()?; + if entry.file_type().ok()?.is_file() && is_schema_file_name(file_name) { + Some(entry.path()) + } else { + None + } + }) + .collect::>(); + + schema_paths.sort(); + + schema_paths + .into_iter() + .map(|path| { + let contents = fs::read_to_string(&path) + .map_err(|error| format!("Failed to read schema file {}: {error}", path.display()))?; + + Ok(SchemaSource { + path: path.to_string_lossy().into_owned(), + contents, + }) + }) + .collect() +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) + .invoke_handler(tauri::generate_handler![load_timeline_schema_sources]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/components/actions/ActionEditor.tsx b/src/components/actions/ActionEditor.tsx index e3d7942..4cea6f9 100644 --- a/src/components/actions/ActionEditor.tsx +++ b/src/components/actions/ActionEditor.tsx @@ -9,7 +9,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { DerivationEditor } from "./DerivationEditor"; import { TimelineEditor } from "@/components/timelines/TimelineEditor"; import type { ActionData, ActionDerivation, NumericValue, TimelinePatch } from "@/models/actionData"; -import type { KnownTimelineType } from "@/models/timelineTypes"; const actionFormSchema = z.object({ id: z.string().optional(), @@ -32,7 +31,7 @@ interface ActionEditorProps { highlightedValidationPath: string | null; onUpdateAction: (patch: Partial) => void; onSelectTimeline: (id: string) => void; - onAddTimeline: (type: KnownTimelineType) => void; + onAddTimeline: (type: string) => void; onUpdateTimeline: (timelineId: string, patch: TimelinePatch) => void; onDuplicateTimeline: (timelineId: string) => void; onDeleteTimeline: (timelineId: string) => void; diff --git a/src/components/actions/DerivationEditor.tsx b/src/components/actions/DerivationEditor.tsx index f75f61b..979c279 100644 --- a/src/components/actions/DerivationEditor.tsx +++ b/src/components/actions/DerivationEditor.tsx @@ -23,8 +23,8 @@ export const DerivationEditor = ({ derivations, actionIndex, highlightedValidati
-

Derivations

-

配置连招派生窗口与下一个动作。

+

Derivations

+

配置连招派生窗口与下一个动作。

+ + ); +}; diff --git a/src/components/layout/AppShell.tsx b/src/components/layout/AppShell.tsx index 53e1832..94398b2 100644 --- a/src/components/layout/AppShell.tsx +++ b/src/components/layout/AppShell.tsx @@ -31,8 +31,11 @@ export const AppShell = () => { }; useEffect(() => { - void store.restoreLastFile(); - }, []); + void (async () => { + await store.restoreLastSchemaDirectory(); + await store.restoreLastFile(); + })(); + }, []); useEffect(() => { if (!highlightedValidationPath) { @@ -67,10 +70,12 @@ export const AppShell = () => {
{ onDelete={store.deleteAction} onMove={store.moveAction} /> -
-
-
- selectedAction && store.updateAction(selectedAction.__editorId, patch)} - onSelectTimeline={store.selectTimeline} - onAddTimeline={(type) => selectedAction && store.addTimeline(selectedAction.__editorId, type)} - onUpdateTimeline={(timelineId, patch) => selectedAction && store.updateTimeline(selectedAction.__editorId, timelineId, patch)} - onDuplicateTimeline={(timelineId) => selectedAction && store.duplicateTimeline(selectedAction.__editorId, timelineId)} - onDeleteTimeline={(timelineId) => selectedAction && store.deleteTimeline(selectedAction.__editorId, timelineId)} - onMoveTimeline={(timelineId, direction) => selectedAction && store.moveTimeline(selectedAction.__editorId, timelineId, direction)} - onUpdateDerivations={(derivations) => selectedAction && store.updateDerivations(selectedAction.__editorId, derivations)} - /> +
+
+
+
+ selectedAction && store.updateAction(selectedAction.__editorId, patch)} + onSelectTimeline={store.selectTimeline} + onAddTimeline={(type) => selectedAction && store.addTimeline(selectedAction.__editorId, type)} + onUpdateTimeline={(timelineId, patch) => selectedAction && store.updateTimeline(selectedAction.__editorId, timelineId, patch)} + onDuplicateTimeline={(timelineId) => selectedAction && store.duplicateTimeline(selectedAction.__editorId, timelineId)} + onDeleteTimeline={(timelineId) => selectedAction && store.deleteTimeline(selectedAction.__editorId, timelineId)} + onMoveTimeline={(timelineId, direction) => selectedAction && store.moveTimeline(selectedAction.__editorId, timelineId, direction)} + onUpdateDerivations={(derivations) => selectedAction && store.updateDerivations(selectedAction.__editorId, derivations)} + /> +
diff --git a/src/components/layout/Toolbar.tsx b/src/components/layout/Toolbar.tsx index e45d79b..433721b 100644 --- a/src/components/layout/Toolbar.tsx +++ b/src/components/layout/Toolbar.tsx @@ -3,16 +3,29 @@ import { Button } from "@/components/ui/button"; interface ToolbarProps { filePath: string | null; + schemaDirectoryPath: string | null; dirty: boolean; issueCount: number; errorCount: number; onOpen: () => void; + onOpenSchemaDirectory: () => void; onSave: () => void; onSaveAs: () => void; onValidate: () => void; } -export const Toolbar = ({ filePath, dirty, issueCount, errorCount, onOpen, onSave, onSaveAs, onValidate }: ToolbarProps) => ( +export const Toolbar = ({ + filePath, + schemaDirectoryPath, + dirty, + issueCount, + errorCount, + onOpen, + onOpenSchemaDirectory, + onSave, + onSaveAs, + onValidate, +}: ToolbarProps) => (

ActionData Studio

@@ -21,6 +34,10 @@ export const Toolbar = ({ filePath, dirty, issueCount, errorCount, onOpen, onSav {filePath ?? "未打开 ActionData.json"} {dirty ? UNSAVED : null}
+
+ + {schemaDirectoryPath ?? "未加载 CS 文件夹"} +
@@ -30,10 +47,26 @@ export const Toolbar = ({ filePath, dirty, issueCount, errorCount, onOpen, onSav
- - - - + + + + +
diff --git a/src/components/timelines/SchemaTimelineFields.tsx b/src/components/timelines/SchemaTimelineFields.tsx new file mode 100644 index 0000000..7113817 --- /dev/null +++ b/src/components/timelines/SchemaTimelineFields.tsx @@ -0,0 +1,260 @@ +import { useEffect, useState } from "react"; +import { BooleanField } from "@/components/fields/BooleanField"; +import { JsonTextArea } from "@/components/fields/JsonTextArea"; +import { NumberField } from "@/components/fields/NumberField"; +import { SelectField } from "@/components/fields/SelectField"; +import { StringArrayField } from "@/components/fields/StringArrayField"; +import { TextField } from "@/components/fields/TextField"; +import type { TimelineData } from "@/models/actionData"; +import { cn } from "@/lib/utils"; +import { + humanizeIdentifier, + type CSharpFieldSchema, + type CSharpTypeSchema, + type TimelineSchemaRegistry, +} from "@/schema/csharpTimelineSchema"; + +interface SchemaTimelineFieldsProps { + schema: CSharpTypeSchema; + timeline: TimelineData; + basePath: string; + highlightedValidationPath: string | null; + registry: TimelineSchemaRegistry; + onChange: (patch: Partial) => void; +} + +interface UnknownJsonFieldProps { + label: string; + value: unknown; + validationPath: string; + highlighted: boolean; + onChange: (value: unknown) => void; +} + +const cloneValue = (value: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +}; + +const setNestedValue = (target: Record, path: string[], value: unknown) => { + let cursor = target; + for (let index = 0; index < path.length - 1; index += 1) { + const segment = path[index]; + const next = cursor[segment]; + cursor[segment] = next && typeof next === "object" && !Array.isArray(next) ? cloneValue(next) : {}; + cursor = cursor[segment] as Record; + } + cursor[path[path.length - 1]] = value; +}; + +const isPathHighlighted = (highlightedValidationPath: string | null, path: string) => + highlightedValidationPath === path || Boolean(highlightedValidationPath?.startsWith(`${path}.`)); + +const UnknownJsonField = ({ label, value, validationPath, highlighted, onChange }: UnknownJsonFieldProps) => { + const [rawJson, setRawJson] = useState(() => JSON.stringify(value ?? null, null, 2)); + const [jsonError, setJsonError] = useState(null); + + useEffect(() => { + setRawJson(JSON.stringify(value ?? null, null, 2)); + setJsonError(null); + }, [value]); + + return ( + { + setRawJson(nextValue); + try { + const parsed = JSON.parse(nextValue); + setJsonError(null); + onChange(parsed); + } catch (error) { + setJsonError(error instanceof Error ? error.message : "JSON 解析失败。"); + } + }} + error={jsonError} + /> + ); +}; + +const DynamicFieldGroup = ({ + fields, + value, + basePath, + highlightedValidationPath, + registry, + onChange, +}: { + fields: CSharpFieldSchema[]; + value: Record; + basePath: string; + highlightedValidationPath: string | null; + registry: TimelineSchemaRegistry; + onChange: (path: string[], value: unknown) => void; +}) => { + const structuredFields = fields.filter((field) => field.kind === "object"); + const flatFields = fields.filter((field) => field.kind !== "object"); + + return ( +
+ {flatFields.length > 0 ? ( +
+ {flatFields.map((field) => { + const fieldPath = `${basePath}.${field.name}`; + const fieldValue = value[field.name]; + const highlighted = isPathHighlighted(highlightedValidationPath, fieldPath); + const label = humanizeIdentifier(field.name); + + switch (field.kind) { + case "boolean": + return ( + onChange([field.name], nextValue)} + /> + ); + case "number": + return ( + onChange([field.name], nextValue)} + /> + ); + case "string": + return ( + onChange([field.name], nextValue)} + /> + ); + case "stringArray": + return ( + typeof item === "string") : []} + validationPath={fieldPath} + highlighted={highlighted} + onChange={(nextValue) => onChange([field.name], nextValue)} + /> + ); + case "enum": + return ( + ({ value: String(index), label: option }))} + onChange={(nextValue) => onChange([field.name], Number(nextValue))} + /> + ); + default: + return ( +
+ onChange([field.name], nextValue)} + /> +
+ ); + } + })} +
+ ) : null} + + {structuredFields.map((field) => { + const fieldPath = `${basePath}.${field.name}`; + const nestedValue = value[field.name]; + const nestedSchema = registry.types[field.typeName]; + + if (!nestedSchema) { + return ( + onChange([field.name], nextValue)} + /> + ); + } + + return ( +
+
+ {humanizeIdentifier(field.name)} +
+ | undefined) ?? {}} + basePath={fieldPath} + highlightedValidationPath={highlightedValidationPath} + registry={registry} + onChange={(nestedPath, nextValue) => onChange([field.name, ...nestedPath], nextValue)} + /> +
+ ); + })} +
+ ); +}; + +export const SchemaTimelineFields = ({ + schema, + timeline, + basePath, + highlightedValidationPath, + registry, + onChange, +}: SchemaTimelineFieldsProps) => ( + } + basePath={basePath} + highlightedValidationPath={highlightedValidationPath} + registry={registry} + onChange={(path, nextValue) => { + if (path.length === 1) { + onChange({ [path[0]]: nextValue } as Partial); + return; + } + + const topLevelKey = path[0]; + const nextTopLevelValue = cloneValue( + ((timeline as Record)[topLevelKey] as Record | undefined) ?? {}, + ); + setNestedValue(nextTopLevelValue, path.slice(1), nextValue); + onChange({ [topLevelKey]: nextTopLevelValue } as Partial); + }} + /> +); diff --git a/src/components/timelines/TimelineEditor.tsx b/src/components/timelines/TimelineEditor.tsx index 19a1c75..1a6b5b3 100644 --- a/src/components/timelines/TimelineEditor.tsx +++ b/src/components/timelines/TimelineEditor.tsx @@ -1,10 +1,13 @@ -import { Copy, Plus, Trash2, ArrowDown, ArrowUp } from "lucide-react"; +import { useEffect, useState } from "react"; +import { ArrowDown, ArrowUp, Copy, Plus, RefreshCw, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Select } from "@/components/ui/select"; +import { useEditorStore } from "@/store/editorStore"; import { TimelineItemEditor } from "./TimelineItemEditor"; import type { ActionData } from "@/models/actionData"; -import { TIMELINE_TYPE_OPTIONS, type KnownTimelineType, timelineTypeLabel } from "@/models/timelineTypes"; +import { TIMELINE_TYPE_OPTIONS, timelineTypeLabel } from "@/models/timelineTypes"; import { cn } from "@/lib/utils"; +import { getTimelineSchemaForType, getTimelineTypeOptions, humanizeIdentifier } from "@/schema/csharpTimelineSchema"; interface TimelineEditorProps { action: ActionData; @@ -12,7 +15,7 @@ interface TimelineEditorProps { selectedTimelineId: string | null; highlightedValidationPath: string | null; onSelectTimeline: (id: string) => void; - onAddTimeline: (type: KnownTimelineType) => void; + onAddTimeline: (type: string) => void; onUpdateTimeline: (timelineId: string, patch: object) => void; onDuplicateTimeline: (timelineId: string) => void; onDeleteTimeline: (timelineId: string) => void; @@ -33,74 +36,146 @@ export const TimelineEditor = ({ onDeleteTimeline, onMoveTimeline, }: TimelineEditorProps) => { + const schemaDirectoryPath = useEditorStore((state) => state.schemaDirectoryPath); + const loadTimelineSchemas = useEditorStore((state) => state.loadTimelineSchemas); + const timelineSchemaRegistry = useEditorStore((state) => state.timelineSchemaRegistry); + const [refreshToast, setRefreshToast] = useState(null); const timelines = action.TimelineDatas ?? []; + const hasTimelines = timelines.length > 0; + const timelineTypeOptions = getTimelineTypeOptions(timelineSchemaRegistry, TIMELINE_TYPE_OPTIONS); const selectedTimeline = timelines.find((timeline) => timeline.__editorId === selectedTimelineId) ?? timelines[0]; const selectedTimelineIndex = selectedTimeline ? timelines.findIndex((timeline) => timeline.__editorId === selectedTimeline.__editorId) : -1; const isHighlighted = (path: string) => highlightedValidationPath === path || Boolean(highlightedValidationPath?.startsWith(`${path}.`)); + useEffect(() => { + if (!refreshToast) { + return; + } + + const timeoutId = window.setTimeout(() => setRefreshToast(null), 2400); + return () => window.clearTimeout(timeoutId); + }, [refreshToast]); + + const refreshTimelines = async () => { + if (!schemaDirectoryPath) { + return; + } + + const count = await loadTimelineSchemas(schemaDirectoryPath); + setRefreshToast(`已加载 ${count} 个 Timeline 类型`); + }; + return (
-
+
-

TimelineDatas

-

按 `$type` 编辑动作期间的移动、碰撞与特效事件。

+

TimelineDatas

+

按 `$type` 编辑动作期间的移动、碰撞与特效事件。

- event.target.value && onAddTimeline(event.target.value)}> + - ))} - + {timelineTypeOptions.map((option) => ( + + ))} + +
-
+ {refreshToast ? ( +
+ {refreshToast} +
+ ) : null} + +
{timelines.map((timeline, index) => { const timelinePath = `[${actionIndex}].TimelineDatas[${index}]`; + const timelineSchema = getTimelineSchemaForType(timelineSchemaRegistry, timeline.$type); + const fallbackLabel = timelineTypeLabel(timeline.$type); + const timelineLabel = fallbackLabel !== timeline.$type + ? fallbackLabel + : timelineSchema + ? humanizeIdentifier(timelineSchema.name.replace(/Data$/, "")) + : timeline.$type; return ( - - - - -
- + + + + +
+ ); })} - {timelines.length === 0 ? ( + {!hasTimelines ? (
还没有 TimelineData。
-
) : null}
-
{selectedTimeline ? onUpdateTimeline(selectedTimeline.__editorId, patch)} /> : null}
+ {hasTimelines ? ( +
+ {selectedTimeline ? ( + onUpdateTimeline(selectedTimeline.__editorId, patch)} + /> + ) : null} +
+ ) : null}
); diff --git a/src/components/timelines/TimelineItemEditor.tsx b/src/components/timelines/TimelineItemEditor.tsx index 8725d08..1873ede 100644 --- a/src/components/timelines/TimelineItemEditor.tsx +++ b/src/components/timelines/TimelineItemEditor.tsx @@ -2,10 +2,14 @@ import { useMemo, useState } from "react"; import { JsonTextArea } from "@/components/fields/JsonTextArea"; import { BooleanField } from "@/components/fields/BooleanField"; import { NumberField } from "@/components/fields/NumberField"; +import { StringArrayField } from "@/components/fields/StringArrayField"; import { TimelineRangeField } from "@/components/fields/TimelineRangeField"; import { TextField } from "@/components/fields/TextField"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import type { BattleData, EffectSpawn, TimelineData } from "@/models/actionData"; +import { useEditorStore } from "@/store/editorStore"; +import { getTimelineSchemaForType, humanizeIdentifier } from "@/schema/csharpTimelineSchema"; +import { SchemaTimelineFields } from "./SchemaTimelineFields"; import { TIMELINE_TYPES, timelineTypeLabel } from "@/models/timelineTypes"; interface TimelineItemEditorProps { @@ -60,11 +64,19 @@ const EffectSpawnFields = ({ value, basePath, isHighlighted, onChange }: { value ); export const TimelineItemEditor = ({ timeline, actionIndex, timelineIndex, highlightedValidationPath, onChange }: TimelineItemEditorProps) => { + const timelineSchemaRegistry = useEditorStore((state) => state.timelineSchemaRegistry); const [rawJson, setRawJson] = useState(() => JSON.stringify(timeline, null, 2)); const [jsonError, setJsonError] = useState(null); const isUnknown = !Object.values(TIMELINE_TYPES).includes(timeline.$type as never); + const timelineSchema = getTimelineSchemaForType(timelineSchemaRegistry, timeline.$type); - const header = useMemo(() => timelineTypeLabel(timeline.$type), [timeline.$type]); + const header = useMemo(() => { + const fallbackLabel = timelineTypeLabel(timeline.$type); + if (fallbackLabel !== timeline.$type) { + return fallbackLabel; + } + return timelineSchema ? humanizeIdentifier(timelineSchema.name.replace(/Data$/, "")) : timeline.$type; + }, [timeline.$type, timelineSchema]); const timelinePath = `[${actionIndex}].TimelineDatas[${timelineIndex}]`; const isHighlighted = (path: string) => highlightedValidationPath === path || Boolean(highlightedValidationPath?.startsWith(`${path}.`)); @@ -97,7 +109,18 @@ export const TimelineItemEditor = ({ timeline, actionIndex, timelineIndex, highl onChange={onChange} /> - {timeline.$type === TIMELINE_TYPES.moveState || timeline.$type === TIMELINE_TYPES.moveStraight ? ( + {timelineSchemaRegistry && timelineSchema ? ( + + ) : null} + + {!timelineSchema && (timeline.$type === TIMELINE_TYPES.moveState || timeline.$type === TIMELINE_TYPES.moveStraight) ? (
onChange({ useGhostLayer })} /> @@ -112,32 +135,32 @@ export const TimelineItemEditor = ({ timeline, actionIndex, timelineIndex, highl
) : null} - {timeline.$type === TIMELINE_TYPES.jointColl ? ( + {!timelineSchema && timeline.$type === TIMELINE_TYPES.jointColl ? ( <> - onChange({ joints: value.split(",").map((item) => item.trim()).filter(Boolean) })} + onChange={(joints) => onChange({ joints })} /> onChange({ battleData })} /> ) : null} - {timeline.$type === TIMELINE_TYPES.weaponColl ? ( + {!timelineSchema && timeline.$type === TIMELINE_TYPES.weaponColl ? ( onChange({ battleData })} /> ) : null} - {timeline.$type === TIMELINE_TYPES.effectSpawn ? ( + {!timelineSchema && timeline.$type === TIMELINE_TYPES.effectSpawn ? ( <> onChange({ effectSpawn })} /> onChange({ battleData })} /> ) : null} - {isUnknown ? : null} + {isUnknown && !timelineSchema ? : null} ); diff --git a/src/io/tauriFileIo.ts b/src/io/tauriFileIo.ts index a3e77d9..15ceb92 100644 --- a/src/io/tauriFileIo.ts +++ b/src/io/tauriFileIo.ts @@ -22,6 +22,15 @@ export const openJsonFile = async (): Promise => { return { path: selected, contents }; }; +export const openDirectory = async (): Promise => { + const selected = await open({ + multiple: false, + directory: true, + }); + + return typeof selected === "string" ? selected : null; +}; + export const saveJsonFile = async (path: string, contents: string): Promise => { await writeTextFile(path, contents); }; diff --git a/src/io/timelineSchemaFileIo.ts b/src/io/timelineSchemaFileIo.ts new file mode 100644 index 0000000..75a6a5c --- /dev/null +++ b/src/io/timelineSchemaFileIo.ts @@ -0,0 +1,46 @@ +import { invoke } from "@tauri-apps/api/core"; +import { join } from "@tauri-apps/api/path"; +import { readDir, readTextFile } from "@tauri-apps/plugin-fs"; +import { parseCSharpSchemaFiles, type TimelineSchemaRegistry } from "@/schema/csharpTimelineSchema"; + +interface SchemaSource { + path: string; + contents: string; +} + +const isSchemaFileName = (fileName: string) => + fileName === "ActionData.cs" || (fileName.endsWith("Data.cs") && !fileName.endsWith(".Designer.cs")); + +const loadSchemaSourcesViaFsPlugin = async (schemaDirectoryPath: string): Promise => { + const entries = await readDir(schemaDirectoryPath); + const fileNames = entries + .filter((entry) => entry.isFile && isSchemaFileName(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)); + + return Promise.all( + fileNames.map(async (fileName) => { + const path = await join(schemaDirectoryPath, fileName); + return { + path, + contents: await readTextFile(path), + }; + }), + ); +}; + +const loadSchemaSources = async (schemaDirectoryPath: string): Promise => { + try { + return await invoke("load_timeline_schema_sources", { schemaDirectoryPath }); + } catch { + return loadSchemaSourcesViaFsPlugin(schemaDirectoryPath); + } +}; + +export const loadTimelineSchemaRegistry = async (schemaDirectoryPath: string): Promise => { + const sources = await loadSchemaSources(schemaDirectoryPath); + if (sources.length === 0) { + return null; + } + return parseCSharpSchemaFiles(sources); +}; diff --git a/src/models/defaults.ts b/src/models/defaults.ts index 5185990..439f3d4 100644 --- a/src/models/defaults.ts +++ b/src/models/defaults.ts @@ -1,5 +1,6 @@ import type { ActionData, BattleData, EffectSpawn, TimelineData } from "./actionData"; import { TIMELINE_TYPES, type KnownTimelineType } from "./timelineTypes"; +import { buildDefaultObjectForType, getTimelineSchemaForType, type TimelineSchemaRegistry } from "@/schema/csharpTimelineSchema"; export const createEditorId = () => typeof crypto !== "undefined" && "randomUUID" in crypto @@ -42,7 +43,7 @@ export const createDefaultAction = (existingCount: number): ActionData => ({ derivations: [], }); -export const createDefaultTimeline = (type: KnownTimelineType): TimelineData => { +export const createDefaultTimeline = (type: KnownTimelineType | string, registry: TimelineSchemaRegistry | null = null): TimelineData => { const base = { __editorId: createEditorId(), $type: type, @@ -87,5 +88,16 @@ export const createDefaultTimeline = (type: KnownTimelineType): TimelineData => ...base, battleData: defaultBattleData(), }; + default: { + const schema = getTimelineSchemaForType(registry, type); + if (!schema) { + return base; + } + const dynamicDefaults = buildDefaultObjectForType(schema.name, registry ?? { types: {}, enums: {}, timelineClassNames: [] }); + return { + ...base, + ...(dynamicDefaults ?? {}), + }; + } } }; diff --git a/src/models/timelineTypes.ts b/src/models/timelineTypes.ts index e469bd3..984fe68 100644 --- a/src/models/timelineTypes.ts +++ b/src/models/timelineTypes.ts @@ -7,8 +7,12 @@ export const TIMELINE_TYPES = { } as const; export type KnownTimelineType = (typeof TIMELINE_TYPES)[keyof typeof TIMELINE_TYPES]; +export interface TimelineTypeOption { + value: string; + label: string; +} -export const TIMELINE_TYPE_OPTIONS: Array<{ value: KnownTimelineType; label: string }> = [ +export const TIMELINE_TYPE_OPTIONS: TimelineTypeOption[] = [ { value: TIMELINE_TYPES.moveState, label: "Move State" }, { value: TIMELINE_TYPES.moveStraight, label: "Move Straight" }, { value: TIMELINE_TYPES.jointColl, label: "Joint Collision" }, diff --git a/src/schema/csharpTimelineSchema.ts b/src/schema/csharpTimelineSchema.ts new file mode 100644 index 0000000..520d5a8 --- /dev/null +++ b/src/schema/csharpTimelineSchema.ts @@ -0,0 +1,278 @@ +export type CSharpFieldKind = "boolean" | "number" | "string" | "stringArray" | "enum" | "object" | "unknown"; + +export interface CSharpFieldSchema { + name: string; + typeName: string; + kind: CSharpFieldKind; + defaultValue?: boolean | number | string; +} + +export interface CSharpTypeSchema { + name: string; + kind: "class" | "struct"; + baseType?: string; + fields: CSharpFieldSchema[]; +} + +export interface TimelineSchemaRegistry { + types: Record; + enums: Record; + timelineClassNames: string[]; +} + +interface PendingType { + type: CSharpTypeSchema | { name: string; kind: "enum"; values: string[]; baseType?: string }; + braceDepth: number; + hasOpenedBody: boolean; + pendingDefaultValue?: boolean | number | string; +} + +const primitiveKinds: Record = { + bool: "boolean", + byte: "number", + short: "number", + int: "number", + long: "number", + float: "number", + double: "number", + decimal: "number", + string: "string", +}; + +const typeDeclarationPattern = /public\s+(class|struct|enum)\s+(\w+)(?:\s*:\s*([\w.<>]+))?/; +const fieldPattern = /public\s+([\w.]+(?:\[\])?)\s+(\w+)\s*;/; +const defaultValuePattern = /\[DefaultValue\((.+)\)\]/; + +const countBraces = (line: string) => ({ + open: (line.match(/\{/g) ?? []).length, + close: (line.match(/\}/g) ?? []).length, +}); + +const parseDefaultValue = (rawValue: string): boolean | number | string | undefined => { + const value = rawValue.trim(); + if (value === "true") { + return true; + } + if (value === "false") { + return false; + } + if ((value.startsWith("\"") && value.endsWith("\"")) || (value.startsWith("'") && value.endsWith("'"))) { + return value.slice(1, -1); + } + if (/^-?\d+(?:\.\d+)?[fFdDmM]?$/.test(value)) { + return Number(value.replace(/[fFdDmM]$/, "")); + } + return undefined; +}; + +const resolveFieldKind = (typeName: string, enumMap: Record, typeMap: Record): CSharpFieldKind => { + if (primitiveKinds[typeName]) { + return primitiveKinds[typeName]; + } + if (typeName === "string[]") { + return "stringArray"; + } + if (enumMap[typeName]) { + return "enum"; + } + if (typeMap[typeName]) { + return "object"; + } + return "unknown"; +}; + +const normalizeField = ( + field: CSharpFieldSchema, + enumMap: Record, + typeMap: Record, +): CSharpFieldSchema => ({ + ...field, + kind: resolveFieldKind(field.typeName, enumMap, typeMap), +}); + +const parseEnumValuesFromLine = (line: string) => + line + .replace(/\/\/.*$/, "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); + +export const timelineTypeToClassName = (typeValue: string) => typeValue.split(",")[0]?.trim() ?? typeValue; + +export const classNameToTimelineType = (className: string) => `${className}, Assembly-CSharp`; + +export const humanizeIdentifier = (value: string) => + value + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/^./, (char) => char.toUpperCase()); + +export const getTimelineTypeOptions = (registry: TimelineSchemaRegistry | null, fallback: Array<{ value: string; label: string }>) => { + const options = new Map(fallback.map((option) => [option.value, option.label])); + for (const className of registry?.timelineClassNames ?? []) { + const typeValue = classNameToTimelineType(className); + if (!options.has(typeValue)) { + options.set(typeValue, humanizeIdentifier(className.replace(/Data$/, ""))); + } + } + return Array.from(options, ([value, label]) => ({ value, label })); +}; + +export const getTimelineSchemaForType = (registry: TimelineSchemaRegistry | null, typeValue: string) => + registry?.types[timelineTypeToClassName(typeValue)] ?? null; + +const createEmptyRegistry = (): TimelineSchemaRegistry => ({ + types: {}, + enums: {}, + timelineClassNames: [], +}); + +export const parseCSharpSchemaFiles = (sources: Array<{ path: string; contents: string }>): TimelineSchemaRegistry => { + const registry = createEmptyRegistry(); + const pendingTypes: CSharpTypeSchema[] = []; + const pendingEnums: Array<{ name: string; values: string[]; baseType?: string }> = []; + + for (const source of sources) { + const lines = source.contents.split(/\r?\n/); + let pending: PendingType | null = null; + + for (const line of lines) { + if (!pending) { + const declaration = line.match(typeDeclarationPattern); + if (!declaration) { + continue; + } + + const [, kind, name, baseType] = declaration; + if (kind === "enum") { + pending = { + type: { name, kind: "enum", values: [], baseType }, + braceDepth: 0, + hasOpenedBody: false, + }; + } else { + pending = { + type: { name, kind: kind as "class" | "struct", baseType, fields: [] }, + braceDepth: 0, + hasOpenedBody: false, + }; + } + } + + const currentPending = pending; + const braceCounts = countBraces(line); + if (braceCounts.open > 0) { + currentPending.hasOpenedBody = true; + } + currentPending.braceDepth += braceCounts.open - braceCounts.close; + + if (currentPending.type.kind === "enum") { + if (currentPending.hasOpenedBody && currentPending.braceDepth >= 1 && !line.includes("{") && !line.includes("}")) { + for (const value of parseEnumValuesFromLine(line)) { + currentPending.type.values.push(value); + } + } + } else if (currentPending.hasOpenedBody && currentPending.braceDepth === 1) { + const defaultValueMatch = line.match(defaultValuePattern); + if (defaultValueMatch) { + currentPending.pendingDefaultValue = parseDefaultValue(defaultValueMatch[1]); + } + + if (!line.includes("(")) { + const fieldMatch = line.match(fieldPattern); + if (fieldMatch) { + const [, typeName, name] = fieldMatch; + currentPending.type.fields.push({ + name, + typeName, + kind: "unknown", + defaultValue: currentPending.pendingDefaultValue, + }); + currentPending.pendingDefaultValue = undefined; + } + } + } + + if (currentPending.hasOpenedBody && currentPending.braceDepth <= 0) { + if (currentPending.type.kind === "enum") { + pendingEnums.push(currentPending.type); + } else { + pendingTypes.push(currentPending.type); + } + pending = null; + } + } + } + + for (const entry of pendingEnums) { + registry.enums[entry.name] = entry.values; + } + + for (const type of pendingTypes) { + registry.types[type.name] = { + ...type, + fields: type.fields.map((field) => normalizeField(field, registry.enums, registry.types)), + }; + } + + for (const [name, type] of Object.entries(registry.types)) { + registry.types[name] = { + ...type, + fields: type.fields.map((field) => normalizeField(field, registry.enums, registry.types)), + }; + + if (type.baseType === "TimelineData") { + registry.timelineClassNames.push(name); + } + } + + registry.timelineClassNames.sort((left, right) => left.localeCompare(right)); + return registry; +}; + +const cloneValue = (value: T): T => { + if (typeof structuredClone === "function") { + return structuredClone(value); + } + return JSON.parse(JSON.stringify(value)) as T; +}; + +const buildDefaultValueForField = (field: CSharpFieldSchema, registry: TimelineSchemaRegistry): unknown => { + if (field.defaultValue !== undefined) { + return field.defaultValue; + } + + switch (field.kind) { + case "boolean": + return false; + case "number": + return 0; + case "string": + return ""; + case "stringArray": + return []; + case "enum": + return 0; + case "object": + return buildDefaultObjectForType(field.typeName, registry) ?? {}; + default: + return null; + } +}; + +export const buildDefaultObjectForType = (typeName: string, registry: TimelineSchemaRegistry): Record | null => { + const schema = registry.types[typeName]; + if (!schema) { + return null; + } + + return schema.fields.reduce>((result, field) => { + const defaultValue = buildDefaultValueForField(field, registry); + if (defaultValue !== null) { + result[field.name] = cloneValue(defaultValue); + } + return result; + }, {}); +}; diff --git a/src/store/editorStore.ts b/src/store/editorStore.ts index e1bce54..ff73ca7 100644 --- a/src/store/editorStore.ts +++ b/src/store/editorStore.ts @@ -1,26 +1,33 @@ import { create } from "zustand"; import type { ActionData, ActionDataDocument, ActionDerivation, TimelineData, TimelinePatch } from "@/models/actionData"; import { createDefaultAction, createDefaultTimeline, createEditorId } from "@/models/defaults"; -import { type KnownTimelineType } from "@/models/timelineTypes"; import { cloneForEditor, parseActionDataJson, serializeActionDataJson } from "@/io/jsonCodec"; -import { openJsonFile, readJsonFile, saveJsonFile, saveJsonFileAs } from "@/io/tauriFileIo"; +import { openDirectory, openJsonFile, readJsonFile, saveJsonFile, saveJsonFileAs } from "@/io/tauriFileIo"; +import { loadTimelineSchemaRegistry } from "@/io/timelineSchemaFileIo"; import { reorder } from "@/lib/utils"; +import type { TimelineSchemaRegistry } from "@/schema/csharpTimelineSchema"; import { validateActionData } from "@/validation/validateActionData"; import type { ValidationIssue } from "@/validation/validationTypes"; const LAST_OPENED_FILE_KEY = "action-data-editor:last-opened-file"; +const LAST_SCHEMA_DIRECTORY_KEY = "action-data-editor:last-schema-directory"; interface EditorState { filePath: string | null; + schemaDirectoryPath: string | null; document: ActionDataDocument | null; selectedActionId: string | null; selectedTimelineId: string | null; + timelineSchemaRegistry: TimelineSchemaRegistry | null; validationIssues: ValidationIssue[]; dirty: boolean; lastError: string | null; openFile: () => Promise; + openSchemaDirectory: () => Promise; restoreLastFile: () => Promise; + restoreLastSchemaDirectory: () => Promise; + loadTimelineSchemas: (schemaDirectoryPath: string | null) => Promise; saveFile: () => Promise; saveFileAs: () => Promise; loadFromText: (text: string, path?: string | null) => void; @@ -36,7 +43,7 @@ interface EditorState { moveAction: (actionId: string, direction: "up" | "down") => void; updateTimeline: (actionId: string, timelineId: string, patch: TimelinePatch) => void; - addTimeline: (actionId: string, type: KnownTimelineType) => void; + addTimeline: (actionId: string, type: string) => void; duplicateTimeline: (actionId: string, timelineId: string) => void; deleteTimeline: (actionId: string, timelineId: string) => void; moveTimeline: (actionId: string, timelineId: string, direction: "up" | "down") => void; @@ -54,8 +61,12 @@ const prepareDuplicatedDerivation = (derivation: ActionDerivation): ActionDeriva __editorId: createEditorId(), }); -const withValidation = (document: ActionDataDocument | null) => validateActionData(document); +const withValidation = (document: ActionDataDocument | null, timelineSchemaRegistry: TimelineSchemaRegistry | null) => + validateActionData(document, timelineSchemaRegistry); + const getLastOpenedFilePath = () => window.localStorage.getItem(LAST_OPENED_FILE_KEY); +const getLastSchemaDirectoryPath = () => window.localStorage.getItem(LAST_SCHEMA_DIRECTORY_KEY); + const setLastOpenedFilePath = (path: string | null) => { if (path) { window.localStorage.setItem(LAST_OPENED_FILE_KEY, path); @@ -64,13 +75,23 @@ const setLastOpenedFilePath = (path: string | null) => { window.localStorage.removeItem(LAST_OPENED_FILE_KEY); }; -const confirmDirty = (dirty: boolean) => !dirty || window.confirm("当前文件有未保存修改,是否继续?"); +const setLastSchemaDirectoryPath = (path: string | null) => { + if (path) { + window.localStorage.setItem(LAST_SCHEMA_DIRECTORY_KEY, path); + return; + } + window.localStorage.removeItem(LAST_SCHEMA_DIRECTORY_KEY); +}; + +const confirmDirty = (dirty: boolean) => !dirty || window.confirm("Current file has unsaved changes. Continue?"); export const useEditorStore = create((set, get) => ({ filePath: null, + schemaDirectoryPath: null, document: null, selectedActionId: null, selectedTimelineId: null, + timelineSchemaRegistry: null, validationIssues: [], dirty: false, lastError: null, @@ -86,8 +107,25 @@ export const useEditorStore = create((set, get) => ({ return; } get().loadFromText(result.contents, result.path); + if (get().schemaDirectoryPath) { + await get().loadTimelineSchemas(get().schemaDirectoryPath); + } + } catch (error) { + set({ lastError: error instanceof Error ? error.message : "Failed to open file." }); + } + }, + + openSchemaDirectory: async () => { + try { + const selected = await openDirectory(); + if (!selected) { + return; + } + setLastSchemaDirectoryPath(selected); + set({ schemaDirectoryPath: selected }); + await get().loadTimelineSchemas(selected); } catch (error) { - set({ lastError: error instanceof Error ? error.message : "打开文件失败" }); + set({ lastError: error instanceof Error ? error.message : "Failed to load CS schema directory." }); } }, @@ -100,17 +138,64 @@ export const useEditorStore = create((set, get) => ({ try { const contents = await readJsonFile(path); get().loadFromText(contents, path); + if (get().schemaDirectoryPath) { + await get().loadTimelineSchemas(get().schemaDirectoryPath); + } if (get().lastError) { setLastOpenedFilePath(null); } } catch (error) { setLastOpenedFilePath(null); set({ - lastError: error instanceof Error ? `自动恢复上次文件失败:${error.message}` : "自动恢复上次文件失败", + lastError: error instanceof Error ? `Failed to restore last file: ${error.message}` : "Failed to restore last file.", }); } }, + restoreLastSchemaDirectory: async () => { + const path = getLastSchemaDirectoryPath(); + if (!path) { + return; + } + + try { + set({ schemaDirectoryPath: path }); + await get().loadTimelineSchemas(path); + } catch { + setLastSchemaDirectoryPath(null); + set({ schemaDirectoryPath: null, timelineSchemaRegistry: null }); + } + }, + + loadTimelineSchemas: async (schemaDirectoryPath) => { + set((state) => ({ + schemaDirectoryPath, + timelineSchemaRegistry: null, + validationIssues: withValidation(state.document, null), + })); + + if (!schemaDirectoryPath) { + return 0; + } + + try { + const timelineSchemaRegistry = await loadTimelineSchemaRegistry(schemaDirectoryPath); + set((state) => ({ + schemaDirectoryPath, + timelineSchemaRegistry, + validationIssues: withValidation(state.document, timelineSchemaRegistry), + })); + return timelineSchemaRegistry?.timelineClassNames.length ?? 0; + } catch { + set((state) => ({ + schemaDirectoryPath, + timelineSchemaRegistry: null, + validationIssues: withValidation(state.document, null), + })); + return 0; + } + }, + saveFile: async () => { const { document, filePath } = get(); if (!document) { @@ -132,7 +217,7 @@ export const useEditorStore = create((set, get) => ({ setLastOpenedFilePath(filePath); set({ dirty: false }); } catch (error) { - set({ lastError: error instanceof Error ? error.message : "保存文件失败" }); + set({ lastError: error instanceof Error ? error.message : "Failed to save file." }); } }, @@ -149,7 +234,7 @@ export const useEditorStore = create((set, get) => ({ set({ filePath: savedPath, dirty: false }); } } catch (error) { - set({ lastError: error instanceof Error ? error.message : "另存为失败" }); + set({ lastError: error instanceof Error ? error.message : "Failed to save file as." }); } }, @@ -164,18 +249,18 @@ export const useEditorStore = create((set, get) => ({ } setLastOpenedFilePath(path); - set({ + set((state) => ({ filePath: path, document: result.document, selectedActionId: result.document[0]?.__editorId ?? null, selectedTimelineId: null, - validationIssues: withValidation(result.document), + validationIssues: withValidation(result.document, state.timelineSchemaRegistry), dirty: false, lastError: null, - }); + })); }, - validate: () => set((state) => ({ validationIssues: withValidation(state.document) })), + validate: () => set((state) => ({ validationIssues: withValidation(state.document, state.timelineSchemaRegistry) })), clearError: () => set({ lastError: null }), selectAction: (actionId) => set({ selectedActionId: actionId, selectedTimelineId: null }), @@ -186,14 +271,14 @@ export const useEditorStore = create((set, get) => ({ const document = state.document?.map((action) => action.__editorId === actionId ? { ...action, ...patch } : action, ) ?? null; - return { document, dirty: true, validationIssues: withValidation(document) }; + return { document, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), addAction: () => set((state) => { const document = [...(state.document ?? []), createDefaultAction(state.document?.length ?? 0)]; const selectedActionId = document[document.length - 1]?.__editorId ?? null; - return { document, selectedActionId, selectedTimelineId: null, dirty: true, validationIssues: withValidation(document) }; + return { document, selectedActionId, selectedTimelineId: null, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), duplicateAction: (actionId) => @@ -207,7 +292,7 @@ export const useEditorStore = create((set, get) => ({ ...cloneForEditor(source), __editorId: createEditorId(), id: `${source.id ?? "Action"}_copy`, - name: `${source.name ?? "动作"} Copy`, + name: `${source.name ?? "Action"} Copy`, TimelineDatas: source.TimelineDatas?.map(prepareDuplicatedTimeline) ?? [], derivations: source.derivations?.map(prepareDuplicatedDerivation) ?? [], }; @@ -219,7 +304,7 @@ export const useEditorStore = create((set, get) => ({ selectedActionId: duplicate.__editorId, selectedTimelineId: null, dirty: true, - validationIssues: withValidation(document), + validationIssues: withValidation(document, state.timelineSchemaRegistry), }; }), @@ -227,7 +312,7 @@ export const useEditorStore = create((set, get) => ({ set((state) => { const document = state.document?.filter((action) => action.__editorId !== actionId) ?? null; const selectedActionId = state.selectedActionId === actionId ? document?.[0]?.__editorId ?? null : state.selectedActionId; - return { document, selectedActionId, selectedTimelineId: null, dirty: true, validationIssues: withValidation(document) }; + return { document, selectedActionId, selectedTimelineId: null, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), moveAction: (actionId, direction) => @@ -237,7 +322,7 @@ export const useEditorStore = create((set, get) => ({ } const index = state.document.findIndex((action) => action.__editorId === actionId); const document = reorder(state.document, index, direction); - return { document, dirty: true, validationIssues: withValidation(document) }; + return { document, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), updateTimeline: (actionId, timelineId, patch) => @@ -253,18 +338,18 @@ export const useEditorStore = create((set, get) => ({ ) ?? [], }; }) ?? null; - return { document, dirty: true, validationIssues: withValidation(document) }; + return { document, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), addTimeline: (actionId, type) => set((state) => { - const timeline = createDefaultTimeline(type); + const timeline = createDefaultTimeline(type, state.timelineSchemaRegistry); const document = state.document?.map((action) => action.__editorId === actionId ? { ...action, TimelineDatas: [...(action.TimelineDatas ?? []), timeline] } : action, ) ?? null; - return { document, selectedTimelineId: timeline.__editorId, dirty: true, validationIssues: withValidation(document) }; + return { document, selectedTimelineId: timeline.__editorId, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), duplicateTimeline: (actionId, timelineId) => @@ -285,7 +370,7 @@ export const useEditorStore = create((set, get) => ({ next.splice(index + 1, 0, duplicate); return { ...action, TimelineDatas: next }; }) ?? null; - return { document, selectedTimelineId, dirty: true, validationIssues: withValidation(document) }; + return { document, selectedTimelineId, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), deleteTimeline: (actionId, timelineId) => @@ -299,7 +384,7 @@ export const useEditorStore = create((set, get) => ({ document, selectedTimelineId: state.selectedTimelineId === timelineId ? null : state.selectedTimelineId, dirty: true, - validationIssues: withValidation(document), + validationIssues: withValidation(document, state.timelineSchemaRegistry), }; }), @@ -313,7 +398,7 @@ export const useEditorStore = create((set, get) => ({ const index = timelines.findIndex((timeline) => timeline.__editorId === timelineId); return { ...action, TimelineDatas: reorder(timelines, index, direction) }; }) ?? null; - return { document, dirty: true, validationIssues: withValidation(document) }; + return { document, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), updateDerivations: (actionId, derivations) => @@ -321,6 +406,6 @@ export const useEditorStore = create((set, get) => ({ const document = state.document?.map((action) => action.__editorId === actionId ? { ...action, derivations } : action, ) ?? null; - return { document, dirty: true, validationIssues: withValidation(document) }; + return { document, dirty: true, validationIssues: withValidation(document, state.timelineSchemaRegistry) }; }), })); diff --git a/src/validation/validateActionData.ts b/src/validation/validateActionData.ts index d69b01e..2e36f22 100644 --- a/src/validation/validateActionData.ts +++ b/src/validation/validateActionData.ts @@ -1,5 +1,6 @@ import type { ActionDataDocument, BattleData, NumericValue, TimelineData } from "@/models/actionData"; import { isKnownTimelineType } from "@/models/timelineTypes"; +import { getTimelineSchemaForType, type TimelineSchemaRegistry } from "@/schema/csharpTimelineSchema"; import type { ValidationIssue } from "./validationTypes"; const toNumber = (value: NumericValue | unknown): number | null => { @@ -41,12 +42,12 @@ const validateBattleData = ( const damage = toNumber(battleData.damage); if (damage !== null && damage < 0) { - pushIssue(issues, "warning", `${path}.damage`, "damage 不应为负数。", actionId, timelineId); + pushIssue(issues, "warning", `${path}.damage`, "damage should not be negative.", actionId, timelineId); } const criticalRateEx = toNumber(battleData.criticalRateEx); if (criticalRateEx !== null && (criticalRateEx < 0 || criticalRateEx > 1)) { - pushIssue(issues, "warning", `${path}.criticalRateEx`, "criticalRateEx 通常应在 0 到 1 之间。", actionId, timelineId); + pushIssue(issues, "warning", `${path}.criticalRateEx`, "criticalRateEx is usually between 0 and 1.", actionId, timelineId); } }; @@ -56,24 +57,26 @@ const validateTimeline = ( actionId: string | undefined, actionIndex: number, timelineIndex: number, + timelineSchemaRegistry: TimelineSchemaRegistry | null, ) => { const path = `[${actionIndex}].TimelineDatas[${timelineIndex}]`; + const hasDynamicSchema = Boolean(getTimelineSchemaForType(timelineSchemaRegistry, timeline.$type)); if (isEmpty(timeline.$type)) { - pushIssue(issues, "error", `${path}.$type`, "TimelineData 缺少 $type。", actionId, timeline.__editorId); - } else if (!isKnownTimelineType(timeline.$type)) { - pushIssue(issues, "warning", `${path}.$type`, `未知 TimelineData 类型:${timeline.$type}`, actionId, timeline.__editorId); + pushIssue(issues, "error", `${path}.$type`, "TimelineData is missing $type.", actionId, timeline.__editorId); + } else if (!isKnownTimelineType(timeline.$type) && !hasDynamicSchema) { + pushIssue(issues, "warning", `${path}.$type`, `Unknown TimelineData type: ${timeline.$type}`, actionId, timeline.__editorId); } const begin = toNumber(timeline.timingBegin); const end = toNumber(timeline.timingEnd); if (timeline.timingBegin === undefined || timeline.timingEnd === undefined) { - pushIssue(issues, "warning", path, "TimelineData 缺少 timingBegin 或 timingEnd。", actionId, timeline.__editorId); + pushIssue(issues, "warning", path, "TimelineData is missing timingBegin or timingEnd.", actionId, timeline.__editorId); } if (begin !== null && end !== null && begin >= end) { - pushIssue(issues, "error", path, "timingBegin 必须小于 timingEnd。", actionId, timeline.__editorId); + pushIssue(issues, "error", path, "timingBegin must be less than timingEnd.", actionId, timeline.__editorId); } if ("battleData" in timeline) { @@ -81,7 +84,10 @@ const validateTimeline = ( } }; -export const validateActionData = (document: ActionDataDocument | null): ValidationIssue[] => { +export const validateActionData = ( + document: ActionDataDocument | null, + timelineSchemaRegistry: TimelineSchemaRegistry | null = null, +): ValidationIssue[] => { const issues: ValidationIssue[] = []; if (!document) { @@ -89,25 +95,25 @@ export const validateActionData = (document: ActionDataDocument | null): Validat } if (!Array.isArray(document)) { - pushIssue(issues, "error", "root", "根节点必须是 ActionData 数组。 异常", undefined, undefined); + pushIssue(issues, "error", "root", "Root node must be an ActionData array."); return issues; } if (document.length === 0) { - pushIssue(issues, "warning", "root", "当前 ActionData 为空。", undefined, undefined); + pushIssue(issues, "warning", "root", "ActionData is empty."); } const actionIds = new Map(); document.forEach((action, actionIndex) => { if (isEmpty(action.id)) { - pushIssue(issues, "error", `[${actionIndex}].id`, "Action 缺少 id。", action.__editorId); + pushIssue(issues, "error", `[${actionIndex}].id`, "Action is missing id.", action.__editorId); return; } const id = action.id?.trim() ?? ""; if (actionIds.has(id)) { - pushIssue(issues, "error", `[${actionIndex}].id`, `Action id 重复:${id}`, action.__editorId); + pushIssue(issues, "error", `[${actionIndex}].id`, `Duplicate Action id: ${id}`, action.__editorId); } else { actionIds.set(id, actionIndex); } @@ -118,23 +124,25 @@ export const validateActionData = (document: ActionDataDocument | null): Validat const id = action.id?.trim() ?? ""; if (isEmpty(action.animStateName)) { - pushIssue(issues, "warning", `[${actionIndex}].animStateName`, "animStateName 为空。", actionId); + pushIssue(issues, "warning", `[${actionIndex}].animStateName`, "animStateName is empty.", actionId); } if (isEmpty(action.command)) { - pushIssue(issues, "warning", `[${actionIndex}].command`, "command 为空。", actionId); + pushIssue(issues, "warning", `[${actionIndex}].command`, "command is empty.", actionId); } if (action.nextActionId && !actionIds.has(action.nextActionId)) { - pushIssue(issues, "error", `[${actionIndex}].nextActionId`, `nextActionId 指向不存在的动作:${action.nextActionId}`, actionId); + pushIssue(issues, "error", `[${actionIndex}].nextActionId`, `nextActionId points to a missing action: ${action.nextActionId}`, actionId); } if (!Array.isArray(action.TimelineDatas)) { - pushIssue(issues, "error", `[${actionIndex}].TimelineDatas`, "TimelineDatas 必须是数组。", actionId); + pushIssue(issues, "error", `[${actionIndex}].TimelineDatas`, "TimelineDatas must be an array.", actionId); } else if (action.TimelineDatas.length === 0) { - pushIssue(issues, "warning", `[${actionIndex}].TimelineDatas`, "TimelineDatas 为空。", actionId); + pushIssue(issues, "warning", `[${actionIndex}].TimelineDatas`, "TimelineDatas is empty.", actionId); } else { - action.TimelineDatas.forEach((timeline, timelineIndex) => validateTimeline(timeline, issues, actionId, actionIndex, timelineIndex)); + action.TimelineDatas.forEach((timeline, timelineIndex) => + validateTimeline(timeline, issues, actionId, actionIndex, timelineIndex, timelineSchemaRegistry), + ); } action.derivations?.forEach((derivation, derivationIndex) => { @@ -142,17 +150,17 @@ export const validateActionData = (document: ActionDataDocument | null): Validat const nextActionId = derivation.nextActionId?.trim(); if (nextActionId && !actionIds.has(nextActionId)) { - pushIssue(issues, "error", `${path}.nextActionId`, `派生动作不存在:${nextActionId}`, actionId); + pushIssue(issues, "error", `${path}.nextActionId`, `Derived action does not exist: ${nextActionId}`, actionId); } if (nextActionId && nextActionId === id) { - pushIssue(issues, "warning", `${path}.nextActionId`, "派生动作指向自身。", actionId); + pushIssue(issues, "warning", `${path}.nextActionId`, "Derived action points to itself.", actionId); } const min = toNumber(derivation.checkPeriod?.min); const max = toNumber(derivation.checkPeriod?.max); if (min !== null && max !== null && min > max) { - pushIssue(issues, "error", `${path}.checkPeriod`, "checkPeriod.min 必须小于等于 max。", actionId); + pushIssue(issues, "error", `${path}.checkPeriod`, "checkPeriod.min must be less than or equal to max.", actionId); } }); });