From 4d48463164e1f525e1c5bcae65c81f9221c0ffb9 Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sun, 7 Jun 2026 23:18:43 +0800 Subject: [PATCH] refactor: rewrite editor with immutable state, async-safe Vue Flow, and loading guard --- editor/App.vue | 145 ++++++++-------------- editor/components/SceneGraph.vue | 84 ++++++------- editor/composables/useGraphEditor.ts | 179 ++++++++++++++++++--------- 3 files changed, 215 insertions(+), 193 deletions(-) diff --git a/editor/App.vue b/editor/App.vue index d9cd484..081b5ab 100644 --- a/editor/App.vue +++ b/editor/App.vue @@ -1,5 +1,5 @@ diff --git a/editor/composables/useGraphEditor.ts b/editor/composables/useGraphEditor.ts index 967a4a6..aeec4a9 100644 --- a/editor/composables/useGraphEditor.ts +++ b/editor/composables/useGraphEditor.ts @@ -1,37 +1,58 @@ -import { ref, computed } from 'vue' +import { ref, computed, shallowRef, triggerRef } from 'vue' import type { GameData, SceneNode, Choice } from '@engine/types' -export interface EditorNode { - id: string - label: string - videoUrl: string - subtitleUrl: string - choices: Choice[] - nextScene: string - onEnter: any[] - qte: any | null -} - export function useGraphEditor() { - const gameData = ref({ scenes: {}, startScene: '', variables: {} }) + const gameData = shallowRef({ scenes: {}, startScene: '', variables: {} }) const selectedNodeId = ref(null) const startSceneId = ref('') - const selectedNode = computed(() => { + const sceneList = computed(() => + Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })), + ) + + 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) { + 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 }) - const sceneList = computed(() => { - return Object.values(gameData.value.scenes).map((s) => ({ - id: s.id, - label: s.id, - })) - }) + function trigger() { + triggerRef(gameData) + } function loadJSON(json: GameData) { gameData.value = JSON.parse(JSON.stringify(json)) - startSceneId.value = json.startScene + trigger() + startSceneId.value = json.startScene || '' + selectedNodeId.value = null } function exportJSON(): GameData { @@ -46,76 +67,112 @@ export function useGraphEditor() { function addScene(): string { const id = generateId() - gameData.value.scenes[id] = { - id, - videoUrl: '', - choices: [], - nextScene: '', - subtitleUrl: '', - onEnter: [], + 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 - delete gameData.value.scenes[id] - for (const s of Object.values(gameData.value.scenes)) { - s.choices = (s.choices || []).filter((c) => c.targetScene !== id) - if (s.nextScene === id) s.nextScene = '' + 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 (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) { + function updateScene(id: string, partial: Partial) { const scene = gameData.value.scenes[id] if (!scene) return - Object.assign(scene, partial) - gameData.value.scenes = { ...gameData.value.scenes } + gameData.value = { + ...gameData.value, + scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } }, + } + trigger() } function addChoice(sourceId: string) { const scene = gameData.value.scenes[sourceId] if (!scene) return - if (!scene.choices) scene.choices = [] - scene.choices.push({ - text: '新选项', - targetScene: '', - }) - gameData.value.scenes = { ...gameData.value.scenes } + 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 - Object.assign(scene.choices[index], partial) - gameData.value.scenes = { ...gameData.value.scenes } + 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 - scene.choices.splice(index, 1) - gameData.value.scenes = { ...gameData.value.scenes } - } - - function newSceneData(): EditorNode { - return { - id: '', - label: '', - videoUrl: '', - subtitleUrl: '', - choices: [], - nextScene: '', - onEnter: [], - qte: null, + gameData.value = { + ...gameData.value, + scenes: { + ...gameData.value.scenes, + [sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) }, + }, } + trigger() } return { - gameData, selectedNodeId, selectedNode, sceneList, startSceneId, - loadJSON, exportJSON, addScene, deleteScene, updateScene, - addChoice, updateChoice, deleteChoice, - newSceneData, generateId, + gameData, + selectedNodeId, + selectedScene, + sceneList, + sceneNodes, + sceneEdges, + startSceneId, + loadJSON, + exportJSON, + addScene, + deleteScene, + updateScene, + addChoice, + updateChoice, + deleteChoice, + generateId, } }