Case Study... Tank Game

A simple game consists of:

Play game



Download .zip file

tank_game.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland.             */
/* There should always be a javaScript file with the same name as the html file. */
/* This file always holds the playGame function().                               */
/* It also holds game specific code, which will be different for each game       */




/******************** Declare game specific global data and functions *****************/
/* images must be declared as global, so that they will load before the game starts  */
let fieldImage = new Image();
fieldImage.src = "images/field.png";
let riverImage = new Image();
riverImage.src = "images/tankFieldRiver.png";
let tankImage = new Image();
tankImage.src = "images/tank.png";
let explosionImage = new Image();
explosionImage.src = "images/explosion.png";

let tankMovingSound = new Audio();
tankMovingSound.src = 'audio/tankMoving.mp3';
let fireShellSound = new Audio();
fireShellSound.src = 'audio/tankFire.mp3';
let shellExplosionSound = new Audio();
shellExplosionSound.src = 'audio/shellExplosionSound.mp3';

const BACKGROUND = 0;
const TANK = 1;
const FIRE_SHELL = 2;  // animation showing initial firing of shell
const SHELL = 3; // a shell that is fired from the tank
const EXPLOSION = 4; // the explosion that results from the firing of the shell 
const WIN_MESSAGE = 5;

let enemyTanks = [];
const numberOfEnemyTanks = 3;
/******************* END OF Declare game specific data and functions *****************/







/* Always have a playGame() function                                     */
/* However, the content of this function will be different for each game */
function playGame()
{
    /* We need to initialise the game objects outside of the Game class */
    /* This function does this initialisation.                          */
    /* This function will:                                              */
    /* 1. create the various game game gameObjects                   */
    /* 2. store the game gameObjects in an array                     */
    /* 3. create a new Game to display the game gameObjects          */
    /* 4. start the Game                                                */


    /* Create the various gameObjects for this game. */
    /* This is game specific code. It will be different for each game, as each game will have it own gameObjects */
    gameObjects[BACKGROUND] = new StaticImage(fieldImage, 0, 0, canvas.width, canvas.height);
    gameObjects[TANK] = new PlayerTank(tankImage, riverImage, tankMovingSound, 100, 100, 90);
    gameObjects[TANK].startMoving();
   
    // create the enemy tanks
    for (let i = 0; i < numberOfEnemyTanks; i++)
    {        
        do
        {
            // make sure that the enemy tanks do not randomly spawn in the river
            enemyTanks[i] = new EnemyTank(tankImage, riverImage, tankMovingSound, Math.random() * (canvas.width - 25) + 25, Math.random() * (canvas.height - 25) + 25, Math.random() * 360);
        }while (enemyTanks[i].collidedWithRiver())
        enemyTanks[i].start();
        enemyTanks[i].startMoving();
    }

    /* END OF game specific code. */

    /* Always create a game that uses the gameObject array */
    let game = new TankCanvasGame();

    /* Always play the game */
    game.start();


    /* If they are needed, then include any game-specific mouse and keyboard listners */
    document.addEventListener("keydown", function (e)
    {
        const ANGLE_STEP_SIZE = 10;

        if (e.keyCode === 37)  // left
        {
            gameObjects[TANK].setDirection(gameObjects[TANK].getDirection() - ANGLE_STEP_SIZE);
        }
        else if (e.keyCode === 39) // right
        {
            gameObjects[TANK].setDirection(gameObjects[TANK].getDirection() + ANGLE_STEP_SIZE);
        }
        else if (e.keyCode === 32) // space
        {
            gameObjects[FIRE_SHELL] = new FireShellAnimation(tankImage, fireShellSound, gameObjects[TANK].getX(), gameObjects[TANK].getY(), gameObjects[TANK].getDirection());
            gameObjects[FIRE_SHELL].start();

            gameObjects[SHELL] = new Shell(explosionImage, shellExplosionSound, gameObjects[TANK].getX(), gameObjects[TANK].getY(), gameObjects[TANK].direction);
            gameObjects[SHELL].start();
        }
    });
}

The game conssts of various gameObjects. In particular, when a shell is fired, three different things happen. Firstly, a small explosion occurs at the top of the tank's gun barrel. Secondly, the shell moves along its path. Thirdly, as it moves, it performs collision with the enemy tanks. If it collides with an enamy tank, then a large explosion occurs at the point of collision. If the shell does not collide with any enemy tanks within its range, then a large explosion occurs when the shell reaches its range.
The small explosion is contained in FIRE_SHELL, the shell movement is contained in SHELL and the large explosion is contained in EXPLOSION.

