feat: editor services, stores, and graph improvements

This commit is contained in:
2026-06-14 17:46:34 +08:00
parent 271c909398
commit 82bfae0e1b
5 changed files with 291 additions and 371 deletions

View File

@@ -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 | 撤销/重做 |

View File

@@ -1,74 +1,37 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { GameData, SceneNode } from '@engine/types'
import type { GameData } from '@engine/types'
import { resolveAsset } from '@engine/utils'
import { useGraphEditor } from './composables/useGraphEditor'
import { useEditorStore } from './stores/editorStore'
import SceneGraph from './components/SceneGraph.vue'
import NodeEditor from './components/NodeEditor.vue'
import PreviewPanel from './components/PreviewPanel.vue'
const store = useEditorStore()
const editor = useGraphEditor()
const dirty = ref(false)
const fileInputRef = ref<HTMLInputElement | null>(null)
const loading = ref(true)
const error = ref('')
const showPreview = ref(false)
const selectedScene = computed<SceneNode | null>(() => {
if (!editor.selectedNodeId.value) return null
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
})
const previewVideoUrl = computed(() => {
const url = selectedScene.value?.videoUrl
const url = store.selectedScene?.videoUrl
if (!url) return null
return resolveAsset(editor.gameData.value.assetBase || '', url)
return resolveAsset(store.gameData.assetBase || '', url)
})
function newNode() {
const id = editor.addScene()
editor.selectedNodeId.value = id
dirty.value = true
const id = store.addScene()
store.selectedNodeId = id
}
function delNode(id: string) {
editor.deleteScene(id)
dirty.value = true
}
function onUpdateScene(id: string, partial: any) {
editor.updateScene(id, partial)
dirty.value = true
}
function onAddChoice(sourceId: string) {
editor.addChoice(sourceId)
dirty.value = true
}
function onUpdateChoice(sourceId: string, index: number, partial: any) {
editor.updateChoice(sourceId, index, partial)
dirty.value = true
}
function onDeleteChoice(sourceId: string, index: number) {
editor.deleteChoice(sourceId, index)
dirty.value = true
}
function onAddEdge(source: string, target: string) {
const scene = editor.gameData.value.scenes[source]
if (!scene) return
const newChoices = [...(scene.choices || []), { text: `${source}${target}`, targetScene: target }]
editor.gameData.value = {
...editor.gameData.value,
scenes: { ...editor.gameData.value.scenes, [source]: { ...scene, choices: newChoices } },
}
dirty.value = true
store.deleteScene(id)
}
function exportJSON() {
const data = editor.exportJSON()
const data = store.exportJSON()
const json = JSON.stringify(data, null, 2)
const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob)
@@ -77,7 +40,7 @@ function exportJSON() {
a.download = 'scenes.json'
a.click()
URL.revokeObjectURL(url)
dirty.value = false
store.dirty = false
}
function importJSON() {
@@ -90,8 +53,7 @@ async function onFileSelected(e: Event) {
try {
const data = JSON.parse(await file.text()) as GameData
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
editor.loadJSON(data)
dirty.value = false
store.loadJSON(data)
} catch (err: any) {
error.value = '导入失败:' + err.message
setTimeout(() => (error.value = ''), 3000)
@@ -103,8 +65,7 @@ async function loadDemo() {
loading.value = true
const resp = await fetch('/scenes/demo.json')
if (!resp.ok) throw new Error('HTTP ' + resp.status)
editor.loadJSON(await resp.json())
dirty.value = false
store.loadJSON(await resp.json())
} catch (err: any) {
error.value = '加载示例失败:' + err.message
} finally {
@@ -112,9 +73,7 @@ async function loadDemo() {
}
}
onMounted(() => {
loadDemo()
})
onMounted(() => loadDemo())
</script>
<template>
@@ -129,13 +88,13 @@ onMounted(() => {
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
</button>
<span v-if="dirty" class="dirty-indicator"> 未保存</span>
<span v-if="store.dirty" class="dirty-indicator"> 未保存</span>
</div>
<span class="toolbar-start">
起始场景:
<select
:value="editor.startSceneId.value"
@change="editor.startSceneId.value = ($event.target as HTMLSelectElement).value; dirty = true"
:value="store.startSceneId"
@change="store.startSceneId = ($event.target as HTMLSelectElement).value; store.markDirty()"
class="start-select"
>
<option value="">-- 选择 --</option>
@@ -152,10 +111,10 @@ onMounted(() => {
v-if="!loading && !showPreview"
:scene-nodes="editor.sceneNodes.value"
:scene-edges="editor.sceneEdges.value"
:start-scene="editor.startSceneId.value"
:selected-node-id="editor.selectedNodeId.value"
@select-node="editor.selectedNodeId.value = $event"
@add-edge="onAddEdge"
:start-scene="store.startSceneId"
:selected-node-id="store.selectedNodeId"
@select-node="store.selectedNodeId = $event"
@add-edge="editor.onAddEdge"
@test-scene="(id: string) => window.open('/index.html?scene=' + id, '_blank')"
/>
<div v-else-if="loading" class="loading-hint">加载剧情数据…</div>
@@ -163,14 +122,14 @@ onMounted(() => {
</div>
<NodeEditor
:scene="selectedScene"
:scene="store.selectedScene"
:scene-list="editor.sceneList.value"
@update="onUpdateScene"
@add-choice="onAddChoice"
@update-choice="onUpdateChoice"
@delete-choice="onDeleteChoice"
@update="store.updateScene"
@add-choice="store.addChoice"
@update-choice="store.updateChoice"
@delete-choice="store.deleteChoice"
@delete-scene="delNode"
@close="editor.selectedNodeId.value = null"
@close="store.selectedNodeId = null"
/>
</div>
@@ -179,133 +138,26 @@ onMounted(() => {
</template>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #0a0a16;
color: #ccc;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
#editor-app {
width: 100%;
height: 100%;
}
.graph-area .preview-panel {
width: 100%;
border-left: none;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a16; color: #ccc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
#editor-app { width: 100%; height: 100%; }
.graph-area .preview-panel { width: 100%; border-left: none; }
</style>
<style scoped>
.editor-layout {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
background: #111122;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.toolbar-title {
font-size: 15px;
font-weight: 600;
color: #ddd;
letter-spacing: 1px;
margin-right: 16px;
}
.toolbar-actions {
display: flex;
gap: 8px;
}
.toolbar-actions button {
padding: 6px 14px;
font-size: 12px;
color: #ddd;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.15);
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; }
.toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; background: #111122; border-bottom: 1px solid rgba(255,255,255,0.06); flex-shrink: 0; }
.toolbar-title { font-size: 15px; font-weight: 600; color: #ddd; letter-spacing: 1px; margin-right: 16px; }
.toolbar-actions { display: flex; gap: 8px; }
.toolbar-actions button { padding: 6px 14px; font-size: 12px; color: #ddd; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); border-radius: 4px; cursor: pointer; transition: background 0.15s; }
.toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
.toolbar-actions button.secondary {
color: #8cf;
border-color: rgba(100,200,255,0.2);
background: rgba(100,200,255,0.06);
}
.toolbar-actions button.active {
background: rgba(100,200,255,0.18);
color: #fff;
}
.toolbar-start {
margin-left: auto;
font-size: 12px;
color: #777;
display: flex;
align-items: center;
gap: 6px;
}
.start-select {
padding: 4px 8px;
font-size: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
color: #ccc;
}
.dirty-indicator {
font-size: 11px;
color: #ff9800;
}
.error-bar {
padding: 8px 16px;
background: #4a1a1a;
color: #f88;
font-size: 13px;
flex-shrink: 0;
}
.editor-main {
flex: 1;
display: flex;
overflow: hidden;
}
.graph-area {
flex: 1;
}
.loading-hint {
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 14px;
}
.toolbar-actions button.secondary { color: #8cf; border-color: rgba(100,200,255,0.2); background: rgba(100,200,255,0.06); }
.toolbar-actions button.active { background: rgba(100,200,255,0.18); color: #fff; }
.toolbar-start { margin-left: auto; font-size: 12px; color: #777; display: flex; align-items: center; gap: 6px; }
.start-select { padding: 4px 8px; font-size: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 3px; color: #ccc; }
.dirty-indicator { font-size: 11px; color: #ff9800; }
.error-bar { padding: 8px 16px; background: #4a1a1a; color: #f88; font-size: 13px; flex-shrink: 0; }
.editor-main { flex: 1; display: flex; overflow: hidden; }
.graph-area { flex: 1; }
.loading-hint { display: flex; align-items: center; justify-content: center; height: 100%; color: #555; font-size: 14px; }
</style>

View File

@@ -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<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(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<SceneNode>) {
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<Choice>) {
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,
}
}

View File

@@ -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 }))
}

View File

@@ -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<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(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<SceneNode>) {
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<Choice>) {
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,
}
})