feat: video loop support for hotspot scenes, demo updates, docs, and engine fixes
This commit is contained in:
70
ROADMAP.md
70
ROADMAP.md
@@ -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(待实现)
|
||||
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ export interface SceneNode {
|
||||
qte?: QTEDefinition
|
||||
nextScene?: string
|
||||
onEnter?: Effect[]
|
||||
loopStart?: number
|
||||
loopEnd?: number
|
||||
}
|
||||
|
||||
export interface Choice {
|
||||
|
||||
@@ -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
BIN
public/videos/stay_loop.mp4
Normal file
Binary file not shown.
Reference in New Issue
Block a user