feat: video loop support for hotspot scenes, demo updates, docs, and engine fixes

This commit is contained in:
2026-06-08 21:48:47 +08:00
parent 5b40781d0a
commit 0dbe1b097d
6 changed files with 102 additions and 22 deletions

View File

@@ -249,18 +249,32 @@ interface SaveData {
- [x] `public/scenes/demo.json` — 新增图片热点场景 `investigation_site` + 视频热点场景 `corridor`
- [x] 验证:图片热区点击触发、视频热区按时出现/消失、条件过滤、hover 高亮
### P5 选择等待循环 — 视频结束循环播放(待实现)
### P5 选择等待循环 — 单文件内时间锚点无缝循环 ✅ 已完成 2026-06-08
目标:视频结束后不暂停画面,而是无缝切换到一段循环视频Idle Loop选项浮在循环画面之上
消除"画面静止等选择"的割裂感提升电影感。这是《底特律变人》《Late Shift》等商业游戏的标配做法。
目标:视频结束后画面不暂停,而是在同一文件内通过 `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.mp4",
"loopVideoUrl": "/videos/tense_loop.mp4",
"videoUrl": "/videos/tense_full.mp4",
"loopStart": 8.0,
"loopEnd": 10.0,
"choices": [
{ "text": "冒险救人", "targetScene": "rescue" },
{ "text": "悄悄离开", "targetScene": "flee" }
@@ -268,24 +282,46 @@ interface SaveData {
}
```
素材制作流程导演剪辑时将主剧情段 + 循环段合成为一个 MP4 文件比如
```
0:00 ~ 8:00 正常剧情演绎
8:00 ~ 10:00 循环片段(角色呼吸、张望)── 循环起点 loopStart=8, loopEnd=10
```
**工作流程:**
```
主视频 A槽播放 ──→ ended ──→ B槽切换 loopVideo (loop=true) + 淡入 ──→ 显示选项
用户点击选择 ────┘
停止循环 → 切换到目标场景
┌─ 主视频正常播放0s → loopStart
├─ time >= loopStart → 标记"已到达循环区间"
│ └─ timeupdate 持续检测time >= loopEnd → video.currentTime = loopStart无任何过渡
│ └─ 无限循环中...
│ │
│ ├─ 用户选择 ──→ break loop → switchTo(nextScene)
│ │
│ ├─ 视频 ended → 自动触发循环区间
│ │
│ └─ 选项面板在循环开始时浮出
└─ 无 loopStart 的场景 → 保持现有行为(结束后暂停,等待选择)
```
**实现清单(基于现有 A/B 双缓冲架构,改动量小)**
**关键设计细节**
- [ ] `engine/types.ts``SceneNode.loopVideoUrl?: string`
- [ ] `engine/core/VideoManager.ts``playLoop(url)` 新方法(复用 inactive 槽,`loop=true` + 交叉淡化)
- [ ] `engine/core/Engine.ts` — 视频 ended 时检测 `loopVideoUrl`,有则调用 `playLoop` 而非直接触发选项;用户选择后停止循环
- [ ] `src/components/ChoicePanel.vue` — 无改动(选项已浮在视频层之上)
- [ ] `public/scenes/demo.json` — 示例场景添加循环视频
- [ ] 验证:主视频结束 → 无缝循环 → 选项中循环播放 → 选择后停止 → 下一场景
- 检测循环完全依赖 `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 独立背景音乐 — 画面循环不打断 BGM待实现