/** * 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. * * `viewer` says which seat is watching, so THIS PLAYER'S OWN MOVES COST NO TIME. They are already on * screen: a seated player's own board is drawn from their authoritative `Frame`, not from the queue, * so holding their click for a dwell shows them nothing and delays the thing they actually want to * watch — the 700ms before a bot's turn starts animating is 700ms of their own move being replayed * at them. The step is still APPLIED, because the delta chain runs through it. * * Automatic phases have no player and are unaffected, which is what keeps TODO #18 working in * solitaire where every intent is the viewer's own. */ export function createStepQueue( pace: () => number = () => 1, viewer: () => number | null = () => null, ): 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; /** How long this step holds the screen — zero for the viewer's own moves; see above. */ const dwell = (step: DisplayStep): number => step.player !== null && step.player === viewer() ? 0 : dwellForStep(step, pace()); /** 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) { // The LAST step of a burst still owes its dwell. Clearing `dueAt` here reported the queue // idle the instant that step was shown, which snapped the district panel home before anyone // could look at it — see `busy()`. if (dueAt !== null && now >= dueAt) 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 + dwell(first); 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 + dwell(next); 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) => dwell(s) > 0).length, showing: () => last, /** * STILL SHOWING SOMETHING, not just still holding something back. * * This was `pending.length > 0`, which went false the moment the last step of a burst was * shown — so the animation loop stopped and the district panel snapped back to the viewer's own * board without that step ever being visible. Reported from real play: "I briefly saw that it was * the bot's office area, then their turn was done and it pointed back to my office area." * * `dueAt` is non-null exactly while the step on screen has time left, so the two together mean * "there is more to come, or what is up has not had its moment yet". */ busy: () => pending.length > 0 || dueAt !== null, }; }