diff --git a/CHANGELOG.md b/CHANGELOG.md index 15e8707a..8558bd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,10 +11,16 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Deprecated -- +- The static `Engine.createMainLoop` is now marked deprecated and will be removed in v0.26.0, it is replaced by the `Clock` api ### Added +- Added new `Clock` api to manage the core main loop. Clocks hide the implementation detail of how the mainloop runs, users just knows that it ticks somehow. Clocks additionally encapsulate any related browser timing, like `performance.now()` + 1. `StandardClock` encapsulates the existing `requestAnimationFrame` api logic + 2. `TestClock` allows a user to manually step the mainloop, this can be useful for frame by frame debugging #1170 + 3. The base abstract clock implements the specifics of elapsed time + +- Added a new feature to Engine options to set a maximum fps `new ex.Engine({...options, maxFps: 30})`. This can be useful when needing to deliver a consistent experience across devices. - Pointers can now be configured to use the collider or the graphics bounds as the target for pointers with the `ex.PointerComponent` - `useColliderShape` - (default true) uses the collider component geometry for pointer events - `useGraphicsBounds` - (default false) uses the graphics bounds for pointer events @@ -38,7 +44,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - The following Engine's pieces: `Collision` `Graphics` `Resources` `Trigger` are updated to reflect the new EventDispatcher behavior. ### Changed - +- Excalibur FPS is now sampled over 100ms blocks, this gives a more usable fps in the stats. The sampler is available off of the engine clock `engine.clock.fpsSampler.fps` - Pointer Events: * Event types (up, down, move, etc) now all exist in 2 types `ex.Input.PointerEvent` and `ex.Input.WheelEvent` * The `stopPropagation()` method used to cancel further dispatches has been renamed to `cancel()` to match other events API. diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index ee587802..bd3e6b66 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -45,6 +45,7 @@ declare module dat { class GUI { constructor(options: { name: string }); addFolder(name: string): GUI; + add(object: T): any; add(object: T, prop: keyof T, min?: number, max?: number, step?: number): any; addColor(object: any, prop: any): any; } @@ -59,6 +60,14 @@ document.body.appendChild(stats.dom); var bootstrap = (game: ex.Engine) => { gui.add({toggleDebug: false}, 'toggleDebug').onChange(() => game.toggleDebug()); + let clock = gui.addFolder('clock'); + clock.add({"Use Test Clock": () => game.debug.useTestClock()}, "Use Test Clock"); + clock.add({"Use Standard Clock": () => game.debug.useStandardClock()}, "Use Standard Clock"); + let msStep = { + "Step MS": 1000 + } + clock.add(msStep, "Step MS", 1, 2000, 16); + clock.add({"Step": () => (game.clock as any).step(msStep["Step MS"]) }, "Step"); var supportedKeys = ['filter', 'entity', 'transform', 'motion', 'body', 'collider', 'physics', 'graphics', 'camera']; for (let key of supportedKeys) { let folder = gui.addFolder(key); @@ -117,7 +126,8 @@ var game = new ex.Engine({ suppressPlayButton: true, pointerScope: ex.Input.PointerScope.Canvas, antialiasing: false, - snapToPixel: true + snapToPixel: true, + maxFps: 60 }); fullscreenButton.addEventListener('click', () => { @@ -704,7 +714,7 @@ game.input.keyboard.on('down', (keyDown?: ex.Input.KeyEvent) => { }); var isColliding = false; -player.on('precollision', (data?: ex.PreCollisionEvent) => { +player.on('postcollision', (data: ex.PostCollisionEvent) => { if (data.side === ex.Side.Bottom) { isColliding = true; diff --git a/src/engine/Debug/Debug.ts b/src/engine/Debug/Debug.ts index a3d287c0..32cc82ae 100644 --- a/src/engine/Debug/Debug.ts +++ b/src/engine/Debug/Debug.ts @@ -2,6 +2,7 @@ import { DebugFlags, ColorBlindFlags } from './DebugFlags'; import { Engine } from '../Engine'; import { Color } from '../Color'; import { CollisionContact } from '../Collision/Detection/CollisionContact'; +import { StandardClock, TestClock } from '..'; /** * Debug stats containing current and previous frame statistics @@ -160,6 +161,45 @@ export class Debug implements DebugFlags { this.colorBlindMode = new ColorBlindFlags(this._engine); } + /** + * Switch the current excalibur clock with the [[TestClock]] and return + * it in the same running state. + * + * This is useful when you need to debug frame by frame. + */ + public useTestClock(): TestClock { + const clock = this._engine.clock; + const wasRunning = clock.isRunning(); + clock.stop(); + + const testClock = clock.toTestClock(); + if (wasRunning) { + testClock.start(); + } + this._engine.clock = testClock; + return testClock; + } + + /** + * Switch the current excalibur clock with the [[StandardClock]] and + * return it in the same runnign state. + * + * This is useful when you need to switch back to normal mode after + * debugging. + */ + public useStandardClock(): StandardClock { + const currentClock = this._engine.clock; + const wasRunning = currentClock.isRunning(); + currentClock.stop(); + + const standardClock = currentClock.toStandardClock(); + if (wasRunning) { + standardClock.start(); + } + this._engine.clock = standardClock; + return standardClock; + } + /** * Performance statistics */ diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 79299b0e..c69474ef 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -42,6 +42,8 @@ import { BrowserEvents } from './Util/Browser'; import { obsolete } from './Util/Decorators'; import { ExcaliburGraphicsContext, ExcaliburGraphicsContext2DCanvas, ExcaliburGraphicsContextWebGL } from './Graphics'; import { PointerEventReceiver } from './Input/PointerEventReceiver'; +import { FpsSampler } from './Util/Fps'; +import { Clock, StandardClock } from './Util/Clock'; /** * Enum representing the different mousewheel event bubble prevention @@ -167,6 +169,14 @@ export interface EngineOptions { * Optionally set the background color */ backgroundColor?: Color; + + /** + * Optionally set the maximum fps if not set Excalibur will go as fast as the device allows. + * + * You may want to constrain max fps if your game cannot maintain fps consistenly, it can look and feel better to have a 30fps game than + * one that bounces between 30fps and 60fps + */ + maxFps?: number; } /** @@ -204,6 +214,19 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { */ public canvasElementId: string; + /** + * Optionally set the maximum fps if not set Excalibur will go as fast as the device allows. + * + * You may want to constrain max fps if your game cannot maintain fps consistenly, it can look and feel better to have a 30fps game than + * one that bounces between 30fps and 60fps + */ + public maxFps: number = Number.POSITIVE_INFINITY; + + /** + * Direct access to the excalibur clock + */ + public clock: Clock; + /** * The width of the game canvas in pixels (physical width component of the * resolution of the canvas element) @@ -387,7 +410,6 @@ export class Engine extends Class implements CanInitialize, CanUpdate, CanDraw { // loading private _loader: Loader; - private _isLoading: boolean = false; private _isInitialized: boolean = false; @@ -612,6 +634,14 @@ O|===|* >________________>\n\ this.backgroundColor = options.backgroundColor.clone(); } + this.maxFps = options.maxFps ?? this.maxFps; + + this.clock = new StandardClock({ + maxFps: this.maxFps, + tick: this._mainloop.bind(this), + onFatalException: (e) => this.onFatalException(e) + }); + this.enableCanvasTransparency = options.enableCanvasTransparency; this._loader = new Loader(); @@ -986,7 +1016,7 @@ O|===|* >________________>\n\ * @param delta Number of milliseconds elapsed since the last update. */ private _update(delta: number) { - if (this._isLoading) { + if (!this.ready) { // suspend updates until loading is finished this._loader.update(this, delta); // Update input listeners @@ -995,7 +1025,6 @@ O|===|* >________________>\n\ return; } - this._overrideInitialize(this); // Publish preupdate events this._preupdate(delta); @@ -1049,10 +1078,10 @@ O|===|* >________________>\n\ const ctx = this.ctx; this._predraw(ctx, delta); - if (this._isLoading) { + // Drawing nothing else while loading + if (!this._isReady) { this._loader.canvas.draw(this.graphicsContext, 0, 0); this.graphicsContext.flush(); - // Drawing nothing else while loading return; } @@ -1130,73 +1159,138 @@ O|===|* >________________>\n\ } private _loadingComplete: boolean = false; + /** * Returns true when loading is totally complete and the player has clicked start */ public get loadingComplete() { return this._loadingComplete; } + + private _isReady = false; + public get ready() { + return this._isReady; + } + private _isReadyResolve: () => any; + private _isReadyPromise = new Promise(resolve => { + this._isReadyResolve = resolve; + }); + public isReady(): Promise { + return this._isReadyPromise; + } + + /** * Starts the internal game loop for Excalibur after loading * any provided assets. * @param loader Optional [[Loader]] to use to load resources. The default loader is [[Loader]], override to provide your own * custom loader. + * + * Note: start() only resolves AFTER the user has clicked the play button */ - public start(loader?: Loader): Promise { + public async start(loader?: Loader): Promise { if (!this._compatible) { - return Promise.reject('Excalibur is incompatible with your browser'); + throw new Error('Excalibur is incompatible with your browser'); } - let loadingComplete: Promise; - // Push the current user entered resolution/viewport - this.screen.pushResolutionAndViewport(); - // Configure resolution for loader - this.screen.resolution = this.screen.viewport; - this.screen.applyResolutionAndViewport(); - this.graphicsContext.updateViewport(); + + // Wire loader if we have it if (loader) { + // Push the current user entered resolution/viewport + this.screen.pushResolutionAndViewport(); + + // Configure resolution for loader, it expects resolution === viewport + this.screen.resolution = this.screen.viewport; + this.screen.applyResolutionAndViewport(); + this.graphicsContext.updateViewport(); this._loader = loader; this._loader.suppressPlayButton = this._suppressPlayButton || this._loader.suppressPlayButton; this._loader.wireEngine(this); - loadingComplete = this.load(this._loader); - } else { - loadingComplete = Promise.resolve(); } - loadingComplete.then(() => { + // Start the excalibur clock which drives the mainloop + // has started is a slight misnomer, it's really mainloop started + this._logger.debug('Starting game clock...'); + this.browser.resume(); + this.clock.start(); + this._logger.debug('Game clock started'); + + if (loader) { + await this.load(this._loader); + this._loadingComplete = true; + + // reset back to previous user resolution/viewport this.screen.popResolutionAndViewport(); this.screen.applyResolutionAndViewport(); this.graphicsContext.updateViewport(); - this.emit('start', new GameStartEvent(this)); - this._loadingComplete = true; - }); - - if (!this._hasStarted) { - // has started is a slight misnomer, it's really mainloop started - this._hasStarted = true; - this._logger.debug('Starting game...'); - this.browser.resume(); - Engine.createMainLoop(this, window.requestAnimationFrame, Date.now)(); - this._logger.debug('Game started'); - } else { - // Game already started; } - return loadingComplete; + + this._loadingComplete = true; + + // Initialize before ready + this._overrideInitialize(this); + + this._isReady = true; + + this._isReadyResolve(); + this.emit('start', new GameStartEvent(this)); + return this._isReadyPromise; } + private _mainloop(elapsed: number) { + this.emit('preframe', new PreFrameEvent(this, this.stats.prevFrame)); + const delta = elapsed * this.timescale; + + // reset frame stats (reuse existing instances) + const frameId = this.stats.prevFrame.id + 1; + this.stats.currFrame.reset(); + this.stats.currFrame.id = frameId; + this.stats.currFrame.delta = delta; + this.stats.currFrame.fps = this.clock.fpsSampler.fps; + + const beforeUpdate = this.clock.now(); + this._update(delta); + const afterUpdate = this.clock.now(); + this._draw(delta); + const afterDraw = this.clock.now(); + + this.stats.currFrame.duration.update = afterUpdate - beforeUpdate; + this.stats.currFrame.duration.draw = afterDraw - afterUpdate; + + this.emit('postframe', new PostFrameEvent(this, this.stats.currFrame)); + } + + /** + * + * @param game + * @param raf + * @param nowFn + * @deprecated Use [[Clock]] to run the mainloop, will be removed in v0.26.0 + */ public static createMainLoop(game: Engine, raf: (func: Function) => number, nowFn: () => number) { let lastTime = nowFn(); - + const fpsSampler = new FpsSampler({ + nowFn, + initialFps: game.maxFps === Infinity ? 60 : game.maxFps + }); return function mainloop() { if (!game._hasStarted) { return; } try { game._requestId = raf(mainloop); + fpsSampler.start(); game.emit('preframe', new PreFrameEvent(game, game.stats.prevFrame)); // Get the time to calculate time-elapsed const now = nowFn(); - let elapsed = Math.floor(now - lastTime) || 1; + let elapsed = now - lastTime || 1; // first frame + + // Constrain fps + const fpsInterval = game.maxFps === Number.POSITIVE_INFINITY ? 0 : 1000 / game.maxFps; + if (elapsed <= fpsInterval) { + return; // too fast 😎 skip this frame + } + // 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 @@ -1211,7 +1305,7 @@ O|===|* >________________>\n\ game.stats.currFrame.reset(); game.stats.currFrame.id = frameId; game.stats.currFrame.delta = delta; - game.stats.currFrame.fps = 1.0 / (delta / 1000); + game.stats.currFrame.fps = fpsSampler.fps; const beforeUpdate = nowFn(); game._update(delta); @@ -1222,9 +1316,14 @@ O|===|* >________________>\n\ game.stats.currFrame.duration.update = afterUpdate - beforeUpdate; game.stats.currFrame.duration.draw = afterDraw - afterUpdate; - lastTime = now; - + // if fps interval is not a multple + if (fpsInterval > 0) { + lastTime = now - (elapsed % fpsInterval); + } else { + lastTime = now; + } game.emit('postframe', new PostFrameEvent(game, game.stats.currFrame)); + fpsSampler.end(); game.stats.prevFrame.reset(game.stats.currFrame); } catch (e) { window.cancelAnimationFrame(game._requestId); @@ -1238,10 +1337,10 @@ O|===|* >________________>\n\ * Stops Excalibur's main loop, useful for pausing the game. */ public stop() { - if (this._hasStarted) { + if (this.clock.isRunning()) { this.emit('stop', new GameStopEvent(this)); this.browser.pause(); - this._hasStarted = false; + this.clock.stop(); this._logger.debug('Game stopped'); } } @@ -1250,7 +1349,7 @@ O|===|* >________________>\n\ * Returns the Engine's Running status, Useful for checking whether engine is running or paused. */ public isPaused(): boolean { - return !this._hasStarted; + return !this.clock.isRunning(); } /** @@ -1270,25 +1369,12 @@ O|===|* >________________>\n\ * will appear. * @param loader Some [[Loadable]] such as a [[Loader]] collection, [[Sound]], or [[Texture]]. */ - public load(loader: Loadable): Promise { - const complete = new Promise((resolve) => { - this._isLoading = true; - - loader.load().then(() => { - if (this._suppressPlayButton) { - setTimeout(() => { - this._isLoading = false; - resolve(); - // Delay is to give the logo a chance to show, otherwise don't delay - }, 500); - } else { - this._isLoading = false; - resolve(); - } - }); - }); - - return complete; + public async load(loader: Loadable): Promise { + try { + await loader.load(); + } catch { + await Promise.resolve(); + } } } diff --git a/src/engine/EntityComponentSystem/SystemManager.ts b/src/engine/EntityComponentSystem/SystemManager.ts index 27bf993b..400a42a6 100644 --- a/src/engine/EntityComponentSystem/SystemManager.ts +++ b/src/engine/EntityComponentSystem/SystemManager.ts @@ -61,12 +61,11 @@ export class SystemManager { } /** - * Updates all systems - * @param type whether this is an update or draw system - * @param context context reference - * @param delta time in milliseconds + * Initialize all systems in the manager + * + * Systems added after initialize() will be initialized on add */ - public updateSystems(type: SystemType, context: ContextType, delta: number) { + public initialize() { if (!this.initialized) { this.initialized = true; for (const s of this.systems) { @@ -75,7 +74,15 @@ export class SystemManager { } } } + } + /** + * Updates all systems + * @param type whether this is an update or draw system + * @param context context reference + * @param delta time in milliseconds + */ + public updateSystems(type: SystemType, context: ContextType, delta: number) { const systems = this.systems.filter((s) => s.systemType === type); for (const s of systems) { if (s.preupdate) { diff --git a/src/engine/Loader.ts b/src/engine/Loader.ts index 61d7a99e..f08ef443 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Loader.ts @@ -240,10 +240,11 @@ export class Loader extends Class implements Loadable[]> { /** * Shows the play button and returns a promise that resolves when clicked */ - public showPlayButton(): Promise { + public async showPlayButton(): Promise { if (this.suppressPlayButton) { this.hidePlayButton(); - return Promise.resolve(); + // Delay is to give the logo a chance to show, otherwise don't delay + await delay(500, this._engine?.clock); } else { const resizeHandler = () => { this._positionPlayButton(); @@ -258,7 +259,8 @@ export class Loader extends Class implements Loadable[]> { this._playButton.click(); } }); - const promise = new Promise((resolve) => { + this._positionPlayButton(); + const playButtonClicked = new Promise((resolve) => { const startButtonHandler = (e: Event) => { // We want to stop propogation to keep bubbling to the engine pointer handlers e.stopPropagation(); @@ -274,7 +276,7 @@ export class Loader extends Class implements Loadable[]> { this._playButton.addEventListener('pointerup', startButtonHandler); }); - return promise; + return await playButtonClicked; } } @@ -303,11 +305,21 @@ export class Loader extends Class implements Loadable[]> { data: Loadable[]; + private _isLoadedResolve: () => any; + private _isLoadedPromise = new Promise(resolve => { + this._isLoadedResolve = resolve; + }); + public areResourcesLoaded() { + return this._isLoadedPromise; + } + /** * Begin loading all of the supplied resources, returning a promise - * that resolves when loading of all is complete + * that resolves when loading of all is complete AND the user has clicked the "Play button" */ public async load(): Promise[]> { + await this._image?.decode(); // decode logo if it exists + await Promise.all( this._resourceList.map((r) => r.load().finally(() => { @@ -316,9 +328,11 @@ export class Loader extends Class implements Loadable[]> { }) ) ); + this._isLoadedResolve(); // short delay in showing the button for aesthetics - await delay(200); + await delay(200, this._engine?.clock); + await this.showPlayButton(); // Unlock browser AudioContext in after user gesture // See: https://github.com/excaliburjs/Excalibur/issues/262 diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts index cfc80cfa..4ebf4fdf 100644 --- a/src/engine/Scene.ts +++ b/src/engine/Scene.ts @@ -255,6 +255,8 @@ export class Scene extends Class implements CanInitialize, CanActivate, CanDeact // Initialize camera first this.camera._initialize(engine); + this.world.systemManager.initialize(); + // This order is important! we want to be sure any custom init that add actors // fire before the actor init this.onInitialize.call(this, engine); diff --git a/src/engine/Util/Clock.ts b/src/engine/Util/Clock.ts new file mode 100644 index 00000000..322721fd --- /dev/null +++ b/src/engine/Util/Clock.ts @@ -0,0 +1,270 @@ +import { Logger } from '..'; +import { FpsSampler } from './Fps'; + +export interface ClockOptions { + /** + * Define the function you'd like the clock to tick when it is started + */ + tick: (elapsedMs: 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: (elapsedMs: 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: [cb: () => any, scheduledTime: number][] = []; + private _totalElapsed: number = 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; + } + + /** + * Schedule a callback to fire given a timeout in milliseconds using the excalibur [[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 + */ + public schedule(cb: () => any, timeoutMs: number = 0) { + const scheduledTime = this.now() + timeoutMs; + this._scheduledCbs.push([cb, scheduledTime]); + } + + private _runScheduledCbs() { + // walk backwards to delete items as we loop + for (let i = this._scheduledCbs.length - 1; i > -1; i--) { + if (this._scheduledCbs[i][1] <= this._totalElapsed) { + this._scheduledCbs[i][0](); + this._scheduledCbs.splice(i, 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 = this._maxFps === Infinity ? 0 : 1000 / this._maxFps; + if (elapsed <= fpsInterval) { + return; // too fast 😎 skip this frame + } + + // 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(); + this.tick(overrideUpdateMs || elapsed); + + // if fps interval is not a multple + if (fpsInterval > 0) { + this._lastTime = now - (elapsed % fpsInterval); + } 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 [[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 { + 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 { + 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 syncronously 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); + } + } +} \ No newline at end of file diff --git a/src/engine/Util/Fps.ts b/src/engine/Util/Fps.ts new file mode 100644 index 00000000..d698bae1 --- /dev/null +++ b/src/engine/Util/Fps.ts @@ -0,0 +1,70 @@ +export interface FpsSamplerOptions { + /** + * Specify the sampling period in milliseconds (default 100) + */ + samplePeriod?: number; + /** + * Specify the initial FPS + */ + initialFps: number; + + /** + * Specify the function used to return the current time (in millseconds) + */ + nowFn: () => number; +} + +export class FpsSampler { + private _fps: number; + private _samplePeriod: number = 100; + private _currentFrameTime: number = 0; + private _frames: number = 0; + private _previousSampleTime: number = 0; + private _beginFrameTime: number = 0; + private _nowFn: () => number; + + constructor(options: FpsSamplerOptions) { + this._fps = options.initialFps; + this._samplePeriod = options.samplePeriod ?? this._samplePeriod; + this._currentFrameTime = 1000/options.initialFps; + this._nowFn = options.nowFn; + this._previousSampleTime = this._nowFn(); + } + + /** + * Start of code block to sample FPS for + */ + start() { + this._beginFrameTime = this._nowFn(); + } + + /** + * End of code block to sample FPS for + */ + end() { + this._frames++; + const time = this._nowFn(); + + this._currentFrameTime = time - this._beginFrameTime; + + if (time >= this._previousSampleTime + this._samplePeriod) { + this._fps = (this._frames * 1000) / (time - this._previousSampleTime); + this._previousSampleTime = time; + this._frames = 0; + } + } + + /** + * Return the currenty sampled fps over the last sample period, by default every 100ms + */ + get fps() { + return this._fps; + } + + /** + * Return the instantanteous fps, this can be less useful because it will fluctuate given the current frames time + */ + get instant() { + return 1000 / this._currentFrameTime; + } +} \ No newline at end of file diff --git a/src/engine/Util/Util.ts b/src/engine/Util/Util.ts index fedeb806..72fcab86 100644 --- a/src/engine/Util/Util.ts +++ b/src/engine/Util/Util.ts @@ -1,6 +1,7 @@ import { Vector } from '../Math/vector'; import { Random } from '../Math/Random'; import { Side } from '../Collision/Side'; +import { Clock } from '..'; /** * Two PI constant @@ -387,11 +388,16 @@ export const range = (from: number, to: number) => Array.from(new Array(to - fro /** * Create a promise that resolves after a certain number of milliseconds + * + * It is strongly recommended you pass the excalibur clock so delays are bound to the + * excalibur clock which would be unaffected by stop/pause. * @param milliseconds + * @param clock */ -export function delay(milliseconds: number): Promise { - return new Promise((resolve) => { - setTimeout(() => { +export function delay(milliseconds: number, clock?: Clock): Promise { + const schedule = clock?.schedule.bind(clock) ?? setTimeout; + return new Promise(resolve => { + schedule(() => { resolve(); }, milliseconds); }); diff --git a/src/engine/Util/WebAudio.ts b/src/engine/Util/WebAudio.ts index 6ac2f5c1..be986945 100644 --- a/src/engine/Util/WebAudio.ts +++ b/src/engine/Util/WebAudio.ts @@ -70,7 +70,6 @@ export class WebAudio { ); }); - return promise; } diff --git a/src/engine/index.ts b/src/engine/index.ts index 4ad49722..6323b6f4 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -83,6 +83,9 @@ export * from './Util/Observable'; export * from './Util/Log'; export * from './Util/SortedList'; export * from './Util/Pool'; +export * from './Util/Fps'; +export * from './Util/Clock'; +export * from './Util/WebAudio'; // ex.Deprecated export * from './Promises'; diff --git a/src/engine/tsconfig.json b/src/engine/tsconfig.json index ac27eab2..b5a606cf 100644 --- a/src/engine/tsconfig.json +++ b/src/engine/tsconfig.json @@ -4,7 +4,7 @@ "moduleResolution": "node", "sourceMap": true, "experimentalDecorators": true, - "target": "es2015", + "target": "es2018", "module": "es2015", "declaration": true, "removeComments": false, @@ -21,13 +21,7 @@ "lib": [ "dom", "es5", - "es2015.collection", - "es2015.iterable", - "es2015.promise", - "es2018.promise", - "es2015.proxy", - "es2016", - "es2017.object" + "es2018" ] } } diff --git a/src/spec/ActionSpec.ts b/src/spec/ActionSpec.ts index de632a23..d31ebe7c 100644 --- a/src/spec/ActionSpec.ts +++ b/src/spec/ActionSpec.ts @@ -19,6 +19,8 @@ describe('Action', () => { engine.addScene('test', scene); engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(100); spyOn(scene, 'draw').and.callThrough(); spyOn(actor, 'draw'); diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts index b1b743d2..aee15e04 100644 --- a/src/spec/ActorSpec.ts +++ b/src/spec/ActorSpec.ts @@ -44,6 +44,8 @@ describe('A game actor', () => { spyOn(actor, 'debugDraw'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); collisionSystem.initialize(scene); scene.world.systemManager.get(ex.Input.PointerSystem).initialize(scene); @@ -601,6 +603,8 @@ describe('A game actor', () => { engine.addScene('test', scene); engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); const green = new ex.Actor({ x: 35, y: 35, width: 50, height: 50, color: ex.Color.Green }); const blue = new ex.Actor({ x: 65, y: 65, width: 50, height: 50, color: ex.Color.Blue }); diff --git a/src/spec/ClockSpec.ts b/src/spec/ClockSpec.ts new file mode 100644 index 00000000..83c1dfe1 --- /dev/null +++ b/src/spec/ClockSpec.ts @@ -0,0 +1,171 @@ +import * as ex from '@excalibur'; + +describe('Clocks', () => { + describe('A TestClock', () => { + it('exists', () => { + expect(ex.TestClock).toBeDefined(); + }); + + it('can manually tick', () => { + const tickSpy = jasmine.createSpy('tick'); + const testClock = new ex.TestClock({ + tick: tickSpy, + defaultUpdateMs: 1000 + }); + testClock.start(); + expect(testClock.isRunning()).toBe(true); + expect(tickSpy).not.toHaveBeenCalled(); + + testClock.step(); + expect(tickSpy).toHaveBeenCalledWith(1000); + expect(testClock.elapsed()).toBe(1000); + + testClock.step(); + expect(testClock.now()).toBe(2000); + + testClock.step(22); + expect(testClock.elapsed()).toBe(22); + expect(testClock.now()).toBe(2022); + + testClock.stop(); + expect(testClock.isRunning()).toBe(false); + }); + + it('can be converted to a standard clock', () => { + const testClock = new ex.TestClock({ + tick: () => { /* nothing */ }, + defaultUpdateMs: 1000 + }); + + const standard = testClock.toStandardClock(); + expect(standard).toBeInstanceOf(ex.StandardClock); + }); + + it('will log a warning if not started', () => { + const logger = ex.Logger.getInstance(); + spyOn(logger, 'warn'); + + const testClock = new ex.TestClock({ + tick: () => { /* nothing */ }, + defaultUpdateMs: 1000 + }); + + testClock.step(); + expect(testClock.isRunning()).toBe(false); + expect(logger.warn).toHaveBeenCalledWith('The clock is not running, no step will be performed'); + }); + + it('works with delay()', (done) => { + const testClock = new ex.TestClock({ + tick: () => { /* nothing */ }, + defaultUpdateMs: 1000 + }); + testClock.start(); + ex.Util.delay(1000, testClock).then(() => { + done(); + }); + + testClock.step(500); + testClock.step(500); + }); + + it('can schedule a callbacks to fire', async () => { + const testClock = new ex.TestClock({ + tick: () => { /* nothing */ }, + defaultUpdateMs: 1000 + }); + testClock.start(); + + const scheduledCb = jasmine.createSpy('scheduled'); + const scheduledCb2 = jasmine.createSpy('scheduled2'); + + testClock.schedule(scheduledCb, 1000); + testClock.schedule(scheduledCb2, 1500); + + expect(scheduledCb).not.toHaveBeenCalled(); + expect(scheduledCb2).not.toHaveBeenCalled(); + + await testClock.step(500); + expect(scheduledCb).not.toHaveBeenCalled(); + expect(scheduledCb2).not.toHaveBeenCalled(); + + await testClock.step(500); + expect(scheduledCb).toHaveBeenCalledTimes(1); + expect(scheduledCb2).not.toHaveBeenCalled(); + + await testClock.step(500); + expect(scheduledCb2).toHaveBeenCalledTimes(1); + }); + }); + + describe('A StandardClock', () => { + it('exists', () => { + expect(ex.StandardClock).toBeDefined(); + }); + + it('can tick', () => { + const tickSpy = jasmine.createSpy('tick'); + const clock = new ex.StandardClock({ + tick: tickSpy + }); + + expect(tickSpy).not.toHaveBeenCalled(); + clock.start(); + + expect(clock.isRunning()).toBe(true); + expect(tickSpy).toHaveBeenCalled(); + + clock.stop(); + expect(clock.isRunning()).toBe(false); + }); + + it('can limit fps', (done) => { + const tickSpy = jasmine.createSpy('tick'); + const clock = new ex.StandardClock({ + tick: tickSpy, + maxFps: 15 + }); + + clock.start(); + setTimeout(() => { + expect(clock.fpsSampler.fps).toBeCloseTo(15, -1); + stop(); + done(); + }, 300); + }); + + it('can handle exceptions and stop', () => { + const errorSpy = jasmine.createSpy('error'); + const clock = new ex.StandardClock({ + tick: () => { + throw new Error('some error'); + }, + onFatalException: errorSpy + }); + spyOn(clock, 'stop'); + + clock.start(); + + expect(errorSpy).toHaveBeenCalledWith(new Error('some error')); + expect(clock.stop).toHaveBeenCalled(); + }); + + it('can return the elapsed time', () => { + const clock = new ex.StandardClock({ + tick: () => { /* nothing */ } + }); + (clock as any).update(100); + expect(clock.elapsed()).toBe(100); + }); + + it('can be converted to a test clock', () => { + const clock = new ex.StandardClock({ + tick: () => { /* nothing */ } + }); + + const test = clock.toTestClock(); + + expect(test).toBeInstanceOf(ex.TestClock); + }); + }); +}); \ No newline at end of file diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index e7962c4a..9fe72310 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -23,6 +23,8 @@ describe('Collision Shape', () => { engine.add('test', scene); engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); actor = new ex.Actor({ x: 0, y: 0, width: 20, height: 20 }); circle = actor.collider.useCircleCollider(10, ex.Vector.Zero); @@ -381,6 +383,8 @@ describe('Collision Shape', () => { engine.addScene('test', scene); engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); }); afterEach(() => { @@ -731,6 +735,8 @@ describe('Collision Shape', () => { engine.addScene('test', scene); engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); actor = new ex.Actor({ x: 5, y: 0, width: 10, height: 10 }); edge = actor.collider.useEdgeCollider(new ex.Vector(-5, 0), new ex.Vector(5, 0)); diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts index 6f2ae136..9d416aa8 100644 --- a/src/spec/CollisionSpec.ts +++ b/src/spec/CollisionSpec.ts @@ -1,18 +1,15 @@ import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; -import { Mocks } from './util/Mocks'; describe('A Collision', () => { let actor1: ex.Actor = null; let actor2: ex.Actor = null; - const scene: ex.Scene = null; let engine: ex.Engine = null; - const mock = new Mocks.Mocker(); - let loop: Mocks.GameLoopLike; + let clock: ex.TestClock = null; beforeEach(() => { engine = TestUtils.engine({ width: 600, height: 400 }); - loop = mock.loop(engine); + clock = engine.clock = engine.clock.toTestClock(); actor1 = new ex.Actor({x: 0, y: 0, width: 10, height: 10}); actor2 = new ex.Actor({x: 5, y: 5, width: 10, height: 10}); @@ -32,7 +29,7 @@ describe('A Collision', () => { actor2 = null; }); - it('should throw one event for each actor participating', () => { + it('should throw one event for each actor participating', async () => { let actor1Collision = 0; let actor2Collision = 0; actor1.on('precollision', (e: ex.PreCollisionEvent) => { @@ -44,9 +41,7 @@ describe('A Collision', () => { actor2Collision++; }); - for (let i = 0; i < 50; i++) { - loop.advance(100); - } + clock.run(1, 100); expect(actor1Collision).toBe(1); expect(actor2Collision).toBe(1); @@ -151,9 +146,7 @@ describe('A Collision', () => { actor2.body.collisionType = ex.CollisionType.Passive; - for (let i = 0; i < 50; i++) { - loop.advance(100); - } + clock.run(1, 100); expect(actor1Collision).toBe(1); expect(actor2Collision).toBe(1); @@ -172,9 +165,7 @@ describe('A Collision', () => { actor2.kill(); - for (let i = 0; i < 50; i++) { - loop.advance(100); - } + clock.run(1, 100); expect(actor1Collision).toBe(0); expect(actor2Collision).toBe(0); @@ -188,9 +179,7 @@ describe('A Collision', () => { } }); - for (let i = 0; i < 50; i++) { - loop.advance(100); - } + clock.run(5, 100); expect(touching).toBe(true); }); @@ -224,9 +213,7 @@ describe('A Collision', () => { activeBlock.once('precollision', collisionHandler); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); }); it('should emit a start collision once when objects start colliding', () => { @@ -250,9 +237,7 @@ describe('A Collision', () => { activeBlock.on('collisionstart', collisionStart); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); expect(count).toBe(1); }); @@ -278,9 +263,7 @@ describe('A Collision', () => { activeBlock.on('collisionend', collisionEnd); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); expect(count).toBe(1); }); @@ -297,9 +280,7 @@ describe('A Collision', () => { fixedBlock.body.collisionType = ex.CollisionType.Fixed; engine.add(fixedBlock); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); expect(activeBlock.vel.x).toBe(0); }); @@ -317,9 +298,7 @@ describe('A Collision', () => { activeBlock.vel = ex.vec(-100, activeBlock.vel.y); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); expect(activeBlock.vel.x).toBe(-100); }); @@ -344,9 +323,7 @@ describe('A Collision', () => { activeBlock.on('collisionstart', collisionEnd); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); }); it('should have the actor as the handler context for collisionend', (done) => { @@ -369,8 +346,6 @@ describe('A Collision', () => { activeBlock.on('collisionend', collisionEnd); - for (let i = 0; i < 20; i++) { - loop.advance(1000); - } + clock.run(5, 1000); }); }); diff --git a/src/spec/ColorBlindCorrectorSpec.ts b/src/spec/ColorBlindCorrectorSpec.ts index a46cb8a3..a3792eff 100644 --- a/src/spec/ColorBlindCorrectorSpec.ts +++ b/src/spec/ColorBlindCorrectorSpec.ts @@ -6,14 +6,16 @@ import { ColorBlindness } from '@excalibur'; describe('A ColorBlindCorrector', () => { let bg: ex.LegacyDrawing.Texture; let engine: ex.Engine; + let clock: ex.TestClock; - beforeEach(() => { + beforeEach(async () => { jasmine.addMatchers(ExcaliburMatchers); engine = TestUtils.engine({ width: 800, height: 200 }); bg = new ex.LegacyDrawing.Texture('src/spec/images/ColorBlindCorrectorSpec/actor.png', true); - - return engine.start(new ex.Loader([bg])); + const loader = new ex.Loader([bg]); + clock = engine.clock as ex.TestClock; + await TestUtils.runToReady(engine, loader); }); afterEach(() => { @@ -30,6 +32,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('corrects deuteranopia', (done) => { @@ -43,6 +46,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('simulates deuteranopia', (done) => { @@ -56,6 +60,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('corrects protanopia', (done) => { @@ -69,6 +74,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('simulates protanopia', (done) => { @@ -82,6 +88,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('corrects tritanopia', (done) => { @@ -95,6 +102,7 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); it('simulates tritanopia', (done) => { @@ -108,5 +116,6 @@ describe('A ColorBlindCorrector', () => { done(); }); }); + clock.step(1); }); }); diff --git a/src/spec/DebugSystemSpec.ts b/src/spec/DebugSystemSpec.ts index ecec01fb..c7b33c79 100644 --- a/src/spec/DebugSystemSpec.ts +++ b/src/spec/DebugSystemSpec.ts @@ -26,6 +26,18 @@ describe('DebugSystem', () => { expect(ex.DebugSystem).toBeDefined(); }); + it('can use a test clock', () => { + engine.debug.useTestClock(); + expect(engine.clock).toBeInstanceOf(ex.TestClock); + expect(engine.clock.isRunning()); + }); + + it('can use a standard clock', () => { + engine.debug.useStandardClock(); + expect(engine.clock).toBeInstanceOf(ex.StandardClock); + expect(engine.clock.isRunning()); + }); + it('does not crash with an empty collider', async () => { const debugSystem = new ex.DebugSystem(); engine.currentScene.world.add(debugSystem); diff --git a/src/spec/EngineSpec.ts b/src/spec/EngineSpec.ts index a7b8b1dc..32ad33a2 100644 --- a/src/spec/EngineSpec.ts +++ b/src/spec/EngineSpec.ts @@ -1,13 +1,11 @@ import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; -import { Mocks } from './util/Mocks'; -import { ensureImagesLoaded, ExcaliburMatchers } from 'excalibur-jasmine'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; +import { Engine } from '@excalibur'; describe('The engine', () => { let engine: ex.Engine; let scene: ex.Scene; - const mock = new Mocks.Mocker(); - let loop: Mocks.GameLoopLike; const reset = () => { engine.stop(); @@ -22,15 +20,15 @@ describe('The engine', () => { beforeEach(() => { jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); engine = TestUtils.engine(); scene = new ex.Scene(); engine.add('default', scene); engine.goToScene('default'); - - loop = mock.loop(engine); - engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); }); afterEach(() => { @@ -41,20 +39,24 @@ describe('The engine', () => { reset(); engine = TestUtils.engine({ suppressPlayButton: false - }); + }, ['use-canvas-context']); (engine)._suppressPlayButton = false; - engine.addScene('root', scene); - - loop = mock.loop(engine); - - engine.start(new ex.Loader([new ex.LegacyDrawing.Texture('src/spec/images/SpriteSpec/icon.png', true)])); - setTimeout(() => { - ensureImagesLoaded(engine.canvas, 'src/spec/images/EngineSpec/engine-load-complete.png').then(([canvas, image]) => { - expect(document.getElementById('excalibur-play')).toBeDefined('Play button should exist in the document'); - expect(canvas).toEqualImage(image); - done(); + const imageSource = new ex.ImageSource('src/spec/images/SpriteSpec/icon.png'); + + const loader = new ex.Loader([imageSource]); + engine.start(loader); + const testClock = engine.clock as ex.TestClock; + + loader.areResourcesLoaded().then(() => { + testClock.run(2, 100); // 200 ms delay in loader + expect(document.getElementById('excalibur-play')).withContext('Play button should exist in the document').toBeDefined(); + setTimeout(() => { // needed for the delay to work + testClock.run(1, 100); + expectAsync(engine.canvas).toEqualImage('src/spec/images/EngineSpec/engine-load-complete.png').then(() => { + done(); + }); }); - }, 600); + }); }); it('should have a default resolution to SVGA (800x600) if none specified', () => { @@ -78,70 +80,80 @@ describe('The engine', () => { }) ); - loop = mock.loop(engine); + const testClock = engine.clock as ex.TestClock; + const loader = new ex.Loader([new ex.ImageSource('src/spec/images/SpriteSpec/icon.png')]); - engine.start(new ex.Loader([new ex.LegacyDrawing.Texture('src/spec/images/SpriteSpec/icon.png', true)])).then(() => { - setTimeout(() => { - ensureImagesLoaded(engine.canvas, 'src/spec/images/EngineSpec/engine-suppress-play.png').then(([canvas, image]) => { - expect(canvas).toEqualImage(image); - done(); - }); - }, 600); + TestUtils.runToReady(engine, loader).then(() => { + // With suppress play there is another 500 ms delay in engine load() + testClock.step(1); + expectAsync(engine.canvas).toEqualImage('src/spec/images/EngineSpec/engine-suppress-play.png').then(() => { + done(); + }); }); }); it('should emit a preframe event', () => { - let fired = false; - engine.on('preframe', () => (fired = true)); - - loop.advance(100); - - expect(fired).toBe(true); + const fired = jasmine.createSpy('fired'); + engine.on('preframe', fired); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should emit a postframe event', () => { - let fired = false; - engine.on('postframe', () => (fired = true)); - - loop.advance(100); + const fired = jasmine.createSpy('fired'); + engine.on('postframe', fired); - expect(fired).toBe(true); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should emit a preupdate event', () => { - let fired = false; - engine.on('preupdate', () => (fired = true)); - - loop.advance(100); - - expect(fired).toBe(true); + const fired = jasmine.createSpy('fired'); + engine.on('preupdate', fired); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should emit a postupdate event', () => { - let fired = false; - engine.on('postupdate', () => (fired = true)); - - loop.advance(100); + const fired = jasmine.createSpy('fired'); + engine.on('postupdate', fired); - expect(fired).toBe(true); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should emit a predraw event', () => { - let fired = false; - engine.on('predraw', () => (fired = true)); - - loop.advance(100); + const fired = jasmine.createSpy('fired'); + engine.on('predraw', fired); - expect(fired).toBe(true); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should emit a postdraw event', () => { - let fired = false; - engine.on('postdraw', () => (fired = true)); + const fired = jasmine.createSpy('fired'); + engine.on('postdraw', fired); - loop.advance(100); - - expect(fired).toBe(true); + expect(fired).not.toHaveBeenCalled(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + clock.step(1); + expect(fired).toHaveBeenCalledTimes(2); }); it('should tell engine is running', () => { @@ -192,6 +204,8 @@ describe('The engine', () => { it('should return correct screen dimensions if zoomed in', () => { engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); engine.currentScene.camera.zoom = 2; expect(engine.drawHeight).toBe(250); @@ -349,6 +363,63 @@ describe('The engine', () => { expect(game.enableCanvasTransparency).toBe(true); }); + it('can limit fps', () => { + const game = new ex.Engine({height: 600, width: 800, maxFps: 15}); + (game as any)._hasStarted = true; // TODO gross + + const mockRAF = (_mainloop: () => any) => { + return 0; + }; + + let _currentTime = 0; + const mockNow = () => { + return _currentTime; + }; + // 16ms tick + const actualFpsInterval = 1000/60; + const tick = () => _currentTime += actualFpsInterval; + + const sut = Engine.createMainLoop(game, mockRAF, mockNow); + + for (let i = 0; i < 6; i++) { + sut(); + tick(); + } + + expect(game.maxFps).toBe(15); + expect(game.stats.currFrame.fps).toBeCloseTo(15); + }); + + it('will allow fps as fast as the tick', () => { + const game = new ex.Engine({height: 600, width: 800}); + (game as any)._hasStarted = true; // TODO gross + + const mockRAF = (_mainloop: () => any) => { + return 0; + }; + + let _currentTime = 0; + const mockNow = () => { + return _currentTime; + }; + + const actualFpsInterval = 1000/120; + const tick = () => _currentTime += actualFpsInterval; + + const sut = Engine.createMainLoop(game, mockRAF, mockNow); + game.on('postframe', tick); + + expect(game.maxFps).toBe(Infinity); + + sut(); + + // fps sampler samples every 100ms + for (let i = 0; i < (100/actualFpsInterval) + 2; i++) { + sut(); + } + expect(game.stats.currFrame.fps).toBeCloseTo(120); + }); + it('will warn if scenes are being overwritten', () => { spyOn(ex.Logger.getInstance(), 'warn'); const scene = new ex.Scene(); @@ -402,8 +473,9 @@ describe('The engine', () => { engine = null; }); - it('can have onInitialize overridden safely', () => { + it('can have onInitialize overridden safely', async () => { let initCalled = false; + engine.onInitialize = (engine) => { expect(engine).not.toBe(null); }; @@ -414,13 +486,19 @@ describe('The engine', () => { spyOn(engine, 'onInitialize').and.callThrough(); - (engine)._update(100); + await TestUtils.runToReady(engine); + const clock = engine.clock as ex.TestClock; + clock.step(1); expect(initCalled).toBe(true); expect(engine.onInitialize).toHaveBeenCalledTimes(1); }); it('can have onPostUpdate overridden safely', () => { + engine.start(); + const clock = engine.clock as ex.TestClock; + expect(engine.clock.isRunning()).toBe(true); + engine.onPostUpdate = (engine, delta) => { expect(engine).not.toBe(null); expect(delta).toBe(100); @@ -429,14 +507,18 @@ describe('The engine', () => { spyOn(engine, 'onPostUpdate').and.callThrough(); spyOn(engine, '_postupdate').and.callThrough(); - (engine)._update(100); - (engine)._update(100); + clock.step(100); + clock.step(100); expect(engine._postupdate).toHaveBeenCalledTimes(2); expect(engine.onPostUpdate).toHaveBeenCalledTimes(2); }); it('can have onPreUpdate overridden safely', () => { + engine.start(); + const clock = engine.clock as ex.TestClock; + expect(engine.clock.isRunning()).toBe(true); + engine.onPreUpdate = (engine, delta) => { expect(engine).not.toBe(null); expect(delta).toBe(100); @@ -445,14 +527,18 @@ describe('The engine', () => { spyOn(engine, 'onPreUpdate').and.callThrough(); spyOn(engine, '_preupdate').and.callThrough(); - (engine)._update(100); - (engine)._update(100); + clock.step(100); + clock.step(100); expect(engine._preupdate).toHaveBeenCalledTimes(2); expect(engine.onPreUpdate).toHaveBeenCalledTimes(2); }); it('can have onPreDraw overridden safely', () => { + engine.start(); + const clock = engine.clock as ex.TestClock; + expect(engine.clock.isRunning()).toBe(true); + engine.currentScene._initialize(engine); engine.onPreDraw = (ctx, delta) => { expect(ctx).not.toBe(null); @@ -461,14 +547,18 @@ describe('The engine', () => { spyOn(engine, 'onPreDraw').and.callThrough(); spyOn(engine, '_predraw').and.callThrough(); - (engine)._draw(100); - (engine)._draw(100); + clock.step(100); + clock.step(100); expect(engine._predraw).toHaveBeenCalledTimes(2); expect(engine.onPreDraw).toHaveBeenCalledTimes(2); }); it('can have onPostDraw overridden safely', () => { + engine.start(); + const clock = engine.clock as ex.TestClock; + expect(engine.clock.isRunning()).toBe(true); + engine.currentScene._initialize(engine); engine.onPostDraw = (ctx, delta) => { expect(ctx).not.toBe(null); @@ -478,8 +568,8 @@ describe('The engine', () => { spyOn(engine, 'onPostDraw').and.callThrough(); spyOn(engine, '_postdraw').and.callThrough(); - (engine)._draw(100); - (engine)._draw(100); + clock.step(100); + clock.step(100); expect(engine._postdraw).toHaveBeenCalledTimes(2); expect(engine.onPostDraw).toHaveBeenCalledTimes(2); diff --git a/src/spec/FrameStatsSpec.ts b/src/spec/FrameStatsSpec.ts index 54835163..55b7c5a0 100644 --- a/src/spec/FrameStatsSpec.ts +++ b/src/spec/FrameStatsSpec.ts @@ -1,12 +1,12 @@ import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; import { Mocks } from './util/Mocks'; +import { TestClock } from '@excalibur'; describe('The engine', () => { let engine: ex.Engine; let scene: ex.Scene; const mock = new Mocks.Mocker(); - let loop: Mocks.GameLoopLike; let actor: ex.Actor; let stats: ex.FrameStats; @@ -20,13 +20,11 @@ describe('The engine', () => { engine.addScene('root', scene); engine.goToScene('root'); actor = new ex.Actor({ x: 0, y: 0, width: 10, height: 10, color: ex.Color.Red }); - loop = mock.loop(engine); scene.add(actor); - engine.start(); - - loop.advance(100); - + TestUtils.runToReady(engine); + const clock = engine.clock as TestClock; + clock.step(16.6); stats = engine.stats.currFrame; }); @@ -42,11 +40,11 @@ describe('The engine', () => { describe('after frame is ended', () => { it('should collect frame delta', () => { - expect(stats.delta).toBe(16, 'Frame stats delta is wrong'); + expect(stats.delta).withContext('Frame stats delta should be ~16ms').toBeCloseTo(16.6, 0); }); it('should collect frame fps', () => { - expect(stats.fps).toBe(62.5, 'Frame stats fps is wrong'); + expect(stats.fps).withContext('Frame stats fps should be ~60fps').toBeCloseTo(60); }); it('should collect frame actor stats', () => { diff --git a/src/spec/LoaderSpec.ts b/src/spec/LoaderSpec.ts index e2ae1ca0..eec3c9a8 100644 --- a/src/spec/LoaderSpec.ts +++ b/src/spec/LoaderSpec.ts @@ -176,6 +176,7 @@ describe('A loader', () => { it('can have the enter key pressed to start', (done) => { const loader = new ex.Loader([, , , ,]); + loader.wireEngine(engine); loader.loadingBarPosition = ex.vec(0, 0); loader.loadingBarColor = ex.Color.Red; loader.markResourceComplete(); @@ -214,24 +215,26 @@ describe('A loader', () => { target.dispatchEvent(evt); } - it('does not propagate the start button click to pointers', (done) => { + it('does not propagate the start button click to pointers', async () => { const engine = new ex.Engine({ width: 1000, height: 1000 }); + (ex.WebAudio as any)._UNLOCKED = true; + const clock = engine.clock = engine.clock.toTestClock(); const pointerHandler = jasmine.createSpy('pointerHandler'); engine.input.pointers.primary.on('up', pointerHandler); const loader = new Loader([new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png')]); engine.start(loader); - setTimeout(() => { - const btn = (loader as any)._playButton; - const btnClickHandler = jasmine.createSpy('btnClickHandler'); - btn.addEventListener('pointerup', btnClickHandler); - const rect = btn.getBoundingClientRect(); - executeMouseEvent('pointerup', btn as any, ex.Input.NativePointerButton.Left, rect.x + rect.width / 2, rect.y + rect.height / 2); + await loader.areResourcesLoaded(); + clock.step(200); - expect(pointerHandler).not.toHaveBeenCalled(); - expect(btnClickHandler).toHaveBeenCalled(); - done(); - }, 1000); + const btn = (loader as any)._playButton; + const btnClickHandler = jasmine.createSpy('btnClickHandler'); + btn.addEventListener('pointerup', btnClickHandler); + const rect = btn.getBoundingClientRect(); + executeMouseEvent('pointerup', btn as any, ex.Input.NativePointerButton.Left, rect.x + rect.width / 2, rect.y + rect.height / 2); + + expect(pointerHandler).not.toHaveBeenCalled(); + expect(btnClickHandler).toHaveBeenCalled(); }); it('updates the play button postion on resize', () => { diff --git a/src/spec/ParticleSpec.ts b/src/spec/ParticleSpec.ts index adda772e..ac5ef24b 100644 --- a/src/spec/ParticleSpec.ts +++ b/src/spec/ParticleSpec.ts @@ -42,6 +42,9 @@ describe('A particle', () => { engine.addScene('root', scene); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + texture = new ex.LegacyDrawing.Texture('src/spec/images/SpriteFontSpec/SpriteFont.png', true); }); afterEach(() => { diff --git a/src/spec/PointerInputSpec.ts b/src/spec/PointerInputSpec.ts index 06a839f0..2240727d 100644 --- a/src/spec/PointerInputSpec.ts +++ b/src/spec/PointerInputSpec.ts @@ -27,6 +27,9 @@ describe('A pointer', () => { pointerScope: ex.Input.PointerScope.Document }); engine.start(); + + const clock = engine.clock as ex.TestClock; + clock.step(1); }); afterEach(() => { diff --git a/src/spec/ResourceSpec.ts b/src/spec/ResourceSpec.ts index d8e4650b..60e6c732 100644 --- a/src/spec/ResourceSpec.ts +++ b/src/spec/ResourceSpec.ts @@ -34,7 +34,7 @@ describe('A generic Resource', () => { it('should not fail on load', (done) => { const emptyLoader = new ex.Loader(); const game = TestUtils.engine(); - game.start(emptyLoader).then(() => { + TestUtils.runToReady(game, emptyLoader).then(() => { expect(emptyLoader.isLoaded()).toBe(true); game.stop(); done(); diff --git a/src/spec/ScaleSpec.ts b/src/spec/ScaleSpec.ts index 5c63b079..0c3b9e4d 100644 --- a/src/spec/ScaleSpec.ts +++ b/src/spec/ScaleSpec.ts @@ -26,9 +26,10 @@ describe('A scaled and rotated actor', () => { }); it('is drawn correctly scaled at 90 degrees', (done) => { + const clock = engine.clock as ex.TestClock; const bg = new ex.LegacyDrawing.Texture('./src/spec/images/ScaleSpec/logo.png', true); - - engine.start(new ex.Loader([bg])).then(() => { + const loader = new ex.Loader([bg]); + TestUtils.runToReady(engine, loader).then(() => { const actor = new ex.Actor({ x: engine.halfDrawWidth, y: engine.halfDrawHeight, @@ -49,6 +50,7 @@ describe('A scaled and rotated actor', () => { done(); }); }); + clock.step(1); }); }); }); diff --git a/src/spec/SceneSpec.ts b/src/spec/SceneSpec.ts index d6339ecb..4aa578eb 100644 --- a/src/spec/SceneSpec.ts +++ b/src/spec/SceneSpec.ts @@ -19,6 +19,9 @@ describe('A scene', () => { engine.addScene('root', scene); engine.goToScene('root'); engine.start(); + + const clock = engine.clock as ex.TestClock; + clock.step(100); }); afterEach(() => { @@ -343,6 +346,8 @@ describe('A scene', () => { engine.goToScene('root'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(100); }); it('fires initialize before actor initialize before activate', (done) => { @@ -372,10 +377,13 @@ describe('A scene', () => { scene.add(actor); engine.goToScene('root'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(100); }); it('can only be initialized once', () => { engine = TestUtils.engine({ width: 100, height: 100 }); + const clock = engine.clock as ex.TestClock; scene = new ex.Scene(); engine.removeScene('root'); @@ -388,6 +396,7 @@ describe('A scene', () => { engine.goToScene('root'); engine.start(); + clock.step(1); scene.update(engine, 100); scene.update(engine, 100); scene._initialize(engine); @@ -399,6 +408,7 @@ describe('A scene', () => { it('should initialize before actors in the scene', () => { engine = TestUtils.engine({ width: 100, height: 100 }); + const clock = engine.clock as ex.TestClock; scene = new ex.Scene(); engine.removeScene('root'); @@ -416,6 +426,7 @@ describe('A scene', () => { engine.goToScene('root'); engine.start(); + clock.step(1); scene.update(engine, 100); }); @@ -662,6 +673,7 @@ describe('A scene', () => { }); it('can have onInitialize overridden safely', () => { + const clock = engine.clock as ex.TestClock; let initCalled = false; scene.onInitialize = (engine) => { expect(engine).not.toBe(null); @@ -673,8 +685,9 @@ describe('A scene', () => { spyOn(scene, 'onInitialize').and.callThrough(); + TestUtils.runToReady(engine); engine.goToScene('root'); - (engine)._update(100); + clock.step(100); expect(initCalled).toBe(true); expect(scene.onInitialize).toHaveBeenCalledTimes(1); diff --git a/src/spec/ScreenElementSpec.ts b/src/spec/ScreenElementSpec.ts index 2ab06476..13fbb012 100644 --- a/src/spec/ScreenElementSpec.ts +++ b/src/spec/ScreenElementSpec.ts @@ -7,7 +7,6 @@ describe('A ScreenElement', () => { let screenElement: ex.ScreenElement; let engine: ex.Engine; let scene: ex.Scene; - const mock = new Mocks.Mocker(); beforeEach(() => { jasmine.addMatchers(ExcaliburMatchers); @@ -27,6 +26,9 @@ describe('A ScreenElement', () => { engine.goToScene('test'); engine.start(); + const clock = engine.clock as ex.TestClock; + clock.step(1); + spyOn(scene, 'draw').and.callThrough(); spyOn(screenElement, 'draw').and.callThrough(); }); @@ -92,9 +94,10 @@ describe('A ScreenElement', () => { it('is drawn on the top left with empty constructor', (done) => { const game = TestUtils.engine({ width: 720, height: 480 }); + const clock = game.clock as ex.TestClock; const bg = new ex.LegacyDrawing.Texture('src/spec/images/ScreenElementSpec/emptyctor.png', true); - - game.start(new ex.Loader([bg])).then(() => { + const loader = new ex.Loader([bg]); + TestUtils.runToReady(game, loader).then(() => { const screenElement = new ex.ScreenElement(); screenElement.addDrawing(bg); game.add(screenElement); @@ -107,6 +110,7 @@ describe('A ScreenElement', () => { done(); }); }); + clock.step(1); }); }); }); diff --git a/src/spec/TileMapSpec.ts b/src/spec/TileMapSpec.ts index 5450aea2..942832a6 100644 --- a/src/spec/TileMapSpec.ts +++ b/src/spec/TileMapSpec.ts @@ -17,7 +17,7 @@ describe('A TileMap', () => { let engine: ex.Engine; let scene: ex.Scene; let texture: ex.LegacyDrawing.Texture; - beforeEach(() => { + beforeEach(async () => { jasmine.addMatchers(ExcaliburMatchers); jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); engine = TestUtils.engine({ @@ -27,8 +27,10 @@ describe('A TileMap', () => { scene = new ex.Scene(); engine.addScene('root', scene); engine.start(); - + const clock = engine.clock as ex.TestClock; texture = new ex.LegacyDrawing.Texture('src/spec/images/TileMapSpec/Blocks.png', true); + await texture.load(); + clock.step(1); }); afterEach(() => { engine.stop(); diff --git a/src/spec/TimescalingSpec.ts b/src/spec/TimescalingSpec.ts index aa2d513d..3177fb1d 100644 --- a/src/spec/TimescalingSpec.ts +++ b/src/spec/TimescalingSpec.ts @@ -5,21 +5,18 @@ import { TestUtils } from './util/TestUtils'; describe('The engine', () => { let engine: ex.Engine; let scene: ex.Scene; - const mock = new Mocks.Mocker(); - let loop: Mocks.GameLoopLike; let actor: ex.Actor; + let clock: ex.TestClock; - beforeEach((done) => { + beforeEach(async () => { engine = TestUtils.engine({ width: 0, height: 0 }); scene = new ex.Scene(); engine.add('test', scene); engine.goToScene('test'); actor = new ex.Actor({x: 0, y: 0, width: 10, height: 10, color: ex.Color.Red}); scene.add(actor); - loop = mock.loop(engine); - engine.start().then(() => { - done(); - }); + clock = engine.clock as ex.TestClock; + await TestUtils.runToReady(engine); }); afterEach(() => { @@ -40,7 +37,7 @@ describe('The engine', () => { // 1s = 5px // 5px * 2x = 10px // actor moves twice as fast - loop.advance(1100); + clock.step(1000); expect(actor.pos.x).toBe(10, 'actor did not move twice as fast'); }); @@ -54,7 +51,7 @@ describe('The engine', () => { // 2s = 10px // 10px * 0.5x = 5px // actor moves twice as slow - loop.advance(2000); + clock.step(2000); expect(actor.pos.x).toBeCloseTo(5, 0.2, 'actor did not move twice as slow'); }); diff --git a/src/spec/TriggerSpec.ts b/src/spec/TriggerSpec.ts index 3a2d9667..f685ad7c 100644 --- a/src/spec/TriggerSpec.ts +++ b/src/spec/TriggerSpec.ts @@ -5,18 +5,18 @@ import { TestUtils } from './util/TestUtils'; describe('A Trigger', () => { let scene: ex.Scene; let engine: ex.Engine; - const mock = new Mocks.Mocker(); - let loop: Mocks.GameLoopLike; + let clock: ex.TestClock; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ width: 600, height: 400 }); scene = new ex.Scene(); engine.addScene('test', scene); engine.goToScene('test'); - loop = mock.loop(engine); - engine.start(); + await TestUtils.runToReady(engine); + clock = engine.clock as ex.TestClock; + clock.step(1); }); afterEach(() => { @@ -151,9 +151,7 @@ describe('A Trigger', () => { // Act actor.vel = ex.vec(0, 10); - for (let i = 0; i < 40; i++) { - loop.advance(1000); - } + clock.run(40, 1000); expect(fired).toBe(1); }); @@ -181,9 +179,7 @@ describe('A Trigger', () => { // Act actor.vel = ex.vec(0, 10); - for (let i = 0; i < 40; i++) { - loop.advance(1000); - } + clock.run(40, 1000); // Assert expect(exitSpy).toHaveBeenCalledTimes(1); @@ -202,9 +198,7 @@ describe('A Trigger', () => { spyOn(trigger, 'draw'); // Act - for (let i = 0; i < 2; i++) { - loop.advance(1000); - } + clock.run(2, 1000); // Assert expect(trigger.draw).not.toHaveBeenCalled(); @@ -223,9 +217,7 @@ describe('A Trigger', () => { spyOn(trigger, 'draw'); // Act - for (let i = 0; i < 2; i++) { - loop.advance(1000); - } + clock.run(2, 1000); // Assert expect(trigger.draw).toHaveBeenCalled(); @@ -249,9 +241,7 @@ describe('A Trigger', () => { spyOn(trigger, 'action'); // Act - for (let i = 0; i < 2; i++) { - loop.advance(1000); - } + clock.run(2, 1000); // Assert expect(trigger.action).not.toHaveBeenCalled(); @@ -277,9 +267,7 @@ describe('A Trigger', () => { spyOn(trigger, 'action').and.callThrough(); // Act - for (let i = 0; i < 2; i++) { - loop.advance(1000); - } + clock.run(2, 1000); // Assert expect(trigger.action).toHaveBeenCalled(); @@ -303,9 +291,7 @@ describe('A Trigger', () => { spyOn(trigger, 'action'); // Act - for (let i = 0; i < 2; i++) { - loop.advance(1000); - } + clock.run(2, 1000); // Assert expect(trigger.action).not.toHaveBeenCalled(); diff --git a/src/spec/util/TestUtils.ts b/src/spec/util/TestUtils.ts index 2839e77c..84ff8279 100644 --- a/src/spec/util/TestUtils.ts +++ b/src/spec/util/TestUtils.ts @@ -26,6 +26,29 @@ export namespace TestUtils { flags.forEach(f => ex.Flags.enable(f)); const game = new ex.Engine(options); + // Make all the clocks test clocks in the test utils + game.clock.stop(); + game.clock = game.clock.toTestClock(); + + (ex.WebAudio as any)._UNLOCKED = true; + return game; } + + /** + * + */ + export async function runToReady(engine: ex.Engine, loader?: ex.Loader) { + const clock = engine.clock as ex.TestClock; + const start = engine.start(loader); + // If loader + if (loader) { + await loader.areResourcesLoaded(); + clock.step(200); + queueMicrotask(() => { + clock.step(500); + }); + await engine.isReady(); + } + } } diff --git a/wallaby.js b/wallaby.js index 4b7e66c5..eaf036a8 100644 --- a/wallaby.js +++ b/wallaby.js @@ -4,6 +4,7 @@ const webpack = require('webpack'); module.exports = function (wallaby) { return { files: [ + { pattern: 'src/spec/util/*.ts', load: false }, { pattern: 'src/engine/**/*.ts', load: false }, { pattern: 'src/engine/**/*.glsl', load: false }, { pattern: 'src/spec/images/**/*.mp3' },