diff --git a/ROADMAP.md b/ROADMAP.md index 92230f3..d5b9dd2 100644 --- a/ROADMAP.md +++ b/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(待实现) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 05baf99..e6d3ef1 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -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') } diff --git a/engine/core/VideoManager.ts b/engine/core/VideoManager.ts index 293bef1..a723f3b 100644 --- a/engine/core/VideoManager.ts +++ b/engine/core/VideoManager.ts @@ -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 } diff --git a/engine/types.ts b/engine/types.ts index 630ad0c..e975bd1 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -9,6 +9,8 @@ export interface SceneNode { qte?: QTEDefinition nextScene?: string onEnter?: Effect[] + loopStart?: number + loopEnd?: number } export interface Choice { diff --git a/public/scenes/demo.json b/public/scenes/demo.json index aa9d466..7cce0b5 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -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", diff --git a/public/videos/stay_loop.mp4 b/public/videos/stay_loop.mp4 new file mode 100644 index 0000000..0344a63 Binary files /dev/null and b/public/videos/stay_loop.mp4 differ