const BACKGROUND = 0;
const TANK = 1;
const FIRE_SHELL = 2;  // animation showing initial firing of shell
const SHELL = 3; // a shell that is fired from the tank
const EXPLOSION = 4; // the explosion that results from the firing of the shell 
const WIN_MESSAGE = 5;

The enemyTanks are held in their own array, called enemyTanks[]. In this game, there are 3 enemy tanks.
As the enemy tanks are not being held in gameObjects[], the game code will be responsible for their rendering.

let enemyTanks = [];
const numberOfEnemyTanks = 3;

When they are created, the enemy tanks are placed in random positions on the canvas. When creating the enemy tanks, we use a do...while loop to ensure that the tanks do get spawned inside the river.

    // create the enemy tanks
    for (let i = 0; i < numberOfEnemyTanks; i++)
    {        
        do
        {
            // make sure that the enemy tanks do not randomly spawn in the river
            enemyTanks[i] = new EnemyTank(tankImage, riverImage, tankMovingSound, Math.random() * (canvas.width - 25) + 25, Math.random() * (canvas.height - 25) + 25, Math.random() * 360);
        }while (enemyTanks[i].collidedWithRiver())
        enemyTanks[i].start();
        enemyTanks[i].startMoving();
    }

The left and right arrow keys are used to turn the player tank
The space bar is used to fire a shell. When a shell is fired, a small explosion occurs at the top of the tank's gun barrel, as shown in red below.
The shell is fired in the direction that the tank is facing, as shown in blue below.

 document.addEventListener("keydown", function (e)
    {
        const ANGLE_STEP_SIZE = 10;

        if (e.keyCode === 37)  // left
        {
            gameObjects[TANK].setDirection(gameObjects[TANK].getDirection() - ANGLE_STEP_SIZE);
        }
        else if (e.keyCode === 39) // right
        {
            gameObjects[TANK].setDirection(gameObjects[TANK].getDirection() + ANGLE_STEP_SIZE);
        }
        else if (e.keyCode === 32) // space
        {
            gameObjects[FIRE_SHELL] = new FireShellAnimation(tankImage, fireShellSound, gameObjects[TANK].getX(), gameObjects[TANK].getY(), gameObjects[TANK].getDirection());
            gameObjects[FIRE_SHELL].start();

            gameObjects[SHELL] = new Shell(explosionImage, shellExplosionSound, gameObjects[TANK].getX(), gameObjects[TANK].getY(), gameObjects[TANK].direction);
            gameObjects[SHELL].start();
        }
    });

TankCanvasGame.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland.       */
/* A CanvasGame that implements collision detection.                       */


class TankCanvasGame extends CanvasGame
{
    constructor()
    {
        super();

        this.numberOfEnemytanksDestroyed = 0;
    }

    collisionDetection()
    {
        /* Collision detection for the player tank bumping into an enemy tank */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if ((enemyTanks[i].pointIsInsideTank(gameObjects[TANK].getFrontLeftCornerX(), gameObjects[TANK].getFrontLeftCornerY())) ||
                    (enemyTanks[i].pointIsInsideTank(gameObjects[TANK].getFrontRightCornerX(), gameObjects[TANK].getFrontRightCornerY())))
            {
                enemyTanks[i].reverse(5);
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }
        }


        /* Collision detection for the enemy tanks bumping into each other */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            /* check if enemy tank bumps into player tank */
            if ((gameObjects[TANK].pointIsInsideTank(enemyTanks[i].getFrontLeftCornerX(), enemyTanks[i].getFrontLeftCornerY())) ||
                    (gameObjects[TANK].pointIsInsideTank(enemyTanks[i].getFrontRightCornerX(), enemyTanks[i].getFrontRightCornerY())))
            {
                enemyTanks[i].reverse(5);
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }

