Making a Lunar Lander in JavaScript

Making the Spaceship

Now that you are familiar with the basics of drawing and animating shapes, we can finally start working on the lunar lander. We begin with creating the spaceship, as it is the main element of the game.

Model

Before delving into the graphics, it is necessary to have a mechanism for tracking the state of the spaceship at any given point in time. To do this, we declare an object spaceship with the following properties:

var spaceship =
{
    color: "black",
    width: 8,
    height: 22,
    position:
    {
        x: 0,
        y: 0
    },
    angle: 0,
    engineOn: false,
    rotatingLeft: false,
    rotatingRight: false
}

Drawing the spaceship

Main body

As we are just beginning game development, we will keep the main body of the spaceship simple to draw. In fact, it will just be a rectangle with the dimensions specified in the object. However, it is not as simple as writing a single draw command, because we must deal with the spaceship facing various angles at various positions.

Shape rotations

In a canvas, shapes are rotated by transforming the context before the shape is drawn. For example, a square can be rotated 45° as such:

context.rotate(Math.PI / 4);
context.rect(0, 0, 50, 50);
context.fill();

Notice that angle parameter for the rotate function must be specified in radians. Furthermore, if you were to test out the above code, you would see that the entire canvas rotates about the origin point in the top left corner. This means the square is starting to rotate out of view.

Rotations can be positioned better by:

  1. translating i.e. moving the context to the desired position
  2. rotating the context
  3. drawing the shape centered around the origin

Our rotated square can be drawn in the center of the canvas like this:

context.translate(canvas.width / 2, canvas.height / 2);    // translate context to center of canvas
context.rotate(Math.PI / 4); // rotate by 45°
context.rect(-25, -25, 50, 50);    // center square at origin by setting x and y to -1/2 of sides
context.fill();

If you are worried about what happens after transformations have been made to a context, you are thinking like a programmer! Unfortunately, what happens is that the context remains transformed and further drawing will conform to the transformation. Luckily, there is a way to restore the context to the original state. This is done by calling context.save() before the transformations and calling context.restore() once the drawing is complete.

So the safe way to draw a centered square is:

context.save()
context.translate(canvas.width / 2, canvas.height / 2);
context.rotate(Math.PI / 4);
context.rect(-25, -25, 50, 50);
context.fill();
context.restore();
Drawing the spaceship

We now know everything we need to draw the spaceship at the desired position with the desired rotation.

context.save();
context.translate(spaceship.position.x, spaceship.position.y);
context.rotate(spaceship.angle);
context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
context.fillStyle = spaceship.color
context.fill()
context.restore()

Wrap this code in a drawSpaceship() function and add it to our JavaScript file:

var canvas = document.getElementById("game");
var context = canvas.getContext("2d");
var spaceship =
{
    color: "black",
    width: 8,
    height: 22,
    position:
    {
        x: 0,
        y: 0
    },
    angle: 0,
    engineOn: false,
    rotatingLeft: false,
    rotatingRight: false
}

function drawSpaceship()
{
    context.save();
    context.beginPath();
    context.translate(spaceship.position.x, spaceship.position.y);
    context.rotate(spaceship.angle);
    context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
    context.fillStyle = spaceship.color;
    context.fill();
    context.closePath();
    context.restore();
}

drawSpaceship();

Drawing Exhaust Flame

Let's animate an exhaust flame at the bottom of the spaceship for the user whenever the engine is on. The flame will just be an inverted triangle, and to mimic a flame, we'll vary height of the triangle randomly.

Recall the method to drawing polygons from Lesson 1: Drawing Shapes. We'll draw the triangle at the bottom of the spaceship in the same way, inserting the code into the existing drawSpaceship() function:

function drawSpaceship()
{
    context.save();
    context.beginPath();
    context.translate(spaceship.position.x, spaceship.position.y);
    context.rotate(spaceship.angle);
    context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
    context.fillStyle = spaceship.color;
    context.fill();
    context.closePath();

    // Draw the flame if engine is on
    if(spaceship.engineOn)
    {
        context.beginPath();
        context.moveTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.lineTo(spaceship.width * 0.5, spaceship.height * 0.5);
        context.lineTo(0, spaceship.height * 0.5 + Math.random() * 5);
        context.lineTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.closePath();
        context.fillStyle = "orange";
        context.fill();
    }
    context.restore();
}

The flame is drawn by:

  1. starting a path at the bottom left corner of the spaceship
  2. adding a line to the bottom right corner
  3. adding a line to a point that is a distance Y from the center of the bottom of the spaceship.
    We want Y to vary randomly between 0 and 5 pixels, so we multiply the result of a call to JavaScript's Math.random() library function (returns a random floating point number between 0 and 1) by 5.
  4. close path by returning to first point
  5. fill with orange

