feat: chapter select system, multi-chapter support, scene manager refactor, and docs update

This commit is contained in:
2026-06-09 11:35:11 +08:00
parent 655b9a23d0
commit ace5ed1fb3
14 changed files with 413 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import type { SceneNode, Choice, EngineEvent, Hotspot } from '../types'
import type { SceneNode, Choice, EngineEvent, Hotspot, ChapterInfo } from '../types'
import { SceneManager } from './SceneManager'
import { VideoManager } from './VideoManager'
import { StateManager } from './StateManager'
@@ -24,6 +24,11 @@ export class Engine {
private qteResolved = false
private justCameFromImage = false
private loopActive = false
private onUnlockChapter: ((chapterId: string) => void) | null = null
setChapterUnlockHandler(handler: (chapterId: string) => void) {
this.onUnlockChapter = handler
}
constructor() {
this.sceneManager = new SceneManager()
@@ -62,6 +67,12 @@ export class Engine {
this.qteResolved = false
this.loopActive = false
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)
}
@@ -333,6 +344,29 @@ export class Engine {
this.emit('gameEnd')
}
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)

View File

@@ -1,12 +1,14 @@
import type { GameData, SceneNode, Choice, Condition } from '../types'
import type { GameData, SceneNode, ChapterInfo, Choice, Condition } from '../types'
export class SceneManager {
private scenes: Record<string, SceneNode> = {}
private startScene: string = ''
chapters: ChapterInfo[] = []
load(data: GameData) {
this.scenes = data.scenes
this.startScene = data.startScene
this.chapters = data.chapters || []
}
getScene(id: string): SceneNode | undefined {
@@ -23,6 +25,14 @@ export class SceneManager {
return Object.keys(this.scenes)
}
getChapterBySceneId(sceneId: string): ChapterInfo | undefined {
return this.chapters.find((ch) => ch.startScene === sceneId)
}
getChapter(chapterId: string): ChapterInfo | undefined {
return this.chapters.find((ch) => ch.id === chapterId)
}
getCandidateTargetIds(scene: SceneNode, evaluateCondition: (conds?: Condition[]) => boolean): string[] {
const targets: string[] = []

View File

@@ -12,13 +12,19 @@ interface SaveRecord {
thumbnail?: string
}
interface UnlockRecord {
chapterId: string
}
class SaveDB extends Dexie {
saves!: Table<SaveRecord, number>
unlocks!: Table<UnlockRecord, string>
constructor() {
super('MovieGameSaves')
this.version(2).stores({
this.version(3).stores({
saves: '++id, slot',
unlocks: 'chapterId',
})
}
}
@@ -73,4 +79,21 @@ export class SaveSystem {
async delete(slot: number): Promise<void> {
await db.saves.where('slot').equals(slot).delete()
}
async unlockChapter(chapterId: string) {
const exists = await db.unlocks.get(chapterId)
if (!exists) {
await db.unlocks.put({ chapterId })
}
}
async isChapterUnlocked(chapterId: string): Promise<boolean> {
const record = await db.unlocks.get(chapterId)
return !!record
}
async getUnlockedChapters(): Promise<string[]> {
const records = await db.unlocks.toArray()
return records.map((r) => r.chapterId)
}
}

View File

@@ -67,10 +67,19 @@ export interface QTEDefinition {
}
}
export interface ChapterInfo {
id: string
label: string
startScene: string
thumbnail?: string
defaultVariables?: Record<string, number>
}
export interface GameData {
scenes: Record<string, SceneNode>
startScene: string
variables: Record<string, number>
chapters?: ChapterInfo[]
}
export interface ChoiceRecord {
@@ -101,3 +110,4 @@ export type EngineEvent =
| 'choiceTimeout'
| 'hotspotRequest'
| 'hotspotUpdate'
| 'chapterUnlock'