            /* check if enemy tank bumps into another enemy tank */
            for (let j = 0; j < enemyTanks.length; j++)
            {
                if (i !== j)
                {
                    if ((enemyTanks[i].pointIsInsideTank(enemyTanks[j].getFrontLeftCornerX(), enemyTanks[j].getFrontLeftCornerY())) ||
                            (enemyTanks[i].pointIsInsideTank(enemyTanks[j].getFrontRightCornerX(), enemyTanks[j].getFrontRightCornerY())))
                    {
                        enemyTanks[i].reverse(3);
                        enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
                    }
                }
            }
        }


        /* Collision detection of the player tank with the river */
        if (gameObjects[TANK].collidedWithRiver())
        {
            gameObjects[TANK].reverse();
        }

        /* Collision detection for enemy tanks with the river */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if (enemyTanks[i].collidedWithRiver())
            {
                enemyTanks[i].reverse();
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }
        }

        /* Collision detection for a shell that is firing */
        if (gameObjects[SHELL] === undefined)
        {
            return;
        }
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if (gameObjects[SHELL].isFiring())
            {
                if ((enemyTanks[i].isDisplayed()) && (enemyTanks[i].pointIsInsideTank(gameObjects[SHELL].getX(), gameObjects[SHELL].getY())))
                {
                    enemyTanks[i].stopAndHide();
                    gameObjects[EXPLOSION] = new Explosion(explosionImage, shellExplosionSound, enemyTanks[i].getX(), enemyTanks[i].getY(), 200);
                    gameObjects[EXPLOSION].start();
                    gameObjects[SHELL].stopAndHide();

                    this.numberOfEnemytanksDestroyed++;
                    if (this.numberOfEnemytanksDestroyed === numberOfEnemyTanks)
                    {
                        /* Player has won                                                                                             */
                        /* Have a two second delay to show the last enemy tank blowing up beofore displaying the 'Game Over!' message */
                        setInterval(function ()
                        {
                            for (let j = 0; j < gameObjects.length; j++) /* stop all gameObjects from animating */
                            {
                                gameObjects[j].stopAndHide();
                            }
                            gameObjects[TANK].stopMoving(); // turn off tank moving sound
                            gameObjects[BACKGROUND].start();
                            gameObjects[WIN_MESSAGE] = new StaticText("Game Over!", 5, 270, "Times Roman", 100, "red");
                            gameObjects[WIN_MESSAGE].start(); /* render win message */
                        }, 2000);
                    }
                }
            }
        }
    }

    render()
    {
        super.render();
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if (enemyTanks[i].isDisplayed())
            {
                enemyTanks[i].render();
            }
        }
    }
}

Various collision detection is done in this game.
If the player's tank bumps into an enemy tank, then the enemy tank will reverse by 5 units and turn 10 degrees clockwise. The enemy tank will then continue forward on its new path.
Note, that collision detection is done by checking if either the front-left or front-right corners of the player tank are inside the bounding rectangle of the enemy tank.

        /* Collision detection for the player tank bumping into an enemy tank */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if ((enemyTanks[i].pointIsInsideTank(gameObjects[TANK].getFrontLeftCornerX(), gameObjects[TANK].getFrontLeftCornerY())) ||
                    (enemyTanks[i].pointIsInsideTank(gameObjects[TANK].getFrontRightCornerX(), gameObjects[TANK].getFrontRightCornerY())))
            {
                enemyTanks[i].reverse(5);
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }
        }

Each enemy tank needs to be tested for collision against the player tank all other enemy tanks. This is done in the same way as the player tank was tested for collision above, were the front-left and front-right corners of the enemy tank are tested againsts the bounding rectangles of the player tank and the other enemy tanks.

        /* Collision detection for the enemy tanks bumping into each other */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            /* check if enemy tank bumps into player tank */
            if ((gameObjects[TANK].pointIsInsideTank(enemyTanks[i].getFrontLeftCornerX(), enemyTanks[i].getFrontLeftCornerY())) ||
                    (gameObjects[TANK].pointIsInsideTank(enemyTanks[i].getFrontRightCornerX(), enemyTanks[i].getFrontRightCornerY())))
            {
                enemyTanks[i].reverse(5);
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }

            /* check if enemy tank bumps into another enemy tank */
            for (let j = 0; j < enemyTanks.length; j++)
            {
                if (i !== j)
                {
                    if ((enemyTanks[i].pointIsInsideTank(enemyTanks[j].getFrontLeftCornerX(), enemyTanks[j].getFrontLeftCornerY())) ||
                            (enemyTanks[i].pointIsInsideTank(enemyTanks[j].getFrontRightCornerX(), enemyTanks[j].getFrontRightCornerY())))
                    {
                        enemyTanks[i].reverse(3);
                        enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
                    }
                }
            }
        }

If the player's tank collides with the river, then it is reversed back out of the river.

/* Collision detection of the player tank with the river */
        if (gameObjects[TANK].collidedWithRiver())
        {
            gameObjects[TANK].reverse();
        }

