feat: editor services, stores, and graph improvements
This commit is contained in:
@@ -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 | 撤销/重做 |
|
||||
|
||||
236
editor/App.vue
236
editor/App.vue
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
46
editor/services/GraphService.ts
Normal file
46
editor/services/GraphService.ts
Normal 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 }))
|
||||
}
|
||||
132
editor/stores/editorStore.ts
Normal file
132
editor/stores/editorStore.ts
Normal 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,
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user