Files
tianshu-engine/ROADMAP.md

1168 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 交互式电影游戏引擎 — Roadmap
## 技术栈
- **框架**: Vue 3 (Composition API + `<script setup>`)
- **构建**: Vite
- **状态管理**: Pinia
- **可视化编辑器**: Vue Flow
- **存储**: IndexedDB (Dexie.js)
- **语言**: TypeScript
- **视频**: 原生 `<video>` + A/B 双缓冲
## 项目结构
```
moviegame/
├── engine/ # 框架无关的核心引擎(纯 TS
│ ├── core/
│ │ ├── Engine.ts # 主循环,驱动各子系统
│ │ ├── SceneManager.ts # 剧情节点图遍历
│ │ ├── VideoManager.ts # A/B 双缓冲视频播放
│ │ └── StateManager.ts # 全局状态、条件求值
│ ├── systems/
│ │ ├── ChoiceSystem.ts # 选择 UI + 倒计时
│ │ ├── QTESystem.ts # QTE 触发、判定、超时
│ │ └── SaveSystem.ts # IndexedDB 存取
│ └── types.ts # 场景数据类型定义
├── src/ # Vue 应用(播放器端)
│ ├── components/
│ │ ├── GamePlayer.vue # 主播放器(挂载 video 元素)
│ │ ├── ChoicePanel.vue # 选项面板
│ │ ├── QTEOverlay.vue # QTE 遮罩
│ │ ├── SaveLoadMenu.vue # 存档界面
│ │ └── Subtitles.vue # 字幕显示
│ ├── composables/
│ │ ├── useGameEngine.ts # 引擎实例 + 生命周期
│ │ ├── useVideoPlayer.ts # 视频状态响应式封装
│ │ └── useGameState.ts # 状态响应式封装
│ ├── stores/
│ │ └── gameStore.ts # 游戏全局状态Pinia
│ ├── App.vue
│ └── main.ts
├── editor/ # Vue Flow 可视化编辑器(独立入口)
│ ├── components/
│ │ ├── SceneGraph.vue
│ │ ├── NodeEditor.vue
│ │ └── PreviewPanel.vue
│ ├── composables/
│ │ └── useGraphEditor.ts
│ └── App.vue
├── public/
│ ├── videos/ # 视频资源
│ └── scenes/
│ └── demo.json # 示例剧情数据
├── vite.config.ts
├── tsconfig.json
├── package.json
└── index.html
```
## 场景数据格式
```typescript
// engine/types.ts
interface SceneNode {
id: string;
videoUrl: string;
subtitleUrl?: string;
choices?: Choice[];
qte?: QTEDefinition;
nextScene?: string;
onEnter?: Effect[];
}
interface Choice {
text: string;
targetScene: string;
conditions?: Condition[];
effects?: Effect[];
timeLimit?: number; // 限时选择0=不限时
}
interface Condition {
variable: string;
op: '>' | '<' | '>=' | '<=' | '==' | '!=' | 'hasFlag';
value: number | string | boolean;
}
interface Effect {
type: 'set' | 'add' | 'toggleFlag' | 'triggerEvent';
target: string;
value?: number | string | boolean;
}
interface QTEDefinition {
triggerTime: number;
prompt: string;
keys: string[];
timeLimit: number;
successScene: string;
failScene: string;
effects?: {
success: Effect[];
fail: Effect[];
};
}
interface GameData {
scenes: Record<string, SceneNode>;
startScene: string;
variables: Record<string, number>;
}
interface SaveData {
slot: number;
timestamp: number;
currentScene: string;
variables: Record<string, number>;
flags: string[];
history: ChoiceRecord[];
thumbnail?: string;
}
```
## 实现路线
### P0 MVP — 最小可玩原型3-5 天)✅ 已完成 2026-06-07
目标:能播放一段视频 → 弹出选项 → 跳到下一段视频
- [x] 项目脚手架Vite + Vue3 + TypeScript + Pinia
- [x] `engine/core/Engine.ts` — 主循环骨架(加载场景 → 播放 → 等选择 → 切换)
- [x] `engine/core/SceneManager.ts` — 加载 JSON按 ID 查找场景节点
- [x] `engine/core/VideoManager.ts` — 单 video 元素播放,监听 ended 事件
- [x] `engine/core/StateManager.ts` — 变量存取、条件求值、效果执行
- [x] `engine/types.ts` — 类型定义
- [x] `src/components/GamePlayer.vue` — 挂载 video控制播放
- [x] `src/components/ChoicePanel.vue` — 渲染选择按钮,触发引擎切换
- [x] `public/scenes/demo.json` — 编写一段简单剧情7 个场景节点)
- [x] 验证:从 demo.json 加载场景,能走通 开始→选择→分支播放→结束 流程
### P1 核心 — 无缝切换 + 条件分支 + 存档1-2 周)✅ 已完成 2026-06-07
- [x] `engine/core/VideoManager.ts` 升级 — A/B 双缓冲预加载候选视频CSS 交叉淡化
- [x] `engine/core/SceneManager.ts` 升级 — 支持条件分支(根据 variables/flags 过滤选项)
- [x] `engine/systems/SaveSystem.ts` — Dexie.js IndexedDB 存取,多槽位
- [x] `engine/systems/ChoiceSystem.ts` — 限时选择倒计时,超时默认选择
- [x] `src/components/SaveLoadMenu.vue` — 存档/读档 UI
- [x] `src/stores/gameStore.ts` — Pinia 全局状态管理(含计时器、存档列表)
- [x] `src/composables/useGameEngine.ts` — 桥接层(双 video、存档、计时器
- [x] `src/components/GamePlayer.vue` — 双 video 元素 + 交叉淡化 CSS
- [x] `src/components/ChoicePanel.vue` — 倒计时进度条 + 计时文字
- [x] `src/App.vue` — 整合 SaveLoadMenu、双 video、计时器
- [x] 验证:条件分支走通,存档读档正常,视频切换交叉淡化
### P2 进阶 — QTE + 字幕 + 多存档槽1 周)✅ 已完成 2026-06-07
- [x] `engine/systems/QTESystem.ts` — QTE 触发、键盘监听(支持多键匹配)、超时判定
- [x] `src/components/QTEOverlay.vue` — SVG 倒计时环 + 按键提示 + 成功/失败动画
- [x] `src/components/Subtitles.vue` — WebVTT 解析 + 字幕同步渲染
- [x] `engine/core/Engine.ts` — 集成 QTEtimeupdate 检测 + 条件跳转 + 效果应用)
- [x] 多存档槽位 + 存档缩略图canvas 截图当前视频帧320x180 JPEG
- [x] `engine/core/VideoManager.ts` — 新增 `getActiveVideoElement()` 供截图
- [x] `engine/systems/SaveSystem.ts` — DB 版本升级 v2支持 thumbnail 字段)
- [x] `src/components/SaveLoadMenu.vue` — 存档缩略图预览
- [x] 完整事件总线sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd
- [x] 验证QTE 正常触发与判定ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常
### P3 编辑器 — 可视化剧情编辑2-3 周)✅ 已完成 2026-06-07
- [x] 编辑器入口:独立 `editor/index.html` + `editor/main.ts`Vite 多入口构建)
- [x] `editor/components/SceneGraph.vue` — Vue Flow 节点图(场景节点 + 分支/默认/QTE 连线)
- [x] `editor/components/NodeEditor.vue` — 右侧面板(视频/字幕路径、nextScene、选项增删改、QTE 参数编辑)
- [x] `editor/components/PreviewPanel.vue` — 嵌入播放器实时预览选中场景视频
- [x] `editor/composables/useGraphEditor.ts` — 图数据与 JSON 双向同步
- [x] JSON 导出/导入(文件下载 + 文件选择)
- [x] 工具栏:新建场景、导入 JSON、导出 JSON、加载示例、起始场景选择
- [x] `vite.config.ts` — 多页面构建main + editor
- [x] 验证:编辑器能产出合法 JSON引擎能正确加载并运行
### P4 视频/图片热点 — 点击画面区域触发分支 ✅ 已完成 2026-06-08
目标在视频或图片上定义可点击热区Hotspot玩家点击画面不同位置触发不同分支。
热区既可覆盖在静态图片上(调查/解谜场景),也可覆盖在播放中的视频上(根据时间轴淡入淡出)。
**视频热点 vs 图片热点(架构统一,差异仅两点):**
| | 图片热点 | 视频热点 |
|------|----------|----------|
| 底层内容 | `<img>` 元素 | `<video>` 元素(已经在播) |
| 热点出现时机 | 始终可见 | 按时间轴出现/消失(`showAt`/`hideAt` |
**场景数据设计:**
```json
{
"id": "investigation",
"type": "video",
"videoUrl": "/videos/investigation.mp4",
"subtitleUrl": "/subtitles/investigation.vtt",
"hotspots": [
{
"id": "hs_desk",
"label": "查看书桌",
"targetScene": "desk_detail",
"x": 0.15, "y": 0.30, "width": 0.25, "height": 0.35,
"showAt": 2.0,
"hideAt": 8.0,
"conditions": [{ "variable": "investigation", "op": ">=", "value": 1 }],
"effects": [{ "type": "setFlag", "target": "checked_desk" }]
},
{
"id": "hs_window",
"label": "靠近窗户",
"targetScene": "window_look",
"x": 0.70, "y": 0.10, "width": 0.20, "height": 0.40,
"showAt": 5.0,
"hideAt": 10.0
},
{
"id": "hs_door",
"label": "离开房间",
"targetScene": "leave_room",
"x": 0.30, "y": 0.60, "width": 0.40, "height": 0.30,
"timeLimit": 15
}
],
"choices": [
{ "text": "放弃调查", "targetScene": "give_up" }
]
}
```
**字段约定:**
- `x/y/width/height` — 热区坐标,使用**相对比例**0~1自适应屏幕尺寸
- `showAt`/`hideAt` — 视频热点的时间轴(秒),未设置时热区始终可见(兼容图片场景和始终可见的视频热点)
- `hotspots` 支持 `conditions`(条件显隐)、`effects`(点击后效果)、`timeLimit`(限时热区)
- 热点场景仍可同时附带底部 `choices`(如"放弃调查"按钮)
- `type` 字段区分 `"video"`(默认)和 `"image"`(静态图,此时 `imageUrl` 替代 `videoUrl`
**实现清单:**
- [x] `engine/types.ts``SceneNode.type` 字段、`Hotspot` 接口(含 `showAt`/`hideAt`
- [x] `src/components/HotspotLayer.vue` — 通用热区覆盖层叠加在视频或图片之上render 热区矩形 + hover 高亮 + label 浮动提示
- [x] `engine/core/Engine.ts` — 视频模式下监听 timeupdate按时显隐热区点击热区触发分支跳转
- [ ] `editor/components/NodeEditor.vue` — 场景类型切换(视频/图片)+ 热区列表编辑 + 时间轴参数showAt/hideAt
- [x] `public/images/` — 示例图片目录
- [x] `public/scenes/demo.json` — 新增图片热点场景 `investigation_site` + 视频热点场景 `corridor`
- [x] 验证:图片热区点击触发、视频热区按时出现/消失、条件过滤、hover 高亮
### P5 选择等待循环 — 单文件内时间锚点无缝循环 ✅ 已完成 2026-06-08
目标:视频结束后画面不暂停,而是在同一文件内通过 `loopStart`/`loopEnd` 时间锚点实现无切换循环,
选项浮在循环画面之上。和《底特律变人》《The Dark Pictures Anthology》等商业游戏的做法一致。
**为什么不用单独 loop 文件做 cross-fade**
- 任何文件切换(硬切或淡入)都会产生可感知的割裂感
- 商业游戏的循环效果本质上就是同一帧内 `video.currentTime = loopStart`,完全透明
- 同一文件内 seek 只在下一个 timeupdate 触发(~250ms但对 ≥2 秒的循环区间来说误差 <5%肉眼无感
**做法对比:**
| 方案 | 体验 |
|------|------|
| ~~主视频 → cross-fade → loopVideo 文件~~ | 两画面重叠 300ms**割裂** |
| ~~主视频 → 硬切 → loopVideo 文件~~ | 一帧黑/依赖浏览器 |
| **同一文件内 `loopStart/loopEnd` seek** | **完全无缝AAA 游戏标准** |
**场景数据设计:**
```json
{
"id": "tense_moment",
"videoUrl": "/videos/tense_full.mp4",
"loopStart": 8.0,
"loopEnd": 10.0,
"choices": [
{ "text": "冒险救人", "targetScene": "rescue" },
{ "text": "悄悄离开", "targetScene": "flee" }
]
}
```
素材制作流程导演剪辑时将主剧情段 + 循环段合成为一个 MP4 文件比如
```
0:00 ~ 8:00 正常剧情演绎
8:00 ~ 10:00 循环片段(角色呼吸、张望)── 循环起点 loopStart=8, loopEnd=10
```
**工作流程:**
```
┌─ 主视频正常播放0s → loopStart
├─ time >= loopStart → 标记"已到达循环区间"
│ └─ timeupdate 持续检测time >= loopEnd → video.currentTime = loopStart无任何过渡
│ └─ 无限循环中...
│ │
│ ├─ 用户选择 ──→ break loop → switchTo(nextScene)
│ │
│ ├─ 视频 ended → 自动触发循环区间
│ │
│ └─ 选项面板在循环开始时浮出
└─ 无 loopStart 的场景 → 保持现有行为(结束后暂停,等待选择)
```
**关键设计细节:**
- 检测循环完全依赖 `timeupdate` 事件无需 `requestAnimationFrame` 或额外定时器——浏览器 ~250ms timeupdate 间隔对 2s 的循环段误差可忽略
- 循环中 A/B 预加载仍然工作inactive slot 加载第一个候选目标场景用户选择后 cross-fade 过去
- `loopStart` 既是触发选项显示的时机视频到达此处时 emit `choiceRequest`也是循环起点
- `loopEnd` 为循环终点到达后 seek `loopStart`
- 若只设 `loopStart` 不设 `loopEnd`则循环区间为 `loopStart → 视频结尾`
**实现清单:**
- [x] `engine/types.ts` `SceneNode.loopStart?: number`, `loopEnd?: number`
- [x] `engine/core/VideoManager.ts` 新增 `seekTo(time)` 方法
- [x] `engine/core/Engine.ts` `checkLoop(time)` timeupdate 中检测循环区间`onVideoEnd` 循环活跃时跳过`goToScene` 重置 `loopActive`
- [x] `public/scenes/demo.json` `stay` 场景添加 loopStart=3, loopEnd=6, 循环中显示选项
- [x] `public/videos/stay_loop.mp4` 6s 测试视频0-3s 蓝色正文 + 3-6s 绿色循环段
- [x] 验证正文播放完毕 进入循环 选项浮现 画面无缝来回 选择后跳转
### P6 独立背景音乐 + Ducking — 画面循环不打断 BGM ✅ 已完成 2026-06-08
目标 BGM 从视频中剥离由独立 AudioSystem 驱动视频循环/切换时 BGM 保持连贯场景间交叉淡化衔接
QTE 和选择面板出现时 BGM 自动闪避ducking以确保提示音不被淹没
**技术选型**
- **Web Audio API** `GainNode.exponentialRampToValueAtTime()` 实现指数渐变听感均匀Wwise/FMOD/UE 同款做法
- **MP3** 全浏览器支持 Safari解码快OGG 暂不采用Safari 不支持P14 短循环音效需要 OGG 时单独处理
- **预加载** `fetch(url) → decodeAudioData() → 缓存 AudioBuffer`已解码 buffer 最多 3 LRU 淘汰
- **视频不自动静音** `videoMuted` 字段由制作者手动设置引擎不做自动静音
**架构变更:**
```
Engine
├── VideoManagerA/B 双缓冲,只管画面和视频内音轨)
│ └── loopStart/loopEnd 循环 → BGM 不受影响
└── AudioSystemWeb Audio API
├── AudioContext → GainNode(BGM) → destination
│ └── 多个 BufferSourceNode新旧 BGM 交叉淡化,指数 ramp
└── ducking 控制QTE/选择/热点触发 → GainNode ramp 降 → 事件结束 → ramp 恢复
```
**BGM 切换策略:**
```
goToScene(Scene B)
├── bgmUrl 相同?→ 什么都不做继续播bgmVolume 变化 → ramp 调整)
├── bgmUrl 为 null→ 当前 BGM 指数 fade outbgmCrossFade 秒)
└── bgmUrl 不同?
├── fetch + decode BGM B若未缓存
├── AudioBufferSourceNode 播 BGM Bgain 从 0.001 ramp 到 bgmVolume
├── 同时 BGM A 的 gain ramp 到 0.001,耗时 bgmCrossFade 秒
├── ramp 完成后 stop BGM A 的 source释放
└── 画面交叉淡化照常(画面和 BGM 各自独立过渡)
```
**Ducking 自动闪避策略:**
| 触发事件 | duck 目标值 | 进入耗时 | 恢复耗时 |
|----------|------------|---------|---------|
| QTE 触发 | bgmDuckLevel × bgmVolume | 0.3s | bgmDuckFade |
| 选择面板出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade |
| 视频热点出现 | bgmDuckLevel × bgmVolume | bgmDuckFade | bgmDuckFade |
实现方式AudioSystem 内部维护一个"当前 duck 等级"计数器允许多个事件重叠)。
GainNode ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)`
最后一个事件结束时恢复为 `bgmVolume`
**场景数据设计:**
```json
{
"id": "tense_moment",
"videoUrl": "/videos/tense_no_bgm.mp4",
"loopStart": 8.0,
"loopEnd": 10.0,
"bgmUrl": "/audio/tense_bgm.mp3",
"bgmVolume": 0.8,
"bgmCrossFade": 2.0,
"bgmDuckLevel": 0.35,
"bgmDuckFade": 0.5,
"videoMuted": false,
"choices": [...]
}
```
**字段说明:**
| 字段 | 类型 | 默认 | 说明 |
|------|------|------|------|
| `bgmUrl` | string | null | BGM 文件路径MP3null/falsy 表示静默并 fade out 当前 BGM |
| `bgmVolume` | number | 0.8 | 目标音量0~1 |
| `bgmCrossFade` | number | 2.0 | BGM 切换交叉淡化时长 |
| `bgmDuckLevel` | number | 0.35 | QTE/选择/热点时 duck bgmVolume 的百分比 |
| `bgmDuckFade` | number | 0.5 | duck 进入和恢复的渐变时长 |
| `videoMuted` | bool | false | 制作者手动设置引擎不自动静音 |
**实现清单:**
- [x] `engine/systems/AudioSystem.ts` Web Audio APIfetch+decode 缓存BufferSourceNode 创建GainNode 指数 ramp 交叉淡化同源继续/不同 crossFade/静音 fade outducking 事件接口
- [x] `engine/core/Engine.ts` 集成 AudioSystem`goToScene` 对比 `bgmUrl` 调度切换QTE/choice/hotspot 触发时调用 `audioSystem.duckOn()`/`duckOff()`
- [x] `engine/types.ts` `SceneNode` `bgmUrl``bgmVolume``bgmCrossFade``bgmDuckLevel``bgmDuckFade``videoMuted`
- [x] `engine/core/VideoManager.ts` 根据 `videoMuted` 设置 `<video>.muted`手工字段不自动
- [x] `public/audio/` BGM 测试 MP3calm_bgm.mp3, tense_bgm.mp3
- [x] `public/scenes/demo.json` intro/stay/right_door 配置 BGM + cross-fade + ducking 示例
- [ ] `editor/components/NodeEditor.vue` BGM 字段编辑面板6 个字段
- [x] 验证BGM 跨视频循环连续场景切换交叉淡化ducking /恢复同源不中断指数曲线听感均匀
**远期功能(不纳入 P6**
| 功能 | 说明 |
|------|------|
| 自适应 BGM | StateManager 变量值切换变奏 suspicion < 50 放安静版>= 50 放紧张版) |
| 水平分段编排 | BGM 前奏/主体/变奏/尾奏自动串联 |
| 分层 Stems | 多轨独立 GainNode 动态叠加,按变量增减层数 |
| Stingers | 短乐句事件音(发现线索的"叮"、惊悚弦乐刺音) |
| BGM 弧线 | 一条 BGM 覆盖多个连续场景而不被切换打断 |
### P7 全屏模式 — 沉浸式浏览器体验 ✅ 已完成 2026-06-08
目标:一键进入全屏播放模式,播放中自动隐藏 UI选项/菜单等浮层除外),提供 F11 级别的沉浸感。
**实现清单:**
- [x] `src/composables/useFullscreen.ts` — Fullscreen API 封装(`toggle` + `isFullscreen` + `fullscreenchange` 监听)
- [x] `src/App.vue` — 右上角全屏按钮,与"菜单"按钮并排;`fullscreenchange` 同步图标状态
- [x] `FUTURE.md` — 远期扩展笔记Pointer Lock、自动全屏、UI 自动隐藏、移动端适配等)
### P8 章节选择 — 到达即解锁,主菜单+通关后跳转 ✅ 已完成 2026-06-09
目标:玩家可从中途任意章节起始点重新开始。到达章节入口即解锁,主菜单和通关后均可进入章节选择界面。
**核心规则:**
| 规则 | 说明 |
|------|------|
| **解锁方式** | 到达即解锁 — `goToScene` 中检测当前场景所属章节,立即标记解锁并持久化到 IndexedDB |
| **变量状态** | 跳转时套用该章的 `defaultVariables`,未定义时 fallback 到全局 `variables`;确保条件分支不锁死 |
| **入口** | 主菜单"章节选择"按钮 + 通关后"章节选择"按钮,两处均可进入 |
**数据结构设计:**
```json
{
"startScene": "intro",
"variables": { "trust": 50, "courage": 0, "investigation": 0 },
"chapters": [
{
"id": "ch1", "label": "第一章:醒来", "startScene": "intro",
"thumbnail": "/images/ch1.jpg",
"defaultVariables": { "trust": 50, "courage": 0, "investigation": 0 }
},
{
"id": "ch2", "label": "第二章:调查", "startScene": "desk_detail",
"thumbnail": "/images/ch2.jpg",
"defaultVariables": { "trust": 60, "courage": 10, "investigation": 1 }
},
{
"id": "ch3", "label": "第三章:终局", "startScene": "qte_success",
"thumbnail": "/images/ch3.jpg",
"defaultVariables": { "trust": 70, "courage": 20, "investigation": 2 }
}
]
}
```
**实现清单:**
- [x] `engine/types.ts``GameData.chapters` 字段、`ChapterInfo` 接口(含 `defaultVariables`
- [x] `engine/systems/SaveSystem.ts` — DB v3 新增 `unlocks` 表;`unlockChapter`/`getUnlockedChapters`
- [x] `engine/core/SceneManager.ts``chapters` 存储、`getChapterBySceneId`/`getChapter` 查询
- [x] `engine/core/Engine.ts``goToScene` 检测场景所属章节 → `chapterUnlock` 事件;`startChapter` 套用 `defaultVariables` 并重置 flags/history
- [x] `src/components/ChapterSelect.vue` — 章节选择 UI缩略图网格 + 标题 + 锁定/解锁
- [x] `src/stores/gameStore.ts``chapters`/`unlockedChapterIds`/`showChapterSelect` 状态
- [x] `src/App.vue` — 主菜单"章节选择"按钮 + 游戏结束"章节选择"按钮
- [x] `public/scenes/demo.json` — 3 章定义(含 defaultVariables 和 thumbnail
- [x] `public/images/ch{1,2,3}.jpg` — 章节缩略图
- [x] 验证TypeScript + Vite build 通过
### P9 跳过已看 + 倍速播放 ✅ 已完成 2026-06-09
目标:玩家重玩分支时可以跳过已看过的场景或加速播放,避免重复等待,鼓励多次探索不同路线。
**核心规则:**
| 决策 | 说明 |
|------|------|
| **判定粒度** | IndexedDB 持久化已看场景列表SaveSystem `watched` 表),跨会话生效 |
| **跳过方式** | 画面右上角浮现"跳过"按钮,点击立即触发 skip |
| **倍速方式** | 画面右上角"倍速"按钮,点击循环切换 1x → 2x → 4x → 1x |
| **两者并存** | 跳过和倍速互不冲突,各用各的按钮 |
| **不可跳过** | 第一次看的场景不可跳(`onVideoEnd` 后才记入已看);`skippable: false` 可永久禁止 |
**新增数据结构:**
```json
{
"id": "tense_moment",
"videoUrl": "/videos/tense.mp4",
"skippable": false,
"choices": [...]
}
```
`skippable` 默认 `true`。设为 `false` 时即使已看过也不显示跳过按钮。
**跳过时的引擎行为:**
```
用户点击跳过按钮
→ engine.skipCurrentScene()
→ videoManager.pause()
→ 直接触发 onVideoEnd(scene) 流程(弹出选项 / 自动跳转 / endGame
→ 和正常播放结束行为完全一致
```
**实现清单:**
- [x] `engine/systems/SaveSystem.ts` — DB v4 新增 `watched` 表;`markWatched` / `isWatched` / `getWatchedSceneIds`
- [x] `engine/core/Engine.ts``onVideoEnd` 调用 `onMarkWatched` 回调;`skipCurrentScene()` 暂停视频并触发 ended 流程
- [x] `engine/core/VideoManager.ts``setPlaybackRate(rate)` / `getPlaybackRate()` 封装原生 API
- [x] `engine/types.ts``SceneNode.skippable?: boolean`
- [x] `src/components/PlaybackBar.vue` — 左上角跳过按钮(已看且 skippable 时显示)+ 倍速按钮(循环 1x/2x/4x
- [x] `src/App.vue` — 整合 PlaybackBar`watch` currentScene 更新 canSkip
- [x] `public/scenes/demo.json``qte_success` / `qte_fail``skippable: false`
- [x] 验证TypeScript + Vite build 通过
### P10a 键盘导航 — 方向键+确认键驱动全流程 ✅ 已完成 2026-06-09
目标:支持纯键盘操作整个游戏流程(选项选择、确认、菜单),方向键/WASD 移动高亮、Enter/Space 确认、Esc 菜单。
适配《底特律》《Telltale》级别的键盘交互体验。
**核心设计(对标业界):**
| 设计点 | 做法 |
|--------|------|
| **输入范围** | 完整接管:选项导航、菜单导航、存档界面、章节选择 |
| **视觉反馈** | 自定义高亮(发光边框/变色),和鼠标 hover 共用样式 |
| **自动检测** | 检测到 keydown → 标记 `inputMode='keyboard'` → 显示焦点环;鼠标移动 → 恢复 `inputMode='mouse'` |
| **QTE** | 本期不做 QTE 键位整合QTE 仍直接监听 keydown远期 P10b 处理 |
**按键映射:**
| 操作 | 按键 |
|------|------|
| 选项上移 | ↑ / W |
| 选项下移 | ↓ / S |
| 确认 | Enter / Space |
| 菜单 | Esc |
| 跳过 | 不变(按钮点击) |
| 全屏 | 不变(按钮点击) |
**实现清单:**
- [x] `src/stores/gameStore.ts``inputMode` 状态mouse/keyboard+ `setInputMode` setter
- [x] `src/App.vue` — 全局 keydown 监听(方向键/Enter/Space/Tab → keyboard 模式Esc → 关闭菜单/章节mousemove → mouse 模式
- [x] `src/components/ChoicePanel.vue` — 选项出现时 auto-focus 第一项;↑↓ 键导航焦点Enter/Space 确认;`:focus-visible` 发光边框样式
- [x] `src/components/ChapterSelect.vue` — ←→ 键在章节卡片间导航跳过锁定章节Enter 选择Esc/Backspace 返回;`:focus-visible` 高亮
- [x] `src/components/SaveLoadMenu.vue``@keydown.escape` 关闭菜单
- [x] 验证TypeScript + Vite build 通过
### P10b 手柄导航(远期 P10b— 见 FUTURE.md
### P11 完整 i18n — 字幕 + UI 国际化,自制 useI18n ✅ 已完成 2026-06-09
目标:字幕和完整 UI 文本(选项、按钮、标签)支持多语言切换。使用自制 `useI18n()` 组合式函数,
零依赖,通过静态 import JSON 翻译文件实现。语言切换入口在主菜单和游戏内顶部栏两处。
**技术选型:自制 useI18n~25 行 TS不用 vue-i18n。**
无需 npm 包,`t(key)` 从静态 import 的 JSON 中按路径查找翻译文本,
`currentLang` 持久化到 localStorage跨会话保持。
**架构分层:**
```
UI 层 (Vue)
├── useI18n.ts t(key), currentLang, setLang(lang)
├── LangSwitch.vue "中文 / English" 按钮组
├── locales/zh.json 中文翻译UI + scene 文本 ~50行
├── locales/en.json 英文翻译UI + scene 文本 ~50行
├── 各组件 t('key') 按钮/提示/标签翻译
└── Subtitles.vue 按 currentLang 加载 subtitles[lang]
数据层 (Engine / Scene JSON)
├── Choice.textKey 可选 i18n key缺省 fallback 到 text
├── SceneNode.subtitles Record<lang, url> 字幕多语言 map
└── 引擎不感知 i18n 纯数据传递,翻译在 composable 层完成
```
**核心 composable 设计:**
```typescript
// src/composables/useI18n.ts
const messages = { zh, en }
const currentLang = ref(localStorage.getItem('lang') || 'zh')
export function useI18n() {
function t(key: string): string { /* key = "ui.start" → messages[lang].ui.start */ }
function setLang(lang: string) { /* localStorage + currentLang */ }
return { t, currentLang, setLang }
}
```
**Choice 翻译策略:**
composable 在 `choiceRequest` 事件中调用 `t(textKey)` 翻译选项文字后存入 store。
`textKey` 未设置时 fallback 到 `text`(向后兼容,不要求每个 Choice 都加 key
```typescript
engine.on('choiceRequest', (choiceList) => {
const translated = choiceList.map(c => ({
...c,
text: c.textKey ? i18n.t(c.textKey) : c.text,
}))
store.setChoices(translated)
})
```
**场景数据变更:**
```json
{
"id": "intro",
"videoUrl": "/videos/intro.mp4",
"subtitles": {
"zh": "/subtitles/intro_zh.vtt",
"en": "/subtitles/intro_en.vtt"
},
"choices": [
{
"text": "走向左边那扇发光的门",
"textKey": "scene.intro.choice.left_door",
"targetScene": "left_door"
}
]
}
```
**翻译文件结构:**
```json
// src/locales/zh.json
{
"ui": {
"start": "开始",
"resume": "继续上次进度",
"chapters": "章节选择",
"menu": "菜单",
"save": "保存",
"load": "读取",
"close": "关闭",
"skip": "跳过",
"fullscreen": "全屏",
"exitFullscreen": "退出全屏",
"gameEnd": "结束",
"choose": "做出你的选择",
"back": "返回",
"autoSave": "自动存档",
"empty": "空",
"loading": "加载中..."
},
"scene": {
"intro": {
"choice": {
"left_door": "走向左边那扇发光的门",
"right_door": "走向右边那扇普通的门",
"search": "搜索房间",
"stay": "留在原地,什么也不做"
}
}
}
}
```
**实现清单:**
- [x] `src/composables/useI18n.ts``t(key)`, `currentLang`, `setLang(lang)`, localStorage 持久化
- [x] `src/locales/zh.json` — 中文翻译UI ~20 项 + scene 选项文字 ~30 项)
- [x] `src/locales/en.json` — 英文翻译(同结构)
- [x] `engine/types.ts``Choice.textKey?: string``SceneNode.subtitles?: Record<string, string>`
- [x] `src/composables/useGameEngine.ts``choiceRequest``t(textKey)` 翻译后存入 store
- [x] `src/components/LangSwitch.vue` — "中文 / English" 切换按钮组,调用 `setLang`
- [x] `src/components/Subtitles.vue``effectiveUrl` computed 优先 `subtitles[lang]`fallback `subtitleUrl`
- [x] `src/App.vue` — 主菜单 LangSwitch + 顶部栏按钮 `t()` 翻译
- [x] `src/components/ChoicePanel.vue``t('ui.choose')` 替代硬编码提示文字
- [x] `src/components/SaveLoadMenu.vue` — 8 处文本用 `t()` 翻译
- [x] `src/components/ChapterSelect.vue` — 标题 + 返回按钮用 `t()` 翻译
- [x] `src/components/PlaybackBar.vue` — 跳过按钮用 `t('ui.skip')`
- [x] `public/subtitles/*_en.vtt` — 3 个英文版字幕文件intro/left_door/stay
- [x] `public/scenes/demo.json` — intro 场景配置 `subtitles` map + 4 个 choice 添加 `textKey`
- [x] 验证TypeScript + Vite build 通过
### P12 场景过渡特效(已废弃)
<!--
现有 A/B cross-fade 已覆盖引擎层的全部技术缓冲需求。
真正的影视转场效果由剪辑师在 Premiere/DaVinci 中处理,引擎不做艺术决策。
- [x] ~~`src/components/TransitionOverlay.vue`~~
- [x] ~~`engine/core/Engine.ts`~~
- [x] ~~`engine/types.ts`~~
- [x] ~~`engine/core/VideoManager.ts`~~
- [x] ~~验证~~
-->
### P13 关键选择提示 — 选前标识 + 选后浮现 ✅ 已完成 2026-06-09
目标让玩家感知哪些选择有重量。Choice 增加 `prompt` 字段,
同时驱动前置金色标识Detroit 风格选前感知和后置文字浮现Telltale 风格,选后提示)。
**前后都做:**
- **前置 —** 有 `prompt` 的选项,按钮左边金色竖线 + 淡金边框,悬停发光。选前感知重要性。
- **后置 —** 选择确认后,画面中央浮现 `prompt` 文字2 秒淡出。
```json
{
"text": "与陌生人握手",
"prompt": "陌生人会记住你的善意",
"targetScene": "trust_ending"
}
```
一个字段驱动两个行为,零额外数据结构。
**实现清单:**
- [x] `engine/types.ts``Choice.prompt?: string`
- [x] `src/components/ChoicePanel.vue``.has-prompt` CSS金色左边框 + 淡金边框 + 悬停发光)+ 选后 `prompt-toast` 浮现 2s 淡出
- [x] `public/scenes/demo.json` — left_door + trust_ending 各一个 prompt 示例
- [x] 验证TypeScript + Vite build 通过
### P14 成就系统 — 纯变量检测 + 单一检查点 + Toast 队列 ✅ 已完成 2026-06-09
目标Steam 式成就系统,驱动重玩探索。所有成就通过变量检测,在 `StateManager.apply` 末尾单一检查点触发。
**设计决策(对标 Detroit / Dark Pictures**
| 决策 | 做法 |
|------|------|
| **检测方式** | **纯变量 + 单一检查点**`condition: { variable, op, value }``StateManager.apply` 末尾 `achievementSystem.check(variables)` |
| **Toast 弹出** | **逐个队列** — 同时解锁多个成就时一个消失后下一个才弹出 |
| **图标** | **可选 URL** — 有 `icon` 路径则显示缩略图,为空则不显示图标栏 |
| **入口** | **仅主菜单** — 不在游戏内 Esc 菜单,属于元游戏层 |
**数据结构:**
```json
{
"achievements": [
{
"id": "qte_master",
"title": "反应达人",
"description": "成功完成一次 QTE",
"icon": "",
"hidden": false,
"condition": { "variable": "qte_succeeded", "op": ">=", "value": 1 }
}
]
}
```
QTE 成功 / 到达隐藏结局 / 通关等"事件型"成就,通过在对应 effects 或 onEnter 中变量来驱动检测。
**实现清单:**
- [x] `engine/types.ts``GameData.achievements``AchievementDef { id, title, description, icon?, hidden, condition }`
- [x] `engine/systems/AchievementSystem.ts``check(variables)` 遍历未解锁成就;`onUnlock` 回调toast 队列管理
- [x] `engine/systems/SaveSystem.ts` — DB v5 新增 `achievements`
- [x] `engine/core/StateManager.ts``apply` 末尾 `onAfterApply` 回调
- [x] `engine/core/Engine.ts``stateManager.onAfterApply → achievementSystem.check`
- [x] `src/components/AchievementToast.vue` — 底部弹窗滑入/滑出动画
- [x] `src/components/AchievementPanel.vue` — 成就列表(全部/已解锁/未解锁/隐藏)
- [x] `src/stores/gameStore.ts` — 成就定义/解锁/toast 状态
- [x] `src/App.vue` — 整合 AchievementToast + 主菜单"成就"入口
- [x] `public/scenes/demo.json` — 3 个成就 + QTE success 变量 set + alone_ending onEnter
### P15 结局画廊 + 章节回顾 — 列表 + 完成度百分比 + 条件提示 ✅ 已完成 2026-06-09
目标:通关后展示结局画廊和章节场景清单,包含完成度百分比和未解锁分支的条件提示,
驱动重玩探索。
**设计决策:**
| 决策 | 做法 |
|------|------|
| **结局存储** | 共用 `visitedSceneIds`,不独立建表。`endings` 数组声明哪些场景是结局,画廊查 `visitedSceneIds.has(sceneId)` |
| **路径追踪** | **节点级** `visitedSceneIds: Set<string>``goToScene``visited.add(scene.id)` |
| **回顾 UI** | 列表模式BFS 遍历可达场景 → visited/unvisited 列表 + 完成度百分比 + 条件提示 |
> **关于分支图(未实施):** 原计划使用 Vue Flow 只读完整分支图(绿色实心高亮 visited 节点、灰色虚线边框 unvisited 节点),但经评估后确认:分支数一旦增多(多父同子、回边循环),自动布局的流程图会变成"意大利面",玩家理解成本反而高于简单列表。列表版已满足 P15 的核心目标——知道哪些场景到过、漏了什么、怎么解锁。
>
> **业界对比与未来路线:**
>
> | 方案 | 做法 | 清晰度 | 成本 |
> |------|------|--------|------|
> | **Detroit 手绘图** | 美术逐章手工设计 InfoGraphic | 极高 | 极高 |
> | **Life is Strange 时间线** | 只画 `keyMoment` 标记的关键节点Dagre 自动排成水平时间线,跳过琐碎小场景 | 高 | 中 |
> | **Telltale 纯统计** | 片尾弹出 "52% 玩家选了救 Doug",不画任何图 | N/A | 极低 |
> | **当前列表版** | BFS 遍历全场景 + visited/unvisited 标记 | 中 | 已实现 |
>
> **未来升级路线Life is Strange 时间线方案):**
>
> 当需要可视化展示时,采用 Dagre 时间线方案而非全分支图:
> - `SceneNode.keyMoment?: boolean` — 场景标记为"关键节点",只有标记了的场景才出现在时间线图中
> - Dagre `rankdir: 'LR'` 从左到右水平排列,仅展示关键节点的 choices / nextScene / QTE 跳转
> - visited 节点绿色实心unvisited 节点灰色虚线,条件提示小锁
> - 普通小场景不展示,避免线路交织
> - 回边用虚线半透明处理
**结局画廊:**
```json
{
"endings": [
{ "id": "trust_end", "label": "信任的伙伴", "sceneId": "trust_ending", "thumbnail": "/images/end_trust.jpg" },
{ "id": "alone_end", "label": "独行之路", "sceneId": "alone_ending", "thumbnail": "/images/end_alone.jpg" }
]
}
```
引擎 `goToScene` 到达场景时自动记录 visited。画廊检查 `visitedSceneIds.has(ending.sceneId)`
**章节回顾(当前列表版):**
**完成度百分比:** 每章展示 "3/7 (43%) 场景已发现"。
统计该章 `startScene` 可达的场景数作为分母visited 中属于本章的场景数作为分子。
**条件提示:** 未到达节点显示小锁图标 + 条件提示:
```
⬜ secret_ending 🔒 trust >= 80
```
引擎读取该边对应 choice/hotspot 的 `conditions`,展示第一个未满足的条件。
**实现清单:**
- [x] `engine/types.ts``GameData.endings``EndingDef { id, label, sceneId, chapterId?, thumbnail? }`
- [x] `engine/core/Engine.ts``goToScene``onMarkVisited(scene.id)` 回调
- [x] `engine/core/SceneManager.ts``getScenes()` 公开 scenes 数据
- [x] `engine/systems/SaveSystem.ts` — DB v6 新增 `visited`
- [x] `src/components/EndingGallery.vue` — 结局缩略图网格 + 锁定/解锁 + 点击打开章节回顾
- [x] `src/components/ChapterRecap.vue` — BFS 遍历可达场景 + 完成度进度条 + visited/unvisited 列表 + 条件提示
- [x] `src/stores/gameStore.ts` — endings/visitedSceneIds/showEndingGallery 状态
- [x] `src/App.vue` — 主菜单"画廊"入口 + EndingGallery/ChapterRecap 组件
- [x] `public/scenes/demo.json` — 3 个 endings + `end_*.jpg` 缩略图 + `chapterId` 关联
- [x] `public/images/end_{trust,alone,continue}.jpg` — 结局缩略图
- [x] 验证TypeScript + Vite build 通过
- [ ] 未来Dagre 关键节点时间线图(`SceneNode.keyMoment?: boolean`
<!--
### P16 自适应码率 — HLS/DASH 流媒体支持(已废弃,移入 FUTURE.md
离线应用模式下视频文件本地存储无网络波动和缓冲需求。HLS/DASH 在离线场景完全多余。
- [x] ~~engine/core/VideoManager.ts~~
- [x] ~~package.json hls.js~~
- [x] ~~验证~~
-->
### P16 可访问性设置 — 字幕 + QTE 辅助 + 防误触 + 暂停 ✅ 已完成 2026-06-09
目标:让不同身体条件的玩家都能舒适游戏。保留 6 个高价值设置和交互改进。
**设置项:**
| 设置 | 默认 | 说明 |
|------|------|------|
| 字幕字号 | 20px | 20/24/28/32px 可选,全局统一,所有场景生效 |
| 字幕背景透明度 | 0 | 0/0.3/0.5/0.7/0.9 可选0=无背景(电影字幕风格) |
| QTE 时限放宽 | 关 | 开启后所有 QTE 时限 × 1.5 |
| QTE 按键简化 | 关 | 开启后所有 QTE 映射为空格键 |
| 防误触延迟 | 开 | 选项出现后 0.5 秒内不接受点击/按键确认,防止连续按跳过误选 |
| 可暂停 | 开 | Space 键暂停/恢复,画面冻结 + 半透明遮罩。非 ESC 菜单式覆盖 |
**入口:** 主菜单"设置"按钮 + 游戏内 ESC 菜单"设置"按钮,两处均可进入。
设置项存 localStorageQTE 参数通过 Engine API 传入 QTESystem。暂停为引擎级功能。
**实现清单:**
- [x] `src/stores/gameStore.ts` — 6 个设置项状态 + localStorage 读写
- [x] `src/components/AccessibilitySettings.vue` — 设置面板 UI下拉 + 开关 + 滑块)
- [x] `src/components/Subtitles.vue``:style` 绑定 `store.subFontSize` / `store.subBgAlpha`
- [x] `src/components/ChoicePanel.vue` — 防误触延迟(选项出现后 0.5s `pointer-events: none`
- [x] `engine/systems/QTESystem.ts``timeLimitMultiplier``singleKeyMode` 参数
- [x] `src/App.vue` — 主菜单 + 游戏内"设置"按钮Space 暂停/恢复带遮罩QTE 参数传入引擎
- [x] 验证TypeScript + Vite build 通过
### P17 主菜单统一化 — 游戏入口整理 ✅ 已完成 2026-06-09
目标:将当前散落在 `start-overlay` 中的 7 个按钮整合到一个 `MainMenu.vue` 统一组件中,
游戏结束后不再只用"游戏结束"大字,而是显示同样的入口栏。
**设计决策:** 全局统计面板废弃。P14 成就系统和 P15 章节回顾已覆盖玩家行为追踪需求,
业界交互式电影游戏Detroit / Dark Pictures / Telltale也不做全局数字统计面板。
**菜单结构:**
```
┌──────────────────────────────┐
│ [中文] [English] │ ← 语言切换
│ │
│ [开始游戏] [继续上次进度] │
│ │
│ [章节] [成就] [画廊] [设置] │
│ │
└──────────────────────────────┘
```
游戏结束后展示同样的按钮栏 + 标题"游戏结束"。
**实现清单:**
- [x] `src/components/MainMenu.vue` — 统一主菜单组件:两行按钮(开始/继续 + 章节/成就/画廊/设置)+ 语言切换 + 游戏结束标题模式
- [x] `src/App.vue` — 使用 `MainMenu` 替代散装 `start-overlay` 按钮 + `game-end-overlay`;移除 60 行旧 CSS
- [x] 验证TypeScript + Vite build 通过
### P18 视频加载失败恢复(待实现)
目标:视频加载失败时显示错误画面 + 重试/跳过按钮,不再 `play().catch(() => {})` 静默黑屏。
**改动点:**
- [ ] `engine/core/VideoManager.ts``play`/`switchTo` 增加错误回调(`onerror` 事件 + `play()` reject
超时检测5 秒未 `canplay` 视为失败);重试逻辑(最多 3 次,指数退避 1s/2s/4s
- [ ] `src/components/VideoErrorOverlay.vue` — 错误画面:警告图标 + "视频加载失败" + [重试] [跳过此场景] 按钮
- [ ] `src/stores/gameStore.ts``videoError` 状态(`{ sceneId: string, message: string, retryCount: number }`
- [ ] `src/App.vue` — 整合 VideoErrorOverlay跳过逻辑重试当前场景或调用 `engine.skipCurrentScene()` 强制跳过
- [ ] 验证:断网播放 → 错误画面 → 重试恢复 → 跳过进入下一场景
### P19 制作者工具链 — HTML / macOS / Windows 打包 ✅ 已完成 2026-06-09
目标:制作者 clone `moviegame` 源码后可直接开发。`npm run dev` 已有 Vite HMR 实时预览,
提供三行命令打包为 Web zip、macOS 可执行应用、Windows 可执行应用。
**命令设计:**
```bash
npm run dev # Vite 实时预览(已有)
npm run pack:html # 打包 Web 版 → release/mygame.zip → 上传 itch.io (HTML)
npm run pack:mac # 打包 macOS → release/MyGame-darwin-arm64/ 可执行文件夹
npm run pack:win # 打包 Windows → release/MyGame-win32-x64/ 可执行文件夹
```
**实施方案:**
| 工具 | 技术 | 选择原因 |
|------|------|---------|
| Web 打包 | `vite build` + `zip dist/` | Vite 标准做法,产物直接上传 itch.io |
| 桌面打包 | Electron + **`@electron/packager`** | 比 `electron-builder` 简单:一行 CLI零配置不需安装向导。产出的可执行文件夹本地可直接双击运行也可以直接分发 |
**Electron 包装结构:**
```
electron/
├── main.js # Electron 主进程,全屏 + 加载 dist/index.html
└── package.json # electron + @electron/packager 依赖
```
核心逻辑:
```js
// electron/main.js
const { app, BrowserWindow } = require('electron')
app.whenReady().then(() => {
new BrowserWindow({
fullscreen: true,
autoHideMenuBar: true,
webPreferences: { nodeIntegration: false }
}).loadFile('dist/index.html')
})
app.on('window-all-closed', () => app.quit())
```
```bash
# pack:mac
npx @electron/packager . MyGame --platform=mas --arch=arm64,x64 --out=release
# pack:win
npx @electron/packager . MyGame --platform=win32 --arch=x64 --out=release
```
**实现清单:**
- [x] `scripts/pack-html.cjs``vite build` + JSON 验证 + 复制 public 资源 + zip 打包
- [x] `electron/main.js` — Electron 主进程,全屏窗口 + 加载 dist
- [x] `electron/package.json``electron` + `@electron/packager` 依赖 + pack scripts
- [x] `package.json` — 新增 `pack:html`/`pack:mac`/`pack:win` scripts
- [x] `README.md` — 面向制作者重写入门指南
- [x] `public/videos/` — 只保留 1 个示例视频
- [x] 删除 `moviegame-starter` 目录
- [x] 验证:`pack:html` 生成 release/mygame.zip
### P20 开场流程 — 启动视频 + 菜单背景视频 ✅ 已完成 2026-06-10
目标:对标行业标准,游戏启动时先播放开场视频,结束后淡入主菜单。
菜单背景支持循环视频(如 Detroit 飘雪的城市夜景),按钮叠加其上。
**设计决策:**
| 决策 | 做法 |
|------|------|
| **跳过逻辑** | 复用 P9 `watched` 表。开场视频用虚拟场景 ID `__intro__`。首次播放不可跳过,播放结束后 `markWatched('__intro__')`。后续启动 `isWatched('__intro__')` 为 true → 显示跳过按钮 |
| **跳过行为** | 和 P9 一致 — 停止视频 → 进入菜单 |
| **菜单背景** | `menuVideo` 循环播放,`MainMenu` 叠加其上 |
| **每次启动都播** | 不区分首次/再次,由 P9 跳过逻辑控制是否可跳 |
**场景数据设计:**
```json
{
"startScene": "intro",
"introVideo": "/videos/studio_logo.mp4",
"menuVideo": "/videos/menu_bg.mp4",
"scenes": { ... }
}
```
**工作流:**
```
打开页面
├── 有 introVideo → 全屏播开场视频
│ ├── watched? → 显示跳过按钮(和 P9 完全一致的交互)
│ └── ended / skip → markWatched('__intro__') → 进入菜单
└── 无 introVideo → 直接显示菜单
主菜单
├── 有 menuVideo → 背景循环播 menuVideo
│ ├── [开始游戏] [继续] ... 按钮叠加在视频上
│ ├── 开始游戏 → 停止 menuVideo → 进入第一场景
│ └── 游戏结束 → 恢复 menuVideo 循环
└── 无 menuVideo → 纯黑背景
```
**实现改动:**
| 文件 | 改动 |
|------|------|
| `engine/types.ts` | `GameData``introVideo?: string``menuVideo?: string` |
| `src/App.vue` | 加载后判断 `introVideo` → 播开场 → ended/跳过 → `MainMenu``MainMenu` 背景用 `menuVideo` 循环 |
**实现清单:**
- [x] `engine/types.ts``GameData.introVideo?` / `GameData.menuVideo?`
- [x] `src/composables/useGameEngine.ts``applyAssetBase` 处理 introVideo/menuVideo`loadGame` 写入 store
- [x] `src/stores/gameStore.ts``introVideo` / `menuVideo` 状态 + setter
- [x] `src/App.vue` — 开场视频全屏播放 + P9 跳过逻辑(`__intro__` watched+ 菜单背景视频循环
- [x] `public/demo/videos/intro_logo.mp4` + `menu_bg.mp4` — 示例视频
- [x] `public/scenes/demo.json` — 配置 `introVideo` / `menuVideo`
- [x] 验证TypeScript + Vite build 通过
### P21 菜单系统重构 — 主菜单 + 暂停菜单 + 设置 + 游戏内顶栏 ✅ 已完成 2026-06-10
目标按照行业标准Detroit / Dark Pictures / Telltale重新整理四个菜单系统的设计和交互。
**现状 vs 业界差距:**
| 菜单 | 现状 | 业界标准 |
|------|------|---------|
| 主菜单 | 两行横排 7 个按钮LangSwitch 悬浮 | 竖排单列 4-5 项,语言在设置里,背景视频可见 |
| 游戏内顶栏 | 左侧 LangSwitch + PlaybackBar右侧 3 个按钮 = 共 5 项 | 几乎无 HUD仅角落菜单图标 |
| ESC 行为 | 直接打开 SaveLoadMenu | 打开暂停菜单(存档是子项) |
| 设置面板 | AccessibilitySettings 独立面板 | 设置中分类展示,**语言在设置里** |
**四个菜单系统重新规划:**
**1. 主菜单MainMenu.vue 重设计)**
```
(menuVideo 循环播放作为背景,半透 overlay)
[ 新游戏 ] ← 竖排,最大
[ 继续 ] ← 可选
章节选择 · 成就 · 画廊 · 设置 ← 底部小字装饰行
```
- 改为竖排单列,视觉层次分明
- LangSwitch 移除,语言挪到设置面板
- 半透明 overlaymenuVideo 背景可透
**2. 游戏内顶栏App.vue 精简)**
```
左:跳过(条件) · 倍速(条<><E69DA1><EFBFBD>) 右:全屏 · [ ≡ ]
```
- LangSwitch 移除
- 设置按钮移除,统一在 ≡ 暂停菜单内
- ≡ 按钮基于现有的"菜单"/"设置"入口替代
**3. ESC 暂停菜单(新建 `PauseMenu.vue`**
```
(当前视频暂停,半透明遮罩覆盖)
[ 继续游戏 ] ← 或按 Space/Esc
[ 存档 / 读档 ]
[ 设置 ]
[ 返回主菜单 ]
```
- 不再直接打开 SaveLoadMenu
- 第一个 Esc 打开暂停菜单,再按 Esc 继续
**4. 设置面板AccessibilitySettings.vue 升级)**
```
语言:中文 ▼ English ← 顶部新增
字幕字号20 ▼
字幕背景透明0 ▼
QTE 时限放宽:关/开
QTE 按键简化:关/开
防误触延迟:开/关
可暂停:开/关
```
- 语言选择下拉移到这里
**实现清单:**
- [x] `src/components/PauseMenu.vue`**新增** — ESC 暂停菜单:继续/存档/设置/返回主菜单 + 按 Esc 继续提示
- [x] `src/components/MainMenu.vue` — 竖排单列设计:开始最大、继续次之、底部装饰行、移除 LangSwitch
- [x] `src/components/AccessibilitySettings.vue` — 顶部新增语言选择下拉(中文/English
- [x] `src/App.vue` — 顶栏精简到跳过/倍速/全屏/≡ESC 打开 PauseMenu 替代直接开 SaveLoadMenu移除 LangSwitch
- [x] `src/locales/zh.json` + `en.json` — 新增 8 个 PauseMenu 专用翻译 key
- [x] 验证TypeScript + Vite build 通过
```json
{
"dependencies": {
"vue": "^3.4",
"pinia": "^2.1",
"@vue-flow/core": "^1.x",
"@vue-flow/background": "^1.x",
"@vue-flow/controls": "^1.x",
"dexie": "^4.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0",
"typescript": "^5.3",
"vite": "^5.0",
"vue-tsc": "^2.0"
}
}
```
## 关键架构决策记录
1. **引擎与 UI 分离**: `engine/` 下纯 TS 类,不 import Vue。UI 层通过 composables 桥接。
2. **A/B 双缓冲**: 两个 `<video>` 元素轮换,一个播放时另一个预加载候选视频。
3. **JSON 驱动**: 所有剧情数据放在 JSON 中,编辑器本质是 JSON 的可视化读写工具。
4. **IndexedDB 存档**: 比 localStorage 容量大,可存储截屏缩略图。