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
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -69,5 +69,7 @@ export type EngineEvent =
|
||||
| 'choiceTimer'
|
||||
| 'gameEnd'
|
||||
| 'qteTrigger'
|
||||
| 'qteTimer'
|
||||
| 'qteResult'
|
||||
| 'videoEnd'
|
||||
| 'choiceTimeout'
|
||||
|
||||
Reference in New Issue
Block a user