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 0000000..f920d3d Binary files /dev/null and b/public/videos/qte_fail.mp4 differ diff --git a/public/videos/qte_success.mp4 b/public/videos/qte_success.mp4 new file mode 100644 index 0000000..3fc6fef Binary files /dev/null and b/public/videos/qte_success.mp4 differ 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()