clone

a browser grid puzzle game where you push words around to rewrite the rules of each level

Add presentation slow motion review clock

Dami committed b7cc9e5
commit
b7cc9e511f7e8b9d56e40b7848b545d78e593ddf
parent
1981ad96212daa3008d63fed63b10dd7a4b77eeb
author
Dami <dami.etoile@gmail.com>
2 files changed, +245 -0
Asrc/presentation-clock.js154++++++++++++++++++++++++++++++
Atests/presentation-clock.test.js91++++++++++++++++++
A src/presentation-clock.js +154 -0
@@ -0,0 +1,154 @@
1+/**2+ * A review clock for presentation work.3+ *4+ * `?slowmo=N` stretches the visual timeline without changing the engine's5+ * turn cadence. Consumers that own simulation or input timing should use6+ * `realNow()` and the native scheduling functions exposed by the clock.7+ * Presentation code can use the virtual functions, or install the clock to8+ * adapt the browser globals before the stage starts.9+ */10+11+const DEFAULT_SLOWMO = 1;12+const MAX_SLOWMO = 1000;13+14+function finitePositive(value, fallback = DEFAULT_SLOWMO) {15+ const number = Number(value);16+ return Number.isFinite(number) && number > 0 ? number : fallback;17+}18+19+/** Read and clamp the review multiplier from a location search string. */20+export function parseSlowmo(search = globalThis.location?.search || '') {21+ const raw = new URLSearchParams(search).get('slowmo');22+ if (raw === null || raw.trim() === '') return DEFAULT_SLOWMO;23+ return Math.min(MAX_SLOWMO, Math.max(DEFAULT_SLOWMO, finitePositive(raw)));24+}25+26+function browserNow() {27+ return typeof performance?.now === 'function' ? performance.now() : Date.now();28+}29+30+function browserRaf(callback) {31+ if (typeof requestAnimationFrame === 'function') return requestAnimationFrame(callback);32+ return setTimeout(() => callback(browserNow()), 16);33+}34+35+function browserCancelRaf(handle) {36+ if (typeof cancelAnimationFrame === 'function') cancelAnimationFrame(handle);37+ else clearTimeout(handle);38+}39+40+/**41+ * Create an isolated presentation clock.42+ *43+ * The virtual timeline has the same origin as the raw clock, then advances at44+ * `1 / slowmo` speed. This keeps existing animation code's timestamps valid45+ * while ensuring a frame callback and a visual timeout observe one timeline.46+ */47+export function createPresentationClock({48+ slowmo = DEFAULT_SLOWMO,49+ now = browserNow,50+ raf = browserRaf,51+ cancelRaf = browserCancelRaf,52+ setTimeoutFn = globalThis.setTimeout?.bind(globalThis),53+ clearTimeoutFn = globalThis.clearTimeout?.bind(globalThis),54+ setIntervalFn = globalThis.setInterval?.bind(globalThis),55+ clearIntervalFn = globalThis.clearInterval?.bind(globalThis),56+} = {}) {57+ const factor = Math.min(MAX_SLOWMO, Math.max(DEFAULT_SLOWMO, finitePositive(slowmo)));58+ const realStart = now();59+ const virtualStart = realStart;60+ const virtualNow = () => virtualStart + (now() - realStart) / factor;61+ const delay = value => Math.max(0, finitePositive(value, 0) * factor);62+63+ const clock = {64+ slowmo: factor,65+ now: virtualNow,66+ realNow: now,67+ requestAnimationFrame(callback) {68+ if (typeof callback !== 'function') throw new TypeError('requestAnimationFrame callback must be a function');69+ return raf(() => callback(virtualNow()));70+ },71+ cancelAnimationFrame: cancelRaf,72+ setTimeout(callback, milliseconds = 0, ...args) {73+ if (typeof setTimeoutFn !== 'function') throw new Error('setTimeout is unavailable');74+ return setTimeoutFn(callback, delay(milliseconds), ...args);75+ },76+ clearTimeout: clearTimeoutFn,77+ setInterval(callback, milliseconds = 0, ...args) {78+ if (typeof setIntervalFn !== 'function') throw new Error('setInterval is unavailable');79+ return setIntervalFn(callback, delay(milliseconds), ...args);80+ },81+ clearInterval: clearIntervalFn,82+ native: Object.freeze({83+ now,84+ requestAnimationFrame: raf,85+ cancelAnimationFrame: cancelRaf,86+ setTimeout: setTimeoutFn,87+ clearTimeout: clearTimeoutFn,88+ setInterval: setIntervalFn,89+ clearInterval: clearIntervalFn,90+ }),91+ };92+ return Object.freeze(clock);93+}94+95+function replace(target, name, value, restore) {96+ const descriptor = Object.getOwnPropertyDescriptor(target, name);97+ const previous = target[name];98+ try {99+ target[name] = value;100+ if (target[name] !== value) throw new TypeError(`Cannot install ${name}`);101+ restore.push(() => {102+ if (descriptor) Object.defineProperty(target, name, descriptor);103+ else target[name] = previous;104+ });105+ } catch {106+ // A host may expose a read-only browser primitive. The isolated clock is107+ // still useful to callers, so leave that primitive unchanged.108+ }109+}110+111+/**112+ * Install the review clock on a browser-like global and return a restore hook.113+ * The returned clock always exposes raw timing for input/simulation callers.114+ */115+export function installPresentationClock({ target = globalThis, search, slowmo } = {}) {116+ const performanceTarget = target.performance;117+ const rawNow = typeof performanceTarget?.now === 'function'118+ ? performanceTarget.now.bind(performanceTarget)119+ : () => Date.now();120+ const rawRaf = typeof target.requestAnimationFrame === 'function'121+ ? target.requestAnimationFrame.bind(target)122+ : callback => target.setTimeout(() => callback(rawNow()), 16);123+ const rawCancelRaf = typeof target.cancelAnimationFrame === 'function'124+ ? target.cancelAnimationFrame.bind(target)125+ : handle => target.clearTimeout(handle);126+ const clock = createPresentationClock({127+ slowmo: slowmo ?? parseSlowmo(search),128+ now: rawNow,129+ raf: rawRaf,130+ cancelRaf: rawCancelRaf,131+ setTimeoutFn: typeof target.setTimeout === 'function' ? target.setTimeout.bind(target) : undefined,132+ clearTimeoutFn: typeof target.clearTimeout === 'function' ? target.clearTimeout.bind(target) : undefined,133+ setIntervalFn: typeof target.setInterval === 'function' ? target.setInterval.bind(target) : undefined,134+ clearIntervalFn: typeof target.clearInterval === 'function' ? target.clearInterval.bind(target) : undefined,135+ });136+ const restore = [];137+ if (performanceTarget && typeof performanceTarget.now === 'function')138+ replace(performanceTarget, 'now', clock.now, restore);139+ replace(target, 'requestAnimationFrame', clock.requestAnimationFrame, restore);140+ if (typeof target.cancelAnimationFrame === 'function') replace(target, 'cancelAnimationFrame', clock.cancelAnimationFrame, restore);141+ replace(target, 'setTimeout', clock.setTimeout, restore);142+ if (typeof target.clearTimeout === 'function') replace(target, 'clearTimeout', clock.clearTimeout, restore);143+ if (typeof target.setInterval === 'function') replace(target, 'setInterval', clock.setInterval, restore);144+ if (typeof target.clearInterval === 'function') replace(target, 'clearInterval', clock.clearInterval, restore);145+146+ return Object.freeze({147+ clock,148+ restore() {149+ for (const undo of restore.splice(0).reverse()) undo();150+ },151+ });152+}153+154+export const PRESENTATION_CLOCK = Object.freeze({ DEFAULT_SLOWMO, MAX_SLOWMO });
A tests/presentation-clock.test.js +91 -0
@@ -0,0 +1,91 @@
1+import test from 'node:test';2+import assert from 'node:assert/strict';3+import {4+ createPresentationClock,5+ installPresentationClock,6+ parseSlowmo,7+} from '../src/presentation-clock.js';8+9+test('parseSlowmo accepts positive review multipliers and clamps unsafe values', () => {10+ assert.equal(parseSlowmo(''), 1);11+ assert.equal(parseSlowmo('?slowmo=8'), 8);12+ assert.equal(parseSlowmo('?slowmo=0'), 1);13+ assert.equal(parseSlowmo('?slowmo=-2'), 1);14+ assert.equal(parseSlowmo('?slowmo=999999'), 1000);15+ assert.equal(parseSlowmo('?slowmo=nope'), 1);16+});17+18+test('presentation clock slows virtual now and RAF timestamps together', () => {19+ let raw = 100;20+ let rafCallback;21+ const clock = createPresentationClock({22+ slowmo: 4,23+ now: () => raw,24+ raf: callback => { rafCallback = callback; return 17; },25+ cancelRaf: () => {},26+ setTimeoutFn: () => 1,27+ clearTimeoutFn: () => {},28+ setIntervalFn: () => 2,29+ clearIntervalFn: () => {},30+ });31+32+ raw = 500;33+ assert.equal(clock.realNow(), 500);34+ assert.equal(clock.now(), 200);35+ let frame;36+ assert.equal(clock.requestAnimationFrame(value => { frame = value; }), 17);37+ rafCallback();38+ assert.equal(frame, 200);39+});40+41+test('presentation timers stretch delays while native timer APIs stay available', () => {42+ const timeouts = [];43+ const intervals = [];44+ const clock = createPresentationClock({45+ slowmo: 8,46+ now: () => 0,47+ raf: callback => { callback(); return 1; },48+ cancelRaf: () => {},49+ setTimeoutFn: (callback, delay, ...args) => { timeouts.push({ callback, delay, args }); return 3; },50+ clearTimeoutFn: handle => { assert.equal(handle, 3); },51+ setIntervalFn: (callback, delay, ...args) => { intervals.push({ callback, delay, args }); return 4; },52+ clearIntervalFn: handle => { assert.equal(handle, 4); },53+ });54+ const callback = () => {};55+ assert.equal(clock.setTimeout(callback, 90, 'x'), 3);56+ assert.equal(timeouts[0].delay, 720);57+ assert.deepEqual(timeouts[0].args, ['x']);58+ assert.equal(clock.setInterval(callback, 110), 4);59+ assert.equal(intervals[0].delay, 880);60+ clock.clearTimeout(3);61+ clock.clearInterval(4);62+});63+64+test('installPresentationClock patches presentation globals and restores them', () => {65+ let raw = 10;66+ const scheduled = [];67+ const target = {68+ performance: { now: () => raw },69+ requestAnimationFrame(callback) { scheduled.push(callback); return 1; },70+ cancelAnimationFrame() {},71+ setTimeout(callback, delay) { scheduled.push({ callback, delay }); return 2; },72+ clearTimeout() {},73+ setInterval() { return 3; },74+ clearInterval() {},75+ };76+ const nativeNow = target.performance.now;77+ const nativeRaf = target.requestAnimationFrame;78+ const installed = installPresentationClock({ target, slowmo: 5 });79+ raw = 510;80+ assert.equal(target.performance.now(), 110);81+ let timestamp;82+ target.requestAnimationFrame(value => { timestamp = value; });83+ scheduled[0]();84+ assert.equal(timestamp, 110);85+ target.setTimeout(() => {}, 20);86+ assert.equal(scheduled[1].delay, 100);87+ assert.equal(installed.clock.realNow(), 510);88+ installed.restore();89+ assert.equal(target.performance.now, nativeNow);90+ assert.equal(target.requestAnimationFrame, nativeRaf);91+});