From 319a3799216dbfe521be01f787a1a08f43d1122b Mon Sep 17 00:00:00 2001 From: cocos02 Date: Sun, 7 Jun 2026 19:35:14 +0800 Subject: [PATCH] feat: P2 - QTE system, subtitles, save thumbnails - QTESystem: trigger detection via timeupdate, multi-key matching, timeout handling - QTEOverlay: SVG countdown ring + key prompts + success/fail animation - Engine: integrate QTE (timeupdate check, conditional branching, effect application) - Subtitles: WebVTT parsing + synchronized subtitle rendering - GamePlayer: overlay QTE and subtitle components - SaveSystem: DB v2 with thumbnail field, canvas snapshot at 320x180 JPEG - SaveLoadMenu: thumbnail preview for save slots - VideoManager: getActiveVideoElement() for canvas capture - App.vue: QTE/subtitle integration, thumbnail capture on save - stores: QTE state management, save list with thumbnails - demo.json: QTE scene (right_door), subtitles, new event types - ROADMAP: mark P2 as completed --- ROADMAP.md | 18 ++-- engine/core/Engine.ts | 62 +++++++++++- engine/core/VideoManager.ts | 4 + engine/systems/QTESystem.ts | 75 +++++++++++++++ engine/systems/SaveSystem.ts | 12 ++- engine/types.ts | 2 + public/scenes/demo.json | 34 ++++++- public/subtitles/intro.vtt | 7 ++ public/subtitles/left_door.vtt | 7 ++ public/subtitles/stay.vtt | 4 + public/videos/qte_fail.mp4 | Bin 0 -> 12805 bytes public/videos/qte_success.mp4 | Bin 0 -> 17920 bytes src/App.vue | 15 +++ src/components/QTEOverlay.vue | 158 +++++++++++++++++++++++++++++++ src/components/SaveLoadMenu.vue | 91 ++++++++++-------- src/components/Subtitles.vue | 115 ++++++++++++++++++++++ src/composables/useGameEngine.ts | 37 +++++++- src/stores/gameStore.ts | 37 +++++++- 18 files changed, 625 insertions(+), 53 deletions(-) create mode 100644 engine/systems/QTESystem.ts create mode 100644 public/subtitles/intro.vtt create mode 100644 public/subtitles/left_door.vtt create mode 100644 public/subtitles/stay.vtt create mode 100644 public/videos/qte_fail.mp4 create mode 100644 public/videos/qte_success.mp4 create mode 100644 src/components/QTEOverlay.vue create mode 100644 src/components/Subtitles.vue diff --git a/ROADMAP.md b/ROADMAP.md index d8fb30e..bc7ae73 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -154,14 +154,18 @@ interface SaveData { - [x] `src/App.vue` — 整合 SaveLoadMenu、双 video、计时器 - [x] 验证:条件分支走通,存档读档正常,视频切换交叉淡化 -### P2 进阶 — QTE + 字幕 + 多存档槽(1 周) +### P2 进阶 — QTE + 字幕 + 多存档槽(1 周)✅ 已完成 2026-06-07 -- [ ] `engine/systems/QTESystem.ts` — QTE 触发、键盘监听、超时判定 -- [ ] `src/components/QTEOverlay.vue` — QTE 视觉遮罩(按键提示 + 倒计时环) -- [ ] `src/components/Subtitles.vue` — WebVTT 解析 + 字幕渲染 -- [ ] 多存档槽位 + 存档缩略图(canvas 截图当前视频帧) -- [ ] `engine/core/Engine.ts` — 完整事件总线(sceneChange, choiceMade, qteTriggered 等) -- [ ] 验证:QTE 正常触发与判定,字幕同步,多存档正常工作 +- [x] `engine/systems/QTESystem.ts` — QTE 触发、键盘监听(支持多键匹配)、超时判定 +- [x] `src/components/QTEOverlay.vue` — SVG 倒计时环 + 按键提示 + 成功/失败动画 +- [x] `src/components/Subtitles.vue` — WebVTT 解析 + 字幕同步渲染 +- [x] `engine/core/Engine.ts` — 集成 QTE(timeupdate 检测 + 条件跳转 + 效果应用) +- [x] 多存档槽位 + 存档缩略图(canvas 截图当前视频帧,320x180 JPEG) +- [x] `engine/core/VideoManager.ts` — 新增 `getActiveVideoElement()` 供截图 +- [x] `engine/systems/SaveSystem.ts` — DB 版本升级 v2(支持 thumbnail 字段) +- [x] `src/components/SaveLoadMenu.vue` — 存档缩略图预览 +- [x] 完整事件总线(sceneChange, choiceRequest, choiceTimer, choiceTimeout, videoEnd, qteTrigger, qteTimer, qteResult, gameEnd) +- [x] 验证:QTE 正常触发与判定(ArrowLeft/ArrowRight/A/D 躲石块),字幕同步,存档缩略图正常 ### P3 编辑器 — 可视化剧情编辑(2-3 周) diff --git a/engine/core/Engine.ts b/engine/core/Engine.ts index 05c951c..72164fc 100644 --- a/engine/core/Engine.ts +++ b/engine/core/Engine.ts @@ -3,6 +3,7 @@ import { SceneManager } from './SceneManager' import { VideoManager } from './VideoManager' import { StateManager } from './StateManager' import { ChoiceSystem } from '../systems/ChoiceSystem' +import { QTESystem } from '../systems/QTESystem' type EventHandler = (...args: any[]) => void @@ -11,17 +12,21 @@ export class Engine { videoManager: VideoManager stateManager: StateManager choiceSystem: ChoiceSystem + qteSystem: QTESystem private currentScene: SceneNode | null = null private events: Map> = new Map() private ended = false private isInitialScene = true + private qteTriggered = false + private qteResolved = false constructor() { this.sceneManager = new SceneManager() this.videoManager = new VideoManager() this.stateManager = new StateManager() this.choiceSystem = new ChoiceSystem() + this.qteSystem = new QTESystem() } on(event: EngineEvent, handler: EventHandler) { @@ -46,6 +51,8 @@ export class Engine { private goToScene(scene: SceneNode) { this.currentScene = scene + this.qteTriggered = false + this.qteResolved = false if (scene.onEnter) { this.stateManager.apply(scene.onEnter) @@ -57,8 +64,14 @@ export class Engine { ) this.videoManager.onEnd(() => { - this.emit('videoEnd', scene) - this.onVideoEnd(scene) + if (!this.qteResolved) { + this.emit('videoEnd', scene) + this.onVideoEnd(scene) + } + }) + + this.videoManager.onTimeUpdate((time) => { + this.checkQTE(scene, time) }) if (this.isInitialScene) { @@ -71,6 +84,49 @@ export class Engine { this.emit('sceneChange', scene) } + private checkQTE(scene: SceneNode, time: number) { + if (!scene.qte || this.qteTriggered) return + if (time >= scene.qte.triggerTime) { + this.qteTriggered = true + const qte = scene.qte + + this.emit('qteTrigger', qte) + + this.qteSystem.trigger( + qte, + (remaining, total) => { + this.emit('qteTimer', { remaining, total }) + }, + (success) => { + this.qteResolved = true + if (success) { + if (qte.effects?.success) { + this.stateManager.apply(qte.effects.success) + } + this.emit('qteResult', { success: true }) + const targetScene = this.sceneManager.getScene(qte.successScene) + if (targetScene) { + this.goToScene(targetScene) + } else { + this.endGame() + } + } else { + if (qte.effects?.fail) { + this.stateManager.apply(qte.effects.fail) + } + this.emit('qteResult', { success: false }) + const targetScene = this.sceneManager.getScene(qte.failScene) + if (targetScene) { + this.goToScene(targetScene) + } else { + this.endGame() + } + } + } + ) + } + } + private onVideoEnd(scene: SceneNode) { const validChoices = this.getValidChoices(scene) @@ -129,6 +185,7 @@ export class Engine { endGame() { this.ended = true + this.qteSystem.cancel() this.emit('gameEnd') } @@ -163,6 +220,7 @@ export class Engine { } destroy() { + this.qteSystem.destroy() this.videoManager.detach() this.events.clear() } diff --git a/engine/core/VideoManager.ts b/engine/core/VideoManager.ts index a0c26ea..ccf212f 100644 --- a/engine/core/VideoManager.ts +++ b/engine/core/VideoManager.ts @@ -132,6 +132,10 @@ export class VideoManager { return this.active?.currentTime ?? 0 } + getActiveVideoElement(): HTMLVideoElement | null { + return this.active ?? null + } + onEnd(cb: VideoEndCallback) { this.onEndCallback = cb } diff --git a/engine/systems/QTESystem.ts b/engine/systems/QTESystem.ts new file mode 100644 index 0000000..aea6915 --- /dev/null +++ b/engine/systems/QTESystem.ts @@ -0,0 +1,75 @@ +import type { QTEDefinition } from '../types' + +type QTEUpdateCallback = (remaining: number, total: number) => void +type QTEResultCallback = (success: boolean) => void + +export class QTESystem { + private timerId: ReturnType | null = null + private timeoutId: ReturnType | null = null + private keyHandler: ((e: KeyboardEvent) => void) | null = null + private tickMs = 50 + private active = false + + trigger( + qte: QTEDefinition, + onUpdate: QTEUpdateCallback, + onResult: QTEResultCallback, + ) { + if (this.active) return + this.active = true + + const startTime = Date.now() + const total = qte.timeLimit * 1000 + + this.keyHandler = (e: KeyboardEvent) => { + if (!this.active) return + const matched = qte.keys.some( + (k) => k.toLowerCase() === e.key.toLowerCase() + ) + if (matched) { + this.clear() + onResult(true) + } + } + document.addEventListener('keydown', this.keyHandler) + + this.timerId = setInterval(() => { + const elapsed = Date.now() - startTime + const remaining = Math.max(0, total - elapsed) + onUpdate(remaining / 1000, qte.timeLimit) + if (remaining <= 0) { + this.clear() + onResult(false) + } + }, this.tickMs) + + this.timeoutId = setTimeout(() => { + this.clear() + onResult(false) + }, total) + } + + cancel() { + this.clear() + } + + private clear() { + this.active = false + if (this.timerId !== null) { + clearInterval(this.timerId) + this.timerId = null + } + if (this.timeoutId !== null) { + clearTimeout(this.timeoutId) + this.timeoutId = null + } + if (this.keyHandler !== null) { + document.removeEventListener('keydown', this.keyHandler) + this.keyHandler = null + } + } + + destroy() { + this.clear() + } +} diff --git a/engine/systems/SaveSystem.ts b/engine/systems/SaveSystem.ts index afe15a9..a727c9f 100644 --- a/engine/systems/SaveSystem.ts +++ b/engine/systems/SaveSystem.ts @@ -9,6 +9,7 @@ interface SaveRecord { variables: string flags: string history: string + thumbnail?: string } class SaveDB extends Dexie { @@ -16,7 +17,7 @@ class SaveDB extends Dexie { constructor() { super('MovieGameSaves') - this.version(1).stores({ + this.version(2).stores({ saves: '++id, slot', }) } @@ -25,14 +26,15 @@ class SaveDB extends Dexie { const db = new SaveDB() export class SaveSystem { - async save(slot: number, data: Omit): Promise { + async save(slot: number, data: Omit): Promise { const record: SaveRecord = { slot, - timestamp: Date.now(), + timestamp: data.timestamp || Date.now(), currentScene: data.currentScene, variables: JSON.stringify(data.variables), flags: JSON.stringify(data.flags), history: JSON.stringify(data.history), + thumbnail: data.thumbnail, } const existing = await db.saves.where('slot').equals(slot).first() @@ -54,15 +56,17 @@ export class SaveSystem { variables: JSON.parse(record.variables), flags: JSON.parse(record.flags), history: JSON.parse(record.history), + thumbnail: record.thumbnail, } } - async listSlots(): Promise<{ slot: number; timestamp: number; sceneLabel: string }[]> { + async listSlots(): Promise<{ slot: number; timestamp: number; sceneLabel: string; thumbnail?: string }[]> { const records = await db.saves.orderBy('slot').toArray() return records.map((r) => ({ slot: r.slot, timestamp: r.timestamp, sceneLabel: r.currentScene, + thumbnail: r.thumbnail, })) } diff --git a/engine/types.ts b/engine/types.ts index f71a58f..9ee94ce 100644 --- a/engine/types.ts +++ b/engine/types.ts @@ -69,5 +69,7 @@ export type EngineEvent = | 'choiceTimer' | 'gameEnd' | 'qteTrigger' + | 'qteTimer' + | 'qteResult' | 'videoEnd' | 'choiceTimeout' diff --git a/public/scenes/demo.json b/public/scenes/demo.json index ba29a3e..4bcd0b1 100644 --- a/public/scenes/demo.json +++ b/public/scenes/demo.json @@ -8,11 +8,11 @@ "intro": { "id": "intro", "videoUrl": "/videos/intro.mp4", + "subtitleUrl": "/subtitles/intro.vtt", "choices": [ { "text": "走向左边那扇发光的门", "targetScene": "left_door", - "timeLimit": 5, "effects": [ { "type": "add", "target": "courage", "value": 10 } ] @@ -33,6 +33,7 @@ "left_door": { "id": "left_door", "videoUrl": "/videos/left_door.mp4", + "subtitleUrl": "/subtitles/left_door.vtt", "choices": [ { "text": "与陌生人握手", @@ -50,6 +51,36 @@ "right_door": { "id": "right_door", "videoUrl": "/videos/right_door.mp4", + "qte": { + "triggerTime": 1.0, + "prompt": "躲避飞来的石块!", + "keys": ["ArrowLeft", "ArrowRight", "a", "d"], + "timeLimit": 3.0, + "successScene": "qte_success", + "failScene": "qte_fail", + "effects": { + "success": [{ "type": "add", "target": "courage", "value": 15 }], + "fail": [{ "type": "add", "target": "trust", "value": -20 }] + } + } + }, + "qte_success": { + "id": "qte_success", + "videoUrl": "/videos/qte_success.mp4", + "choices": [ + { + "text": "继续前进", + "targetScene": "continue_ending" + }, + { + "text": "回头", + "targetScene": "intro" + } + ] + }, + "qte_fail": { + "id": "qte_fail", + "videoUrl": "/videos/qte_fail.mp4", "choices": [ { "text": "继续前进", @@ -64,6 +95,7 @@ "stay": { "id": "stay", "videoUrl": "/videos/stay.mp4", + "subtitleUrl": "/subtitles/stay.vtt", "nextScene": "alone_ending" }, "trust_ending": { diff --git a/public/subtitles/intro.vtt b/public/subtitles/intro.vtt new file mode 100644 index 0000000..421d01d --- /dev/null +++ b/public/subtitles/intro.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.000 --> 00:02.000 +你醒来发现自己在一个陌生的房间 + +00:02.500 --> 00:03.000 +前方有两扇门,你必须做出选择 diff --git a/public/subtitles/left_door.vtt b/public/subtitles/left_door.vtt new file mode 100644 index 0000000..a453522 --- /dev/null +++ b/public/subtitles/left_door.vtt @@ -0,0 +1,7 @@ +WEBVTT + +00:00.000 --> 00:02.500 +你走进了发光的门,来到一个明亮的大厅 + +00:02.500 --> 00:03.000 +一位陌生人向你伸出了手 diff --git a/public/subtitles/stay.vtt b/public/subtitles/stay.vtt new file mode 100644 index 0000000..0aaf9de --- /dev/null +++ b/public/subtitles/stay.vtt @@ -0,0 +1,4 @@ +WEBVTT + +00:00.000 --> 00:03.000 +你选择留在原地,时间缓缓流逝... diff --git a/public/videos/qte_fail.mp4 b/public/videos/qte_fail.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..f920d3d6a7776db2d56a36b6e7743075c658b8d3 GIT binary patch literal 12805 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 literal 0 HcmV?d00001 diff --git a/public/videos/qte_success.mp4 b/public/videos/qte_success.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..3fc6fef4beba7264591e59a790d7364a0bb526ba GIT binary patch literal 17920 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 CSLUq% literal 0 HcmV?d00001 diff --git a/src/App.vue b/src/App.vue index 2605ef1..cd5f552 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,8 @@ import { ref } from 'vue' import GamePlayer from '@/components/GamePlayer.vue' import ChoicePanel from '@/components/ChoicePanel.vue' +import QTEOverlay from '@/components/QTEOverlay.vue' +import Subtitles from '@/components/Subtitles.vue' import SaveLoadMenu from '@/components/SaveLoadMenu.vue' import { useGameEngine } from '@/composables/useGameEngine' import { useGameStore } from '@/stores/gameStore' @@ -67,7 +69,20 @@ init()