Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/excaliburjs/Excalibur. 🎮 Your friendly TypeScript 2D game engine for the web 🗡️ excaliburjs.com
excalibur excaliburjs game-development game-engine game-framework gamedev games html5-canvas typescript
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322import { 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); } }}