diff --git a/site/docs/02-fundamentals/05-transitions.mdx b/site/docs/02-fundamentals/05-transitions.mdx
new file mode 100644
index 00000000..13bccd38
--- /dev/null
+++ b/site/docs/02-fundamentals/05-transitions.mdx
@@ -0,0 +1,237 @@
+---
+title: Scene Transitions 🧪
+slug: /transitions
+section: Fundamentals
+---
+
+import TransitionExample from '!!raw-loader!./examples/scene-transitions.ts';
+import CrossFadeTransitionExample from '!!raw-loader!./examples/scene-crossfade.ts';
+
+```twoslash include ex
+///
+class MyScene extends ex.Scene {}
+class MyOtherScene extends ex.Scene {}
+```
+
+:::warning
+
+This is currently an alpha feature, and will be released in the next version! API might change/fluctuate until it lands in a supported version.
+
+:::
+
+🧪 `npm install excalibur@0.29.0-alpha.835` or greater
+
+Many times in your game you'll want to smoothly go from one scene to the next, or provide a custom effect when transitioning!
+
+## Using Pre-Definited Scene Transitions
+
+It is generally recommended that you define you scenes up front, when you do you have the opportunity to also specify the in/out transitions for a scene.
+
+* `in` transitions play when the target scene has started, and will play the effect until the `duration` in milliseconds is complete
+* `out` transitions play before the current scene is deactivated, the transition must complete before deactivation.
+
+If no `in/out` transition is specified for a scene it will hard cut from one to the other.
+
+```ts twoslash
+// @include: ex
+// ---cut---
+const game = new ex.Engine({
+ scenes: {
+ scene1: {
+ scene: MyScene,
+ transitions: {
+ in: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.Black}),
+ out: new ex.FadeInOut({duration: 500, direction: 'out', color: ex.Color.Black})
+ }
+ },
+ scene2: {
+ scene: MyOtherScene,
+ transitions: {
+ in: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.Black}),
+ out: new ex.FadeInOut({duration: 500, direction: 'out', color: ex.Color.Black})
+ }
+ }
+ }
+});
+
+
+game.goto('scene1');
+
+```
+
+or using the add scene api
+
+```ts twoslash
+// @include: ex
+// ---cut---
+const game = new ex.Engine();
+
+game.add('scene1', {
+ scene: MyScene,
+ transitions: {
+ in: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.Black}),
+ out: new ex.FadeInOut({duration: 500, direction: 'out', color: ex.Color.Black})
+ }
+});
+
+```
+
+Click canvas to transition!
+
+
+
+## Transition Options
+Transitions have a few tricks up their sleeves, you can control duration, direction, easing function, whether to hide any loaders, or block user input during the transition
+
+```ts twoslash
+// @include: ex
+// ---cut---
+const transition = new ex.Transition({
+ /**
+ * Transition duration in milliseconds
+ */
+ duration: 1000,
+
+ /**
+ * 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: false,
+
+ /**
+ * Optionally blocks user input during a transition
+ *
+ * Default false
+ */
+ blockInput: false,
+
+ /**
+ * Optionally specify a easing function, by default linear
+ */
+ easing: ex.EasingFunctions.Linear,
+ /**
+ * 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',
+})
+```
+
+## Overriding Transitions
+
+There are 2 ways to override pre-defined transitions
+
+* `goto('myscene', { destinationIn: ..., sourceOut: ... })` takes the highest precedence and will override any transition
+* Extending [[Scene.onTransition]] you can provide dynamic transitions depending on your scene's state
+
+```typescript
+class MyCustomScene extends ex.Scene {
+ onTransition(direction: "in" | "out") {
+ return new ex.FadeInOut({
+ direction,
+ color: ex.Color.Violet,
+ duration: 2000
+ });
+ }
+}
+```
+
+## FadeInOut
+
+This transition does exactly as it sounds, you can specific a duration in milliseconds and it will fade in the specified `direction`. [[FadeInOut]] uses the color [[Color.Black]] by default.
+
+* The `direction: 'in'` direction means the transition will start fully opaque (non-transparent), then transition to fully transparent.
+* The `direction: 'out'` direction means the transition will start fully transparent, then transition to fully opaque (non-transparent).
+
+## CrossFade
+
+:::warning
+
+[[CrossFade]] can only be used on the `in`` transition for a scene, this is because it needs to [[Engine.screenshot|screenshot]] the previous scene in order to cross fade it.
+
+:::
+
+
+You can specific a duration in milliseconds and it will fade in the specified `direction`. [[CrossFade]] takes a screen shot of the previous scene and blends that into the current scene.
+
+* The `direction: 'in'` direction means the transition will start fully opaque (non-transparent), then transition to fully transparent.
+* The `direction: 'out'` direction means the transition will start fully transparent, then transition to fully opaque (non-transparent).
+
+Click canvas to transition!
+
+
+
+## Starting Scene Transition
+
+Sometimes you want a special start transition for the beginning of your game after loading. You may want to match the color, do something special, etc.
+
+This can be done on the [[Engine.start]] by providing a start transition
+
+```typescript
+game.start('scene1',
+{
+ inTransition: startTransition
+});
+
+```
+
+## Custom built Transitions
+
+Transitions are really an [[Entity]] with a [[TransformComponent]] and [[GraphicsComponent]] that take up the entire screen and draw on top of everything by default `z = Infinity`.
+
+To build your own custom transition, extend [[Transition]] and implement the stubbed methods
+
+For example this is CrossFade's implementation
+
+```typescript
+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/site/docs/02-fundamentals/examples/scene-crossfade.ts b/site/docs/02-fundamentals/examples/scene-crossfade.ts
new file mode 100644
index 00000000..564054af
--- /dev/null
+++ b/site/docs/02-fundamentals/examples/scene-crossfade.ts
@@ -0,0 +1,46 @@
+
+
+class MyScene extends ex.Scene {
+ public onInitialize(): void {
+ this.add(
+ new ex.Actor({
+ pos: ex.vec(200, 200),
+ color: ex.Color.Red,
+ width: 100,
+ height: 200
+ }))
+ }
+}
+
+
+class MyOtherScene extends ex.Scene {
+ public onInitialize(): void {
+ this.add(
+ new ex.Actor({
+ pos: ex.vec(200, 200),
+ color: ex.Color.Blue,
+ width: 200,
+ height: 100
+ }))
+ }
+}
+
+game.add('scene1', {
+ scene: MyScene,
+ transitions: {
+ in: new ex.CrossFade({duration: 1500, blockInput: true }),
+ }
+});
+
+game.add('scene2', {
+ scene: MyOtherScene,
+ transitions: {
+ in: new ex.CrossFade({duration: 1500, blockInput: true }),
+ }
+});
+
+game.input.pointers.primary.on('down', () => {
+ game.currentSceneName === 'scene2' ? game.goto('scene1') : game.goto('scene2');
+});
+
+game.start('scene2');
\ No newline at end of file
diff --git a/site/docs/02-fundamentals/examples/scene-transitions.ts b/site/docs/02-fundamentals/examples/scene-transitions.ts
new file mode 100644
index 00000000..276df019
--- /dev/null
+++ b/site/docs/02-fundamentals/examples/scene-transitions.ts
@@ -0,0 +1,48 @@
+
+
+class MyScene extends ex.Scene {
+ public onInitialize(): void {
+ this.add(
+ new ex.Actor({
+ pos: ex.vec(200, 200),
+ color: ex.Color.Red,
+ width: 100,
+ height: 200
+ }))
+ }
+}
+
+
+class MyOtherScene extends ex.Scene {
+ public onInitialize(): void {
+ this.add(
+ new ex.Actor({
+ pos: ex.vec(200, 200),
+ color: ex.Color.Blue,
+ width: 200,
+ height: 100
+ }))
+ }
+}
+
+game.add('scene1', {
+ scene: MyScene,
+ transitions: {
+ in: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.Black}),
+ out: new ex.FadeInOut({duration: 500, direction: 'out', color: ex.Color.Black})
+ }
+});
+
+game.add('scene2', {
+ scene: MyOtherScene,
+ transitions: {
+ in: new ex.FadeInOut({duration: 500, direction: 'in', color: ex.Color.Black}),
+ out: new ex.FadeInOut({duration: 500, direction: 'out', color: ex.Color.Black})
+ }
+});
+
+game.input.pointers.primary.on('down', () => {
+ game.currentSceneName === 'scene2' ? game.goto('scene1') : game.goto('scene2');
+});
+
+game.start('scene2');
\ No newline at end of file
diff --git a/src/engine/Director/CrossFade.ts b/src/engine/Director/CrossFade.ts
index b9bc9240..ee4527b6 100644
--- a/src/engine/Director/CrossFade.ts
+++ b/src/engine/Director/CrossFade.ts
@@ -18,12 +18,15 @@ export class CrossFade extends Transition {
image: HTMLImageElement;
screenCover: Sprite;
constructor(options: TransitionOptions & CrossFadeOptions) {
- super(options);
+ super({direction: 'in', ...options}); // default the correct direction
this.name = `CrossFade#${this.id}`;
}
override async onPreviousSceneDeactivate(scene: Scene) {
this.image = await scene.engine.screenshot(true);
+ // Firefox is particularly slow
+ // needed in case the image isn't ready yet
+ await this.image.decode();
}
override onInitialize(engine: Engine): void {
diff --git a/src/spec/CrossFadeSpec.ts b/src/spec/CrossFadeSpec.ts
index b73d44df..08cb5a64 100644
--- a/src/spec/CrossFadeSpec.ts
+++ b/src/spec/CrossFadeSpec.ts
@@ -11,53 +11,80 @@ describe('A CrossFade transition', () => {
});
it('can be constructed', () => {
- const sut = new ex.CrossFade({duration: 1000});
+ 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});
+ /**
+ *
+ */
+ async function nextTask() {
+ const future = new ex.Future();
+ setTimeout(() => {
+ future.resolve();
+ });
+
+ return await future.promise;
+ }
+ /**
+ *
+ */
+ async function nextMicroTask() {
+ const future = new ex.Future();
+ queueMicrotask(() => {
+ future.resolve();
+ });
+ return await future.promise;
+ }
+
+ it('can cross fade', async () => {
+ 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();
- });
- });
+ await TestUtils.runToReady(engine);
+ 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 });
+ clock.step(1);
+ await nextMicroTask();
+ clock.step(1);
+ await nextMicroTask();
+ clock.step(1);
+ await nextTask();
+ clock.step(1);
+ await nextTask();
+ clock.step(1);
+ await nextTask();
+ clock.step(100);
+ clock.step(100);
+ clock.step(100);
+ clock.step(100);
+ clock.step(100);
+ clock.step(100);
+
+ expect(engine.currentSceneName).toBe('newScene');
+ expect(onDeactivateSpy).toHaveBeenCalledTimes(1);
+ await expectAsync(TestUtils.flushWebGLCanvasTo2D(engine.canvas)).toEqualImage('/src/spec/images/CrossFadeSpec/crossfade.png');
});
});
\ No newline at end of file
diff --git a/src/spec/images/CrossFadeSpec/crossfade.png b/src/spec/images/CrossFadeSpec/crossfade.png
index 5f06e37228303b411d6ceaa24136247c27a10096..4da3943765e1c4eb4bd6e7ec92b902f2f886d0b5 100644
GIT binary patch
literal 6229
zcmeAS@N?(olHy`uVBq!ia0y~yVEh8Y9Bd2>45zQ%?_ywJU@Q)DcVbv~PUa;8g9N{)
zi(^Pd+}o=Md!L1fv|N0r!O^7aqbAI{&_Si8>4wmGg^j6h)0S>Jr8hrr`@6sQE6@M3
ztN;J$>+Saa|2hABd>yXE!XcpG@W7C1e$&6dyM?|S-;-!Rg@KWY#m>%-D^-mhX#g5MvzQn`Wp_AI*_pTsPJf*4ATVh;pgT2
z`ntmo4WL}WVH53eVSa6Woh>613x~iDe}@aUEFhMGfr7#d=IJlMS;E2L!d|fasOV@o
zfJ)oZ^e~z)2DE%=)NG9Z_v`1i=Rbd+w%`7*woZnT>Aa}Gm*@B9#{ZLLbo{QvAyBcp
zVZr{d&)m=JzE~{&I)a5mhKu8u;>7xjNev6^UvB#o#>6s-scGI7lht|(FTQGjVCE3e
z;N+;{ymu>`h2^hQcrd5Hj{OY_?*H%mb4Zw}sgCLNejx<|RfQM9*Iybmz2g;7_z=eE
zSpWKL|Gel6?)uj@atK(62z+r?`VVSq)L+W|xt4`Pg_Xs2WoB5c!-d!357-410)zy<
z2<_Q=orB|->AGb?3I+9yj{E=1{%loeVfo84tzOk3!OP*o@^zOJS?)?JI6PR()b#J#
zY{-GA#(-2NOKzc_XNdo>)G(y+ikcKKnBUFHrA2clV7{;oB*
zugY_j-y3mU$>BkO!v+4%KZhr?u>AGh{&c&L!UPe4FH4q1mNGi7k8hNBXmId!xZwTa
zTAZ@Ni|adPc{LpP%gXZiU-=X9c>)4ogd_e~HukwT99Y<}Kwju)yE-SwugQ6*^Hm)bG!WQ!rDLUFlptON)`|ySTuY-}fiRS2#Ib@K5*|&cw2eg{AhDm8=`M?z^X@@FIWj&x^&}zg`@*
z5@Td)XKJeRcp^VfMd5}2F5Uf885)-~EKoN+s|2oCA*OqS>sUx4>jqc1Q&?E`vgGT6
zD^p0s>40lnXtDscKVg9ZDraFqEd*|lj#?nJXX|JXv2cv0qS1^tnh!?v!DxjuS|5O#
q5~KCOXninRAB@%qL%%*au>YaMA6+9k$p8ih1_n=8KbLh*2~7a<@G86j
literal 6231
zcmeAS@N?(olHy`uVBq!ia0y~yVEh8Y9Bd2>45zQ%?_ywJU@Q)DcVbv~PUa;8gM^@`
zi(^Pd+}o=Md!L1fv|OAg5V7TkFpuH{mQDfgn*zsmI1=5GPWR||r%sKv+naay_x{iG
zzSjPH{&{=8{J($dAAZZ%2P!%=FfcOp3;s}gVZTq?vHpzoH)l=(1&0TZA3I;D|HQ*`
z_q!uVg};zMiDUhx>aWpYS#_o+y%%Al#;G%7qACc`vA
z#Mk}({61fTLqNfyfuS*-o8#B!_vhEEgVX+hPL5seU=|A-3rp<-A=yR-MkW>xj$P)X
zqNCw3njV-&^TnVj2@F&}{QNpSdj9j*&-ZWtS6e5;$aG#*;LG!SbL0OpGdh0P;Si`;
z-LPQ)*JtkMbzdwNe;vWXA;ZP-OL1a-#iWJ>_Aj^n31ecJ#MCtJipgp{g%@A7KQMC$
zXmD~=ao)R?&BF3mDm<7|V8{N31^55={W&De)KtfGdcTl@fvUob;Oj39n%?mWD0~QG
zbgX}Uwtrsq1$X^x8#x3lLzSHptq>ksfNI4n?6
zc%fR6y6eSOigwFtv_-5b8!6P)cNn#a9~Qq0{__MhrxyVfoN8iziZ9y
ztMVM>_eLC7a(EEnaDl(`&*8}|EPp+>Kiw{*FhNA%%aUc0rHqd2;~V828XPq
z7N@N6;`)wRUJVESva+a042(>wjE?#TqpN!x
z7Q{c!(qd%#E-vup_x*|S6;2Kp{1bkLGqEgVVX1v(CF^!!FC!DnJuQV7`Fnp}Eav|8
z;;5AvBU3w5Q=P{X`FSb|FZ_4u?w`ugxTIl$y6IUZa6Jhz-5b>GgeJ0Xa6LMOg=H^G
zzAmUrg+`nXsHF)@7A&Aj85$TMCM>9hz*Y6A1wwnajs_76$7m`V&1j?fU^E|$Ryd>e
t0jMc4S|5zo2cz}DXnipB>w^R3k!*i-RsMZXXJBAp@O1TaS?83{1OQUMt_c7D