Files
tianshu-engine/engine/core/Engine.ts
2026-06-09 17:21:54 +08:00

418 lines
11 KiB
TypeScript

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'
import { AchievementSystem } from '../systems/AchievementSystem'
type EventHandler = (...args: any[]) => void
export class Engine {
sceneManager: SceneManager
videoManager: VideoManager
stateManager: StateManager
choiceSystem: ChoiceSystem
qteSystem: QTESystem
audioSystem: AudioSystem
achievementSystem: AchievementSystem
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
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.achievementSystem = new AchievementSystem()
this.stateManager.onAfterApply = (vars) => {
this.achievementSystem.check(vars)
}
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.qteTriggered || 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<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
this.enterScene(scene)
}
destroy() {
this.qteSystem.destroy()
this.audioSystem.destroy()
this.videoManager.detach()
this.events.clear()
}
}