- VideoManager: A/B dual-buffered video with crossfade transitions and candidate preloading - Engine: condition-based choice filtering, ChoiceSystem timer, resumeScene for save/load - SceneManager: getCandidateUrls for preloading next scenes - SaveSystem: Dexie.js IndexedDB multi-slot save/load - ChoiceSystem: timed choices with countdown and auto-default on timeout - GamePlayer: dual video elements with crossfade CSS - ChoicePanel: timer progress bar and countdown text - SaveLoadMenu: save/load UI component - App.vue: menu trigger, dual video refs, save/load integration - gameStore: timer state, saves list - demo.json: conditional choice example (secret ending, requires trust >= 80) - ROADMAP: mark P1 as completed
170 lines
4.3 KiB
TypeScript
170 lines
4.3 KiB
TypeScript
import type { SceneNode, Choice, EngineEvent } from '../types'
|
|
import { SceneManager } from './SceneManager'
|
|
import { VideoManager } from './VideoManager'
|
|
import { StateManager } from './StateManager'
|
|
import { ChoiceSystem } from '../systems/ChoiceSystem'
|
|
|
|
type EventHandler = (...args: any[]) => void
|
|
|
|
export class Engine {
|
|
sceneManager: SceneManager
|
|
videoManager: VideoManager
|
|
stateManager: StateManager
|
|
choiceSystem: ChoiceSystem
|
|
|
|
private currentScene: SceneNode | null = null
|
|
private events: Map<EngineEvent, Set<EventHandler>> = new Map()
|
|
private ended = false
|
|
private isInitialScene = true
|
|
|
|
constructor() {
|
|
this.sceneManager = new SceneManager()
|
|
this.videoManager = new VideoManager()
|
|
this.stateManager = new StateManager()
|
|
this.choiceSystem = new ChoiceSystem()
|
|
}
|
|
|
|
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
|
|
|
|
if (scene.onEnter) {
|
|
this.stateManager.apply(scene.onEnter)
|
|
}
|
|
|
|
const preloadUrls = this.sceneManager.getCandidateUrls(
|
|
scene,
|
|
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
|
)
|
|
|
|
this.videoManager.onEnd(() => {
|
|
this.emit('videoEnd', scene)
|
|
this.onVideoEnd(scene)
|
|
})
|
|
|
|
if (this.isInitialScene) {
|
|
this.isInitialScene = false
|
|
this.videoManager.playInitial(scene.videoUrl, preloadUrls)
|
|
} else {
|
|
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
|
|
}
|
|
|
|
this.emit('sceneChange', scene)
|
|
}
|
|
|
|
private onVideoEnd(scene: SceneNode) {
|
|
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 {
|
|
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.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
|
|
|
|
const preloadUrls = this.sceneManager.getCandidateUrls(
|
|
scene,
|
|
(conds) => conds ? this.stateManager.evaluate(conds) : true
|
|
)
|
|
|
|
this.videoManager.switchTo(scene.videoUrl, preloadUrls)
|
|
|
|
this.videoManager.onEnd(() => {
|
|
this.emit('videoEnd', scene)
|
|
this.onVideoEnd(scene)
|
|
})
|
|
|
|
this.currentScene = scene
|
|
this.emit('sceneChange', scene)
|
|
}
|
|
|
|
destroy() {
|
|
this.videoManager.detach()
|
|
this.events.clear()
|
|
}
|
|
}
|