If an enemy tank collides with the river, it is reversed back out of the river and it changes direction by 10 degrees in a clockwise direction. In this way the tank will eventually move away from the river.

        /* Collision detection for enemy tanks with the river */
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if (enemyTanks[i].collidedWithRiver())
            {
                enemyTanks[i].reverse();
                enemyTanks[i].setDirection(enemyTanks[i].getDirection() + 10); // turn away from the river, so that the tank does not get stuck in one place
            }
        }

Collision detection of a shell can only happen if there is a shell. The test 'gameObjects[SHELL] === undefined' is needed to stop a code crash when there is no shell firing. The test 'gameObjects[SHELL].isFiring()' is used to detect that a shell is firing.
A shell can only strike a tank that is being displayed. The test '(enemyTanks[i].isDisplayed())' is used to detect this.
A shell will strike a tank if it is insdie the tank's bounding rectangle. The test '(enemyTanks[i].pointIsInsideTank(gameObjects[SHELL].getX(), gameObjects[SHELL].getY()))' is used to detect this.
If a shell hits an enemy tank, then:

When the game ends, all of the gameObjects except the BACKGROUND are hidden. The two-second interval allows the gameObject effect of the last enemy tank blowing up to be displayed prior to the game ending.

        /* Collision detection for a shell that is firing */
        if (gameObjects[SHELL] === undefined)
        {
            return;
        }
        for (let i = 0; i < enemyTanks.length; i++)
        {
            if (gameObjects[SHELL].isFiring())
            {
                if ((enemyTanks[i].isDisplayed()) && (enemyTanks[i].pointIsInsideTank(gameObjects[SHELL].getX(), gameObjects[SHELL].getY())))
                {
                    enemyTanks[i].stopAndHide();
                    gameObjects[EXPLOSION] = new Explosion(explosionImage, shellExplosionSound, enemyTanks[i].getX(), enemyTanks[i].getY(), 200);
                    gameObjects[EXPLOSION].start();
                    gameObjects[SHELL].stopAndHide();

                    this.numberOfEnemytanksDestroyed++;
                    if (this.numberOfEnemytanksDestroyed === numberOfEnemyTanks)
                    {
                        /* Player has won                                                                                             */
                        /* Have a two second delay to show the last enemy tank blowing up beofore displaying the 'Game Over!' message */
                        setInterval(function ()
                        {
                            for (let j = 0; j < gameObjects.length; j++) /* stop all gameObjects from animating */
                            {
                                gameObjects[j].stopAndHide();
                            }
                            gameObjects[TANK].stopMoving(); // turn off tank moving sound
                            gameObjects[BACKGROUND].start();
                            gameObjects[WIN_MESSAGE] = new StaticText("Game Over!", 5, 270, "Times Roman", 100, "red");
                            gameObjects[WIN_MESSAGE].start(); /* render win message */
                        }, 2000);
                    }
                }
            }
        }


        


