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:
75
engine/systems/QTESystem.ts
Normal file
75
engine/systems/QTESystem.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user