feat: P3 - visual scenario editor with Vue Flow
- editor/: stand-alone Vite multi-page app for visual scenario editing - editor/components/SceneGraph.vue: Vue Flow graph with scene nodes, branch/default/QTE edges - editor/components/NodeEditor.vue: right panel editing video/subtitle paths, choices, QTE params - editor/components/PreviewPanel.vue: embedded video player previewing selected scene - editor/composables/useGraphEditor.ts: bidirectional graph<->JSON sync - editor/App.vue: toolbar (new scene, import/export JSON, load demo, start scene selector) - @vue-flow/core|background|controls: graph visualization dependencies - vite.config.ts: multi-page build (main + editor) - ROADMAP: mark P3 as completed
This commit is contained in:
331
editor/App.vue
Normal file
331
editor/App.vue
Normal file
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import type { GameData, SceneNode } from '@engine/types'
|
||||
import { useGraphEditor } from './composables/useGraphEditor'
|
||||
import SceneGraph from './components/SceneGraph.vue'
|
||||
import NodeEditor from './components/NodeEditor.vue'
|
||||
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 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
|
||||
})
|
||||
|
||||
function newNode() {
|
||||
const id = editor.addScene()
|
||||
editor.selectedNodeId.value = id
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
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
|
||||
if (!scene.choices) scene.choices = []
|
||||
scene.choices.push({
|
||||
text: `${source} → ${target}`,
|
||||
targetScene: target,
|
||||
})
|
||||
editor.gameData.value.scenes = { ...editor.gameData.value.scenes }
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
async function exportJSON() {
|
||||
const data = editor.exportJSON()
|
||||
const json = JSON.stringify(data, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'scenes.json'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
function importJSON() {
|
||||
fileInputRef.value?.click()
|
||||
}
|
||||
|
||||
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
|
||||
editor.loadJSON(data)
|
||||
dirty.value = false
|
||||
} catch (err) {
|
||||
alert('JSON 解析失败:' + (err as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDemo() {
|
||||
const resp = await fetch('/scenes/demo.json')
|
||||
const data = await resp.json()
|
||||
editor.loadJSON(data)
|
||||
dirty.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDemo()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-layout">
|
||||
<div class="toolbar">
|
||||
<span class="toolbar-title">剧情编辑器</span>
|
||||
<div class="toolbar-actions">
|
||||
<button @click="newNode">+ 新场景</button>
|
||||
<button @click="importJSON" class="secondary">导入 JSON</button>
|
||||
<button @click="exportJSON" class="secondary">导出 JSON</button>
|
||||
<button @click="loadDemo" class="secondary">加载示例</button>
|
||||
<span v-if="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"
|
||||
class="start-select"
|
||||
>
|
||||
<option value="">-- 选择 --</option>
|
||||
<option v-for="n in sceneNodes" :key="n.id" :value="n.id">{{ n.label }}</option>
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="editor-main">
|
||||
<SceneGraph
|
||||
class="graph-area"
|
||||
:scene-nodes="sceneNodes"
|
||||
:scene-edges="sceneEdges"
|
||||
:start-scene="editor.startSceneId.value"
|
||||
:selected-node-id="editor.selectedNodeId.value"
|
||||
@select-node="editor.selectedNodeId.value = $event"
|
||||
@add-edge="onAddEdge"
|
||||
/>
|
||||
|
||||
<NodeEditor
|
||||
:scene="selectedScene"
|
||||
:scene-list="sceneList"
|
||||
@update="onUpdateScene"
|
||||
@add-choice="onAddChoice"
|
||||
@update-choice="onUpdateChoice"
|
||||
@delete-choice="onDeleteChoice"
|
||||
@delete-scene="delNode"
|
||||
@close="editor.selectedNodeId.value = null"
|
||||
/>
|
||||
|
||||
<PreviewPanel :video-url="previewVideoUrl" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept=".json"
|
||||
style="display:none"
|
||||
@change="onFileSelected"
|
||||
/>
|
||||
</div>
|
||||
</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%;
|
||||
}
|
||||
</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;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.editor-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.graph-area {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user