Tank.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class Tank extends GameObject
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(tankImage, riverImage, tankMovingSound, speed, centreX, centreY, direction, startRow, startColumn)
    {
        super(200 - speed); /* as this class extends from GameObject, you must always call super() */

        this.tankImage = tankImage;
        this.tankMovingSound = tankMovingSound;

        /* this.offscreenObstaclesCtx will be used for collision detection with the river */
        this.offscreenObstacles = document.createElement('canvas');
        this.offscreenObstaclesCtx = this.offscreenObstacles.getContext('2d');
        this.offscreenObstacles.width = canvas.width;
        this.offscreenObstacles.height = canvas.height;
        this.offscreenObstaclesCtx.drawImage(riverImage, 0, 0, canvas.width, canvas.height);

        this.centreX = centreX;
        this.centreY = centreY;
        this.size = 50;  // the width and height of the tank
        this.halfSize = this.size / 2;

        this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE = 8; // the number of columns in the sprite image
        this.NUMBER_OF_ROWS_IN_SPRITE_IMAGE = 4; // the number of rows in the sprite image	
        this.NUMBER_OF_SPRITES = 8; // the number of sprites in the sprite image

        this.START_ROW = startRow;
        this.START_COLUMN = startColumn;

        this.currentSprite = 0; // the current sprite to be displayed from the sprite image  
        this.row = this.START_ROW; // current row in sprite image
        this.column = this.START_COLUMN; // current column in sprite image
        this.SPRITE_INCREMENT = -1; // sub-images in the sprite image are ordered from bottom to top, right to left

        this.SPRITE_WIDTH = (this.tankImage.width / this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE);
        this.SPRITE_HEIGHT = (this.tankImage.height / this.NUMBER_OF_ROWS_IN_SPRITE_IMAGE);

        this.STEP_SIZE = 2; // the number of pixels to move forward
this.setDirection(direction); this.isMoving = false; // the tank is initially stopped } updateState() { if (!this.isMoving) { return; } this.currentSprite++; this.column += this.SPRITE_INCREMENT; if (this.currentSprite >= this.NUMBER_OF_SPRITES) { this.currentSprite = 0; this.row = this.START_ROW; this.column = this.START_COLUMN; } if (this.column < 0) { this.column = this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE - 1; this.row--; } this.centreX += this.stepSizeX; this.centreY += this.stepSizeY; /* if the tank goes off the canvas, then make it reappear at the opposite side of the canvas */ if ((this.centreX - this.halfSize) > canvas.width) { this.centreX = -this.halfSize; } else if ((this.centreY - this.halfSize) > canvas.height) { this.centreY = -this.halfSize; } else if ((this.centreX + this.halfSize) < 0) { this.centreX = canvas.width + this.halfSize; } else if ((this.centreY + this.halfSize) < 0) { this.centreY = canvas.height + this.halfSize; } } render() { ctx.save(); ctx.translate(this.centreX, this.centreY); ctx.rotate(Math.radians(this.direction)); ctx.translate(-this.centreX, -this.centreY); ctx.drawImage(this.tankImage, this.column * this.SPRITE_WIDTH, this.row * this.SPRITE_HEIGHT, this.SPRITE_WIDTH, this.SPRITE_HEIGHT, this.centreX - parseInt(this.size / 2), this.centreY - parseInt(this.size / 2), this.size, this.size); ctx.restore(); } pointIsInsideTank(x, y) { /* transform the shell into the enemy tank's coordinate system */ let transformedX = x - this.centreX; let transformedY = y - this.centreY; x = transformedX * Math.cos(Math.radians((this.direction))) - transformedY * Math.sin(Math.radians(this.direction)); y = transformedX * Math.sin(Math.radians((this.direction))) + transformedY * Math.cos(Math.radians(this.direction)); x += this.centreX; y += this.centreY; let imageTopLeftX = this.centreX - parseInt(this.size / 2); let imageTopLeftY = this.centreY - parseInt(this.size / 2); if ((x > imageTopLeftX) && (y > imageTopLeftY)) { if (x > imageTopLeftX) { if ((x - imageTopLeftX) > this.size) { return false; // to the right of the tank image } } if (y > imageTopLeftY) { if ((y - imageTopLeftY) > this.size) { return false; // below the tank image } } } else // above or to the left of the tank image { return false; } return true; // inside tank image } collidedWithRiver() { /* test the front-left corner and the front-right corner of the tank for collision with the river */ /* we only need to test the front of the tank, as the tank can only move forward */ if ((this.pointCollisionWithRiver(this.getFrontLeftCornerX(), this.getFrontLeftCornerY())) || (this.pointCollisionWithRiver(this.getFrontRightCornerX(), this.getFrontRightCornerY()))) { return true; } return false; } pointCollisionWithRiver(x, y) { let transformedX = x - this.centreX; let transformedY = y - this.centreY; x = transformedX * Math.cos(Math.radians((this.direction))) - transformedY * Math.sin(Math.radians(this.direction)); y = transformedX * Math.sin(Math.radians((this.direction))) + transformedY * Math.cos(Math.radians(this.direction)); x += this.centreX; y += this.centreY; let imageData = this.offscreenObstaclesCtx.getImageData(x, y, 1, 1); let data = imageData.data; if (data[3] !== 0) { return true; } return false; } positionRandomly() { this.centreX = Math.random() * (canvas.width - (this.size * 2)) + this.size; this.centreY = Math.random() * (canvas.height - (this.size * 2)) + this.size; this.setDirection(Math.random() * 360); // reset the tank sprite this.currentSprite = 0; this.row = this.START_ROW; this.column = this.START_COLUMN; } startMoving() { this.isMoving = true; this.tankMovingSound.currentTime = 0; this.tankMovingSound.play(); /* ensure that the sound loops continuously */ this.tankMovingSound.addEventListener('ended', function () { this.currentTime = 0; this.play(); }); } stopMoving() { this.isMoving = false; this.tankMovingSound.pause(); } tankIsMoving() { return this.isMoving; } reverse(numberOfReverseSteps = 1) { // move in reverse direction for (let i = 0; i < numberOfReverseSteps; i++) { this.setX(this.getX() - this.getStepSizeX()); this.setY(this.getY() - this.getStepSizeY()); } } getDirection() { return this.direction; } setDirection(newDirection) { this.direction = newDirection; this.stepSizeX = this.STEP_SIZE * Math.sin(Math.radians(this.direction)); this.stepSizeY = -this.STEP_SIZE * Math.cos(Math.radians(this.direction)); } getX() { return this.centreX; } getY() { return this.centreY; } setX(x) { this.centreX = x; } setY(y) { this.centreY = y; } getStepSizeX() { return this.stepSizeX; } getStepSizeY() { return this.stepSizeY; } getSize() { return this.size; } getFrontLeftCornerX() { return this.centreX - this.getSize() / 2.8; } getFrontLeftCornerY() { return this.centreY - this.getSize() / 2.8; } getFrontRightCornerX() { return this.centreX + this.getSize() / 2.8; } getFrontRightCornerY() { return this.centreY - this.getSize() / 2.8; } getFrontCentreX() { return this.centreX; } getFrontCentreY() { return this.centreY - this.getSize() / 2.8; } }

Inside the constructor() method, an offscreen canvas is created to hold an image of the river. This will be used for collision detection between the tank and the river.

        /* this.offscreenObstaclesCtx will be used for collision detection with the river */
        this.offscreenObstacles = document.createElement('canvas');
        this.offscreenObstaclesCtx = this.offscreenObstacles.getContext('2d');
        this.offscreenObstacles.width = canvas.width;
        this.offscreenObstacles.height = canvas.height;
        this.offscreenObstaclesCtx.drawImage(riverImage, 0, 0, canvas.width, canvas.height);

The direction that the tank is moving in is given in degrees. The variable 'this.STEP_SIZE' determines how many pixels the tank will move forward on each call to updateState(). Higher values will cause the tank to move faster.

        this.STEP_SIZE = 2; // the number of pixels to move forward
        this.setDirection(direction);

The tank code includes methods for making the tank move and stop. The tank is initially set to be stopped.

        this.isMoving = false; // the tank is initially stopped

The updateState() method does three things:

updateState()
    {
        if (!this.isMoving)
        {
            return;
        }
        
        this.currentSprite++;
        this.column += this.SPRITE_INCREMENT;
        if (this.currentSprite >= this.NUMBER_OF_SPRITES)
        {
            this.currentSprite = 0;
            this.row = this.START_ROW;
            this.column = this.START_COLUMN;
        }

        if (this.column < 0)
        {
            this.column = this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE - 1;
            this.row--;
        }

        this.centreX += this.stepSizeX;
        this.centreY += this.stepSizeY;

        /* if the tank goes off the canvas, then make it reappear at the opposite side of the canvas */
        if ((this.centreX - this.halfSize) > canvas.width)
        {
            this.centreX = -this.halfSize;
        }
        else if ((this.centreY - this.halfSize) > canvas.height)
        {
            this.centreY = -this.halfSize;
        }
        else if ((this.centreX + this.halfSize) < 0)
        {
            this.centreX = canvas.width + this.halfSize;
        }
        else if ((this.centreY + this.halfSize) < 0)
        {
            this.centreY = canvas.height + this.halfSize;
        }
    }

When rendering the tank, we must rotate the canvas so that the tank is made to point in its current direction.

    render()
    {
        ctx.save();
        ctx.translate(this.centreX, this.centreY);
        ctx.rotate(Math.radians(this.direction));
        ctx.translate(-this.centreX, -this.centreY);

        ctx.drawImage(this.tankImage, this.column * this.SPRITE_WIDTH, this.row * this.SPRITE_HEIGHT, this.SPRITE_WIDTH, this.SPRITE_HEIGHT, this.centreX - parseInt(this.size / 2), this.centreY - parseInt(this.size / 2), this.size, this.size);
        ctx.restore();
    }

We need to account for the fact that the tank is displayed in a rotated position on the canvas. The best way to achieve this is to rotate the tank and the bullet by an amount that will bring the tank back to a position where it is not rotated. The rotation of the point is done by rotating the point by the minus angle of 'this.direction'. The rotation is about the tank's centre point. This is shown in red in the code below.
The rest of the code is a standard test of a point against a rectangle.

pointIsInsideTank(x, y)
    {
        /* transform the shell into the enemy tank's coordinate system */
        let transformedX = x - this.centreX;
        let transformedY = y - this.centreY;
        x = transformedX * Math.cos(Math.radians((this.direction))) - transformedY * Math.sin(Math.radians(this.direction));
        y = transformedX * Math.sin(Math.radians((this.direction))) + transformedY * Math.cos(Math.radians(this.direction));
        x += this.centreX;
        y += this.centreY;

        let imageTopLeftX = this.centreX - parseInt(this.size / 2);
        let imageTopLeftY = this.centreY - parseInt(this.size / 2);
        if ((x > imageTopLeftX) && (y > imageTopLeftY))
        {
            if (x > imageTopLeftX)
            {
                if ((x - imageTopLeftX) > this.size)
                {
                    return false; // to the right of the tank image
                }
            }

            if (y > imageTopLeftY)
            {
                if ((y - imageTopLeftY) > this.size)
                {
                    return false; // below the tank image
                }
            }
        }
        else // above or to the left of the tank image
        {
            return false;
        }
        return true; // inside tank image
    }

The The collidedWithRiver() method tests the front-left and front-right corners of the tank against the offscreen canvas that contains the river image.
The pointCollisionWithRiver() method is used for the collision detection of one point against the offscreen canvas containing the river image.

    collidedWithRiver()
    {
        /* test the front-left corner and the front-right corner of the tank for collision with the river */
        /* we only need to test the front of the tank, as the tank can only move forward                                    */
        if ((this.pointCollisionWithRiver(this.getFrontLeftCornerX(), this.getFrontLeftCornerY())) ||
                (this.pointCollisionWithRiver(this.getFrontRightCornerX(), this.getFrontRightCornerY())))
        {
            return true;
        }
        return false;
    }

    pointCollisionWithRiver(x, y)
    {

        let transformedX = x - this.centreX;
        let transformedY = y - this.centreY;
        x = transformedX * Math.cos(Math.radians((this.direction))) - transformedY * Math.sin(Math.radians(this.direction));
        y = transformedX * Math.sin(Math.radians((this.direction))) + transformedY * Math.cos(Math.radians(this.direction));
        x += this.centreX;
        y += this.centreY;

        let imageData = this.offscreenObstaclesCtx.getImageData(x, y, 1, 1);
        let data = imageData.data;
        if (data[3] !== 0)
        {
            return true;
        }
        return false;
    }

The positionRandomly() method places the tank ramdomly on the canvas. The calculation in red ensures that the entire tank is displayed on the canvas.

    positionRandomly()
    {
        this.centreX = Math.random() * (canvas.width - (this.size * 2)) + this.size;
        this.centreY = Math.random() * (canvas.height - (this.size * 2)) + this.size;
        this.setDirection(Math.random() * 360);

        // reset the tank sprite
        this.currentSprite = 0;
        this.row = this.START_ROW;
        this.column = this.START_COLUMN;
    }

The rest of the Tank class methods are straight forward.

PlayerTank.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class PlayerTank extends Tank
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(tankImage, riverImage, tankMovingSound, centreX, centreY, direction)
    {
        super(tankImage, riverImage, tankMovingSound, 100, centreX, centreY, direction, 1, 0); /* as this class extends from GameObject, you must always call super() */  
    }  
}

