1233 lines
54 KiB
Markdown
1233 lines
54 KiB
Markdown
# 交互式电影游戏引擎 — 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` — 集成 QTE(timeupdate 检测 + 条件跳转 + 效果应用)
|
||
- [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
|
||
├── VideoManager(A/B 双缓冲,只管画面和视频内音轨)
|
||
│ └── loopStart/loopEnd 循环 → BGM 不受影响
|
||
└── AudioSystem(Web 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 out(bgmCrossFade 秒)
|
||
└── bgmUrl 不同?
|
||
├── fetch + decode BGM B(若未缓存)
|
||
├── AudioBufferSourceNode 播 BGM B,gain 从 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 文件路径(MP3),null/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 API:fetch+decode 缓存、BufferSourceNode 创建、GainNode 指数 ramp 交叉淡化、同源继续/不同 crossFade/静音 fade out、ducking 事件接口
|
||
- [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 测试 MP3(calm_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 菜单"设置"按钮,两处均可进入。
|
||
|
||
设置项存 localStorage,QTE 参数通过 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 移除,语言挪到设置面板
|
||
- 半透明 overlay,menuVideo 背景可透
|
||
|
||
**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 通过
|
||
|
||
### P22 故事进度总览 — 章节选择 + 画廊合并 ✅ 已完成 2026-06-10
|
||
|
||
目标:对标 Detroit: Become Human,将章节选择和结局画廊合并为一张"故事进度总览"视图。
|
||
|
||
**优化设计(两处改进):**
|
||
|
||
| 优化 | 说明 |
|
||
|------|------|
|
||
| **A: 去掉 `chapterId`** | 结局归属哪章不用显式声明。StoryGallery 通过 BFS 判断 `ending.sceneId` 是否在章节可达范围内,自动推导。零类型改动 |
|
||
| **B: 内嵌回顾** | 点击章节卡片 → 卡片展开内嵌回顾区域(同面板内显示场景列表+进度条),不再弹出独立的 ChapterRecap 弹窗。避免主菜单→故事进度→章节回顾三层模态框 |
|
||
|
||
**现状 vs 业界对比:**
|
||
|
||
| | 当前 | 业界标准(Detroit 等) |
|
||
|------|------|---------------------|
|
||
| 章节选择 | `ChapterSelect.vue` — 独立卡片选择器 | 章节卡片内嵌结局标签 |
|
||
| 结局画廊 | `EndingGallery.vue` — 独立缩略图网格 | 结局归属章节,不独立展示 |
|
||
| 主菜单入口 | 两个按钮:"章节选择" + "画廊" | 一个按钮:"故事进度" |
|
||
|
||
任何产品都不会把这两个功能做成独立界面。合并后:
|
||
|
||
```
|
||
┌──────────────────────────────────────────┐
|
||
│ 故事进度总览 │
|
||
├──────────────────────────────────────────┤
|
||
│ │
|
||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||
│ │ 第一章 │ │ 第二章 │ │ 第三章 │ │
|
||
│ │ [缩略图] │ │ [缩略图] │ │ [缩略图] │ │
|
||
│ │████ 43% │ │███░ 67% │ │███░ 80% │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ 结局: │ │ 结局: │ │ 结局: │ │
|
||
│ │ ✅ 信任 │ │ — │ │ ✅ 继续 │ │
|
||
│ │ 🔒 独行 │ │ │ │ │ │
|
||
│ │ │ │ │ │ │ │
|
||
│ │ [▶ 开始] │ │ [▶ 开始] │ │ [▶ 开始] │ │
|
||
│ └─────────┘ └─────────┘ └─────────┘ │
|
||
│ │
|
||
│ 点击卡片主体 → 卡片展开内嵌回顾区域 │
|
||
│ 点击 ▶ 开始 → 进入该章节 │
|
||
│ [返回] │
|
||
└──────────────────────────────────────────┘
|
||
```
|
||
|
||
埋入展示后,点击卡片主体 → 卡片区域自动展开,内嵌显示 BFS 可达的场景列表(✅ visited / ⬜ unvisited + 条件提示),无需额外弹出 ChapterRecap 弹窗。
|
||
|
||
**数据依赖:** `chapters` + `endings`(按 BFS 自动推导归属) + `visitedSceneIds` + BFS 完成度计算。零额外数据字段。
|
||
|
||
**实现清单:**
|
||
|
||
- [x] `src/components/StoryGallery.vue` — **新建** — 统一故事进度总览组件。BFS 自动推导结局归属;点击卡片内嵌展开场景列表
|
||
- [x] `src/components/ChapterSelect.vue` — **删除**
|
||
- [x] `src/components/EndingGallery.vue` — **删除**
|
||
- [x] `src/components/ChapterRecap.vue` — 逻辑内嵌到 StoryGallery(场景列表 + 完成度 + 条件提示)
|
||
- [x] `src/components/MainMenu.vue` — chapters/gallery 两 emit 合并为 `story` emit
|
||
- [x] `src/App.vue` — 主菜单只显示"故事进度"一个按钮;删除旧两组件引用
|
||
- [x] `src/locales/zh.json` + `en.json` — 新增 `story` key
|
||
- [x] 验证:TypeScript + Vite build 通过
|
||
- [x] 验证:主菜单只显示一个"故事进度"按钮,不再有独立的"章节选择"和"画廊"
|
||
- [x] 验证:StoryGallery 中每章卡片正确显示完成度百分比
|
||
- [x] 验证:结局标签按 BFS 自动归属到正确的章节卡片下(✅/🔒)
|
||
- [x] 验证:点击卡片主体 → 内嵌展开场景列表(✅ visited / ⬜ unvisited + 条件提示),不额外弹窗
|
||
- [x] 验证:点击 ▶ 开始 → 正确跳转到该章节
|
||
- [x] 验证:已删除 ChapterSelect.vue / EndingGallery.vue 后项目无编译残留
|
||
|
||
```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 容量大,可存储截屏缩略图。
|