Source: game-engine.js

/*******************************************************************************
 *
 *  @file game-engine.js A file with the basic game engine
 *
 *  @author Omar Essilfie-Quaye <omareq08+githubio@gmail.com>
 *  @version 1.0
 *  @date 12-February-2025
 *  @link https://omareq.github.io/tanks/
 *  @link https://omareq.github.io/tanks/docs/
 *
 *******************************************************************************
 *
 *                   GNU General Public License V3.0
 *                   --------------------------------
 *
 *   Copyright (C) 2025 Omar Essilfie-Quaye
 *
 *   This program is free software: you can redistribute it and/or modify
 *   it under the terms of the GNU General Public License as published by
 *   the Free Software Foundation, either version 3 of the License, or
 *   (at your option) any later version.
 *
 *   This program is distributed in the hope that it will be useful,
 *   but WITHOUT ANY WARRANTY; without even the implied warranty of
 *   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *   GNU General Public License for more details.
 *
 *   You should have received a copy of the GNU General Public License
 *   along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 *****************************************************************************/
"use strict";


/**
 * TankGame namespace object
 */
var TankGame = TankGame || {};

/**
 * Class GameEngine that represents the tank game engine
 *
 */
TankGame.GameEngine = class {
    /**
     * Creates a new instance of GameEngine
     *
     * @param {TankGame.Mode} startingMode - The game mode that the game engine
     *                                       will run at startup
     *
     * @see TankGame.Mode
     */
    constructor(startingMode) {
        this.reset();
        this.setMode(startingMode);
    };

    /**
     * reset game engine data
     */
    reset() {
        this.screenWidth = width;
        this.screenHeight = height;

        this.frameNumber = 0;
        this.firstFrameTime = Date.now();
        this.lastFrameTime = this.firstFrameTime;
        this.updateFrameData();
        this.gravity = createVector(0, 0.002);
        this.projectiles = [];
        this.terrain = undefined;

        // Good wind values -0.1 to 0.1
        this.wind = createVector(-0.02, 0);
        this.isPaused = false;
        this.tanks = [];
        this.players = [];
        this.activePlayerIndex = 0;
        this.playerData = [];
        this.scoresChanged = true;
    }

    /**
     * update the current frame data
     */
    updateFrameData() {
        this.frameNumber++;
        if(this.currentFrameData != undefined) {
            this.lastFrameTime = this.currentFrameData.frameTime;
        }
        this.currentFrameData = (new TankGame.FrameData(
            this.frameNumber,
            this.firstFrameTime,
            this.lastFrameTime)
        );
    };

    /**
     * Inserts a projectile into the game engine projectile list
     *
     * @param {TankGame.Projectile} newProjectile - an instance of
     *                                              TankGame.Projectile
     *
     * @throws {Error} param newProjectile should be instance of TankGame.Projectile
     */
    addProjectile(newProjectile) {
        if(!(newProjectile instanceof TankGame.Projectile)) {
            let err = "newProjectile should be an instance of ";
            err += "TankGame.Projectile\n";
            throw(err);
        }

        newProjectile.attachTo(this);
        this.projectiles.push(newProjectile);
    };

    /**
     * Update the position of all the projectiles and delete them if they are
     * off screen or collide with the ground
     */
    updateProjectiles() {
        if(this.projectiles.length > 0) {
            for(let i = this.projectiles.length-1; i >=0; i--) {
                this.projectiles[i].update(this.currentFrameData.dtSeconds);
                if(this.projectiles[i].isOffScreen()) {
                    this.projectiles.splice(i, 1);
                    console.debug("Remove out of bounds projectile from list");
                    continue;
                }

                if(this.projectiles[i].finishedExploding) {
                    const pos = this.projectiles[i].pos.copy();
                    const radius = this.projectiles[i].projectileParam.explosionRadius;
                    this.terrain.addCrater(pos, radius);

                    for(let j = 0; j < this.tanks.length; j++) {
                        this.projectileHitsTank(this.projectiles[i], this.tanks[j]);
                    }

                    this.projectiles.splice(i, 1);
                    console.debug("Remove exploded projectile from list");
                    continue;
                }

                if(this.projectiles[i].isExploding) {
                    // update terrain
                    continue;
                }

                if(this.projectileHitsTerrain(this.projectiles[i])) {
                    this.projectiles[i].explode();
                    continue;
                }
            }
        }
    };

    /**
     * Draws all the projectiles
     */
    drawProjectiles() {
    if(this.projectiles.length > 0) {
        for(let i = this.projectiles.length-1; i >=0; i--) {
            this.projectiles[i].draw();
            }
        }
    };

    /**
     * Determines if a projectile hits a tank and applies the correct damage
     * based on how far away the tank is from the center of the explosion.
     *
     * @param {TankGame.Projectile} projectile - The projectile
     * @param {TankGame.Tank} tank - The tank
     */
    projectileHitsTank(projectile, tank) {
// TODOD: Add param type checks
        const distance = dist(projectile.pos.x, projectile.pos.y,
            tank.pos.x, tank.pos.y);
        const explosionRadius = projectile.projectileParam.explosionRadius;
        if(distance < (explosionRadius + tank.width/2)) {
            const nonOverlapDist = distance;
            const maxDamage = projectile.projectileParam.damage;
            const damageStart = map(nonOverlapDist,
                0, explosionRadius + tank.width/2,
                maxDamage, 0);
            const damage = min(damageStart, maxDamage);
            console.debug("Projectile Tanks Dist: " + distance + " max damage: " + maxDamage
                + " Damage: " + damage);
// TODO: Add some asserts damage > 0, damage less than maxDamage, no undefined
            tank.addDamage(damage);

            if(this.players.length > 0) {
                this.scoresChanged = true;
                if(tank == this.currentPlayer().tank) {
                    this.currentPlayer().decreaseScore(round(damage) * 10);
                    return;
                }

                this.currentPlayer().increaseScore(round(damage) * 10);
            }
            return;
        }
    };

    /**
     * Calculate if a given projectile hits the ground
     *
     * @param {TankGame.Projectile} projectile - The projectile to test.
     *
     * @returns {Boolean} - If projectile hits the ground
     */
    projectileHitsTerrain(projectile) {
        if(projectile.pos.y >= this.terrain.groundHeight[floor(projectile.pos.x)]) {
            console.debug("Projectile hit terrain");
            return true;
        }
        return false;
    };

    /**
     * Add the terrain to the game engine.
     *
     * @param {TankGame.World.Terrain} terrain - the ground
     *
     * @throws {Error} param terrain should be instance of TankGame.World.Terrain
     */
    addTerrain(terrain) {
        if(!(terrain instanceof TankGame.World.Terrain)) {
            let err = "terrain should be an instance of ";
            err += " TankGame.World.Terrain\n";
            console.warn(err);
            throw(err);
        }

        this.terrain = terrain;
    };

     /**
     * Add new tank to the game engine.
     *
     * @param {TankGame.Tank} terrain - the ground
     *
     * @throws {Error} param newTank should be instance of TankGame.Tank
     */
    addTank(newTank) {
        if(!(newTank instanceof TankGame.Tank)) {
            let err = "newTank should be an instance of TankGame.Tanks\n";
            console.warn(err);
            throw(err);
        }

        newTank.attachTo(this);
        this.tanks.push(newTank);
    };

    /**
     * Update the tanks according to the game physics
     */
    updateTanks() {
        for(let i = this.tanks.length - 1; i >= 0; i--) {
            this.tanks[i].update(this.currentFrameData.dtSeconds);
            const index = floor(this.tanks[i].pos.x);
            const terrainHeight = this.terrain.groundHeight[index] + 2;
            if(terrainHeight < this.tanks[i].pos.y) {
                this.tanks[i].vel = createVector();
                this.tanks[i].pos.y = terrainHeight+2;
            }

            if(this.tanks[i].isDead()) {
                this.tanks.splice(i, 1);
// TODO: Detach from player
                console.debug("Removing dead tank from the game engine");
            }
        }
    };

    /**
     * Draw the tanks
     */
    drawTanks() {
        for(let i = this.tanks.length - 1; i >= 0; i--) {
            this.tanks[i].draw();
        }
    };

    /**
     * Add new player to the game engine
     *
     * @param {TankGame.Player} player - The new player
     *
     * @throws {Error} param player should be instance of TankGame.Player
     */
    addPlayer(player) {
        if(!(player instanceof TankGame.Player)) {
            let err = "player should be an instance of TankGame.Player\n";
            console.warn(err);
            throw(err);
        }
        player.attachTo(this);
        this.players.push(player);
        this.scoresChanged = true;
    }

    /**
     * Moves to the next player with a live tank.
     *
     * @returns {Boolean} success of operation
     */
    nextPlayer() {
        if(this.tanks.length <= 1) {
            return false;
        }

        do {
            this.activePlayerIndex++;
            if(this.activePlayerIndex >= this.players.length) {
                this.activePlayerIndex = 0;
            }
        } while(this.currentPlayer().tank.isDead());

        return true;
    }

    /**
     * Returns a reference to the current active player
     *
     * @returns {TankGame.Player} - The current player
     */
    currentPlayer() {
        return this.players[this.activePlayerIndex];
    }

    drawPlayerScores() {
        if(this.scoresChanged || this.playerData.length == 0) {
            this.playerData = [];
            for(let i = 0; i < this.players.length; i++) {
                this.playerData.push(
                    {
                        name:this.players[i].name,
                        score:this.players[i].score,
                        index: i
                    }
                );
            }
            this.playerData.sort((a, b) => b.score - a.score);
        }

        const offset = 0.09 * height;
        for(let i = 0; i < this.playerData.length; i++) {
            const score = this.playerData[i].name + ":" + this.playerData[i].score;
            fill(this.players[this.playerData[i].index].tank.color);
            noStroke();
            text(score, 10, offset + 1.15 * i * textSize());
        }
    }

    /**
     * Passes the current global wind vector to the calling function.
     *
     * @returns {P5.Vector} - The wind vector
     */
    getCurrentWind() {
        return this.wind.copy();
    }

    /**
     * Pause the game engine updates
     */
    pause() {
        this.isPaused = true;
    }

    /**
     * Resume game engine updates
     */
    unpause() {
        this.isPaused = false;
    }

    /**
     * Handle key press
     */
    handleKeyPress() {
        if(this.players.length > 0 && this.currentPlayer().tank.isDead()) {
            return;
        }
        if(this.players.length <= 0) {
            return;
        }

        let activePlayer = this.currentPlayer();
        if(activePlayer.isAI()) {
            // activePlayer.ai.makeMove();
            return;
        }
        let activeTank = this.currentPlayer().tank;

        if(key == "w") {
            activeTank.increaseGunAngle();
        } else if(key == "s") {
            activeTank.decreaseGunAngle();
        } else if(key == "a") {
            activeTank.moveLeft();
        } else if(key == "d") {
            activeTank.moveRight();
        } else if(key == "o") {
            activeTank.increaseFiringSpeed();
        } else if(key == "l") {
            activeTank.decreaseFiringSpeed();
        } else if(keyCode == ENTER) {
            const shot = activeTank.shootProjectile(activePlayer.peekNextWeapon());
            if(shot) {
                activePlayer.shootNextWeapon();
            }
        } else if(key == "q") {
            activePlayer.moveToNextWeaponType();
        } else if(key == "e") {
            activePlayer.moveToPrevWeaponType();
        }
    }

    /**
     * Updates the game engine and all of the components.  this includes running
     * a rendering operation after all updates are complete.
     */
    update() {
        this.scoresChanged = false;
        if(!this.isPaused) {
            if(keyIsPressed) {
                this.handleKeyPress();
            }
            this.updateFrameData();
            this.updateProjectiles();
            this.updateTanks();
            this.activeMode.update(this.currentFrameData.dtSeconds);
        }

// TODO: cache background drawing as img for faster refresh

        p5.disableFriendlyErrors = true;
        this.activeMode.draw();
        if(this.activeMode.gameMode)
        {
            if(this.terrain != undefined) {
                this.terrain.draw();
            }
            this.drawProjectiles();
            this.drawTanks();
            if(this.players.length > 0) {
                this.currentPlayer().draw();
                this.drawPlayerScores();
            }
        }
        p5.disableFriendlyErrors = false;
    };

    /**
     * Set the mode of the game engine.  Will return early and set the mode to
     * debug empty if the mode is not a valid instance of TankGame.Mode.
     *
     * @param {TankGame.Mode} newMode - the new mode
     */
    setMode(newMode) {
        if(newMode == undefined) {
            this.setMode(new TankGame.ModeList.DebugEmpty());
            return;
        }

        if(this.activeMode != undefined) {
            this.activeMode.shutdown();
        }

        if(!(newMode instanceof TankGame.Mode)) {
            this.activeMode = new TankGame.ModeList.DebugEmpty();
            let err = "newMode should be an instance of TankGame.Mode\n";
            err += "Switching to TankGame.ModeList.DebugEmpty Mode.";
            console.warn(err);
            return;
        }

        this.activeMode = newMode;
        this.activeMode.attachTo(this);
        this.activeMode.startup();
    };
};

