Build a HTML5 game like “Risky Road” using Phaser – step 5: drawing a better terrain

Read all posts about "" game

My Risky Road tutorial series has been quite successful, and I also buil a game out of it, it’s called RRRisky Hills and you can play it from my itch.io page.

I was asked to explain how I managed to paint the hills that way, so here is the answer:

1 – Create an array with all colors you want to use to paint the hills.

2 – Create another array specifying the height of each color slice.

3 – Loop through the array and draw the same hills, just shifted down by the amount of pixels specified at step 2.

This is the result:

Tap and hold to accelerate, don’t make the crate fall off the cart.

The source code is pretty similar to the one explained in previous step, anyway I highlighted the new lines:

var game;

var gameOptions = {

    // start vertical point of the terrain, 0 = very top; 1 = very bottom
    startTerrainHeight: 0.5,

    // max slope amplitude, in pixels
    amplitude: 100,

    // slope length range, in pixels
    slopeLength: [150, 350],

    // a mountain is a a group of slopes.
    mountainsAmount: 3,

    // amount of slopes for each mountain
    slopesPerMountain: 6,

    // car acceleration
    carAcceleration: 0.01,

    // maximum car velocity
    maxCarVelocity: 1,

    // rocks ratio, in %
    rocksRatio: 5,

    // mountain colors
    mountainColors: [0x3d6728, 0x244016, 0x2d2c2c, 0x3a3232, 0x2d2c2c],

    // line width for each mountain color, in pixels
    mountainColorsLineWidth: [0, 70, 100, 110, 500]
}
window.onload = function() {
    let gameConfig = {
        type: Phaser.AUTO,
        backgroundColor: 0x75d5e3,
        scale: {
            mode: Phaser.Scale.FIT,
            autoCenter: Phaser.Scale.CENTER_BOTH,
            parent: "thegame",
            width: 750,
            height: 1334
        },
        physics: {
            default: "matter",
            matter: {
                debug: true,
                debugBodyColor: 0x000000
            }
        },
        scene: playGame
    }
    game = new Phaser.Game(gameConfig);
    window.focus();
}
class playGame extends Phaser.Scene{
    constructor(){
        super("PlayGame");
    }
    create(){

        // creation of pool arrays
        this.bodyPool = [];
        this.rocksPool = [];

        // array to store mountains
        this.mountainGraphics = [];

        // mountain start coordinates
        this.mountainStart = new Phaser.Math.Vector2(0, 0);

        // loop through all mountains
        for(let i = 0; i < gameOptions.mountainsAmount; i++){

            // each mountain is a graphics object
            this.mountainGraphics[i] = this.add.graphics();

            // generateTerrain is the method to generate the terrain. The arguments are the graphics object and the start position
            this.mountainStart = this.generateTerrain(this.mountainGraphics[i], this.mountainStart);
        }

        // method to add the car, arguments represent x and y position
        this.addCar(250, game.config.height / 2 - 70);

        // the car is not accelerating
        this.isAccelerating = false;

        // input management
        this.input.on("pointerdown", this.accelerate, this);
        this.input.on("pointerup", this.decelerate, this);

        // collision check between the diamond and the car. Any other diamond collision is not allowed
        this.matter.world.on("collisionstart", function(event, bodyA, bodyB){
            if((bodyA.label == "diamond" &amp;&amp; bodyB.label != "car") || (bodyB.label == "diamond" &amp;&amp; bodyA.label != "car")){
                this.scene.start("PlayGame")
            }

        }.bind(this));

        // a text to show when we are flying
        this.flyingText = this.add.text(100, 100, "FLYING!!", {
            fontFamily: "Arial",
            fontSize: 128,
            color: "#FF8800"
        });
        this.flyingText.setVisible(false);

        // variable to count the time flying
        this.flyingTime = 0;

        // this event will check all active collisions
        this.matter.world.on("collisionactive", function(e){

            // no wheels colliding
            this.wheelsColliding = false;

            // a collision made by a pair of bodies
            e.pairs.forEach(function(p){

                // if a colliding body's label is "wheel"...
                if(p.bodyA.label == "wheel" || p.bodyB.label == "wheel"){

                    // at least a wheel is colliding
                    this.wheelsColliding = true;
                }
            }.bind(this))
        }.bind(this))
    }

