refactor: rewrite editor with immutable state, async-safe Vue Flow, and loading guard

This commit is contained in:
2026-06-07 23:18:43 +08:00
parent 45461b4ed7
commit 4d48463164
3 changed files with 215 additions and 193 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import type { GameData, SceneNode } from '@engine/types' import type { GameData, SceneNode } from '@engine/types'
import { useGraphEditor } from './composables/useGraphEditor' import { useGraphEditor } from './composables/useGraphEditor'
import SceneGraph from './components/SceneGraph.vue' import SceneGraph from './components/SceneGraph.vue'
@@ -9,73 +9,18 @@ import PreviewPanel from './components/PreviewPanel.vue'
const editor = useGraphEditor() const editor = useGraphEditor()
const dirty = ref(false) const dirty = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null) const fileInputRef = ref<HTMLInputElement | null>(null)
const loading = ref(true)
const sceneNodes = computed(() => { const error = ref('')
return Object.values(editor.gameData.value.scenes).map((s) => ({
id: s.id,
label: s.id,
}))
})
const sceneEdges = computed(() => {
const edges: { id: string; source: string; target: string; label?: string }[] = []
for (const [id, scene] of Object.entries(editor.gameData.value.scenes)) {
if (scene.choices) {
let ci = 0
for (const c of scene.choices) {
if (c.targetScene) {
edges.push({
id: `${id}_choice_${ci}`,
source: id,
target: c.targetScene,
label: c.text.slice(0, 10),
})
}
ci++
}
}
if (scene.nextScene) {
edges.push({
id: `${id}_next`,
source: id,
target: scene.nextScene,
label: '→ 默认',
})
}
if (scene.qte) {
if (scene.qte.successScene) {
edges.push({
id: `${id}_qte_s`,
source: id,
target: scene.qte.successScene,
label: 'QTE成功',
})
}
if (scene.qte.failScene) {
edges.push({
id: `${id}_qte_f`,
source: id,
target: scene.qte.failScene,
label: 'QTE失败',
})
}
}
}
return edges
})
const selectedScene = computed<SceneNode | null>(() => { const selectedScene = computed<SceneNode | null>(() => {
if (!editor.selectedNodeId.value) return null if (!editor.selectedNodeId.value) return null
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
}) })
const sceneList = computed(() => editor.sceneList.value)
const previewVideoUrl = computed(() => { const previewVideoUrl = computed(() => {
if (!selectedScene.value?.videoUrl) return null const url = selectedScene.value?.videoUrl
const url = selectedScene.value.videoUrl if (!url) return null
if (url.startsWith('/')) return url return url.startsWith('/') ? url : '/' + url
return '/' + url
}) })
function newNode() { function newNode() {
@@ -112,16 +57,15 @@ function onDeleteChoice(sourceId: string, index: number) {
function onAddEdge(source: string, target: string) { function onAddEdge(source: string, target: string) {
const scene = editor.gameData.value.scenes[source] const scene = editor.gameData.value.scenes[source]
if (!scene) return if (!scene) return
if (!scene.choices) scene.choices = [] const newChoices = [...(scene.choices || []), { text: `${source}${target}`, targetScene: target }]
scene.choices.push({ editor.gameData.value = {
text: `${source}${target}`, ...editor.gameData.value,
targetScene: target, scenes: { ...editor.gameData.value.scenes, [source]: { ...scene, choices: newChoices } },
}) }
editor.gameData.value.scenes = { ...editor.gameData.value.scenes }
dirty.value = true dirty.value = true
} }
async function exportJSON() { function exportJSON() {
const data = editor.exportJSON() const data = editor.exportJSON()
const json = JSON.stringify(data, null, 2) const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' }) const blob = new Blob([json], { type: 'application/json' })
@@ -141,21 +85,29 @@ function importJSON() {
async function onFileSelected(e: Event) { async function onFileSelected(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0] const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return if (!file) return
const text = await file.text()
try { try {
const data = JSON.parse(text) as GameData const data = JSON.parse(await file.text()) as GameData
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
editor.loadJSON(data) editor.loadJSON(data)
dirty.value = false dirty.value = false
} catch (err) { } catch (err: any) {
alert('JSON 解析失败:' + (err as Error).message) error.value = '导入失败:' + err.message
setTimeout(() => (error.value = ''), 3000)
} }
} }
async function loadDemo() { async function loadDemo() {
const resp = await fetch('/scenes/demo.json') try {
const data = await resp.json() loading.value = true
editor.loadJSON(data) const resp = await fetch('/scenes/demo.json')
dirty.value = false if (!resp.ok) throw new Error('HTTP ' + resp.status)
editor.loadJSON(await resp.json())
dirty.value = false
} catch (err: any) {
error.value = '加载示例失败:' + err.message
} finally {
loading.value = false
}
} }
onMounted(() => { onMounted(() => {
@@ -182,25 +134,30 @@ onMounted(() => {
class="start-select" class="start-select"
> >
<option value="">-- 选择 --</option> <option value="">-- 选择 --</option>
<option v-for="n in sceneNodes" :key="n.id" :value="n.id">{{ n.label }}</option> <option v-for="n in editor.sceneNodes.value" :key="n.id" :value="n.id">{{ n.label }}</option>
</select> </select>
</span> </span>
</div> </div>
<div v-if="error" class="error-bar">{{ error }}</div>
<div class="editor-main"> <div class="editor-main">
<SceneGraph <SceneGraph
v-if="!loading"
class="graph-area" class="graph-area"
:scene-nodes="sceneNodes" :scene-nodes="editor.sceneNodes.value"
:scene-edges="sceneEdges" :scene-edges="editor.sceneEdges.value"
:start-scene="editor.startSceneId.value" :start-scene="editor.startSceneId.value"
:selected-node-id="editor.selectedNodeId.value" :selected-node-id="editor.selectedNodeId.value"
@select-node="editor.selectedNodeId.value = $event" @select-node="editor.selectedNodeId.value = $event"
@add-edge="onAddEdge" @add-edge="onAddEdge"
/> />
<div v-else class="graph-area loading-hint">加载剧情数据</div>
<NodeEditor <NodeEditor
:scene="selectedScene" :scene="selectedScene"
:scene-list="sceneList" :scene-list="editor.sceneList.value"
@update="onUpdateScene" @update="onUpdateScene"
@add-choice="onAddChoice" @add-choice="onAddChoice"
@update-choice="onUpdateChoice" @update-choice="onUpdateChoice"
@@ -212,13 +169,7 @@ onMounted(() => {
<PreviewPanel :video-url="previewVideoUrl" /> <PreviewPanel :video-url="previewVideoUrl" />
</div> </div>
<input <input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
ref="fileInputRef"
type="file"
accept=".json"
style="display:none"
@change="onFileSelected"
/>
</div> </div>
</template> </template>
@@ -286,9 +237,7 @@ html, body {
transition: background 0.15s; transition: background 0.15s;
} }
.toolbar-actions button:hover { .toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
background: rgba(255,255,255,0.14);
}
.toolbar-actions button.secondary { .toolbar-actions button.secondary {
color: #8cf; color: #8cf;
@@ -319,6 +268,14 @@ html, body {
color: #ff9800; color: #ff9800;
} }
.error-bar {
padding: 8px 16px;
background: #4a1a1a;
color: #f88;
font-size: 13px;
flex-shrink: 0;
}
.editor-main { .editor-main {
flex: 1; flex: 1;
display: flex; display: flex;
@@ -328,4 +285,12 @@ html, body {
.graph-area { .graph-area {
flex: 1; flex: 1;
} }
.loading-hint {
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 14px;
}
</style> </style>

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch, onMounted, nextTick } from 'vue' import { watch, nextTick } from 'vue'
import { VueFlow, useVueFlow } from '@vue-flow/core' import { VueFlow, useVueFlow } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls' import { Controls } from '@vue-flow/controls'
import '@vue-flow/core/dist/style.css' import '@vue-flow/core/dist/style.css'
import '@vue-flow/controls/dist/style.css' import '@vue-flow/controls/dist/style.css'
import '@vue-flow/core/dist/theme-default.css' import '@vue-flow/core/dist/theme-default.css'
import type { Node, Edge, Connection } from '@vue-flow/core' import type { Connection } from '@vue-flow/core'
const props = defineProps<{ const props = defineProps<{
sceneNodes: { id: string; label: string }[] sceneNodes: { id: string; label: string }[]
@@ -17,82 +17,82 @@ const props = defineProps<{
const emit = defineEmits<{ const emit = defineEmits<{
selectNode: [id: string] selectNode: [id: string]
addNode: []
addEdge: [source: string, target: string] addEdge: [source: string, target: string]
}>() }>()
const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow() const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow()
function buildNodes() { function makeNodes() {
return props.sceneNodes.map((n, i) => ({ return props.sceneNodes.map((n, i) => ({
id: n.id, id: n.id,
type: 'default', type: 'default',
position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 }, position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 },
data: { label: n.id === props.startScene ? ` ${n.label}` : n.label }, data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
style: n.id === props.startScene style: n.id === props.startScene
? 'background:#1b5e20; color:#fff; border-color:#388e3c' ? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
: n.id === props.selectedNodeId : n.id === props.selectedNodeId
? 'background:#1565c0; color:#fff; border-color:#1976d2' ? { background: '#1565c0', color: '#fff', borderColor: '#1976d2' }
: '', : {},
sourcePosition: 'right' as const, sourcePosition: 'right' as const,
targetPosition: 'left' as const, targetPosition: 'left' as const,
connectable: true, connectable: true,
})) }))
} }
function buildEdges() { function makeEdges() {
return props.sceneEdges.map((e) => ({ return props.sceneEdges.map((e) => ({
id: e.id, id: e.id,
source: e.source, source: e.source,
target: e.target, target: e.target,
label: e.label || '', label: e.label ?? '',
animated: true, animated: true,
style: { stroke: '#4fc3f7' },
labelStyle: { fill: '#aaa', fontWeight: 400, fontSize: 11 },
labelBgStyle: { fill: 'rgba(0,0,0,0.7)' },
})) }))
} }
let lastNodesLen = 0 let prevNodeCount = -1
let lastEdgesLen = 0 let prevEdgeCount = -1
let didFitView = false
let rebuildSeq = 0
async function structuralRebuild() { async function structuralRebuild() {
const seq = ++rebuildSeq
edges.value = [] edges.value = []
await nextTick() await nextTick()
nodes.value = buildNodes() if (seq !== rebuildSeq) return
nodes.value = makeNodes()
await nextTick() await nextTick()
edges.value = buildEdges() if (seq !== rebuildSeq) return
lastNodesLen = props.sceneNodes.length edges.value = makeEdges()
lastEdgesLen = props.sceneEdges.length prevNodeCount = props.sceneNodes.length
prevEdgeCount = props.sceneEdges.length
if (!didFitView && nodes.value.length > 0) {
didFitView = true
setTimeout(() => fitView({ padding: 0.2 }), 80)
}
} }
function styleOnlyRebuild() { function inlineRebuild() {
nodes.value = buildNodes() nodes.value = makeNodes()
edges.value = buildEdges() edges.value = makeEdges()
} }
watch(() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId], () => { watch(
const nodesLen = props.sceneNodes.length () => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
const edgesLen = props.sceneEdges.length () => {
if (nodesLen !== lastNodesLen || edgesLen !== lastEdgesLen) { const nc = props.sceneNodes.length
structuralRebuild() const ec = props.sceneEdges.length
} else { if (nc !== prevNodeCount || ec !== prevEdgeCount) {
styleOnlyRebuild() structuralRebuild()
} } else {
}) inlineRebuild()
}
},
)
onNodeClick((event) => { onNodeClick((ev) => emit('selectNode', ev.node.id))
emit('selectNode', event.node.id)
})
onConnect((connection: Connection) => { onConnect((conn: Connection) => {
if (connection.source && connection.target) { if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
emit('addEdge', connection.source, connection.target)
}
})
onMounted(() => {
setTimeout(() => fitView({ padding: 0.2 }), 100)
}) })
</script> </script>

View File

@@ -1,37 +1,58 @@
import { ref, computed } from 'vue' import { ref, computed, shallowRef, triggerRef } from 'vue'
import type { GameData, SceneNode, Choice } from '@engine/types' 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() { export function useGraphEditor() {
const gameData = ref<GameData>({ scenes: {}, startScene: '', variables: {} }) const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(null) const selectedNodeId = ref<string | null>(null)
const startSceneId = ref('') 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 if (!selectedNodeId.value) return null
return gameData.value.scenes[selectedNodeId.value] ?? null return gameData.value.scenes[selectedNodeId.value] ?? null
}) })
const sceneList = computed(() => { function trigger() {
return Object.values(gameData.value.scenes).map((s) => ({ triggerRef(gameData)
id: s.id, }
label: s.id,
}))
})
function loadJSON(json: GameData) { function loadJSON(json: GameData) {
gameData.value = JSON.parse(JSON.stringify(json)) gameData.value = JSON.parse(JSON.stringify(json))
startSceneId.value = json.startScene trigger()
startSceneId.value = json.startScene || ''
selectedNodeId.value = null
} }
function exportJSON(): GameData { function exportJSON(): GameData {
@@ -46,76 +67,112 @@ export function useGraphEditor() {
function addScene(): string { function addScene(): string {
const id = generateId() const id = generateId()
gameData.value.scenes[id] = { gameData.value = {
id, ...gameData.value,
videoUrl: '', scenes: {
choices: [], ...gameData.value.scenes,
nextScene: '', [id]: {
subtitleUrl: '', id,
onEnter: [], videoUrl: '',
choices: [],
nextScene: '',
subtitleUrl: '',
onEnter: [],
},
},
} }
trigger()
return id return id
} }
function deleteScene(id: string) { function deleteScene(id: string) {
if (startSceneId.value === id) return if (startSceneId.value === id) return
delete gameData.value.scenes[id] const nextScenes = { ...gameData.value.scenes }
for (const s of Object.values(gameData.value.scenes)) { delete nextScenes[id]
s.choices = (s.choices || []).filter((c) => c.targetScene !== id) for (const key of Object.keys(nextScenes)) {
if (s.nextScene === id) s.nextScene = '' 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 if (selectedNodeId.value === id) selectedNodeId.value = null
} }
function updateScene(id: string, partial: Partial<EditorNode>) { function updateScene(id: string, partial: Partial<SceneNode>) {
const scene = gameData.value.scenes[id] const scene = gameData.value.scenes[id]
if (!scene) return if (!scene) return
Object.assign(scene, partial) gameData.value = {
gameData.value.scenes = { ...gameData.value.scenes } ...gameData.value,
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
}
trigger()
} }
function addChoice(sourceId: string) { function addChoice(sourceId: string) {
const scene = gameData.value.scenes[sourceId] const scene = gameData.value.scenes[sourceId]
if (!scene) return if (!scene) return
if (!scene.choices) scene.choices = [] gameData.value = {
scene.choices.push({ ...gameData.value,
text: '新选项', scenes: {
targetScene: '', ...gameData.value.scenes,
}) [sourceId]: {
gameData.value.scenes = { ...gameData.value.scenes } ...scene,
choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }],
},
},
}
trigger()
} }
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) { function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
const scene = gameData.value.scenes[sourceId] const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return if (!scene?.choices) return
Object.assign(scene.choices[index], partial) const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
gameData.value.scenes = { ...gameData.value.scenes } gameData.value = {
...gameData.value,
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
}
trigger()
} }
function deleteChoice(sourceId: string, index: number) { function deleteChoice(sourceId: string, index: number) {
const scene = gameData.value.scenes[sourceId] const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return if (!scene?.choices) return
scene.choices.splice(index, 1) gameData.value = {
gameData.value.scenes = { ...gameData.value.scenes } ...gameData.value,
} scenes: {
...gameData.value.scenes,
function newSceneData(): EditorNode { [sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
return { },
id: '',
label: '',
videoUrl: '',
subtitleUrl: '',
choices: [],
nextScene: '',
onEnter: [],
qte: null,
} }
trigger()
} }
return { return {
gameData, selectedNodeId, selectedNode, sceneList, startSceneId, gameData,
loadJSON, exportJSON, addScene, deleteScene, updateScene, selectedNodeId,
addChoice, updateChoice, deleteChoice, selectedScene,
newSceneData, generateId, sceneList,
sceneNodes,
sceneEdges,
startSceneId,
loadJSON,
exportJSON,
addScene,
deleteScene,
updateScene,
addChoice,
updateChoice,
deleteChoice,
generateId,
} }
} }