Compare commits

...

93 Commits

Author SHA1 Message Date
9f2e6d5605 chore: add skill frontmatter metadata 2026-06-16 19:47:59 +08:00
298f0930ad refactor: rename skill file to SKILL.md for auto-loading 2026-06-16 19:37:47 +08:00
e1512c66d4 chore: add opencode skill configuration 2026-06-16 19:31:02 +08:00
21a088bd99 refactor: move version history dropdown from AIPanel to toolbar 2026-06-16 17:56:39 +08:00
4cca832641 fix: reset version debounce state on version restore 2026-06-16 17:53:31 +08:00
b8a5d55e8c chore: add debug logging for version history debounce 2026-06-16 17:49:31 +08:00
bef04efb1e feat: auto-save version history with 3s debounce 2026-06-16 17:46:06 +08:00
7e896d53b8 refactor: move delete button to left side of NodeEditor header 2026-06-16 17:35:00 +08:00
a40cc8874a refactor: remove close button and AI button from NodeEditor header 2026-06-16 17:31:08 +08:00
6bd61ae522 feat: collapsible NodeEditor panel with right-edge bar 2026-06-16 17:06:25 +08:00
0616f4a702 feat: remove AI toolbar button, show AI panel by default 2026-06-16 16:43:36 +08:00
9a990274d6 fix: hide collapsed bar when expanded, fix bottom offset 2026-06-16 16:39:38 +08:00
897522ed5a feat: collapsible AI panel with overlay layout 2026-06-16 16:34:58 +08:00
f14390a69c chore: add debug logging for AI plugin in dev server 2026-06-16 16:00:15 +08:00
a21652b1ca feat: add version history and AI diff highlighting in editor 2026-06-16 15:23:16 +08:00
c1f7be1507 refactor: simplify AI panel workflow - direct file modification and reload from disk 2026-06-15 15:10:09 +08:00
395c55b6b0 feat: enrich AI requests with scene context and project root path 2026-06-15 14:31:23 +08:00
5f717ac3b6 fix: validate JSON before applying AI result, show raw text on parse failure 2026-06-15 14:16:44 +08:00
b1ea2e6474 fix: improve AI response JSON parsing robustness 2026-06-15 14:06:32 +08:00
7b3ad95549 fix: accumulate streaming AI text parts instead of replacing 2026-06-15 14:00:48 +08:00
f346f8d568 perf: use direct opencode binary path instead of npx 2026-06-15 12:30:57 +08:00
c2a9fcdb2e fix: conditionally enable shell on Windows for npx spawn calls 2026-06-15 12:20:12 +08:00
78208cd4b1 refactor: parse opencode JSON stream output directly, remove extra session list call, increase timeout to 60s 2026-06-15 12:02:40 +08:00
525fa5ef8f fix: add shell:true to spawn calls for cross-platform npx compatibility 2026-06-15 11:42:31 +08:00
a34f3cf240 fix: prevent double response with responded guard in spawn handlers 2026-06-15 11:36:50 +08:00
119b8201bb fix: use npx opencode instead of direct bin path, add spawn error handling 2026-06-15 11:33:34 +08:00
0aac429908 refactor: AI session managed server-side, sessionId returned from API 2026-06-15 11:23:14 +08:00
33357650c7 feat: AI assistant panel, editor improvements, vite and package config 2026-06-15 10:24:27 +08:00
80b361813e chore: editor App and NodeEditor updates 2026-06-14 21:39:09 +08:00
f8af9e608d chore: editor App.vue update 2026-06-14 21:34:36 +08:00
7c80fc431c feat: remember last edited scene path in editor via localStorage 2026-06-14 21:31:46 +08:00
34e11a4f52 chore: NodeEditor.vue update 2026-06-14 21:23:21 +08:00
f741f73e11 chore: editor App and SceneGraph updates 2026-06-14 21:19:29 +08:00
94e0ea9c24 chore: NodeEditor.vue update 2026-06-14 21:16:33 +08:00
b45ad8bbc3 fix: App.vue and useGameEngine refinements 2026-06-14 21:11:34 +08:00
8b90ba0501 chore: App.vue and useGameEngine updates 2026-06-14 21:03:26 +08:00
35ddef9dcc chore: editor App.vue update 2026-06-14 20:50:43 +08:00
61fd5dbc2d chore: editor App.vue update 2026-06-14 20:48:19 +08:00
c6afeb2691 chore: vite config update 2026-06-14 20:43:34 +08:00
59f6956b50 chore: editor store, graph, and vite config updates 2026-06-14 20:38:12 +08:00
48da10147b chore: App.vue updates 2026-06-14 20:25:49 +08:00
ed462b1bee chore: editor NodeEditor, App.vue, and roadmap updates 2026-06-14 20:17:51 +08:00
1619c9db8b fix: editor layout and SceneGraph refinements 2026-06-14 19:57:59 +08:00
681efe1d92 fix: increase editor Dagre nodesep to 100, align ranksep with TreeFlow 2026-06-14 19:49:52 +08:00
5cf0461e55 chore: App.vue updates for player and editor 2026-06-14 19:42:26 +08:00
669a652ec7 fix: extract testScene window.open to method to avoid template scope issue 2026-06-14 19:33:54 +08:00
0c59e54a2a fix: prevent preview video overflow with aspect-ratio + max-height constraint 2026-06-14 18:05:09 +08:00
8c736d5c08 fix: remove redundant preview header from editor PreviewPanel 2026-06-14 18:02:03 +08:00
51b71d07e7 fix: editor PreviewPanel updates 2026-06-14 17:58:48 +08:00
94c55d3597 fix: editor PreviewPanel updates 2026-06-14 17:53:27 +08:00
a681e371ae fix: editor App.vue updates 2026-06-14 17:50:26 +08:00
82bfae0e1b feat: editor services, stores, and graph improvements 2026-06-14 17:46:34 +08:00
271c909398 feat: engine utils, editor and asset prefix improvements 2026-06-14 17:39:07 +08:00
e0331ab5a7 fix: handle array nextScene in editor sceneEdges and deleteScene 2026-06-14 17:31:34 +08:00
73fade1b94 fix: add back missing alone_ending scene definition 2026-06-14 17:26:22 +08:00
920f0ee9f3 feat: editor improvements and roadmap doc 2026-06-14 17:19:10 +08:00
c75db2886f docs: add battle system, conditional routing, key moments, and creators guide docs 2026-06-14 16:42:16 +08:00
02a82e9801 refactor: unify panel UI to gold design system, add backdrop blur to all overlays 2026-06-14 16:25:56 +08:00
b61d08a0ca chore: battle result UI, demo locales, and scene updates 2026-06-14 16:19:31 +08:00
c46c4efd6c chore: engine, types, demo, and UI updates 2026-06-14 16:05:00 +08:00
544f548275 chore: update demo locales and scene data 2026-06-14 15:42:12 +08:00
d0e901bd1f feat: battle system, state manager enhancements, engine and demo updates 2026-06-14 15:35:31 +08:00
4d066b53bf fix: increase ranksep to 200 for wider horizontal spacing 2026-06-14 12:21:32 +08:00
d2b1d88ce3 fix: increase TreeFlow ranksep and nodesep for less crowded layout 2026-06-14 12:12:02 +08:00
199ab1153b fix: use solid gold for main edge path instead of viewBox-mapped gradient 2026-06-14 12:05:47 +08:00
92966331d3 feat: add dev diary and ending thumbnails, update chapter endings display 2026-06-14 11:51:32 +08:00
d373cb8fc0 fix: align quality popup menu to right edge to prevent overflow 2026-06-13 20:22:30 +08:00
57118d3bfe simplify: embed bandwidth text directly into quality i18n labels, remove speed field 2026-06-13 20:19:10 +08:00
cf3060b7fe refactor: use full i18n keys for quality speed text instead of concatenation 2026-06-13 20:16:20 +08:00
a9929666a5 feat: add bandwidth hints to quality selector popup 2026-06-13 20:15:25 +08:00
5a0252d0ea feat: i18n support for quality selector labels 2026-06-13 20:14:05 +08:00
a3379430cd fix: restore missing onEnter effect handling in goToScene 2026-06-13 01:41:37 +08:00
16293eb11c fix: startChapter only overwrites specified defaultVars, no longer clears all variables 2026-06-13 01:34:41 +08:00
e949a84171 feat: P25 conditional routing, nextScene supports Choice[] with conditions 2026-06-13 00:50:48 +08:00
db4f06883d feat: replace speed cycle with popup dropdown, same style as quality selector 2026-06-12 21:52:11 +08:00
7a802cdb02 chore: App.vue updates 2026-06-12 21:43:53 +08:00
5a4acfc6bb docs: update SCENE_JSON_SPEC with current types, deprecate flag fields 2026-06-12 21:26:00 +08:00
6a4ff7a328 fix: hide top bar when choices panel is visible 2026-06-12 19:54:38 +08:00
e7af4a8659 fix: hide bottom bar when choices panel is visible 2026-06-12 19:46:11 +08:00
453b2c68d2 refactor: horizontal choice panel layout 2026-06-12 19:35:25 +08:00
5ff8e2b669 refactor: move quality/skip/speed controls to bottom bar, sync visibility with top bar 2026-06-12 19:27:32 +08:00
8655e01c23 refactor: unify video mode detection into getVideoMode() 2026-06-12 19:10:51 +08:00
32f7e34130 fix: preload candidate URLs now use resolveVideoUrl in Web mode 2026-06-12 18:56:29 +08:00
320502a7c3 feat: track HLS demo segments for all scenes 2026-06-12 18:04:53 +08:00
47230b4a66 feat: add streamingUrl to all scenes in demo.json 2026-06-12 18:03:22 +08:00
503496ea0e fix: only switch quality during active playback, not after video ended 2026-06-12 17:51:01 +08:00
d46a2194f4 fix: switchQuality reset currentTime=0 to unset ended flag 2026-06-12 17:42:43 +08:00
a465009086 fix: switchQuality add load() to reset HLS state after video ended 2026-06-12 17:39:58 +08:00
b62af5b7de chore: update HLS segments and App.vue tweaks 2026-06-12 17:33:32 +08:00
08f4bf3648 fix: apply assetBase to streamingUrl paths for adaptive bitrate HLS 2026-06-12 17:18:30 +08:00
b6231e4efd feat: adaptive bitrate support, engine improvements, demo updates, and electron preload 2026-06-12 17:15:30 +08:00
6575b0be0f Revert "docs: add UI scaling guide with transform scale approach and new page checklist"
This reverts commit 0177af3416.
2026-06-12 16:31:05 +08:00
18bf98aa16 Revert "feat: add transform:scale() UI scaling for 1920x1080 canvas"
This reverts commit 6a6414510e.
2026-06-12 16:30:58 +08:00
118 changed files with 4263 additions and 932 deletions

2
.gitignore vendored
View File

@@ -23,4 +23,4 @@ npm-debug.log*
# TypeScript
*.tsbuildinfo
video
release
release

View File

