diff --git a/ROADMAP.md b/ROADMAP.md index bc7ae73..741f76a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -167,15 +167,17 @@ interface SaveData { - [x] 完整事件总线(sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd) - [x] 验证:QTE 正常触发与判定(ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常 -### P3 编辑器 — 可视化剧情编辑(2-3 周) +### P3 编辑器 — 可视化剧情编辑(2-3 周)✅ 已完成 2026-06-07 -- [ ] 编辑器入口:独立 `editor/index.html` + `editor/main.ts` -- [ ] `editor/components/SceneGraph.vue` — Vue Flow 节点图(节点=场景,边=选择分支) -- [ ] `editor/components/NodeEditor.vue` — 右侧面板,编辑选中节点的视频、选项、QTE、条件/效果 -- [ ] `editor/components/PreviewPanel.vue` — 嵌入播放器,实时预览当前编辑的剧情线 -- [ ] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步 -- [ ] JSON 导出/导入 -- [ ] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行 +- [x] 编辑器入口:独立 `editor/index.html` + `editor/main.ts`(Vite 多入口构建) +- [x] `editor/components/SceneGraph.vue` — Vue Flow 节点图(场景节点 + 分支/默认/QTE 连线) +- [x] `editor/components/NodeEditor.vue` — 右侧面板(视频/字幕路径、nextScene、选项增删改、QTE 参数编辑) +- [x] `editor/components/PreviewPanel.vue` — 嵌入播放器实时预览选中场景视频 +- [x] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步 +- [x] JSON 导出/导入(文件下载 + 文件选择) +- [x] 工具栏:新建场景、导入 JSON、导出 JSON、加载示例、起始场景选择 +- [x] `vite.config.ts` — 多页面构建(main + editor) +- [x] 验证:编辑器能产出合法 JSON,引擎能正确加载并运行 ## 依赖清单 diff --git a/editor/App.vue b/editor/App.vue new file mode 100644 index 0000000..d9cd484 --- /dev/null +++ b/editor/App.vue @@ -0,0 +1,331 @@ + + + + + + 剧情编辑器 + + + 新场景 + 导入 JSON + 导出 JSON + 加载示例 + ● 未保存 + + + 起始场景: + + -- 选择 -- + {{ n.label }} + + + + + + + + + + + + + + + + + + + diff --git a/editor/components/NodeEditor.vue b/editor/components/NodeEditor.vue new file mode 100644 index 0000000..9c27bde --- /dev/null +++ b/editor/components/NodeEditor.vue @@ -0,0 +1,385 @@ + + + + + + {{ scene.id }} + + 🗑 + ✕ + + + + + + 视频路径 + + + + + + + 字幕路径 + + + + + + + 默认下一场景 (nextScene) + + -- 无 -- + + {{ s.label }} + + + + + + + + QTE 快速反应事件 + + + + 触发时间 (秒) + + + + 提示文字 + + + + 按键 (逗号分隔) + + + + 限时 (秒) + + + + 成功场景 + + -- 选择 -- + + {{ s.label }} + + + + + 失败场景 + + -- 选择 -- + + {{ s.label }} + + + + + + + + 选项列表 + + 添加选项 + + + + 选项 {{ index + 1 }} + × + + + + -- 目标场景 -- + + {{ s.label }} + + + + 限时(秒, 0=不限) + + + + + + + + + 点击左侧画布中的节点来编辑 + + + + diff --git a/editor/components/PreviewPanel.vue b/editor/components/PreviewPanel.vue new file mode 100644 index 0000000..794df65 --- /dev/null +++ b/editor/components/PreviewPanel.vue @@ -0,0 +1,105 @@ + + + + + 预览 + + + + {{ playing ? '暂停' : '播放' }} + + + + 选择场景节点以预览视频 + + + + + diff --git a/editor/components/SceneGraph.vue b/editor/components/SceneGraph.vue new file mode 100644 index 0000000..b072c2f --- /dev/null +++ b/editor/components/SceneGraph.vue @@ -0,0 +1,93 @@ + + + + + + + + + + + + diff --git a/editor/composables/useGraphEditor.ts b/editor/composables/useGraphEditor.ts new file mode 100644 index 0000000..967a4a6 --- /dev/null +++ b/editor/composables/useGraphEditor.ts @@ -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({ scenes: {}, startScene: '', variables: {} }) + const selectedNodeId = ref(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) { + 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) { + 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, + } +} diff --git a/editor/index.html b/editor/index.html new file mode 100644 index 0000000..f120f6e --- /dev/null +++ b/editor/index.html @@ -0,0 +1,12 @@ + + + + + + 剧情编辑器 — 交互式电影游戏 + + + + + + diff --git a/editor/main.ts b/editor/main.ts new file mode 100644 index 0000000..4aaa1b5 --- /dev/null +++ b/editor/main.ts @@ -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') diff --git a/package-lock.json b/package-lock.json index 38bc4da..1227e2b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "moviegame", "version": "0.1.0", "dependencies": { + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "dexie": "^4.4.3", "pinia": "^2.1.0", "vue": "^3.4.0" @@ -765,6 +768,11 @@ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "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": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", @@ -804,6 +812,39 @@ "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": { "version": "3.5.35", "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", "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": { "version": "1.0.13", "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", "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": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/package.json b/package.json index e44f8d7..a7292d2 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,9 @@ "preview": "vite preview" }, "dependencies": { + "@vue-flow/background": "^1.3.2", + "@vue-flow/controls": "^1.1.3", + "@vue-flow/core": "^1.48.2", "dexie": "^4.4.3", "pinia": "^2.1.0", "vue": "^3.4.0" diff --git a/vite.config.ts b/vite.config.ts index 837c3f8..820aa25 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -10,4 +10,12 @@ export default defineConfig({ '@engine': resolve(__dirname, 'engine'), }, }, + build: { + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + editor: resolve(__dirname, 'editor/index.html'), + }, + }, + }, })
点击左侧画布中的节点来编辑