    // method to generate the terrain. Arguments: the graphics object and the start position
    generateTerrain(graphics, mountainStart){

        // place graphics object
        graphics.x = mountainStart.x;

        // draw the ground
        graphics.clear();

        // array to store slope points
        let slopePoints = [];

        // variable to count the amount of slopes
        let slopes = 0;

        // slope start point
        let slopeStart = new Phaser.Math.Vector2(0, mountainStart.y);

        // set a random slope length
        let slopeLength = Phaser.Math.Between(gameOptions.slopeLength[0], gameOptions.slopeLength[1]);

        // determine slope end point, with an exception if this is the first slope of the fist mountain: we want it to be flat
        let slopeEnd = (mountainStart.x == 0) ? new Phaser.Math.Vector2(slopeStart.x + gameOptions.slopeLength[1] * 1.5, 0) : new Phaser.Math.Vector2(slopeStart.x + slopeLength, Math.random());

        // current horizontal point
        let pointX = 0;

        // while we have less slopes than regular slopes amount per mountain...
        while(slopes < gameOptions.slopesPerMountain){

            // slope interpolation value
            let interpolationVal = this.interpolate(slopeStart.y, slopeEnd.y, (pointX - slopeStart.x) / (slopeEnd.x - slopeStart.x));

            // if current point is at the end of the slope...
            if(pointX == slopeEnd.x){

                // increase slopes amount
                slopes ++;

                // next slope start position
                slopeStart = new Phaser.Math.Vector2(pointX, slopeEnd.y);

                // next slope end position
                slopeEnd = new Phaser.Math.Vector2(slopeEnd.x + Phaser.Math.Between(gameOptions.slopeLength[0], gameOptions.slopeLength[1]), Math.random());

                // no need to interpolate, we use slope start y value
                interpolationVal = slopeStart.y;
            }

            // current vertical point
            let pointY = game.config.height * gameOptions.startTerrainHeight + interpolationVal * gameOptions.amplitude;

            // add new point to slopePoints array
            slopePoints.push(new Phaser.Math.Vector2(pointX, pointY));

            // move on to next point
            pointX ++ ;
        }

        // simplify the slope
        let simpleSlope = simplify(slopePoints, 1, true);

        // loop through all simpleSlope points starting from the second
        for(let i = 1; i < simpleSlope.length; i++){

            // define a line between previous and current simpleSlope points
            let line = new Phaser.Geom.Line(simpleSlope[i - 1].x, simpleSlope[i - 1].y, simpleSlope[i].x, simpleSlope[i].y);

            // calculate line length, which is the distance between the two points
            let distance = Phaser.Geom.Line.Length(line);

            // calculate the center of the line
            let center = Phaser.Geom.Line.GetPoint(line, 0.5);

            // calculate line angle
            let angle = Phaser.Geom.Line.Angle(line);

            // if the pool is empty...
            if(this.bodyPool.length == 0){

                // create a new rectangle body
                let body = this.matter.add.rectangle(center.x + mountainStart.x, center.y, distance, 10, {
                    isStatic: true,
                    angle: angle,
                    friction: 1,
                    restitution: 0,
                    collisionFilter: {
                        category: 2
                    },
                    label: "ground"
                });

                // assign inPool property to check if the body is in the pool
                body.inPool = false;

            }

            // if the pool is not empty...
            else{

                // get the body from the pool
                let body = this.bodyPool.shift();

                // change inPool property
                body.inPool = false;

                // reset, reshape and move the body to its new position
                this.matter.body.setPosition(body, {
                    x: center.x + mountainStart.x,
                    y: center.y
                });
                let length = body.area / 10;
                this.matter.body.setAngle(body, 0)
                this.matter.body.scale(body, 1 / length, 1);
                this.matter.body.scale(body, distance, 1);
                this.matter.body.setAngle(body, angle);
            }

            // should we add a rock?
            if(Phaser.Math.Between(0, 100) < gameOptions.rocksRatio &amp;&amp; (mountainStart.x > 0 || i != 1)){

                // random rock position
                let size = Phaser.Math.Between(20, 30)
                let depth = Phaser.Math.Between(0, size / 2)
                let rockX = center.x + mountainStart.x + depth * Math.cos(angle + Math.PI / 2);
                let rockY = center.y + depth * Math.sin(angle + Math.PI / 2);

                // draw the rock
                graphics.fillStyle(0x6b6b6b, 1);
                graphics.fillCircle(rockX - mountainStart.x, rockY, size);

                // if the pool is empty...
                if(this.rocksPool.length == 0){

                    // create a new circle body
                    let rock = this.matter.add.circle(rockX, rockY, size, {
                        isStatic: true,
                        angle: angle,
                        friction: 1,
                        restitution: 0,
                        collisionFilter: {
                            category: 2
                        },
                        label: "rock"
                    });

                    // assign inPool property to check if the body is in the pool
                    rock.inPool = false;
                }
                else{

                    // get the rock from the pool
                    let rock = this.rocksPool.shift();

                    // resize the rock
                    this.matter.body.scale(rock, size / rock.circleRadius, size / rock.circleRadius);

                    // move the rock to its new position
                    this.matter.body.setPosition(rock, {
                        x: rockX,
                        y: rockY
                    });
                    rock.inPool = false;
                }
            }
        }

        // new way to draw the slopes
        for(let i = 0; i < gameOptions.mountainColors.length; i++){
            graphics.moveTo(0, game.config.height * 2);
            graphics.fillStyle(gameOptions.mountainColors[i]);
            graphics.beginPath();
            simpleSlope.forEach(function(point){
                graphics.lineTo(point.x, point.y + gameOptions.mountainColorsLineWidth[i]);
            }.bind(this))
            graphics.lineTo(simpleSlope[simpleSlope.length - 1].x, game.config.height * 2);
            graphics.lineTo(0, game.config.height * 2);
            graphics.closePath();
            graphics.fillPath();
        }

        // old way to draw the slopes
        /*graphics.moveTo(0, game.config.height * 2);
        graphics.fillStyle(0x654b35);
        graphics.beginPath();
        simpleSlope.forEach(function(point){
            graphics.lineTo(point.x, point.y);
        }.bind(this))
        graphics.lineTo(pointX, game.config.height * 2);
        graphics.lineTo(0, game.config.height * 2);
        graphics.closePath();
        graphics.fillPath();*/

        // draw the grass
        graphics.lineStyle(16, 0x6b9b1e);
        graphics.beginPath();
        simpleSlope.forEach(function(point){
            graphics.lineTo(point.x, point.y);
        })
        graphics.strokePath();

        // assign a custom "width" property to the graphics object
        graphics.width = pointX - 1

        // return the coordinates of last mountain point
        return new Phaser.Math.Vector2(graphics.x + pointX - 1, slopeStart.y);
    }

