TODO #13, #15 and #18 — Gitea#20 steps 2-4 pointed at a seated player's own screen. Every accepted intent, and every automatic phase that does anything, becomes an ordered presentation step. A bot's whole switching turn used to land in one push; now it arrives as a run of steps, the district panel follows whoever is acting, and a [N behind] … [Skip] row says how far the board is from the game. Solitaire runs the same path — one collector inside submit(), which both session kinds already funnel through — which is where its automatic phases finally get a visible beat. Dwell is assigned by kind: switching holds the screen, turn bookkeeping costs nothing, and the clock turning over earns the beat. Tunable per viewer without a rebuild, and off entirely at pace 0. Also: switching was the one class of action logging unattributed, and now names its train. Reasoning, measurements and the three things that turned out wrong are in CHANGELOG.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01X6cF1iYvJ1kNmzYBzu4QX6
121 lines
4.7 KiB
TypeScript
121 lines
4.7 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|