297 lines
7.0 KiB
Vue
297 lines
7.0 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import type { GameData, SceneNode } from '@engine/types'
|
|
import { useGraphEditor } from './composables/useGraphEditor'
|
|
import SceneGraph from './components/SceneGraph.vue'
|
|
import NodeEditor from './components/NodeEditor.vue'
|
|
import PreviewPanel from './components/PreviewPanel.vue'
|
|
|
|
const editor = useGraphEditor()
|
|
const dirty = ref(false)
|
|
const fileInputRef = ref<HTMLInputElement | null>(null)
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
|
|
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
|
|
if (!url) return null
|
|
return url.startsWith('/') ? url : '/' + url
|
|
})
|
|
|
|
function newNode() {
|
|
const id = editor.addScene()
|
|
editor.selectedNodeId.value = id
|
|
dirty.value = true
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
function exportJSON() {
|
|
const data = editor.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)
|
|
dirty.value = false
|
|
}
|
|
|
|
function importJSON() {
|
|
fileInputRef.value?.click()
|
|
}
|
|
|
|
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('无效的场景数据')
|
|
editor.loadJSON(data)
|
|
dirty.value = false
|
|
} 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)
|
|
editor.loadJSON(await resp.json())
|
|
dirty.value = false
|
|
} catch (err: any) {
|
|
error.value = '加载示例失败:' + err.message
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadDemo()
|
|
})
|
|
</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>
|
|
<span v-if="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"
|
|
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">
|
|
<SceneGraph
|
|
v-if="!loading"
|
|
class="graph-area"
|
|
: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"
|
|
/>
|
|
|
|
<div v-else class="graph-area loading-hint">加载剧情数据…</div>
|
|
|
|
<NodeEditor
|
|
:scene="selectedScene"
|
|
:scene-list="editor.sceneList.value"
|
|
@update="onUpdateScene"
|
|
@add-choice="onAddChoice"
|
|
@update-choice="onUpdateChoice"
|
|
@delete-choice="onDeleteChoice"
|
|
@delete-scene="delNode"
|
|
@close="editor.selectedNodeId.value = null"
|
|
/>
|
|
|
|
<PreviewPanel :video-url="previewVideoUrl" />
|
|
</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%;
|
|
}
|
|
</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-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>
|