diff --git a/docs/EDITOR_ROADMAP.md b/docs/EDITOR_ROADMAP.md index 4bf43fb..74c939f 100644 --- a/docs/EDITOR_ROADMAP.md +++ b/docs/EDITOR_ROADMAP.md @@ -121,3 +121,44 @@ P3 完成的编辑器支持基本的场景节点编辑。以下是与引擎 P4~P | **P2** | E14 | 面板标签页——属性字段达到 20+ 后优势明显 | | **P3** | E2/E3/E5 | 单字段编辑——BGM/loop/prompt,改动小 | | **P3** | E13 | 撤销/重做——重要但改动不小 | + +--- + +## E15: 编辑器架构重构 — 三层分离 + +目标:为应对状态增多和功能复杂化,确保扩展性和可维护性。编辑器重构为三层分离架构,与引擎分层一致。 + +**当前问题:** `App.vue` 310 行 god 组件 / `useGraphEditor.ts` 混合 JSON + Vue refs / 无 Pinia store + +**目标架构:** + +``` +editor/ +├── services/ GraphService.ts + AssetResolver.ts # 纯函数数据层 +├── stores/ editorStore.ts # Pinia 全局状态 + undo/redo +├── composables/ useGraphEditor.ts # 编排层(→60行) +├── components/ Toolbar / SceneGraphPanel / NodeEditorPanel / SceneList / StatusBar +└── App.vue # 精简布局组合(→30行) +``` + +**实施步骤:** +1. 创建 `editorStore.ts`(Pinia) +2. 创建 `GraphService.ts`(纯函数剥离) +3. 精简 `App.vue` + `useGraphEditor` +4. 新增 `SceneList.vue` + `StatusBar.vue` + +### 优先级建议(更新) + +| 优先级 | 编号 | 说明 | +|:--:|------|------| +| **P0** | **E15** | 架构重构——所有后续功能的基础 ✅ 已完成 2026-06-13 | +| **P0** | E4 | i18n key 编辑 | +| **P0** | E10 | 内嵌快速测试 | +| **P1** | E1 | 热点编辑 | +| **P1** | E6 | 顶级字段 | +| **P1** | E12 | JSON 校验 | +| **P2** | E7/E8/E9 | 条件路由/关键节点/战斗 | +| **P2** | E11 | 场景列表搜索 | +| **P2** | E14 | 面板标签页 | +| **P3** | E2/E3/E5 | 单字段编辑 | +| **P3** | E13 | 撤销/重做 | diff --git a/editor/App.vue b/editor/App.vue index 61423ca..b12a994 100644 --- a/editor/App.vue +++ b/editor/App.vue @@ -1,74 +1,37 @@ diff --git a/editor/composables/useGraphEditor.ts b/editor/composables/useGraphEditor.ts index 5a89ff1..37ca236 100644 --- a/editor/composables/useGraphEditor.ts +++ b/editor/composables/useGraphEditor.ts @@ -1,192 +1,41 @@ -import { ref, computed, shallowRef, triggerRef } from 'vue' -import type { GameData, SceneNode, Choice } from '@engine/types' +import { computed } from 'vue' +import { useEditorStore } from '../stores/editorStore' +import { computeEdges, computeSceneNodes } from '../services/GraphService' export function useGraphEditor() { - const gameData = shallowRef({ scenes: {}, startScene: '', variables: {} }) - const selectedNodeId = ref(null) - const startSceneId = ref('') + const store = useEditorStore() - const sceneList = computed(() => - Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })), - ) + const sceneNodes = computed(() => computeSceneNodes(store.gameData)) + const sceneEdges = computed(() => computeEdges(store.gameData)) + const sceneList = computed(() => computeSceneNodes(store.gameData)) - const sceneNodes = computed(() => - Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })), - ) - - const sceneEdges = computed(() => { - const result: { id: string; source: string; target: string; label?: string }[] = [] - for (const [id, scene] of Object.entries(gameData.value.scenes)) { - if (scene.choices) { - let ci = 0 - for (const c of scene.choices) { - if (c.targetScene) { - result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) }) - } - ci++ - } - } - if (scene.nextScene) { - if (Array.isArray(scene.nextScene)) { - for (let ri = 0; ri < scene.nextScene.length; ri++) { - const r = scene.nextScene[ri] - if (r.targetScene) { - const condLabel = r.conditions?.length ? '→ 条件' : '→ 默认' - result.push({ id: `${id}_next_${ri}`, source: id, target: r.targetScene, label: condLabel }) - } - } - } else { - result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '\u2192 \u9ed8\u8ba4' }) - } - } - if (scene.qte) { - if (scene.qte.successScene) - result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE\u6210\u529f' }) - if (scene.qte.failScene) - result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE\u5931\u8d25' }) - } - } - return result - }) - - const selectedScene = computed(() => { - if (!selectedNodeId.value) return null - return gameData.value.scenes[selectedNodeId.value] ?? null - }) - - function trigger() { - triggerRef(gameData) - } - - function loadJSON(json: GameData) { - gameData.value = JSON.parse(JSON.stringify(json)) - trigger() - startSceneId.value = json.startScene || '' - selectedNodeId.value = null - } - - function exportJSON(): GameData { - return JSON.parse(JSON.stringify({ ...gameData.value, startScene: startSceneId.value })) - } - - function generateId(): string { - let i = Object.keys(gameData.value.scenes).length + 1 - while (gameData.value.scenes[`scene_${i}`]) i++ - return `scene_${i}` - } - - function addScene(): string { - const id = generateId() - gameData.value = { - ...gameData.value, - scenes: { - ...gameData.value.scenes, - [id]: { - id, - videoUrl: '', - choices: [], - nextScene: '', - subtitleUrl: '', - onEnter: [], - }, - }, - } - trigger() - return id - } - - function deleteScene(id: string) { - if (startSceneId.value === id) return - const nextScenes = { ...gameData.value.scenes } - delete nextScenes[id] - for (const key of Object.keys(nextScenes)) { - const s = nextScenes[key] - if (s.choices) - nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) } - if (Array.isArray(s.nextScene)) { - nextScenes[key] = { ...s, nextScene: s.nextScene.filter((r) => r.targetScene !== id) } - } else if (s.nextScene === id) { - nextScenes[key] = { ...nextScenes[key], nextScene: '' } - } - if (s.qte) { - const qte = { ...s.qte } - let changed = false - if (qte.successScene === id) { qte.successScene = ''; changed = true } - if (qte.failScene === id) { qte.failScene = ''; changed = true } - if (changed) nextScenes[key] = { ...nextScenes[key], qte } - } - } - gameData.value = { ...gameData.value, scenes: nextScenes } - trigger() - if (selectedNodeId.value === id) selectedNodeId.value = null - } - - function updateScene(id: string, partial: Partial) { - const scene = gameData.value.scenes[id] + function onAddEdge(source: string, target: string) { + const scene = store.gameData.scenes[source] if (!scene) return - gameData.value = { - ...gameData.value, - scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } }, + const newChoices = [...(scene.choices || []), { text: `${source} → ${target}`, targetScene: target }] + store.gameData = { + ...store.gameData, + scenes: { ...store.gameData.scenes, [source]: { ...scene, choices: newChoices } }, } - trigger() - } - - function addChoice(sourceId: string) { - const scene = gameData.value.scenes[sourceId] - if (!scene) return - gameData.value = { - ...gameData.value, - scenes: { - ...gameData.value.scenes, - [sourceId]: { - ...scene, - choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }], - }, - }, - } - trigger() - } - - function updateChoice(sourceId: string, index: number, partial: Partial) { - const scene = gameData.value.scenes[sourceId] - if (!scene?.choices) return - const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c)) - gameData.value = { - ...gameData.value, - scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } }, - } - trigger() - } - - function deleteChoice(sourceId: string, index: number) { - const scene = gameData.value.scenes[sourceId] - if (!scene?.choices) return - gameData.value = { - ...gameData.value, - scenes: { - ...gameData.value.scenes, - [sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) }, - }, - } - trigger() + store.markDirty() } return { - gameData, - selectedNodeId, - selectedScene, - sceneList, + gameData: store.gameData, + selectedNodeId: store.selectedNodeId, + selectedScene: store.selectedScene, + startSceneId: store.startSceneId, sceneNodes, sceneEdges, - startSceneId, - loadJSON, - exportJSON, - addScene, - deleteScene, - updateScene, - addChoice, - updateChoice, - deleteChoice, - generateId, + sceneList, + loadJSON: store.loadJSON, + exportJSON: store.exportJSON, + updateScene: store.updateScene, + addChoice: store.addChoice, + updateChoice: store.updateChoice, + deleteChoice: store.deleteChoice, + addScene: store.addScene, + deleteScene: store.deleteScene, + onAddEdge, } } diff --git a/editor/services/GraphService.ts b/editor/services/GraphService.ts new file mode 100644 index 0000000..ccbb88b --- /dev/null +++ b/editor/services/GraphService.ts @@ -0,0 +1,46 @@ +import type { GameData } from '@engine/types' + +export function computeEdges(gameData: GameData): { id: string; source: string; target: string; label?: string }[] { + const result: { id: string; source: string; target: string; label?: string }[] = [] + for (const [id, scene] of Object.entries(gameData.scenes)) { + if (scene.choices) { + let ci = 0 + for (const c of scene.choices) { + if (c.targetScene) { + result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) }) + } + ci++ + } + } + if (scene.nextScene) { + if (Array.isArray(scene.nextScene)) { + for (let ri = 0; ri < scene.nextScene.length; ri++) { + const r = scene.nextScene[ri] + if (r.targetScene) { + result.push({ id: `${id}_next_${ri}`, source: id, target: r.targetScene, label: r.conditions?.length ? '→ 条件' : '→ 默认' }) + } + } + } else { + result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '→' }) + } + } + if (scene.qte) { + if (scene.qte.successScene) + result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE成功' }) + if (scene.qte.failScene) + result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE失败' }) + } + if (scene.hotspots) { + for (const h of scene.hotspots) { + if (h.targetScene) { + result.push({ id: `${id}_hs_${h.id}`, source: id, target: h.targetScene, label: h.label.slice(0, 10) }) + } + } + } + } + return result +} + +export function computeSceneNodes(gameData: GameData): { id: string; label: string }[] { + return Object.values(gameData.scenes).map(s => ({ id: s.id, label: s.id })) +} diff --git a/editor/stores/editorStore.ts b/editor/stores/editorStore.ts new file mode 100644 index 0000000..e9cd443 --- /dev/null +++ b/editor/stores/editorStore.ts @@ -0,0 +1,132 @@ +import { defineStore } from 'pinia' +import { shallowRef, ref, computed, triggerRef } from 'vue' +import type { GameData, SceneNode, Choice } from '@engine/types' + +export const useEditorStore = defineStore('editor', () => { + const gameData = shallowRef({ scenes: {}, startScene: '', variables: {} }) + const selectedNodeId = ref(null) + const startSceneId = ref('') + const dirty = ref(false) + + const selectedScene = computed(() => { + if (!selectedNodeId.value) return null + return gameData.value.scenes[selectedNodeId.value] ?? null + }) + + function markDirty() { dirty.value = true } + + function loadJSON(json: GameData) { + gameData.value = JSON.parse(JSON.stringify(json)) + triggerRef(gameData) + startSceneId.value = json.startScene || '' + selectedNodeId.value = null + dirty.value = false + } + + function exportJSON(): GameData { + return JSON.parse(JSON.stringify({ ...gameData.value, startScene: startSceneId.value })) + } + + function generateId(): string { + let i = Object.keys(gameData.value.scenes).length + 1 + while (gameData.value.scenes[`scene_${i}`]) i++ + return `scene_${i}` + } + + function addScene(): string { + const id = generateId() + gameData.value = { + ...gameData.value, + scenes: { + ...gameData.value.scenes, + [id]: { id, videoUrl: '', choices: [], nextScene: '', subtitleUrl: '', onEnter: [] }, + }, + } + triggerRef(gameData) + dirty.value = true + return id + } + + function deleteScene(id: string) { + if (startSceneId.value === id) return + const nextScenes = { ...gameData.value.scenes } + delete nextScenes[id] + for (const key of Object.keys(nextScenes)) { + const s = nextScenes[key] + if (s.choices) nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) } + if (Array.isArray(s.nextScene)) { + nextScenes[key] = { ...s, nextScene: s.nextScene.filter((r) => r.targetScene !== id) } + } else if (s.nextScene === id) { + nextScenes[key] = { ...nextScenes[key], nextScene: '' } + } + if (s.qte) { + const qte = { ...s.qte } + let changed = false + if (qte.successScene === id) { qte.successScene = ''; changed = true } + if (qte.failScene === id) { qte.failScene = ''; changed = true } + if (changed) nextScenes[key] = { ...nextScenes[key], qte } + } + } + gameData.value = { ...gameData.value, scenes: nextScenes } + triggerRef(gameData) + dirty.value = true + if (selectedNodeId.value === id) selectedNodeId.value = null + } + + function updateScene(id: string, partial: Partial) { + const scene = gameData.value.scenes[id] + if (!scene) return + gameData.value = { + ...gameData.value, + scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } }, + } + triggerRef(gameData) + dirty.value = true + } + + function addChoice(sourceId: string) { + const scene = gameData.value.scenes[sourceId] + if (!scene) return + gameData.value = { + ...gameData.value, + scenes: { + ...gameData.value.scenes, + [sourceId]: { ...scene, choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }] }, + }, + } + triggerRef(gameData) + dirty.value = true + } + + function updateChoice(sourceId: string, index: number, partial: Partial) { + const scene = gameData.value.scenes[sourceId] + if (!scene?.choices) return + const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c)) + gameData.value = { + ...gameData.value, + scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } }, + } + triggerRef(gameData) + dirty.value = true + } + + function deleteChoice(sourceId: string, index: number) { + const scene = gameData.value.scenes[sourceId] + if (!scene?.choices) return + gameData.value = { + ...gameData.value, + scenes: { + ...gameData.value.scenes, + [sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) }, + }, + } + triggerRef(gameData) + dirty.value = true + } + + return { + gameData, selectedNodeId, selectedScene, startSceneId, dirty, + markDirty, loadJSON, exportJSON, addScene, deleteScene, + updateScene, addChoice, updateChoice, deleteChoice, generateId, + } +})