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

@@ -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<EngineEvent, Set<EventHandler>> = 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()
}

View File

@@ -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
}