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 @@
@@ -129,13 +88,13 @@ onMounted(() => {
- ● 未保存
+ ● 未保存
起始场景:
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,
+ }
+})