    // method to build the car
    addCar(posX, posY){

        // car is made by three rectangle bodies which will be merged into a compound object
        let floor = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX, posY, 100, 10, {
            label: "car"
        });
        let rightBarrier = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX + 45, posY - 15, 10, 20, {
            label: "car"
        });
        let leftBarrier = Phaser.Physics.Matter.Matter.Bodies.rectangle(posX - 45, posY - 15, 10, 20, {
            label: "car"
        });

        // this is how we create the compound object
        this.body = Phaser.Physics.Matter.Matter.Body.create({

            // array of single bodies
            parts: [floor, leftBarrier, rightBarrier],
            friction: 1,
            restitution: 0
        });

        // add the body to the world
        this.matter.world.add(this.body);

        // the diamond. It cannot fall off the car
        this.diamond = this.matter.add.rectangle(posX, posY - 40, 30, 30, {
            friction: 1,
            restitution: 0,
            label: "diamond"
        });

        // add front wheel. A circle
        this.frontWheel = this.matter.add.circle(posX + 35, posY + 25, 30, {
            friction: 1,
            restitution: 0,
            collisionFilter: {
                mask: 2
            },
            label: "wheel"
        });

        // add rear wheel
        this.rearWheel = this.matter.add.circle(posX - 35, posY + 25, 30, {
            friction: 1,
            restitution: 0,
            collisionFilter: {
                mask: 2
            },
            label: "wheel"
        });

        // these two constraints will bind front wheel to the body
        this.matter.add.constraint(this.body, this.frontWheel, 20, 0, {
            pointA: {
                x: 30,
                y: 0
            }
        });
        this.matter.add.constraint(this.body, this.frontWheel, 20, 0, {
            pointA: {
                x: 45,
                y: 0
            }
        });

        // same thing for rear wheel
        this.matter.add.constraint(this.body, this.rearWheel, 20, 0, {
            pointA: {
                x: -30,
                y: 0
            }
        });
        this.matter.add.constraint(this.body, this.rearWheel, 20, 0, {
            pointA: {
                x: -45,
                y: 0
            }
        });
    }

    // method to accelerate
    accelerate(){
        this.isAccelerating = true;
    }

    // method to decelerate
    decelerate(){
        this.isAccelerating = false;
    }

    update(t, dt){

        // if wheels aren't colliding...
        if(!this.wheelsColliding){

            // add frame delta time to flying time
            this.flyingTime += dt;

            // we can say the car is flying when it's in the air for more than 0.5 seconds
            if(this.flyingTime > 500){

                // show flying text
                this.flyingText.setVisible(true);
            }
        }

        // if wheels aren colliding...
        else{

            // reset flying time
            this.flyingTime = 0;

            // hide flying text
            this.flyingText.setVisible(false);
        }

        // zoom is calculated according to car speed.
        // zoom = 1: no zoom
        // zoom > 1: zoom in
        // zoom < 1: zoom out
        let zoom = 1 - Phaser.Math.Clamp(this.body.speed, 0, 15) / 25

        // zoomTo method allows the camera to zoom at "zoom" ratio in 1000 milliseconds
        // the most important argument is the 4th argument.
        // If set to "false", camera won't adjust its zoom if already zooming.
        this.cameras.main.zoomTo(zoom, 1000, "Linear", false);

        // make the game follow the car
        this.cameras.main.scrollX = this.body.position.x - game.config.width / 4 + game.config.width * (1 - this.cameras.main.zoom);
        this.cameras.main.scrollY = this.body.position.y - game.config.height / 2.2;

        // flyingText too should follow the car
        this.flyingText.x = 100 + this.cameras.main.scrollX;

        // adjust velocity according to acceleration
        if(this.isAccelerating){
            let velocity = this.frontWheel.angularSpeed + gameOptions.carAcceleration;
            velocity = Phaser.Math.Clamp(velocity, 0, gameOptions.maxCarVelocity);

            // set angular velocity to wheels
            this.matter.body.setAngularVelocity(this.frontWheel, velocity);
            this.matter.body.setAngularVelocity(this.rearWheel, velocity);
        }

        // loop through all mountains
        this.mountainGraphics.forEach(function(item){

            // if the mountain leaves the screen to the left...
            if(this.cameras.main.scrollX > item.x + item.width + game.config.width){

                // reuse the mountain
                this.mountainStart = this.generateTerrain(item, this.mountainStart)
            }
        }.bind(this));

        // get all bodies
        let bodies = this.matter.world.localWorld.bodies;

        // loop through all bodies
        bodies.forEach(function(body){

            // if the body is out of camera view to the left side and is not yet in the pool..
            if(this.cameras.main.scrollX > body.position.x + game.config.width &amp;&amp; !body.inPool){

                // ...add the body to proper pool
                switch(body.label){
                    case "ground":
                        this.bodyPool.push(body);
                        break;
                    case "rock":
                        this.rocksPool.push(body);
                        break;
                }
                body.inPool = true;
            }
        }.bind(this))
    }

    // method to apply a cosine interpolation between two points
    interpolate(vFrom, vTo, delta){
        let interpolation = (1 - Math.cos(delta * Math.PI)) * 0.5;
        return vFrom * (1 - interpolation) + vTo * interpolation;
    }
}

