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:
75
engine/systems/ChoiceSystem.ts
Normal file
75
engine/systems/ChoiceSystem.ts
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user