345 lines
9.1 KiB
TypeScript
345 lines
9.1 KiB
TypeScript
import type { SceneNode, Choice, EngineEvent, Hotspot } from '../types'
|
|
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
|
|
|
|
export class Engine {
|
|
sceneManager: SceneManager
|
|
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
|
|
private justCameFromImage = false
|
|
private loopActive = false
|
|
|
|
constructor() {
|
|
this.sceneManager = new SceneManager()
|
|
this.videoManager = new VideoManager()
|
|
this.stateManager = new StateManager()
|
|
this.choiceSystem = new ChoiceSystem()
|
|
this.qteSystem = new QTESystem()
|
|
|
|
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) {
|
|
this.currentScene = scene
|
|
this.qteTriggered = false
|
|
this.qteResolved = false
|
|
this.loopActive = false
|
|
|
|
if (scene.onEnter) {
|
|
this.stateManager.apply(scene.onEnter)
|
|
}
|
|
|
|
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.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 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.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)
|
|
}
|
|
|
|
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
|
|
|
|
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) {
|
|
if (this.loopActive) return
|
|
|
|
const validChoices = this.getValidChoices(scene)
|
|
|
|
if (validChoices.length > 0) {
|
|
this.emit('choiceRequest', validChoices)
|
|
|
|
this.choiceSystem.start(
|
|
validChoices,
|
|
(timerState) => {
|
|
this.emit('choiceTimer', timerState)
|
|
},
|
|
(defaultChoice) => {
|
|
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
|
|
|
|
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.emit('gameEnd')
|
|
}
|
|
|
|
resumeScene(sceneId: string, savedState: { variables: Record<string, number>; 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
|
|
|
|
if (scene.type === 'image') {
|
|
this.emit('sceneChange', scene)
|
|
const visible = this.getVisibleHotspots(scene)
|
|
if (visible.length > 0) {
|
|
this.emit('hotspotRequest', visible)
|
|
}
|
|
return
|
|
}
|
|
|
|
const preloadUrls = this.sceneManager.getCandidateUrls(
|
|
scene,
|
|
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
|
)
|
|
|
|
this.videoManager.onEnd(() => {
|
|
this.emit('videoEnd', scene)
|
|
this.onVideoEnd(scene)
|
|
})
|
|
|
|
const activeEl = this.videoManager.getActiveVideoElement()
|
|
activeEl?.pause()
|
|
|
|
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
|
|
|
|
this.currentScene = scene
|
|
this.emit('sceneChange', scene)
|
|
}
|
|
|
|
destroy() {
|
|
this.qteSystem.destroy()
|
|
this.videoManager.detach()
|
|
this.events.clear()
|
|
}
|
|
}
|