You may have noticed that drawSpaceship() only gets called once, so the exhuast flame does not actually animate at this point. Recall the method to animating drawings in canvas from Lesson 2: Animating Shapes. We will now write the draw() function that gets called every animation frame. This function will contain a call to our existing drawSpaceship() function:

function draw()
{
    // Clear entire screen
    context.clearRect(0, 0, canvas.width, canvas.height);

    // Begin drawing
    drawSpaceship();
    /* other draw methods (to add later) */

    requestAnimationFrame(draw);
}

Our JavaScript file:

var canvas = document.getElementById("game");
var context = canvas.getContext("2d");
var spaceship =
{
    color: "black",
    width: 8,
    height: 22,
    position:
    {
        x: 0,
        y: 0
    },
    angle: 0,
    engineOn: false,
    rotatingLeft: false,
    rotatingRight: false
}

function drawSpaceship()
{
    context.save();
    context.beginPath();
    context.translate(spaceship.position.x, spaceship.position.y);
    context.rotate(spaceship.angle);
    context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
    context.fillStyle = spaceship.color;
    context.fill();
    context.closePath();

    // Draw the flame if engine is on
    if(spaceship.engineOn)
    {
        context.beginPath();
        context.moveTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.lineTo(spaceship.width * 0.5, spaceship.height * 0.5);
        context.lineTo(0, spaceship.height * 0.5 + Math.random() * 5);
        context.lineTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.closePath();
        context.fillStyle = "orange";
        context.fill();
    }
    context.restore();
}

function draw()
{
    // Clear entire screen
    context.clearRect(0, 0, canvas.width, canvas.height);

    // Begin drawing
    drawSpaceship();
    /* other draw methods (to add later) */

    requestAnimationFrame(draw);
}

draw();

User Interaction

Event Listeners

Currently, our spaceship is only a static drawing in the canvas, so it is time to add user interactivity. The spaceship will be controlled by the Left, Right and Up Arrow Keys.

In JavaScript, a keystroke is regarded as an event, specifically a 'keydown' event, so the way to detect keystrokes is to listen for this event. We do this by calling the document.addEventListener() function, which takes two parameters:

  1. event name - 'keydown' in our case
  2. a function we write that gets called when event fires.
function keyPressed(event)
{
    // implement this later
}

document.addEventListener('keydown', keyPressed);

We now have a function keyPressed() that gets called every time the user presses a key, but how do we distinguish between different keys being pressed? The answer lies in the event parameter. event has a property keyCode that encodes which key was pressed. You don't have to worry about character encoding, because all you need to know is that the Left, Right and Up Arrow Keys have codes 37, 39 and 38, respectively. Knowing the codes, we can use a switch statement to identify the key and update the properties in spaceship.

function keyPressed(event)
{
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = true;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = true;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = true;
            break;
    }
}

We now run into a problem: the properties of spaceship get updated properly when an arrow key is pressed, but how about when the key is let go?

The answer is they don't, because currently, the properties remain true indefinitely. The solution to this problem is listening for another event provided by JavaScript: 'keyup', which fires when the user has let go of a key:

function keyLetGo(event)
{
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = false;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = false;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = false;
            break;
    }
}

document.addEventListener('keyup', keyLetGo);

The above code should be fairly easy to follow, as it is nearly identical to the previous code, except for the properties being set to false.

Your JavaScript file should now look like:

var canvas = document.getElementById("game");
var context = canvas.getContext("2d");
var spaceship =
{
    color: "black",
    width: 8,
    height: 22,
    position:
    {
        x: 0,
        y: 0
    },
    angle: 0,
    engineOn: false,
    rotatingLeft: false,
    rotatingRight: false
}

function drawSpaceship()
{
    context.save();
    context.beginPath();
    context.translate(spaceship.position.x, spaceship.position.y);
    context.rotate(spaceship.angle);
    context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
    context.fillStyle = spaceship.color;
    context.fill();
    context.closePath();

    // Draw the flame if engine is on
    if(spaceship.engineOn)
    {
        context.beginPath();
        context.moveTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.lineTo(spaceship.width * 0.5, spaceship.height * 0.5);
        context.lineTo(0, spaceship.height * 0.5 + Math.random() * 5);
        context.lineTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.closePath();
        context.fillStyle = "orange";
        context.fill();
    }
    context.restore();
}

function draw()
{
    // Clear entire screen
    context.clearRect(0, 0, canvas.width, canvas.height);

    // Begin drawing
    drawSpaceship();
    /* other draw methods (to add later) */

    requestAnimationFrame(draw);
}

function keyLetGo(event)
{
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = false;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = false;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = false;
            break;
    }
}

document.addEventListener('keyup', keyLetGo);

function keyPressed(event)
{
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = true;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = true;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = true;
            break;
    }
}

document.addEventListener('keydown', keyPressed);

draw();

Animating Changes

