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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user