EnemyTank.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class EnemyTank extends Tank
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(tankImage, riverImage, tankMovingSound, x, y, direction)
    {
        super(tankImage, riverImage, tankMovingSound, 70, x, y, direction, 2, 0);
    }
}

The player tank and the enemy tank use a different set of sprites, so that they are two different colours. This is done using the last two parameters of the Tank constructor, as shown in red in the code above.

Shell.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class Shell extends GameObject
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(explosionImage, shellExplosionSound, x, y, direction)
    {
        super(5); /* as this class extends from GameObject, you must always call super() */
        
        this.explosionImage = explosionImage;
        this.shellExplosionSound = shellExplosionSound;
        this.x = x;
        this.y = y;
        this.explosionTargetX = x;
        this.explosionTargetY = y;
        this.direction = direction;

        /* define the maximum range of the shell */
        /* the shell will explode here if it has not hit a target beforehand */
        this.shellRange = 200;
        this.distanceShellTravelled = gameObjects[TANK].getSize() / 2;  // the shell starts from the front of the tank's turret
    }

    updateState()
    {
        if (this.distanceShellTravelled < this.shellRange)
        {
            this.distanceShellTravelled += this.stepSize;
            this.explosionTargetX = this.x + (this.distanceShellTravelled * Math.sin(Math.radians(this.direction)));
            this.explosionTargetY = this.y - (this.distanceShellTravelled * Math.cos(Math.radians(this.direction)));
        }
        else
        {
            this.stopAndHide();
            gameObjects[EXPLOSION] = new Explosion(this.explosionImage, this.shellExplosionSound, this.explosionTargetX, this.explosionTargetY, 120);
            gameObjects[EXPLOSION].start();
        }
    }

    getX()
    {
        return this.explosionTargetX;
    }

    getY()
    {
        return this.explosionTargetY;
    }

    getRange()
    {
        return this.shellRange;
    }

    isFiring()
    {
        return this.gameObjectIsDisplayed;
    }
}

