In this chapter, we'll explore procedural generation, a technique where game content is created algorithmically. This approach can simplify game development and enable seemingly endless possibilities.
Get everything ready
By reorganizing the game elements, we can group similar objects together (mountains, trees, etc). This will enable us to manage these objects as the game world becomes more complex.
To accomplish this, we will create a world object that groups these elements by category.
We also need to make changes to the add function to make sure it works with this new organization.
export default class Scene {
constructor () {
this.world = {
mountains: [],
props: [],
trees: [],
floorTiles: [],
obstacles: []
}
// ...
}
add (type, object) {
this.world[type].push(object)
}
// omitted for brevity
}
We need to apply this change to each of our elements in the toy.js file.
scene.add('mountains', {
x: 0,
y: 0.85,
width: 2,
height: 2,
image: images.mountain1
})
// ... and so on
Currently, when we add an element to the scene, we attach an image to it. However, because the scene is not responsible for display, it's best practice to separate the object from its image.
To do this, we can rename the image property to sprite and only pass the image's name when creating the object.
This way, the scene will only hold the object information, while the drawScene function can handle the display by using the appropriate image based on the sprite name.
By separating these responsibilities, we can create a cleaner and more organized development process.
scene.add('mountains', {
x: 0,
y: 0.85,
width: 2,
height: 2,
sprite: 'mountain1'
})
Now let's modify the drawScene function to take into account this new way of organizing the elements.
export function drawScene (ctx, scene, images) {
for (let category in scene.world) {
scene.world[category].forEach(element => {
const drawParams = Object.assign({}, element)
drawParams.image = images[drawParams.sprite]
drawParams.x -= scene.camera.x
drawParams.y -= scene.camera.y
drawImage(ctx, drawParams)
})
}
}
Don't forget to pass the images as parameters to the drawScene function in the toy.js file.
drawScene(ctx, scene, images)
You may think that this change hasn't made any difference. And you are right, but now with this new way of organizing the elements that make up our scene, we will be able to generate them procedurally.
Random Number Generator
Procedural generation often involves randomness. It's an opportunity to create new tools.
In JavaScript, one such tool is the Math.random function. This function returns a random number between 0 and 1. But, in many cases we may need to generate random numbers within a specific range.
So, let's create a function that will generate random numbers between 2 values. This function will be used to calculate the position of our elements.
Note that I added a condition to check if the value passed as a parameter is an array. If it's not, we simply return the value.
export function floatBetween (range) {
if (!Array.isArray(range)) {
return range
}
const [min, max] = range
return Math.random() * (max - min) + min
}
Now let's create another function that will allow us to pick a random element from an array. This function will be used to choose a random sprite from a list.
export function randomPick (choices) {
if (!Array.isArray(choices)) {
return choices
}
return choices[Math.floor(Math.random() * choices.length)]
}
Procedural generation
Finally, we come to the main point of this chapter. We want to display objects on the screen by generating them and spacing them randomly.
Every time the screen updates, we add new objects on the right side of the screen if there is enough space, and we get rid of objects that have moved past the left side of the screen.
To figure out where to place the next object, we have to know the position of the last object in that category.
lastElementFor (category) {
const collection = this.world[category]
return collection[collection.length - 1]
}
From here, we can get the position of the last element. If there is none, we take the position of the camera.
lastPositionFor (category) {
const {camera} = this
const lastElement = this.lastElementFor(category)
return lastElement ? lastElement.x : camera.x
}
Now we can make a function that generate random objects. This function needs some details, which could be either set values or ranges (minimum and maximum) for spacing, height, width, and y position.
For the sprite part, we give a set of images names, and the randomPick function will pick one randomly from the set.
Lastly, we use the count parameter to decide the most number of objects we want to see on the screen.
generate (category, {spacing, y, width, height, sprite, count}) {
const lastPosition = this.lastPositionFor(category)
let x = lastPosition
if (this.world[category].length > 0) {
x += floatBetween(spacing)
}
while (!this.isOffCamera(x)) {
this.world[category].push({
x,
y: floatBetween(y),
width: floatBetween(width),
height: floatBetween(height),
sprite: randomPick(sprite)
})
x += floatBetween(spacing)
}
this.cleanCategory(category, count)
}
The generate function will loop until a generated element is off the screen.
At the end of the loop, we call the cleanCategory function, which removes elements that are off the screen.
cleanCategory (category, count) {
while (this.world[category].length > count) {
this.world[category].shift()
}
}
Generating the scene
We're going to generate each element in the scene. To do this, we'll make a new function called generateWorld. This function will be called in the update loop.
generateWorld () {
this.generate('floorTiles', {
spacing: 1,
y: 3,
width: 1,
height: 0.5,
sprite: ['floor1', 'floor2', 'floor3', 'floor4', 'floor5', 'floor6'],
count: 8
})
this.generate('trees', {
spacing: [1, 2],
y: 1,
width: [1.8, 2.2],
height: [1.8, 2.2],
sprite: ['tree1', 'tree2', 'tree3', 'tree4', 'tree5'],
count: 12
})
// and so on...
}
update (deltaTime) {
this.camera.x += this.camera.speed * deltaTime
this.generateWorld()
}
You can delete the objects you made manually after starting the scene because the new objects we generate in the update loop will replace them.
Here is the result.
We gained new tools
- Generate a random number between two values
- Choose a random element from a list
- Generate random elements
The next chapter will be dedicated to the hero.