And here you have a procedural terrain with some style. Download the source code.

Get the most popular Phaser 3 book

Through 202 pages, 32 source code examples and an Android Studio project you will learn how to build cross platform HTML5 games and create a complete game along the way.

Get the book

214 GAME PROTOTYPES EXPLAINED WITH SOURCE CODE
// 1+2=3
// 100 rounds
// 10000000
// 2 Cars
// 2048
// A Blocky Christmas
// A Jumping Block
// A Life of Logic
// Angry Birds
// Angry Birds Space
// Artillery
// Astro-PANIC!
// Avoider
// Back to Square One
// Ball Game
// Ball vs Ball
// Ball: Revamped
// Balloon Invasion
// BallPusher
// Ballz
// Bar Balance
// Bejeweled
// Biggification
// Block it
// Blockage
// Bloons
// Boids
// Bombuzal
// Boom Dots
// Bouncing Ball
// Bouncing Ball 2
// Bouncy Light
// BoxHead
// Breakout
// Bricks
// Bubble Chaos
// Bubbles 2
// Card Game
// Castle Ramble
// Chronotron
// Circle Chain
// Circle Path
// Circle Race
// Circular endless runner
// Cirplosion
// CLOCKS - The Game
// Color Hit
// Color Jump
// ColorFill
// Columns
// Concentration
// Crossy Road
// Crush the Castle
// Cube Jump
// CubesOut
// Dash N Blast
// Dashy Panda
// Deflection
// Diamond Digger Saga
// Don't touch the spikes
// Dots
// Down The Mountain
// Drag and Match
// Draw Game
// Drop Wizard
// DROP'd
// Dudeski
// Dungeon Raid
// Educational Game
// Elasticity
// Endless Runner
// Erase Box
// Eskiv
// Farm Heroes Saga
// Filler
// Flappy Bird
// Fling
// Flipping Legend
// Floaty Light
// Fuse Ballz
// GearTaker
// Gem Sweeper
// Globe
// Goat Rider
// Gold Miner
// Grindstone
// GuessNext
// Helicopter
// Hero Emblems
// Hero Slide
// Hexagonal Tiles
// HookPod
// Hop Hop Hop Underwater
// Horizontal Endless Runner
// Hundreds
// Hungry Hero
// Hurry it's Christmas
// InkTd
// Iromeku
// Jet Set Willy
// Jigsaw Game
// Knife Hit
// Knightfall
// Legends of Runeterra
// Lep's World
// Line Rider
// Lumines
// Magick
// MagOrMin
// Mass Attack
// Math Game
// Maze
// Meeblings
// Memdot
// Metro Siberia Underground
// Mike Dangers
// Mikey Hooks
// Nano War
// Nodes
// o:anquan
// One Button Game
// One Tap RPG
// Ononmin
// Pacco
// Perfect Square!
// Perfectionism
// Phyballs
// Pixel Purge
// PixelField
// Planet Revenge
// Plants Vs Zombies
// Platform
// Platform game
// Plus+Plus
// Pocket Snap
// Poker
// Pool
// Pop the Lock
// Pop to Save
// Poux
// Pudi
// Pumpkin Story
// Puppet Bird
// Pyramids of Ra
// qomp
// Quick Switch
// Racing
// Radical
// Rebuild Chile
// Renju
// Rise Above
// Risky Road
// Roguelike
// Roly Poly
// Run Around
// Rush Hour
// SameGame
// SamePhysics
// Save the Totem
// Security
// Serious Scramblers
// Shrink it
// Sling
// Slingy
// Snowflakes
// Sokoban
// Space Checkers
// Space is Key
// Spellfall
// Spinny Gun
// Splitter
// Spring Ninja
// Sproing
// Stabilize!
// Stack
// Stick Hero
// String Avoider
// Stringy
// Sudoku
// Super Mario Bros
// Surfingers
// Survival Horror
// Talesworth Adventure
// Tetris
// The Impossible Line
// The Moops - Combos of Joy
// The Next Arrow
// Threes
// Tic Tac Toe
// Timberman
// Tiny Wings
// Tipsy Tower
// Toony
// Totem Destroyer
// Tower Defense
// Trick Shot
// Tunnelball
// Turn
// Turnellio
// TwinSpin
// vvvvvv
// Warp Shift
// Way of an Idea
// Whack a Creep
// Wheel of Fortune
// Where's my Water
// Wish Upon a Star
// Word Game
// Wordle
// Worms
// Yanga
// Yeah Bunny
// Zhed
// zNumbers