feat: P3 - visual scenario editor with Vue Flow
- editor/: stand-alone Vite multi-page app for visual scenario editing - editor/components/SceneGraph.vue: Vue Flow graph with scene nodes, branch/default/QTE edges - editor/components/NodeEditor.vue: right panel editing video/subtitle paths, choices, QTE params - editor/components/PreviewPanel.vue: embedded video player previewing selected scene - editor/composables/useGraphEditor.ts: bidirectional graph<->JSON sync - editor/App.vue: toolbar (new scene, import/export JSON, load demo, start scene selector) - @vue-flow/core|background|controls: graph visualization dependencies - vite.config.ts: multi-page build (main + editor) - ROADMAP: mark P3 as completed
This commit is contained in:
18
ROADMAP.md
18
ROADMAP.md
@@ -167,15 +167,17 @@ interface SaveData {
|
|||||||
- [x] 完整事件总线(sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd)
|
- [x] 完整事件总线(sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd)
|
||||||
- [x] 验证:QTE 正常触发与判定(ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常
|
- [x] 验证:QTE 正常触发与判定(ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常
|
||||||
|
|
||||||
### P3 编辑器 — 可视化剧情编辑(2-3 周)
|
### P3 编辑器 — 可视化剧情编辑(2-3 周)✅ 已完成 2026-06-07
|
||||||
|
|
||||||
- [ ] 编辑器入口:独立 `editor/index.html` + `editor/main.ts`
|
- [x] 编辑器入口:独立 `editor/index.html` + `editor/main.ts`(Vite 多入口构建)
|
||||||
- [ ] `editor/components/SceneGraph.vue` — Vue Flow 节点图(节点=场景,边=选择分支)
|
- [x] `editor/components/SceneGraph.vue` — Vue Flow 节点图(场景节点 + 分支/默认/QTE 连线)
|
||||||
- [ ] `editor/components/NodeEditor.vue` — 右侧面板,编辑选中节点的视频、选项、QTE、条件/效果
|
- [x] `editor/components/NodeEditor.vue` — 右侧面板(视频/字幕路径、nextScene、选项增删改、QTE 参数编辑)
|
||||||
- [ ] `editor/components/PreviewPanel.vue` — 嵌入播放器,实时预览当前编辑的剧情线
|
- [x] `editor/components/PreviewPanel.vue` — 嵌入播放器实时预览选中场景视频
|
||||||
- [ ] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步
|
- [x] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步
|
||||||
- [ ] JSON 导出/导入
|
- [x] JSON 导出/导入(文件下载 + 文件选择)
|
||||||
- [ ] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行
|
- [x] 工具栏:新建场景、导入 JSON、导出 JSON、加载示例、起始场景选择
|
||||||
|
- [x] `vite.config.ts` — 多页面构建(main + editor)
|
||||||
|
- [x] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行
|
||||||
|
|
||||||
## 依赖清单
|
## 依赖清单
|
||||||
|
|
||||||
|
|||||||
331
editor/App.vue
Normal file
331
editor/App.vue
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<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>
|
||||||
385
editor/components/NodeEditor.vue
Normal file
385
editor/components/NodeEditor.vue
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import type { SceneNode, Choice } from '@engine/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
scene: SceneNode | null
|
||||||
|
sceneList: { id: string; label: string }[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [id: string, partial: Partial<SceneNode>]
|
||||||
|
addChoice: [sourceId: string]
|
||||||
|
updateChoice: [sourceId: string, index: number, partial: Partial<Choice>]
|
||||||
|
deleteChoice: [sourceId: string, index: number]
|
||||||
|
deleteScene: [id: string]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localVideo = ref('')
|
||||||
|
const localSubtitle = ref('')
|
||||||
|
const localNextScene = ref('')
|
||||||
|
const editingQTE = ref(false)
|
||||||
|
const localQteTime = ref(1)
|
||||||
|
const localQtePrompt = ref('')
|
||||||
|
const localQteKeys = ref('')
|
||||||
|
const localQteLimit = ref(3)
|
||||||
|
const localQteSuccess = ref('')
|
||||||
|
const localQteFail = ref('')
|
||||||
|
|
||||||
|
watch(() => props.scene, (s) => {
|
||||||
|
if (!s) return
|
||||||
|
localVideo.value = s.videoUrl || ''
|
||||||
|
localSubtitle.value = s.subtitleUrl || ''
|
||||||
|
localNextScene.value = s.nextScene || ''
|
||||||
|
if (s.qte) {
|
||||||
|
editingQTE.value = true
|
||||||
|
localQteTime.value = s.qte.triggerTime
|
||||||
|
localQtePrompt.value = s.qte.prompt
|
||||||
|
localQteKeys.value = s.qte.keys.join(', ')
|
||||||
|
localQteLimit.value = s.qte.timeLimit
|
||||||
|
localQteSuccess.value = s.qte.successScene
|
||||||
|
localQteFail.value = s.qte.failScene
|
||||||
|
} else {
|
||||||
|
editingQTE.value = false
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function saveVideo() {
|
||||||
|
if (!props.scene) return
|
||||||
|
emit('update', props.scene.id, { videoUrl: localVideo.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSubtitle() {
|
||||||
|
if (!props.scene) return
|
||||||
|
emit('update', props.scene.id, { subtitleUrl: localSubtitle.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveNextScene() {
|
||||||
|
if (!props.scene) return
|
||||||
|
emit('update', props.scene.id, { nextScene: localNextScene.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveQTE() {
|
||||||
|
if (!props.scene) return
|
||||||
|
emit('update', props.scene.id, {
|
||||||
|
qte: editingQTE.value ? {
|
||||||
|
triggerTime: localQteTime.value,
|
||||||
|
prompt: localQtePrompt.value,
|
||||||
|
keys: localQteKeys.value.split(',').map((k) => k.trim()).filter(Boolean),
|
||||||
|
timeLimit: localQteLimit.value,
|
||||||
|
successScene: localQteSuccess.value,
|
||||||
|
failScene: localQteFail.value,
|
||||||
|
} : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="node-editor" v-if="scene">
|
||||||
|
<div class="editor-header">
|
||||||
|
<h3>{{ scene.id }}</h3>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">🗑</button>
|
||||||
|
<button class="icon-btn" @click="emit('close')" title="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<div class="field-group">
|
||||||
|
<label>视频路径</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<input v-model="localVideo" @blur="saveVideo" placeholder="/videos/scene.mp4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>字幕路径</label>
|
||||||
|
<div class="field-row">
|
||||||
|
<input v-model="localSubtitle" @blur="saveSubtitle" placeholder="/subtitles/scene.vtt" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label>默认下一场景 (nextScene)</label>
|
||||||
|
<select v-model="localNextScene" @change="saveNextScene">
|
||||||
|
<option value="">-- 无 --</option>
|
||||||
|
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group">
|
||||||
|
<label class="qte-toggle">
|
||||||
|
<input type="checkbox" v-model="editingQTE" @change="saveQTE" />
|
||||||
|
QTE 快速反应事件
|
||||||
|
</label>
|
||||||
|
<div v-if="editingQTE" class="qte-fields">
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>触发时间 (秒)</span>
|
||||||
|
<input type="number" v-model.number="localQteTime" @change="saveQTE" min="0" step="0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>提示文字</span>
|
||||||
|
<input v-model="localQtePrompt" @blur="saveQTE" placeholder="按下空格键!" />
|
||||||
|
</div>
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>按键 (逗号分隔)</span>
|
||||||
|
<input v-model="localQteKeys" @blur="saveQTE" placeholder="Space, ArrowUp" />
|
||||||
|
</div>
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>限时 (秒)</span>
|
||||||
|
<input type="number" v-model.number="localQteLimit" @change="saveQTE" min="1" step="0.5" />
|
||||||
|
</div>
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>成功场景</span>
|
||||||
|
<select v-model="localQteSuccess" @change="saveQTE">
|
||||||
|
<option value="">-- 选择 --</option>
|
||||||
|
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="qte-row">
|
||||||
|
<span>失败场景</span>
|
||||||
|
<select v-model="localQteFail" @change="saveQTE">
|
||||||
|
<option value="">-- 选择 --</option>
|
||||||
|
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field-group choices-section">
|
||||||
|
<label>选项列表</label>
|
||||||
|
<button class="add-btn" @click="emit('addChoice', scene.id)">+ 添加选项</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(choice, index) in (scene.choices || [])"
|
||||||
|
:key="index"
|
||||||
|
class="choice-item"
|
||||||
|
>
|
||||||
|
<div class="choice-header">
|
||||||
|
<span>选项 {{ index + 1 }}</span>
|
||||||
|
<button class="icon-btn danger small" @click="emit('deleteChoice', scene.id, index)">×</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
:value="choice.text"
|
||||||
|
@blur="emit('updateChoice', scene.id, index, { text: ($event.target as HTMLInputElement).value })"
|
||||||
|
placeholder="选项文字"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:value="choice.targetScene"
|
||||||
|
@change="emit('updateChoice', scene.id, index, { targetScene: ($event.target as HTMLSelectElement).value })"
|
||||||
|
>
|
||||||
|
<option value="">-- 目标场景 --</option>
|
||||||
|
<option v-for="s in sceneList.filter(s => s.id !== scene.id)" :key="s.id" :value="s.id">
|
||||||
|
{{ s.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<div class="choice-extra">
|
||||||
|
<label class="inline-label">限时(秒, 0=不限)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
:value="choice.timeLimit ?? 0"
|
||||||
|
@change="emit('updateChoice', scene.id, index, { timeLimit: +($event.target as HTMLInputElement).value })"
|
||||||
|
min="0" step="1"
|
||||||
|
class="time-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="node-editor empty-state" v-else>
|
||||||
|
<p>点击左侧画布中的节点来编辑</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.node-editor {
|
||||||
|
width: 340px;
|
||||||
|
height: 100%;
|
||||||
|
background: #141428;
|
||||||
|
border-left: 1px solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #555;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid rgba(255,255,255,0.12);
|
||||||
|
color: #888;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.25); }
|
||||||
|
.icon-btn.danger:hover { color: #e74c3c; border-color: #e74c3c; }
|
||||||
|
.icon-btn.small { width: 22px; height: 22px; font-size: 11px; }
|
||||||
|
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-group label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #ddd;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus {
|
||||||
|
border-color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-toggle input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
background: rgba(255,255,255,0.03);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-row span {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qte-row input, .qte-row select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choices-section {
|
||||||
|
border-top: 1px solid rgba(255,255,255,0.06);
|
||||||
|
padding-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #8cf;
|
||||||
|
background: rgba(100,200,255,0.08);
|
||||||
|
border: 1px solid rgba(100,200,255,0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-btn:hover { background: rgba(100,200,255,0.15); }
|
||||||
|
|
||||||
|
.choice-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px;
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
border: 1px solid rgba(255,255,255,0.05);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choice-extra {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inline-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #666;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-input {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
105
editor/components/PreviewPanel.vue
Normal file
105
editor/components/PreviewPanel.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
videoUrl: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const videoRef = ref<HTMLVideoElement | null>(null)
|
||||||
|
const playing = ref(false)
|
||||||
|
const paused = ref(false)
|
||||||
|
|
||||||
|
watch(() => props.videoUrl, (url) => {
|
||||||
|
if (!videoRef.value || !url) return
|
||||||
|
videoRef.value.src = url
|
||||||
|
playing.value = false
|
||||||
|
paused.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
function togglePlay() {
|
||||||
|
if (!videoRef.value) return
|
||||||
|
if (videoRef.value.paused) {
|
||||||
|
videoRef.value.play().catch(() => {})
|
||||||
|
playing.value = true
|
||||||
|
} else {
|
||||||
|
videoRef.value.pause()
|
||||||
|
playing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="preview-panel">
|
||||||
|
<div class="preview-header">预览</div>
|
||||||
|
<div class="preview-video" v-if="videoUrl">
|
||||||
|
<video ref="videoRef" preload="auto" />
|
||||||
|
<div class="preview-controls">
|
||||||
|
<button @click="togglePlay">{{ playing ? '暂停' : '播放' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="preview-empty" v-else>
|
||||||
|
<span>选择场景节点以预览视频</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.preview-panel {
|
||||||
|
width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
background: #141428;
|
||||||
|
border-left: 1px solid rgba(255,255,255,0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-video {
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-video video {
|
||||||
|
width: 100%;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls button {
|
||||||
|
padding: 6px 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
border: 1px solid rgba(255,255,255,0.15);
|
||||||
|
color: #ddd;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-controls button:hover {
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #444;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
editor/components/SceneGraph.vue
Normal file
93
editor/components/SceneGraph.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { watch, ref, onMounted } from 'vue'
|
||||||
|
import { VueFlow, useVueFlow } from '@vue-flow/core'
|
||||||
|
import { Background } from '@vue-flow/background'
|
||||||
|
import { Controls } from '@vue-flow/controls'
|
||||||
|
import '@vue-flow/core/dist/style.css'
|
||||||
|
import '@vue-flow/controls/dist/style.css'
|
||||||
|
import '@vue-flow/core/dist/theme-default.css'
|
||||||
|
import type { Node, Edge, Connection } from '@vue-flow/core'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
sceneNodes: { id: string; label: string }[]
|
||||||
|
sceneEdges: { id: string; source: string; target: string; label?: string }[]
|
||||||
|
startScene: string
|
||||||
|
selectedNodeId: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectNode: [id: string]
|
||||||
|
addNode: []
|
||||||
|
addEdge: [source: string, target: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { nodes, edges, onNodeClick, onConnect, fitView } = useVueFlow()
|
||||||
|
|
||||||
|
function rebuild() {
|
||||||
|
nodes.value = props.sceneNodes.map((n, i) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: 'default',
|
||||||
|
position: { x: (i % 4) * 220 + 50, y: Math.floor(i / 4) * 120 + 50 },
|
||||||
|
data: { label: n.id === props.startScene ? `▶ ${n.label}` : n.label },
|
||||||
|
style: n.id === props.startScene
|
||||||
|
? 'background:#1b5e20; color:#fff; border-color:#388e3c'
|
||||||
|
: n.id === props.selectedNodeId
|
||||||
|
? 'background:#1565c0; color:#fff; border-color:#1976d2'
|
||||||
|
: '',
|
||||||
|
sourcePosition: 'right' as const,
|
||||||
|
targetPosition: 'left' as const,
|
||||||
|
connectable: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
edges.value = props.sceneEdges.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
label: e.label || '',
|
||||||
|
animated: true,
|
||||||
|
style: { stroke: '#4fc3f7' },
|
||||||
|
labelStyle: { fill: '#aaa', fontWeight: 400, fontSize: 11 },
|
||||||
|
labelBgStyle: { fill: 'rgba(0,0,0,0.7)' },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId], () => {
|
||||||
|
rebuild()
|
||||||
|
}, { deep: true, immediate: true })
|
||||||
|
|
||||||
|
onNodeClick((event) => {
|
||||||
|
emit('selectNode', event.node.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
onConnect((connection: Connection) => {
|
||||||
|
if (connection.source && connection.target) {
|
||||||
|
emit('addEdge', connection.source, connection.target)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setTimeout(() => fitView({ padding: 0.2 }), 100)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="scene-graph">
|
||||||
|
<VueFlow
|
||||||
|
:nodes="nodes"
|
||||||
|
:edges="edges"
|
||||||
|
:min-zoom="0.2"
|
||||||
|
:max-zoom="2"
|
||||||
|
>
|
||||||
|
<Background :gap="20" />
|
||||||
|
<Controls />
|
||||||
|
</VueFlow>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scene-graph {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0d0d1a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
121
editor/composables/useGraphEditor.ts
Normal file
121
editor/composables/useGraphEditor.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { ref, computed } from 'vue'
|
||||||
|
import type { GameData, SceneNode, Choice } from '@engine/types'
|
||||||
|
|
||||||
|
export interface EditorNode {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
videoUrl: string
|
||||||
|
subtitleUrl: string
|
||||||
|
choices: Choice[]
|
||||||
|
nextScene: string
|
||||||
|
onEnter: any[]
|
||||||
|
qte: any | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphEditor() {
|
||||||
|
const gameData = ref<GameData>({ scenes: {}, startScene: '', variables: {} })
|
||||||
|
const selectedNodeId = ref<string | null>(null)
|
||||||
|
const startSceneId = ref('')
|
||||||
|
|
||||||
|
const selectedNode = computed(() => {
|
||||||
|
if (!selectedNodeId.value) return null
|
||||||
|
return gameData.value.scenes[selectedNodeId.value] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const sceneList = computed(() => {
|
||||||
|
return Object.values(gameData.value.scenes).map((s) => ({
|
||||||
|
id: s.id,
|
||||||
|
label: s.id,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadJSON(json: GameData) {
|
||||||
|
gameData.value = JSON.parse(JSON.stringify(json))
|
||||||
|
startSceneId.value = json.startScene
|
||||||
|
}
|
||||||
|
|
||||||
|
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.scenes[id] = {
|
||||||
|
id,
|
||||||
|
videoUrl: '',
|
||||||
|
choices: [],
|
||||||
|
nextScene: '',
|
||||||
|
subtitleUrl: '',
|
||||||
|
onEnter: [],
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteScene(id: string) {
|
||||||
|
if (startSceneId.value === id) return
|
||||||
|
delete gameData.value.scenes[id]
|
||||||
|
for (const s of Object.values(gameData.value.scenes)) {
|
||||||
|
s.choices = (s.choices || []).filter((c) => c.targetScene !== id)
|
||||||
|
if (s.nextScene === id) s.nextScene = ''
|
||||||
|
}
|
||||||
|
if (selectedNodeId.value === id) selectedNodeId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateScene(id: string, partial: Partial<EditorNode>) {
|
||||||
|
const scene = gameData.value.scenes[id]
|
||||||
|
if (!scene) return
|
||||||
|
Object.assign(scene, partial)
|
||||||
|
gameData.value.scenes = { ...gameData.value.scenes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function addChoice(sourceId: string) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene) return
|
||||||
|
if (!scene.choices) scene.choices = []
|
||||||
|
scene.choices.push({
|
||||||
|
text: '新选项',
|
||||||
|
targetScene: '',
|
||||||
|
})
|
||||||
|
gameData.value.scenes = { ...gameData.value.scenes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene?.choices) return
|
||||||
|
Object.assign(scene.choices[index], partial)
|
||||||
|
gameData.value.scenes = { ...gameData.value.scenes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteChoice(sourceId: string, index: number) {
|
||||||
|
const scene = gameData.value.scenes[sourceId]
|
||||||
|
if (!scene?.choices) return
|
||||||
|
scene.choices.splice(index, 1)
|
||||||
|
gameData.value.scenes = { ...gameData.value.scenes }
|
||||||
|
}
|
||||||
|
|
||||||
|
function newSceneData(): EditorNode {
|
||||||
|
return {
|
||||||
|
id: '',
|
||||||
|
label: '',
|
||||||
|
videoUrl: '',
|
||||||
|
subtitleUrl: '',
|
||||||
|
choices: [],
|
||||||
|
nextScene: '',
|
||||||
|
onEnter: [],
|
||||||
|
qte: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameData, selectedNodeId, selectedNode, sceneList, startSceneId,
|
||||||
|
loadJSON, exportJSON, addScene, deleteScene, updateScene,
|
||||||
|
addChoice, updateChoice, deleteChoice,
|
||||||
|
newSceneData, generateId,
|
||||||
|
}
|
||||||
|
}
|
||||||
12
editor/index.html
Normal file
12
editor/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>剧情编辑器 — 交互式电影游戏</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="editor-app"></div>
|
||||||
|
<script type="module" src="/editor/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
7
editor/main.ts
Normal file
7
editor/main.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import EditorApp from './App.vue'
|
||||||
|
|
||||||
|
const app = createApp(EditorApp)
|
||||||
|
app.use(createPinia())
|
||||||
|
app.mount('#editor-app')
|
||||||
170
package-lock.json
generated
170
package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "moviegame",
|
"name": "moviegame",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vue-flow/background": "^1.3.2",
|
||||||
|
"@vue-flow/controls": "^1.1.3",
|
||||||
|
"@vue-flow/core": "^1.48.2",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
@@ -765,6 +768,11 @@
|
|||||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/web-bluetooth": {
|
||||||
|
"version": "0.0.20",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
|
||||||
|
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow=="
|
||||||
|
},
|
||||||
"node_modules/@vitejs/plugin-vue": {
|
"node_modules/@vitejs/plugin-vue": {
|
||||||
"version": "5.2.4",
|
"version": "5.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
|
||||||
@@ -804,6 +812,39 @@
|
|||||||
"vscode-uri": "^3.0.8"
|
"vscode-uri": "^3.0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@vue-flow/background": {
|
||||||
|
"version": "1.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue-flow/background/-/background-1.3.2.tgz",
|
||||||
|
"integrity": "sha512-eJPhDcLj1wEo45bBoqTXw1uhl0yK2RaQGnEINqvvBsAFKh/camHJd5NPmOdS1w+M9lggc9igUewxaEd3iCQX2w==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue-flow/core": "^1.23.0",
|
||||||
|
"vue": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue-flow/controls": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue-flow/controls/-/controls-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-XCf+G+jCvaWURdFlZmOjifZGw3XMhN5hHlfMGkWh9xot+9nH9gdTZtn+ldIJKtarg3B21iyHU8JjKDhYcB6JMw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@vue-flow/core": "^1.23.0",
|
||||||
|
"vue": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vue-flow/core": {
|
||||||
|
"version": "1.48.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vue-flow/core/-/core-1.48.2.tgz",
|
||||||
|
"integrity": "sha512-raxhgKWE+G/mcEvXJjGFUDYW9rAI3GOtiHR3ZkNpwBWuIaCC1EYiBmKGwJOoNzVFgwO7COgErnK7i08i287AFA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@vueuse/core": "^10.5.0",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vue/compiler-core": {
|
"node_modules/@vue/compiler-core": {
|
||||||
"version": "3.5.35",
|
"version": "3.5.35",
|
||||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz",
|
||||||
@@ -934,6 +975,39 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
|
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz",
|
||||||
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="
|
"integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@vueuse/core": {
|
||||||
|
"version": "10.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/core/-/core-10.11.1.tgz",
|
||||||
|
"integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/web-bluetooth": "^0.0.20",
|
||||||
|
"@vueuse/metadata": "10.11.1",
|
||||||
|
"@vueuse/shared": "10.11.1",
|
||||||
|
"vue-demi": ">=0.14.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/metadata": {
|
||||||
|
"version": "10.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-10.11.1.tgz",
|
||||||
|
"integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@vueuse/shared": {
|
||||||
|
"version": "10.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-10.11.1.tgz",
|
||||||
|
"integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
|
||||||
|
"dependencies": {
|
||||||
|
"vue-demi": ">=0.14.8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/antfu"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/alien-signals": {
|
"node_modules/alien-signals": {
|
||||||
"version": "1.0.13",
|
"version": "1.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz",
|
||||||
@@ -960,6 +1034,102 @@
|
|||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-color": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-ease": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-interpolate": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-timer": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/de-indent": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@vue-flow/background": "^1.3.2",
|
||||||
|
"@vue-flow/controls": "^1.1.3",
|
||||||
|
"@vue-flow/core": "^1.48.2",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"pinia": "^2.1.0",
|
"pinia": "^2.1.0",
|
||||||
"vue": "^3.4.0"
|
"vue": "^3.4.0"
|
||||||
|
|||||||
@@ -10,4 +10,12 @@ export default defineConfig({
|
|||||||
'@engine': resolve(__dirname, 'engine'),
|
'@engine': resolve(__dirname, 'engine'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, 'index.html'),
|
||||||
|
editor: resolve(__dirname, 'editor/index.html'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user