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:
2026-06-07 21:38:08 +08:00
parent 65c26e0972
commit 3b4c6d7024
11 changed files with 1245 additions and 8 deletions

View File

@@ -0,0 +1,121 @@
import { ref, computed } 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<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(null)
const startSceneId = ref('')
const selectedNode = 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 loadJSON(json: GameData) {
gameData.value = JSON.parse(JSON.stringify(json))
startSceneId.value = json.startScene
}
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.scenes[id] = {
id,
videoUrl: '',
choices: [],
nextScene: '',
subtitleUrl: '',
onEnter: [],
}
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 = ''
}
if (selectedNodeId.value === id) selectedNodeId.value = null
}
function updateScene(id: string, partial: Partial<EditorNode>) {
const scene = gameData.value.scenes[id]
if (!scene) return
Object.assign(scene, partial)
gameData.value.scenes = { ...gameData.value.scenes }
}
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 }
}
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return
Object.assign(scene.choices[index], partial)
gameData.value.scenes = { ...gameData.value.scenes }
}
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,
}
}
return {
gameData, selectedNodeId, selectedNode, sceneList, startSceneId,
loadJSON, exportJSON, addScene, deleteScene, updateScene,
addChoice, updateChoice, deleteChoice,
newSceneData, generateId,
}
}