feat: P1 core - seamless video switching, conditional branches, save/load

- 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
This commit is contained in:
2026-06-07 16:48:52 +08:00
parent 42181fe185
commit 937e45c203
16 changed files with 763 additions and 71 deletions

View File

@@ -0,0 +1,75 @@
import type { Choice } from '../types'
export interface ChoiceTimerState {
total: number
remaining: number
}
type TimerUpdateCallback = (state: ChoiceTimerState) => void
type TimeoutCallback = (choice: Choice) => void
export class ChoiceSystem {
private timerId: ReturnType<typeof setInterval> | null = null
private timeoutId: ReturnType<typeof setTimeout> | null = null
private timeLimit = 0
private elapsed = 0
private tickMs = 100
private onUpdate: TimerUpdateCallback | null = null
private onTimeout: TimeoutCallback | null = null
start(choices: Choice[], onUpdate: TimerUpdateCallback, onTimeout: TimeoutCallback) {
this.clear()
const timed = choices.filter((c) => c.timeLimit && c.timeLimit > 0)
if (timed.length === 0) return
const maxLimit = Math.max(...timed.map((c) => c.timeLimit!))
this.timeLimit = maxLimit
this.elapsed = 0
this.onUpdate = onUpdate
this.onTimeout = onTimeout
const state: ChoiceTimerState = { total: maxLimit, remaining: maxLimit }
this.onUpdate(state)
this.timerId = setInterval(() => {
this.elapsed += this.tickMs
const remaining = Math.max(0, this.timeLimit - this.elapsed) / 1000
const nextState: ChoiceTimerState = {
total: this.timeLimit / 1000,
remaining: Math.ceil(remaining * 10) / 10,
}
this.onUpdate?.(nextState)
}, this.tickMs)
this.timeoutId = setTimeout(() => {
this.clear()
// Pick the first choice as default on timeout
if (choices.length > 0) {
this.onTimeout?.(choices[0])
}
}, this.timeLimit)
}
stop() {
this.clear()
}
private clear() {
if (this.timerId !== null) {
clearInterval(this.timerId)
this.timerId = null
}
if (this.timeoutId !== null) {
clearTimeout(this.timeoutId)
this.timeoutId = null
}
}
destroy() {
this.clear()
this.onUpdate = null
this.onTimeout = null
}
}