In the constructor() method, we need to declare the range of the shell. The shell starts from the top of the barrel of the tank's gun.

        /* define the maximum range of the shell */
        /* the shell will explode here if it has not hit a target beforehand */
        this.shellRange = 200;
        this.distanceShellTravelled = gameObjects[TANK].getSize() / 2;  // the shell starts from the front of the tank's turret
    

The shell will travel until it either collides with an enemy tank or reaches its range. The collision with an enemy tank is dealt with inside the TankCanvasGame collisionDetection() method. Therefore, the updateState() method below only needs to test if the shell has reached its maximum range. If the shell has not reached its maximum range, then move it to its next position. If the shell has reached it maximum range, then hide the shell and show an explosion.

    updateState()
    {
        if (this.distanceShellTravelled < this.shellRange)
        {
            this.distanceShellTravelled += this.stepSize;
            this.explosionTargetX = this.x + (this.distanceShellTravelled * Math.sin(Math.radians(this.direction)));
            this.explosionTargetY = this.y - (this.distanceShellTravelled * Math.cos(Math.radians(this.direction)));
        }
        else
        {
            this.stopAndHide();
            gameObjects[EXPLOSION] = new Explosion(this.explosionImage, this.shellExplosionSound, this.explosionTargetX, this.explosionTargetY, 120);
            gameObjects[EXPLOSION].start();
        }
    }