// Modes

/**
 * Class Mode that represents variable modes that the game engine can run.  If
 * there are any issues the game engine will switch to the default game mode
 * which is the empty mode.
 *
 * @see TankGame.ModeList.DebugEmpty
 */
TankGame.Mode = class {
     /**
     * An abstract class constructor that throws an error if it is instantiated.
     *
     * @throws {Error} Abstract class TankGame.Mode can't be instantiated
     */
    constructor() {
        let err = "Abstract class TankGame.Mode can't be instantiated.";
        if(this.constructor == TankGame.Mode) {
          throw new Error(err);
        }
    };

    /**
     * Attach the game engine to the mode.  This allows the mode to pull data
     * such as the frame rate, gravity, screen dimensions etc from the game
     * engine.
     *
     * @param {TankGame.GameEngine} gameEngine - The running game engine
     *
     * @throws {Error} input param gameEngine is not instance of TankGame.GameEngine
     */
    attachTo(gameEngine) {
        if(!(gameEngine instanceof TankGame.GameEngine)) {
            let err = "gameEngine should be an instance of ";
            err += "TankGame.GameEngine\n";
            throw(err);
            return;
        }
        this.gameEngine = gameEngine;
    }

     /**
       * An abstract method which needs to be overridden.
       *
       * @throws {Error} Method 'update()' must be implemented
       */
    update(dt) {
        throw new Error("Method 'update()' must be implemented.");
    };

    /**
       * An abstract method which needs to be overridden.
       *
       * @throws {Error} Method 'draw()' must be implemented
       */
    draw() {
        background(255, 30, 255);
        throw new Error("Method 'draw()' must be implemented.");
    };

    /**
     * An abstract method called when teh mode is started after it is attached
     * to the game engine
     */
    startup() {

    }


     /**
       * An abstract method called when the mode is exited.
       */
    shutdown() {

    };
};

