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待实现

View File

@@ -21,6 +21,7 @@ export class Engine {
private qteTriggered = false
private qteResolved = false
private justCameFromImage = false
private loopActive = false
constructor() {
this.sceneManager = new SceneManager()
@@ -29,7 +30,7 @@ export class Engine {
this.choiceSystem = new ChoiceSystem()
this.qteSystem = new QTESystem()
this.videoManager.onTimeUpdate(this.checkQTE)
this.videoManager.onTimeUpdate(this.onTimeUpdate)
}
on(event: EngineEvent, handler: EventHandler) {
@@ -56,6 +57,7 @@ export class Engine {
this.currentScene = scene
this.qteTriggered = false
this.qteResolved = false
this.loopActive = false
if (scene.onEnter) {
this.stateManager.apply(scene.onEnter)
@@ -106,12 +108,14 @@ export class Engine {
}
}
private checkQTE = (time: number) => {
private onTimeUpdate = (time: number) => {
const scene = this.currentScene
if (!scene) return
this.checkHotspotTime(scene, time)
this.checkLoop(time)
// QTE check after loop check, so loop doesn't interfere with QTE
if (!scene.qte || this.qteTriggered) return
if (time >= scene.qte.triggerTime) {
this.qteTriggered = true
@@ -154,6 +158,33 @@ export class Engine {
}
}
private checkLoop(time: number) {
const scene = this.currentScene
if (!scene?.loopStart) return
if (!this.loopActive && time >= scene.loopStart) {
this.loopActive = true
const validChoices = this.getValidChoices(scene)
if (validChoices.length > 0) {
this.emit('choiceRequest', validChoices)
this.choiceSystem.start(
validChoices,
(timerState) => {
this.emit('choiceTimer', timerState)
},
(defaultChoice) => {
this.emit('choiceTimeout', defaultChoice)
this.makeChoice(defaultChoice)
}
)
}
}
if (this.loopActive && scene.loopEnd && time >= scene.loopEnd) {
this.videoManager.seekTo(scene.loopStart)
}
}
private checkHotspotTime(scene: SceneNode, time: number) {
if (!scene.hotspots || scene.hotspots.length === 0) return
@@ -197,6 +228,8 @@ export class Engine {
}
private onVideoEnd(scene: SceneNode) {
if (this.loopActive) return
const validChoices = this.getValidChoices(scene)
if (validChoices.length > 0) {
@@ -220,7 +253,6 @@ export class Engine {
this.endGame()
}
} else if (scene.hotspots?.length) {
// hotspot-only scene: wait for user to click a hotspot
return
} else {
this.endGame()
@@ -257,6 +289,7 @@ export class Engine {
endGame() {
this.ended = true
this.loopActive = false
this.qteSystem.cancel()
this.emit('gameEnd')
}

View File

@@ -149,6 +149,11 @@ export class VideoManager {
return this.active ?? null
}
seekTo(time: number) {
if (!this.active) return
this.active.currentTime = time
}
onEnd(cb: VideoEndCallback) {
this.onEndCallback = cb
}

View File

@@ -9,6 +9,8 @@ export interface SceneNode {
qte?: QTEDefinition
nextScene?: string
onEnter?: Effect[]
loopStart?: number
loopEnd?: number
}
export interface Choice {

View File

@@ -183,9 +183,13 @@
},
"stay": {
"id": "stay",
"videoUrl": "/videos/stay.mp4",
"videoUrl": "/videos/stay_loop.mp4",
"subtitleUrl": "/subtitles/stay.vtt",
"nextScene": "alone_ending"
"loopStart": 3.0,
"loopEnd": 6.0,
"choices": [
{ "text": "站起来离开", "targetScene": "alone_ending" }
]
},
"trust_ending": {
"id": "trust_ending",

BIN
public/videos/stay_loop.mp4 Normal file

Binary file not shown.