The rest of the Shell code is straight-forward.

FireShellAnimation.js

/* Author: Derek O Reilly, Dundalk Institute of Technology, Ireland. */

class FireShellAnimation extends GameObject
{
    /* Each gameObject MUST have a constructor() and a render() method.        */
    /* If the object animates, then it must also have an updateState() method. */

    constructor(tankImage, fireShellSound, centreX, centreY, direction)
    {
        super(50); /* as this class extends from GameObject, you must always call super() */

        this.tankImage = tankImage;
        
        this.centreX = centreX;
        this.centreY = centreY;
        this.direction = direction;

        this.NUMBER_OF_SPRITES = 3; // the number of sprites in the sprite image
        this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE = 8; // the number of columns in the sprite image
        this.NUMBER_OF_ROWS_IN_SPRITE_IMAGE = 4; // the number of columns in the sprite image
        //    this.NUMBER_OF_SPRITES = 3; // the number of sprites in the sprite image
        this.START_ROW = 2;
        this.START_COLUMN = 1;

        this.currentSprite = 0; // the current sprite to be displayed from the sprite image  
        this.row = this.START_ROW; // current row in sprite image
        this.column = this.START_COLUMN; // current column in sprite image
        this.spriteIncrement = 1;
        this.SPRITE_WIDTH;
        this.SPRITE_HEIGHT;
        this.size = 50;

        fireShellSound.currentTime = 0;
        fireShellSound.play();

        this.SPRITE_WIDTH = (tankImage.width / this.NUMBER_OF_COLUMNS_IN_SPRITE_IMAGE);
        this.SPRITE_HEIGHT = (tankImage.height / this.NUMBER_OF_ROWS_IN_SPRITE_IMAGE);
    }

    updateState()
    {
        this.currentSprite++;
        if (this.currentSprite >= this.NUMBER_OF_SPRITES)
        {
            this.stopAndHide();
        }

        this.column += this.spriteIncrement;
    }

    render()
    {


        ctx.save();
        ctx.translate(this.centreX, this.centreY);
        ctx.rotate(Math.radians(this.direction));
        ctx.translate(-this.centreX, -this.centreY);

        ctx.drawImage(this.tankImage, this.column * this.SPRITE_WIDTH, this.row * this.SPRITE_HEIGHT, this.SPRITE_WIDTH, this.SPRITE_HEIGHT, this.centreX - parseInt(this.size / 2), this.centreY - parseInt(this.size), this.size, this.size);
        ctx.restore();
    }
}

The FireShellAnimation uses standard sprite animation code.