Talking about Risky Road game, Game development, HTML5, Javascript and Phaser.
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" && bodyB.label != "car") || (bodyB.label == "diamond" && 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 && (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 && !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.
Never miss an update! Subscribe, and I will bother you by email only when a new game or full source code comes out.