import { ActionExecutor } from './actions.js'; import { SeededRNG } from './rng.js'; import { ResolutionEngine } from './resolution.js'; import { CadenceSubsystem } from './cadence.js'; import { ScenarioDirector } from './scenario.js'; export class CommonGrammarPerformance { constructor(record, rootSeed, options = {}) { this.record = record; this.rootSeed = rootSeed; this.rng = new SeededRNG(rootSeed); this.diagnostics = options.diagnostics; this.onUpdate = options.onUpdate ?? (() => {}); // Called once per animation frame, after any logical ticks. A frame draws // the most recent tick's state and advances nothing (9.1, 18.2). this.onFrame = options.onFrame ?? (() => {}); // Called once per logical tick, with the fixed step of 9.1. Procedural // motion advances here and nowhere else. this.onTick = options.onTick ?? (() => {}); this.engine = new ResolutionEngine(record.document, this.rng, { diagnostics: this.diagnostics, initialParameters: options.initialParameters, onParameterChange: options.onParameterChange }); this.actions = new ActionExecutor(this.engine, { diagnostics: this.diagnostics }); this.actions.audio = options.audio ?? null; if (this.actions.audio) this.actions.audio.useLogicalClock = true; this.scenarios = new ScenarioDirector({ document: record.document, resolution: this.engine, actions: this.actions, rng: this.rng, diagnostics: this.diagnostics, onTrace: options.onScenarioTrace }); this.cadence = new CadenceSubsystem({ document: record.document, rng: this.rng, resolution: this.engine, audio: options.audio ?? null, diagnostics: this.diagnostics }); this.state = 'prepared'; this.resources = new Set(); this.frameRequest = null; this.lastFrame = undefined; this.accumulator = 0; this.pauseReasons = new Set(); this.boundFrame = (now) => this.frame(now); this.boundPointer = (event) => { this.engine.signals.set('signals.pointer.x', event.clientX); this.engine.signals.set('signals.pointer.y', event.clientY); this.engine.invalidate(); }; this.boundResize = () => { this.engine.signals.set('signals.viewport.width', globalThis.innerWidth ?? 0); this.engine.signals.set('signals.viewport.height', globalThis.innerHeight ?? 0); this.engine.invalidate(); }; } async activate() { if (this.state !== 'prepared') throw new Error(`Cannot activate a performance in state ${this.state}.`); this.state = 'active'; this.onUpdate(this.engine); if (typeof globalThis.addEventListener === 'function') { globalThis.addEventListener('pointermove', this.boundPointer, { passive: true }); globalThis.addEventListener('resize', this.boundResize, { passive: true }); } if (typeof globalThis.requestAnimationFrame === 'function') this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); } frame(now) { if (this.state !== 'active') return; if (this.lastFrame === undefined) this.lastFrame = now; const observed = Math.max(0, now - this.lastFrame); this.lastFrame = now; const accepted = Math.min(observed, 250); if (this.pauseReasons.size) { this.onFrame(this.engine); this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); return; } this.accumulator += accepted; const step = 1000 / 60; let ticks = 0; while (this.accumulator + 1e-9 >= step && ticks < 8) { this.tick(); this.accumulator -= step; ticks += 1; } if (observed > 250 || this.accumulator >= step) { this.accumulator = Math.min(this.accumulator, step - 1e-9); this.diagnostics?.warn('WARN_CLOCK_STALL', 'Discarded excess elapsed time to preserve the fixed-step work bound.', { exhibitId: this.record.id, section: 'scheduler' }); } if (ticks > 0) this.onUpdate(this.engine); this.onFrame(this.engine); this.frameRequest = globalThis.requestAnimationFrame(this.boundFrame); } setAudio(audio) { this.actions.audio = audio; if (audio) audio.useLogicalClock = true; this.cadence.setAudio(audio); } tick() { if (this.pauseReasons.size || this.scenarios.disposed) return; const step = 1000 / 60; this.actions.resetBudget(); this.engine.advance(step); this.actions.audio?.advanceLogical?.(); this.cadence.advance(step); this.scenarios.update(); this.onTick(step, this.engine); } // Development acceleration executes the same fixed ticks without rendering. advanceTicks(count) { if (!Number.isSafeInteger(count) || count < 0) throw new RangeError('Tick count must be a nonnegative safe integer.'); for (let i = 0; i < count; i++) this.tick(); } pause(reason = 'user') { this.pauseReasons.add(reason); this.lastFrame = undefined; this.accumulator = 0; } resume(reason = 'user') { this.pauseReasons.delete(reason); this.lastFrame = undefined; this.accumulator = 0; } setParameter(id, value) { const result = this.engine.setParameter(id, value); this.onUpdate(this.engine); return result; } execute(actionArray, context) { const result = this.actions.execute(actionArray, context); this.onUpdate(this.engine); return result; } releaseOverride(id) { const released = this.engine.overrides.beginRelease(id); this.engine.resolveAll(); this.onUpdate(this.engine); return released; } async deactivate() { this.scenarios.dispose(); if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest); this.frameRequest = null; this.lastFrame = undefined; if (typeof globalThis.removeEventListener === 'function') { globalThis.removeEventListener('pointermove', this.boundPointer); globalThis.removeEventListener('resize', this.boundResize); } if (this.state === 'active') this.state = 'inactive'; this.engine.overrides.clear(); this.resources.clear(); } async dispose() { this.scenarios.dispose(); if (this.frameRequest !== null && typeof globalThis.cancelAnimationFrame === 'function') globalThis.cancelAnimationFrame(this.frameRequest); this.frameRequest = null; if (typeof globalThis.removeEventListener === 'function') { globalThis.removeEventListener('pointermove', this.boundPointer); globalThis.removeEventListener('resize', this.boundResize); } this.engine.dispose(); this.resources.clear(); this.state = 'disposed'; } }