refactor: rewrite editor with immutable state, async-safe Vue Flow, and loading guard
This commit is contained in:
145
editor/App.vue
145
editor/App.vue
@@ -1,5 +1,5 @@
|
||||
<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 { useGraphEditor } from './composables/useGraphEditor'
|
||||
import SceneGraph from './components/SceneGraph.vue'
|
||||
@@ -9,73 +9,18 @@ import PreviewPanel from './components/PreviewPanel.vue'
|
||||
const editor = useGraphEditor()
|
||||
const dirty = ref(false)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const sceneNodes = computed(() => {
|
||||
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 loading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const selectedScene = computed<SceneNode | null>(() => {
|
||||
if (!editor.selectedNodeId.value) return null
|
||||
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
|
||||
})
|
||||
|
||||
const sceneList = computed(() => editor.sceneList.value)
|
||||
|
||||
const previewVideoUrl = computed(() => {
|
||||
if (!selectedScene.value?.videoUrl) return null
|
||||
const url = selectedScene.value.videoUrl
|
||||
if (url.startsWith('/')) return url
|
||||
return '/' + url
|
||||
const url = selectedScene.value?.videoUrl
|
||||
if (!url) return null
|
||||
return url.startsWith('/') ? url : '/' + url
|
||||
})
|
||||
|
||||
function newNode() {
|
||||
@@ -112,16 +57,15 @@ function onDeleteChoice(sourceId: string, index: number) {
|
||||
function onAddEdge(source: string, target: string) {
|
||||
const scene = editor.gameData.value.scenes[source]
|
||||
if (!scene) return
|
||||
if (!scene.choices) scene.choices = []
|
||||
scene.choices.push({
|
||||
text: `${source} → ${target}`,
|
||||
targetScene: target,
|
||||
})
|
||||
editor.gameData.value.scenes = { ...editor.gameData.value.scenes }
|
||||
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
|
||||
}
|
||||
|
||||
async function exportJSON() {
|
||||
function exportJSON() {
|
||||
const data = editor.exportJSON()
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
@@ -141,21 +85,29 @@ function importJSON() {
|
||||
async function onFileSelected(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
const text = await file.text()
|
||||
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)
|
||||
dirty.value = false
|
||||
} catch (err) {
|
||||
alert('JSON 解析失败:' + (err as Error).message)
|
||||
} catch (err: any) {
|
||||
error.value = '导入失败:' + err.message
|
||||
setTimeout(() => (error.value = ''), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDemo() {
|
||||
const resp = await fetch('/scenes/demo.json')
|
||||
const data = await resp.json()
|
||||
editor.loadJSON(data)
|
||||
dirty.value = false
|
||||
try {
|
||||
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
|
||||
} catch (err: any) {
|
||||
error.value = '加载示例失败:' + err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
@@ -182,25 +134,30 @@ onMounted(() => {
|
||||
class="start-select"
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||
|
||||
<div class="editor-main">
|
||||
<SceneGraph
|
||||
v-if="!loading"
|
||||
class="graph-area"
|
||||
:scene-nodes="sceneNodes"
|
||||
:scene-edges="sceneEdges"
|
||||
: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"
|
||||
/>
|
||||
|
||||
<div v-else class="graph-area loading-hint">加载剧情数据…</div>
|
||||
|
||||
<NodeEditor
|
||||
:scene="selectedScene"
|
||||
:scene-list="sceneList"
|
||||
:scene-list="editor.sceneList.value"
|
||||
@update="onUpdateScene"
|
||||
@add-choice="onAddChoice"
|
||||
@update-choice="onUpdateChoice"
|
||||
@@ -212,13 +169,7 @@ onMounted(() => {
|
||||
<PreviewPanel :video-url="previewVideoUrl" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
<input ref="fileInputRef" type="file" accept=".json" style="display:none" @change="onFileSelected" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -286,9 +237,7 @@ html, body {
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.toolbar-actions button:hover {
|
||||
background: rgba(255,255,255,0.14);
|
||||
}
|
||||
.toolbar-actions button:hover { background: rgba(255,255,255,0.14); }
|
||||
|
||||
.toolbar-actions button.secondary {
|
||||
color: #8cf;
|
||||
@@ -319,6 +268,14 @@ html, body {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.error-bar {
|
||||
padding: 8px 16px;
|
||||
background: #4a1a1a;
|
||||
color: #f88;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -328,4 +285,12 @@ html, body {
|
||||
.graph-area {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #555;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user