Files
tianshu-engine/editor/App.vue

332 lines
7.5 KiB
Vue

<script setup lang="ts">
import { ref, computed, watch, 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 sceneNodes = computed(() => {
return Object.values(editor.gameData.value.scenes).map((s) => ({
id: s.id,
label: s.id,
}))
})
const sceneEdges = computed(() => {
const edges: { id: string; source: string; target: string; label?: string }[] = []
for (const [id, scene] of Object.entries(editor.gameData.value.scenes)) {
if (scene.choices) {
let ci = 0
for (const c of scene.choices) {
if (c.targetScene) {
edges.push({
id: `${id}_choice_${ci}`,
source: id,
target: c.targetScene,
label: c.text.slice(0, 10),
})
}
ci++
}
}
if (scene.nextScene) {
edges.push({
id: `${id}_next`,
source: id,
target: scene.nextScene,
label: '→ 默认',
})
}
if (scene.qte) {
if (scene.qte.successScene) {
edges.push({
id: `${id}_qte_s`,
source: id,
target: scene.qte.successScene,
label: 'QTE成功',
})
}
if (scene.qte.failScene) {
edges.push({
id: `${id}_qte_f`,
source: id,
target: scene.qte.failScene,
label: 'QTE失败',
})
}
}
}
return edges
})
const selectedScene = computed<SceneNode | null>(() => {
if (!editor.selectedNodeId.value) return null
return editor.gameData.value.scenes[editor.selectedNodeId.value] ?? null
})
const sceneList = computed(() => editor.sceneList.value)
const previewVideoUrl = computed(() => {
if (!selectedScene.value?.videoUrl) return null
const url = selectedScene.value.videoUrl
if (url.startsWith('/')) return url
return '/' + 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
if (!scene.choices) scene.choices = []
scene.choices.push({
text: `${source}${target}`,
targetScene: target,
})
editor.gameData.value.scenes = { ...editor.gameData.value.scenes }
dirty.value = true
}
async 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
const text = await file.text()
try {
const data = JSON.parse(text) as GameData
editor.loadJSON(data)
dirty.value = false
} catch (err) {
alert('JSON 解析失败:' + (err as Error).message)
}
}
async function loadDemo() {
const resp = await fetch('/scenes/demo.json')
const data = await resp.json()
editor.loadJSON(data)
dirty.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 sceneNodes" :key="n.id" :value="n.id">{{ n.label }}</option>
</select>
</span>
</div>
<div class="editor-main">
<SceneGraph
class="graph-area"
:scene-nodes="sceneNodes"
:scene-edges="sceneEdges"
:start-scene="editor.startSceneId.value"
:selected-node-id="editor.selectedNodeId.value"
@select-node="editor.selectedNodeId.value = $event"
@add-edge="onAddEdge"
/>
<NodeEditor
:scene="selectedScene"
:scene-list="sceneList"
@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;
}
.editor-main {
flex: 1;
display: flex;
overflow: hidden;
}
.graph-area {
flex: 1;
}
</style>