From ef5bb7a73660c5b08829ff2ebeee64b88e60264b Mon Sep 17 00:00:00 2001 From: Erik Onarheim Date: Mon, 15 Jan 2024 15:46:10 -0600 Subject: [PATCH] fix: CrossFade for firefox + docs update test --- site/docs/02-fundamentals/05-transitions.mdx | 237 ++++++++++++++++++ .../examples/scene-crossfade.ts | 46 ++++ .../examples/scene-transitions.ts | 48 ++++ src/engine/Director/CrossFade.ts | 5 +- src/spec/CrossFadeSpec.ts | 111 ++++---- src/spec/images/CrossFadeSpec/crossfade.png | Bin 6231 -> 6229 bytes 6 files changed, 404 insertions(+), 43 deletions(-) create mode 100644 site/docs/02-fundamentals/05-transitions.mdx create mode 100644 site/docs/02-fundamentals/examples/scene-crossfade.ts create mode 100644 site/docs/02-fundamentals/examples/scene-transitions.ts 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 -- 2.51.2