DEV Community

Chris Wright
Chris Wright

Posted on • Edited on

Creating Spelunky-style level transitions in Phaser

My last article turned out to be a bit of a niche topic, so I decided to try my hand at something a little more mainstream. Though we'll still be discussing Phaser (gotta monopolize that niche!), you don't need to read the previous article to follow along with this one.

Today we're going to take a look at how we can implement Spelunky-inspired level transitions in Phaser. You can see the finished product in the live demo and you can find the source code over on Github. We'll start by reviewing the effect and learning a bit about scene events and transitions, then jumping in to the implementation.


The concept

Before we get into the weeds, let's review the effect that we're looking to achieve. If you haven't played Spelunky before (you really should), I've included a video for reference:


Each level starts with a completely blank, black screen, which immediately reveals the entire screen using a pinhole transition. The transition doesn't start from the centre of the screen; instead, the transition is positioned on the player's character to centre your attention there. Exit transitions do the same in reverse — fillingthe screen with darkness around the player.

Let's dig into how we can replicate this effect.

Update Nov 26, 2020 — Here is a preview of the final result:

Transition demo

Scene events

There are many events built into Phaser triggered during the lifecycle of a Scene that give you a lot of control. For example, if you're a plugin author you might use the boot event to hook into the boot sequence of a Scene; or, you may want to do some cleanup when your scene is destroyed or put to sleep. For our purposes, we'll be using the create event to know when our level is ready to be played.

You can listen to events from within your scene like this:

this.events.on('create', fn);
Enter fullscreen mode Exit fullscreen mode

I prefer to use the provided namespaced constants:

this.events.on(Phaser.Scenes.Events.CREATE_EVENT, fn);
Enter fullscreen mode Exit fullscreen mode

Refer to the documentation for more information on Scene events.

Scene transitions

For this effect, we're going to use Scene transitions, which allow us to smoothly move from one scene to another. We can control exactly how this transition behaves by specifying a configuration object. If you've ever worked with tweens then you'll feel right at home as there are similarities between them.

Transitions can be started by invoking the Scene plugin:

this.scene.transition({
    // Configuration options
});
Enter fullscreen mode Exit fullscreen mode

Similar to Scene events, there are corresponding events for the transition lifecycle. These events can be subscribed to directly on the scene. We'll be using the out event to know when a transition is taking place.

this.events.on(Phaser.Scenes.Events.TRANSITION_OUT, fn);
Enter fullscreen mode Exit fullscreen mode

The arguments for transition event handlers subtly change from event to event. Refer to the documentation for details.

Putting it all together

The first step is to create an empty base class. It is not strictly necessary to create a separate class but doing so will help isolate the code and make reusing it across levels easier. For now, just extend this bare scene; we'll flesh it out as we go along.

class SceneTransition extends Phaser.Scene {
    // TODO
}

class LevelScene extends SceneTransition {}
Enter fullscreen mode Exit fullscreen mode

All your base (class)

Now that we have our classes in place, we can begin filling them out. Start by using the Graphics object to create a circle and centre it in the scene. The circle should be as large as possible while still being contained within the scene, otherwise the graphic will be cropped later on. This also helps minimize artifacts from appearing along the edges during scaling.

const maskShape = new Phaser.Geom.Circle(
    this.sys.game.config.width / 2,
    this.sys.game.config.height / 2,
    this.sys.game.config.height / 2
);
const maskGfx = this.add.graphics()
    .setDefaultStyles({
        fillStyle: {
            color: 0xffffff,
        }
    })
    .fillCircleShape(maskShape)
;
Enter fullscreen mode Exit fullscreen mode

You should end up with the following:

Alt Text

Next we're going to convert the mask graphic to a texture and add that to the scene as an image. We don't want the mask graphic itself to be visible in the final result, so make sure to remove the fill.

// ...

const maskGfx = this.add.graphics()
    .fillCircleShape(maskShape)
    .generateTexture('mask')
;
this.mask = this.add.image(0, 0, 'mask')
    .setPosition(
        this.sys.game.config.width / 2,
        this.sys.game.config.height / 2,
    )
;
Enter fullscreen mode Exit fullscreen mode

You should now be back to a blank scene. Finally, we apply the mask to the camera.

this.cameras.main.setMask(
    new Phaser.Display.Masks.BitmapMask(this, this.mask)
);
Enter fullscreen mode Exit fullscreen mode

Creating the level

We're not going to spend much time setting up the level itself. The only requirement is that you extend the base class we created and include a key. Get creative!

import SceneTransition from './SceneTransition';

