import type { SceneNode, Choice, EngineEvent, Hotspot, ChapterInfo } from '../types' import { SceneManager } from './SceneManager' import { VideoManager } from './VideoManager' import { StateManager } from './StateManager' import { ChoiceSystem } from '../systems/ChoiceSystem' import { QTESystem } from '../systems/QTESystem' import { AudioSystem } from '../systems/AudioSystem' type EventHandler = (...args: any[]) => void export class Engine { sceneManager: SceneManager videoManager: VideoManager stateManager: StateManager choiceSystem: ChoiceSystem qteSystem: QTESystem audioSystem: AudioSystem private currentScene: SceneNode | null = null private events: Map> = new Map() private ended = false private isInitialScene = true private qteTriggered = false private qteResolved = false private justCameFromImage = false private loopActive = false private onUnlockChapter: ((chapterId: string) => void) | null = null private onMarkWatched: ((sceneId: string) => void) | null = null setChapterUnlockHandler(handler: (chapterId: string) => void) { this.onUnlockChapter = handler } setMarkWatchedHandler(handler: (sceneId: string) => void) { this.onMarkWatched = handler } constructor() { this.sceneManager = new SceneManager() this.videoManager = new VideoManager() this.stateManager = new StateManager() this.choiceSystem = new ChoiceSystem() this.qteSystem = new QTESystem() this.audioSystem = new AudioSystem() this.videoManager.onTimeUpdate(this.onTimeUpdate) } on(event: EngineEvent, handler: EventHandler) { if (!this.events.has(event)) this.events.set(event, new Set()) this.events.get(event)!.add(handler) } off(event: EngineEvent, handler: EventHandler) { this.events.get(event)?.delete(handler) } private emit(event: EngineEvent, ...args: any[]) { this.events.get(event)?.forEach((h) => h(...args)) } start() { this.ended = false this.isInitialScene = true const startScene = this.sceneManager.getStartScene() this.goToScene(startScene) } private goToScene(scene: SceneNode) { const chapter = this.sceneManager.getChapterBySceneId(scene.id) if (chapter) { this.onUnlockChapter?.(chapter.id) this.emit('chapterUnlock', chapter) } if (scene.onEnter) { this.stateManager.apply(scene.onEnter) } if (scene.videoMuted) { this.videoManager.setMuted(true) } else { this.videoManager.setMuted(false) } const bgmUrl = scene.bgmUrl || null if (bgmUrl) { this.audioSystem.play( bgmUrl, scene.bgmVolume ?? 0.8, scene.bgmCrossFade ?? 2.0, scene.bgmDuckLevel, scene.bgmDuckFade, ) } else { this.audioSystem.stop(scene.bgmCrossFade ?? 2.0) } this.enterScene(scene) } private enterScene(scene: SceneNode) { this.currentScene = scene this.qteTriggered = false this.qteResolved = false this.loopActive = false if (scene.type === 'image') { this.justCameFromImage = true this.isInitialScene = false this.emit('sceneChange', scene) const visible = this.getVisibleHotspots(scene) if (visible.length > 0) { this.emit('hotspotRequest', visible) } return } this.emit('sceneChange', scene) this.checkHotspotTime(scene, 0) const preloadUrls = this.sceneManager.getCandidateUrls( scene, (conds) => conds ? this.stateManager.evaluate(conds) : true ) this.videoManager.onEnd(() => { if (!this.qteResolved) { this.emit('videoEnd', scene) this.onVideoEnd(scene) } }) const activeEl = this.videoManager.getActiveVideoElement() activeEl?.pause() if (this.justCameFromImage) { this.justCameFromImage = false if (activeEl) { activeEl.style.opacity = '0' activeEl.style.transition = 'none' } } if (this.isInitialScene) { this.isInitialScene = false this.videoManager.playInitial(scene.videoUrl, preloadUrls) } else { this.videoManager.switchTo(scene.videoUrl, preloadUrls) } } private onTimeUpdate = (time: number) => { const scene = this.currentScene if (!scene) return this.checkHotspotTime(scene, time) this.checkLoop(time) // QTE check after loop check, so loop doesn't interfere with QTE if (!scene.qte || this.qteTriggered) return if (time >= scene.qte.triggerTime) { this.qteTriggered = true const qte = scene.qte this.emit('qteTrigger', qte) this.audioSystem.duckOn('qte') this.qteSystem.trigger( qte, (remaining, total) => { this.emit('qteTimer', { remaining, total }) }, (success) => { this.qteResolved = true this.audioSystem.duckOff('qte') 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 checkLoop(time: number) { const scene = this.currentScene if (!scene?.loopStart) return if (!this.loopActive && time >= scene.loopStart) { this.loopActive = true const validChoices = this.getValidChoices(scene) if (validChoices.length > 0) { this.emit('choiceRequest', validChoices) this.audioSystem.duckOn('choice') this.choiceSystem.start( validChoices, (timerState) => { this.emit('choiceTimer', timerState) }, (defaultChoice) => { this.emit('choiceTimeout', defaultChoice) this.makeChoice(defaultChoice) } ) } } if (this.loopActive && scene.loopEnd && time >= scene.loopEnd) { this.videoManager.seekTo(scene.loopStart) } } private checkHotspotTime(scene: SceneNode, time: number) { if (!scene.hotspots || scene.hotspots.length === 0) return const visible = scene.hotspots.filter((hs) => { if (hs.conditions && !this.stateManager.evaluate(hs.conditions)) return false if (hs.showAt !== undefined && time < hs.showAt) return false if (hs.hideAt !== undefined && time >= hs.hideAt) return false return true }) this.emit('hotspotUpdate', visible) if (visible.length > 0) { this.audioSystem.duckOn('hotspot') } else { this.audioSystem.duckOff('hotspot') } } getVisibleHotspots(scene: SceneNode): Hotspot[] { if (!scene.hotspots) return [] return scene.hotspots.filter((hs) => { if (hs.conditions && !this.stateManager.evaluate(hs.conditions)) return false return true }) } clickHotspot(hotspot: Hotspot) { if (!this.currentScene) return this.audioSystem.duckOff('hotspot') if (hotspot.effects) { this.stateManager.apply(hotspot.effects) } this.stateManager.recordChoice({ sceneId: this.currentScene.id, choiceIndex: -1, choiceText: hotspot.label, }) const next = this.sceneManager.getScene(hotspot.targetScene) if (next) { this.goToScene(next) } else { this.endGame() } } private onVideoEnd(scene: SceneNode) { this.onMarkWatched?.(scene.id) if (this.loopActive) return const validChoices = this.getValidChoices(scene) if (validChoices.length > 0) { this.emit('choiceRequest', validChoices) this.audioSystem.duckOn('choice') this.choiceSystem.start( validChoices, (timerState) => { this.emit('choiceTimer', timerState) }, (defaultChoice) => { this.audioSystem.duckOff('choice') this.emit('choiceTimeout', defaultChoice) this.makeChoice(defaultChoice) } ) } else if (scene.nextScene) { const next = this.sceneManager.getScene(scene.nextScene) if (next) { this.goToScene(next) } else { this.endGame() } } else if (scene.hotspots?.length) { return } else { this.endGame() } } getValidChoices(scene: SceneNode): Choice[] { if (!scene.choices) return [] return scene.choices.filter((c) => !c.conditions || this.stateManager.evaluate(c.conditions) ) } makeChoice(choice: Choice) { if (!this.currentScene) return this.audioSystem.duckOff('choice') if (choice.effects) { this.stateManager.apply(choice.effects) } this.stateManager.recordChoice({ sceneId: this.currentScene.id, choiceIndex: this.currentScene.choices?.indexOf(choice) ?? -1, choiceText: choice.text, }) const next = this.sceneManager.getScene(choice.targetScene) if (next) { this.goToScene(next) } else { this.endGame() } } endGame() { this.ended = true this.loopActive = false this.qteSystem.cancel() this.audioSystem.stop(2.0) this.emit('gameEnd') } skipCurrentScene() { const scene = this.currentScene if (!scene) return this.videoManager.getActiveVideoElement()?.pause() this.onVideoEnd(scene) } startChapter(chapterId: string) { const chapter = this.sceneManager.getChapter(chapterId) if (!chapter) return const scene = this.sceneManager.getScene(chapter.startScene) if (!scene) return const defaultVars = chapter.defaultVariables if (defaultVars) { this.stateManager.variables = { ...defaultVars } } else { this.stateManager.init(this.sceneManager.chapters.length > 0 ? {} // from chapters, use the chapter's defaultVariables or empty : {}) } this.stateManager.flags = new Set() this.stateManager.history = [] this.ended = false this.isInitialScene = false this.goToScene(scene) } resumeScene(sceneId: string, savedState: { variables: Record; flags: string[]; history: any[] }) { this.stateManager.variables = { ...savedState.variables } this.stateManager.flags = new Set(savedState.flags) this.stateManager.history = [...savedState.history] const scene = this.sceneManager.getScene(sceneId) if (!scene) { this.endGame() return } this.ended = false this.isInitialScene = false this.enterScene(scene) } destroy() { this.qteSystem.destroy() this.audioSystem.destroy() this.videoManager.detach() this.events.clear() } }