diff --git a/.eslintrc.json b/.eslintrc.json index 26ec91ce4..979f20807 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -59,6 +59,7 @@ "no-unused-labels": "error", "no-var": "error", "prefer-const": "error", + "require-await": "warn", "radix": "error", "max-len": ["error", { "code": 140 }], "semi": ["error", "always"], diff --git a/CHANGELOG.md b/CHANGELOG.md index e7e84ebd6..c0d8de303 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,49 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### Added -- +- Scene Transition & Loader API, this gives you the ability to have first class support for individual scene resource loading and scene transitions. + * Add or remove scenes by constructor + * Add loaders by constructor + * New `ex.DefaultLoader` type that allows for easier custom loader creation + * New `ex.Transition` type for building custom transitions + * New scene lifecycle to allow scene specific resource loading + * `onTransition(direction: "in" | "out") {...}` + * `onPreLoad(loader: DefaultLoader) {...}` + * New async goto API that allows overriding loaders/transitions between scenes + * Scenes now can have `async onInitialize` and `async onActivate`! + * New scenes director API that allows upfront definition of scenes/transitions/loaders + + * Example: + Defining scenes upfront + ```typescript + const game = new ex.Engine({ + scenes: { + scene1: { + scene: scene1, + transitions: { + out: new ex.FadeInOut({duration: 1000, direction: 'out', color: ex.Color.Black}), + in: new ex.FadeInOut({duration: 1000, direction: 'in'}) + } + }, + scene2: { + scene: scene2, + loader: ex.DefaultLoader, // Constructor only option! + transitions: { + out: new ex.FadeInOut({duration: 1000, direction: 'out'}), + in: new ex.FadeInOut({duration: 1000, direction: 'in', color: ex.Color.Black }) + } + }, + scene3: ex.Scene // Constructor only option! + } + }) + + // Specify the boot loader & first scene transition from loader + game.start('scene1', + { + inTransition: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.ExcaliburBlue}) + loader: boot, + }); + ``` ### Fixed diff --git a/package-lock.json b/package-lock.json index fbfdf6358..8b40043e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -63,7 +63,6 @@ "typedoc": "0.25.3", "typescript": "5.3.3", "url-loader": "4.1.1", - "wallaby-webpack": "3.9.16", "webpack": "5.89.0", "webpack-cli": "5.1.4" } @@ -27264,29 +27263,6 @@ "makeerror": "1.0.12" } }, - "node_modules/wallaby-webpack": { - "version": "3.9.16", - "resolved": "https://registry.npmjs.org/wallaby-webpack/-/wallaby-webpack-3.9.16.tgz", - "integrity": "sha512-z252lpX5+SrE3DM/YM1XKqJNsYjO5Sd9ATPe9MZCvPHDJZk015OVkBbJqBlcTwOjSS3WCrMQzm6t9tgFnNYHIg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.3", - "lodash": "^4.17.10", - "minimatch": "3.0.3" - } - }, - "node_modules/wallaby-webpack/node_modules/minimatch": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", - "integrity": "sha512-NyXjqu1IwcqH6nv5vmMtaG3iw7kdV3g6MwlUBZkc3Vn5b5AMIWYKfptvzipoyFfhlfOgBQ9zoTxQMravF1QTnw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, "node_modules/watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", @@ -49124,28 +49100,6 @@ "makeerror": "1.0.12" } }, - "wallaby-webpack": { - "version": "3.9.16", - "resolved": "https://registry.npmjs.org/wallaby-webpack/-/wallaby-webpack-3.9.16.tgz", - "integrity": "sha512-z252lpX5+SrE3DM/YM1XKqJNsYjO5Sd9ATPe9MZCvPHDJZk015OVkBbJqBlcTwOjSS3WCrMQzm6t9tgFnNYHIg==", - "dev": true, - "requires": { - "graceful-fs": "^4.1.3", - "lodash": "^4.17.10", - "minimatch": "3.0.3" - }, - "dependencies": { - "minimatch": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.3.tgz", - "integrity": "sha512-NyXjqu1IwcqH6nv5vmMtaG3iw7kdV3g6MwlUBZkc3Vn5b5AMIWYKfptvzipoyFfhlfOgBQ9zoTxQMravF1QTnw==", - "dev": true, - "requires": { - "brace-expansion": "^1.0.0" - } - } - } - }, "watchpack": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", diff --git a/package.json b/package.json index 5f65bc247..5b2b74e02 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,6 @@ "typedoc": "0.25.3", "typescript": "5.3.3", "url-loader": "4.1.1", - "wallaby-webpack": "3.9.16", "webpack": "5.89.0", "webpack-cli": "5.1.4" }, diff --git a/sandbox/src/game.ts b/sandbox/src/game.ts index be70c5764..390313989 100644 --- a/sandbox/src/game.ts +++ b/sandbox/src/game.ts @@ -128,16 +128,16 @@ cards2.draw(game.graphicsContext, 0, 0); jump.volume = 0.3; -var loader = new ex.Loader(); -loader.addResource(heartImageSource); -loader.addResource(heartTex); -loader.addResource(imageRun); -loader.addResource(imageJump); -loader.addResource(imageBlocks); -loader.addResource(spriteFontImage); -loader.addResource(cards); -loader.addResource(cloud); -loader.addResource(jump); +var boot = new ex.Loader(); +boot.addResource(heartImageSource); +boot.addResource(heartTex); +boot.addResource(imageRun); +boot.addResource(imageJump); +boot.addResource(imageBlocks); +boot.addResource(spriteFontImage); +boot.addResource(cards); +boot.addResource(cloud); +boot.addResource(jump); // Set background color game.backgroundColor = new ex.Color(114, 213, 224); @@ -942,6 +942,6 @@ game.currentScene.camera.strategy.lockToActorAxis(player, ex.Axis.X); game.currentScene.camera.y = 200; // Run the mainloop -game.start(loader).then(() => { +game.start(boot).then(() => { logger.info('All Resources have finished loading'); }); \ No newline at end of file diff --git a/sandbox/tests/gotoscene/index.ts b/sandbox/tests/gotoscene/index.ts index 3baf1bd30..47562a151 100644 --- a/sandbox/tests/gotoscene/index.ts +++ b/sandbox/tests/gotoscene/index.ts @@ -1,5 +1,8 @@ class Scene1 extends ex.Scene { - onInitialize(_engine: ex.Engine): void { + async onInitialize(_engine: ex.Engine) { + console.log('before async'); + await ex.Util.delay(1000); + console.log('after async'); const actor = new ex.Actor({ x: _engine.halfDrawWidth, y: _engine.halfDrawHeight, @@ -11,20 +14,21 @@ class Scene1 extends ex.Scene { _engine.input.pointers.primary.on( "down", - (event: ex.PointerEvent): void => { - _engine.goToScene("scene2"); + async (event: ex.PointerEvent) => { + await _engine.goToScene("scene2"); } ); } - onActivate(): void { + async onActivate() { console.log('Scene 1 Activate') } } class Scene2 extends ex.Scene { - onInitialize(_engine: ex.Engine): void { + async onInitialize(_engine: ex.Engine) { + await ex.Util.delay(1000); // _engine.start(); const actor = new ex.Actor({ pos: ex.Vector.Zero, @@ -32,9 +36,11 @@ class Scene2 extends ex.Scene { height: 1000, color: ex.Color.Cyan, }); + actor.angularVelocity = 1; _engine.add(actor); } - onActivate(): void { + async onActivate() { + await ex.Util.delay(1000); console.log('Scene 2 Activate') } } diff --git a/sandbox/tests/router/index.html b/sandbox/tests/router/index.html new file mode 100644 index 000000000..595383858 --- /dev/null +++ b/sandbox/tests/router/index.html @@ -0,0 +1,12 @@ + + + + + + Router Test + + + + + + \ No newline at end of file diff --git a/sandbox/tests/router/index.ts b/sandbox/tests/router/index.ts new file mode 100644 index 000000000..9b9371858 --- /dev/null +++ b/sandbox/tests/router/index.ts @@ -0,0 +1,139 @@ +/// +var scene1 = new ex.Scene(); +scene1.add(new ex.Label({ + pos: ex.vec(100, 100), + color: ex.Color.Green, + text: 'Scene 1', + z: 99 +})) +var scene2 = new ex.Scene(); +scene2.add(new ex.Label({ + pos: ex.vec(100, 100), + color: ex.Color.Violet, + text: 'Scene 2', + z: 99 +})) + +class MyCustomScene extends ex.Scene { + onTransition(direction: "in" | "out") { + return new ex.FadeInOut({ + direction, + color: ex.Color.Violet, + duration: 2000 + }); + } + onPreLoad(loader: ex.DefaultLoader): void { + const image1 = new ex.ImageSource('./spritefont.png?=1'); + const image2 = new ex.ImageSource('./spritefont.png?=2'); + const image3 = new ex.ImageSource('./spritefont.png?=3'); + const image4 = new ex.ImageSource('./spritefont.png?=4'); + const sword = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png'); + loader.addResource(image1); + loader.addResource(image2); + loader.addResource(image3); + loader.addResource(image4); + loader.addResource(sword); + } + onActivate(context: ex.SceneActivationContext): void { + console.log(context.data); + } +} + +let scenes = { + scene1: { + scene: scene1, + transitions: { + in: new ex.FadeInOut({duration: 500, direction: 'in'}) + } + }, + scene2: { + scene: scene2, + loader: ex.DefaultLoader, + transitions: { + out: new ex.FadeInOut({duration: 500, direction: 'out'}), + in: new ex.CrossFade({duration: 2500, direction: 'in', blockInput: true}) + } + }, + scene3: MyCustomScene +} satisfies ex.SceneMap; + +var gameWithTransitions = new ex.Engine({ + width: 800, + height: 600, + displayMode: ex.DisplayMode.FitScreenAndFill, + scenes +}); + + +var actor = new ex.Actor({ + width: 100, + height: 100, + pos: ex.vec(100, 100), + color: ex.Color.Red +}) +actor.addChild(new ex.Actor({ + width: 100, + height: 100, + pos: ex.vec(100, 100), + color: ex.Color.Black +})); +scene1.add(actor); + + +scene2.onPreLoad = (loader) => { + const image1 = new ex.ImageSource('./spritefont.png?=1'); + const image2 = new ex.ImageSource('./spritefont.png?=2'); + const image3 = new ex.ImageSource('./spritefont.png?=3'); + const image4 = new ex.ImageSource('./spritefont.png?=4'); + const sword = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png'); + loader.addResource(image1); + loader.addResource(image2); + loader.addResource(image3); + loader.addResource(image4); + loader.addResource(sword); +} +scene1.onActivate = () => { + setTimeout(() => { + gameWithTransitions.goto('scene2'); + // router.goto('scene2', { + // outTransition: new ex.FadeOut({duration: 1000, direction: 'in'}), + // inTransition: new ex.FadeOut({duration: 1000, direction: 'out'}) + // }); + }, 1000); +} +scene2.add(new ex.Actor({ + width: 100, + height: 100, + pos: ex.vec(400, 400), + color: ex.Color.Blue +})); + +var boot = new ex.Loader(); +const image1 = new ex.ImageSource('./spritefont.png?=1'); +const image2 = new ex.ImageSource('./spritefont.png?=2'); +const image3 = new ex.ImageSource('./spritefont.png?=3'); +const image4 = new ex.ImageSource('./spritefont.png?=4'); +const sword = new ex.ImageSource('https://cdn.rawgit.com/excaliburjs/Excalibur/7dd48128/assets/sword.png'); +boot.addResource(image1); +boot.addResource(image2); +boot.addResource(image3); +boot.addResource(image4); +boot.addResource(sword); +gameWithTransitions.input.keyboard.on('press', evt => { + gameWithTransitions.goto('scene3', { + sceneActivationData: { data: 1 } + }); +}); +gameWithTransitions.input.pointers.primary.on('down', () => { + gameWithTransitions.goto('scene1'); +}); +var startTransition = new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.ExcaliburBlue}); +// startTransition.events.on('kill', () => { +// console.log(game.currentScene.entities); +// console.log('killed!'); +// }) +gameWithTransitions.start('scene1', +{ + inTransition: startTransition, + loader: boot +}); \ No newline at end of file diff --git a/sandbox/tests/router/spritefont.png b/sandbox/tests/router/spritefont.png new file mode 100644 index 000000000..a7fbf645b Binary files /dev/null and b/sandbox/tests/router/spritefont.png differ diff --git a/sandbox/tests/scene/lifecycle.ts b/sandbox/tests/scene/lifecycle.ts index 29b97e01e..7554ed9fe 100644 --- a/sandbox/tests/scene/lifecycle.ts +++ b/sandbox/tests/scene/lifecycle.ts @@ -17,7 +17,7 @@ class MyGame2 extends ex.Engine { console.log("scene deactivate"); } } - onInitialize() { + async onInitialize() { console.log("engine init"); } } diff --git a/sandbox/tests/sound-loop/index.ts b/sandbox/tests/sound-loop/index.ts index 7f5e5da02..1cf589a63 100644 --- a/sandbox/tests/sound-loop/index.ts +++ b/sandbox/tests/sound-loop/index.ts @@ -27,13 +27,13 @@ class BaseScene extends ex.Scene { } } -var scene1 = new BaseScene("scene1"); +var scene11 = new BaseScene("scene1"); var scene22 = new BaseScene("scene2"); -var scene3 = new BaseScene("scene3"); +var scene33 = new BaseScene("scene3"); -game.add('scene1', scene1); +game.add('scene1', scene11); game.add('scene2', scene22); -game.add('scene3', scene3); +game.add('scene3', scene33); game.start(loader).then(() => { game.goToScene('scene1'); diff --git a/sandbox/tsconfig.json b/sandbox/tsconfig.json index 4ee0140a4..6c1fe2dcd 100644 --- a/sandbox/tsconfig.json +++ b/sandbox/tsconfig.json @@ -2,8 +2,8 @@ "compilerOptions": { "sourceMap": true, "experimentalDecorators": true, - "target": "es2015", - "module": "es2015", + "target": "ES2022", + "module": "ESNext", "removeComments": false, "skipLibCheck": true, "lib": ["dom", "es5", "es2015", "es2015.promise", "es2015.collection", "es2015.iterable"] diff --git a/src/engine/Collision/CollisionSystem.ts b/src/engine/Collision/CollisionSystem.ts index cf8b799ce..c530688a6 100644 --- a/src/engine/Collision/CollisionSystem.ts +++ b/src/engine/Collision/CollisionSystem.ts @@ -11,7 +11,10 @@ import { RealisticSolver } from './Solver/RealisticSolver'; import { CollisionSolver } from './Solver/Solver'; import { ColliderComponent } from './ColliderComponent'; import { CompositeCollider } from './Colliders/CompositeCollider'; -import { Engine, ExcaliburGraphicsContext, Scene, Side } from '..'; +import { Engine } from '../Engine'; +import { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext'; +import { Scene } from '../Scene'; +import { Side } from '../Collision/Side'; import { DynamicTreeCollisionProcessor } from './Detection/DynamicTreeCollisionProcessor'; import { PhysicsWorld } from './PhysicsWorld'; export class CollisionSystem extends System { diff --git a/src/engine/Debug/Debug.ts b/src/engine/Debug/Debug.ts index b1e5466da..eddf3742d 100644 --- a/src/engine/Debug/Debug.ts +++ b/src/engine/Debug/Debug.ts @@ -2,7 +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 '..'; +import { StandardClock, TestClock } from '../Util/Clock'; /** * Debug stats containing current and previous frame statistics diff --git a/src/engine/Debug/DebugFlags.ts b/src/engine/Debug/DebugFlags.ts index c2574da2e..c83031bf6 100644 --- a/src/engine/Debug/DebugFlags.ts +++ b/src/engine/Debug/DebugFlags.ts @@ -1,7 +1,7 @@ import { ColorBlindnessMode } from '../Graphics/PostProcessor/ColorBlindnessMode'; import { ColorBlindnessPostProcessor } from '../Graphics/PostProcessor/ColorBlindnessPostProcessor'; import { Engine } from '../Engine'; -import { ExcaliburGraphicsContextWebGL } from '..'; +import { ExcaliburGraphicsContextWebGL } from '../Graphics/Context/ExcaliburGraphicsContextWebGL'; export interface DebugFlags { colorBlindMode: ColorBlindFlags; diff --git a/src/engine/Debug/DebugSystem.ts b/src/engine/Debug/DebugSystem.ts index fcc139321..68b8d2190 100644 --- a/src/engine/Debug/DebugSystem.ts +++ b/src/engine/Debug/DebugSystem.ts @@ -8,7 +8,11 @@ import { System, SystemType } from '../EntityComponentSystem/System'; import { ExcaliburGraphicsContext } from '../Graphics/Context/ExcaliburGraphicsContext'; import { vec, Vector } from '../Math/vector'; import { toDegrees } from '../Math/util'; -import { BodyComponent, CollisionSystem, CompositeCollider, GraphicsComponent, Particle } from '..'; +import { BodyComponent } from '../Collision/BodyComponent'; +import { CollisionSystem } from '../Collision/CollisionSystem'; +import { CompositeCollider } from '../Collision/Colliders/CompositeCollider'; +import { GraphicsComponent } from '../Graphics/GraphicsComponent'; +import { Particle } from '../Particles'; import { DebugGraphicsComponent } from '../Graphics/DebugGraphicsComponent'; import { CoordPlane } from '../Math/coord-plane'; diff --git a/src/engine/Director/CrossFade.ts b/src/engine/Director/CrossFade.ts new file mode 100644 index 000000000..b9bc9240b --- /dev/null +++ b/src/engine/Director/CrossFade.ts @@ -0,0 +1,54 @@ +import { ImageSource, Sprite } from '../Graphics'; +import { Engine } from '../Engine'; +import { Scene } from '../Scene'; +import { Transition, TransitionOptions } from './Transition'; +import { vec } from '../Math/vector'; + +export interface CrossFadeOptions { + duration: number; +} + +/** + * CrossFades between the previous scene and the destination scene + * + * Note: CrossFade only works as an "in" transition + */ +export class CrossFade extends Transition { + engine: Engine; + image: HTMLImageElement; + screenCover: Sprite; + constructor(options: TransitionOptions & CrossFadeOptions) { + super(options); + this.name = `CrossFade#${this.id}`; + } + + override async onPreviousSceneDeactivate(scene: Scene) { + this.image = await scene.engine.screenshot(true); + } + + override onInitialize(engine: Engine): void { + this.engine = engine; + const bounds = engine.screen.getWorldBounds(); + this.transform.pos = vec(bounds.left, bounds.top); + this.screenCover = ImageSource.fromHtmlImageElement(this.image).toSprite(); + this.graphics.add(this.screenCover); + this.transform.scale = vec(1 / engine.screen.pixelRatio, 1 / engine.screen.pixelRatio); + this.graphics.opacity = this.progress; + } + + override onStart(_progress: number): void { + this.graphics.opacity = this.progress; + } + + override onReset() { + this.graphics.opacity = this.progress; + } + + override onEnd(progress: number): void { + this.graphics.opacity = progress; + } + + override onUpdate(progress: number): void { + this.graphics.opacity = progress; + } +} \ No newline at end of file diff --git a/src/engine/Director/DefaultLoader.ts b/src/engine/Director/DefaultLoader.ts new file mode 100644 index 000000000..bafe3dcc8 --- /dev/null +++ b/src/engine/Director/DefaultLoader.ts @@ -0,0 +1,260 @@ +import { WebAudio } from '../Util/WebAudio'; +import { Engine } from '../Engine'; +import { Loadable } from '../Interfaces/Loadable'; +import { Canvas } from '../Graphics/Canvas'; +import { ImageFiltering } from '../Graphics/Filtering'; +import { clamp } from '../Math/util'; +import { Sound } from '../Resources/Sound/Sound'; +import { Future } from '../Util/Future'; +import { EventEmitter, EventKey, Handler, Subscription } from '../EventEmitter'; +import { Color } from '../Color'; +import { delay } from '../Util/Util'; + +export interface LoaderOptions { + loadables: Loadable[]; +} + +export type LoaderEvents = { + // Add event types here + beforeload: void, + afterload: void, + useraction: void, + loadresourcestart: Loadable, + loadresourceend: Loadable, +} + +export const LoaderEvents = { + // Add event types here + BeforeLoad: 'beforeload', + AfterLoad: 'afterload', + UserAction: 'useraction', + LoadResourceStart: 'loadresourcestart', + LoadResourceEnd: 'loadresourceend' +}; + +export type LoaderConstructor = new (...args: any[]) => DefaultLoader; +/** + * Returns true if the constructor is for an Excalibur Loader + */ +export function isLoaderConstructor(x: any): x is LoaderConstructor { + return !!x?.prototype && !!x?.prototype?.constructor?.name; +} + +export class DefaultLoader implements Loadable[]> { + public data: Loadable[]; + public events = new EventEmitter(); + public canvas: Canvas = new Canvas({ + filtering: ImageFiltering.Blended, + smoothing: true, + cache: false, + draw: this.onDraw.bind(this) + }); + private _resources: Loadable[] = []; + public get resources(): readonly Loadable[] { + return this._resources; + } + private _numLoaded: number = 0; + public engine: Engine; + + + /** + * @param options Optionally provide the list of resources you want to load at constructor time + */ + constructor(options?: LoaderOptions) { + if (options && options.loadables?.length) { + this.addResources(options.loadables); + } + } + + /** + * Called by the engine before loading + * @param engine + */ + public onInitialize(engine: Engine) { + this.engine = engine; + this.canvas.width = this.engine.screen.canvasWidth; + this.canvas.height = this.engine.screen.canvasHeight; + } + + /** + * Return a promise that resolves when the user interacts with the loading screen in some way, usually a click. + * + * It's important to implement this in order to unlock the audio context in the browser. Browsers automatically prevent + * audio from playing until the user performs an action. + * + */ + public async onUserAction(): Promise { + + return await Promise.resolve(); + } + + /** + * Overridable lifecycle method, called directly before loading starts + */ + public async onBeforeLoad() { + // override me + } + + /** + * Overridable lifecycle method, called after loading has completed + */ + public async onAfterLoad() { + // override me + await delay(500, this.engine.clock); // avoid a flicker + } + + /** + * Add a resource to the loader to load + * @param loadable Resource to add + */ + public addResource(loadable: Loadable) { + this._resources.push(loadable); + } + + /** + * Add a list of resources to the loader to load + * @param loadables The list of resources to load + */ + public addResources(loadables: Loadable[]) { + let i = 0; + const len = loadables.length; + + for (i; i < len; i++) { + this.addResource(loadables[i]); + } + } + + public markResourceComplete(): void { + this._numLoaded++; + } + + /** + * Returns the progress of the loader as a number between [0, 1] inclusive. + */ + public get progress(): number { + const total = this._resources.length; + return total > 0 ? clamp(this._numLoaded, 0, total) / total : 1; + } + + /** + * Returns true if the loader has completely loaded all resources + */ + public isLoaded() { + return this._numLoaded === this._resources.length; + } + + private _totalTimeMs = 0; + + /** + * Optionally override the onUpdate + * @param engine + * @param elapsedMilliseconds + */ + onUpdate(engine: Engine, elapsedMilliseconds: number): void { + this._totalTimeMs += elapsedMilliseconds; + // override me + } + + /** + * Optionally override the onDraw + */ + onDraw(ctx: CanvasRenderingContext2D) { + const seconds = this._totalTimeMs / 1000; + + ctx.fillStyle = Color.Black.toRGBA(); + ctx.fillRect(0, 0, this.engine.screen.drawWidth, this.engine.screen.drawHeight); + + ctx.save(); + ctx.translate(this.engine.screen.center.x, this.engine.screen.center.y); + const speed = seconds * 10; + ctx.strokeStyle = 'white'; + ctx.lineWidth = 10; + ctx.lineCap = 'round'; + ctx.arc(0, 0, 40, speed, speed + (Math.PI * 3 / 2)); + ctx.stroke(); + + ctx.fillStyle = 'white'; + ctx.font = '16px sans-serif'; + const text = (this.progress * 100).toFixed(0) + '%'; + const textbox = ctx.measureText(text); + const width = Math.abs(textbox.actualBoundingBoxLeft) + Math.abs(textbox.actualBoundingBoxRight); + const height = Math.abs(textbox.actualBoundingBoxAscent) + Math.abs(textbox.actualBoundingBoxDescent); + ctx.fillText(text, -width / 2, height / 2); // center + ctx.restore(); + } + + + private _loadingFuture = new Future(); + public areResourcesLoaded() { + return this._loadingFuture.promise; + } + + /** + * Not meant to be overridden + * + * Begin loading all of the supplied resources, returning a promise + * that resolves when loading of all is complete AND the user has interacted with the loading screen + */ + public async load(): Promise[]> { + await this.onBeforeLoad(); + this.events.emit('beforeload'); + this.canvas.flagDirty(); + + await Promise.all( + this._resources.map(async (r) => { + this.events.emit('loadresourcestart', r); + await r.load().finally(() => { + // capture progress + this._numLoaded++; + this.canvas.flagDirty(); + this.events.emit('loadresourceend', r); + }); + }) + ); + + // Wire all sound to the engine + for (const resource of this._resources) { + if (resource instanceof Sound) { + resource.wireEngine(this.engine); + } + } + + this._loadingFuture.resolve(); + this.canvas.flagDirty(); + // Unlock browser AudioContext in after user gesture + // See: https://github.com/excaliburjs/Excalibur/issues/262 + // See: https://github.com/excaliburjs/Excalibur/issues/1031 + await this.onUserAction(); + this.events.emit('useraction'); + await WebAudio.unlock(); + + await this.onAfterLoad(); + this.events.emit('afterload'); + return (this.data = this._resources); + } + + public emit>(eventName: TEventName, event: LoaderEvents[TEventName]): void; + public emit(eventName: string, event?: any): void; + public emit | string>(eventName: TEventName, event?: any): void { + this.events.emit(eventName, event); + } + + public on>(eventName: TEventName, handler: Handler): Subscription; + public on(eventName: string, handler: Handler): Subscription; + public on | string>(eventName: TEventName, handler: Handler): Subscription { + return this.events.on(eventName, handler); + } + + public once>(eventName: TEventName, handler: Handler): Subscription; + public once(eventName: string, handler: Handler): Subscription; + public once | string>(eventName: TEventName, handler: Handler): Subscription { + return this.events.once(eventName, handler); + } + + public off>(eventName: TEventName, handler: Handler): void; + public off(eventName: string, handler: Handler): void; + public off(eventName: string): void; + public off | string>(eventName: TEventName, handler?: Handler): void { + this.events.off(eventName, handler); + } +} diff --git a/src/engine/Director/Director.ts b/src/engine/Director/Director.ts new file mode 100644 index 000000000..1658d0ea0 --- /dev/null +++ b/src/engine/Director/Director.ts @@ -0,0 +1,553 @@ +import { Engine } from '../Engine'; +import { DefaultLoader, LoaderConstructor, isLoaderConstructor } from './DefaultLoader'; +import { Scene, SceneConstructor, isSceneConstructor } from '../Scene'; +import { Transition } from './Transition'; +import { Loader } from './Loader'; +import { Logger } from '../Util/Log'; +import { ActivateEvent, DeactivateEvent } from '../Events'; +import { EventEmitter } from '../EventEmitter'; + +export interface DirectorNavigationEvent { + sourceName: string; + sourceScene: Scene; + destinationName: string; + destinationScene: Scene; +} + +export type DirectorEvents = { + navigationstart: DirectorNavigationEvent, + navigation: DirectorNavigationEvent, + navigationend: DirectorNavigationEvent, +} + +export const DirectorEvents = { + NavigationStart: 'navigationstart', + Navigation: 'navigation', + NavigationEnd: 'navigationend' +}; + +export interface SceneWithOptions { + /** + * Scene associated with this route + * + * If a constructor is provided it will not be constructed until navigation is requested + */ + scene: Scene | SceneConstructor; + /** + * Specify scene transitions + */ + transitions?: { + /** + * Optionally specify a transition when going "in" to this scene + */ + in?: Transition; + /** + * Optionally specify a transition when going "out" of this scene + */ + out?: Transition; + }; + /** + * Optionally specify a loader for the scene + */ + loader?: DefaultLoader | LoaderConstructor; +} + +export type WithRoot = TScenes | 'root'; + +export type SceneMap = Record; + +export interface StartOptions { + /** + * First transition from the game start screen + */ + inTransition: Transition; + /** + * Optionally provide a main loader to run before the game starts + */ + loader?: DefaultLoader | LoaderConstructor +} + + +/** + * Provide scene activation data and override any existing configured route transitions or loaders + */ +export interface GoToOptions { + /** + * Optionally supply scene activation data passed to Scene.onActivate + */ + sceneActivationData?: any, + /** + * Optionally supply destination scene "in" transition, this will override any previously defined transition + */ + destinationIn?: Transition, + /** + * Optionally supply source scene "out" transition, this will override any previously defined transition + */ + sourceOut?: Transition, + /** + * Optionally supply a different loader for the destination scene, this will override any previously defined loader + */ + loader?: DefaultLoader +} + +/** + * The Director is responsible for managing scenes and changing scenes in Excalibur. + * + * It deals with transitions, scene loaders, switching scenes + * + * This is used internally by Excalibur, generally not mean to + * be instantiated end users directly. + */ +export class Director { + public events = new EventEmitter(); + private _logger = Logger.getInstance(); + private _deferredGoto: string; + private _initialized = false; + + /** + * Current scene's name + */ + currentSceneName: string; + /** + * Current scene playing in excalibur + */ + currentScene: Scene; + /** + * Current transition if any + */ + currentTransition: Transition | null; + + /** + * All registered scenes in Excalibur + */ + public readonly scenes: SceneMap> = {} as SceneMap>; + + /** + * Holds all instantiated scenes + */ + private _sceneToInstance = new Map(); + + startScene: string; + mainLoader: DefaultLoader; + + /** + * The default [[Scene]] of the game, use [[Engine.goto]] to transition to different scenes. + */ + public readonly rootScene: Scene; + + private _sceneToLoader = new Map(); + private _sceneToTransition = new Map(); + /** + * Used to keep track of scenes that have already been loaded so we don't load multiple times + */ + private _loadedScenes = new Set(); + + private _isTransitioning = false; + + /** + * Gets whether the director currently transitioning between scenes + * + * Useful if you need to block behavior during transition + */ + public get isTransitioning() { + return this._isTransitioning; + } + + constructor(private _engine: Engine, scenes: SceneMap) { + this.rootScene = this.currentScene = new Scene(); + this.add('root', this.rootScene); + this.currentScene = this.rootScene; + this.currentSceneName = 'root'; + for (const sceneKey in scenes) { + const sceneOrOptions = scenes[sceneKey]; + this.add(sceneKey, sceneOrOptions); + } + } + + /** + * Initialize the director's internal state + */ + async onInitialize() { + if (!this._initialized) { + this._initialized = true; + if (this._deferredGoto) { + const deferredScene = this._deferredGoto; + this._deferredGoto = null; + await this.swapScene(deferredScene); + } else { + await this.swapScene('root'); + } + } + } + + get isInitialized() { + return this._initialized; + } + + /** + * Configures the start scene, and optionally the transition & loader for the director + * + * Typically this is called at the beginning of the game to the start scene and transition and never again. + * @param startScene + * @param options + */ + configureStart(startScene: WithRoot, options?: StartOptions) { + const maybeLoaderOrCtor = options?.loader; + if (maybeLoaderOrCtor instanceof DefaultLoader) { + this.mainLoader = maybeLoaderOrCtor; + } else if (isLoaderConstructor(maybeLoaderOrCtor)) { + this.mainLoader = new maybeLoaderOrCtor(); + } else { + this.mainLoader = new Loader(); + } + + let maybeStartTransition: Transition; + + if (options) { + const { inTransition } = options; + maybeStartTransition = inTransition; + } + + this.startScene = startScene; + + // Fire and forget promise for the initial scene + if (maybeStartTransition) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.swapScene(this.startScene); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.playTransition(maybeStartTransition); + } else { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.swapScene(this.startScene); + } + + this.currentSceneName = this.startScene; + } + + private _getLoader(sceneName: string) { + return this._sceneToLoader.get(sceneName); + } + + private _getInTransition(sceneName: string): Transition | undefined { + const sceneOrRoute = this.scenes[sceneName as TKnownScenes]; + if (sceneOrRoute instanceof Scene || isSceneConstructor(sceneOrRoute)) { + return null; + } + return sceneOrRoute?.transitions?.in; + } + + private _getOutTransition(sceneName: string): Transition | undefined { + const sceneOrRoute = this.scenes[sceneName as TKnownScenes]; + if (sceneOrRoute instanceof Scene || isSceneConstructor(sceneOrRoute)) { + return null; + } + return sceneOrRoute?.transitions?.out; + } + + getDeferredScene() { + const maybeDeferred = this.getSceneDefinition(this._deferredGoto); + if (this._deferredGoto && maybeDeferred) { + return maybeDeferred; + } + return null; + } + + /** + * Returns a scene by name if it exists, might be the constructor and not the instance of a scene + * @param name + */ + getSceneDefinition(name: string): Scene | SceneConstructor | undefined { + const maybeScene = this.scenes[name as TKnownScenes]; + if (maybeScene instanceof Scene || isSceneConstructor(maybeScene)) { + return maybeScene; + } else if (maybeScene) { + return maybeScene.scene; + } + return undefined; + } + + /** + * Returns the same Director, but asserts a scene DOES exist to the type system + * @param name + */ + assertAdded(name: TScene): Director { + return this as Director; + } + + /** + * Returns the same Director, but asserts a scene DOES NOT exist to the type system + * @param name + */ + assertRemoved(name: TScene): Director> { + return this as Director>; + } + + /** + * Adds additional Scenes to the game! + * @param name + * @param sceneOrRoute + */ + add(name: TScene, sceneOrRoute: Scene | SceneConstructor | SceneWithOptions): Director { + if (!(sceneOrRoute instanceof Scene) && !(isSceneConstructor(sceneOrRoute))) { + const { loader, transitions } = sceneOrRoute; + const {in: inTransition, out: outTransition } = transitions ?? {}; + this._sceneToTransition.set(name, {in: inTransition, out: outTransition}); + + if (isLoaderConstructor(loader)) { + this._sceneToLoader.set(name, new loader()); + } else { + this._sceneToLoader.set(name, loader); + } + } + + if (this.scenes[name as unknown as TKnownScenes]) { + this._logger.warn('Scene', name, 'already exists overwriting'); + } + this.scenes[name as unknown as TKnownScenes] = sceneOrRoute; + return this.assertAdded(name); + } + + remove(scene: Scene): void; + remove(sceneCtor: SceneConstructor): void; + remove(name: WithRoot): void; + remove(nameOrScene: TKnownScenes | Scene | SceneConstructor | string) { + + if (nameOrScene instanceof Scene || isSceneConstructor(nameOrScene)) { + const sceneOrCtor = nameOrScene; + // remove scene + for (const key in this.scenes) { + if (this.scenes.hasOwnProperty(key)) { + const potentialSceneOrOptions = this.scenes[key as TKnownScenes]; + let scene: Scene | SceneConstructor; + if (potentialSceneOrOptions instanceof Scene || isSceneConstructor(potentialSceneOrOptions)) { + scene = potentialSceneOrOptions; + } else { + scene = potentialSceneOrOptions.scene; + } + + if (scene === sceneOrCtor) { + if (key === this.currentSceneName) { + throw new Error(`Cannot remove a currently active scene: ${key}`); + } + + this._sceneToTransition.delete(key); + this._sceneToLoader.delete(key); + delete this.scenes[key as TKnownScenes]; + } + } + } + } + if (typeof nameOrScene === 'string') { + if (nameOrScene === this.currentSceneName) { + throw new Error(`Cannot remove a currently active scene: ${nameOrScene}`); + } + + // remove scene + this._sceneToTransition.delete(nameOrScene); + this._sceneToLoader.delete(nameOrScene); + delete this.scenes[nameOrScene as TKnownScenes]; + } + } + + /** + * Go to a specific scene, and optionally override loaders and transitions + * @param destinationScene + * @param options + */ + async goto(destinationScene: TKnownScenes | string, options?: GoToOptions) { + if (destinationScene === this.currentSceneName) { + return; + } + + const maybeDest = this.getSceneInstance(destinationScene); + if (!maybeDest) { + this._logger.warn(`Scene ${destinationScene} does not exist! Check the name, are you sure you added it?`); + return; + } + + if (this._isTransitioning) { + // ? is this going to suck? I remember flux would block actions if one was already running and it made me sad + this._logger.warn('Cannot transition while a transition is in progress'); + return; + } + + const sourceScene = this.currentSceneName; + this._isTransitioning = true; + + const maybeSourceOut = this.getSceneInstance(sourceScene)?.onTransition('out'); + const maybeDestinationIn = maybeDest?.onTransition('in'); + + options = { + // Engine configuration then dynamic scene transitions + ...{ sourceOut: this._getOutTransition(this.currentSceneName) ?? maybeSourceOut}, + ...{ destinationIn: this._getInTransition(destinationScene) ?? maybeDestinationIn}, + // Goto options + ...options }; + + const { sourceOut, destinationIn, sceneActivationData } = options; + + const outTransition = sourceOut ?? this._getOutTransition(this.currentSceneName); + const inTransition = destinationIn ?? this._getInTransition(destinationScene); + + const hideLoader = outTransition?.hideLoader || inTransition?.hideLoader; + if (hideLoader) { + // Start hidden loader early and take advantage of the transition + // Don't await and block on a hidden loader + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.maybeLoadScene(destinationScene, hideLoader); + } + + this._emitEvent('navigationstart', sourceScene, destinationScene); + + // Run the out transition on the current scene if present + await this.playTransition(outTransition); + + // Run the loader if present + await this.maybeLoadScene(destinationScene, hideLoader); + + // Give incoming transition a chance to grab info from previous + await inTransition?.onPreviousSceneDeactivate(this.currentScene); + + // Swap to the new scene + await this.swapScene(destinationScene, sceneActivationData); + this._emitEvent('navigation', sourceScene, destinationScene); + + // Run the in transition on the new scene if present + await this.playTransition(inTransition); + this._emitEvent('navigationend', sourceScene, destinationScene); + + this._engine.toggleInputEnabled(true); + this._isTransitioning = false; + } + + /** + * Retrieves a scene instance by key if it's registered. + * + * This will call any constructors that were given as a definition + * @param scene + */ + getSceneInstance(scene: string): Scene | undefined { + const sceneDefinition = this.getSceneDefinition(scene); + if (!sceneDefinition) { + return undefined; + } + if (this._sceneToInstance.has(scene)) { + return this._sceneToInstance.get(scene) as Scene; + } + if (sceneDefinition instanceof Scene) { + this._sceneToInstance.set(scene, sceneDefinition); + return sceneDefinition; + } + const newScene = new sceneDefinition(); + this._sceneToInstance.set(scene, newScene); + return newScene; + } + + /** + * Triggers scene loading if has not already been loaded + * @param scene + * @param hideLoader + */ + async maybeLoadScene(scene: string, hideLoader = false) { + const loader = this._getLoader(scene) ?? new DefaultLoader(); + const sceneToLoad = this.getSceneDefinition(scene); + const sceneToLoadInstance = this.getSceneInstance(scene); + if (sceneToLoad && sceneToLoadInstance && !this._loadedScenes.has(sceneToLoadInstance)) { + sceneToLoadInstance.onPreLoad(loader); + sceneToLoadInstance.events.emit('preload', { loader }); + if (hideLoader) { + // Don't await a hidden loader + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._engine.load(loader, hideLoader); + } else { + await this._engine.load(loader); + } + this._loadedScenes.add(sceneToLoadInstance); + } + } + + /** + * Plays a transition in the current scene + * @param transition + */ + async playTransition(transition: Transition) { + if (transition) { + this.currentTransition = transition; + this._engine.toggleInputEnabled(!transition.blockInput); + this._engine.add(this.currentTransition); + await this.currentTransition.done; + } + this.currentTransition?.kill(); + this.currentTransition?.reset(); + this.currentTransition = null; + } + + /** + * Swaps the current and destination scene after performing required lifecycle events + * @param destinationScene + * @param data + */ + async swapScene(destinationScene: string, data?: TData): Promise { + const engine = this._engine; + // if not yet initialized defer goToScene + if (!this.isInitialized) { + this._deferredGoto = destinationScene; + return; + } + + const maybeDest = this.getSceneInstance(destinationScene); + + if (maybeDest) { + const previousScene = this.currentScene; + const nextScene = maybeDest; + + this._logger.debug('Going to scene:', destinationScene); + // only deactivate when initialized + if (this.currentScene.isInitialized) { + const context = { engine, previousScene, nextScene }; + await this.currentScene._deactivate(context); + this.currentScene.events.emit('deactivate', new DeactivateEvent(context, this.currentScene)); + } + + // wait for the scene to be loaded if needed + const destLoader = this._sceneToLoader.get(destinationScene); + await destLoader?.areResourcesLoaded(); + + // set current scene to new one + this.currentScene = nextScene; + this.currentSceneName = destinationScene; + engine.screen.setCurrentCamera(nextScene.camera); + + // initialize the current scene if has not been already + await this.currentScene._initialize(engine); + + const context = { engine, previousScene, nextScene, data }; + await this.currentScene._activate(context); + this.currentScene.events.emit('activate', new ActivateEvent(context, this.currentScene)); + } else { + this._logger.error('Scene', destinationScene, 'does not exist!'); + } + } + + private _emitEvent(eventName: keyof DirectorEvents, sourceScene: string, destinationScene: string) { + const source = this.getSceneDefinition(sourceScene)!; + const dest = this.getSceneDefinition(destinationScene)!; + this.events.emit(eventName, { + sourceScene: source, + sourceName: sourceScene, + destinationScene: dest, + destinationName: destinationScene + } as DirectorNavigationEvent); + } + + /** + * Updates internal transitions + */ + update() { + if (this.currentTransition) { + this.currentTransition.execute(); + } + } +} + + diff --git a/src/engine/Director/FadeInOut.ts b/src/engine/Director/FadeInOut.ts new file mode 100644 index 000000000..d2cb53106 --- /dev/null +++ b/src/engine/Director/FadeInOut.ts @@ -0,0 +1,51 @@ +import { Engine } from '../Engine'; +import { Color } from '../Color'; +import { vec } from '../Math/vector'; +import { Rectangle } from '../Graphics'; +import { Transition, TransitionOptions } from './Transition'; + +export interface FadeOptions { + duration?: number; + color?: Color; +} + +export class FadeInOut extends Transition { + screenCover: Rectangle; + color: Color; + constructor(options: FadeOptions & TransitionOptions) { + super({ + ...options, + duration: options.duration ?? 2000 + }); + this.name = `FadeInOut#${this.id}`; + this.color = options.color ?? Color.Black; + } + + public onInitialize(engine: Engine): void { + const bounds = engine.screen.getWorldBounds(); + this.transform.pos = vec(bounds.left, bounds.top); + this.screenCover = new Rectangle({ + width: bounds.width, + height: bounds.height, + color: this.color + }); + this.graphics.add(this.screenCover); + this.graphics.opacity = this.progress; + } + + override onReset() { + this.graphics.opacity = this.progress; + } + + override onStart(progress: number): void { + this.graphics.opacity = progress; + } + + override onEnd(progress: number): void { + this.graphics.opacity = progress; + } + + override onUpdate(progress: number): void { + this.graphics.opacity = progress; + } +} \ No newline at end of file diff --git a/src/engine/Loader.css b/src/engine/Director/Loader.css similarity index 100% rename from src/engine/Loader.css rename to src/engine/Director/Loader.css diff --git a/src/engine/Loader.logo.png b/src/engine/Director/Loader.logo.png similarity index 100% rename from src/engine/Loader.logo.png rename to src/engine/Director/Loader.logo.png diff --git a/src/engine/Loader.ts b/src/engine/Director/Loader.ts similarity index 58% rename from src/engine/Loader.ts rename to src/engine/Director/Loader.ts index 291e5ba46..4bbce7151 100644 --- a/src/engine/Loader.ts +++ b/src/engine/Director/Loader.ts @@ -1,28 +1,15 @@ -import { Color } from './Color'; -import { WebAudio } from './Util/WebAudio'; -import { Engine } from './Engine'; -import { Loadable } from './Interfaces/Loadable'; -import * as DrawUtil from './Util/DrawUtil'; +import { Color } from '../Color'; +import { Loadable } from '../Interfaces/Loadable'; +import * as DrawUtil from '../Util/DrawUtil'; import logoImg from './Loader.logo.png'; import loaderCss from './Loader.css'; -import { Canvas } from './Graphics/Canvas'; -import { Vector } from './Math/vector'; -import { delay } from './Util/Util'; -import { ImageFiltering } from './Graphics/Filtering'; -import { clamp } from './Math/util'; -import { Sound } from './Resources/Sound/Sound'; -import { Future } from './Util/Future'; -import { EventEmitter, EventKey, Handler, Subscription } from './EventEmitter'; -import { ScreenResizeEvent } from './Screen'; - -export type LoaderEvents = { - // Add event types here -} - -export const LoaderEvents = { - // Add event types here -}; +import { Vector } from '../Math/vector'; +import { delay } from '../Util/Util'; +import { EventEmitter } from '../EventEmitter'; +import { DefaultLoader } from './DefaultLoader'; +import { Engine } from '../Engine'; +import { Screen } from '../Screen'; /** * Pre-loading assets @@ -89,23 +76,10 @@ export const LoaderEvents = { * engine.start(loader).then(() => {}); * ``` */ -export class Loader implements Loadable[]> { +export class Loader extends DefaultLoader { public events = new EventEmitter(); - public canvas: Canvas = new Canvas({ - filtering: ImageFiltering.Blended, - smoothing: true, - cache: true, - draw: this.draw.bind(this) - }); - private _resourceList: Loadable[] = []; - private _index = 0; - + public screen: Screen; private _playButtonShown: boolean = false; - private _resourceCount: number = 0; - private _numLoaded: number = 0; - private _progressCounts: { [key: string]: number } = {}; - private _totalCounts: { [key: string]: number } = {}; - private _engine: Engine; // logo drawing stuff @@ -149,13 +123,6 @@ export class Loader implements Loadable[]> { return this._imageElement; } - private _getScreenParent() { - if (this._engine) { - return this._engine.screen.canvas.parentElement; - } - return document.body; - } - public suppressPlayButton: boolean = false; public get playButtonRootElement(): HTMLElement | null { return this._playButtonRootElement; @@ -177,10 +144,7 @@ export class Loader implements Loadable[]> { this._playButtonRootElement = document.createElement('div'); this._playButtonRootElement.id = 'excalibur-play-root'; this._playButtonRootElement.style.position = 'absolute'; - - // attach play button to canvas parent, this is important for fullscreen - const parent = this._getScreenParent(); - parent.appendChild(this._playButtonRootElement); + document.body.appendChild(this._playButtonRootElement); } if (!this._styleBlock) { this._styleBlock = document.createElement('style'); @@ -218,60 +182,14 @@ export class Loader implements Loadable[]> { * @param loadables Optionally provide the list of resources you want to load at constructor time */ constructor(loadables?: Loadable[]) { - if (loadables) { - this.addResources(loadables); - } + super({loadables}); } - private _loaderResizeHandler = (evt: ScreenResizeEvent) => { - // Configure resolution for loader, it expects resolution === viewport - this._engine.screen.resolution = this._engine.screen.viewport; - this._engine.screen.applyResolutionAndViewport(); - - this.canvas.width = evt.viewport.width; - this.canvas.height = evt.viewport.height; - this.canvas.flagDirty(); - }; - - public wireEngine(engine: Engine) { - // wire once - if (!this._engine) { - this._engine = engine; - this.canvas.width = this._engine.canvas.width; - this.canvas.height = this._engine.canvas.height; - } - } - - /** - * Add a resource to the loader to load - * @param loadable Resource to add - */ - public addResource(loadable: Loadable) { - const key = this._index++; - this._resourceList.push(loadable); - this._progressCounts[key] = 0; - this._totalCounts[key] = 1; - this._resourceCount++; - } - - /** - * Add a list of resources to the loader to load - * @param loadables The list of resources to load - */ - public addResources(loadables: Loadable[]) { - let i = 0; - const len = loadables.length; - - for (i; i < len; i++) { - this.addResource(loadables[i]); - } - } - - /** - * Returns true if the loader has completely loaded all resources - */ - public isLoaded() { - return this._numLoaded === this._resourceCount; + public override onInitialize(engine: Engine): void { + this.engine = engine; + this.screen = engine.screen; + this.canvas.width = this.engine.canvas.width; + this.canvas.height = this.engine.canvas.height; } /** @@ -281,13 +199,13 @@ export class Loader implements Loadable[]> { if (this.suppressPlayButton) { this.hidePlayButton(); // Delay is to give the logo a chance to show, otherwise don't delay - await delay(500, this._engine?.clock); + await delay(500, this.engine?.clock); } else { const resizeHandler = () => { this._positionPlayButton(); }; - if (this._engine?.browser) { - this._engine.browser.window.on('resize', resizeHandler); + if (this.engine?.browser) { + this.engine.browser.window.on('resize', resizeHandler); } this._playButtonShown = true; this._playButton.style.display = 'block'; @@ -303,8 +221,8 @@ export class Loader implements Loadable[]> { e.stopPropagation(); // Hide Button after click this.hidePlayButton(); - if (this._engine?.browser) { - this._engine.browser.window.off('resize', resizeHandler); + if (this.engine?.browser) { + this.engine.browser.window.off('resize', resizeHandler); } resolve(); }; @@ -336,79 +254,43 @@ export class Loader implements Loadable[]> { } } - update(engine: Engine, delta: number): void { - // override me - } - data: Loadable[]; - private _loadingFuture = new Future(); - public areResourcesLoaded() { - return this._loadingFuture.promise; - } - - /** - * Begin loading all of the supplied resources, returning a promise - * that resolves when loading of all is complete AND the user has clicked the "Play button" - */ - public async load(): Promise[]> { - this._engine.screen.events.on('resize', this._loaderResizeHandler); - - await this._image?.decode(); // decode logo if it exists - this.canvas.flagDirty(); - - await Promise.all( - this._resourceList.map(async (r) => { - await r.load().finally(() => { - // capture progress - this._numLoaded++; - this.canvas.flagDirty(); - }); - }) - ); - // Wire all sound to the engine - for (const resource of this._resourceList) { - if (resource instanceof Sound) { - resource.wireEngine(this._engine); - } - } - - this._loadingFuture.resolve(); - + public override async onUserAction(): Promise { // short delay in showing the button for aesthetics - await delay(200, this._engine?.clock); + await delay(200, this.engine?.clock); this.canvas.flagDirty(); - + // show play button await this.showPlayButton(); - // Unlock browser AudioContext in after user gesture - // See: https://github.com/excaliburjs/Excalibur/issues/262 - // See: https://github.com/excaliburjs/Excalibur/issues/1031 - await WebAudio.unlock(); + } - // unload loader resize watcher - this._engine.screen.events.off('resize', this._loaderResizeHandler); + public override async onBeforeLoad(): Promise { + // 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(); - return (this.data = this._resourceList); - } + this.canvas.width = this.engine.canvas.width; + this.canvas.height = this.engine.canvas.height; - public markResourceComplete(): void { - this._numLoaded++; + await this._image?.decode(); // decode logo if it exists } - /** - * Returns the progress of the loader as a number between [0, 1] inclusive. - */ - public get progress(): number { - return this._resourceCount > 0 ? clamp(this._numLoaded, 0, this._resourceCount) / this._resourceCount : 1; + // eslint-disable-next-line require-await + public override async onAfterLoad(): Promise { + this.screen.popResolutionAndViewport(); + this.screen.applyResolutionAndViewport(); + this.dispose(); } private _positionPlayButton() { - if (this._engine) { - const screenHeight = this._engine.screen.viewport.height; - const screenWidth = this._engine.screen.viewport.width; + if (this.engine) { + const screenHeight = this.engine.screen.viewport.height; + const screenWidth = this.engine.screen.viewport.width; if (this._playButtonRootElement) { - const left = this._engine.canvas.offsetLeft; - const top = this._engine.canvas.offsetTop; + const left = this.engine.canvas.offsetLeft; + const top = this.engine.canvas.offsetTop; const buttonWidth = this._playButton.clientWidth; const buttonHeight = this._playButton.clientHeight; if (this.playButtonPosition) { @@ -427,9 +309,9 @@ export class Loader implements Loadable[]> { * Override `logo`, `logoWidth`, `logoHeight` and `backgroundColor` properties * to customize the drawing, or just override entire method. */ - public draw(ctx: CanvasRenderingContext2D) { - const canvasHeight = this._engine.canvasHeight / this._engine.pixelRatio; - const canvasWidth = this._engine.canvasWidth / this._engine.pixelRatio; + public override onDraw(ctx: CanvasRenderingContext2D) { + const canvasHeight = this.engine.canvasHeight / this.engine.pixelRatio; + const canvasWidth = this.engine.canvasWidth / this.engine.pixelRatio; this._positionPlayButton(); @@ -446,8 +328,8 @@ export class Loader implements Loadable[]> { } const imageHeight = Math.floor(width * (this.logoHeight / this.logoWidth)); // OG height/width factor - const oldAntialias = this._engine.getAntialiasing(); - this._engine.setAntialiasing(true); + const oldAntialias = this.engine.getAntialiasing(); + this.engine.setAntialiasing(true); if (!this.logoPosition) { ctx.drawImage(this._image, 0, 0, this.logoWidth, this.logoHeight, logoX, logoY - imageHeight - 20, width, imageHeight); } else { @@ -456,7 +338,7 @@ export class Loader implements Loadable[]> { // loading box if (!this.suppressPlayButton && this._playButtonShown) { - this._engine.setAntialiasing(oldAntialias); + this.engine.setAntialiasing(oldAntialias); return; } @@ -483,31 +365,6 @@ export class Loader implements Loadable[]> { null, this.loadingBarColor ); - this._engine.setAntialiasing(oldAntialias); - } - - public emit>(eventName: TEventName, event: LoaderEvents[TEventName]): void; - public emit(eventName: string, event?: any): void; - public emit | string>(eventName: TEventName, event?: any): void { - this.events.emit(eventName, event); - } - - public on>(eventName: TEventName, handler: Handler): Subscription; - public on(eventName: string, handler: Handler): Subscription; - public on | string>(eventName: TEventName, handler: Handler): Subscription { - return this.events.on(eventName, handler); - } - - public once>(eventName: TEventName, handler: Handler): Subscription; - public once(eventName: string, handler: Handler): Subscription; - public once | string>(eventName: TEventName, handler: Handler): Subscription { - return this.events.once(eventName, handler); - } - - public off>(eventName: TEventName, handler: Handler): void; - public off(eventName: string, handler: Handler): void; - public off(eventName: string): void; - public off | string>(eventName: TEventName, handler?: Handler): void { - this.events.off(eventName, handler); + this.engine.setAntialiasing(oldAntialias); } } diff --git a/src/engine/Director/Transition.ts b/src/engine/Director/Transition.ts new file mode 100644 index 000000000..103295c03 --- /dev/null +++ b/src/engine/Director/Transition.ts @@ -0,0 +1,215 @@ +import { Engine } from '../Engine'; +import { Scene } from '../Scene'; +import { Future } from '../Util/Future'; +import { Entity, TransformComponent } from '../EntityComponentSystem'; +import { GraphicsComponent } from '../Graphics'; +import { CoordPlane } from '../Math/coord-plane'; +import { Vector } from '../Math/vector'; +import { clamp } from '../Math/util'; +import { EasingFunction, EasingFunctions } from '../Util/EasingFunctions'; + +export interface TransitionOptions { + /** + * Transition duration in milliseconds + */ + duration: number; + + /** + * Optionally hides the loader during the transition + * + * If either the out or in transition have this set to true, then the loader will be hidden. + * + * Default false + */ + hideLoader?: boolean; + + /** + * Optionally blocks user input during a transition + * + * Default false + */ + blockInput?: boolean; + + /** + * Optionally specify a easing function, by default linear + */ + easing?: EasingFunction; + /** + * Optionally specify a transition direction, by default 'out' + * + * * For 'in' direction transitions start at 1 and complete is at 0 + * * For 'out' direction transitions start at 0 and complete is at 1 + */ + direction?: 'out' | 'in'; +} + +/** + * Base Transition that can be extended to provide custom scene transitions in Excalibur. + */ +export class Transition extends Entity { + transform = new TransformComponent(); + graphics = new GraphicsComponent(); + readonly hideLoader: boolean; + readonly blockInput: boolean; + readonly duration: number; + readonly easing: EasingFunction; + readonly direction: 'out' | 'in'; + private _completeFuture = new Future(); + + // State needs to be reset between uses + public started = false; + private _currentDistance: number = 0; + private _currentProgress: number = 0; + + public done = this._completeFuture.promise; + + /** + * Returns a number between [0, 1] indicating what state the transition is in. + * + * * For 'out' direction transitions start at 0 and end at 1 + * * For 'in' direction transitions start at 1 and end at 0 + */ + get progress(): number { + return this._currentProgress; + } + + get complete(): boolean { + if (this.direction === 'out') { + return this.progress >= 1; + } else { + return this.progress <= 0; + } + } + + constructor(options: TransitionOptions) { + super(); + this.name = `Transition#${this.id}`; + this.duration = options.duration; + this.easing = options.easing ?? EasingFunctions.Linear; + this.direction = options.direction ?? 'out'; + this.hideLoader = options.hideLoader ?? false; + this.blockInput = options.blockInput ?? false; + this.transform.coordPlane = CoordPlane.Screen; + this.transform.pos = Vector.Zero; + this.transform.z = Infinity; // Transitions sit on top of everything + this.graphics.anchor = Vector.Zero; + this.addComponent(this.transform); + this.addComponent(this.graphics); + + if (this.direction === 'out') { + this._currentProgress = 0; + } else { + this._currentProgress = 1; + } + } + + /** + * Overridable lifecycle method, called before each update. + * + * **WARNING BE SURE** to call `super.onPreUpdate()` if overriding in your own custom implementation + * @param engine + * @param delta + */ + public override onPreUpdate(engine: Engine, delta: number): void { + if (this.complete) { + return; + } + + this._currentDistance += clamp(delta / this.duration, 0, 1); + if (this._currentDistance >= 1) { + this._currentDistance = 1; + } + + if (this.direction === 'out') { + this._currentProgress = clamp(this.easing(this._currentDistance, 0, 1, 1), 0, 1); + } else { + this._currentProgress = clamp(this.easing(this._currentDistance, 1, 0, 1), 0, 1); + } + } + + /** + * Overridable lifecycle method, called right before the previous scene has deactivated. + * + * This gives incoming transition a chance to grab info from previous scene if desired + * @param scene + */ + async onPreviousSceneDeactivate(scene: Scene) { + // override me + } + + /** + * Overridable lifecycle method, called once at the beginning of the transition + * + * `progress` is given between 0 and 1 + * @param progress + */ + onStart(progress: number) { + // override me + } + + /** + * Overridable lifecycle method, called every frame of the transition + * + * `progress` is given between 0 and 1 + * @param progress + */ + onUpdate(progress: number) { + // override me + } + + /** + * Overridable lifecycle method, called at the end of the transition, + * + * `progress` is given between 0 and 1 + * @param progress + */ + onEnd(progress: number) { + // override me + } + + /** + * Overridable lifecycle method, called when the transition is reset + * + * Use this to override and provide your own reset logic for internal state in custom transition implementations + */ + onReset() { + // override me + } + + /** + * reset() is called by the engine to reset transitions + */ + reset() { + this.started = false; + this._completeFuture = new Future(); + this.done = this._completeFuture.promise; + this._currentDistance = 0; + if (this.direction === 'out') { + this._currentProgress = 0; + } else { + this._currentProgress = 1; + } + this.onReset(); + } + + /** + * execute() is called by the engine every frame to update the Transition lifecycle onStart/onUpdate/onEnd + */ + execute() { + if (!this.isInitialized) { + return; + } + + if (!this.started) { + this.started = true; + this.onStart(this.progress); + } + + this.onUpdate(this.progress); + + if (this.complete && !this._completeFuture.isCompleted) { + this.onEnd(this.progress); + this._completeFuture.resolve(); + } + } +} \ No newline at end of file diff --git a/src/engine/Director/index.ts b/src/engine/Director/index.ts new file mode 100644 index 000000000..938301f24 --- /dev/null +++ b/src/engine/Director/index.ts @@ -0,0 +1,7 @@ + +export * from './Transition'; +export * from './FadeInOut'; +export * from './CrossFade'; +export * from './Director'; +export * from './Loader'; +export * from './DefaultLoader'; \ No newline at end of file diff --git a/src/engine/Engine.ts b/src/engine/Engine.ts index 1ef850d6a..347032929 100644 --- a/src/engine/Engine.ts +++ b/src/engine/Engine.ts @@ -1,21 +1,20 @@ import { EX_VERSION } from './'; +import { obsolete } from './Util/Decorators'; +import { Future } from './Util/Future'; import { EventEmitter, EventKey, Handler, Subscription } from './EventEmitter'; -import { Gamepads } from './Input/Gamepad'; -import { Keyboard } from './Input/Keyboard'; import { PointerScope } from './Input/PointerScope'; -import { EngineInput } from './Input/EngineInput'; import { Flags } from './Flags'; import { polyfill } from './Polyfill'; polyfill(); import { CanUpdate, CanDraw, CanInitialize } from './Interfaces/LifecycleEvents'; -import { Loadable } from './Interfaces/Loadable'; import { Vector } from './Math/vector'; import { Screen, DisplayMode, ScreenDimension, Resolution } from './Screen'; import { ScreenElement } from './ScreenElement'; import { Actor } from './Actor'; import { Timer } from './Timer'; import { TileMap } from './TileMap'; -import { Loader } from './Loader'; +import { DefaultLoader } from './Director/DefaultLoader'; +import { Loader } from './Director/Loader'; import { Detector } from './Util/Detector'; import { VisibleEvent, @@ -26,25 +25,24 @@ import { PostUpdateEvent, PreFrameEvent, PostFrameEvent, - DeactivateEvent, - ActivateEvent, PreDrawEvent, PostDrawEvent, InitializeEvent } from './Events'; import { Logger, LogLevel } from './Util/Log'; import { Color } from './Color'; -import { Scene } from './Scene'; +import { Scene, SceneConstructor, isSceneConstructor } from './Scene'; import { Entity } from './EntityComponentSystem/Entity'; import { Debug, DebugStats } from './Debug/Debug'; import { BrowserEvents } from './Util/Browser'; import { ExcaliburGraphicsContext, ExcaliburGraphicsContext2DCanvas, ExcaliburGraphicsContextWebGL, TextureLoader } from './Graphics'; -import { PointerEventReceiver } from './Input/PointerEventReceiver'; import { Clock, StandardClock } from './Util/Clock'; import { ImageFiltering } from './Graphics/Filtering'; import { GraphicsDiagnostics } from './Graphics/GraphicsDiagnostics'; import { Toaster } from './Util/Toaster'; import { InputMapper } from './Input/InputMapper'; +import { GoToOptions, SceneMap, Director, StartOptions, SceneWithOptions, WithRoot } from './Director/Director'; +import { InputHost } from './Input/InputHost'; export type EngineEvents = { fallbackgraphicscontext: ExcaliburGraphicsContext2DCanvas, @@ -97,7 +95,7 @@ export enum ScrollPreventionMode { /** * Defines the available options to configure the Excalibur engine at constructor time. */ -export interface EngineOptions { +export interface EngineOptions { /** * Optionally configure the width of the viewport in css pixels */ @@ -268,7 +266,14 @@ export interface EngineOptions { * Canvas renderer. */ threshold?: { numberOfFrames: number, fps: number }; - } + }, + + /** + * Optionally specify scenes with their transitions and loaders to excalibur's scene [[Director]] + * + * Scene transitions can can overridden dynamically by the `Scene` or by the call to `.goto` + */ + scenes?: SceneMap } /** @@ -278,7 +283,7 @@ export interface EngineOptions { * starting/stopping the game, maintaining state, transmitting events, * loading resources, and managing the scene. */ -export class Engine implements CanInitialize, CanUpdate, CanDraw { +export class Engine implements CanInitialize, CanUpdate, CanDraw { /** * Current Excalibur version string * @@ -301,6 +306,11 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { */ public screen: Screen; + /** + * Scene director, manages all scenes, scene transitions, and loaders in excalibur + */ + public director: Director; + /** * Direct access to the engine's canvas element */ @@ -410,13 +420,15 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { /** * Access engine input like pointer, keyboard, or gamepad */ - public input: EngineInput; + public input: InputHost; /** * Map multiple input sources to specific game actions actions */ public inputMapper: InputMapper; + private _inputEnabled: boolean = true; + /** * Access Excalibur debugging functionality. * @@ -437,17 +449,23 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { /** * The current [[Scene]] being drawn and updated on screen */ - public currentScene: Scene; + public get currentScene(): Scene { + return this.director.currentScene; + } /** - * The default [[Scene]] of the game, use [[Engine.goToScene]] to transition to different scenes. + * The default [[Scene]] of the game, use [[Engine.goto]] to transition to different scenes. */ - public readonly rootScene: Scene; + public get rootScene(): Scene { + return this.director.rootScene; + } /** * Contains all the scenes currently registered with Excalibur */ - public readonly scenes: { [key: string]: Scene } = {}; + public get scenes(): { [key: string]: Scene | SceneConstructor | SceneWithOptions } { + return this.director.scenes; + }; /** * Indicates whether the engine is set to fullscreen or not @@ -509,7 +527,7 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { * The action to take when a fatal exception is thrown */ public onFatalException = (e: any) => { - Logger.getInstance().fatal(e); + Logger.getInstance().fatal(e, e.stack); }; /** @@ -527,12 +545,10 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { private _timescale: number = 1.0; // loading - private _loader: Loader; + private _loader: DefaultLoader; private _isInitialized: boolean = false; - private _deferredGoTo: string = null; - public emit>(eventName: TEventName, event: EngineEvents[TEventName]): void; public emit(eventName: string, event?: any): void; public emit | string>(eventName: TEventName, event?: any): void { @@ -611,7 +627,7 @@ export class Engine implements CanInitialize, CanUpdate, CanDraw { * }); * ``` */ - constructor(options?: EngineOptions) { + constructor(options?: EngineOptions) { options = { ...Engine._DEFAULT_ENGINE_OPTIONS, ...options }; this._originalOptions = options; @@ -748,6 +764,7 @@ O|===|* >________________>\n\ pixelRatio: options.suppressHiDPIScaling ? 1 : (options.pixelRatio ?? null) }); + // TODO REMOVE STATIC!!! // Set default filtering based on antialiasing TextureLoader.filtering = options.antialiasing ? ImageFiltering.Blended : ImageFiltering.Pixel; @@ -766,15 +783,12 @@ O|===|* >________________>\n\ this.enableCanvasTransparency = options.enableCanvasTransparency; - this._loader = new Loader(); - this._loader.wireEngine(this); this.debug = new Debug(this); - this._initialize(options); + this.director = new Director(this, options.scenes); - this.rootScene = this.currentScene = new Scene(); + this._initialize(options); - this.addScene('root', this.rootScene); (window as any).___EXCALIBUR_DEVTOOL = this; } @@ -929,18 +943,16 @@ O|===|* >________________>\n\ * @param key The name of the scene, must be unique * @param scene The scene to add to the engine */ - public addScene(key: string, scene: Scene) { - if (this.scenes[key]) { - this._logger.warn('Scene', key, 'already exists overwriting'); - } - this.scenes[key] = scene; + public addScene(key: TScene, scene: Scene | SceneConstructor | SceneWithOptions): Engine { + this.director.add(key, scene); + return this as Engine; } /** * Removes a [[Scene]] instance from the engine * @param scene The scene to remove */ - public removeScene(scene: Scene): void; + public removeScene(scene: Scene | SceneConstructor): void; /** * Removes a scene from the engine by key * @param key The scene key to remove @@ -950,21 +962,7 @@ O|===|* >________________>\n\ * @internal */ public removeScene(entity: any): void { - if (entity instanceof Scene) { - // remove scene - for (const key in this.scenes) { - if (this.scenes.hasOwnProperty(key)) { - if (this.scenes[key] === entity) { - delete this.scenes[key]; - } - } - } - } - - if (typeof entity === 'string') { - // remove scene - delete this.scenes[entity]; - } + this.director.remove(entity); } /** @@ -973,7 +971,7 @@ O|===|* >________________>\n\ * @param sceneKey The key of the scene, must be unique * @param scene The scene to add to the engine */ - public add(sceneKey: string, scene: Scene): void; + public add(sceneKey: string, scene: Scene | SceneConstructor | SceneWithOptions): void; /** * Adds a [[Timer]] to the [[currentScene]]. * @param timer The timer to add to the [[currentScene]]. @@ -1005,11 +1003,12 @@ O|===|* >________________>\n\ public add(screenElement: ScreenElement): void; public add(entity: any): void { if (arguments.length === 2) { - this.addScene(arguments[0], arguments[1]); + this.director.add(arguments[0], arguments[1]); return; } - if (this._deferredGoTo && this.scenes[this._deferredGoTo]) { - this.scenes[this._deferredGoTo].add(entity); + const maybeDeferred = this.director.getDeferredScene(); + if (maybeDeferred instanceof Scene) { + maybeDeferred.add(entity); } else { this.currentScene.add(entity); } @@ -1019,7 +1018,7 @@ O|===|* >________________>\n\ * Removes a scene instance from the engine * @param scene The scene to remove */ - public remove(scene: Scene): void; + public remove(scene: Scene | SceneConstructor): void; /** * Removes a scene from the engine by key * @param sceneKey The scene to remove @@ -1051,7 +1050,7 @@ O|===|* >________________>\n\ this.currentScene.remove(entity); } - if (entity instanceof Scene) { + if (entity instanceof Scene || isSceneConstructor(entity)) { this.removeScene(entity); } @@ -1060,45 +1059,50 @@ O|===|* >________________>\n\ } } + /** + * Changes the current scene with optionally supplied: + * * Activation data + * * Transitions + * * Loaders + * + * Example: + * ```typescript + * game.goto('myScene', { + * sceneActivationData: {any: 'thing at all'}, + * destinationIn: new FadeInOut({duration: 1000, direction: 'in'}), + * sourceOut: new FadeInOut({duration: 1000, direction: 'out'}), + * loader: MyLoader + * }); + * ``` + * + * Scenes are defined in the Engine constructor + * ```typescript + * const engine = new ex.Engine({ + scenes: {...} + }); + * ``` + * Or by adding dynamically + * + * ```typescript + * engine.addScene('myScene', new ex.Scene()); + * ``` + * @param destinationScene + * @param options + */ + public async goto(destinationScene: WithRoot, options?: GoToOptions) { + await this.director.goto(destinationScene, options); + } + /** * Changes the currently updating and drawing scene to a different, * named scene. Calls the [[Scene]] lifecycle events. * @param key The key of the scene to transition to. * @param data Optional data to send to the scene's onActivate method + * @deprecated Use [[Engine.goto]] will be removed in v1! */ - public goToScene(key: string, data?: TData): void { - // if not yet initialized defer goToScene - if (!this.isInitialized) { - this._deferredGoTo = key; - return; - } - - if (this.scenes[key]) { - const previousScene = this.currentScene; - const nextScene = this.scenes[key]; - - this._logger.debug('Going to scene:', key); - - // only deactivate when initialized - if (this.currentScene.isInitialized) { - const context = { engine: this, previousScene, nextScene }; - this.currentScene._deactivate.apply(this.currentScene, [context, nextScene]); - this.currentScene.events.emit('deactivate', new DeactivateEvent(context, this.currentScene)); - } - - // set current scene to new one - this.currentScene = nextScene; - this.screen.setCurrentCamera(nextScene.camera); - - // initialize the current scene if has not been already - this.currentScene._initialize(this); - - const context = { engine: this, previousScene, nextScene, data }; - this.currentScene._activate.apply(this.currentScene, [context, nextScene]); - this.currentScene.events.emit('activate', new ActivateEvent(context, this.currentScene)); - } else { - this._logger.error('Scene', key, 'does not exist!'); - } + @obsolete({message: 'Engine.goToScene is deprecated, will be removed in v1', alternateMethod: 'Engine.goto'}) + public async goToScene(key: string, data?: TData): Promise { + await this.director.swapScene(key, data); } /** @@ -1125,19 +1129,13 @@ O|===|* >________________>\n\ // initialize inputs const pointerTarget = options && options.pointerScope === PointerScope.Document ? document : this.canvas; - this.input = { - keyboard: new Keyboard(), - pointers: new PointerEventReceiver(pointerTarget, this), - gamepads: new Gamepads() - }; - this.input.keyboard.init({ - grabWindowFocus: this._originalOptions?.grabWindowFocus ?? true - }); - this.input.pointers.init({ - grabWindowFocus: this._originalOptions?.grabWindowFocus ?? true + const grabWindowFocus = this._originalOptions?.grabWindowFocus ?? true; + this.input = new InputHost({ + pointerTarget, + grabWindowFocus, + engine: this }); - this.input.gamepads.init(); - this.inputMapper = new InputMapper(this.input); + this.inputMapper = this.input.inputMapper; // Issue #385 make use of the visibility api // https://developer.mozilla.org/en-US/docs/Web/Guide/User_experience/Using_the_Page_Visibility_API @@ -1157,6 +1155,11 @@ O|===|* >________________>\n\ } } + public toggleInputEnabled(enabled: boolean) { + this._inputEnabled = enabled; + this.input.toggleEnabled(this._inputEnabled); + } + public onInitialize(engine: Engine) { // Override me } @@ -1185,18 +1188,12 @@ O|===|* >________________>\n\ return this._isInitialized; } - private _overrideInitialize(engine: Engine) { + private async _overrideInitialize(engine: Engine) { if (!this.isInitialized) { - this.onInitialize(engine); + await this.director.onInitialize(); + await this.onInitialize(engine); this.events.emit('initialize', new InitializeEvent(engine, this)); this._isInitialized = true; - if (this._deferredGoTo) { - const deferredScene = this._deferredGoTo; - this._deferredGoTo = null; - this.goToScene(deferredScene); - } else { - this.goToScene('root'); - } } } @@ -1205,17 +1202,15 @@ O|===|* >________________>\n\ * @param delta Number of milliseconds elapsed since the last update. */ private _update(delta: number) { - if (!this.ready) { + this.director.update(); + if (this._isLoading) { // suspend updates until loading is finished - this._loader.update(this, delta); + this._loader?.onUpdate(this, delta); // Update input listeners - this.inputMapper.execute(); - this.input.keyboard.update(); - this.input.gamepads.update(); + this.input.update(); return; } - // Publish preupdate events this._preupdate(delta); @@ -1229,9 +1224,7 @@ O|===|* >________________>\n\ this._postupdate(delta); // Update input listeners - this.inputMapper.execute(); - this.input.keyboard.update(); - this.input.gamepads.update(); + this.input.update(); } /** @@ -1268,9 +1261,12 @@ O|===|* >________________>\n\ this._predraw(this.graphicsContext, delta); // Drawing nothing else while loading - if (!this._isReady) { - this._loader.canvas.draw(this.graphicsContext, 0, 0); - this.graphicsContext.flush(); + if (this._isLoading) { + if (!this._hideLoader) { + this._loader?.canvas.draw(this.graphicsContext, 0, 0); + this.graphicsContext.flush(); + this.graphicsContext.endDrawLifecycle(); + } return; } @@ -1328,80 +1324,73 @@ O|===|* >________________>\n\ return this._isDebug; } - private _loadingComplete: boolean = false; - /** * Returns true when loading is totally complete and the player has clicked start */ public get loadingComplete() { - return this._loadingComplete; + return !this._isLoading; } - private _isReady = false; + private _isLoading = false; + private _hideLoader = false; + private _isReadyFuture = new Future(); public get ready() { - return this._isReady; + return this._isReadyFuture.isCompleted; } - private _isReadyResolve: () => any; - private _isReadyPromise = new Promise(resolve => { - this._isReadyResolve = resolve; - }); public isReady(): Promise { - return this._isReadyPromise; + return this._isReadyFuture.promise; } + /** * 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. + * @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 async start(loader?: Loader): Promise { + public async start(loader?: DefaultLoader): Promise; + /** + * Starts the internal game loop for Excalibur after configuring any routes, loaders, or transitions + * @param startOptions Optional [[StartOptions]] to configure the routes for scenes in Excalibur + * + * Note: start() only resolves AFTER the user has clicked the play button + */ + public async start(sceneName: WithRoot, options?: StartOptions): Promise; + /** + * Starts the internal game loop after any loader is finished + * @param loader + */ + public async start(loader?: DefaultLoader): Promise; + public async start(sceneNameOrLoader?: WithRoot | DefaultLoader, options?: StartOptions): Promise { if (!this._compatible) { throw new Error('Excalibur is incompatible with your browser'); } - - // 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._loader = loader; - this._loader.suppressPlayButton = this._suppressPlayButton || this._loader.suppressPlayButton; - this._loader.wireEngine(this); + this._isLoading = true; + let loader: DefaultLoader; + if (sceneNameOrLoader instanceof DefaultLoader) { + loader = sceneNameOrLoader; + } else if (typeof sceneNameOrLoader === 'string') { + this.director.configureStart(sceneNameOrLoader, options); + loader = this.director.mainLoader; } // 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._loadingComplete = true; + await this.load(loader ?? new Loader()); // Initialize before ready - this._overrideInitialize(this); + await this._overrideInitialize(this); - this._isReady = true; - - this._isReadyResolve(); + this._isReadyFuture.resolve(); this.emit('start', new GameStartEvent(this)); - return this._isReadyPromise; + return this._isReadyFuture.promise; } /** @@ -1518,12 +1507,29 @@ O|===|* >________________>\n\ * will appear. * @param loader Some [[Loadable]] such as a [[Loader]] collection, [[Sound]], or [[Texture]]. */ - public async load(loader: Loadable): Promise { + public async load(loader: DefaultLoader, hideLoader = false): Promise { try { + // early exit if loaded + if (loader.isLoaded()) { + return; + } + this._loader = loader; + this._isLoading = true; + this._hideLoader = hideLoader; + + if (loader instanceof Loader) { + loader.suppressPlayButton = this._suppressPlayButton; + } + this._loader.onInitialize(this); + await loader.load(); } catch (e) { this._logger.error('Error loading resources, things may not behave properly', e); await Promise.resolve(); + } finally { + this._isLoading = false; + this._hideLoader = false; + this._loader = null; } } } \ No newline at end of file diff --git a/src/engine/EntityComponentSystem/Entity.ts b/src/engine/EntityComponentSystem/Entity.ts index 79d5a47e2..470ae9772 100644 --- a/src/engine/EntityComponentSystem/Entity.ts +++ b/src/engine/EntityComponentSystem/Entity.ts @@ -4,7 +4,10 @@ import { Observable, Message } from '../Util/Observable'; import { OnInitialize, OnPreUpdate, OnPostUpdate } from '../Interfaces/LifecycleEvents'; import { Engine } from '../Engine'; import { InitializeEvent, PreUpdateEvent, PostUpdateEvent } from '../Events'; -import { EventEmitter, EventKey, Handler, Scene, Subscription, Util } from '..'; +import { KillEvent } from '../Events'; +import { EventEmitter, EventKey, Handler, Subscription } from '../EventEmitter'; +import { Scene } from '../Scene'; +import { removeItemFromArray } from '../Util/Util'; /** * Interface holding an entity component pair @@ -51,12 +54,14 @@ export type EntityEvents = { 'initialize': InitializeEvent; 'preupdate': PreUpdateEvent; 'postupdate': PostUpdateEvent; + 'kill': KillEvent }; export const EntityEvents = { Initialize: 'initialize', PreUpdate: 'preupdate', - PostUpdate: 'postupdate' + PostUpdate: 'postupdate', + Kill: 'kill' } as const; /** @@ -127,6 +132,7 @@ export class Entity implements OnInitialize, OnPreUpdate, OnPostUpdate { this.active = false; this.unparent(); } + this.emit('kill', new KillEvent(this)); } public isKilled() { @@ -268,7 +274,7 @@ export class Entity implements OnInitialize, OnPreUpdate, OnPostUpdate { */ public removeChild(entity: Entity): Entity { if (entity.parent === this) { - Util.removeItemFromArray(entity, this._children); + removeItemFromArray(entity, this._children); entity._parent = null; this.childrenRemoved$.notifyAll(entity); } diff --git a/src/engine/EntityComponentSystem/EntityManager.ts b/src/engine/EntityComponentSystem/EntityManager.ts index 6a5803ef6..ee99d0478 100644 --- a/src/engine/EntityComponentSystem/EntityManager.ts +++ b/src/engine/EntityComponentSystem/EntityManager.ts @@ -1,7 +1,7 @@ import { Entity, RemovedComponent, AddedComponent, isAddedComponent, isRemovedComponent } from './Entity'; import { Observer } from '../Util/Observable'; import { World } from './World'; -import { Util } from '..'; +import { removeItemFromArray } from '../Util/Util'; // Add/Remove entities and components @@ -102,7 +102,7 @@ export class EntityManager implements Observer extends Observable extends Observable { new (...args: any[]): T; @@ -53,7 +54,7 @@ export class SystemManager { * @param system */ public removeSystem(system: System) { - Util.removeItemFromArray(system, this.systems); + removeItemFromArray(system, this.systems); const query = this._world.queryManager.getQuery(system.types); if (query) { query.unregister(system); diff --git a/src/engine/Events.ts b/src/engine/Events.ts index f25615789..d6fa1cf57 100644 --- a/src/engine/Events.ts +++ b/src/engine/Events.ts @@ -199,8 +199,8 @@ export class GameEvent { /** * The 'kill' event is emitted on actors when it is killed. The target is the actor that was killed. */ -export class KillEvent extends GameEvent { - constructor(public target: Actor) { +export class KillEvent extends GameEvent { + constructor(public target: Entity) { super(); } } diff --git a/src/engine/Events/PointerEvents.ts b/src/engine/Events/PointerEvents.ts index 810ba31c9..3b61eb7c4 100644 --- a/src/engine/Events/PointerEvents.ts +++ b/src/engine/Events/PointerEvents.ts @@ -1,4 +1,4 @@ -import { GlobalCoordinates } from '..'; +import { GlobalCoordinates } from '../Math/global-coordinates'; import { WheelDeltaMode } from '../Input/WheelDeltaMode'; import { ExEvent } from './ExEvent'; diff --git a/src/engine/Graphics/Circle.ts b/src/engine/Graphics/Circle.ts index 3fe632f35..d920d81aa 100644 --- a/src/engine/Graphics/Circle.ts +++ b/src/engine/Graphics/Circle.ts @@ -1,4 +1,4 @@ -import { ImageFiltering } from '.'; +import { ImageFiltering } from './Filtering'; import { Raster, RasterOptions } from './Raster'; export interface CircleOptions { diff --git a/src/engine/Graphics/Context/debug-text.ts b/src/engine/Graphics/Context/debug-text.ts index 32b1bfb90..a11fa6ac6 100644 --- a/src/engine/Graphics/Context/debug-text.ts +++ b/src/engine/Graphics/Context/debug-text.ts @@ -1,5 +1,8 @@ -import { ExcaliburGraphicsContext, ImageSource, SpriteFont, SpriteSheet } from '..'; -import { Vector } from '../..'; +import { ExcaliburGraphicsContext } from '../Context/ExcaliburGraphicsContext'; +import { ImageSource } from '../ImageSource'; +import { SpriteFont } from '../SpriteFont'; +import { SpriteSheet } from '../SpriteSheet'; +import { Vector } from '../../Math/vector'; import debugFont from './debug-font.png'; /** @@ -7,6 +10,8 @@ import debugFont from './debug-font.png'; */ export class DebugText { constructor() { + // We fire and forget, we don't care if it's loaded or not + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.load(); } diff --git a/src/engine/Graphics/DebugGraphicsComponent.ts b/src/engine/Graphics/DebugGraphicsComponent.ts index 5201e8ce9..2d54f1a65 100644 --- a/src/engine/Graphics/DebugGraphicsComponent.ts +++ b/src/engine/Graphics/DebugGraphicsComponent.ts @@ -1,4 +1,4 @@ -import { ExcaliburGraphicsContext } from '.'; +import { ExcaliburGraphicsContext } from './Context/ExcaliburGraphicsContext'; import { Debug } from '../Debug'; import { Component } from '../EntityComponentSystem/Component'; diff --git a/src/engine/Graphics/GraphicsSystem.ts b/src/engine/Graphics/GraphicsSystem.ts index a6c48eb00..bb9e597c8 100644 --- a/src/engine/Graphics/GraphicsSystem.ts +++ b/src/engine/Graphics/GraphicsSystem.ts @@ -7,8 +7,8 @@ import { Entity } from '../EntityComponentSystem/Entity'; import { Camera } from '../Camera'; import { AddedEntity, isAddedSystemEntity, RemovedEntity, System, SystemType } from '../EntityComponentSystem'; import { Engine } from '../Engine'; -import { GraphicsGroup } from '.'; -import { Particle } from '../Particles'; +import { GraphicsGroup } from './GraphicsGroup'; +import { Particle } from '../Particles'; // this import seems to bomb wallaby import { ParallaxComponent } from './ParallaxComponent'; import { CoordPlane } from '../Math/coord-plane'; import { BodyComponent } from '../Collision/BodyComponent'; @@ -138,6 +138,7 @@ export class GraphicsSystem extends System { private _logger = Logger.getInstance(); private _resource: Resource; @@ -66,6 +71,27 @@ export class ImageSource implements Loadable { } } + /** + * Create an ImageSource from and HTML tag element + * @param image + */ + static fromHtmlImageElement(image: HTMLImageElement, options?: ImageSourceOptions) { + const imageSource = new ImageSource(''); + imageSource._src = 'image-element'; + imageSource.data = image; + imageSource.data.setAttribute('data-original-src', 'image-element'); + + if (options?.filtering) { + imageSource.data.setAttribute('filtering', options?.filtering); + } else { + imageSource.data.setAttribute('filtering', ImageFiltering.Blended); + } + + TextureLoader.checkImageSizeSupportedAndLog(image); + imageSource._readyFuture.resolve(image); + return imageSource; + } + /** * Should excalibur add a cache busting querystring? By default false. * Must be set before loading diff --git a/src/engine/Graphics/Polygon.ts b/src/engine/Graphics/Polygon.ts index e9c065fdd..248936eb3 100644 --- a/src/engine/Graphics/Polygon.ts +++ b/src/engine/Graphics/Polygon.ts @@ -1,4 +1,4 @@ -import { ImageFiltering } from '.'; +import { ImageFiltering } from './Filtering'; import { Vector, vec } from '../Math/vector'; import { Raster, RasterOptions } from './Raster'; diff --git a/src/engine/Graphics/PostProcessor/PostProcessor.ts b/src/engine/Graphics/PostProcessor/PostProcessor.ts index 626503217..85a0359cd 100644 --- a/src/engine/Graphics/PostProcessor/PostProcessor.ts +++ b/src/engine/Graphics/PostProcessor/PostProcessor.ts @@ -1,5 +1,5 @@ -import { VertexLayout } from '..'; +import { VertexLayout } from '../Context/vertex-layout'; import { Shader } from '../Context/shader'; diff --git a/src/engine/Graphics/Sprite.ts b/src/engine/Graphics/Sprite.ts index 3f4210cb2..26c5d5703 100644 --- a/src/engine/Graphics/Sprite.ts +++ b/src/engine/Graphics/Sprite.ts @@ -41,6 +41,8 @@ export class Sprite extends Graphic { this.sourceView = options.sourceView ?? { x: 0, y: 0, width: width ?? 0, height: height ?? 0 }; this.destSize = options.destSize ?? { width: width ?? 0, height: height ?? 0 }; this._updateSpriteDimensions(); + // Fire when loaded + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.image.ready.then(() => { this._updateSpriteDimensions(); }); diff --git a/src/engine/Graphics/SpriteFont.ts b/src/engine/Graphics/SpriteFont.ts index c8ff093d8..605e887d5 100644 --- a/src/engine/Graphics/SpriteFont.ts +++ b/src/engine/Graphics/SpriteFont.ts @@ -5,7 +5,8 @@ import { FontRenderer } from './FontCommon'; import { Graphic, GraphicOptions } from './Graphic'; import { Sprite } from './Sprite'; import { SpriteSheet } from './SpriteSheet'; -import { BoundingBox, Color } from '..'; +import { BoundingBox } from '../Collision/BoundingBox'; +import { Color } from '../Color'; export interface SpriteFontOptions { /** diff --git a/src/engine/Input/EngineInput.ts b/src/engine/Input/EngineInput.ts deleted file mode 100644 index 3972d1684..000000000 --- a/src/engine/Input/EngineInput.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Keyboard } from './Keyboard'; -import { Gamepads } from './Gamepad'; -import { PointerEventReceiver } from './PointerEventReceiver'; - -export interface EngineInput { - keyboard: Keyboard; - pointers: PointerEventReceiver; - gamepads: Gamepads; -} diff --git a/src/engine/Input/Gamepad.ts b/src/engine/Input/Gamepad.ts index bbe1b5b14..585fbe63f 100644 --- a/src/engine/Input/Gamepad.ts +++ b/src/engine/Input/Gamepad.ts @@ -42,6 +42,7 @@ export class Gamepads { private _initSuccess: boolean = false; private _navigator: NavigatorGamepads = navigator; private _minimumConfiguration: GamepadConfiguration = null; + private _enabled = true; public init() { if (!this.supported) { @@ -59,6 +60,10 @@ export class Gamepads { } } + public toggleEnabled(enabled: boolean) { + this._enabled = enabled; + } + /** * Sets the minimum gamepad configuration, for example {axis: 4, buttons: 4} means * this game requires at minimum 4 axis inputs and 4 buttons, this is not restrictive @@ -134,6 +139,9 @@ export class Gamepads { if (!this.enabled || !this.supported) { return; } + if (!this._enabled) { + return; + } this.init(); const gamepads = this._navigator.getGamepads(); diff --git a/src/engine/Input/Index.ts b/src/engine/Input/Index.ts index f1df9e26e..65a1af251 100644 --- a/src/engine/Input/Index.ts +++ b/src/engine/Input/Index.ts @@ -48,12 +48,6 @@ export { CapturePointerConfig } from './CapturePointerConfig'; -export { - /** - * @deprecated ex.Input.EngineInput import site will be removed in v0.29.0, use ex.EngineInput - */ - EngineInput -} from './EngineInput'; export { /** diff --git a/src/engine/Input/InputHost.ts b/src/engine/Input/InputHost.ts new file mode 100644 index 000000000..39f66ecf8 --- /dev/null +++ b/src/engine/Input/InputHost.ts @@ -0,0 +1,51 @@ +import { Engine } from '../Engine'; +import { Gamepads } from './Gamepad'; +import { InputMapper } from './InputMapper'; +import { Keyboard } from './Keyboard'; +import { PointerEventReceiver } from './PointerEventReceiver'; + +export interface InputHostOptions { + pointerTarget: Document | HTMLCanvasElement + grabWindowFocus: boolean, + engine: Engine +} + +export class InputHost { + private _enabled = true; + + keyboard: Keyboard; + pointers: PointerEventReceiver; + gamepads: Gamepads; + inputMapper: InputMapper; + + constructor(options: InputHostOptions) { + const { pointerTarget, grabWindowFocus, engine } = options; + this.keyboard = new Keyboard(); + this.pointers = new PointerEventReceiver(pointerTarget, engine); + this.gamepads = new Gamepads(); + + this.keyboard.init({grabWindowFocus}); + this.pointers.init({grabWindowFocus}); + this.gamepads.init(); + this.inputMapper = new InputMapper({ + keyboard: this.keyboard, + pointers: this.pointers, + gamepads: this.gamepads + }); + } + + toggleEnabled(enabled: boolean) { + this._enabled = enabled; + this.keyboard.toggleEnabled(this._enabled); + this.pointers.toggleEnabled(this._enabled); + this.gamepads.toggleEnabled(this._enabled); + } + + update() { + if (this._enabled) { + this.inputMapper.execute(); + this.keyboard.update(); + this.gamepads.update(); + } + } +} \ No newline at end of file diff --git a/src/engine/Input/Keyboard.ts b/src/engine/Input/Keyboard.ts index f6e856f14..8064a7866 100644 --- a/src/engine/Input/Keyboard.ts +++ b/src/engine/Input/Keyboard.ts @@ -212,6 +212,7 @@ export const KeyEvents = { */ export class Keyboard { public events = new EventEmitter(); + private _enabled = true; /** * Keys that are currently held down */ @@ -283,6 +284,10 @@ export class Keyboard { global.addEventListener('keydown', this._handleKeyDown); } + toggleEnabled(enabled: boolean) { + this._enabled = enabled; + } + private _releaseAllKeys = (ev: KeyboardEvent) => { for (const code of this._keys) { const keyEvent = new KeyEvent(code, ev.key, ev); @@ -294,6 +299,10 @@ export class Keyboard { }; private _handleKeyDown = (ev: KeyboardEvent) => { + if (!this._enabled) { + return; + } + // handle macos meta key issue // https://github.com/excaliburjs/Excalibur/issues/2608 if (!ev.metaKey && (this._keys.includes(Keys.MetaLeft) || this._keys.includes(Keys.MetaRight))) { @@ -311,6 +320,9 @@ export class Keyboard { }; private _handleKeyUp = (ev: KeyboardEvent) => { + if (!this._enabled) { + return; + } const code = ev.code as Keys; const key = this._keys.indexOf(code); this._keys.splice(key, 1); diff --git a/src/engine/Input/PointerEventReceiver.ts b/src/engine/Input/PointerEventReceiver.ts index fe747b319..bf130998e 100644 --- a/src/engine/Input/PointerEventReceiver.ts +++ b/src/engine/Input/PointerEventReceiver.ts @@ -74,8 +74,14 @@ export class PointerEventReceiver { public currentFrameCancel: PointerEvent[] = []; public currentFrameWheel: WheelEvent[] = []; + private _enabled = true; + constructor(public readonly target: GlobalEventHandlers & EventTarget, public engine: Engine) {} + public toggleEnabled(enabled: boolean) { + this._enabled = enabled; + } + /** * Creates a new PointerEventReceiver with a new target and engine while preserving existing pointer event * handlers. @@ -364,6 +370,9 @@ export class PointerEventReceiver { * Responsible for handling and parsing pointer events */ private _handle(ev: NativeTouchEvent | NativePointerEvent | NativeMouseEvent) { + if (!this._enabled) { + return; + } ev.preventDefault(); const eventCoords = new Map(); let button: PointerButton; @@ -422,6 +431,9 @@ export class PointerEventReceiver { } private _handleWheel(ev: NativeWheelEvent) { + if (!this._enabled) { + return; + } // Should we prevent page scroll because of this event if ( this.engine.pageScrollPreventionMode === ScrollPreventionMode.All || diff --git a/src/engine/Label.ts b/src/engine/Label.ts index 1acedd0c1..4d6cc256d 100644 --- a/src/engine/Label.ts +++ b/src/engine/Label.ts @@ -5,7 +5,7 @@ import { Text } from './Graphics/Text'; import { GraphicsComponent, SpriteFont } from './Graphics'; import { Font } from './Graphics/Font'; import { Actor } from './Actor'; -import { ActorArgs } from '.'; +import { ActorArgs } from './Actor'; /** * Option for creating a label diff --git a/src/engine/Resources/Sound/Sound.ts b/src/engine/Resources/Sound/Sound.ts index dd90ced1d..8e23fdbe0 100644 --- a/src/engine/Resources/Sound/Sound.ts +++ b/src/engine/Resources/Sound/Sound.ts @@ -194,6 +194,7 @@ export class Sound implements Audio, Loadable { this._engine.on('visible', () => { if (engine.pauseAudioWhenHidden && this._wasPlayingOnHidden) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises this.play(); this._wasPlayingOnHidden = false; } diff --git a/src/engine/Scene.ts b/src/engine/Scene.ts index 78b493ec9..bde393746 100644 --- a/src/engine/Scene.ts +++ b/src/engine/Scene.ts @@ -34,6 +34,12 @@ import { ExcaliburGraphicsContext } from './Graphics'; import { PhysicsWorld } from './Collision/PhysicsWorld'; import { EventEmitter, EventKey, Handler, Subscription } from './EventEmitter'; import { Color } from './Color'; +import { DefaultLoader } from './Director/DefaultLoader'; +import { Transition } from './Director'; + +export class PreLoadEvent { + loader: DefaultLoader; +} export type SceneEvents = { initialize: InitializeEvent, @@ -45,6 +51,7 @@ export type SceneEvents = { postdraw: PostDrawEvent, predebugdraw: PreDebugDrawEvent, postdebugdraw: PostDebugDrawEvent + preload: PreLoadEvent } export const SceneEvents = { @@ -56,9 +63,18 @@ export const SceneEvents = { PreDraw: 'predraw', PostDraw: 'postdraw', PreDebugDraw: 'predebugdraw', - PostDebugDraw: 'postdebugdraw' + PostDebugDraw: 'postdebugdraw', + PreLoad: 'preload' }; +export type SceneConstructor = new (...args: any[]) => Scene; +/** + * + */ +export function isSceneConstructor(x: any): x is SceneConstructor { + return !!x?.prototype && !!x?.prototype?.constructor?.name; +} + /** * [[Actor|Actors]] are composed together into groupings called Scenes in * Excalibur. The metaphor models the same idea behind real world @@ -180,6 +196,33 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate this.events.off(eventName, handler); } + /** + * Event hook to provide Scenes a way of loading scene specific resources. + * + * This is called before the Scene.onInitialize during scene transition. It will only ever fire once for a scene. + * @param loader + */ + public onPreLoad(loader: DefaultLoader) { + // will be overridden + } + + /** + * Event hook fired directly before transition, either "in" or "out" of the scene + * + * This overrides the Engine scene definition. However transitions specified in goto take hightest precedence + * + * ```typescript + * // Overrides all + * Engine.goto('scene', { destinationIn: ..., sourceOut: ... }); + * ``` + * + * This can be used to configure custom transitions for a scene dynamically + */ + public onTransition(direction: 'in' | 'out'): Transition | undefined { + // will be overridden + return undefined; + } + /** * This is called before the first update of the [[Scene]]. Initializes scene members like the camera. This method is meant to be * overridden. This is where initialization of child actors should take place. @@ -245,7 +288,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate /** * Initializes actors in the scene */ - private _initializeChildren(): void { + private _initializeChildren() { for (const child of this.entities) { child._initialize(this.engine); } @@ -258,6 +301,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate return this._isInitialized; } + /** * It is not recommended that internal excalibur methods be overridden, do so at your own risk. * @@ -265,7 +309,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate * Excalibur * @internal */ - public _initialize(engine: Engine) { + public async _initialize(engine: Engine) { if (!this.isInitialized) { this.engine = engine; // Initialize camera first @@ -275,7 +319,7 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate // 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); + await this.onInitialize(engine); this._initializeChildren(); this._logger.debug('Scene.onInitialize', this, engine); @@ -290,9 +334,9 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate * Activates the scene with the base behavior, then calls the overridable `onActivate` implementation. * @internal */ - public _activate(context: SceneActivationContext): void { + public async _activate(context: SceneActivationContext) { this._logger.debug('Scene.onActivate', this); - this.onActivate(context); + await this.onActivate(context); } /** @@ -301,9 +345,9 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate * Deactivates the scene with the base behavior, then calls the overridable `onDeactivate` implementation. * @internal */ - public _deactivate(context: SceneActivationContext): void { + public async _deactivate(context: SceneActivationContext) { this._logger.debug('Scene.onDeactivate', this); - this.onDeactivate(context); + await this.onDeactivate(context); } /** @@ -356,6 +400,9 @@ implements CanInitialize, CanActivate, CanDeactivate, CanUpdate * @param delta The number of milliseconds since the last update */ public update(engine: Engine, delta: number) { + if (!this.isInitialized) { + throw new Error('Scene update called before it was initialized!'); + } this._preupdate(engine, delta); // TODO differed entity removal for timers diff --git a/src/engine/Screen.ts b/src/engine/Screen.ts index 371347da3..c1c8bcc18 100644 --- a/src/engine/Screen.ts +++ b/src/engine/Screen.ts @@ -448,8 +448,10 @@ export class Screen { } public popResolutionAndViewport() { - this.resolution = this._resolutionStack.pop(); - this.viewport = this._viewportStack.pop(); + if (this._resolutionStack.length && this._viewportStack.length) { + this.resolution = this._resolutionStack.pop(); + this.viewport = this._viewportStack.pop(); + } } private _alreadyWarned = false; diff --git a/src/engine/TileMap/IsometricMap.ts b/src/engine/TileMap/IsometricMap.ts index d7b9b29ac..1a1982346 100644 --- a/src/engine/TileMap/IsometricMap.ts +++ b/src/engine/TileMap/IsometricMap.ts @@ -1,4 +1,10 @@ -import { BodyComponent, BoundingBox, Collider, ColliderComponent, CollisionType, CompositeCollider, vec, Vector } from '..'; +import { BodyComponent } from '../Collision/BodyComponent'; +import { BoundingBox} from '../Collision/BoundingBox'; +import { ColliderComponent } from '../Collision/ColliderComponent'; +import { Collider } from '../Collision/Colliders/Collider'; +import { CollisionType } from '../Collision/CollisionType'; +import { CompositeCollider } from '../Collision/Colliders/CompositeCollider'; +import { vec, Vector } from '../Math/vector'; import { TransformComponent } from '../EntityComponentSystem/Components/TransformComponent'; import { Entity } from '../EntityComponentSystem/Entity'; import { DebugGraphicsComponent, ExcaliburGraphicsContext, Graphic, GraphicsComponent } from '../Graphics'; diff --git a/src/engine/Util/Clock.ts b/src/engine/Util/Clock.ts index 865c44181..9413f75ff 100644 --- a/src/engine/Util/Clock.ts +++ b/src/engine/Util/Clock.ts @@ -1,4 +1,4 @@ -import { Logger } from '..'; +import { Logger } from '../Util/Log'; import { FpsSampler } from './Fps'; export interface ClockOptions { diff --git a/src/engine/Util/Pool.ts b/src/engine/Util/Pool.ts index dee3b5e9d..c7b272226 100644 --- a/src/engine/Util/Pool.ts +++ b/src/engine/Util/Pool.ts @@ -1,4 +1,4 @@ -import { Logger } from '..'; +import { Logger } from '../Util/Log'; export class Pool { public totalAllocations = 0; public index = 0; diff --git a/src/engine/Util/Semaphore.ts b/src/engine/Util/Semaphore.ts index 0c73e9f98..2b7b9ffc6 100644 --- a/src/engine/Util/Semaphore.ts +++ b/src/engine/Util/Semaphore.ts @@ -38,6 +38,7 @@ export class Semaphore { return this._waitQueue.length; } + // eslint-disable-next-line require-await public async enter() { if (this._count !== 0) { this._count--; diff --git a/src/engine/index.ts b/src/engine/index.ts index 07b386b32..d475c66cb 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -23,7 +23,6 @@ export * from './Events/MediaEvents'; export * from './Events'; export * from './Label'; export { FontStyle, FontUnit, TextAlign, BaseAlign } from './Graphics/FontCommon'; -export * from './Loader'; export { Particle, ParticleTransform, ParticleEmitter, ParticleArgs, ParticleEmitterArgs, EmitterType } from './Particles'; export * from './Collision/Physics'; export * from './Scene'; @@ -42,6 +41,8 @@ export * from './Resources/Index'; export * from './EntityComponentSystem/index'; +export * from './Director/index'; + export * from './Color'; export * from './Graphics/index'; @@ -79,9 +80,6 @@ export { CapturePointerConfig } from './Input/CapturePointerConfig'; -export { - EngineInput -} from './Input/EngineInput'; export { NativePointerEvent, diff --git a/src/spec/ActionSpec.ts b/src/spec/ActionSpec.ts index 1a2936c8f..29cb3ecf1 100644 --- a/src/spec/ActionSpec.ts +++ b/src/spec/ActionSpec.ts @@ -8,7 +8,7 @@ describe('Action', () => { let engine: ex.Engine & any; let scene: ex.Scene; - beforeEach(() => { + beforeEach(async () => { jasmine.addMatchers(ExcaliburMatchers); engine = TestUtils.engine({ width: 100, height: 100 }); @@ -16,8 +16,8 @@ describe('Action', () => { scene = new ex.Scene(); scene.add(actor); engine.addScene('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(100); diff --git a/src/spec/ActorSpec.ts b/src/spec/ActorSpec.ts index a31dee9f8..7adffe5b8 100644 --- a/src/spec/ActorSpec.ts +++ b/src/spec/ActorSpec.ts @@ -17,7 +17,7 @@ describe('A game actor', () => { jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); }); - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ width: 100, height: 100 }); actor = new ex.Actor({name: 'Default'}); actor.body.collisionType = ex.CollisionType.Active; @@ -27,13 +27,13 @@ describe('A game actor', () => { scene = new ex.Scene(); scene.add(actor); engine.addScene('test', scene); - engine.goToScene('test'); + await engine.goToScene('test'); spyOn(scene, 'draw').and.callThrough(); spyOn(scene, 'debugDraw').and.callThrough(); - engine.start(); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); collisionSystem.initialize(scene); @@ -628,10 +628,11 @@ describe('A game actor', () => { expect(scene.actors.length).toBe(0); }); - it('once killed is not drawn', () => { + it('once killed is not drawn', async () => { engine.stop(); engine = null; engine = TestUtils.engine({ width: 100, height: 100 }); + await TestUtils.runToReady(engine); actor = new ex.Actor(); actor.body.collisionType = ex.CollisionType.Active; motionSystem = new ex.MotionSystem(); @@ -639,8 +640,7 @@ describe('A game actor', () => { scene = new ex.Scene(); scene.add(actor); engine.addScene('test', scene); - engine.goToScene('test'); - scene._initialize(engine); + await engine.goToScene('test'); engine.screen.setCurrentCamera(engine.currentScene.camera); spyOn(scene, 'draw').and.callThrough(); @@ -694,8 +694,8 @@ describe('A game actor', () => { scene = new ex.Scene(); engine.addScene('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); @@ -730,8 +730,8 @@ describe('A game actor', () => { }); scene = new ex.Scene(); engine.addScene('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); @@ -900,12 +900,11 @@ describe('A game actor', () => { expect(parent.contains(250, 250, true)).toBeTruthy(); }); - it('with an active collision type can be placed on a fixed type', () => { + it('with an active collision type can be placed on a fixed type', async () => { ex.Physics.useArcadePhysics(); const scene = new ex.Scene(); engine.add('somescene', scene); - engine.goToScene('somescene'); - scene._initialize(engine); + await engine.goToScene('somescene'); const active = new ex.Actor({ x: 0, y: -50, width: 100, height: 100 }); active.body.collisionType = ex.CollisionType.Active; @@ -936,9 +935,9 @@ describe('A game actor', () => { expect(fixed.pos.y).toBe(50); }); - it('with an active collision type can jump on a fixed type', () => { + it('with an active collision type can jump on a fixed type', async () => { const scene = new ex.Scene(); - scene._initialize(engine); + await scene._initialize(engine); const active = new ex.Actor({ x: 0, y: -50, width: 100, height: 100 }); active.body.collisionType = ex.CollisionType.Active; active.vel.y = -100; @@ -1060,21 +1059,20 @@ describe('A game actor', () => { scene.update(engine, 100); }); - it('can only be initialized once', () => { + it('can only be initialized once', async () => { const actor = new ex.Actor(); - let initializeCount = 0; - actor.on('initialize', () => { - initializeCount++; - }); - actor._initialize(engine); - actor._initialize(engine); - actor._initialize(engine); + const initializeSpy = jasmine.createSpy('initialize'); + + actor.on('initialize', initializeSpy); + await actor._initialize(engine); + await actor._initialize(engine); + await actor._initialize(engine); - expect(initializeCount).toBe(1, 'Actors can only be initialized once'); + expect(initializeSpy).toHaveBeenCalledTimes(1); }); - it('can initialize child actors', () => { + it('can initialize child actors', async () => { const actor = new ex.Actor(); const child = new ex.Actor(); const grandchild = new ex.Actor(); @@ -1091,10 +1089,10 @@ describe('A game actor', () => { initializeCount++; }); - actor._initialize(engine); - actor._initialize(engine); + await actor._initialize(engine); + await actor._initialize(engine); - expect(initializeCount).toBe(3, 'All child actors should be initialized'); + expect(initializeCount).withContext('All child actors should be initialized').toBe(3); }); describe('should detect assigned events and', () => { diff --git a/src/spec/AlgebraSpec.ts b/src/spec/AlgebraSpec.ts index cb5b8d8dc..816fce5a4 100644 --- a/src/spec/AlgebraSpec.ts +++ b/src/spec/AlgebraSpec.ts @@ -1,7 +1,5 @@ -import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; +import { ExcaliburMatchers } from 'excalibur-jasmine'; import * as ex from '@excalibur'; -import { TestUtils } from './util/TestUtils'; -import { Mocks } from './util/Mocks'; describe('Vectors', () => { beforeEach(() => { diff --git a/src/spec/BodyComponentSpec.ts b/src/spec/BodyComponentSpec.ts index 940bdf15a..783a43e22 100644 --- a/src/spec/BodyComponentSpec.ts +++ b/src/spec/BodyComponentSpec.ts @@ -1,5 +1,5 @@ import * as ex from '@excalibur'; -import { BodyComponent, CollisionGroup, CollisionType } from '@excalibur'; +import { BodyComponent } from '@excalibur'; import { ExcaliburMatchers } from 'excalibur-jasmine'; describe('A body component', () => { diff --git a/src/spec/ColliderComponentSpec.ts b/src/spec/ColliderComponentSpec.ts index b5b387ea1..b18496b2f 100644 --- a/src/spec/ColliderComponentSpec.ts +++ b/src/spec/ColliderComponentSpec.ts @@ -46,6 +46,7 @@ describe('A ColliderComponent', () => { new ex.CollisionStartEvent( ex.Shape.Circle(50), ex.Shape.Circle(50), + null, null)); expect(originalCollisionHandler).toHaveBeenCalledTimes(1); diff --git a/src/spec/CollisionShapeSpec.ts b/src/spec/CollisionShapeSpec.ts index afe785726..2bc790120 100644 --- a/src/spec/CollisionShapeSpec.ts +++ b/src/spec/CollisionShapeSpec.ts @@ -16,13 +16,13 @@ describe('Collision Shape', () => { let circle: ex.CircleCollider; let actor: ex.Actor; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine(); engine.backgroundColor = ex.Color.Transparent; scene = new ex.Scene(); engine.add('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); @@ -423,13 +423,13 @@ describe('Collision Shape', () => { describe('a ConvexPolygon', () => { let engine: ex.Engine; let scene: ex.Scene; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine(); engine.backgroundColor = ex.Color.Transparent; scene = new ex.Scene(); engine.addScene('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); }); @@ -821,13 +821,13 @@ describe('Collision Shape', () => { engine = null; }); - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine(); engine.backgroundColor = ex.Color.Transparent; scene = new ex.Scene(); engine.addScene('test', scene); - engine.goToScene('test'); - engine.start(); + await engine.goToScene('test'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); diff --git a/src/spec/CollisionSpec.ts b/src/spec/CollisionSpec.ts index b285b91f4..e275fb8c9 100644 --- a/src/spec/CollisionSpec.ts +++ b/src/spec/CollisionSpec.ts @@ -7,7 +7,7 @@ describe('A Collision', () => { let engine: ex.Engine = null; let clock: ex.TestClock = null; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ width: 600, height: 400 }); clock = engine.clock = engine.clock.toTestClock(); @@ -16,7 +16,7 @@ describe('A Collision', () => { actor1.body.collisionType = ex.CollisionType.Active; actor2.body.collisionType = ex.CollisionType.Active; - engine.start(); + await engine.start(); engine.add(actor1); engine.add(actor2); }); @@ -29,7 +29,7 @@ describe('A Collision', () => { actor2 = null; }); - it('should throw one event for each actor participating', async () => { + it('should throw one event for each actor participating', () => { let actor1Collision = 0; let actor2Collision = 0; actor1.on('precollision', (e: ex.PreCollisionEvent) => { diff --git a/src/spec/CrossFadeSpec.ts b/src/spec/CrossFadeSpec.ts new file mode 100644 index 000000000..b73d44dff --- /dev/null +++ b/src/spec/CrossFadeSpec.ts @@ -0,0 +1,63 @@ +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; +import { ExcaliburAsyncMatchers } from 'excalibur-jasmine'; + +describe('A CrossFade transition', () => { + beforeAll(() => { + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + it('exists', () => { + expect(ex.CrossFade).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.CrossFade({duration: 1000}); + expect(sut.duration).toBe(1000); + expect(sut.name).toContain('CrossFade#'); + }); + + it('can cross fade', (done) => { + const engine = TestUtils.engine({backgroundColor: ex.Color.ExcaliburBlue}); + const clock = engine.clock as ex.TestClock; + TestUtils.runToReady(engine).then(() => { + engine.rootScene.add(new ex.Actor({ + pos: ex.vec(20, 20), + width: 100, + height: 100, + color: ex.Color.Red + })); + + const onDeactivateSpy = jasmine.createSpy('onDeactivate').and.callFake(async () => { + await Promise.resolve(); + }); + + engine.director.getSceneInstance('root').onDeactivate = onDeactivateSpy; + + const sut = new ex.CrossFade({duration: 1000}); + const scene = new ex.Scene(); + scene.add(new ex.Actor({ + pos: ex.vec(200, 200), + width: 40, + height: 40, + color: ex.Color.Violet + })); + engine.addScene('newScene', { scene, transitions: {in: sut }}); + + const goto = engine.goto('newScene', { destinationIn: sut}); + setTimeout(() => { + clock.step(1); + }); + setTimeout(() => { + clock.step(400); + clock.step(400); + }); + setTimeout(() => { + clock.step(1); + expect(onDeactivateSpy).toHaveBeenCalledTimes(1); + expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('/src/spec/images/CrossFadeSpec/crossfade.png').then(() => { + done(); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/spec/DefaultLoaderSpec.ts b/src/spec/DefaultLoaderSpec.ts new file mode 100644 index 000000000..0613dd0ad --- /dev/null +++ b/src/spec/DefaultLoaderSpec.ts @@ -0,0 +1,72 @@ +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; +import { ExcaliburAsyncMatchers, ExcaliburMatchers } from 'excalibur-jasmine'; + +describe('A DefaultLoader', () => { + + let engine: ex.Engine; + beforeEach(() => { + jasmine.addMatchers(ExcaliburMatchers); + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + engine = TestUtils.engine(); + }); + + it('exists', () => { + expect(ex.DefaultLoader).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.DefaultLoader(); + expect(sut).toBeDefined(); + }); + + it('can be constructed with non-defaults', () => { + const sut = new ex.DefaultLoader({ + loadables: [new ex.ImageSource('./some/image.png')] + }); + expect(sut.resources.length).toBe(1); + expect(sut.isLoaded()).toBe(false); + expect(sut.progress).toBe(0); + }); + + it('can draw', async () => { + const sut = new ex.DefaultLoader({ + loadables: [ , , , ] + }); + sut.onInitialize(engine); + sut.markResourceComplete(); + sut.onUpdate(engine, 100); + sut.onDraw(sut.canvas.ctx); + expect(sut.resources.length).toBe(3); + await expectAsync(sut.canvas.ctx).toEqualImage('src/spec/images/DefaultLoaderSpec/loading.png'); + }); + + it('can load stuff', async () => { + const img1 = new ex.ImageSource('src/spec/images/DefaultLoaderSpec/loading.png'); + const img2 = new ex.ImageSource('src/spec/images/DefaultLoaderSpec/loading.png'); + const sut = new ex.DefaultLoader({ + loadables: [ img1, img2 ] + }); + sut.onInitialize(engine); + + const onBeforeLoadSpy = jasmine.createSpy('onBeforeLoad'); + const onAfterLoadSpy = jasmine.createSpy('onBeforeLoad'); + const onUserAction = jasmine.createSpy('onUserAction'); + const resourceStartLoad = jasmine.createSpy('resourceStartLoad'); + const resourceEndLoad = jasmine.createSpy('resourceStartLoad'); + sut.on('loadresourcestart', resourceStartLoad); + sut.on('loadresourceend', resourceEndLoad); + sut.onBeforeLoad = onBeforeLoadSpy; + sut.onAfterLoad = onAfterLoadSpy; + sut.onUserAction = onUserAction; + + await sut.load(); + + expect(onBeforeLoadSpy).toHaveBeenCalledTimes(1); + expect(onAfterLoadSpy).toHaveBeenCalledTimes(1); + expect(onUserAction).toHaveBeenCalledTimes(1); + + expect(resourceStartLoad).toHaveBeenCalledTimes(2); + expect(resourceEndLoad).toHaveBeenCalledTimes(2); + }); +}); \ No newline at end of file diff --git a/src/spec/DirectorSpec.ts b/src/spec/DirectorSpec.ts new file mode 100644 index 000000000..29abb9f87 --- /dev/null +++ b/src/spec/DirectorSpec.ts @@ -0,0 +1,221 @@ +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; +import { ExcaliburAsyncMatchers } from 'excalibur-jasmine'; + +describe('A Director', () => { + beforeAll(() => { + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + it('exists', () => { + expect(ex.Director).toBeDefined(); + }); + + it('can be constructed with a varied scene map', () => { + const engine = TestUtils.engine(); + const sut = new ex.Director(engine, { + scene1: new ex.Scene(), + scene2: { scene: new ex.Scene() }, + scene3: { scene: ex.Scene }, + scene4: ex.Scene + }); + + expect(sut).toBeDefined(); + expect(sut.rootScene).not.toBe(null); + expect(sut.getSceneInstance('scene1')).not.toBe(null); + expect(sut.getSceneInstance('scene2')).not.toBe(null); + expect(sut.getSceneInstance('scene3')).not.toBe(null); + expect(sut.getSceneInstance('scene4')).not.toBe(null); + }); + + it('can be constructed with a varied loaders', () => { + const engine = TestUtils.engine(); + const sut = new ex.Director(engine, { + scene1: new ex.Scene(), + scene2: { scene: new ex.Scene(), loader: new ex.DefaultLoader() }, + scene3: { scene: ex.Scene, loader: ex.DefaultLoader }, + scene4: ex.Scene + }); + + expect(sut).toBeDefined(); + expect(sut.rootScene).not.toBe(null); + expect(sut.getSceneInstance('scene1')).not.toBe(null); + expect(sut.getSceneInstance('scene2')).not.toBe(null); + expect(sut.getSceneInstance('scene3')).not.toBe(null); + expect(sut.getSceneInstance('scene4')).not.toBe(null); + }); + + it('can configure start, non deferred', async () => { + const engine = TestUtils.engine(); + const scene1 = new ex.Scene(); + const scene2 = new ex.Scene(); + const sut = new ex.Director(engine, { + scene1, + scene2 + }); + sut.onInitialize(); + + + const fadeIn = new ex.FadeInOut({ direction: 'in', duration: 1000}); + const loader = new ex.DefaultLoader(); + sut.configureStart('scene1', { + inTransition: fadeIn, + loader + }); + await engine.load(loader); + sut.update(); + + expect(sut.currentTransition).toBe(fadeIn); + expect(sut.currentSceneName).toBe('scene1'); + expect(sut.currentScene).toBe(scene1); + }); + + it('can configure start deferred', async () => { + const engine = TestUtils.engine(); + const scene1 = new ex.Scene(); + const scene2 = new ex.Scene(); + const sut = new ex.Director(engine, { + scene1, + scene2 + }); + const fadeIn = new ex.FadeInOut({ direction: 'in', duration: 1000}); + const loader = new ex.DefaultLoader(); + sut.configureStart('scene1', { + inTransition: fadeIn, + loader + }); + + sut.onInitialize(); + await engine.load(loader); + sut.update(); + + expect(sut.currentTransition).toBe(fadeIn); + expect(sut.currentSceneName).toBe('scene1'); + expect(sut.currentScene).toBe(scene1); + }); + + it('will draw a start scene transition', async () => { + const engine = TestUtils.engine(); + const clock = engine.clock as ex.TestClock; + clock.start(); + const scene1 = new ex.Scene(); + const scene2 = new ex.Scene(); + const sut = new ex.Director(engine, { + scene1, + scene2 + }); + const fadeIn = new ex.FadeInOut({ direction: 'in', duration: 1000}); + const loader = new ex.DefaultLoader(); + sut.configureStart('scene1', { + inTransition: fadeIn, + loader + }); + + sut.onInitialize(); + await engine.load(loader); + await (engine as any)._overrideInitialize(engine); + + clock.step(100); + sut.update(); + clock.step(100); + sut.update(); + clock.step(100); + sut.update(); + clock.step(100); + sut.update(); + + expect(sut.currentTransition).toBe(fadeIn); + expect(sut.currentSceneName).toBe('scene1'); + expect(sut.currentScene).toBe(scene1); + + await expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('/src/spec/images/DirectorSpec/fadein.png'); + }); + + it('will run the loader cycle on a scene only once', async () => { + const engine = TestUtils.engine(); + const clock = engine.clock as ex.TestClock; + clock.start(); + const scene1 = new ex.Scene(); + const loaderSpy = jasmine.createSpy('loader'); + scene1.onPreLoad = loaderSpy; + const scene2 = new ex.Scene(); + const sut = new ex.Director(engine, { + scene1, + scene2 + }); + + await sut.maybeLoadScene('scene1'); + await sut.maybeLoadScene('scene1'); + await sut.maybeLoadScene('scene1'); + await sut.maybeLoadScene('scene1'); + + expect(loaderSpy).toHaveBeenCalledTimes(1); + }); + + it('can remove a scene', () => { + const engine = TestUtils.engine(); + const clock = engine.clock as ex.TestClock; + clock.start(); + const sut = new ex.Director(engine, { + scene1: new ex.Scene(), + scene2: { scene: new ex.Scene() }, + scene3: { scene: ex.Scene }, + scene4: ex.Scene + }); + + sut.remove('scene1'); + expect(sut.getSceneDefinition('scene1')).toBe(undefined); + expect(sut.getSceneInstance('scene1')).toBe(undefined); + sut.remove('scene2'); + expect(sut.getSceneDefinition('scene2')).toBe(undefined); + expect(sut.getSceneInstance('scene2')).toBe(undefined); + sut.remove('scene3'); + expect(sut.getSceneDefinition('scene3')).toBe(undefined); + expect(sut.getSceneInstance('scene3')).toBe(undefined); + sut.remove('scene4'); + expect(sut.getSceneDefinition('scene4')).toBe(undefined); + expect(sut.getSceneInstance('scene4')).toBe(undefined); + }); + + it('cant remove an active scene', () => { + const engine = TestUtils.engine(); + const clock = engine.clock as ex.TestClock; + clock.start(); + const sut = new ex.Director(engine, { + scene1: new ex.Scene(), + scene2: { scene: new ex.Scene() }, + scene3: { scene: ex.Scene }, + scene4: ex.Scene + }); + + expect(() => sut.remove('root')).toThrowError('Cannot remove a currently active scene: root'); + expect(() => sut.remove(sut.rootScene)).toThrowError('Cannot remove a currently active scene: root'); + }); + + it('can goto a scene', async () => { + const engine = TestUtils.engine(); + const clock = engine.clock as ex.TestClock; + clock.start(); + const scene2 = new ex.Scene(); + class MyScene extends ex.Scene {} + const sut = new ex.Director(engine, { + scene1: new ex.Scene(), + scene2: { scene: scene2 }, + scene3: { scene: ex.Scene }, + scene4: MyScene + }); + sut.configureStart('root'); + + sut.onInitialize(); + await engine.load(sut.mainLoader); + await (engine as any)._overrideInitialize(engine); + + await sut.goto('scene2'); + + expect(sut.currentScene).toBe(scene2); + expect(sut.currentSceneName).toBe('scene2'); + + await sut.goto('scene4'); + expect(sut.currentSceneName).toBe('scene4'); + expect(sut.currentScene).toBeInstanceOf(MyScene); + }); +}); \ No newline at end of file diff --git a/src/spec/EngineSpec.ts b/src/spec/EngineSpec.ts index 4ed47a7d1..cc715aa75 100644 --- a/src/spec/EngineSpec.ts +++ b/src/spec/EngineSpec.ts @@ -14,7 +14,7 @@ function flushWebGLCanvasTo2D(source: HTMLCanvasElement): HTMLCanvasElement { return canvas; } -describe('The engine', () => { +describe('The engine', () => { // TODO timeout let engine: ex.Engine; let scene: ex.Scene; @@ -29,15 +29,15 @@ describe('The engine', () => { } }; - beforeEach(() => { + beforeEach(async () => { jasmine.addMatchers(ExcaliburMatchers); jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); engine = TestUtils.engine(); scene = new ex.Scene(); engine.add('default', scene); - engine.goToScene('default'); - engine.start(); + await engine.goToScene('default'); + await engine.start(); const clock = engine.clock as ex.TestClock; clock.step(1); }); @@ -46,21 +46,22 @@ describe('The engine', () => { reset(); }); - it('should not throw if no loader provided', () => { + it('should not throw if no loader provided', async () => { const exceptionSpy = jasmine.createSpy('exception'); - const boot = () => { + const boot = async () => { const engine = TestUtils.engine(); + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; clock.setFatalExceptionHandler(exceptionSpy); clock.start(); clock.step(100); }; - boot(); + await boot(); expect(exceptionSpy).not.toHaveBeenCalled(); }); - it('should show the play button by default', (done) => { + xit('should show the play button by default', (done) => { reset(); engine = TestUtils.engine({ suppressPlayButton: false @@ -69,27 +70,28 @@ describe('The engine', () => { 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); - engine.graphicsContext.flush(); - expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)) - .toEqualImage('src/spec/images/EngineSpec/engine-load-complete.png').then(() => { - done(); - }); + engine.start(loader).then(() => { + 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); + engine.graphicsContext.flush(); + expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)) + .toEqualImage('src/spec/images/EngineSpec/engine-load-complete.png').then(() => { + done(); + }); + }); }); }); }); it('should log if loading fails', async () => { - class FailedLoader implements ex.Loadable { + class FailedLoader extends ex.DefaultLoader { data = undefined; - load(): Promise { + // eslint-disable-next-line require-await + async load(): Promise[]> { throw new Error('I failed'); } isLoaded(): boolean { @@ -194,8 +196,9 @@ describe('The engine', () => { expect(engine.graphicsContext).toBeInstanceOf(ex.ExcaliburGraphicsContext2DCanvas); }); - it('should update the frame stats every tick', () => { + it('should update the frame stats every tick', async () => { engine = TestUtils.engine(); + await TestUtils.runToReady(engine); const testClock = engine.clock as ex.TestClock; testClock.start(); expect(engine.stats.currFrame.id).toBe(0); @@ -639,7 +642,7 @@ describe('The engine', () => { expect(ex.Logger.getInstance().error).toHaveBeenCalledWith('Scene', 'madeUp', 'does not exist!'); }); - it('will add actors to the correct scene when initialized after a deferred goTo', () => { + it('will add actors to the correct scene when initialized after a deferred goTo', async () => { const engine = TestUtils.engine(); const scene1 = new ex.Scene(); const scene2 = new ex.Scene(); @@ -657,9 +660,9 @@ describe('The engine', () => { spyOn(scene2, 'onInitialize').and.callThrough(); - engine.goToScene('scene1'); + await engine.goToScene('scene1'); - TestUtils.runToReady(engine); + await TestUtils.runToReady(engine); expect(engine.currentScene).toBe(scene2); expect(scene1.actors.length).toBe(0); @@ -678,17 +681,19 @@ describe('The engine', () => { height: 100, color: ex.Color.Red })); - TestUtils.runToReady(engine); - - clock.step(1); + TestUtils.runToReady(engine).then(() => { + clock.step(1); - engine.screenshot().then((image) => { - return expectAsync(image).toEqualImage(flushWebGLCanvasTo2D(engine.canvas)).then(() => { - done(); + engine.screenshot().then((image) => { + expectAsync(image).toEqualImage(flushWebGLCanvasTo2D(engine.canvas)).then(() => { + done(); + }); }); + clock.step(1); + clock.step(1); + clock.step(1); }); - clock.step(1); }); it('can screen shot the game HiDPI (in WebGL)', async () => { @@ -705,7 +710,7 @@ describe('The engine', () => { height: 100, color: ex.Color.Red })); - TestUtils.runToReady(engine); + await TestUtils.runToReady(engine); clock.step(1); const screenShotPromise = engine.screenshot(); @@ -743,7 +748,7 @@ describe('The engine', () => { }); actor.graphics.use(img.toSprite()); engine.add(actor); - TestUtils.runToReady(engine); + await TestUtils.runToReady(engine); clock.step(1); const screenShotPromise = engine.screenshot(); @@ -786,8 +791,8 @@ describe('The engine', () => { expect(engine.onInitialize).toHaveBeenCalledTimes(1); }); - it('can have onPostUpdate overridden safely', () => { - engine.start(); + it('can have onPostUpdate overridden safely', async () => { + await engine.start(); const clock = engine.clock as ex.TestClock; expect(engine.clock.isRunning()).toBe(true); @@ -806,8 +811,8 @@ describe('The engine', () => { expect(engine.onPostUpdate).toHaveBeenCalledTimes(2); }); - it('can have onPreUpdate overridden safely', () => { - engine.start(); + it('can have onPreUpdate overridden safely', async () => { + await engine.start(); const clock = engine.clock as ex.TestClock; expect(engine.clock.isRunning()).toBe(true); @@ -826,8 +831,8 @@ describe('The engine', () => { expect(engine.onPreUpdate).toHaveBeenCalledTimes(2); }); - it('can have onPreDraw overridden safely', () => { - engine.start(); + it('can have onPreDraw overridden safely', async () => { + await engine.start(); const clock = engine.clock as ex.TestClock; expect(engine.clock.isRunning()).toBe(true); @@ -846,8 +851,8 @@ describe('The engine', () => { expect(engine.onPreDraw).toHaveBeenCalledTimes(2); }); - it('can have onPostDraw overridden safely', () => { - engine.start(); + it('can have onPostDraw overridden safely', async () => { + await engine.start(); const clock = engine.clock as ex.TestClock; expect(engine.clock.isRunning()).toBe(true); diff --git a/src/spec/FadeInOutSpec.ts b/src/spec/FadeInOutSpec.ts new file mode 100644 index 000000000..5f1c74026 --- /dev/null +++ b/src/spec/FadeInOutSpec.ts @@ -0,0 +1,101 @@ +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; +import { ExcaliburAsyncMatchers } from 'excalibur-jasmine'; + +describe('A FadeInOut transition', () => { + beforeAll(() => { + jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); + }); + it('exists', () => { + expect(ex.CrossFade).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.FadeInOut({duration: 1000, color: ex.Color.Red}); + expect(sut.duration).toBe(1000); + expect(sut.name).toContain('FadeInOut#'); + expect(sut.color).toEqual(ex.Color.Red); + }); + + it('can fade in', (done) => { + const engine = TestUtils.engine({backgroundColor: ex.Color.ExcaliburBlue}); + const clock = engine.clock as ex.TestClock; + TestUtils.runToReady(engine).then(() => { + engine.add(new ex.Actor({ + pos: ex.vec(20, 20), + width: 100, + height: 100, + color: ex.Color.Red + })); + + const onDeactivateSpy = jasmine.createSpy('onDeactivate').and.callFake(async () => { + await Promise.resolve(); + }); + + engine.director.getSceneInstance('root').onDeactivate = onDeactivateSpy; + + const sut = new ex.FadeInOut({duration: 1000, direction: 'in'}); + const scene = new ex.Scene(); + scene.add(new ex.Actor({ + pos: ex.vec(200, 200), + width: 40, + height: 40, + color: ex.Color.Violet + })); + engine.addScene('newScene', { scene, transitions: {in: sut}}); + + const goto = engine.goto('newScene'); + setTimeout(() => { + clock.step(1); + }); + setTimeout(() => { + clock.step(500); + }); + setTimeout(() => { + clock.step(1); + expect(onDeactivateSpy).toHaveBeenCalledTimes(1); + expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('/src/spec/images/FadeInOutSpec/fadein.png').then(() => { + done(); + }); + }); + }); + }); + + it('can fade out', (done) => { + const engine = TestUtils.engine({backgroundColor: ex.Color.ExcaliburBlue}); + const clock = engine.clock as ex.TestClock; + TestUtils.runToReady(engine).then(() => { + engine.add(new ex.Actor({ + pos: ex.vec(20, 20), + width: 100, + height: 100, + color: ex.Color.Red + })); + + + const sut = new ex.FadeInOut({duration: 1000, direction: 'out', color: ex.Color.Violet}); + const scene = new ex.Scene(); + scene.add(new ex.Actor({ + pos: ex.vec(200, 200), + width: 40, + height: 40, + color: ex.Color.Violet + })); + engine.addScene('newScene', scene); + + const goto = engine.goto('newScene', {sourceOut: sut}); + setTimeout(() => { + clock.step(1); + }); + setTimeout(() => { + clock.step(900); + }); + setTimeout(() => { + clock.step(1); + expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('/src/spec/images/FadeInOutSpec/fadeout.png').then(() => { + done(); + }); + }); + }); + }); +}); \ No newline at end of file diff --git a/src/spec/FrameStatsSpec.ts b/src/spec/FrameStatsSpec.ts index 55b7c5a05..dc9b63c0e 100644 --- a/src/spec/FrameStatsSpec.ts +++ b/src/spec/FrameStatsSpec.ts @@ -10,19 +10,18 @@ describe('The engine', () => { let actor: ex.Actor; let stats: ex.FrameStats; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ width: 400, height: 400 }); scene = new ex.Scene(); - engine.removeScene('root'); - engine.addScene('root', scene); - engine.goToScene('root'); + engine.addScene('newScene', scene); + await engine.goToScene('newScene'); // TODO use new method actor = new ex.Actor({ x: 0, y: 0, width: 10, height: 10, color: ex.Color.Red }); scene.add(actor); - TestUtils.runToReady(engine); + await TestUtils.runToReady(engine); const clock = engine.clock as TestClock; clock.step(16.6); stats = engine.stats.currFrame; diff --git a/src/spec/InputMapperSpec.ts b/src/spec/InputMapperSpec.ts index b9822ac11..0938195e3 100644 --- a/src/spec/InputMapperSpec.ts +++ b/src/spec/InputMapperSpec.ts @@ -50,10 +50,10 @@ describe('An InputMapper', () => { expect(command).toHaveBeenCalledTimes(0); }); - it('can fire wasPressed events when used in a engine', () => { + it('can fire wasPressed events when used in a engine', async () => { const engine = TestUtils.engine({ width: 100, height: 100 }); - + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; clock.start(); engine.input.keyboard.triggerEvent('down', ex.Keys.Space); diff --git a/src/spec/LabelSpec.ts b/src/spec/LabelSpec.ts index 1f613a631..491f90cff 100644 --- a/src/spec/LabelSpec.ts +++ b/src/spec/LabelSpec.ts @@ -1,7 +1,6 @@ import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; import * as ex from '@excalibur'; import { TestUtils } from './util/TestUtils'; -import { Mocks } from './util/Mocks'; describe('A label', () => { let label: ex.Label; diff --git a/src/spec/LineSpec.ts b/src/spec/LineSpec.ts index c13dee25b..524b19051 100644 --- a/src/spec/LineSpec.ts +++ b/src/spec/LineSpec.ts @@ -100,7 +100,7 @@ describe('A Line', () => { const testClock = game.clock as ex.TestClock; - TestUtils.runToReady(game); + await TestUtils.runToReady(game); const sut = new ex.Line({ start: ex.vec(0, 0), diff --git a/src/spec/LoaderSpec.ts b/src/spec/LoaderSpec.ts index 5fc78fce3..36d75c4bf 100644 --- a/src/spec/LoaderSpec.ts +++ b/src/spec/LoaderSpec.ts @@ -1,5 +1,4 @@ import * as ex from '@excalibur'; -import { Loader } from '@excalibur'; import { ExcaliburMatchers, ensureImagesLoaded } from 'excalibur-jasmine'; import { TestUtils } from './util/TestUtils'; @@ -45,8 +44,8 @@ describe('A loader', () => { it('can be drawn at 0', (done) => { const loader = new ex.Loader([, , , ,]); (loader as any)._image.onload = () => { - loader.wireEngine(engine); - loader.draw(loader.canvas.ctx); + loader.onInitialize(engine); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/zero.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); done(); @@ -60,8 +59,8 @@ describe('A loader', () => { loader.markResourceComplete(); loader.markResourceComplete(); - loader.wireEngine(engine); - loader.draw(loader.canvas.ctx); + loader.onInitialize(engine); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/fifty.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); done(); @@ -77,8 +76,8 @@ describe('A loader', () => { loader.markResourceComplete(); loader.markResourceComplete(); - loader.wireEngine(engine); - loader.draw(loader.canvas.ctx); + loader.onInitialize(engine); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/100.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); done(); @@ -93,10 +92,10 @@ describe('A loader', () => { loader.markResourceComplete(); loader.markResourceComplete(); loader.markResourceComplete(); - loader.wireEngine(engine); + loader.onInitialize(engine); loader.showPlayButton(); - loader.draw(loader.canvas.ctx); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/playbuttonshown-noprogressbar.png') .then(([canvas, image]) => { expect(canvas).toEqualImage(image); @@ -107,10 +106,10 @@ describe('A loader', () => { it('can have the play button position customized', () => { const loader = new ex.Loader([, , , ,]); - loader.wireEngine(engine); + loader.onInitialize(engine); loader.playButtonPosition = ex.vec(42, 77); loader.showPlayButton(); - loader.draw(loader.canvas.ctx); + loader.onDraw(loader.canvas.ctx); // there is some dom pollution want to be sure we get the RIGHT root element const playbutton = (loader as any)._playButtonRootElement as HTMLDivElement; expect(playbutton.style.left).toBe('42px'); @@ -120,10 +119,10 @@ describe('A loader', () => { it('can have the logo position customized', (done) => { const loader = new ex.Loader([, , , ,]); (loader as any)._image.onload = () => { - loader.wireEngine(engine); + loader.onInitialize(engine); loader.logoPosition = ex.vec(0, 0); loader.showPlayButton(); - loader.draw(loader.canvas.ctx); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/logo-position.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); done(); @@ -140,8 +139,8 @@ describe('A loader', () => { loader.markResourceComplete(); loader.markResourceComplete(); loader.markResourceComplete(); - loader.wireEngine(engine); - loader.draw(loader.canvas.ctx); + loader.onInitialize(engine); + loader.onDraw(loader.canvas.ctx); ensureImagesLoaded(loader.canvas.ctx.canvas, 'src/spec/images/LoaderSpec/loader-position-color.png').then(([canvas, image]) => { expect(canvas).toEqualImage(image); done(); @@ -177,7 +176,7 @@ describe('A loader', () => { it('can have the enter key pressed to start', (done) => { const loader = new ex.Loader([, , , ,]); - loader.wireEngine(engine); + loader.onInitialize(engine); loader.loadingBarPosition = ex.vec(0, 0); loader.loadingBarColor = ex.Color.Red; loader.markResourceComplete(); @@ -222,8 +221,8 @@ describe('A loader', () => { 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); + const loader = new ex.Loader([new ex.ImageSource('src/spec/images/GraphicsTextSpec/spritefont.png')]); + const start = engine.start(loader); await loader.areResourcesLoaded(); clock.step(200); @@ -241,7 +240,7 @@ describe('A loader', () => { it('updates the play button postion on resize', () => { const engine = new ex.Engine({width: 1000, height: 1000}); const loader = new ex.Loader([, , , ,]); - loader.wireEngine(engine); + loader.onInitialize(engine); loader.markResourceComplete(); loader.markResourceComplete(); loader.markResourceComplete(); diff --git a/src/spec/ParticleSpec.ts b/src/spec/ParticleSpec.ts index 8de11fab9..ae0636cc9 100644 --- a/src/spec/ParticleSpec.ts +++ b/src/spec/ParticleSpec.ts @@ -17,7 +17,7 @@ function flushWebGLCanvasTo2D(source: HTMLCanvasElement): HTMLCanvasElement { describe('A particle', () => { let engine: ex.Engine; let scene: ex.Scene; - beforeEach(() => { + beforeEach(async () => { jasmine.addMatchers(ExcaliburMatchers); jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); engine = TestUtils.engine( @@ -30,8 +30,7 @@ describe('A particle', () => { ); scene = new ex.Scene(); engine.addScene('root', scene); - engine.start(); - + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; clock.step(1); diff --git a/src/spec/PointerInputSpec.ts b/src/spec/PointerInputSpec.ts index ab9108572..e28ae38cc 100644 --- a/src/spec/PointerInputSpec.ts +++ b/src/spec/PointerInputSpec.ts @@ -22,11 +22,11 @@ describe('A pointer', () => { target.dispatchEvent(evt); } - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ pointerScope: ex.PointerScope.Document }); - engine.start(); + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; clock.step(1); diff --git a/src/spec/ResourceSpec.ts b/src/spec/ResourceSpec.ts index 60e6c7323..2f0527ae2 100644 --- a/src/spec/ResourceSpec.ts +++ b/src/spec/ResourceSpec.ts @@ -31,14 +31,11 @@ describe('A generic Resource', () => { }); describe('without data', () => { - it('should not fail on load', (done) => { + it('should not fail on load', async () => { const emptyLoader = new ex.Loader(); const game = TestUtils.engine(); - TestUtils.runToReady(game, emptyLoader).then(() => { - expect(emptyLoader.isLoaded()).toBe(true); - game.stop(); - done(); - }); + await game.start(); + expect(emptyLoader.isLoaded()).toBe(true); }); }); diff --git a/src/spec/SceneSpec.ts b/src/spec/SceneSpec.ts index 8a8e12096..7bedc3197 100644 --- a/src/spec/SceneSpec.ts +++ b/src/spec/SceneSpec.ts @@ -7,16 +7,15 @@ describe('A scene', () => { let scene: ex.Scene; let clock: ex.TestClock; - beforeEach(() => { + beforeEach(async () => { actor = new ex.Actor(); engine = TestUtils.engine({ width: 100, height: 100 }); scene = new ex.Scene(); spyOn(scene, 'draw').and.callThrough(); - engine.removeScene('root'); - engine.addScene('root', scene); - engine.goToScene('root'); - engine.start(); + engine.addScene('newScene', scene); + engine.goToScene('newScene'); + await TestUtils.runToReady(engine); clock = engine.clock as ex.TestClock; clock.step(100); @@ -31,14 +30,14 @@ describe('A scene', () => { expect(ex.Scene).toBeTruthy(); }); - it('can have a background color set', () => { + it('can have a background color set', async () => { engine.backgroundColor = ex.Color.Black; const newScene = new ex.Scene(); newScene.backgroundColor = ex.Color.Yellow; engine.addScene('background', newScene); - engine.goToScene('background'); + await engine.goToScene('background'); (engine as any)._draw(100); @@ -315,39 +314,30 @@ describe('A scene', () => { expect(actor.graphics.onPostDraw).not.toHaveBeenCalled(); }); - it('initializes after start or play in first update', () => { + it('initializes after start or play in first update', async () => { const scene = new ex.Scene(); spyOn(scene, 'onInitialize'); - engine.removeScene('root'); - engine.addScene('root', scene); + engine.addScene('otherScene', scene); expect(scene.onInitialize).toHaveBeenCalledTimes(0); - engine.goToScene('root'); - engine.start(); - clock.step(100); + await engine.goToScene('otherScene'); expect(scene.onInitialize).toHaveBeenCalledTimes(1); }); - it('calls onActivate and onDeactivate with the correct args', () => { + it('calls onActivate and onDeactivate with the correct args', async () => { const sceneA = new ex.Scene(); sceneA.onDeactivate = jasmine.createSpy('onDeactivate()'); const sceneB = new ex.Scene(); sceneB.onActivate = jasmine.createSpy('onActivate()'); - engine.removeScene('root'); - engine.addScene('root', sceneA); + engine.addScene('sceneA', sceneA); engine.addScene('sceneB', sceneB); - engine.goToScene('root'); - engine.start(); - clock.step(100); - clock.step(100); + await engine.goToScene('sceneA'); - engine.goToScene('sceneB', { foo: 'bar' }); - clock.step(100); - clock.step(100); + await engine.goToScene('sceneB', { foo: 'bar' }); expect(sceneA.onDeactivate).toHaveBeenCalledWith({ engine, @@ -366,8 +356,8 @@ describe('A scene', () => { engine = TestUtils.engine({ width: 100, height: 100 }); scene = new ex.Scene(); - engine.removeScene('root'); - engine.addScene('root', scene); + engine.removeScene('otherScene'); + engine.addScene('otherScene', scene); let initialized = false; scene.on('initialize', (evt: ex.InitializeEvent) => { @@ -378,7 +368,7 @@ describe('A scene', () => { done(); }); - engine.goToScene('root'); + engine.goToScene('otherScene'); engine.start(); const clock = engine.clock as ex.TestClock; clock.step(100); @@ -388,8 +378,8 @@ describe('A scene', () => { engine = TestUtils.engine({ width: 100, height: 100 }); scene = new ex.Scene(); - engine.removeScene('root'); - engine.addScene('root', scene); + engine.removeScene('otherScene'); + engine.addScene('otherScene', scene); let sceneInitialized = false; const sceneActivated = false; @@ -409,44 +399,39 @@ describe('A scene', () => { }); scene.add(actor); - engine.goToScene('root'); + engine.goToScene('otherScene'); engine.start(); const clock = engine.clock as ex.TestClock; clock.step(100); }); - it('can only be initialized once', () => { + it('can only be initialized once', async () => { engine = TestUtils.engine({ width: 100, height: 100 }); - const clock = engine.clock as ex.TestClock; + await TestUtils.runToReady(engine); scene = new ex.Scene(); - engine.removeScene('root'); - engine.addScene('root', scene); + engine.addScene('newScene', scene); - let initializeCount = 0; - scene.on('initialize', (evt) => { - initializeCount++; - }); + const initSpy = jasmine.createSpy('init'); + scene.on('initialize', initSpy); - engine.goToScene('root'); - engine.start(); - clock.step(1); + await engine.goToScene('newScene'); scene.update(engine, 100); scene.update(engine, 100); - scene._initialize(engine); - scene._initialize(engine); - scene._initialize(engine); + await scene._initialize(engine); + await scene._initialize(engine); + await scene._initialize(engine); - expect(initializeCount).toBe(1, 'Scenes can only be initialized once'); + expect(initSpy).toHaveBeenCalledTimes(1); }); - it('should initialize before actors in the scene', () => { + it('should initialize before actors in the scene', async () => { engine = TestUtils.engine({ width: 100, height: 100 }); + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; + clock.step(1); scene = new ex.Scene(); - - engine.removeScene('root'); - engine.addScene('root', scene); + engine.addScene('newScene', scene); const actor = new ex.Actor(); scene.add(actor); @@ -458,8 +443,8 @@ describe('A scene', () => { expect(sceneInit).toBe(true, 'Scene should be initialized first before any actors'); }; - engine.goToScene('root'); - engine.start(); + await engine.goToScene('newScene'); + clock.step(1); scene.update(engine, 100); }); @@ -752,11 +737,11 @@ describe('A scene', () => { expect(otherScene.isCurrentScene()).toBe(false); }); - it('will not be the current scene if the scene was switched', () => { + it('will not be the current scene if the scene was switched', async () => { const otherScene = new ex.Scene(); - engine.goToScene('root'); + await engine.goToScene('root'); engine.addScene('secondaryScene', otherScene); - engine.goToScene('secondaryScene'); + await engine.goToScene('secondaryScene'); expect(scene.isCurrentScene()).toBe(false); expect(otherScene.isCurrentScene()).toBe(true); @@ -768,8 +753,8 @@ describe('A scene', () => { beforeEach(() => { engine = TestUtils.engine({ width: 100, height: 100 }); scene = new ex.Scene(); - engine.removeScene('root'); - engine.addScene('root', scene); + engine.removeScene('newScene'); + engine.addScene('newScene', scene); }); afterEach(() => { @@ -778,7 +763,7 @@ describe('A scene', () => { scene = null; }); - it('can have onInitialize overridden safely', () => { + it('can have onInitialize overridden safely', async () => { const clock = engine.clock as ex.TestClock; let initCalled = false; scene.onInitialize = (engine) => { @@ -791,16 +776,16 @@ describe('A scene', () => { spyOn(scene, 'onInitialize').and.callThrough(); - TestUtils.runToReady(engine); - engine.goToScene('root'); + await TestUtils.runToReady(engine); + await engine.goToScene('newScene'); clock.step(100); expect(initCalled).toBe(true); expect(scene.onInitialize).toHaveBeenCalledTimes(1); }); - it('can have onPostUpdate overridden safely', () => { - scene._initialize(engine); + it('can have onPostUpdate overridden safely', async () => { + await scene._initialize(engine); scene.onPostUpdate = (engine, delta) => { expect(engine).not.toBe(null); expect(delta).toBe(100); @@ -816,8 +801,8 @@ describe('A scene', () => { expect(scene.onPostUpdate).toHaveBeenCalledTimes(2); }); - it('can have onPreUpdate overridden safely', () => { - scene._initialize(engine); + it('can have onPreUpdate overridden safely', async () => { + await scene._initialize(engine); scene.onPreUpdate = (engine, delta) => { expect(engine).not.toBe(null); expect(delta).toBe(100); diff --git a/src/spec/ScreenElementSpec.ts b/src/spec/ScreenElementSpec.ts index 61e1201f7..b82aaaed8 100644 --- a/src/spec/ScreenElementSpec.ts +++ b/src/spec/ScreenElementSpec.ts @@ -15,7 +15,7 @@ describe('A ScreenElement', () => { jasmine.addAsyncMatchers(ExcaliburAsyncMatchers); }); - beforeEach(() => { + beforeEach(async () => { screenElement = new ex.ScreenElement({ pos: new ex.Vector(50, 50), width: 100, @@ -28,7 +28,8 @@ describe('A ScreenElement', () => { scene = new ex.Scene(); engine.addScene('test', scene); engine.goToScene('test'); - engine.start(); + + await TestUtils.runToReady(engine); clock = engine.clock as ex.TestClock; diff --git a/src/spec/TextSpec.ts b/src/spec/TextSpec.ts index ec73f5ddd..a57e595aa 100644 --- a/src/spec/TextSpec.ts +++ b/src/spec/TextSpec.ts @@ -61,7 +61,7 @@ declare global { /** * */ -export async function waitForFontLoad(font: string, timeout = 2000, interval = 100) { +export function waitForFontLoad(font: string, timeout = 2000, interval = 100): Promise { return new Promise((resolve, reject) => { // repeatedly poll check const poller = setInterval(async () => { diff --git a/src/spec/TileMapSpec.ts b/src/spec/TileMapSpec.ts index 2d1862f89..cb0567a92 100644 --- a/src/spec/TileMapSpec.ts +++ b/src/spec/TileMapSpec.ts @@ -26,7 +26,7 @@ describe('A TileMap', () => { }); scene = new ex.Scene(); engine.addScene('root', scene); - engine.start(); + await TestUtils.runToReady(engine); const clock = engine.clock as ex.TestClock; texture = new ex.ImageSource('src/spec/images/TileMapSpec/Blocks.png'); await texture.load(); @@ -188,6 +188,7 @@ describe('A TileMap', () => { columns: 5 }); tm._initialize(engine); + tm.update(engine, 99); const cell = tm.getTile(0, 0); const rectangle = new ex.Rectangle({ diff --git a/src/spec/TimerSpec.ts b/src/spec/TimerSpec.ts index 67626a6be..b7458d754 100644 --- a/src/spec/TimerSpec.ts +++ b/src/spec/TimerSpec.ts @@ -6,7 +6,7 @@ describe('A Timer', () => { let scene: ex.Scene; let engine: ex.Engine; - beforeEach(() => { + beforeEach(async () => { engine = TestUtils.engine({ width: 600, height: 400 @@ -19,7 +19,8 @@ describe('A Timer', () => { }); scene = new ex.Scene(); engine.addScene('root', scene); - scene._initialize(engine); + + await TestUtils.runToReady(engine); }); it('has a unique id', () => { diff --git a/src/spec/TransistionSpec.ts b/src/spec/TransistionSpec.ts new file mode 100644 index 000000000..731893032 --- /dev/null +++ b/src/spec/TransistionSpec.ts @@ -0,0 +1,130 @@ +import * as ex from '@excalibur'; +import { TestUtils } from './util/TestUtils'; + +describe('A Transition', () => { + + it('exists', () => { + expect(ex.Transition).toBeDefined(); + }); + + it('can be constructed', () => { + const sut = new ex.Transition({ + duration: 1000, + direction: 'in', + easing: ex.EasingFunctions.EaseInOutCubic, + hideLoader: false, + blockInput: false + }); + + expect(sut).toBeTruthy(); + expect(sut.name).toContain('Transition#'); + expect(sut.duration).toBe(1000); + expect(sut.direction).toBe('in'); + expect(sut.blockInput).toBe(false); + expect(sut.hideLoader).toBe(false); + expect(sut.easing).toBe(ex.EasingFunctions.EaseInOutCubic); + + expect(sut.progress).toBe(1); + expect(sut.complete).toBe(false); + expect(sut.started).toBe(false); + }); + + it('can be constructed with defaults', () => { + const sut = new ex.Transition({duration: 2000}); + expect(sut).toBeTruthy(); + expect(sut.name).toContain('Transition#'); + expect(sut.duration).toBe(2000); + + expect(sut.direction).toBe('out'); + expect(sut.blockInput).toBe(false); + expect(sut.hideLoader).toBe(false); + expect(sut.easing).toBe(ex.EasingFunctions.Linear); + + expect(sut.progress).toBe(0); + expect(sut.complete).toBe(false); + expect(sut.started).toBe(false); + }); + + it('can be started with execute()', () => { + const engine = TestUtils.engine(); + const sut = new ex.Transition({duration: 3000}); + const onUpdateSpy = jasmine.createSpy('onUpdate'); + const onStartSpy = jasmine.createSpy('onStart'); + const onEndSpy = jasmine.createSpy('onEnd'); + sut.onUpdate = onUpdateSpy; + sut.onStart = onStartSpy; + sut.onEnd = onEndSpy; + sut._initialize(engine); + + + sut.execute(); + expect(sut.started).toBe(true); + expect(sut.progress).toBe(0); + expect(sut.onStart).toHaveBeenCalledWith(0); + expect(sut.onUpdate).toHaveBeenCalledWith(0); + + sut.onPreUpdate(engine, 16); + sut.execute(); + expect(onUpdateSpy.calls.argsFor(1)).toEqual([16/3000]); + + sut.onPreUpdate(engine, 16); + sut.execute(); + expect(onUpdateSpy.calls.argsFor(2)).toEqual([32/3000]); + + sut.onPreUpdate(engine, 3200 -32); + sut.execute(); + expect(onEndSpy).toHaveBeenCalledWith(1); + expect(sut.complete).toBe(true); + + sut.onPreUpdate(engine, 4000); + sut.execute(); + + // Start and end should only be called once + expect(onStartSpy).toHaveBeenCalledTimes(1); + expect(onEndSpy).toHaveBeenCalledTimes(1); + }); + + it('can be reset()', () => { + const engine = TestUtils.engine(); + const sut = new ex.Transition({duration: 3000}); + const onUpdateSpy = jasmine.createSpy('onUpdate'); + const onStartSpy = jasmine.createSpy('onStart'); + const onEndSpy = jasmine.createSpy('onEnd'); + sut.onUpdate = onUpdateSpy; + sut.onStart = onStartSpy; + sut.onEnd = onEndSpy; + sut._initialize(engine); + + + sut.execute(); + expect(sut.started).toBe(true); + expect(sut.progress).toBe(0); + expect(sut.onStart).toHaveBeenCalledWith(0); + expect(sut.onUpdate).toHaveBeenCalledWith(0); + + sut.onPreUpdate(engine, 16); + sut.execute(); + expect(onUpdateSpy.calls.argsFor(1)).toEqual([16/3000]); + + sut.onPreUpdate(engine, 16); + sut.execute(); + expect(onUpdateSpy.calls.argsFor(2)).toEqual([32/3000]); + + sut.onPreUpdate(engine, 3200 -32); + sut.execute(); + expect(onEndSpy).toHaveBeenCalledWith(1); + expect(sut.complete).toBe(true); + + sut.onPreUpdate(engine, 4000); + sut.execute(); + + expect(sut.complete).toBe(true); + + sut.onReset = jasmine.createSpy('onReset'); + sut.reset(); + + expect(sut.complete).toBe(false); + expect(sut.started).toBe(false); + expect(sut.onReset).toHaveBeenCalledTimes(1); + }); +}); \ No newline at end of file diff --git a/src/spec/images/CrossFadeSpec/crossfade.png b/src/spec/images/CrossFadeSpec/crossfade.png new file mode 100644 index 000000000..5f06e3722 Binary files /dev/null and b/src/spec/images/CrossFadeSpec/crossfade.png differ diff --git a/src/spec/images/DefaultLoaderSpec/loading.png b/src/spec/images/DefaultLoaderSpec/loading.png new file mode 100644 index 000000000..a014936e8 Binary files /dev/null and b/src/spec/images/DefaultLoaderSpec/loading.png differ diff --git a/src/spec/images/DirectorSpec/fadein.png b/src/spec/images/DirectorSpec/fadein.png new file mode 100644 index 000000000..5085f5f0b Binary files /dev/null and b/src/spec/images/DirectorSpec/fadein.png differ diff --git a/src/spec/images/FadeInOutSpec/fadein.png b/src/spec/images/FadeInOutSpec/fadein.png new file mode 100644 index 000000000..b16b0f1f0 Binary files /dev/null and b/src/spec/images/FadeInOutSpec/fadein.png differ diff --git a/src/spec/images/FadeInOutSpec/fadeout.png b/src/spec/images/FadeInOutSpec/fadeout.png new file mode 100644 index 000000000..db5f5e5d1 Binary files /dev/null and b/src/spec/images/FadeInOutSpec/fadeout.png differ diff --git a/src/spec/util/TestUtils.ts b/src/spec/util/TestUtils.ts index 0eb0fdc63..48e123401 100644 --- a/src/spec/util/TestUtils.ts +++ b/src/spec/util/TestUtils.ts @@ -39,9 +39,12 @@ export namespace TestUtils { } /** - * + * Waits for the internal loader state to be ready by ticking the test clock */ - export async function runToReady(engine: ex.Engine, loader?: ex.Loader) { + export async function runToReady(engine: ex.Engine, loader?: ex.DefaultLoader) { + if (!(engine.clock instanceof ex.TestClock)) { + throw Error('Engine does not have TestClock enabled'); + } const clock = engine.clock as ex.TestClock; const start = engine.start(loader); // If loader @@ -53,6 +56,7 @@ export namespace TestUtils { }); await engine.isReady(); } + await start; } /** diff --git a/wallaby.js b/wallaby.js index 2bc66a9c8..db34e2702 100644 --- a/wallaby.js +++ b/wallaby.js @@ -3,6 +3,7 @@ const webpack = require('webpack'); module.exports = function (wallaby) { return { + runMode: 'onsave', files: [ { pattern: 'src/spec/util/*.ts', load: false }, { pattern: 'src/engine/**/*.ts', load: false },