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
This commit is contained in:
2026-06-07 19:35:14 +08:00
parent c168e30e52
commit 319a379921
18 changed files with 625 additions and 53 deletions

View File

@@ -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<typeof setInterval> | null = null
private timeoutId: ReturnType<typeof setTimeout> | 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()
}
}

View File

@@ -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<SaveData, 'slot' | 'thumbnail'>): Promise<void> {
async save(slot: number, data: Omit<SaveData, 'slot'>): Promise<void> {
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,
}))
}