From 660fa9347cf76d97e462dfc9cce1b93ea278031a Mon Sep 17 00:00:00 2001 From: cocos02 Date: Tue, 9 Jun 2026 14:21:41 +0800 Subject: [PATCH] feat: playback bar component, save system improvements, demo and roadmap updates --- ROADMAP.md | 52 +++++++++++++++++++---- engine/core/Engine.ts | 14 +++++++ engine/core/VideoManager.ts | 9 ++++ engine/systems/SaveSystem.ts | 26 +++++++++++- engine/types.ts | 1 + public/scenes/demo.json | 2 + src/App.vue | 28 ++++++++++++- src/components/PlaybackBar.vue | 71 ++++++++++++++++++++++++++++++++ src/composables/useGameEngine.ts | 35 +++++++++++++--- 9 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 src/components/PlaybackBar.vue diff --git a/ROADMAP.md b/ROADMAP.md index b8c935a..ee55315 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -484,19 +484,53 @@ GainNode 的 ramp 目标值 = `Math.min(bgmVolume, bgmDuckLevel × bgmVolume)` - [x] `public/images/ch{1,2,3}.jpg` — 章节缩略图 - [x] 验证:TypeScript + Vite build 通过 -### P9 跳过已看 + 倍速播放(待实现) +### P9 跳过已看 + 倍速播放 ✅ 已完成 2026-06-09 -目标:玩家重玩时,可以跳过已看过的场景或加速播放(2x/4x),避免重复等待。 +目标:玩家重玩分支时可以跳过已看过的场景或加速播放,避免重复等待,鼓励多次探索不同路线。 + +**核心规则:** + +| 决策 | 说明 | +|------|------| +| **判定粒度** | 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) + → 和正常播放结束行为完全一致 +``` **实现清单:** -- [ ] `engine/systems/SkipSystem.ts` — 记录已观看的场景 ID 列表(持久化到 IndexedDB) -- [ ] `src/components/SkipIndicator.vue` — "按住 Space 跳过" 进度环指示器 -- [ ] `engine/core/VideoManager.ts` — `setPlaybackRate(rate)` 方法(原生 `video.playbackRate`) -- [ ] `src/App.vue` — 跳过按钮/快捷键(Space 长按 → 进度环走满 → 触发 skip),倍速切换按钮(1x/2x/4x) -- [ ] `engine/core/Engine.ts` — 跳过逻辑:跳过 → 直接触发 `onVideoEnd` 流程;倍速不触发跳过 -- [ ] `public/scenes/demo.json` — 添加 `skippable: true/false` 字段(关键场景禁止跳过) -- [ ] 验证:已看场景可跳过、未看不可跳过、倍速切换正常、关键场景不可跳过 +- [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 通过 ### P10 键盘/手柄导航(待实现) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 9d26d98..64756b2 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -25,11 +25,16 @@ export class Engine { private justCameFromImage = false private loopActive = false private onUnlockChapter: ((chapterId: string) => void) | null = null + private onMarkWatched: ((sceneId: string) => void) | null = null setChapterUnlockHandler(handler: (chapterId: string) => void) { this.onUnlockChapter = handler } + setMarkWatchedHandler(handler: (sceneId: string) => void) { + this.onMarkWatched = handler + } + constructor() { this.sceneManager = new SceneManager() this.videoManager = new VideoManager() @@ -277,6 +282,8 @@ export class Engine { } private onVideoEnd(scene: SceneNode) { + this.onMarkWatched?.(scene.id) + if (this.loopActive) return const validChoices = this.getValidChoices(scene) @@ -348,6 +355,13 @@ export class Engine { this.emit('gameEnd') } + skipCurrentScene() { + const scene = this.currentScene + if (!scene) return + this.videoManager.getActiveVideoElement()?.pause() + this.onVideoEnd(scene) + } + startChapter(chapterId: string) { const chapter = this.sceneManager.getChapter(chapterId) if (!chapter) return diff --git a/engine/core/VideoManager.ts b/engine/core/VideoManager.ts index 6737cc5..23751cb 100644 --- a/engine/core/VideoManager.ts +++ b/engine/core/VideoManager.ts @@ -155,6 +155,15 @@ export class VideoManager { this.active.currentTime = time } + setPlaybackRate(rate: number) { + if (this.elA) this.elA.playbackRate = rate + if (this.elB) this.elB.playbackRate = rate + } + + getPlaybackRate(): number { + return this.active?.playbackRate ?? 1 + } + setMuted(muted: boolean) { if (this.elA) this.elA.muted = muted if (this.elB) this.elB.muted = muted diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts index 551bf06..0e8b9a7 100644 --- a/engine/systems/SaveSystem.ts +++ b/engine/systems/SaveSystem.ts @@ -16,15 +16,22 @@ interface UnlockRecord { chapterId: string } +interface WatchedRecord { + sceneId: string + timestamp: number +} + class SaveDB extends Dexie { saves!: Table unlocks!: Table + watched!: Table constructor() { super('MovieGameSaves') - this.version(3).stores({ + this.version(4).stores({ saves: '++id, slot', unlocks: 'chapterId', + watched: 'sceneId', }) } } @@ -96,4 +103,21 @@ export class SaveSystem { const records = await db.unlocks.toArray() return records.map((r) => r.chapterId) } + + async markWatched(sceneId: string) { + const exists = await db.watched.get(sceneId) + if (!exists) { + await db.watched.put({ sceneId, timestamp: Date.now() }) + } + } + + async isWatched(sceneId: string): Promise { + const record = await db.watched.get(sceneId) + return !!record + } + + async getWatchedSceneIds(): Promise { + const records = await db.watched.toArray() + return records.map((r) => r.sceneId) + } } diff --git a/engine/types.ts b/engine/types.ts index 203b384..7fd7c71 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -17,6 +17,7 @@ export interface SceneNode { bgmDuckLevel?: number bgmDuckFade?: number videoMuted?: boolean + skippable?: boolean } export interface Choice { diff --git a/public/scenes/demo.json b/public/scenes/demo.json index 79f0328..03a4d69 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -173,6 +173,7 @@ "qte_success": { "id": "qte_success", "videoUrl": "/videos/qte_success.mp4", + "skippable": false, "choices": [ { "text": "继续前进", @@ -187,6 +188,7 @@ "qte_fail": { "id": "qte_fail", "videoUrl": "/videos/qte_fail.mp4", + "skippable": false, "choices": [ { "text": "继续前进", diff --git a/src/App.vue b/src/App.vue index 09bda94..28be430 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ @@ -112,6 +132,12 @@ init() @choose="onChoose" />
+ diff --git a/src/components/PlaybackBar.vue b/src/components/PlaybackBar.vue new file mode 100644 index 0000000..1a3fcef --- /dev/null +++ b/src/components/PlaybackBar.vue @@ -0,0 +1,71 @@ + + + + + diff --git a/src/composables/useGameEngine.ts b/src/composables/useGameEngine.ts index 22a6fea..75666fa 100644 --- a/src/composables/useGameEngine.ts +++ b/src/composables/useGameEngine.ts @@ -15,10 +15,14 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide ;(window as any).__store = store } - engine.setChapterUnlockHandler(async (chapterId) => { - await saveSystem.unlockChapter(chapterId) - store.addUnlockedChapter(chapterId) - }) + engine.setChapterUnlockHandler(async (chapterId) => { + await saveSystem.unlockChapter(chapterId) + store.addUnlockedChapter(chapterId) + }) + + engine.setMarkWatchedHandler(async (sceneId) => { + await saveSystem.markWatched(sceneId) + }) engine.on('sceneChange', (scene) => { store.setScene(scene) @@ -116,11 +120,28 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide } function startChapter(chapterId: string) { - ensureVideo() + const [elA, elB] = videoEls() + if (elA && elB) engine.videoManager.attach(elA, elB) store.setGameEnded(false) engine.startChapter(chapterId) } + function skipScene() { + engine.skipCurrentScene() + } + + function setSpeed(rate: number) { + engine.videoManager.setPlaybackRate(rate) + } + + function getSpeed(): number { + return engine.videoManager.getPlaybackRate() + } + + async function isSceneWatched(sceneId: string): Promise { + return await saveSystem.isWatched(sceneId) + } + function makeChoice(index: number) { const scene = store.currentScene if (!scene?.choices) return @@ -184,6 +205,10 @@ export function useGameEngine(videoEls: () => [HTMLVideoElement | null, HTMLVide makeChoice, clickHotspot, startChapter, + skipScene, + setSpeed, + getSpeed, + isSceneWatched, saveGame, loadGameFromSlot, refreshSaves,