feat: editor services, stores, and graph improvements
This commit is contained in:
@@ -121,3 +121,44 @@ P3 完成的编辑器支持基本的场景节点编辑。以下是与引擎 P4~P
|
|||||||
| **P2** | E14 | 面板标签页——属性字段达到 20+ 后优势明显 |
|
| **P2** | E14 | 面板标签页——属性字段达到 20+ 后优势明显 |
|
||||||
| **P3** | E2/E3/E5 | 单字段编辑——BGM/loop/prompt,改动小 |
|
| **P3** | E2/E3/E5 | 单字段编辑——BGM/loop/prompt,改动小 |
|
||||||
| **P3** | E13 | 撤销/重做——重要但改动不小 |
|
| **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">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
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 { resolveAsset } from '@engine/utils'
|
||||||
import { useGraphEditor } from './composables/useGraphEditor'
|
import { useGraphEditor } from './composables/useGraphEditor'
|
||||||
|
import { useEditorStore } from './stores/editorStore'
|
||||||
import SceneGraph from './components/SceneGraph.vue'
|
import SceneGraph from './components/SceneGraph.vue'
|
||||||
import NodeEditor from './components/NodeEditor.vue'
|
import NodeEditor from './components/NodeEditor.vue'
|
||||||
import PreviewPanel from './components/PreviewPanel.vue'
|
import PreviewPanel from './components/PreviewPanel.vue'
|
||||||
|
|
||||||
|
const store = useEditorStore()
|
||||||
const editor = useGraphEditor()
|
const editor = useGraphEditor()
|
||||||
const dirty = ref(false)
|
|
||||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showPreview = ref(false)
|
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 previewVideoUrl = computed(() => {
|
||||||
const url = selectedScene.value?.videoUrl
|
const url = store.selectedScene?.videoUrl
|
||||||
if (!url) return null
|
if (!url) return null
|
||||||
return resolveAsset(editor.gameData.value.assetBase || '', url)
|
return resolveAsset(store.gameData.assetBase || '', url)
|
||||||
})
|
})
|
||||||
|
|
||||||
function newNode() {
|
function newNode() {
|
||||||
const id = editor.addScene()
|
const id = store.addScene()
|
||||||
editor.selectedNodeId.value = id
|
store.selectedNodeId = id
|
||||||
dirty.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function delNode(id: string) {
|
function delNode(id: string) {
|
||||||
editor.deleteScene(id)
|
store.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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportJSON() {
|
function exportJSON() {
|
||||||
const data = editor.exportJSON()
|
const data = store.exportJSON()
|
||||||
const json = JSON.stringify(data, null, 2)
|
const json = JSON.stringify(data, null, 2)
|
||||||
const blob = new Blob([json], { type: 'application/json' })
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
@@ -77,7 +40,7 @@ function exportJSON() {
|
|||||||
a.download = 'scenes.json'
|
a.download = 'scenes.json'
|
||||||
a.click()
|
a.click()
|
||||||
URL.revokeObjectURL(url)
|
URL.revokeObjectURL(url)
|
||||||
dirty.value = false
|
store.dirty = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function importJSON() {
|
function importJSON() {
|
||||||
@@ -90,8 +53,7 @@ async function onFileSelected(e: Event) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(await file.text()) as GameData
|
const data = JSON.parse(await file.text()) as GameData
|
||||||
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
|
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
|
||||||
editor.loadJSON(data)
|
store.loadJSON(data)
|
||||||
dirty.value = false
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = '导入失败:' + err.message
|
error.value = '导入失败:' + err.message
|
||||||
setTimeout(() => (error.value = ''), 3000)
|
setTimeout(() => (error.value = ''), 3000)
|
||||||
@@ -103,8 +65,7 @@ async function loadDemo() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
const resp = await fetch('/scenes/demo.json')
|
const resp = await fetch('/scenes/demo.json')
|
||||||
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
if (!resp.ok) throw new Error('HTTP ' + resp.status)
|
||||||
editor.loadJSON(await resp.json())
|
store.loadJSON(await resp.json())
|
||||||
dirty.value = false
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
error.value = '加载示例失败:' + err.message
|
error.value = '加载示例失败:' + err.message
|
||||||
} finally {
|
} finally {
|
||||||
@@ -112,9 +73,7 @@ async function loadDemo() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => loadDemo())
|
||||||
loadDemo()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -129,13 +88,13 @@ onMounted(() => {
|
|||||||
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
|
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
|
||||||
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
|
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
|
||||||
</button>
|
</button>
|
||||||
<span v-if="dirty" class="dirty-indicator">● 未保存</span>
|
<span v-if="store.dirty" class="dirty-indicator">● 未保存</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="toolbar-start">
|
<span class="toolbar-start">
|
||||||
起始场景:
|
起始场景:
|
||||||
<select
|
<select
|
||||||
:value="editor.startSceneId.value"
|
:value="store.startSceneId"
|
||||||
@change="editor.startSceneId.value = ($event.target as HTMLSelectElement).value; dirty = true"
|
@change="store.startSceneId = ($event.target as HTMLSelectElement).value; store.markDirty()"
|
||||||
class="start-select"
|
class="start-select"
|
||||||
>
|
>
|
||||||
<option value="">-- 选择 --</option>
|
<option value="">-- 选择 --</option>
|
||||||
@@ -152,10 +111,10 @@ onMounted(() => {
|
|||||||
v-if="!loading && !showPreview"
|
v-if="!loading && !showPreview"
|
||||||
:scene-nodes="editor.sceneNodes.value"
|
:scene-nodes="editor.sceneNodes.value"
|
||||||
:scene-edges="editor.sceneEdges.value"
|
:scene-edges="editor.sceneEdges.value"
|
||||||
:start-scene="editor.startSceneId.value"
|
:start-scene="store.startSceneId"
|
||||||
:selected-node-id="editor.selectedNodeId.value"
|
:selected-node-id="store.selectedNodeId"
|
||||||
@select-node="editor.selectedNodeId.value = $event"
|
@select-node="store.selectedNodeId = $event"
|
||||||
@add-edge="onAddEdge"
|
@add-edge="editor.onAddEdge"
|
||||||
@test-scene="(id: string) => window.open('/index.html?scene=' + id, '_blank')"
|
@test-scene="(id: string) => window.open('/index.html?scene=' + id, '_blank')"
|
||||||
/>
|
/>
|
||||||
<div v-else-if="loading" class="loading-hint">加载剧情数据…</div>
|
<div v-else-if="loading" class="loading-hint">加载剧情数据…</div>
|
||||||
@@ -163,14 +122,14 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NodeEditor
|
<NodeEditor
|
||||||
:scene="selectedScene"
|
:scene="store.selectedScene"
|
||||||
:scene-list="editor.sceneList.value"
|
:scene-list="editor.sceneList.value"
|
||||||
@update="onUpdateScene"
|
@update="store.updateScene"
|
||||||
@add-choice="onAddChoice"
|
@add-choice="store.addChoice"
|
||||||
@update-choice="onUpdateChoice"
|
@update-choice="store.updateChoice"
|
||||||
@delete-choice="onDeleteChoice"
|
@delete-choice="store.deleteChoice"
|
||||||
@delete-scene="delNode"
|
@delete-scene="delNode"
|
||||||
@close="editor.selectedNodeId.value = null"
|
@close="store.selectedNodeId = null"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,133 +138,26 @@ onMounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* {
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
margin: 0;
|
html, body { width: 100%; height: 100%; overflow: hidden; background: #0a0a16; color: #ccc; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
|
||||||
padding: 0;
|
#editor-app { width: 100%; height: 100%; }
|
||||||
box-sizing: border-box;
|
.graph-area .preview-panel { width: 100%; border-left: none; }
|
||||||
}
|
|
||||||
|
|
||||||
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>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.editor-layout {
|
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; }
|
||||||
width: 100%;
|
.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; }
|
||||||
height: 100%;
|
.toolbar-title { font-size: 15px; font-weight: 600; color: #ddd; letter-spacing: 1px; margin-right: 16px; }
|
||||||
display: flex;
|
.toolbar-actions { display: flex; gap: 8px; }
|
||||||
flex-direction: column;
|
.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 {
|
|
||||||
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: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.secondary {
|
.toolbar-actions button.active { background: rgba(100,200,255,0.18); color: #fff; }
|
||||||
color: #8cf;
|
.toolbar-start { margin-left: auto; font-size: 12px; color: #777; display: flex; align-items: center; gap: 6px; }
|
||||||
border-color: rgba(100,200,255,0.2);
|
.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; }
|
||||||
background: rgba(100,200,255,0.06);
|
.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; }
|
||||||
.toolbar-actions button.active {
|
.graph-area { flex: 1; }
|
||||||
background: rgba(100,200,255,0.18);
|
.loading-hint { display: flex; align-items: center; justify-content: center; height: 100%; color: #555; font-size: 14px; }
|
||||||
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;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,192 +1,41 @@
|
|||||||
import { ref, computed, shallowRef, triggerRef } from 'vue'
|
import { computed } from 'vue'
|
||||||
import type { GameData, SceneNode, Choice } from '@engine/types'
|
import { useEditorStore } from '../stores/editorStore'
|
||||||
|
import { computeEdges, computeSceneNodes } from '../services/GraphService'
|
||||||
|
|
||||||
export function useGraphEditor() {
|
export function useGraphEditor() {
|
||||||
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
|
const store = useEditorStore()
|
||||||
const selectedNodeId = ref<string | null>(null)
|
|
||||||
const startSceneId = ref('')
|
|
||||||
|
|
||||||
const sceneList = computed(() =>
|
const sceneNodes = computed(() => computeSceneNodes(store.gameData))
|
||||||
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
const sceneEdges = computed(() => computeEdges(store.gameData))
|
||||||
)
|
const sceneList = computed(() => computeSceneNodes(store.gameData))
|
||||||
|
|
||||||
const sceneNodes = computed(() =>
|
function onAddEdge(source: string, target: string) {
|
||||||
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
|
const scene = store.gameData.scenes[source]
|
||||||
)
|
|
||||||
|
|
||||||
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]
|
|
||||||
if (!scene) return
|
if (!scene) return
|
||||||
gameData.value = {
|
const newChoices = [...(scene.choices || []), { text: `${source} → ${target}`, targetScene: target }]
|
||||||
...gameData.value,
|
store.gameData = {
|
||||||
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
|
...store.gameData,
|
||||||
|
scenes: { ...store.gameData.scenes, [source]: { ...scene, choices: newChoices } },
|
||||||
}
|
}
|
||||||
trigger()
|
store.markDirty()
|
||||||
}
|
|
||||||
|
|
||||||
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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gameData,
|
gameData: store.gameData,
|
||||||
selectedNodeId,
|
selectedNodeId: store.selectedNodeId,
|
||||||
selectedScene,
|
selectedScene: store.selectedScene,
|
||||||
sceneList,
|
startSceneId: store.startSceneId,
|
||||||
sceneNodes,
|
sceneNodes,
|
||||||
sceneEdges,
|
sceneEdges,
|
||||||
startSceneId,
|
sceneList,
|
||||||
loadJSON,
|
loadJSON: store.loadJSON,
|
||||||
exportJSON,
|
exportJSON: store.exportJSON,
|
||||||
addScene,
|
updateScene: store.updateScene,
|
||||||
deleteScene,
|
addChoice: store.addChoice,
|
||||||
updateScene,
|
updateChoice: store.updateChoice,
|
||||||
addChoice,
|
deleteChoice: store.deleteChoice,
|
||||||
updateChoice,
|
addScene: store.addScene,
|
||||||
deleteChoice,
|
deleteScene: store.deleteScene,
|
||||||
generateId,
|
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