import { Logger } from '../util/log'; import { FpsSampler } from './fps'; export type ScheduledCallbackTiming = 'preframe' | 'postframe' | 'preupdate' | 'postupdate' | 'predraw' | 'postdraw'; /** * Unique identifier for a scheduled callback */ export type ScheduleId = number; export interface ClockOptions { /** * Define the function you'd like the clock to tick when it is started */ tick: (elapsed: number) => any; /** * Optionally define the fatal exception handler, used if an error is thrown in tick */ onFatalException?: (e: unknown) => any; /** * Optionally limit the maximum FPS of the clock */ maxFps?: number; } /** * Abstract Clock is the base type of all Clocks * * It has a few opinions * 1. It manages the calculation of what "elapsed" time means and thus maximum fps * 2. The default timing api is implemented in now() * * To implement your own clock, extend Clock and override start/stop to start and stop the clock, then call update() with whatever * method is unique to your clock implementation. */ export abstract class Clock { protected tick: (elapsed: number) => any; private _onFatalException: (e: unknown) => any = () => { /* default nothing */ }; private _maxFps: number = Infinity; private _lastTime: number = 0; public fpsSampler: FpsSampler; private _options: ClockOptions; private _elapsed: number = 1; private _scheduledCbs: [id: ScheduleId, cb: (elapsed: number) => any, scheduledTime: number, timing: ScheduledCallbackTiming][] = []; private _totalElapsed: number = 0; private _nextScheduleId: ScheduleId = 0; constructor(options: ClockOptions) { this._options = options; this.tick = options.tick; this._lastTime = this.now() ?? 0; this._maxFps = options.maxFps ?? this._maxFps; this._onFatalException = options.onFatalException ?? this._onFatalException; this.fpsSampler = new FpsSampler({ initialFps: 60, nowFn: () => this.now() }); } /** * Get the elapsed time for the last completed frame */ public elapsed(): number { return this._elapsed; } /** * Get the current time in milliseconds */ public now(): number { return performance.now(); } public toTestClock() { const testClock = new TestClock({ ...this._options, defaultUpdateMs: 16.6 }); return testClock; } public toStandardClock() { const clock = new StandardClock({ ...this._options }); return clock; } public setFatalExceptionHandler(handler: (e: unknown) => any) { this._onFatalException = handler; } /** * Schedule a callback to fire given a timeout in milliseconds using the excalibur {@apilink Clock} * * This is useful to use over the built in browser `setTimeout` because callbacks will be tied to the * excalibur update clock, instead of browser time, this means that callbacks wont fire if the game is * stopped or paused. * @param cb callback to fire * @param timeoutMs Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick * @param timing Optionally specify a timeout in milliseconds from now, default is 0ms which means the next possible tick * @returns A unique identifier that can be used to clear the scheduled callback with {@apilink clearSchedule} */ public schedule(cb: (elapsed: number) => any, timeoutMs: number = 0, timing: ScheduledCallbackTiming = 'preframe'): ScheduleId { // Scheduled based on internal elapsed time const scheduledTime = this._totalElapsed + timeoutMs; const id = this._nextScheduleId++; this._scheduledCbs.push([id, cb, scheduledTime, timing]); return id; } private _idsToRemove: ScheduleId[] = []; /** * Clears a scheduled callback using the ID returned from {@apilink schedule} * @param id The ID of the scheduled callback to clear */ public clearSchedule(id: ScheduleId): void { // Deferred removal this._idsToRemove.push(id); } /** * Called internally to trigger scheduled callbacks in the clock * @param timing * @internal */ public __runScheduledCbs(timing: ScheduledCallbackTiming = 'preframe') { // walk backwards to delete items as we loop for (let i = this._scheduledCbs.length - 1; i > -1; i--) { const [scheduleId, callback, scheduledTime, callbackTiming] = this._scheduledCbs[i]; if (this._idsToRemove.includes(scheduleId)) { // skip canceled ids continue; } if (timing === callbackTiming && scheduledTime <= this._totalElapsed) { callback(this._elapsed); this._scheduledCbs.splice(i, 1); } } // deferred removal for (const id of this._idsToRemove) { const index = this._scheduledCbs.findIndex(([scheduleId]) => scheduleId === id); if (index !== -1) { this._scheduledCbs.splice(index, 1); } } } protected update(overrideUpdateMs?: number): void { try { this.fpsSampler.start(); // Get the time to calculate time-elapsed const now = this.now(); let elapsed = now - this._lastTime || 1; // first frame // Constrain fps const fpsInterval = 1000 / this._maxFps; // only run frame if enough time has elapsed if (elapsed >= fpsInterval) { let leftover = 0; if (fpsInterval !== 0) { leftover = elapsed % fpsInterval; elapsed = elapsed - leftover; // shift elapsed to be "in phase" with the current loop fps } // Resolves issue #138 if the game has been paused, or blurred for // more than a 200 milliseconds, reset elapsed time to 1. This improves reliability // and provides more expected behavior when the engine comes back // into focus if (elapsed > 200) { elapsed = 1; } // tick the mainloop and run scheduled callbacks this._elapsed = overrideUpdateMs || elapsed; this._totalElapsed += this._elapsed; this.__runScheduledCbs('preframe'); this.tick(overrideUpdateMs || elapsed); this.__runScheduledCbs('postframe'); if (fpsInterval !== 0) { this._lastTime = now - leftover; } else { this._lastTime = now; } this.fpsSampler.end(); } } catch (e) { this._onFatalException(e); this.stop(); } } /** * Returns if the clock is currently running */ public abstract isRunning(): boolean; /** * Start the clock, it will then periodically call the tick(elapsedMilliseconds) since the last tick */ public abstract start(): void; /** * Stop the clock, tick() is no longer called */ public abstract stop(): void; } /** * The {@apilink StandardClock} implements the requestAnimationFrame browser api to run the tick() */ export class StandardClock extends Clock { private _running = false; private _requestId!: number; constructor(options: ClockOptions) { super(options); } public isRunning(): boolean { return this._running; } public start(): void { if (this._running) { return; } this._running = true; const mainloop = () => { // stop the loop if (!this._running) { return; } try { // request next loop this._requestId = window.requestAnimationFrame(mainloop); this.update(); } catch (e) { window.cancelAnimationFrame(this._requestId); throw e; } }; // begin the first frame mainloop(); } public stop(): void { window.cancelAnimationFrame(this._requestId); this._running = false; } } export interface TestClockOptions { /** * Specify the update milliseconds to use for each manual step() */ defaultUpdateMs: number; } /** * The TestClock is meant for debugging interactions in excalibur that require precise timing to replicate or test */ export class TestClock extends Clock { private _logger = Logger.getInstance(); private _updateMs: number; private _running: boolean = false; private _currentTime = 0; constructor(options: ClockOptions & TestClockOptions) { super({ ...options }); this._updateMs = options.defaultUpdateMs; } /** * Get the current time in milliseconds */ public override now() { return this._currentTime ?? 0; } public isRunning(): boolean { return this._running; } public start(): void { this._running = true; } public stop(): void { this._running = false; } /** * Manually step the clock forward 1 tick, optionally specify an elapsed time in milliseconds * @param overrideUpdateMs */ step(overrideUpdateMs?: number): void { const time = overrideUpdateMs ?? this._updateMs; if (this._running) { // to be comparable to RAF this needs to be a full blown Task // For example, images cannot decode synchronously in a single step this.update(time); this._currentTime += time; } else { this._logger.warn('The clock is not running, no step will be performed'); } } /** * Run a number of steps that tick the clock, optionally specify an elapsed time in milliseconds * @param numberOfSteps * @param overrideUpdateMs */ run(numberOfSteps: number, overrideUpdateMs?: number): void { for (let i = 0; i < numberOfSteps; i++) { this.step(overrideUpdateMs ?? this._updateMs); } } }