export default class LevelOne extends SceneTransition {

    constructor () {
        super({
            key: 'ONE',
        });
    }

    preload () {
        this.load.image('background_one', 'https://labs.phaser.io/assets/demoscene/birdy-nam-nam-bg1.png');
    }

    create() {
        super.create();

        this.add.image(0, 0, 'background_one')
            .setOrigin(0, 0)
            .setDisplaySize(
                this.sys.game.config.width,
                this.sys.game.config.height
            )
        ;
    }

}
Enter fullscreen mode Exit fullscreen mode

You should now see something similar to this:

Alt Text

Setting up the events

Returning to the base class, we need to record two values. The first will be the minimum scale that the mask will be; the second is the maximum.

const MASK_MIN_SCALE = 0;
const MASK_MAX_SCALE = 2;
Enter fullscreen mode Exit fullscreen mode

The minimum value is fairly straightforward: to create a seamless transition, we need the mask to shrink completely. The maximum is a little more tricky and will depend on the aspect ratio of your game and what shape you use for your mask. Play around with this value until you're confident it does the job. In my case, my mask needs to be twice its initial scale to completely clear the outside of the scene.

Next we can (finally) leverage those events from earlier. When a transition is started, we want to animate the mask from its maximum scale to its minimum. It would also be a nice touch to have the action paused to prevent enemies from attacking the player, so let's add that in.

this.events.on(Phaser.Scenes.Events.TRANSITION_OUT, () => {
    this.scene.pause();

    const propertyConfig = {
        ease: 'Expo.easeInOut',
        from: MASK_MAX_SCALE,
        start: MASK_MAX_SCALE,
        to: MASK_MIN_SCALE,
    };

    this.tweens.add({
        duration: 2500,
        scaleX: propertyConfig,
        scaleY: propertyConfig,
        targets: this.mask,
    });
});
Enter fullscreen mode Exit fullscreen mode

Once the next scene is ready we want to run the animation in reverse to complete the loop. There are a few changes between this animation and the last that are worth discussing, primarily around timing. The first change is the duration of the animation; it has been roughly halved in order to get the player back into the action faster. You may have also noticed the addition of the delay property. In my testing, I found that the animation can look a bit off if it reverses too quickly. So a small pause has been added to create a sense of anticipation.

this.events.on(Phaser.Scenes.Events.CREATE, () => {
    const propertyConfig = {
        ease: 'Expo.easeInOut',
        from: MASK_MIN_SCALE,
        start: MASK_MIN_SCALE,
        to: MASK_MAX_SCALE,
    };

    this.tweens.add({
        delay: 2750,
        duration: 1500,
        scaleX: propertyConfig,
        scaleY: propertyConfig,
        targets: this.mask,
    });
});
Enter fullscreen mode Exit fullscreen mode

Triggering a transition

So far we have very little to show for all of this setup that we've done. Let's add a trigger to start a transition. Here we're using a pointer event in our level but this could be triggered by anything in your game (e.g., collision with a tile, the result of a timer counting down, etc.).

this.input.on('pointerdown', () => {
    this.scene.transition({
        duration: 2500,
        target: 'ONE',
    });
});
Enter fullscreen mode Exit fullscreen mode

If you tried to trigger the transition, you may have noticed that nothing happens. This is because you cannot transition to a scene from itself. For the sake of this example, you can duplicate your level (be sure to give it a unique key) and then transition to that.

And that's it! You should now have your very own Spelunky-inspired level transition.

Conclusion

Level transitions are a great way to add a level of immersion and polish to your game that doesn't take a whole lot of effort. Since the effect is entirely created by applying a mask to the Camera, it could easily be modified to use, say, Mario's head to replicate the effect found in New Super Mario Bros. Or if you're feeling more adventurous (and less copyright-infringy) you could create a wholly unique sequence with subtle animation flourishes. The only limit really is your imagination.


Thank you for taking the time to join me on this adventure! I've been having a lot of fun working on these articles and I hope they come in handy for someone. If you end up using this technique in one of your games or just want to let me know what you think, leave a comment here or hit me up on Twitter.

Top comments (4)

Collapse
 
jdnichollsc profile image
J.D Nicholls

Beautiful, thanks for sharing!

Collapse
 
jorbascrumps profile image
Chris Wright

Thanks, Juan! Glad you enjoyed it.

Collapse
 
floodgames profile image
FloodGames

Do you have a gif of the final result?

Collapse
 
jorbascrumps profile image
Chris Wright

I've added a gif to the beginning of the post :) Thanks for reading!