TankGame.ModeList = {};

/**
 * Class empty mode that doesn't do anything.
 *
 * @see TankGame.Mode
 */
TankGame.ModeList.DebugEmpty = class extends TankGame.Mode {
    /**
     * Calls super() and exits
     */
    constructor() {
        super();
        this.gameMode = false;
    };

    /**
     * exits immediately
     */
    update(dt) {};

    /**
     * exits immediately
     */
    draw() {};
};

/**
 * Class used as a data class to store the current frame statistics.  This
 * includes dt, fps, and time since start.
 */
TankGame.FrameData = class {
    /**
     * constructor for the FrameData class.  The data is automatically populated
     * on creation using timing data from the previous frame that is stored in
     * the Simulation.lastFrameTime and Simulation.firstFrameTime variables.
     *
     * If dt is greater than 100ms (for example when switching windows context,
     * or by changing tabs) then dt is set to 10ms. Due to this time manipulation
     * the time since start is different to the sum of all dts.  Time since
     * start is always the true difference between the start time and the
     * current frame time.
     */
    constructor(frameNumber, firstFrameTime, lastFrameTime) {
        this.frameTime = Date.now();

        this.dt = this.frameTime - lastFrameTime;
        if(this.dt > 100) {
            // this is done to remove jumping effects when you switch windows
            // contexts by changing tab or application.
            this.dt = 10;
        }

        this.dtMillis = this.dt;
        this.dtSeconds = 0.001 * this.dt;
        this.fps = 1 / this.dtSeconds;

        this.frame = frameNumber;
        this.timeSinceStart = this.frameTime - firstFrameTime;
        this.timeSinceStartSeconds = 0.001 * this.timeSinceStart;
    };
};

Documentation generated by JSDoc 4.0.2 on Wed Oct 29 2025 23:51:01 GMT-0700 (Pacific Daylight Time)