/** * THE ANIMATION QUEUE — v0.8.0, `docs/plans/jitsi-common-board.md` § v0.8.0 §§ 4-6. * * Holds the public board the screen is currently showing, which is not always the board the game is * actually on. Steps arrive faster than a person can follow — a bot's whole switching turn lands in * ONE push, because `driveBots()` plays it out before the push goes back — so this is what turns a * burst into something watchable. TODO #13. * * TRANSPORT-AGNOSTIC ON PURPOSE. It takes `DisplayStep`s and does not care whether they came from * the engine in this tab or off an SSE stream, which is what lets solitaire (#18: phases that are * never drawn) and multiplayer (#13: turns you never see) run one implementation. Nothing here * imports the DOM either, so it is testable without one. * * NO TIMERS OF ITS OWN. The caller drives it with `advance(now)` from whatever loop it already has * — a `requestAnimationFrame`, a test's fake clock. A queue that owned a `setInterval` would need * starting, stopping and cleaning up on every game replacement, and would be untestable without * faking timers. */ import type { PublicFrame } from '../sim/view.ts'; import type { DisplayStep } from '../sim/display-step.ts'; import { applyPublicDelta } from '../sim/public-delta.ts'; import { dwellForStep } from '../sim/pacing.ts'; export type StepQueue = { /** Throw away what is queued and show this board — a first connect, a reconnect, an undo. */ reset(frame: PublicFrame): void; /** Queue steps to be shown in order. */ push(steps: readonly DisplayStep[]): void; /** * Show as much as `now` allows. Returns true if the displayed board changed, so a caller can skip * a redraw when nothing did. */ advance(now: number): boolean; /** Show everything immediately. Returns true if anything was skipped. */ skip(): boolean; /** The board to draw, or null before any reset has arrived. */ current(): PublicFrame | null; /** * How many queued steps the player is still going to WATCH — the number the "N behind" counter * shows. Not the queue length: see `watchableCount` in `sim/pacing.ts`. */ behind(): number; /** The last step actually shown, for the caption line (#15). Null before anything has been shown. */ showing(): DisplayStep | null; /** True while there is anything left to show. */ busy(): boolean; }; /** `pace` is read on every step rather than captured, so changing the setting takes effect at once. */ export function createStepQueue(pace: () => number = () => 1): StepQueue { let shown: PublicFrame | null = null; let last: DisplayStep | null = null; let pending: DisplayStep[] = []; /** When the step now on screen is due to give way. Null when nothing is waiting. */ let dueAt: number | null = null; /** Applies one step to the displayed board. A step's delta chains off the previous step's frame. */ const show = (step: DisplayStep): void => { shown = applyPublicDelta(shown, step.frame); last = step; }; return { reset(frame) { shown = frame; pending = []; dueAt = null; // `last` deliberately survives: a reconnect should not blank the caption line, and the // sentence describing the most recent action is still true. }, push(steps) { pending.push(...steps); }, advance(now) { if (pending.length === 0) { dueAt = null; return false; } // First step of a burst: show it immediately rather than waiting out a dwell for a board the // player has not been shown yet. if (dueAt === null) { const first = pending.shift()!; show(first); dueAt = now + dwellForStep(first, pace()); return true; } let drew = false; /** * A LOOP, not a single step. A dwell of zero means "do not spend the player's attention on * this" — bookkeeping, and phases where nothing happened (TODO #18) — so a run of them must * collapse within one call instead of costing a frame each. The board still passes through * every state in order; nobody is shown a state that never existed. */ while (pending.length > 0 && now >= dueAt) { const next = pending.shift()!; show(next); dueAt = dueAt + dwellForStep(next, pace()); drew = true; } if (pending.length === 0 && now >= dueAt) dueAt = null; return drew; }, skip() { if (pending.length === 0) return false; for (const step of pending) show(step); pending = []; dueAt = null; return true; }, current: () => shown, behind: () => pending.filter((s) => dwellForStep(s, pace()) > 0).length, showing: () => last, busy: () => pending.length > 0, }; }