Files
tianshu-engine/editor/App.vue

183 lines
6.8 KiB
Vue

<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
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 fileInputRef = ref<HTMLInputElement | null>(null)
const loading = ref(true)
const error = ref('')
const showPreview = ref(false)
const previewVideoUrl = computed(() => {
const url = store.selectedScene?.videoUrl
if (!url) return null
const resolved = resolveAsset(store.gameData.assetBase || '', url)
return resolved.startsWith('/') ? resolved : '/' + resolved
})
function newNode() {
const id = store.addScene()
store.selectedNodeId = id
}
function delNode(id: string) {
store.deleteScene(id)
}
function 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)
const a = document.createElement('a')
a.href = url
a.download = 'scenes.json'
a.click()
URL.revokeObjectURL(url)
store.dirty = false
}
function importJSON() {
fileInputRef.value?.click()
}
function testScene(id: string) {
window.open('/?scene=' + store.sourcePath + '&startScene=' + id, '_blank')
}
async function onFileSelected(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
try {
const data = JSON.parse(await file.text()) as GameData
if (!data.scenes || typeof data.scenes !== 'object') throw new Error('无效的场景数据')
store.loadJSON(data)
store.setSourcePath('/scenes/' + file.name)
} catch (err: any) {
error.value = '导入失败:' + err.message
setTimeout(() => (error.value = ''), 3000)
}
}
async function loadDemo() {
try {
loading.value = true
const resp = await fetch('/scenes/demo.json')
if (!resp.ok) throw new Error('HTTP ' + resp.status)
store.loadJSON(await resp.json())
} catch (err: any) {
error.value = '加载示例失败:' + err.message
} finally {
loading.value = false
}
}
async function restoreOrLoad() {
const lastSource = localStorage.getItem('editor_last_source')
if (lastSource) {
try {
loading.value = true
const resp = await fetch(lastSource)
if (resp.ok) {
store.loadJSON(await resp.json())
store.setSourcePath(lastSource)
return
}
} catch {}
}
await loadDemo()
}
onMounted(() => restoreOrLoad())
</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>
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
</button>
<span v-if="store.dirty" class="dirty-indicator"> 未保存</span>
</div>
<span class="toolbar-start">
起始场景:
<select
:value="store.startSceneId"
@change="store.startSceneId = ($event.target as HTMLSelectElement).value; store.markDirty()"
class="start-select"
>
<option value="">-- 选择 --</option>
<option v-for="n in editor.sceneNodes.value" :key="n.id" :value="n.id">{{ n.label }}</option>
</select>
</span>
</div>
<div v-if="error" class="error-bar">{{ error }}</div>
<div class="editor-main">
<div class="graph-area">
<SceneGraph
v-if="!loading && !showPreview"
:scene-nodes="editor.sceneNodes.value"
:scene-edges="editor.sceneEdges.value"
:start-scene="store.startSceneId"
:selected-node-id="store.selectedNodeId"
@select-node="store.selectedNodeId = $event"
@add-edge="editor.onAddEdge"
@test-scene="testScene"
@clear-selection="store.selectedNodeId = null"
/>
<div v-else-if="loading" class="loading-hint">加载剧情数据</div>
<PreviewPanel v-if="showPreview" :video-url="previewVideoUrl" />
</div>
<NodeEditor
:scene="store.selectedScene"
:scene-list="editor.sceneList.value"
@delete-scene="delNode"
@close="store.selectedNodeId = null"
/>
</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%; }
.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; }
.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; height: 100%; color: #555; font-size: 14px; }
</style>