@@ -0,0 +1,59 @@
---
name: moviegame
description: Edit JSON-driven interactive movie game config files. Validates scene references, variables, and structure.
---
# Movie Game Engine Skill
你正在编辑一个 JSON 驱动的交互电影游戏配置文件。先读文件、理解现有结构,再精确修改。
## 关键参考文件(需要时自行读取)
| 文件 | 内容 |
|------|------|
| `engine/types.ts` | 全部类型定义SceneNode、Choice、Condition、Effect、QTEDefinition、GameData 等 |
| `docs/ARCHITECTURE.md` | 架构约束A/B 双缓冲、JSON 驱动、事件系统 |
| `AGENTS.md` | 项目开发约定 |
当前编辑的故事配置文件路径由对话上下文提供(如 `public/scenes/demo.json`)。
## 核心操作规则(必须遵守)
1. **key === id**`scenes` 对象 key 必须等于 `SceneNode.id`,始终一致。新增场景时两者设为相同值
2. **变量先声明后使用**:新增 effects/conditions 引用新变量名 → 必须先在 `GameData.variables` 中追加声明(初始值建议 0
3. **删除清理**:删除场景时,必须清理所有其他场景中对它的引用:`choices[].targetScene``nextScene``qte.successScene``qte.failScene``hotspots[].targetScene`
4. **引用有效**:所有 `targetScene``nextScene`string 形式)、`successScene``failScene``startScene` 必须指向 `scenes` 中存在的 key
5. **保持结构**:修改场景时保留所有已有字段,除非明确要求删除
## 变量使用位置(全部)
下列所有位置引用的变量名都必须在 `GameData.variables` 中声明:
| 位置 | 字段 |
|------|------|
| `Choice.effects` | `Effect.target` |
| `Choice.conditions` | `Condition.variable` |
| `nextScene (Choice[])` 每个路由的 `effects` | `Effect.target` |
| `nextScene (Choice[])` 每个路由的 `conditions` | `Condition.variable` |
| `SceneNode.onEnter` | `Effect.target` |
| `QTEDefinition.effects.success` | `Effect.target` |
| `QTEDefinition.effects.fail` | `Effect.target` |
| `Hotspot.effects` | `Effect.target` |
| `Hotspot.conditions` | `Condition.variable` |
| `AchievementDef.condition` | `Condition.variable` |
| `BattleHUDStat` | `variable` |
| `BattleResultStat` | `variable` |
| `ChapterInfo.defaultVariables` | keys |
**Effect 格式**`{ "type": "set", "target": "变量名", "value": 5 }``{ "type": "add", "target": "变量名", "delta": 1 }`
**Condition 格式**`{ "variable": "变量名", "op": ">=", "value": 3 }`op 支持 > < >= <= == !=
## 场景引用关系
- `Choice.targetScene` — 选项跳转目标
- `nextScene` — string = 直接跳转Choice[] = 条件路由(逐条 evaluate conditions第一条匹配的生效
- `QTEDefinition.successScene` / `failScene` — QTE 成功/失败跳转
- `Hotspot.targetScene` — 热点跳转
- `ChapterInfo.startScene` — 章节起始场景
- `EndingDef.sceneId` — 结局关联场景
- `GameData.startScene` — 游戏起始场景

119
AGENTS.md Normal file
View File

@@ -0,0 +1,119 @@
# 引擎开发约定与参考
本文档记录交互电影游戏引擎的开发规范、架构约定和工作流程,供后续开发对话中引用。
## 工作流程
| 规则 | 说明 |
|------|------|
| **先讨论再执行** | 功能实现前必须先讨论方案,确认后再写代码 |
| **先更新 ROADMAP 再实现** | 新功能先写入 ROADMAP 的 P 条目,附实现清单,再逐项完成 |
| **不自动提交** | 代码写完不要 `git commit` / `git push`,等用户检查通过后再操作 |
| **验证方式** | `npx vue-tsc --noEmit` + `npx vite build` 通过视为基本验证 |
| **生成测试数据** | 新功能要生成配套的示例视频 / 音频 / JSON 数据 |
## 架构原则
| 原则 | 说明 |
|------|------|
| **引擎与 UI 分离** | `engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接 |
| **A/B 双缓冲** | 两个 `<video>` 元素轮换,一个播放时另一个预加载候选场景 |
| **JSON 驱动** | 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具 |
| **IndexedDB 存档** | 比 localStorage 容量大,可存储截屏缩略图。多槽位支持 |
| **故事图与玩家树** | 创作端 JSON 是有向图,展示端 BFS 投影为树(汇聚节点复制展示) |
## 目录结构
```
moviegame/
├── engine/ # 框架无关的核心引擎(纯 TS
│ ├── core/
│ │ ├── Engine.ts # 主循环,驱动各子系统
│ │ ├── SceneManager.ts # 剧情节点图遍历、章节管理
│ │ ├── VideoManager.ts # A/B 双缓冲视频播放 + 流媒体质量选择
│ │ └── StateManager.ts # 全局状态、变量条件求值、效果执行
│ ├── systems/
│ │ ├── ChoiceSystem.ts # 限时选择 + 倒计时
│ │ ├── QTESystem.ts # QTE 触发、键盘监听、超时判定
│ │ ├── AudioSystem.ts # Web Audio API BGM + Ducking
│ │ ├── AchievementSystem.ts # 纯变量成就检测 + 解锁
│ │ └── SaveSystem.ts # IndexedDB 多表持久化
│ └── types.ts # 全部类型定义
├── src/
│ ├── components/ # Vue 组件——玩家端 UI + 编辑器 UI
│ ├── composables/ # 引擎 ↔ UI 桥接
│ ├── stores/ # Pinia 全局状态
│ └── locales/ # UI 文本翻译(静态 import构建时打包
├── editor/ # Vue Flow 可视化编辑器(独立入口)
├── electron/ # Electron 桌面应用打包
├── public/
│ └── demo/ # 示例素材——按场景分目录,每场景含视频、字幕、缩略图
│ └── locales/ # 故事文本翻译(动态 fetch 加载,制作者维护)
├── docs/
│ ├── SCENE_JSON_SPEC.md # 场景 JSON 完整字段参考
│ └── ARCHITECTURE.md # 关键架构决策记录
├── scripts/ # 构建与打包脚本
├── ROADMAP.md # 待实现功能清单
└── CHANGELOG.md # 功能更新日志
```
## 代码约定
| 规则 | 示例 |
|------|------|
| 不要写注释 | 代码自解释,不添加多余的中英文注释 |
| Engine 事件驱动 | `this.emit('sceneChange', scene)` → composable 响应 |
| composable 桥接 | `useGameEngine.ts` 是 Engine 和 Vue store 的中间层 |
| i18n 双文件分层 | `src/locales/` 存 UI 文本,`public/demo/locales/` 存故事文本。`useI18n.t()` 先查故事消息fallback UI 消息 |
| 选择翻译在 composable 中 | `choiceRequest` 事件触发时 composable 调用 `t(textKey)` 翻译后存入 store |
| 引擎不感知 i18n | `Choice.textKey``Hotspot.labelKey` 是数据层字段,翻译完全在 Vue 层完成 |
| demo 素材按场景分目录 | `public/demo/<scene_id>/<file>` |
| demo 的 assetBase | `"assetBase": "demo/"` — 所有资源路径以此为前缀 |
## 菜单系统
| 菜单 | 设计 |
|------|------|
| **主菜单 (MainMenu.vue)** | 竖排单列,开始游戏最大、继续次之、底部小字装饰行(故事进度 · 成就 · 设置) |
| **暂停菜单 (PauseMenu.vue)** | ESC 弹出的全屏暂停,包含继续 / 存档 / 设置 / 返回主菜单 |
| **设置面板 (AccessibilitySettings.vue)** | 语言 + 画质(仅 Web) + 字幕 + QTE 辅助 + 防误触 + 可暂停 |
| **游戏内顶栏** | 精简:跳过(条件显示) · 倍速(条件显示) · 全屏 · ≡菜单 |
## 视频播放模式
| 模式 | 检测方式 | 使用的 URL 字段 | 切换方式 |
|------|---------|---------------|---------|
| **Electron 桌面** | `window.__ELECTRON__ === true` | `videoUrl`(本地 MP4 | 不切换,单文件 |
| **Web 浏览器** | `__ELECTRON__` undefined | `streamingUrl[quality]`CDN HLS | 设置面板手动选择超清/高清/标清 |
| **画质切换** | 仅在视频播放中切换(`video.ended === false`),结束后不切换等待下一场景 |
关键方法:
- `VideoManager.resolveVideoUrl(scene, quality)` — 环境检测 + URL 选择
- `VideoManager.switchQuality(src, seekTime)` — 实时切换画质
- `SceneManager.getCandidateSceneIds()` — 返回候选场景 IDEngine 用 `resolveVideoUrl` 解析为 URL
- 预加载也走 `resolveVideoUrl`Web 模式禁止预加载本地 MP4
## 场景 JSON 约定
| 规则 | 说明 |
|------|------|
| 单文件包含全部场景 | 不拆分章节为独立 JSON 文件 |
| 章节用 `startScene` 标记 | 无 `endScene`BFS 遍历可达场景作为章节范围 |
| 影像场景标记 | `"type": "image"`, `videoUrl` 为空 |
| QTE 配置 | `effects.success` / `effects.fail` 中附加变量修改,供成就检测 |
| 结局归属 | 通过 BFS 自动推导 `ending.sceneId` 属于哪个章节,不显式声明 `chapterId` |
## 成就系统
- 纯变量检测,在 `StateManager.apply` 末尾 `onAfterApply` 单一检查点触发
- 事件型成就改写为变量型QTE 成功 → effects 中 `set: qte_succeeded, value: 1`
- 解锁时有 toast 弹出、持久化写入 IndexedDB `achievements`
## 流媒体 / HLS 约定
- 每场景三档 HLS目录结构`public/demo/<scene>/<quality>p/index.m3u8` + `seg_000.ts`
- `demo.json` 中每场景配 `streamingUrl: { "超清 (1080P)": "...", "高清 (720P)": "...", "标清 (480P)": "..." }`
- 生成命令:`ffmpeg -i source.mp4 -c:v libx264 -b:v <bitrate>k -c:a aac -b:a 128k -hls_time 2 -hls_segment_filename <dir>/seg_%03d.ts <dir>/index.m3u8`
- `pack-html.cjs` 跳过 `videos/` 目录Web 版使用 CDN 流媒体)
- `pack-mac` / `pack-win` 保留完整视频文件(桌面版使用本地 MP4

View File

@@ -23,6 +23,286 @@
- [ ] `src/App.vue` — 整合 VideoErrorOverlay
- [ ] 验证:断网播放 → 错误画面 → 重试恢复 → 跳过下一场景
### P24 多画质视频 — 本地 + CDN 流双模式 ✅ 已完成 2026-06-10
目标:桌面版用本地 `videoUrl`Web 版用 CDN `streamingUrl`HLS 流)。
Web 版不打包视频文件,用户手动选择超清/高清/标清,系统提示各画质所需网速。
**设计决策:**
| 决策 | 做法 |
|------|------|
| **环境检测** | Electron `preload.js` 注入 `__ELECTRON__``VideoManager` 判断走本地还是 CDN |
| **Web 画质** | 用户从设置面板手动选择(超清/高清/标清非带宽自适应。localStorage 持久化 |
| **Web 打包** | `pack:html` 跳过 `videos/` 目录,音频/图片/字幕保留 |
| **HLS 兼容** | Safari 原生播放 `.m3u8`Chrome/Edge 按需动态 `import('hls.js')`~100KB |
**场景数据设计:**
```json
{
"id": "intro",
"videoUrl": "/videos/intro.mp4",
"streamingUrl": {
"超清 (1080P)": "https://cdn.example.com/hls/intro/1080p.m3u8",
"高清 (720P)": "https://cdn.example.com/hls/intro/720p.m3u8",
"标清 (480P)": "https://cdn.example.com/hls/intro/480p.m3u8"
}
}
```
**设置面板画质选项:**
| 选项 | 网速提示 |
|------|---------|
| 超清 (1080P) | 需要 2.5 Mbps |
| 高清 (720P) | 需要 2 Mbps |
| 标清 (480P) | 需要 0.8 Mbps |
**实现清单:**
- [x] `engine/types.ts``SceneNode.streamingUrl?: Record<string, string>`
- [x] `engine/core/VideoManager.ts``resolveVideoUrl(scene, quality)` + `streamingQuality` 属性
- [x] `engine/core/Engine.ts``goToScene``resolveVideoUrl` 替代直接 `scene.videoUrl`
- [x] `electron/preload.js``contextBridge.exposeInMainWorld('__ELECTRON__', true)`
- [x] `electron/main.js``webPreferences.preload` 加载 preload.js
- [x] `src/stores/gameStore.ts``preferredQuality` + localStorage 持久化
- [x] `src/components/AccessibilitySettings.vue` — Web 模式新增画质下拉(附网速提示)
- [x] `src/App.vue` — watch `preferredQuality` → sync 到 `engine.videoManager.streamingQuality`
- [x] `scripts/pack-html.cjs` — 跳过 `videos/` 目录
- [x] 验证TypeScript + Vite build 通过
- [ ] 验证Electron `window.__ELECTRON__` = true使用本地 `videoUrl`
- [ ] 验证:浏览器 `window.__ELECTRON__` = undefined设置面板显示画质下拉
- [ ] 验证:`pack:html` 产物不包含 `videos/` 目录
### P25 条件路由 — nextScene 支持条件数组 ✅ 已完成 2026-06-12
目标:`nextScene` 从单一场景 ID 扩展为条件路由数组。第一个满足条件的场景自动跳转,
否则 fallback 到末尾无条件的默认场景。不局限于 QTE所有场景均可使用。
**场景数据设计:**
```json
{
"id": "combat_router",
"nextScene": [
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
{ "targetScene": "combat" }
]
}
```
**引擎行为:**
```
onVideoEnd(scene)
├── nextScene 是 string→ 现存逻辑不变
├── nextScene 是 Choice[]
│ → 遍历数组,第一个满足 conditions 的 → 跳转到它的 targetScene
│ → 都不满足 → endGame()
└── 无 nextScene → 现有逻辑不变
```
**使用场景:**
```
QTE 成功 → effects: enemy_hp -= 25
→ successScene = "combat_router"
├── enemy_hp <= 0 → victory 场景
├── player_hp <= 0 → defeat 场景
└── 否则 → 回到 QTE 场景(循环)
```
**实现清单:**
- [x] `engine/types.ts``SceneNode.nextScene` 类型改为 `string | Choice[]`
- [x] `engine/core/Engine.ts``onVideoEnd` 中加数组判断,遍历 conditions 跳转
- [x] `engine/core/SceneManager.ts``getCandidateTargetIds` 支持数组 nextScene
- [x] `src/components/StoryGallery.vue` — BFS 遍历 + `buildPlayerTree` 支持数组 nextScene
- [x] `public/scenes/demo.json` — 新增 `combat_router` 条件路由示例
- [x] 验证TypeScript + Vite build 通过
### P26 关键节点过滤 — StoryGallery 只展示剧情分叉点 ✅ 已完成 2026-06-12
目标StoryGallery 不再展示所有场景,只展示剧情关键节点(章节起始、选择分支点、结局点)。
QTE 场景和过渡/路由场景被过滤,子节点上浮一级。
**三层判断:**
| 优先级 | 来源 | 说明 |
|:--:|------|------|
| 1 | `SceneNode.keyMoment` | 手动覆盖。`true`=强制展示,`false`=强制隐藏 |
| 2 | `endings[].sceneId` | 结局节点 |
| 3 | 自动判断 | 章节起点 / 有 `choices`(分支点)→ 关键节点。QTE 不算 |
**扁平化:** 非关键节点不渲染,子节点上浮到父节点层级,路径语义不变。
**实现清单:**
- [x] `engine/types.ts``SceneNode.keyMoment?: boolean`
- [x] `src/components/StoryGallery.vue``isKeyMoment()` 三层逻辑 + `collectKeyTargets()` 扁平化非关键节点
- [x] 验证TypeScript + Vite build 通过
### P27 全局计时器 — 跨场景时间压力(待实现)
目标:跨场景倒计时,时间用尽强制跳转。独立的 `TimerSystem` 类 + 三个新 Effect 类型,
支持启动/停止/重置/加减时间。
**Effect 类型:**
| Effect type | 参数 | 说明 |
|-------------|------|------|
| `startTimer` | `duration`(秒), `expireScene` | 启动倒计时。如已存在则重置 |
| `stopTimer` | — | 暂停计时器 |
| `addTime` | `value`(秒) | 增加剩余时间(正数)或扣减(负数) |
**使用场景:**
```json
{
"id": "chapter_start",
"onEnter": [
{ "type": "startTimer", "duration": 3600, "value": 3600, "target": "timeout_ending" }
]
}
```
**TimerSystem 核心逻辑:**
```typescript
class TimerSystem {
private remaining: number = 0
private expireScene: string = ''
private intervalId: ReturnType<typeof setInterval> | null = null
private onExpire: ((sceneId: string) => void) | null = null
start(duration: number, expireScene: string) { ... }
stop() { ... }
addTime(seconds: number) { ... }
getRemaining(): number { ... }
}
```
setInterval 每秒递减,剩余 ≤0 时调用 `onExpire(expireScene)`
**UI 显示:** PlaybackBar 右下角 `MM:SS` 格式,最后一分钟变红。
**实现清单:**
- [ ] `engine/systems/TimerSystem.ts`**新建** — 计时器核心逻辑
- [ ] `engine/types.ts` — Effect 新增 `startTimer`/`stopTimer`/`addTime` 类型
- [ ] `engine/core/StateManager.ts``apply` 中处理新 Effect
- [ ] `engine/core/Engine.ts` — 集成 `TimerSystem``startChapter` 停止旧 Timer
- [ ] `engine/systems/SaveSystem.ts` — 存档/读档包含 Timer 状态
- [ ] `src/components/PlaybackBar.vue` — HUD 显示倒计时
### P28 随机路由 — 变量初始值/场景随机选择(待实现)
目标:`nextScene` / 变量初始值支持随机选择,每次玩法不同。
### P29 背包/装备系统 — 物品持有影响叙事(待实现)
目标:玩家可持有物品,物品影响 `conditions` 判断、选择可见性、场景解锁。
### P30 通关评分/反馈 — 结算面板展示统计(待实现)
目标通关后展示玩家行为统计线索数、成就数、结局数。DeathPanel 升级为通用的 `ResultPanel`,死亡和通关统一走这里。
**数据设计GameData 顶层):**
```json
{
"stats": [
{ "label": "线索发现", "variable": "investigation", "max": 5 },
{ "label": "QTE 成功次数", "variable": "qte_succeeded" },
{ "label": "达成结局数", "type": "endingsCount" }
]
}
```
| 字段 | 说明 |
|------|------|
| `label` | 统计项名称 |
| `variable` | 从 `variables` 读值 |
| `max` | 满分(可选),用于进度条 |
| `type: "endingsCount"` | 特殊统计 — `visitedSceneIds ∩ endings[].sceneId` 计数 |
**实现清单:**
- [ ] `engine/types.ts``GameData.stats?: StatDef[]`
- [ ] `src/components/DeathPanel.vue` → 升级为 `ResultPanel.vue`
- [ ] `src/App.vue``gameEnd` 触发后展示 ResultPanel
### P31 战斗 HUD + 结算面板 — RPG HUD 流派 ✅ 已完成 2026-06-12
目标:战斗场景中展示角色属性 HUD头像 + HP/MP 条 + 数值),胜利后弹出结算面板。
走 RPG HUD 流派,非极简派。战败不做结算面板,直接走战败叙事。
**SceneNode 新增字段:**
```json
{
"id": "combat",
"videoUrl": "combat/combat.mp4",
"qte": { ... },
"battleHUD": [
{
"label": "你",
"portrait": "images/player.jpg",
"stats": [
{ "variable": "player_hp", "label": "HP", "max": 100 },
{ "variable": "player_mp", "label": "MP", "max": 50 },
{ "variable": "combo_score", "label": "连击" }
]
},
{
"label": "敌人",
"portrait": "images/enemy.jpg",
"stats": [
{ "variable": "enemy_hp", "label": "HP", "max": 100 }
]
}
],
"battleResult": {
"title": "战斗胜利!",
"stats": [
{ "label": "剩余生命", "variable": "player_hp" },
{ "label": "QTE 成功次数", "variable": "qte_succeeded" }
]
}
}
```
**BattleHUD 字段说明:**
| 字段 | 说明 |
|------|------|
| `label` | 角色名称 |
| `portrait` | 角色头像路径 |
| `stats` | 属性数组。`variable`/`label`/`max`/`style``"bar"``"number"`,缺省时根据有无 `max` 自动判断) |
**布局:** 角色头像左侧stats 竖排叠在头像右侧。多角色水平排列在屏幕一侧。
**组件:**
| 组件 | 说明 |
|------|------|
| `BattleHUD.vue` | 战斗场景中显示角色属性条,`variables` 实时响应 |
| `BattleResult.vue` | 胜利结算面板 — 标题 + stats + "继续"按钮 → 下一场景 |
**实现清单:**
- [x] `engine/types.ts``BattleHUDStat` / `BattleHUDEntry` / `BattleResultStat` / `BattleResultDef` 接口
- [x] `src/components/BattleHUD.vue`**新建** — 角色头像 + stats 进度条/数值i18n labelKey
- [x] `src/components/BattleResult.vue`**新建** — 胜利结算面板 + titleKey + "继续"按钮
- [x] `src/stores/gameStore.ts``variable()` 读值 + `showBattleResult` 状态
- [x] `src/composables/useGameEngine.ts``sceneChange` 中检测 `scene.battleResult` 自动弹出
- [x] `src/App.vue` — 整合 BattleHUD + BattleResult
- [x] `src/locales/zh.json` + `en.json``continue` / `toMenu` i18n
- [x] `public/scenes/demo.json``right_door` 场景添加 `battleHUD` 示例
- [x] 验证TypeScript + Vite build 通过
## 已完成
P0~P23 全部实现(除 P18。详见 [CHANGELOG.md](CHANGELOG.md)。

View File

@@ -0,0 +1,234 @@
# E17: AI 编码助手 — opencode + DeepSeek 集成
## 概述
编辑器内嵌 AI 对话面板,使用 opencode Agent + DeepSeek 后端,支持两种模式:
- **JSON 模式** — 修改场景配置,填充 textarea 供用户审查后接受
- **代码模式** — 直接修改 `src/` 目录下的 Vue 组件和 CSSVite HMR 实时预览
## 完整架构
```
浏览器 (Editor)
├── AIPanel.vue 用户输入自然语言
├── NodeEditor.vue 接收 AI 返回的 JSON[接受]/[撤销]
└── App.vue 管理 AI 面板状态
↓ POST /api/ai { sessionId, userMessage, apiKey, mode, nodeId? }
Vite 中间件(零状态)
└── spawn('node_modules/.bin/opencode', ['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage])
├── fullMessage = modePrefix + userMessage
│ JSON模式: "JSON模式只返回修改后的 JSON 文本,不要写任何文件。需求:..." + nodeJson
│ 代码模式: "代码模式:直接修改 src/ 下的源码文件并保存。需求:..."
├── opencode 自身管理会话上下文(--session 复用已有会话)
├── 自动读取项目文件构建 prompt → 调 DeepSeek API
└── 返回 stdout
```
## JSON 模式交互时序
```
用户 编辑器 Vite 中间件 opencode DeepSeek
│ │ │ │ │
│ 选中节点+输入需求 │ │ │ │
│─────────────────────→│ │ │ │
│ │ POST /api/ai │ │ │
│ │──────────────────────→│ │ │
│ │ │ spawn opencode │ │
│ │ │───────────────────→│ │
│ │ │ │ 读 story.json + │
│ │ │ │ SPEC.md + 用户需求 │
│ │ │ │────────────────────→│
│ │ │ │ 返回 JSON │
│ │ │ │←────────────────────│
│ │ │ stdout: JSON │ │
│ │ │←───────────────────│ │
│ │ 200 { result } │ │ │
│ │←──────────────────────│ │ │
│ │ │ │ │
│ 显示 [接受] [撤销] │ │ │ │
│─────→ textarea 填充 │ │ │ │
│ │ │ │ │
│ 点击 [接受] │ │ │ │
│─────────────────────→│ │ │ │
│ │ JSON.parse → update │ │ │
│ │ → autoSave 写磁盘 │ │ │
```
## 代码模式交互时序
```
用户 编辑器 Vite 中间件 opencode DeepSeek
│ │ │ │ │
│ 输入 UI 修改需求 │ │ │ │
│─────────────────────→│ │ │ │
│ │ POST /api/ai │ │ │
│ │──────────────────────→│ │ │
│ │ │ spawn opencode │ │
│ │ │───────────────────→│ │
│ │ │ │ 读 src/components/ │
│ │ │ │ 改 Vue/CSS 文件 │
│ │ │ │────────────────────→│
│ │ │ │ 写文件到 src/ │
│ │ │ │←────────────────────│
│ │ │ stdout: done │ │
│ │ │←───────────────────│ │
│ │ 200 { result } │ │ │
│ │←──────────────────────│ │ │
│ │ │ │ │
│ Vite HMR 检测变化 │ │ │ │
│←─────────────────────│ │ │ │
│ 浏览器热更新预览 │ │ │ │
```
## 关键设计决策
| 决策 | 做法 | 原因 |
|------|------|------|
| **AI 后端** | opencode Agent + DeepSeek | 唯一覆盖 JSON + 代码双模式;自动读取项目文件构建上下文 |
| **opencode 安装** | npm 包 `opencode-ai`,作为 `devDependencies``npm install``node_modules/.bin/opencode` 即可用 | clone 即用,无需手动全局安装 |
| **API Key 存储** | 编辑器设置中输入,存 localStorage每次请求传给 `/api/ai` | 不硬编码,创作者自行管理 |
| **API Key 传输** | 通过 POST body 传到 Vite 中间件,不暴露在浏览器网络日志 | XSS 只能获取 localStorage看不到服务端日志 |
| **JSON 编辑** | AI 返回 JSON 填充 textarea用户审查后 [接受] 才保存 | 保留创作者对故事数据的最终控制权 |
| **代码编辑** | opencode 直接写 `src/` 目录Vite HMR 秒级刷新 | 代码修改即时可视化,不需要手动刷新 |
| **上下文构建** | opencode 读取项目文件自成上下文 | 不需要手动拼系统 prompt |
| **模式切换** | AIPanel 当前模式标签:选中节点 → "JSON 模式";未选中节点 → "代码模式"。手动可切换 | 减少用户认知负担,大部分场景自动判断正确 |
| **DeepSeek 模型** | `--model deepseek`,通过 opencode providers 配置 Key | opencode 自身管理模型路由 |
## 会话管理
opencode 原生支持会话:`opencode run --session <id>` 复用已有上下文,`opencode session list` 列出历史。
### 架构原则:前端持 sessionId中间件无状态
```
AIPanel (Vue)
├── sessionId = localStorage.getItem('editor_ai_session') || crypto.randomUUID()
├── 每次请求 POST /api/ai 带上 sessionId
├── "新建对话" → 新 UUID → 覆盖 localStorage
└── 历史列表 → GET /api/ai/sessions → opencode session list → 前端渲染
Vite 中间件 — 零状态
└── spawn('opencode', ['run', '--session', sessionId, '--format', 'json', userMessage])
└── 不存任何 sessionId不维护子进程
```
### 会话操作
| 操作 | opencode CLI | 触发方式 |
|------|-------------|---------|
| 首次对话 | `opencode run --session <新UUID> --model deepseek --format json "..."` | AIPanel 检测 localStorage 无 sessionId |
| 继续对话 | `opencode run --session <已有UUID> --model deepseek --format json "..."` | 带上同一 sessionId |
| 新建对话 | 前端生成新 UUID → 旧会话仍存在于 opencode 内部 DB | AIPanel "新建对话" 按钮 |
| 历史列表 | `opencode session list` | AIPanel 顶部下拉(可选) |
### 为什么不用长活子进程
| | 长活子进程 + stdin/stdout | **独立进程 + --session** |
|------|:--:|:--:|
| 进程管理 | 需管道通信、心跳检测 | `spawn` 等待 exit0 行管理代码 |
| 中间件状态 | 需维护 sessionId→process Map | 零状态 |
| dev server 重启 | 会话丢失 | localStorage 持久化,重启可恢复 |
| 稳定性 | 子进程可能 crash | 每次独立进程,天然隔离 |
| 延迟 | ~1-3s管道通信 | ~5s启动 Node + 加载会话),对编辑器 AI 场景可接受 |
## 安全设计
| 层级 | 措施 |
|------|------|
| **API Key** | AES 加密存 localStorage→ 不用Key 本身是服务端 API 凭证浏览器存储已是业界实践VS Code Copilot、Cursor 同理) |
| **传输** | POST body 中传递HTTPS 加密。仅 `/api/ai` 路由可读取,上游不打印日志 |
| **文件写入** | opencode 通过 Vite 中间件 spawn中间件做路径白名单JSON 模式只允许 `public/scenes/*.json`;代码模式只允许 `src/**/*.vue``src/**/*.ts``src/locales/*.json` |
| **请求频率** | 中间件加 3s 内去重(同一 mode+userMessage 不重复 spawn |
## 错误处理
| 错误场景 | 处理 |
|----------|------|
| DeepSeek API 超时15s | 中间件 kill 子进程,返回 `504 { error: "timeout" }` |
| opencode 进程崩溃exit code ≠ 0 | 中间件返回 `500 { error: "opencode exited with code " + code }` |
| DeepSeek API 余额不足 | 中间件返回 `402 { error: "insufficient_quota" }` |
| opencode 返回非 JSON代码模式 | 直接视为成功stdout 文本无关紧要) |
| opencode 返回非 JSONJSON 模式) | 中间件尝试用正则提取 JSON 块,无法提取则返回 `500` 给前端 |
## 文件改动清单
| 文件 | 职能 |
|------|------|
| `package.json` | 新增 `opencode-ai` devDependency`"opencode-ai": "^1.17"` |
| `vite.config.ts` | 新增 `POST /api/ai` 中间件:接收 sessionId/userMessage/apiKey/mode构造 modePrefix + spawn(`opencode`, `['run', '--session', sessionId, '--model', 'deepseek', '--format', 'json', fullMessage]`)`GET /api/ai/sessions``opencode session list` |
| `editor/composables/useAI.ts` | **新建**`sendAIRequest(sessionId, userMessage, mode)` → fetch(`/api/ai`)`listSessions()` → fetch(`/api/ai/sessions`) |
| `editor/components/AIPanel.vue` | **新建** — 消息历史 + `<input>` + loading + "正在生成..." + **"新建对话"按钮** + 会话列表下拉 |
| `editor/stores/editorStore.ts` | 新增 `deepseekKey`localStorage`showAIPanel``aiResult``sessionId` |
| `editor/App.vue` | 新增 "AI 助手" 按钮 + AIPanel 组件 + 设置面板中 API Key 输入栏 |
---
## 测试用例
### T1: JSON 模式 — 正常流程
| 步骤 | 预期结果 |
|------|---------|
| 选中一个场景节点 | NodeEditor 显示该场景 JSON |
| 打开 AIPanel输入"给这个场景添加一个 QTE" | 显示 loading 状态 |
| opencode 返回修改后 JSON | NodeEditor textarea 被填充,出现 [接受] [撤销] |
| 点击 [接受] | JSON.parse 成功store.updateScene 执行autoSave 写磁盘 |
| 点击 [撤销] | textarea 回滚到 AI 修改前的值 |
### T2: JSON 模式 — opencode 返回无效 JSON
| 步骤 | 预期结果 |
|------|---------|
| AI 返回非法 JSON | 中间件返回 `500 { error }` |
| AIPanel 显示红色错误提示 | "AI 返回格式异常,请重试"textarea 不被填充 |
### T3: 代码模式 — 正常流程
| 步骤 | 预期结果 |
|------|---------|
| 未选中任何节点 | AIPanel 显示 "代码模式" 标签 |
| 输入"把按钮改成圆角 20px" | 显示 loading |
| opencode 修改 `src/components/ChoicePanel.vue` 的 CSS | 文件写磁盘成功 |
| Vite HMR 触发 | 预览窗按钮圆角即时可见 |
### T4: 会话管理
| 步骤 | 预期结果 |
|------|---------|
| 首次对话 | localStorage 无 `editor_ai_session` → AIPanel 自动生成 UUID |
| 第二次对话 | 带上同一 sessionId → opencode `--session <id>` 恢复上下文 |
| 点击 "新建对话" | 新 UUID 写入 localStorage |
| 刷新页面 | sessionId 不丢失,历史列表可查 |
### T5: 错误处理
| 步骤 | 预期结果 |
|------|---------|
| DeepSeek Key 无效 | AIPanel 显示 "API 认证失败,请检查 Key" |
| opencode 进程被 kill | 中间件 `child.on('exit', 137)` → 500 |
| 请求 15s 无响应 | 中间件 kill 子进程 → 504 |
| 同一 userMessage 3s 内发送两次 | 去重,不重复 spawn |
### T6: 路径白名单
| 步骤 | 预期结果 |
|------|---------|
| 代码模式让 AI 修改 `/etc/passwd` | 中间件拒绝或 opencode 写盘失败 |
| JSON 模式让 AI 修改 `../secret.json` | 路径不在 `public/scenes/*.json` 范围内, 被拒绝 |
### T7: API Key 管理
| 步骤 | 预期结果 |
|------|---------|
| 未填 Key 就发送请求 | AIPanel 提示 "请先在设置中输入 DeepSeek API Key" |
| 填入 Key → 发送请求 | Key 从 localStorage 读取POST body 传给中间件 |
| 刷新页面 | Key 仍存在 |
### T8: NodeEditor 覆盖保护
| 步骤 | 预期结果 |
|------|---------|
| 用户正在手动编辑 textarea → AI 返回结果 | AI 结果填充到 textarea覆盖手动编辑可 [撤销] |
| AI 返回后用户手动再编辑 → 失焦 | 手动编辑内容通过 blur 保存(不是 AI 内容) |

View File

@@ -0,0 +1,336 @@
# E18: 版本回滚 & AI 修改标记
## 概述
为编辑器的 AI 助手增加两个生产级功能:
1. **版本回滚** — AI 修改前自动存档,错误时可恢复到任意历史版本
2. **修改标记** — AI 修改后在 SceneGraph 节点上显示角标(新增/修改/删除),视觉可感知变更
---
## 一、版本回滚
### 流程
```
用户发起 AI 请求
→ buildMessage → POST /api/ai
→ AIPanel.send() 在调用 sendAIRequest 前:
editorStore.saveVersion("AI 修改前")
→ AI 修改完成 → reloadFromDisk() → gameData 刷新
用户发现改错了
→ AIPanel 版本下拉 → 选择历史版本
→ editorStore.restoreVersion(index)
→ gameData = version.gameData → autoSave()
→ SceneGraph + NodeEditor 重绘
```
### 涉及文件
| 文件 | 改动 |
|------|------|
| `editor/stores/editorStore.ts` | 新增 `versions` ref、`saveVersion(label)``restoreVersion(idx)` |
| `editor/db/editorDB.ts` | **新建** — IndexedDB 封装:`put`/`get`/`clear`,按 `sourcePath` 分组 |
| `editor/components/AIPanel.vue` | 新增版本下拉 UI在聊天区顶部send() 前调用 `saveVersion()` |
### 版本数据结构
```ts
interface EditorVersion {
id: string // crypto.randomUUID()
sourcePath: string // "/scenes/demo.json"
timestamp: number // Date.now()
label: string // "AI 修改前" / "初始版本"
gameData: GameData // 完整游戏数据快照
}
```
### IndexedDB 设计
```
数据库: editor_versions
表: versions
索引: sourcePath (查询同一文件的所有版本)
索引: timestamp (排序)
```
保留策略:同一 `sourcePath` 最多 20 个版本,超出删除最旧的。
### editorDB.ts API
```ts
// 保存版本
putVersion(v: EditorVersion): Promise<void>
// 获取某文件的所有版本(按时间倒序)
getVersions(sourcePath: string): Promise<EditorVersion[]>
// 清除某文件的所有版本(切换文件时)
clearVersions(sourcePath: string): Promise<void>
```
### 版本 UI 设计
```
AIPanel 顶部
┌──────────────────────┐
│ 历史版本: [AI 修改前 ▼] │ ← 下拉列表,默认隐藏
│ 2026-06-15 14:30 AI 修改前 │
│ 2026-06-15 14:28 初始版本 │ ← restoreVersion 后刷新全部
└──────────────────────┘
```
恢复后逻辑:
1. `gameData = version.gameData`
2. `autoSave()` 写回磁盘
3. `selectedNodeId = null`(重置选中)
4. AIPanel 聊天区显示 "已恢复到 'AI 修改前'"
5. 不清除该版本快照(用户可能想在不同版本间多次切换)
### saveVersion 触发时机
| 时机 | 标签 |
|------|------|
| AI 模式 `send()` 调用前(`reloadFromDisk` 之前) | `"AI 修改前"` |
| `App.vue` 首次加载 demo.json 后 | `"初始版本"` |
不自动在手动编辑时保存版本(用户手动点"保存版本"按钮即可,可选后续添加)。
---
## 二、AI 修改标记
### 流程
```
reloadFromDisk() 前:
记下 preGameData = gameData深拷贝
reloadFromDisk() 后:
diff = computeDiff(preGameData, newGameData)
aiChanges = { added, modified, deleted }
SceneGraph:
渲染时根据 aiChanges 给对应节点加角标
```
### 数据结构
```ts
interface AIDiff {
added: string[] // 新 gameData 中有,旧 gameData 中没有的 scene ID
modified: string[] // 相同 IDJSON.stringify 不同的 scene ID
deleted: string[] // 旧 gameData 中有,新 gameData 中没有的 scene ID
globalFields: string[] // 变更的全局配置字段名,例如 ["title", "assetBase"]
}
```
### diff 计算逻辑editorStore.reloadFromDisk 内)
```ts
const { scenes: oldScenes, ...oldGlobal } = oldGameData
const { scenes: newScenes, ...newGlobal } = newGameData
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
for (const id of Object.keys(newScenes)) {
if (!oldScenes[id]) diff.added.push(id)
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
}
for (const id of Object.keys(oldScenes)) {
if (!newScenes[id]) diff.deleted.push(id)
}
for (const key of Object.keys({ ...oldGlobal, ...newGlobal })) {
if (JSON.stringify(oldGlobal[key]) !== JSON.stringify(newGlobal[key])) {
diff.globalFields.push(key)
}
}
```
同时检测全局配置变更startScene、title 等),记录到 `globalFields`
### 角标视觉效果
Vue Flow 自定义节点模板SceneGraph.vue 已有 `CustomNode` 组件):
```
┌──────────────┐
│ ● NEW │ ← 绿色角标 (纯 CSS 色块)
│ scene_A │
└──────────────┘
┌──────────────┐
│ ● MOD │ ← 橙色角标
│ scene_B │
└──────────────┘
```
CSS 实现:在节点元素右上角添加绝对定位的 `<span class="diff-badge">`
### 全局配置变更标记
`aiChanges.globalFields` 非空时,在 `NodeEditor` 的"全局配置"标题区展示:
```
┌─────────────────────────────┐
│ 全局配置 ● 已修改 3 字段 │ ← 橙色圆点 + 变更字段数
│ ╭──────────────────╮ │
│ │ title: "xxx" │ │ ← 鼠标 hover 时弹出 tooltip
│ │ startScene: "yyy" │ │ 列出具体变更的字段名
│ │ assetBase: "zzz" │ │
│ ╰──────────────────╯ │
│ { │
│ "title": "xxx", ← 正常 JSON textarea 不变
│ ... │
└─────────────────────────────┘
```
**不变更项占位**:用逗号分隔字段名,如 `已修改 3 字段`hover 列出所有字段名。
**清除**:用户 blur JSON textarea 后标记消失(手工编辑即为最终确认)。
### 标记清除时机
| 条件 | 行为 |
|------|------|
| 用户手动编辑任意节点后 blur 保存 | 清除 `aiChanges`,所有标记消失 |
| 用户手动编辑全局配置后 blur 保存 | 清除全局配置标记 |
| AIPanel 点击"清除高亮"按钮 | 手动清除所有标记 |
| 新一轮 AI 修改 | 覆盖旧 diff新 diff 替换旧 diff |
### 涉及文件
| 文件 | 改动 |
|------|------|
| `editor/stores/editorStore.ts` | 新增 `aiChanges` ref、diff 计算逻辑、标记清除 |
| `editor/components/SceneGraph.vue` | watch `aiChanges`、自定义节点模板加角标 |
| `editor/components/NodeEditor.vue` | 全局配置标题加变更提示 |
---
## 三、完整交互时序
```
用户输入需求 "添加一个新场景 scene_battle"
AIPanel.send()
① saveVersion("AI 修改前") ← 快照
② preGameData = clone gameData ← 记下旧值
③ sendAIRequest(buildMessage(msg), ...)
opencode 读文件 → 新增 scene_battle → 写盘 → 回复 "已创建 scene_battle"
AIPanel 收到 result
④ 聊天显示 result
⑤ store.reloadFromDisk()
├─ fetch → 新 gameData
├─ diff(oldGameData, newGameData) → aiChanges = { added: ["scene_battle"], modified: [], deleted: [], globalFields: [] }
├─ gameData = newGameData
└─ selectedNodeId = null
SceneGraph 检测到 aiChanges 非空
→ 新增节点 scene_battle 渲染时显示绿色 "NEW" 角标
→ 已有节点无角标
用户手动编辑任一节点后 blur
→ store.clearAIMarkers()
→ 所有角标消失
```
---
## 四、改动清单
### editor/db/editorDB.ts新建
| API | 说明 |
|-----|------|
| `initDB()` | 打开/创建 `editor_versions` 数据库 |
| `putVersion(v)` | 存入版本快照 |
| `getVersions(sourcePath)` | 获取某文件所有版本(按时间倒序,最多 20 |
| `clearVersions(sourcePath)` | 清除某文件的版本(切换文件/删除旧版本时) |
### editor/stores/editorStore.ts
| 新增 | 说明 |
|------|------|
| `versions: Ref<EditorVersion[]>` | 当前文件的所有历史版本 |
| `aiChanges: Ref<AIDiff>` | 上次 AI 修改的 diff 结果 |
| `saveVersion(label)` | 深拷贝 gameData → 存 IndexedDB → 更新 versions |
| `restoreVersion(idx)` | gameData = versions[idx].gameData → autoSave |
| `reloadFromDisk()` 增强 | diff 计算 + 设置 aiChanges |
| `clearAIMarkers()` | aiChanges = null |
| `loadVersions()` | 加载当前 sourcePath 的版本列表 |
### editor/components/SceneGraph.vue
| 新增 | 说明 |
|------|------|
| `watch(aiChanges)` | 重新渲染时按 aiChanges 给节点加 badge |
| CSS `.diff-badge` / `.diff-badge-added` / `.diff-badge-modified` / `.diff-badge-deleted` | 角标样式 |
### editor/components/AIPanel.vue
| 新增 | 说明 |
|------|------|
| 版本下拉 UI | `<select v-model="selectedVersion">` + 恢复按钮 |
| `send()` 前调 `saveVersion("AI 修改前")` | 在 `loading = true` 前 |
| "清除高亮" 按钮 | 调用 `store.clearAIMarkers()` |
### editor/components/NodeEditor.vue
| 新增 | 说明 |
|------|------|
| 全局配置标题加标记 | `全局配置 ● 已修改 N 字段` + hover tooltip 显示字段名,当 `aiChanges.globalFields` 非空 |
| `onBlur()` 时调 `clearAIMarkers()` | 编辑后清除标记 |
---
## 五、非侵入性保证
| 项 | 保障 |
|----|------|
| IndexedDB 故障容忍 | try/catch 包裹所有操作,失败不影响主流程 |
| 版本存储增长 | 20 个上限 + 切换文件时清空旧文件的版本 |
| SceneGraph 性能 | diff 比较仅用 `Object.keys` + `JSON.stringify`,百级节点 < 2ms |
| 不影响手动编辑 | `clearAIMarkers()` blur 时调用角标不留存 |
| 不影响 Code 模式 | 仅在 JSON 模式 `reloadFromDisk()` 时做 diff |
| 不使用 emoji | CSS 纯色圆点 + 文字实现角标 |
---
## 六、测试用例
### V1版本自动保存
| 步骤 | 预期 |
|------|------|
| 编辑器中加载 demo.json | 自动创建版本 "初始版本" |
| 打开 AIPanelJSON 模式发送"添加场景" | 自动创建版本 "AI 修改前" |
| 打开版本下拉 | 显示两个版本 |
### V2版本恢复
| 步骤 | 预期 |
|------|------|
| AI 修改后选择 "AI 修改前" 版本 | 点击恢复 |
| 编辑器内容回到修改前 | SceneGraph + NodeEditor 刷新为旧内容 |
### V3修改标记
| 步骤 | 预期 |
|------|------|
| AI 新增 scene_battle | SceneGraph 中该节点显示绿色 "NEW" 角标 |
| AI 修改 scene_start | 该节点显示橙色 "MOD" 角标 |
| AI 修改 title + startScene | NodeEditor 全局配置标题显示 "● 已修改 2 字段"hover 列出 title/startScene |
| 用户手动编辑任一节点后 blur | 所有角标消失 |
| 用户手动编辑全局配置后 blur | 全局配置标记消失 |
### V4非侵入
| 步骤 | 预期 |
|------|------|
| 不通过 AI 直接手动编辑 JSON | 不触发版本保存不显示角标 |
| 浏览器不支持 IndexedDB | 版本功能静默失效不影响编辑 |

82
docs/EDITOR_ROADMAP.md Normal file
View File

@@ -0,0 +1,82 @@
# 编辑器 Roadmap
编辑器核心定位:**图谱可视化 + 实时预览测试**。场景字段由创作者在 VS Code 中直接写 JSON编辑器不做 GUI 表单。
---
## 已完成
| 功能 | 完成 |
|------|:--:|
| 场景节点图Vue Flow | ✅ |
| JSON 导入/导出 | ✅ |
| 视频预览 | ✅ |
| Pinia 状态管理editorStore | ✅ |
| 纯函数数据层GraphService | ✅ |
| 图谱/预览切换 | ✅ |
| 节点右键测试(新标签页) | ✅ |
| 节点拖拽位置记忆 | ✅ |
---
## E10: 内嵌快速测试 ✅ 部分完成
- [x] 节点右键菜单 → "从此场景开始测试"
- [x] 新标签页打开游戏
- [x] Engine 支持 `?startScene=` URL 参数
- [ ] PreviewPanel 内嵌 iframe 游戏播放器(远期)
## E11: 场景列表 + 搜索
- [ ] 左侧可折叠场景列表面板(`SceneList.vue`
- [ ] 按名称搜索/筛选场景节点
- [ ] 点击列表项 → 画布跳转到对应节点并选中
## E12: JSON 校验器
- [ ] 导出/保存前实时检查
- [ ] 引用完整性targetScene 指向不存在的场景 ID
- [ ] 死路检测:无 choices / 无 nextScene / 无 qte / 无 hotspots
- [ ] 变量引用conditions 中的 variable 未在 `variables` 声明
## E13: 撤销/重做
- [ ] 操作历史栈add/delete/update/move node & edge
- [ ] Ctrl+Z 撤销 / Ctrl+Shift+Z 重做
## E16: NodeEditor → JSON 编辑器 ✅ 已完成
- [x] 删除现有 GUI 表单,改为 `<textarea>` + 等宽字体 + 深色背景
- [x] 初始值 `JSON.stringify(scene, null, 2)`
- [x] `@blur` → JSON.parse → `store.updateScene(id, parsed)`
- [x] 解析失败 → 红色边框 + 错误提示
## E17: AI 编码助手 — opencode + DeepSeek 集成
目标:编辑器内嵌 AI 对话面板。JSON 模式填充 textarea 供用户审查后接受;代码模式直接修改 Vue 组件/CSSVite HMR 实时预览。
详见 [E17_AI_ASSISTANT_PROPOSAL.md](../E17_AI_ASSISTANT_PROPOSAL.md)
- [x] `vite.config.ts``POST /api/ai` 中间件spawn opencode
- [x] `editor/composables/useAI.ts` — DeepSeek API 调用封装
- [x] `editor/components/AIPanel.vue` — AI 对话面板
- [x] `editor/components/NodeEditor.vue``aiResult` prop + [接受]/[撤销]
- [x] `editor/stores/editorStore.ts``deepseekKey` + `showAIPanel` + `aiResult`
- [x] `editor/App.vue` — "AI 助手" 按钮 + API Key 设置
**优先级建议:**
| 优先级 | 编号 | 说明 |
|:--:|------|------|
| **P0** | E17 | AI 编码助手 — DeepSeek 集成 ✅ 核心已完成 |
| **P0** | E16 | JSON 编辑器 ✅ 已完成 |
| **P0** | E10 | 内嵌快速测试 |
| **P1** | E12 | JSON 校验 |
| **P1** | E11 | 场景列表搜索 |
| **P2** | E13 | 撤销/重做 |
---
## 废弃项
E1~E9、E14 废弃。编辑器不再做 NodeEditor GUI 表单,场景字段由创作者在 JSON 中直接编写。

112
docs/guide/BATTLE_SYSTEM.md Normal file
View File

@@ -0,0 +1,112 @@
# 战斗系统
## 概述
P31 新增。分为两个独立功能:**战斗 HUD**(战斗中显示)和**战斗结算**(胜利后弹出)。
---
## BattleHUD — 战斗中显示的角色属性
配置在 `SceneNode.battleHUD`
```json
{
"battleHUD": [
{
"label": "主角",
"labelKey": "battle.hud.player",
"portrait": "images/player.jpg",
"stats": [
{ "variable": "player_hp", "label": "HP", "labelKey": "stat.hp", "max": 100 },
{ "variable": "player_mp", "label": "MP", "labelKey": "stat.mp", "max": 50 },
{ "variable": "score", "label": "连击", "labelKey": "stat.combo" }
]
}
]
}
```
| stat 字段 | 说明 |
|-----------|------|
| `variable` | 变量名,值随 effect 实时变化 |
| `label` | 显示标签(回退值) |
| `labelKey` | i18n key |
| `max` | 最大值,有则为进度条,无则为纯数字 |
| `style` | 强制 `"bar"``"number"`。没配时根据有无 `max` 自动 |
进度条颜色根据百分比自动变化100-50% 绿色 / 50-25% 橙色 / 25-0% 红色。
---
## BattleResult — 胜利结算面板
配置在 `SceneNode.battleResult`。视频播放完后自动弹出。
```json
{
"battleResult": {
"title": "击退成功!",
"titleKey": "battle.result.victory",
"stats": [
{ "label": "勇气", "labelKey": "stat.courage", "variable": "courage", "max": 100 },
{ "label": "QTE 成功", "labelKey": "stat.qte_succeeded", "variable": "qte_succeeded" }
]
}
}
```
| 字段 | 说明 |
|------|------|
| `title` | 结算标题(回退值) |
| `titleKey` | 标题 i18n key |
| `stats` | 统计项数组。`variable` / `label` / `labelKey` / `max` |
### 关键规则
- 战败场景**不配** `battleResult`——战败直接走视频叙事
- 结算面板关闭后自动跳转到 `nextScene` 或第一项 `choice` 目标
- stats 数量随意增减,面板自动适配
---
## 完整模板
```json
{
"id": "combat",
"videoUrl": "combat/video.mp4",
"battleHUD": [ ... ],
"qte": {
"successScene": "combat_router",
"failScene": "defeat",
"effects": {
"success": [{ "type": "add", "target": "enemy_hp", "value": -25 }],
"fail": [{ "type": "add", "target": "player_hp", "value": -20 }]
}
},
"nextScene": "combat_router"
},
{
"id": "combat_router",
"keyMoment": false,
"nextScene": [
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
{ "targetScene": "combat" }
]
},
{
"id": "victory",
"videoUrl": "victory/video.mp4",
"battleResult": { "title": "胜利!", "stats": [ ... ] },
"nextScene": "next_chapter"
},
{
"id": "defeat",
"videoUrl": "defeat/video.mp4",
"keyMoment": true,
"onEnter": [{ "type": "set", "target": "player_dead", "value": 1 }],
"nextScene": "game_over"
}
```

View File

@@ -0,0 +1,36 @@
# 条件路由
## 概述
P25 新增。`nextScene` 从单一场景 ID 扩展为**条件路由数组**。
第一个满足 `conditions` 的场景自动跳转,末尾无条件项作为默认兜底。
## 基本用法
```json
{
"id": "combat_router",
"keyMoment": false,
"nextScene": [
{ "conditions": [{ "variable": "enemy_hp", "op": "<=", "value": 0 }], "targetScene": "victory" },
{ "conditions": [{ "variable": "player_hp", "op": "<=", "value": 0 }], "targetScene": "defeat" },
{ "targetScene": "combat" }
]
}
```
**解读:** 敌人 HP ≤ 0 → 胜利 / 自己 HP ≤ 0 → 战败 / 否则 → 回到战斗场景循环。
## 配合 QTE 使用
```
QTE 成功 → effects: enemy_hp -= 25
→ successScene = "combat_router"
├── enemy_hp <= 0 → victory 场景
├── player_hp <= 0 → defeat 场景
└── 否则 → 回到 QTE 场景(循环)
```
## 向后兼容
旧的 `"nextScene": "alone_ending"` 写法**完全不受影响**。引擎自动区分字符串和数组。

View File

@@ -0,0 +1,15 @@
# 创作者指南
本文档集涵盖所有剧作和功能配置的教程。
| 文档 | 说明 |
|------|------|
| [QUICK_START.md](QUICK_START.md) | 快速上手——5 分钟跑通第一个场景 |
| [SCENE_JSON_SPEC.md](SCENE_JSON_SPEC.md) | 所有字段的完整参考手册 |
| [BRANCHING.md](BRANCHING.md) | 基本分支、条件分支、隐藏选项 |
| [CONDITIONAL_ROUTING.md](CONDITIONAL_ROUTING.md) | `nextScene` 数组——条件路由P25 |
| [KEY_MOMENTS.md](KEY_MOMENTS.md) | StoryGallery 关键节点过滤P26 |
| [BATTLE_SYSTEM.md](BATTLE_SYSTEM.md) | 战斗 HUD + 战斗结算面板P31 |
| [INTERACTIONS.md](INTERACTIONS.md) | QTE、热点、循环等待 |
| [I18N.md](I18N.md) | 多语言字幕和 UI 国际化 |
| [PUBLISHING.md](PUBLISHING.md) | 打包发布——Web zip / macOS / Windows |

31
docs/guide/KEY_MOMENTS.md Normal file
View File

@@ -0,0 +1,31 @@
# 关键节点过滤
## 概述
P26 新增。StoryGallery 的章节回顾默认只展示**剧情关键节点**,过滤掉过渡/路由/中间场景。
## 自动判断规则
| 场景类型 | 是否展示 |
|----------|:--:|
| 章节起始场景(`chapters[].startScene` | ✅ |
| 有 `choices` 的分支点 | ✅ |
| 结局场景(`endings[].sceneId` | ✅ |
| QTE 场景 | ❌ |
| 路由/过渡场景 | ❌ |
## 手动覆盖:`keyMoment` 字段
```json
{
"id": "combat_router",
"keyMoment": false,
"nextScene": [...]
}
```
| 值 | 行为 |
|:--:|------|
| `true` | 强制展示 |
| `false` | 强制隐藏 |
| 未设置 | 自动判断 |

View File

@@ -39,14 +39,16 @@ interface SceneNode {
id: string
type?: 'video' | 'image'
videoUrl: string
streamingUrl?: Record<string, string>
imageUrl?: string
thumbnail?: string
contentSize?: { w: number; h: number }
subtitleUrl?: string
subtitles?: Record<string, string>
choices?: Choice[]
hotspots?: Hotspot[]
qte?: QTEDefinition
nextScene?: string
nextScene?: string | Choice[]
onEnter?: Effect[]
loopStart?: number
loopEnd?: number
@@ -57,6 +59,9 @@ interface SceneNode {
bgmDuckFade?: number
videoMuted?: boolean
skippable?: boolean
keyMoment?: boolean
battleHUD?: BattleHUDEntry[]
battleResult?: BattleResultDef
}
```
@@ -64,15 +69,17 @@ interface SceneNode {
|------|------|------|
| `id` | string | 场景唯一标识 |
| `type` | string | `"video"` (默认) 或 `"image"`。image 类型展示图片+热点,不播放视频 |
| `videoUrl` | string | 视频文件路径。image 场景可为空 |
| `videoUrl` | string | 视频文件路径(本地 MP4桌面版使用。image 场景可为空 |
| `streamingUrl` | object | Web 版使用的 CDN 流媒体路径。三档画质:`{ "超清 (1080P)": "...", "高清 (720P)": "...", "标清 (480P)": "..." }`。引擎根据 `getVideoMode()` 自动选择环境 |
| `imageUrl` | string | 图片场景的图片路径 |
| `thumbnail` | string | 场景缩略图路径 |
| `contentSize` | object | 内容基准分辨率 `{ w: 1280, h: 720 }`Hotspot 坐标基于此计算。用于 object-fit: contain 的偏移补偿 |
| `subtitleUrl` | string | 回退字幕路径。优先使用 `subtitles` |
| `subtitles` | object | 多语言字幕 `{ "zh": "...", "en": "..." }`。语言切换时自动选择 |
| `choices` | Choice[] | 选项列表 |
| `hotspots` | Hotspot[] | 可点击热点区域 |
| `qte` | QTEDefinition | QTE 定义 |
| `nextScene` | string | 无选项时的默认跳转场景 |
| `nextScene` | string \| Choice[] | 无选项时的默认跳转。字符串为单一场景 ID数组为条件路由遍历第一个满足 conditions 的跳转。末尾无条件项作为默认目标 |
| `onEnter` | Effect[] | 进入场景时触发的效果 |
| `loopStart` | number | 循环起始时间(秒) |
| `loopEnd` | number | 循环结束时间(秒)。视频播放到 loopEnd 时跳回 loopStart |
@@ -83,6 +90,9 @@ interface SceneNode {
| `bgmDuckFade` | number | BGM Duck 过渡时长(秒),默认 0.5 |
| `videoMuted` | boolean | 视频静音(配合独立 BGM 使用) |
| `skippable` | boolean | `false` = 禁止跳过(用于 QTE 等关键场景) |
| `keyMoment` | boolean | StoryGallery 中是否展示为关键节点。`true`=强制展示,`false`=强制隐藏,未设置时自动判断(章节起始/有 choice/是结局) |
| `battleHUD` | BattleHUDEntry[] | 战斗场景中显示的角色属性 HUD。每个 entry 为一个角色(头像 + stats 列表) |
| `battleResult` | BattleResultDef | 胜利结算面板。视频结束后弹出,展示战斗统计数据。战败场景不配置此字段 |
---
@@ -106,7 +116,7 @@ interface Choice {
| `text` | 选项文本(回退值) |
| `textKey` | i18n key优先于 `text`。如 `"intro.choice.left_door"` |
| `prompt` | 选择后浮现的提示文字(回退值) |
| `promptKey` | prompt 的 i18n key。如 `"left_door.prompt.handshake"` |
| `promptKey` | prompt 的 i18n key。如 `"left_door.prompt.handshake"`。优先于 `prompt` |
| `targetScene` | 目标场景 ID |
| `conditions` | 显示条件,不满足的选项隐藏 |
| `effects` | 选择后触发的效果 |
@@ -130,6 +140,7 @@ interface Hotspot {
hideAt?: number
conditions?: Condition[]
effects?: Effect[]
timeLimit?: number
}
```
@@ -145,6 +156,7 @@ interface Hotspot {
| `targetScene` | 点击后跳转的场景 |
| `conditions` | 显示条件 |
| `effects` | 点击后触发的效果 |
| `timeLimit` | 限时热点(秒),超时后热点消失 |
---
@@ -170,7 +182,7 @@ interface QTEDefinition {
|------|------|
| `triggerTime` | 触发时间(秒),视频播放到此时间弹出 QTE |
| `prompt` | 提示文字(回退值) |
| `promptKey` | i18n key。如 `"right_door.qte.dodge"` |
| `promptKey` | i18n key。如 `"right_door.qte.dodge"`。优先于 `prompt` |
| `keys` | 有效按键列表,如 `["ArrowLeft", "ArrowRight", "a", "d"]` |
| `timeLimit` | 限时秒数 |
| `successScene` | 成功跳转场景 |
@@ -217,6 +229,15 @@ interface ChapterInfo {
}
```
| 字段 | 说明 |
|------|------|
| `id` | 章节 ID |
| `label` | 章节名称(回退值) |
| `labelKey` | i18n key优先于 `label` |
| `startScene` | 起始场景 ID。玩家到达此场景时章节自动解锁 |
| `thumbnail` | 缩略图路径 |
| `defaultVariables` | 从章节选择界面进入时的默认变量值。未设时 fallback 到全局 `variables` |
### AchievementDef
```typescript
@@ -232,6 +253,17 @@ interface AchievementDef {
}
```
| 字段 | 说明 |
|------|------|
| `id` | 成就唯一 ID |
| `title` | 成就标题(回退值) |
| `titleKey` | i18n key优先于 `title` |
| `description` | 成就描述(回退值) |
| `descKey` | i18n key优先于 `description` |
| `icon` | 图标路径 |
| `hidden` | `true` 时未解锁不显示标题和描述(显示 ??? |
| `condition` | 解锁条件。变量满足时自动解锁并弹出 toast |
### EndingDef
```typescript
@@ -245,6 +277,15 @@ interface EndingDef {
}
```
| 字段 | 说明 |
|------|------|
| `id` | 结局唯一 ID |
| `label` | 结局名称(回退值) |
| `labelKey` | i18n key优先于 `label` |
| `sceneId` | 结局场景 ID。玩家到达此场景时结局自动标记已解锁 |
| `chapterId` | 结局归属章节 ID可选BFS 自动推导) |
| `thumbnail` | 缩略图路径 |
### LocalesConfig
```typescript
@@ -253,3 +294,61 @@ interface LocalesConfig {
languages: string[]
}
```
| 字段 | 说明 |
|------|------|
| `path` | 语言文件目录(相对于 `assetBase`),如 `"locales/"` |
| `languages` | 支持的语言列表,如 `["zh", "en", "ja"]` |
### BattleHUDEntry / BattleHUDStat
战斗场景中显示的角色属性 HUD。配置在 `SceneNode.battleHUD`
```typescript
interface BattleHUDEntry {
label: string
labelKey?: string
portrait?: string
stats: BattleHUDStat[]
}
interface BattleHUDStat {
variable: string
label: string
labelKey?: string
max?: number
style?: 'bar' | 'number'
}
```
| 字段 | 说明 |
|------|------|
| `label` | 角色名称(回退值) |
| `labelKey` | 角色名称 i18n key |
| `portrait` | 角色头像路径 |
| `stats` | 属性数组。`variable`/`label`/`labelKey`/`max`/`style``style` 缺省时根据有无 `max` 自动判断(有则为 bar无则为 number |
### BattleResultDef / BattleResultStat
战斗胜利结算面板。配置在 `SceneNode.battleResult`
```typescript
interface BattleResultDef {
title: string
titleKey?: string
stats: BattleResultStat[]
}
interface BattleResultStat {
label: string
labelKey?: string
variable: string
max?: number
}
```
| 字段 | 说明 |
|------|------|
| `title` | 结算标题(回退值) |
| `titleKey` | 标题 i18n key |
| `stats` | 统计项数组。`variable`/`label`/`labelKey`/`max` |

View File

@@ -1,67 +0,0 @@
# UI 屏幕适配方案
## 原则
**不做响应式布局,只做等比缩放。** 天书的 UI 是为 1920×1080 画布设计的,所有屏幕统一等比缩放,不发散断点。
## 实施方案:`transform: scale()`
### 入口处加 3 行
```html
<!-- index.html / editor/index.html -->
<style>
html, body {
width: 100vw; height: 100vh; overflow: hidden;
}
#app {
width: 1920px; height: 1080px;
transform-origin: top left;
transform: scale(var(--scale));
}
</style>
```
```ts
// main.ts — app mount 前
const scale = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
document.documentElement.style.setProperty('--scale', String(scale))
// resize 时更新
window.addEventListener('resize', () => {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
document.documentElement.style.setProperty('--scale', String(s))
})
```
### 影响范围
| 影响的 | 说明 |
|--------|------|
| ✅ 所有 `px` 值 | 等比缩放,按钮/字号/间距全部生效 |
| ✅ SVG / Canvas 内部坐标 | 天然缩放,无需换算 |
| ⚠️ `position: fixed` | `transform``none` 的元素会创建新的坐标基准,`fixed` 定位可能"粘"在缩放容器内而非视口。**解决:** overlay 统一用 `position: absolute`,根容器设 `position: relative` |
### 兼容注意事项
| 属性 | 兼容性 |
|------|--------|
| `backdrop-filter` | Chromium bug: `transform` 父容器内可能失效。把 `backdrop-filter` 放到 `transform` 容器**外面**(比如直接在 `body` 上) |
| `getBoundingClientRect()` | 返回的是缩放前的逻辑坐标,需 `÷ scale` 得到实际屏幕坐标 |
| `window.innerWidth/Height` | 始终返回物理像素,用于计算 scale内部逻辑坐标始终 = 设计尺寸 |
### 为什么不用 rem / clamp / media query
- **无断点布局需求** — 不是手机→平板→桌面三套排版,只是等比缩放一张画布
- **改动量** — `rem` 需要把 200+ 处 `px` 改为 `rem`,收益为零
- **创作者** — 后续页面新增不需要考虑响应式,直接按 1920×1080 画布设计
## 新增页面的适配清单
当需要新增一个全屏页面(如新弹窗/面板)时:
- [ ] 所有尺寸使用 `px`(不要用 `vw`/`vh`/`rem`
- [ ] 弹窗用 `position: absolute`(不用 `fixed`
- [ ] 弹窗的父容器设 `position: relative`
- [ ]`backdrop-filter` 时,确认它在非 `transform` 的父容器内
- [ ] 设计画布按 1920×1080 基准

7
docs/使用经验.md Normal file
View File

@@ -0,0 +1,7 @@
带选择的场景用单独的一个场景和视频表示。配置循环0.1到视频长度-0.1.效果就是进入视频就会出现选择而且还能循环播放视频
用户进攻是否成功由qte决定 用户防守是否成功由qte决定
战斗 |<- 用户进攻回合 用户进攻回合 ->| |<- 用户防守回合 用户防守回合 ->|
ready -> 用户选择进攻qte -> qte_success -> 播放玩家进攻敌人不防守动画 -> 用户选择防守qte -> qte_sucess -> 用户防守敌人进攻 -> 回到 用户选择进攻qte节点
-> qte_fail -> 播放玩家进攻敌人防守动画 -> 用户选择防守qte -> qte_fail -> 用户不防守敌人进攻 ->

View File

@@ -1,72 +1,39 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import type { GameData, SceneNode } from '@engine/types'
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'
import AIPanel from './components/AIPanel.vue'
const store = useEditorStore()
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 showPreview = ref(false)
const previewVideoUrl = computed(() => {
const url = selectedScene.value?.videoUrl
const url = store.selectedScene?.videoUrl
if (!url) return null
return url.startsWith('/') ? url : '/' + url
const resolved = resolveAsset(store.gameData.assetBase || '', url)
return resolved.startsWith('/') ? resolved : '/' + resolved
})
function newNode() {
const id = editor.addScene()
editor.selectedNodeId.value = id
dirty.value = true
const id = store.addScene()
store.selectedNodeId = id
}
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
store.deleteScene(id)
}
function exportJSON() {
const data = editor.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)
@@ -75,21 +42,25 @@ function exportJSON() {
a.download = 'scenes.json'
a.click()
URL.revokeObjectURL(url)
dirty.value = false
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('无效的场景数据')
editor.loadJSON(data)
dirty.value = false
store.loadJSON(data)
store.setSourcePath('/scenes/' + file.name)
} catch (err: any) {
error.value = '导入失败:' + err.message
setTimeout(() => (error.value = ''), 3000)
@@ -101,8 +72,7 @@ async function loadDemo() {
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
store.loadJSON(await resp.json())
} catch (err: any) {
error.value = '加载示例失败:' + err.message
} finally {
@@ -110,9 +80,24 @@ async function loadDemo() {
}
}
onMounted(() => {
loadDemo()
})
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)
loading.value = false
return
}
} catch {}
}
await loadDemo()
}
onMounted(() => restoreOrLoad())
</script>
<template>
@@ -124,173 +109,82 @@ onMounted(() => {
<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>
<button @click="showPreview = !showPreview" :class="{ secondary: true, active: showPreview }">
{{ showPreview ? '📐 图谱' : '🎬 预览' }}
</button>
</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>
<select v-if="store.versions.length > 0" class="toolbar-version-select" @change="store.restoreVersion(($event.target as HTMLSelectElement).selectedIndex - 1)">
<option disabled selected>版本</option>
<option v-for="v in store.versions" :key="v.timestamp">
{{ new Date(v.timestamp).toLocaleString('zh-CN') }} {{ v.label }}
</option>
</select>
</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>
<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="selectedScene"
:scene="store.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>
<AIPanel v-if="store.showAIPanel" />
<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%;
}
* { 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;
}
.editor-layout { width: 100%; height: 100%; display: flex; flex-direction: column; position: relative; }
.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; }
.error-bar { padding: 8px 16px; background: #4a1a1a; color: #f88; font-size: 13px; flex-shrink: 0; }
.editor-main { flex: 1; display: flex; overflow: hidden; position: relative; }
.graph-area { flex: 1; }
.loading-hint { display: flex; align-items: center; justify-content: center; height: 100%; color: #555; font-size: 14px; }
.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 {
.toolbar-version-select {
padding: 4px 10px;
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;
max-width: 200px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.12);
border-radius: 4px;
color: #aaa;
outline: none;
cursor: pointer;
margin-left: auto;
}
.toolbar-version-select:hover { border-color: rgba(255,255,255,0.2); color: #ccc; }
</style>

View File

@@ -0,0 +1,393 @@
<script setup lang="ts">
import { ref, computed, nextTick, onMounted } from 'vue'
import { useEditorStore } from '../stores/editorStore'
import { sendAIRequest } from '../composables/useAI'
const store = useEditorStore()
const inputText = ref('')
const loading = ref(false)
const errorMsg = ref('')
const messages = ref<{ role: string; content: string }[]>([])
const chatRef = ref<HTMLDivElement | null>(null)
const mode = ref<'json' | 'code'>('json')
const lastMsg = computed(() => {
const m = messages.value[messages.value.length - 1]
if (!m) return ''
const text = m.role === 'user' ? m.content : m.content.substring(0, 60)
return text.length > 60 ? text + '...' : text
})
function toggleMode() {
mode.value = mode.value === 'json' ? 'code' : 'json'
}
function buildMessage(userInput: string): string {
if (mode.value !== 'json') return `需求: ${userInput}`
let ctx = `修改文件 ${store.sourcePath}`
if (store.selectedScene) ctx += `,针对场景节点 ${store.selectedScene.id}`
ctx += `。需求: ${userInput}`
return ctx
}
async function send() {
const userInput = inputText.value.trim()
if (!userInput || loading.value) return
inputText.value = ''
errorMsg.value = ''
if (!store.deepseekKey) {
errorMsg.value = '请先在设置中输入 DeepSeek API Key'
return
}
const fullMessage = buildMessage(userInput)
messages.value.push({ role: 'user', content: userInput })
loading.value = true
if (mode.value === 'json') {
await store.saveVersion('AI 修改前')
}
const oldGameData = mode.value === 'json' ? JSON.parse(JSON.stringify(store.gameData)) : undefined
try {
const { result, sessionId: newSid } = await sendAIRequest(fullMessage, mode.value, store.deepseekKey, store.aiSessionId || undefined)
if (newSid) store.setAISessionId(newSid)
if (mode.value === 'json') {
messages.value.push({ role: 'assistant', content: result || '已完成' })
await store.reloadFromDisk(oldGameData)
} else {
messages.value.push({ role: 'assistant', content: result || '已完成' })
}
} catch (e: any) {
errorMsg.value = e.message || '请求失败'
} finally {
loading.value = false
await nextTick()
chatRef.value?.scrollTo({ top: chatRef.value.scrollHeight })
}
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send() }
}
function newSession() {
store.clearAISession()
messages.value = []
errorMsg.value = ''
}
onMounted(() => {
store.loadVersions()
})
</script>
<template>
<div class="ai-panel" v-if="store.showAIPanel">
<Transition name="slide-up">
<div v-if="!store.aiCollapsed" class="ai-expanded">
<div class="ai-exp-header">
<span class="ai-exp-title">AI 助手</span>
<div class="ai-exp-actions">
<button class="ai-mode-btn" @click="toggleMode" :title="mode === 'json' ? '切换到代码模式' : '切换到 JSON 模式'">
{{ mode === 'json' ? '📋 JSON' : '💻 代码' }}
</button>
<button class="ai-new-btn" @click="newSession" title="新建对话">+</button>
<button v-if="store.aiChanges" class="ai-clear-btn" @click="store.clearAIMarkers()">清除高亮</button>
<button class="ai-collapse-btn" @click="store.aiCollapsed = true"> 折叠</button>
</div>
</div>
<div class="ai-chat" ref="chatRef">
<div v-if="!store.deepseekKey" class="ai-key-setup">
<span class="key-label">DeepSeek API Key</span>
<input
class="key-input"
type="password"
placeholder="sk-..."
:value="store.deepseekKey"
@input="store.setDeepseekKey(($event.target as HTMLInputElement).value)"
/>
<div class="key-hint">Key 保存在浏览器本地不会上传到编辑器服务器</div>
</div>
<div v-if="messages.length === 0 && store.deepseekKey" class="ai-empty">
输入你的需求AI 将根据引擎规范修改配置
</div>
<div v-for="(m, i) in messages" :key="i" class="ai-msg" :class="m.role">
<span class="ai-role">{{ m.role === 'user' ? '👤' : '🤖' }}</span>
<span class="ai-content">{{ m.content }}</span>
</div>
<div v-if="loading" class="ai-msg assistant">
<span class="ai-role">🤖</span>
<span class="ai-content loading-dots">正在生成<span class="dots">...</span></span>
</div>
<div v-if="errorMsg" class="ai-error">{{ errorMsg }}</div>
</div>
<div class="ai-input-area">
<input
v-model="inputText"
class="ai-input"
placeholder="输入你的需求..."
:disabled="loading"
@keydown="onKeydown"
/>
<button class="ai-send-btn" @click="send" :disabled="loading">发送</button>
</div>
</div>
</Transition>
<div v-if="store.aiCollapsed" class="ai-collapsed-bar" @click="store.aiCollapsed = false">
<span class="ai-bar-icon">🤖</span>
<span class="ai-bar-title">AI 助手</span>
<span class="ai-bar-divider">·</span>
<span class="ai-bar-msg">{{ messages.length > 0 ? lastMsg : '点击开始对话' }}</span>
<span class="ai-bar-arrow"></span>
</div>
</div>
</template>
<style scoped>
.ai-panel {
flex-shrink: 0;
position: relative;
}
.ai-collapsed-bar {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 16px;
background: #161633;
border-top: 1px solid rgba(100,200,255,0.1);
cursor: pointer;
user-select: none;
font-size: 12px;
color: #999;
transition: background 0.15s;
}
.ai-collapsed-bar:hover {
background: #1a1a3a;
color: #bbb;
}
.ai-bar-icon { font-size: 13px; }
.ai-bar-title { font-weight: 500; color: #8cf; }
.ai-bar-divider { color: rgba(255,255,255,0.15); }
.ai-bar-msg { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #777; }
.ai-bar-arrow { color: #555; font-size: 10px; }
.ai-collapsed-bar:hover .ai-bar-arrow { color: #8cf; }
.ai-expanded {
position: absolute;
bottom: 0;
left: 0;
right: 0;
max-height: 40vh;
min-height: 200px;
display: flex;
flex-direction: column;
background: rgba(20, 20, 44, 0.98);
border-top: 1px solid rgba(255,255,255,0.1);
box-shadow: 0 -6px 24px rgba(0,0,0,0.5);
z-index: 100;
}
.ai-exp-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.ai-exp-title {
font-size: 14px;
font-weight: 500;
color: #ddd;
}
.ai-exp-actions {
display: flex;
align-items: center;
gap: 6px;
}
.ai-mode-btn {
padding: 3px 8px;
font-size: 11px;
color: #8cf;
background: rgba(100,200,255,0.08);
border: 1px solid rgba(100,200,255,0.2);
border-radius: 3px;
cursor: pointer;
}
.ai-mode-btn:hover { background: rgba(100,200,255,0.15); }
.ai-new-btn {
background: none;
border: 1px solid rgba(255,255,255,0.1);
color: #888;
width: 26px;
height: 26px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.ai-new-btn:hover { color: #ddd; border-color: rgba(255,255,255,0.2); }
.ai-clear-btn {
padding: 3px 8px;
font-size: 11px;
color: #ccc;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
}
.ai-clear-btn:hover { color: #fff; border-color: rgba(255,255,255,0.2); }
.ai-collapse-btn {
padding: 3px 8px;
font-size: 11px;
color: #888;
background: none;
border: 1px solid rgba(255,255,255,0.1);
border-radius: 3px;
cursor: pointer;
}
.ai-collapse-btn:hover { color: #ddd; }
.ai-chat {
flex: 1;
overflow-y: auto;
padding: 10px 16px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 80px;
}
.ai-empty {
color: #555;
font-size: 12px;
text-align: center;
margin-top: 30px;
}
.ai-msg {
display: flex;
gap: 8px;
font-size: 12px;
line-height: 1.5;
}
.ai-role {
flex-shrink: 0;
font-size: 13px;
}
.ai-msg.user .ai-content {
color: #8cf;
}
.ai-msg.assistant .ai-content {
color: #ccc;
}
.loading-dots .dots {
animation: dotPulse 1.2s infinite;
}
@keyframes dotPulse {
0%, 20% { opacity: 0; }
50% { opacity: 1; }
80%, 100% { opacity: 0; }
}
.ai-error {
font-size: 12px;
color: #e74c3c;
padding: 8px 12px;
background: rgba(231,76,60,0.1);
border-radius: 4px;
}
.ai-input-area {
display: flex;
gap: 6px;
padding: 8px 14px 10px;
border-top: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.ai-input {
flex: 1;
padding: 7px 12px;
font-size: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
color: #ddd;
outline: none;
}
.ai-input:focus { border-color: rgba(255,255,255,0.2); }
.ai-send-btn {
padding: 7px 14px;
font-size: 12px;
color: #fff;
background: rgba(100,200,255,0.15);
border: 1px solid rgba(100,200,255,0.25);
border-radius: 4px;
cursor: pointer;
}
.ai-send-btn:hover { background: rgba(100,200,255,0.25); }
.ai-send-btn:disabled { opacity: 0.4; cursor: default; }
.ai-key-setup {
display: flex;
flex-direction: column;
gap: 8px;
padding: 16px 0;
}
.key-label {
font-size: 13px;
color: #ccc;
}
.key-input {
padding: 6px 10px;
font-size: 12px;
background: rgba(255,255,255,0.05);
border: 1px solid rgba(255,255,255,0.1);
border-radius: 4px;
color: #ddd;
outline: none;
}
.key-input:focus { border-color: rgba(100,200,255,0.3); }
.key-hint {
font-size: 11px;
color: #666;
}
.slide-up-enter-active,
.slide-up-leave-active {
transition: max-height 0.25s ease, opacity 0.2s ease;
}
.slide-up-enter-from,
.slide-up-leave-to {
max-height: 0;
opacity: 0;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { SceneNode, Choice } from '@engine/types'
import { ref, watch } from 'vue'
import type { SceneNode } from '@engine/types'
import { useEditorStore } from '../stores/editorStore'
const props = defineProps<{
scene: SceneNode | null
@@ -8,214 +9,148 @@ const props = defineProps<{
}>()
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('')
const store = useEditorStore()
const jsonText = ref('')
const errorMsg = ref('')
const saved = ref(false)
const globalTooltip = 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
watch(() => [props.scene, store.gameData] as const, () => {
errorMsg.value = ''
saved.value = false
if (props.scene) {
jsonText.value = JSON.stringify(props.scene, null, 2)
} else if (Object.keys(store.gameData.scenes).length > 0) {
const { scenes, ...rest } = store.gameData
jsonText.value = JSON.stringify(rest, null, 2)
} else {
editingQTE.value = false
jsonText.value = ''
}
}, { immediate: true })
}, { immediate: true, deep: 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,
})
function onBlur() {
store.clearAIMarkers()
errorMsg.value = ''
try {
const parsed = JSON.parse(jsonText.value)
if (props.scene) {
store.updateScene(props.scene.id, parsed)
} else {
const { scenes } = store.gameData
store.gameData = { ...parsed, scenes }
store.startSceneId = parsed.startScene || store.startSceneId
store.autoSave()
}
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
} catch (e: any) {
errorMsg.value = e.message
}
}
</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 class="node-editor">
<Transition name="slide-right">
<div v-if="!store.nodeEditorCollapsed" class="ne-expanded">
<div class="editor-header">
<div class="header-left">
<h3>{{ scene ? scene.id : '全局配置' }}</h3>
<button v-if="scene" class="icon-btn danger" @click="emit('deleteScene', scene.id)" title="删除场景">🗑</button>
<span v-if="!scene && store.aiChanges?.globalFields?.length" class="global-diff-tag" :title="store.aiChanges.globalFields.join(', ')"> 已修改 {{ store.aiChanges.globalFields.length }} 字段</span>
</div>
<div class="header-actions">
<span v-if="saved" class="saved-hint">已保存</span>
<span v-if="errorMsg" class="error-hint">JSON 错误: {{ errorMsg }}</span>
<button class="icon-btn" @click="store.nodeEditorCollapsed = true" title="折叠"></button>
</div>
</div>
<textarea
class="json-area"
:class="{ error: errorMsg }"
:value="jsonText"
@input="jsonText = ($event.target as HTMLTextAreaElement).value"
@blur="onBlur"
spellcheck="false"
></textarea>
</div>
</Transition>
<div v-if="store.nodeEditorCollapsed" class="ne-collapsed-bar" @click="store.nodeEditorCollapsed = false">
<span class="ne-bar-arrow"></span>
<span class="ne-bar-label">{{ scene?.id || '全局' }}</span>
</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 {
position: absolute;
top: 0;
right: 0;
bottom: 0;
z-index: 100;
}
.ne-expanded {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 340px;
height: 100%;
background: #141428;
border-left: 1px solid rgba(255,255,255,0.08);
display: flex;
flex-direction: column;
overflow: hidden;
pointer-events: auto;
}
.empty-state {
.ne-collapsed-bar {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 36px;
display: flex;
flex-direction: column;
align-items: center;
padding: 10px 0;
gap: 6px;
background: #161633;
border-left: 1px solid rgba(100,200,255,0.1);
cursor: pointer;
user-select: none;
pointer-events: auto;
transition: background 0.15s;
}
.ne-collapsed-bar:hover {
background: #1a1a3a;
}
.ne-bar-arrow {
font-size: 11px;
color: #555;
flex-shrink: 0;
}
.ne-collapsed-bar:hover .ne-bar-arrow {
color: #8cf;
}
.ne-bar-label {
writing-mode: vertical-rl;
font-size: 12px;
color: #8cf;
font-weight: 500;
letter-spacing: 2px;
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: #555;
font-size: 14px;
}
.editor-header {
@@ -224,6 +159,7 @@ function saveQTE() {
justify-content: space-between;
padding: 14px 16px;
border-bottom: 1px solid rgba(255,255,255,0.06);
flex-shrink: 0;
}
.editor-header h3 {
@@ -232,9 +168,32 @@ function saveQTE() {
color: #ddd;
}
.header-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.header-actions {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.saved-hint {
font-size: 11px;
color: #4caf50;
}
.error-hint {
font-size: 11px;
color: #e74c3c;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon-btn {
@@ -246,140 +205,50 @@ function saveQTE() {
border-radius: 4px;
cursor: pointer;
font-size: 13px;
flex-shrink: 0;
}
.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 {
.json-area {
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;
padding: 14px 16px;
background: #0a0a1a;
color: #ccc;
border: none;
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-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 12px;
color: #777;
white-space: nowrap;
min-width: 80px;
line-height: 1.5;
resize: none;
tab-size: 2;
}
.qte-row input, .qte-row select {
flex: 1;
.json-area.error {
border-left: 3px solid #e74c3c;
}
.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;
.global-diff-tag {
display: inline-block;
margin-left: 10px;
padding: 1px 8px;
font-size: 11px;
color: #666;
font-weight: 400;
color: #ffe0b2;
background: rgba(230, 81, 0, 0.15);
border: 1px solid rgba(230, 81, 0, 0.3);
border-radius: 3px;
cursor: default;
}
.choice-extra {
display: flex;
align-items: center;
gap: 8px;
.slide-right-enter-active,
.slide-right-leave-active {
transition: max-width 0.25s ease, opacity 0.2s ease;
}
.inline-label {
font-size: 11px;
color: #666;
white-space: nowrap;
}
.time-input {
width: 60px;
.slide-right-enter-from,
.slide-right-leave-to {
max-width: 0;
opacity: 0;
}
</style>

View File

@@ -15,7 +15,7 @@ watch(() => props.videoUrl, async (url) => {
videoRef.value.src = url
playing.value = false
paused.value = false
})
}, { immediate: true })
function togglePlay() {
if (!videoRef.value) return
@@ -31,7 +31,6 @@ function togglePlay() {
<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">
@@ -52,19 +51,14 @@ function togglePlay() {
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;
align-items: center;
justify-content: center;
}
.preview-video {
padding: 12px;
width: 100%;
aspect-ratio: 16 / 9;
max-height: 100%;
display: flex;
flex-direction: column;
gap: 8px;
@@ -72,6 +66,8 @@ function togglePlay() {
.preview-video video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
border-radius: 4px;
}

View File

@@ -1,13 +1,14 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { VueFlow, useVueFlow, SmoothStepEdge } from '@vue-flow/core'
import { VueFlow, useVueFlow, SmoothStepEdge, Handle, Position } 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 { Connection } from '@vue-flow/core'
import { computePositions } from '../composables/useLayout'
import { computePositions, savePosition } from '../composables/useLayout'
import { useEditorStore } from '../stores/editorStore'
const props = defineProps<{
sceneNodes: { id: string; label: string }[]
@@ -19,21 +20,39 @@ const props = defineProps<{
const emit = defineEmits<{
selectNode: [id: string]
addEdge: [source: string, target: string]
testScene: [id: string]
clearSelection: []
}>()
const store = useEditorStore()
const nodes = ref<any[]>([])
const edges = ref<any[]>([])
const { onNodeClick, onConnect, fitView } = useVueFlow()
const { onNodeClick, onConnect, onNodeContextMenu, onNodeDragStop, onPaneClick, fitView } = useVueFlow()
const ctxMenuVisible = ref(false)
const ctxMenuX = ref(0)
const ctxMenuY = ref(0)
const ctxMenuNodeId = ref('')
function makeNodes() {
const positions = computePositions(props.sceneNodes, props.sceneEdges, props.startScene)
const changes = store.aiChanges
return props.sceneNodes.map((n) => {
const pos = positions.get(n.id) ?? { x: 0, y: 0 }
let badge = ''
if (changes) {
if (changes.added.includes(n.id)) badge = 'NEW'
else if (changes.modified.includes(n.id)) badge = 'MOD'
else if (changes.deleted.includes(n.id)) badge = 'DEL'
}
return {
id: n.id,
type: 'default',
position: pos,
data: { label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label },
data: {
label: n.id === props.startScene ? `\u25b6 ${n.label}` : n.label,
badge,
},
style: n.id === props.startScene
? { background: '#1b5e20', color: '#fff', borderColor: '#388e3c' }
: n.id === props.selectedNodeId
@@ -93,7 +112,7 @@ function inlineRebuild() {
}
watch(
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId] as const,
() => [props.sceneNodes, props.sceneEdges, props.startScene, props.selectedNodeId, store.aiChanges] as const,
() => {
const nc = props.sceneNodes.length
const ec = props.sceneEdges.length
@@ -108,6 +127,30 @@ watch(
onNodeClick((ev) => emit('selectNode', ev.node.id))
onNodeContextMenu((ev) => {
ev.event.preventDefault()
ctxMenuNodeId.value = ev.node.id
ctxMenuX.value = ev.event.clientX
ctxMenuY.value = ev.event.clientY
ctxMenuVisible.value = true
})
function testFromHere() {
emit('testScene', ctxMenuNodeId.value)
ctxMenuVisible.value = false
}
function closeMenu() {
ctxMenuVisible.value = false
}
onNodeDragStop((ev) => {
const pos = ev.node.position
savePosition(ev.node.id, Math.round(pos.x), Math.round(pos.y))
})
onPaneClick(() => emit('clearSelection'))
onConnect((conn: Connection) => {
if (conn.source && conn.target) emit('addEdge', conn.source, conn.target)
})
@@ -122,9 +165,27 @@ onConnect((conn: Connection) => {
:min-zoom="0.2"
:max-zoom="2"
>
<template #node-default="nodeProps">
<div class="custom-node">
<Handle type="target" :position="Position.Left" />
<div class="custom-node-label">{{ nodeProps.data.label }}</div>
<span v-if="nodeProps.data.badge" class="diff-badge" :class="`diff-badge-${nodeProps.data.badge}`">
{{ nodeProps.data.badge }}
</span>
<Handle type="source" :position="Position.Right" />
</div>
</template>
<Background :gap="20" />
<Controls />
</VueFlow>
<Teleport to="body">
<div v-if="ctxMenuVisible" class="ctx-menu-overlay" @click="closeMenu">
<div class="ctx-menu" :style="{ left: ctxMenuX + 'px', top: ctxMenuY + 'px' }">
<button class="ctx-item" @click="testFromHere">🎬 从此场景开始测试</button>
</div>
</div>
</Teleport>
</div>
</template>
@@ -134,4 +195,79 @@ onConnect((conn: Connection) => {
height: 100%;
background: #0d0d1a;
}
.ctx-menu-overlay {
position: fixed;
inset: 0;
z-index: 1000;
}
.ctx-menu {
position: absolute;
background: #1a1a2e;
border: 1px solid rgba(255,255,255,0.15);
border-radius: 6px;
padding: 4px;
min-width: 160px;
box-shadow: 0 4px 16px rgba(0,0,0,0.5);
}
.ctx-item {
display: block;
width: 100%;
padding: 8px 14px;
font-size: 13px;
color: #ccc;
background: none;
border: none;
border-radius: 3px;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.ctx-item:hover {
background: rgba(100,200,255,0.12);
color: #fff;
}
.custom-node {
position: relative;
padding: 8px 30px 8px 12px;
min-width: 120px;
font-size: 12px;
}
.custom-node-label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff-badge {
position: absolute;
top: 2px;
right: 4px;
padding: 1px 5px;
font-size: 9px;
font-weight: 600;
border-radius: 2px;
line-height: 1.4;
}
.diff-badge-NEW {
background: #2e7d32;
color: #c8e6c9;
}
.diff-badge-MOD {
background: #e65100;
color: #ffe0b2;
}
.diff-badge-DEL {
background: #c62828;
color: #ffcdd2;
text-decoration: line-through;
}
</style>

View File

@@ -0,0 +1,18 @@
export async function sendAIRequest(userMessage: string, mode: string, apiKey: string, sessionId?: string): Promise<{ result: string; sessionId: string }> {
const resp = await fetch('/api/ai', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userMessage, apiKey, mode, ...(sessionId ? { sessionId } : {}) }),
})
if (!resp.ok) {
const err = await resp.json().catch(() => ({ error: 'request failed' }))
throw new Error(err.error || 'request failed')
}
return resp.json()
}
export async function listSessions(): Promise<any[]> {
const resp = await fetch('/api/ai/sessions')
if (!resp.ok) return []
return resp.json()
}

View File

@@ -1,178 +1,42 @@
import { ref, computed, shallowRef, triggerRef } from 'vue'
import type { GameData, SceneNode, Choice } from '@engine/types'
import { computed } from 'vue'
import { useEditorStore } from '../stores/editorStore'
import { computeEdges, computeSceneNodes } from '../services/GraphService'
export function useGraphEditor() {
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(null)
const startSceneId = ref('')
const store = useEditorStore()
const sceneList = computed(() =>
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
)
const sceneNodes = computed(() => computeSceneNodes(store.gameData))
const sceneEdges = computed(() => computeEdges(store.gameData))
const sceneList = computed(() => computeSceneNodes(store.gameData))
const sceneNodes = computed(() =>
Object.values(gameData.value.scenes).map((s) => ({ id: s.id, label: s.id })),
)
const sceneEdges = computed(() => {
const result: { id: string; source: string; target: string; label?: string }[] = []
for (const [id, scene] of Object.entries(gameData.value.scenes)) {
if (scene.choices) {
let ci = 0
for (const c of scene.choices) {
if (c.targetScene) {
result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) })
}
ci++
}
}
if (scene.nextScene) {
result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '\u2192 \u9ed8\u8ba4' })
}
if (scene.qte) {
if (scene.qte.successScene)
result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE\u6210\u529f' })
if (scene.qte.failScene)
result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE\u5931\u8d25' })
}
}
return result
})
const selectedScene = computed(() => {
if (!selectedNodeId.value) return null
return gameData.value.scenes[selectedNodeId.value] ?? null
})
function trigger() {
triggerRef(gameData)
}
function loadJSON(json: GameData) {
gameData.value = JSON.parse(JSON.stringify(json))
trigger()
startSceneId.value = json.startScene || ''
selectedNodeId.value = null
}
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 = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[id]: {
id,
videoUrl: '',
choices: [],
nextScene: '',
subtitleUrl: '',
onEnter: [],
},
},
}
trigger()
return id
}
function deleteScene(id: string) {
if (startSceneId.value === id) return
const nextScenes = { ...gameData.value.scenes }
delete nextScenes[id]
for (const key of Object.keys(nextScenes)) {
const s = nextScenes[key]
if (s.choices)
nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
if (s.nextScene === id) nextScenes[key] = { ...nextScenes[key], nextScene: '' }
if (s.qte) {
const qte = { ...s.qte }
let changed = false
if (qte.successScene === id) { qte.successScene = ''; changed = true }
if (qte.failScene === id) { qte.failScene = ''; changed = true }
if (changed) nextScenes[key] = { ...nextScenes[key], qte }
}
}
gameData.value = { ...gameData.value, scenes: nextScenes }
trigger()
if (selectedNodeId.value === id) selectedNodeId.value = null
}
function updateScene(id: string, partial: Partial<SceneNode>) {
const scene = gameData.value.scenes[id]
function onAddEdge(source: string, target: string) {
const scene = store.gameData.scenes[source]
if (!scene) return
gameData.value = {
...gameData.value,
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
const newChoices = [...(scene.choices || []), { text: `${source}${target}`, targetScene: target }]
store.gameData = {
...store.gameData,
scenes: { ...store.gameData.scenes, [source]: { ...scene, choices: newChoices } },
}
trigger()
}
function addChoice(sourceId: string) {
const scene = gameData.value.scenes[sourceId]
if (!scene) return
gameData.value = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[sourceId]: {
...scene,
choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }],
},
},
}
trigger()
}
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
gameData.value = {
...gameData.value,
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
}
trigger()
}
function deleteChoice(sourceId: string, index: number) {
const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return
gameData.value = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
},
}
trigger()
store.markDirty()
store.autoSave()
}
return {
gameData,
selectedNodeId,
selectedScene,
sceneList,
gameData: store.gameData,
selectedNodeId: store.selectedNodeId,
selectedScene: store.selectedScene,
startSceneId: store.startSceneId,
sceneNodes,
sceneEdges,
startSceneId,
loadJSON,
exportJSON,
addScene,
deleteScene,
updateScene,
addChoice,
updateChoice,
deleteChoice,
generateId,
sceneList,
loadJSON: store.loadJSON,
exportJSON: store.exportJSON,
updateScene: store.updateScene,
addChoice: store.addChoice,
updateChoice: store.updateChoice,
deleteChoice: store.deleteChoice,
addScene: store.addScene,
deleteScene: store.deleteScene,
onAddEdge,
}
}

View File

@@ -12,27 +12,54 @@ interface EdgeInfo {
const NODE_W = 180
const NODE_H = 60
const POSITIONS_KEY = 'editor_positions'
export function loadSavedPositions(): Record<string, { x: number; y: number }> {
try {
return JSON.parse(localStorage.getItem(POSITIONS_KEY) || '{}')
} catch { return {} }
}
export function savePosition(nodeId: string, x: number, y: number) {
const saved = loadSavedPositions()
saved[nodeId] = { x, y }
localStorage.setItem(POSITIONS_KEY, JSON.stringify(saved))
}
export function computePositions(
nodes: NodeInfo[],
edges: EdgeInfo[],
_startScene: string,
): Map<string, { x: number; y: number }> {
const saved = loadSavedPositions()
const result = new Map<string, { x: number; y: number }>()
const unsaved: NodeInfo[] = []
for (const n of nodes) {
if (saved[n.id]) {
result.set(n.id, saved[n.id])
} else {
unsaved.push(n)
}
}
if (unsaved.length === 0) return result
const g = new dagre.graphlib.Graph()
g.setGraph({
rankdir: 'LR',
nodesep: 50,
ranksep: 240,
nodesep: 100,
ranksep: 200,
marginx: 60,
marginy: 60,
})
g.setDefaultEdgeLabel(() => ({}))
for (const n of nodes) {
for (const n of unsaved) {
g.setNode(n.id, { width: NODE_W, height: NODE_H })
}
const nodeIds = new Set(nodes.map((n) => n.id))
const nodeIds = new Set(unsaved.map((n) => n.id))
for (const e of edges) {
if (nodeIds.has(e.source) && nodeIds.has(e.target)) {
g.setEdge(e.source, e.target)
@@ -41,13 +68,12 @@ export function computePositions(
dagre.layout(g)
const positions = new Map<string, { x: number; y: number }>()
for (const n of nodes) {
for (const n of unsaved) {
const node = g.node(n.id)
if (node) {
positions.set(n.id, { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 })
result.set(n.id, { x: node.x - NODE_W / 2, y: node.y - NODE_H / 2 })
}
}
return positions
return result
}

57
editor/db/editorDB.ts Normal file
View File

@@ -0,0 +1,57 @@
import Dexie, { type Table } from 'dexie'
import type { GameData } from '@engine/types'
export interface VersionRecord {
id?: number
sourcePath: string
timestamp: number
label: string
gameData: GameData
}
class EditorDB extends Dexie {
versions!: Table<VersionRecord, number>
constructor() {
super('EditorVersions')
this.version(1).stores({
versions: '++id, sourcePath, timestamp',
})
}
}
const db = new EditorDB()
export async function putVersion(record: VersionRecord): Promise<void> {
try {
await db.versions.add(record)
const all = await db.versions
.where('sourcePath')
.equals(record.sourcePath)
.reverse()
.sortBy('timestamp')
if (all.length > 20) {
const toDelete = all.slice(20)
await db.versions.bulkDelete(toDelete.map((v) => v.id!))
}
} catch {}
}
export async function getVersions(sourcePath: string): Promise<VersionRecord[]> {
try {
return await db.versions
.where('sourcePath')
.equals(sourcePath)
.reverse()
.sortBy('timestamp')
} catch {
return []
}
}
export async function clearVersions(sourcePath: string): Promise<void> {
try {
const records = await db.versions.where('sourcePath').equals(sourcePath).toArray()
await db.versions.bulkDelete(records.map((v) => v.id!))
} catch {}
}

View File

@@ -4,14 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>剧情编辑器 — 交互式电影游戏</title>
<style>
html, body { width: 100vw; height: 100vh; margin: 0; overflow: hidden; background: #0a0a16; }
#editor-app {
width: 1920px; height: 1080px;
transform-origin: top left;
transform: scale(var(--scale));
}
</style>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<div id="editor-app"></div>

View File

@@ -2,13 +2,6 @@ import { createApp } from 'vue'
import { createPinia } from 'pinia'
import EditorApp from './App.vue'
function applyScale() {
const s = Math.min(window.innerWidth / 1920, window.innerHeight / 1080)
document.documentElement.style.setProperty('--scale', String(s))
}
applyScale()
window.addEventListener('resize', applyScale)
const app = createApp(EditorApp)
app.use(createPinia())
app.mount('#editor-app')

View File

@@ -0,0 +1,46 @@
import type { GameData } from '@engine/types'
export function computeEdges(gameData: GameData): { id: string; source: string; target: string; label?: string }[] {
const result: { id: string; source: string; target: string; label?: string }[] = []
for (const [id, scene] of Object.entries(gameData.scenes)) {
if (scene.choices) {
let ci = 0
for (const c of scene.choices) {
if (c.targetScene) {
result.push({ id: `${id}_choice_${ci}`, source: id, target: c.targetScene, label: c.text.slice(0, 10) })
}
ci++
}
}
if (scene.nextScene) {
if (Array.isArray(scene.nextScene)) {
for (let ri = 0; ri < scene.nextScene.length; ri++) {
const r = scene.nextScene[ri]
if (r.targetScene) {
result.push({ id: `${id}_next_${ri}`, source: id, target: r.targetScene, label: r.conditions?.length ? '→ 条件' : '→ 默认' })
}
}
} else {
result.push({ id: `${id}_next`, source: id, target: scene.nextScene, label: '→' })
}
}
if (scene.qte) {
if (scene.qte.successScene)
result.push({ id: `${id}_qte_s`, source: id, target: scene.qte.successScene, label: 'QTE成功' })
if (scene.qte.failScene)
result.push({ id: `${id}_qte_f`, source: id, target: scene.qte.failScene, label: 'QTE失败' })
}
if (scene.hotspots) {
for (const h of scene.hotspots) {
if (h.targetScene) {
result.push({ id: `${id}_hs_${h.id}`, source: id, target: h.targetScene, label: h.label.slice(0, 10) })
}
}
}
}
return result
}
export function computeSceneNodes(gameData: GameData): { id: string; label: string }[] {
return Object.values(gameData.scenes).map(s => ({ id: s.id, label: s.id }))
}

View File

@@ -0,0 +1,266 @@
import { defineStore } from 'pinia'
import { shallowRef, ref, computed, triggerRef } from 'vue'
import type { GameData, SceneNode, Choice } from '@engine/types'
import { putVersion, getVersions } from '../db/editorDB'
export interface AIDiff {
added: string[]
modified: string[]
deleted: string[]
globalFields: string[]
}
interface EditorVersion {
id?: number
sourcePath: string
timestamp: number
label: string
gameData: GameData
}
export const useEditorStore = defineStore('editor', () => {
const gameData = shallowRef<GameData>({ scenes: {}, startScene: '', variables: {} })
const selectedNodeId = ref<string | null>(null)
const startSceneId = ref('')
const dirty = ref(false)
const sourcePath = ref('/scenes/demo.json')
const deepseekKey = ref(localStorage.getItem('deepseek_key') || '')
const showAIPanel = ref(true)
const aiCollapsed = ref(true)
const nodeEditorCollapsed = ref(true)
const aiSessionId = ref('')
const aiChanges = ref<AIDiff | null>(null)
const versions = ref<EditorVersion[]>([])
const selectedScene = computed(() => {
if (!selectedNodeId.value) return null
return gameData.value.scenes[selectedNodeId.value] ?? null
})
function markDirty() { dirty.value = true }
function loadJSON(json: GameData) {
gameData.value = JSON.parse(JSON.stringify(json))
triggerRef(gameData)
startSceneId.value = json.startScene || ''
selectedNodeId.value = null
dirty.value = false
}
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 = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[id]: { id, videoUrl: '', choices: [], nextScene: '', subtitleUrl: '', onEnter: [] },
},
}
triggerRef(gameData)
dirty.value = true
autoSave()
return id
}
function deleteScene(id: string) {
if (startSceneId.value === id) return
const nextScenes = { ...gameData.value.scenes }
delete nextScenes[id]
for (const key of Object.keys(nextScenes)) {
const s = nextScenes[key]
if (s.choices) nextScenes[key] = { ...s, choices: s.choices.filter((c) => c.targetScene !== id) }
if (Array.isArray(s.nextScene)) {
nextScenes[key] = { ...s, nextScene: s.nextScene.filter((r) => r.targetScene !== id) }
} else if (s.nextScene === id) {
nextScenes[key] = { ...nextScenes[key], nextScene: '' }
}
if (s.qte) {
const qte = { ...s.qte }
let changed = false
if (qte.successScene === id) { qte.successScene = ''; changed = true }
if (qte.failScene === id) { qte.failScene = ''; changed = true }
if (changed) nextScenes[key] = { ...nextScenes[key], qte }
}
}
gameData.value = { ...gameData.value, scenes: nextScenes }
triggerRef(gameData)
dirty.value = true
if (selectedNodeId.value === id) selectedNodeId.value = null
autoSave()
}
function updateScene(id: string, partial: Partial<SceneNode>) {
const scene = gameData.value.scenes[id]
if (!scene) return
gameData.value = {
...gameData.value,
scenes: { ...gameData.value.scenes, [id]: { ...scene, ...partial } },
}
triggerRef(gameData)
dirty.value = true
autoSave()
}
function addChoice(sourceId: string) {
const scene = gameData.value.scenes[sourceId]
if (!scene) return
gameData.value = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[sourceId]: { ...scene, choices: [...(scene.choices || []), { text: '\u65b0\u9009\u9879', targetScene: '' }] },
},
}
triggerRef(gameData)
dirty.value = true
autoSave()
}
function updateChoice(sourceId: string, index: number, partial: Partial<Choice>) {
const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return
const newChoices = scene.choices.map((c, i) => (i === index ? { ...c, ...partial } : c))
gameData.value = {
...gameData.value,
scenes: { ...gameData.value.scenes, [sourceId]: { ...scene, choices: newChoices } },
}
triggerRef(gameData)
dirty.value = true
autoSave()
}
function deleteChoice(sourceId: string, index: number) {
const scene = gameData.value.scenes[sourceId]
if (!scene?.choices) return
gameData.value = {
...gameData.value,
scenes: {
...gameData.value.scenes,
[sourceId]: { ...scene, choices: scene.choices.filter((_, i) => i !== index) },
},
}
triggerRef(gameData)
dirty.value = true
}
function setSourcePath(p: string) { sourcePath.value = p; localStorage.setItem('editor_last_source', p) }
function setDeepseekKey(k: string) { deepseekKey.value = k; localStorage.setItem('deepseek_key', k) }
function setAISessionId(id: string) { aiSessionId.value = id; localStorage.setItem('editor_ai_session', id) }
function clearAISession() { aiSessionId.value = ''; localStorage.removeItem('editor_ai_session') }
let saveVersionTimer: ReturnType<typeof setTimeout> | null = null
let lastSavedContent = ''
function debouncedSaveVersion() {
const current = JSON.stringify(gameData.value)
if (current === lastSavedContent) {
console.log('[版本] 内容相同,跳过')
return
}
if (saveVersionTimer) { console.log('[版本] 重置 3s 计时器'); clearTimeout(saveVersionTimer) }
else console.log('[版本] 启动 3s 计时器')
saveVersionTimer = setTimeout(async () => {
console.log('[版本] 保存到 IndexedDB')
lastSavedContent = current
await saveVersion('手动编辑')
}, 3000)
}
async function autoSave() {
try {
await fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: sourcePath.value, data: gameData.value }),
})
} catch { /* dev server not running */ }
debouncedSaveVersion()
}
async function saveVersion(label: string) {
try {
await putVersion({
sourcePath: sourcePath.value,
timestamp: Date.now(),
label,
gameData: JSON.parse(JSON.stringify(gameData.value)),
})
await loadVersions()
} catch {}
}
async function restoreVersion(idx: number) {
const v = versions.value[idx]
if (!v) return
gameData.value = v.gameData
startSceneId.value = v.gameData.startScene || ''
selectedNodeId.value = null
aiChanges.value = null
dirty.value = false
lastSavedContent = JSON.stringify(v.gameData)
if (saveVersionTimer) { clearTimeout(saveVersionTimer); saveVersionTimer = null }
autoSave()
}
async function loadVersions() {
try {
versions.value = await getVersions(sourcePath.value)
} catch {
versions.value = []
}
}
function clearAIMarkers() {
aiChanges.value = null
}
async function reloadFromDisk(oldGameData?: GameData) {
try {
const resp = await fetch(sourcePath.value)
const newData = await resp.json()
if (oldGameData) {
const { scenes: newScenes, ...newGlobal } = newData
const { scenes: oldScenes, ...oldGlobal } = oldGameData
const diff: AIDiff = { added: [], modified: [], deleted: [], globalFields: [] }
for (const id of Object.keys(newScenes)) {
if (!oldScenes[id]) diff.added.push(id)
else if (JSON.stringify(oldScenes[id]) !== JSON.stringify(newScenes[id])) diff.modified.push(id)
}
for (const id of Object.keys(oldScenes)) {
if (!newScenes[id]) diff.deleted.push(id)
}
for (const key of Object.keys({ ...(oldGlobal as any), ...(newGlobal as any) })) {
if (JSON.stringify((oldGlobal as any)[key]) !== JSON.stringify((newGlobal as any)[key])) {
diff.globalFields.push(key)
}
}
aiChanges.value = diff
}
gameData.value = newData
selectedNodeId.value = null
clearAISession()
} catch { /* failed to reload */ }
}
return {
gameData, selectedNodeId, selectedScene, startSceneId, dirty, sourcePath,
deepseekKey, showAIPanel, aiSessionId, aiCollapsed, nodeEditorCollapsed, aiChanges, versions,
markDirty, loadJSON, exportJSON, addScene, deleteScene,
updateScene, addChoice, updateChoice, deleteChoice, generateId,
setSourcePath, setDeepseekKey, setAISessionId, clearAISession, autoSave, reloadFromDisk,
saveVersion, restoreVersion, loadVersions, clearAIMarkers,
}
})