We now have event listeners that update the state of spaceship, but the spaceship in our canvas does not update to reflect these state changes. This is because we have yet to implement the math that changes the angle and position properties of spaceship accordingly.

To do this, we revisit our code for drawing the spaceship. The only place where we can regularly perform calculations is in the draw() function, so we insert a call to a new function updateSpaceship() inside draw().

function updateSpaceship()
{
    // implement this later
}

function draw()
{
    // Clear entire screen
    context.clearRect(0, 0, canvas.width, canvas.height);

    updateSpaceship();

    // Begin drawing
    drawSpaceship();
    /* other draw methods (to add later) */

    requestAnimationFrame(draw);
}
Some Math

Inside updateSpaceship(), we need to update spaceship's angle and position properties.

The math for updating angle is quite straightforward, because it will either increase or decrease depending on the states of rotatingLeft and rotatingRight:

function updateSpaceship()
{
    if(spaceship.rotatingRight)
    {
        spaceship.angle += Math.PI / 180;
    }
    else if(spaceship.rotatingLeft)
    {
        spaceship.angle -= Math.PI / 180;
    }
}

The angle property on spaceship now increments or decrements by an arbitrary value of 1° = pi / 180 rad depending on the rotatingRight and rotatingLeft properties.

The tricky part of updateSpaceship() is updating the position property depending on the engineOn property. We cannot simply increment position's x and y properties, because that would not account for the spaceship's angle. Therefore, we must use trigonometry to separate the displacement of the spaceship into x and y components as such:

function updateSpaceship()
{
    if(spaceship.rotatingRight)
    {
        spaceship.angle += Math.PI / 180;
    }
    else if(spaceship.rotatingLeft)
    {
        spaceship.angle -= Math.PI / 180;
    }

    if(spaceship.engineOn)
    {
        spaceship.position.x += Math.sin(spaceship.angle);
        spaceship.position.y -= Math.cos(spaceship.angle);
    }
}

The x-component of the displacement is calculated with Math.sin() and incremented onto the x property of spaceship's position. The y-component of the displacement is calcualted with Math.cos() but decremented instead of incremented onto the spaceship.position.y because the y-axis of the canvas is flipped.

Conclusion

Congratulations, you have succesfully written a functional interactive spaceship in HTML5. Play around with your game! The code you wrote for the model, drawing, user interaction and animation of the spaceship will serve as the foundation for the next sections.

Your final JavaScript file should look like:

var canvas = document.getElementById("game");
var context = canvas.getContext("2d");

var spaceship =
{
    color: "black",
    width: 8,
    height: 22,
    position:
    {
        x: 20,
        y: 20
    },
    velocity:
    {
        x: 0,
        y: 0
    },
    angle: Math.PI / 2,
    engineOn: false,
    rotatingLeft: false,
    rotatingRight: false,
    crashed: false
}

function drawSpaceship()
{
    context.save();
    context.beginPath();
    context.translate(spaceship.position.x, spaceship.position.y);
    context.rotate(spaceship.angle);
    context.rect(spaceship.width * -0.5, spaceship.height * -0.5, spaceship.width, spaceship.height);
    context.fillStyle = spaceship.color;
    context.fill();
    context.closePath();

    // Draw the flame if engine is on
    if(spaceship.engineOn)
    {
        context.beginPath();
        context.moveTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.lineTo(spaceship.width * 0.5, spaceship.height * 0.5);
        context.lineTo(0, spaceship.height * 0.5 + Math.random() * 10);
        context.lineTo(spaceship.width * -0.5, spaceship.height * 0.5);
        context.closePath();
        context.fillStyle = "orange";
        context.fill();
    }
    context.restore();
}

function updateSpaceship()
{
    if(spaceship.rotatingRight)
    {
        spaceship.angle += Math.PI / 180;
    }
    else if(spaceship.rotatingLeft)
    {
        spaceship.angle -= Math.PI / 180;
    }

    if(spaceship.engineOn)
    {
        spaceship.position.x += Math.sin(spaceship.angle);
        spaceship.position.y -= Math.cos(spaceship.angle);
    }
}


function draw()
{
    // Clear entire screen
    context.clearRect(0, 0, canvas.width, canvas.height);

    updateSpaceship();

    // Begin drawing
    drawSpaceship();
    /* other draw methods (to add later) */

    requestAnimationFrame(draw);
}

function keyLetGo(event)
{
    console.log(spaceship);
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = false;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = false;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = false;
            break;
    }
}

document.addEventListener('keyup', keyLetGo);

function keyPressed(event)
{
    console.log(spaceship);
    switch(event.keyCode)
    {
        case 37:
            // Left Arrow key
            spaceship.rotatingLeft = true;
            break;
        case 39:
            // Right Arrow key
            spaceship.rotatingRight = true;
            break;
        case 38:
            // Up Arrow key
            spaceship.engineOn = true;
            break;
    }
}

document.addEventListener('keydown', keyPressed);

draw();