feat: editor services, stores, and graph improvements
This commit is contained in:
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>
|
||||
|
||||
Reference in New Issue
Block a user