View File

@@ -27,7 +27,8 @@ app.whenReady().then(async () => {
autoHideMenuBar: false,
webPreferences: {
nodeIntegration: false,
contextIsolation: true
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '..', 'public', 'icon.png') // 应用图标
})

3
electron/preload.js Normal file
View File

@@ -0,0 +1,3 @@
const { contextBridge } = require('electron')
contextBridge.exposeInMainWorld('__ELECTRON__', true)

View File

@@ -51,9 +51,9 @@ export class Engine {
this.audioSystem = new AudioSystem()
this.achievementSystem = new AchievementSystem()
this.stateManager.onAfterApply = (vars) => {
this.stateManager.onAfterApply.add((vars) => {
this.achievementSystem.check(vars)
}
})
this.videoManager.onTimeUpdate(this.onTimeUpdate)
}
@@ -78,7 +78,7 @@ export class Engine {
this.goToScene(startScene)
}
private goToScene(scene: SceneNode) {
public goToScene(scene: SceneNode) {
this.currentScene = scene
const chapter = this.sceneManager.getChapterBySceneId(scene.id)
@@ -89,6 +89,10 @@ export class Engine {
this.onMarkVisited?.(scene.id)
if (scene.onEnter) {
this.stateManager.apply(scene.onEnter)
}
this.qteTriggered = false
this.qteResolved = false
this.loopActive = false
@@ -107,10 +111,14 @@ export class Engine {
this.emit('sceneChange', scene)
this.checkHotspotTime(scene, 0)
const preloadUrls = this.sceneManager.getCandidateUrls(
const candidateIds = this.sceneManager.getCandidateSceneIds(
scene,
(conds) => conds ? this.stateManager.evaluate(conds) : true
)
const preloadUrls = candidateIds
.map(id => this.sceneManager.getScene(id))
.filter((s) => !!s && s.type !== 'image')
.map(s => this.videoManager.resolveVideoUrl(s!, this.videoManager.streamingQuality))
this.videoManager.onEnd(() => {
if (!this.qteTriggered || this.qteResolved) {
@@ -132,9 +140,9 @@ export class Engine {
if (this.isInitialScene) {
this.isInitialScene = false
this.videoManager.playInitial(scene.videoUrl, preloadUrls)
this.videoManager.playInitial(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
} else {
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
this.videoManager.switchTo(this.videoManager.resolveVideoUrl(scene, this.videoManager.streamingQuality), preloadUrls)
}
}
@@ -274,6 +282,11 @@ export class Engine {
if (this.loopActive) return
if (scene.battleResult) {
this.emit('battleResultRequest', scene.battleResult)
return
}
const validChoices = this.getValidChoices(scene)
if (validChoices.length > 0) {
@@ -292,11 +305,26 @@ export class Engine {
}
)
} else if (scene.nextScene) {
const next = this.sceneManager.getScene(scene.nextScene)
if (next) {
this.goToScene(next)
} else {
if (Array.isArray(scene.nextScene)) {
for (const route of scene.nextScene) {
if (!route.conditions || this.stateManager.evaluate(route.conditions)) {
const next = this.sceneManager.getScene(route.targetScene)
if (next) {
this.goToScene(next)
} else {
this.endGame()
}
return
}
}
this.endGame()
} else {
const next = this.sceneManager.getScene(scene.nextScene)
if (next) {
this.goToScene(next)
} else {
this.endGame()
}
}
} else if (scene.hotspots?.length) {
return
@@ -365,9 +393,9 @@ export class Engine {
private initChapterState(chapter: { defaultVariables?: Record<string, number> }) {
if (chapter.defaultVariables) {
this.stateManager.variables = { ...chapter.defaultVariables }
} else {
this.stateManager.init({})
for (const [key, val] of Object.entries(chapter.defaultVariables)) {
this.stateManager.variables[key] = val
}
}
this.stateManager.flags = new Set()
this.stateManager.history = []

View File

@@ -50,16 +50,19 @@ export class SceneManager {
}
}
if (scene.nextScene && !targets.includes(scene.nextScene)) {
targets.push(scene.nextScene)
if (scene.nextScene) {
const nextIds = Array.isArray(scene.nextScene)
? scene.nextScene.map(r => r.targetScene)
: [scene.nextScene]
for (const id of nextIds) {
if (!targets.includes(id)) targets.push(id)
}
}
return targets
}
getCandidateUrls(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] {
getCandidateSceneIds(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] {
return this.getCandidateTargetIds(scene, evaluateCondition)
.map(id => this.scenes[id]?.videoUrl)
.filter((url): url is string => !!url)
}
}

View File

@@ -4,7 +4,7 @@ export class StateManager {
variables: Record<string, number> = {}
flags: Set<string> = new Set()
history: ChoiceRecord[] = []
onAfterApply: ((variables: Record<string, number>) => void) | null = null
onAfterApply: Set<((variables: Record<string, number>) => void)> = new Set()
init(initialVars: Record<string, number>) {
this.variables = { ...initialVars }
@@ -73,7 +73,7 @@ export class StateManager {
break
}
}
this.onAfterApply?.(this.variables)
this.onAfterApply.forEach((cb) => cb(this.variables))
}
recordChoice(choice: ChoiceRecord) {

View File

@@ -1,6 +1,15 @@
type VideoEndCallback = () => void
type TimeUpdateCallback = (time: number) => void
export function getVideoMode(): 'auto' | 'local' | 'streaming' {
const params = typeof URLSearchParams !== 'undefined' ? new URLSearchParams(location.search) : { get: () => null }
const override = params.get('videoMode')
if (override === 'local') return 'local'
if (override === 'streaming') return 'streaming'
if (typeof window !== 'undefined' && (window as any).__ELECTRON__) return 'local'
return 'auto'
}
export class VideoManager {
private elA: HTMLVideoElement | null = null
private elB: HTMLVideoElement | null = null
@@ -12,6 +21,7 @@ export class VideoManager {
private preloaded: Map<'A' | 'B', string> = new Map()
private switching = false
private sceneVideo: HTMLVideoElement | null = null
streamingQuality = ''
private get active(): HTMLVideoElement {
return this.activeSlot === 'A' ? this.elA! : this.elB!
@@ -169,6 +179,32 @@ export class VideoManager {
if (this.elB) this.elB.muted = muted
}
resolveVideoUrl(scene: { videoUrl: string; streamingUrl?: Record<string, string> }, quality?: string): string {
const mode = getVideoMode()
if (mode === 'local') return scene.videoUrl
if (scene.streamingUrl) {
const key = quality || Object.keys(scene.streamingUrl)[0]
return scene.streamingUrl[key] || scene.videoUrl
}
return scene.videoUrl
}
switchQuality(src: string, seekTime: number) {
const active = this.active
this.currentSrc = src
active.src = src
active.load()
this.preloaded.set(this.keyOf(active), src)
this.waitReady(active).then(() => {
active.currentTime = seekTime
active.play().catch(() => {})
})
}
private keyOf(el: HTMLVideoElement): 'A' | 'B' {
return el === this.elA ? 'A' : 'B'
}
onEnd(cb: VideoEndCallback) {
this.onEndCallback = cb
}

View File

@@ -10,7 +10,7 @@ export interface SceneNode {
choices?: Choice[]
hotspots?: Hotspot[]
qte?: QTEDefinition
nextScene?: string
nextScene?: string | Choice[]
onEnter?: Effect[]
loopStart?: number
loopEnd?: number
@@ -21,6 +21,10 @@ export interface SceneNode {
bgmDuckFade?: number
videoMuted?: boolean
skippable?: boolean
streamingUrl?: Record<string, string>
keyMoment?: boolean
battleHUD?: BattleHUDEntry[]
battleResult?: BattleResultDef
}
export interface Choice {
@@ -52,12 +56,12 @@ export interface Hotspot {
export interface Condition {
variable: string
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag'
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag' // @deprecated hasFlag will be removed
value: number | string | boolean
}
export interface Effect {
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent'
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent' // @deprecated toggleFlag will be removed
target: string
value?: number | string | boolean
}
@@ -153,6 +157,7 @@ export type EngineEvent =
| 'hotspotUpdate'
| 'chapterUnlock'
| 'achievementUnlock'
| 'battleResultRequest'
export interface PlayerTreeNode {
sceneId: string
@@ -164,3 +169,31 @@ export interface PlayerTreeNode {
isGateway?: boolean
gatewayChapterId?: string
}
export interface BattleHUDStat {
variable: string
label: string
labelKey?: string
max?: number
style?: 'bar' | 'number'
}
export interface BattleHUDEntry {
label: string
labelKey?: string
portrait?: string
stats: BattleHUDStat[]
}
export interface BattleResultStat {
label: string
labelKey?: string
variable: string
max?: number
}
export interface BattleResultDef {
title: string
titleKey?: string
stats: BattleResultStat[]
}

6
engine/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
export function resolveAsset(base: string, path: string): string {
if (!path || path.startsWith('http://') || path.startsWith('https://') || path.startsWith('data:')) return path
const b = base.endsWith('/') ? base.slice(0, -1) : base
const p = path.startsWith('/') ? path : '/' + path
return b + p
}

View File

@@ -4,14 +4,6 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>交互式电影游戏</title>
<style>
html, body { width: 100vw; height: 100vh; margin: 0; overflow: hidden; background: #000; }
#app {
width: 1920px; height: 1080px;
transform-origin: top left;
transform: scale(var(--scale));
}
</style>
</head>
<body>
<div id="app"></div>

190
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"adm-zip": "^0.5.17",
"opencode-ai": "^1.17.6",
"typescript": "~5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"
@@ -1304,6 +1305,195 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/opencode-ai": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-ai/-/opencode-ai-1.17.7.tgz",
"integrity": "sha512-5oMjuqlVL78JhvXshwp2NCXCI+CHr24wWi7/5aI0CZoHnI44qTqssWOBUW59dPTpRGSOQmXTDTOOsPVYW38JPg==",
"cpu": [
"arm64",
"x64"
],
"dev": true,
"hasInstallScript": true,
"os": [
"darwin",
"linux",
"win32"
],
"bin": {
"opencode": "bin/opencode.exe"
},
"optionalDependencies": {
"opencode-darwin-arm64": "1.17.7",
"opencode-darwin-x64": "1.17.7",
"opencode-darwin-x64-baseline": "1.17.7",
"opencode-linux-arm64": "1.17.7",
"opencode-linux-arm64-musl": "1.17.7",
"opencode-linux-x64": "1.17.7",
"opencode-linux-x64-baseline": "1.17.7",
"opencode-linux-x64-baseline-musl": "1.17.7",
"opencode-linux-x64-musl": "1.17.7",
"opencode-windows-arm64": "1.17.7",
"opencode-windows-x64": "1.17.7",
"opencode-windows-x64-baseline": "1.17.7"
}
},
"node_modules/opencode-darwin-arm64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-darwin-arm64/-/opencode-darwin-arm64-1.17.7.tgz",
"integrity": "sha512-/uoZpJvnxY1jtRXAASQTIn0goya61M1RJhX0Zx2RwO+sdnrfvYYX6p7iL82Rl+Sp+TAS8y2NBvN+p/OLAnxsqg==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-darwin-x64/-/opencode-darwin-x64-1.17.7.tgz",
"integrity": "sha512-v60XhJae1eKn/Kjhy2PLOY+ss7peSox8ILZFz7fwBzRgz4q61gIo1vM9WzXQ6Vt+5Oj8etYbPl3xmt1XDLZtEA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-darwin-x64-baseline": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.17.7.tgz",
"integrity": "sha512-nvaY4qQgS9ZSkCvw9+DOrQQeycbW8/AEcD/Q0suleMuUgFIqfrsVa4cdsmYrUh3BH+y2NRaVgMSIfU9gPqXAKA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"darwin"
]
},
"node_modules/opencode-linux-arm64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-arm64/-/opencode-linux-arm64-1.17.7.tgz",
"integrity": "sha512-HTH5Z5V7xiAD+/nYn9wQYwM/LDokBZu8Ig+npwJrhGWzZGM+lChbv2+griYyRoaNEZ+zRsCvwyv96TTfb6nWDQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-arm64-musl": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.17.7.tgz",
"integrity": "sha512-bIySXi+XNLHL5m8lS1ljL5XZzQ0iMdf/X6KKaqHbDZQ3E0Qu7ERIHZjohx8S7htvCdPztuzeSr2udS0emumf6g==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-x64/-/opencode-linux-x64-1.17.7.tgz",
"integrity": "sha512-UIxkdA/8281EHbHYVr5PSD+eVoMdlyfkmXiZp3u9duttsMHdf1F6lw0XjYmDRBCPp8zQM1D1RLCABuRA/kUX+Q==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.17.7.tgz",
"integrity": "sha512-fj62eWDQSygxS3Q5S3Gm4VYOsHrlTnje46bXoWc9IXfFNwFHAyL+izKzpU1SePCpCCEdsjy//nCKOnqN4PPK5g==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-baseline-musl": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.17.7.tgz",
"integrity": "sha512-kTuZpRxMOzKt+ztp6yb+cSN8L69UYWN1x7Na4egX2d26IU0xK+RlXE9HjHzF/EjsmSBMc3DR8MvKUVldQ2XdbA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-linux-x64-musl": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.17.7.tgz",
"integrity": "sha512-iKUBKzVD1ybMmAy3KW6cjfst18+glp3Fgtd7POGEAaQO7cWzwSmOnFHN3uCAi/wYrfrvYd4zXxHsFP0yMJRUmA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"linux"
]
},
"node_modules/opencode-windows-arm64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-windows-arm64/-/opencode-windows-arm64-1.17.7.tgz",
"integrity": "sha512-BVlfloqHrjPhpDvbm3u1vQuEn063lbT3lcT7HBLmHpvwJd6FJutjPpm5/3xYxSusmXRGL7bmMZ3v1KPOeY0tBQ==",
"cpu": [
"arm64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-windows-x64/-/opencode-windows-x64-1.17.7.tgz",
"integrity": "sha512-MAykQj6ouoZ2rMt+q8ujBTnc4sD86WfLnPom28CTD+KgmI1s2D2qka7J6DjpK6A3j8PpkC0bEBIqVBciVgY6EA==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/opencode-windows-x64-baseline": {
"version": "1.17.7",
"resolved": "https://registry.npmmirror.com/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.17.7.tgz",
"integrity": "sha512-Gui/cezrLsLEZb1rUwNoKGXIiuZA0FsaGN3L/qR3/Qpce3e+hhqfqLHQXqlh0PFU1dSRKVRF8ukwWVx+2G5CPQ==",
"cpu": [
"x64"
],
"dev": true,
"optional": true,
"os": [
"win32"
]
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",

View File

@@ -24,6 +24,7 @@
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"adm-zip": "^0.5.17",
"opencode-ai": "^1.17.6",
"typescript": "~5.6.0",
"vite": "^5.4.0",
"vue-tsc": "^2.1.0"

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:8.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:8.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:8
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:8.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -90,5 +90,10 @@
"title": "Completed",
"desc": "Complete the game once"
}
}
},
"battle": {
"hud": { "player": "You" },
"result": { "victory": "Victory!" }
},
"stat": { "courage": "Courage", "qte_succeeded": "QTE Wins", "trust": "Trust", "investigation": "Investigation" }
}

View File

@@ -90,5 +90,10 @@
"title": "通关达成",
"desc": "完成一次游戏"
}
}
},
"battle": {
"hud": { "player": "你" },
"result": { "victory": "击退成功!" }
},
"stat": { "courage": "勇气", "qte_succeeded": "QTE 成功", "trust": "信任值", "investigation": "调查进度" }
}

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

View File

@@ -0,0 +1,7 @@
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:3
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:3.000000,
seg_000.ts
#EXT-X-ENDLIST

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More