HTML5 endless runner built with Phaser and Arcade physics step 4: adding a scrolling parallax background
Here we go with the 4th part of the HTML5 endless runner prototype powered by Phaser and Arcade physics.
Time for a small recap: in first step, I was able to set up a basic endless runner with double jump and room for customization using object pooling to save resources.
In second step I added even more room for customization and – most important – a player animation.
In third step I added texture to platforms – no more white boxes – and coins to collect. Coins have their own animation and once collected they disappear in a way similar to the one you used to see in Super Mario series.
Now it’s time to add a scrolling parallax background, which is made by a series of images moving at a slower speed than the platform, giving the illusion they are moving in the distance.
To create this effect, I created four mountains with my script to programmatically draw mountains, which I randomly placed as background.
Unlike platforms and coins, this time I didn’t recycle the mountains using object pooling, I just placed mountains to cover an area way wider – two times, actually – than game width, then simply moving a mountain to the far right once it leaves the screen to the left.
Have a look at our hero running and jumping over platforms:
Click or tap to jump or double jump.
Enjoy the commented source code:
let game;
// global game options
let gameOptions = {
// platform speed range, in pixels per second
platformSpeedRange: [300, 300],
// mountain speed, in pixels per second
mountainSpeed: 80,
// spawn range, how far should be the rightmost platform from the right edge
// before next platform spawns, in pixels
spawnRange: [80, 300],
// platform width range, in pixels
platformSizeRange: [90, 300],
// a height range between rightmost platform and next platform to be spawned
platformHeightRange: [-5, 5],
// a scale to be multiplied by platformHeightRange
platformHeighScale: 20,
// platform max and min height, as screen height ratio
platformVerticalLimit: [0.4, 0.8],
// player gravity
playerGravity: 900,
// player jump force
jumpForce: 400,
// player starting X position
playerStartPosition: 200,
// consecutive jumps allowed
jumps: 2,
// % of probability a coin appears on the platform
coinPercent: 25
}
window.onload = function() {
// object containing configuration options
let gameConfig = {
type: Phaser.AUTO,
width: 1334,
height: 750,
scene: [preloadGame, playGame],
backgroundColor: 0x0c88c7,
// physics settings
physics: {
default: "arcade"
}
}
game = new Phaser.Game(gameConfig);
window.focus();
resize();
window.addEventListener("resize", resize, false);
}
// preloadGame scene
class preloadGame extends Phaser.Scene{
constructor(){
super("PreloadGame");
}
preload(){
this.load.image("platform", "platform.png");
// player is a sprite sheet made by 24x48 pixels
this.load.spritesheet("player", "player.png", {
frameWidth: 24,
frameHeight: 48
});
// the coin is a sprite sheet made by 20x20 pixels
this.load.spritesheet("coin", "coin.png", {
frameWidth: 20,
frameHeight: 20
});
// mountains are a sprite sheet made by 512x512 pixels
this.load.spritesheet("mountain", "mountain.png", {
frameWidth: 512,
frameHeight: 512
});
}
create(){
// setting player animation
this.anims.create({
key: "run",
frames: this.anims.generateFrameNumbers("player", {
start: 0,
end: 1
}),
frameRate: 8,
repeat: -1
});
// setting coin animation
this.anims.create({
key: "rotate",
frames: this.anims.generateFrameNumbers("coin", {
start: 0,
end: 5
}),
frameRate: 15,
yoyo: true,
repeat: -1
});
this.scene.start("PlayGame");
}
}
// playGame scene
class playGame extends Phaser.Scene{
constructor(){
super("PlayGame");
}
create(){
// group with all active mountains.
this.mountainGroup = this.add.group();
// group with all active platforms.
this.platformGroup = this.add.group({
// once a platform is removed, it's added to the pool
removeCallback: function(platform){
platform.scene.platformPool.add(platform)
}
});
// platform pool
this.platformPool = this.add.group({
// once a platform is removed from the pool, it's added to the active platforms group
removeCallback: function(platform){
platform.scene.platformGroup.add(platform)
}
});
// group with all active coins.
this.coinGroup = this.add.group({
// once a coin is removed, it's added to the pool
removeCallback: function(coin){
coin.scene.coinPool.add(coin)
}
});
// coin pool
this.coinPool = this.add.group({
// once a coin is removed from the pool, it's added to the active coins group
removeCallback: function(coin){
coin.scene.coinGroup.add(coin)
}
});
// adding a mountain
this.addMountains()
// keeping track of added platforms
this.addedPlatforms = 0;
// number of consecutive jumps made by the player so far
this.playerJumps = 0;
// adding a platform to the game, the arguments are platform width, x position and y position
this.addPlatform(game.config.width, game.config.width / 2, game.config.height * gameOptions.platformVerticalLimit[1]);
// adding the player;
this.player = this.physics.add.sprite(gameOptions.playerStartPosition, game.config.height * 0.7, "player");
this.player.setGravityY(gameOptions.playerGravity);
this.player.setDepth(2);
// setting collisions between the player and the platform group
this.physics.add.collider(this.player, this.platformGroup, function(){
// play "run" animation if the player is on a platform
if(!this.player.anims.isPlaying){
this.player.anims.play("run");
}
}, null, this);
// setting collisions between the player and the coin group
this.physics.add.overlap(this.player, this.coinGroup, function(player, coin){
this.tweens.add({
targets: coin,
y: coin.y - 100,
alpha: 0,
duration: 800,
ease: "Cubic.easeOut",
callbackScope: this,
onComplete: function(){
this.coinGroup.killAndHide(coin);
this.coinGroup.remove(coin);
}
});
}, null, this);
// checking for input
this.input.on("pointerdown", this.jump, this);
}
// adding mountains
addMountains(){
let rightmostMountain = this.getRightmostMountain();
if(rightmostMountain < game.config.width * 2){
let mountain = this.physics.add.sprite(rightmostMountain + Phaser.Math.Between(100, 350), game.config.height + Phaser.Math.Between(0, 100), "mountain");
mountain.setOrigin(0.5, 1);
mountain.body.setVelocityX(gameOptions.mountainSpeed * -1)
this.mountainGroup.add(mountain);
if(Phaser.Math.Between(0, 1)){
mountain.setDepth(1);
}
mountain.setFrame(Phaser.Math.Between(0, 3))
this.addMountains()
}
}
// getting rightmost mountain x position
getRightmostMountain(){
let rightmostMountain = -200;
this.mountainGroup.getChildren().forEach(function(mountain){
rightmostMountain = Math.max(rightmostMountain, mountain.x);
})
return rightmostMountain;
}
// the core of the script: platform are added from the pool or created on the fly
addPlatform(platformWidth, posX, posY){
this.addedPlatforms ++;
let platform;
if(this.platformPool.getLength()){
platform = this.platformPool.getFirst();
platform.x = posX;
platform.y = posY;
platform.active = true;
platform.visible = true;
this.platformPool.remove(platform);
let newRatio = platformWidth / platform.displayWidth;
platform.displayWidth = platformWidth;
platform.tileScaleX = 1 / platform.scaleX;
}
else{
platform = this.add.tileSprite(posX, posY, platformWidth, 32, "platform");
this.physics.add.existing(platform);
platform.body.setImmovable(true);
platform.body.setVelocityX(Phaser.Math.Between(gameOptions.platformSpeedRange[0], gameOptions.platformSpeedRange[1]) * -1);
platform.setDepth(2);
this.platformGroup.add(platform);
}
this.nextPlatformDistance = Phaser.Math.Between(gameOptions.spawnRange[0], gameOptions.spawnRange[1]);
// is there a coin over the platform?
if(this.addedPlatforms > 1){
if(Phaser.Math.Between(1, 100) <= gameOptions.coinPercent){
if(this.coinPool.getLength()){
let coin = this.coinPool.getFirst();
coin.x = posX;
coin.y = posY - 96;
coin.alpha = 1;
coin.active = true;
coin.visible = true;
this.coinPool.remove(coin);
}
else{
let coin = this.physics.add.sprite(posX, posY - 96, "coin");
coin.setImmovable(true);
coin.setVelocityX(platform.body.velocity.x);
coin.anims.play("rotate");
coin.setDepth(2);
this.coinGroup.add(coin);
}
}
}
}
// the player jumps when on the ground, or once in the air as long as there are jumps left and the first jump was on the ground
jump(){
if(this.player.body.touching.down || (this.playerJumps > 0 && this.playerJumps < gameOptions.jumps)){
if(this.player.body.touching.down){
this.playerJumps = 0;
}
this.player.setVelocityY(gameOptions.jumpForce * -1);
this.playerJumps ++;
// stops animation
this.player.anims.stop();
}
}
update(){
// game over
if(this.player.y > game.config.height){
this.scene.start("PlayGame");
}
this.player.x = gameOptions.playerStartPosition;
// recycling platforms
let minDistance = game.config.width;
let rightmostPlatformHeight = 0;
this.platformGroup.getChildren().forEach(function(platform){
let platformDistance = game.config.width - platform.x - platform.displayWidth / 2;
if(platformDistance < minDistance){
minDistance = platformDistance;
rightmostPlatformHeight = platform.y;
}
if(platform.x < - platform.displayWidth / 2){
this.platformGroup.killAndHide(platform);
this.platformGroup.remove(platform);
}
}, this);
// recycling coins
this.coinGroup.getChildren().forEach(function(coin){
if(coin.x < - coin.displayWidth / 2){
this.coinGroup.killAndHide(coin);
this.coinGroup.remove(coin);
}
}, this);
// recycling mountains
this.mountainGroup.getChildren().forEach(function(mountain){
if(mountain.x < - mountain.displayWidth){
let rightmostMountain = this.getRightmostMountain();
mountain.x = rightmostMountain + Phaser.Math.Between(100, 350);
mountain.y = game.config.height + Phaser.Math.Between(0, 100);
mountain.setFrame(Phaser.Math.Between(0, 3))
if(Phaser.Math.Between(0, 1)){
mountain.setDepth(1);
}
}
}, this);
// adding new platforms
if(minDistance > this.nextPlatformDistance){
let nextPlatformWidth = Phaser.Math.Between(gameOptions.platformSizeRange[0], gameOptions.platformSizeRange[1]);
let platformRandomHeight = gameOptions.platformHeighScale * Phaser.Math.Between(gameOptions.platformHeightRange[0], gameOptions.platformHeightRange[1]);
let nextPlatformGap = rightmostPlatformHeight + platformRandomHeight;
let minPlatformHeight = game.config.height * gameOptions.platformVerticalLimit[0];
let maxPlatformHeight = game.config.height * gameOptions.platformVerticalLimit[1];
let nextPlatformHeight = Phaser.Math.Clamp(nextPlatformGap, minPlatformHeight, maxPlatformHeight);
this.addPlatform(nextPlatformWidth, game.config.width + nextPlatformWidth / 2, nextPlatformHeight);
}
}
};
function resize(){
let canvas = document.querySelector("canvas");
let windowWidth = window.innerWidth;
let windowHeight = window.innerHeight;
let windowRatio = windowWidth / windowHeight;
let gameRatio = game.config.width / game.config.height;
if(windowRatio < gameRatio){
canvas.style.width = windowWidth + "px";
canvas.style.height = (windowWidth / gameRatio) + "px";
}
else{
canvas.style.width = (windowHeight * gameRatio) + "px";
canvas.style.height = windowHeight + "px";
}
}
Next time I’ll add some deadly object, meanwhile download the source code.