CyberLord09 / AnvayDNHSCompSci

Apache License 2.0
0 stars 0 forks source link

Final Boss Issue #3

Open CyberLord09 opened 5 months ago

Hypernova101 commented 5 months ago

Boss Enemy Addition as a Final Stage

As our significant change, we plan to add a boss as a "mega" enemy that will be presented at the end of all the levels.


To start, we needed to create a sprite sheet for the boss. We searched the web for something that could work, but none were good for us. So, we decided to create our own using this link. We want to make the final boss around twice to thrice the size of a regular Goomba.

Spritesheet

Our Spritesheet


First, we added code to GameSetup.js so that there is a Enemy called boss with its properties and spritesheet.

boss: {
    src: "/images/platformer/sprites/boss.png",
    width: 46,
    height: 52.5,
    scaleSize: 60,
    speedRatio: 0.7,
    wa: {row: 1, frames: 3}, // Up-Left Movement
    wd: {row: 2, frames: 3}, // Up-Right Movement
    idle: { row: 6, frames: 1, idleFrame: {column: 1, frames: 0} },
    a: { row: 1, frames: 3, idleFrame: { column: 1, frames: 0 } }, // Left Movements: {row: 1, frames: 3}, // Stop the movement
    d: { row: 2, frames: 3, idleFrame: { column: 1, frames: 0 } }, // Right Movement
    walkingLeft: { row: 5, frames: 3, idleFrame: {column: 1, frames: 0} },
    walkingRight: { row: 4, frames: 3, idleFrame: {column: 1, frames: 0} },
    }

GameSetup.js

Next, we created a file called Enemy.js that extends the character. We did this so that we can have a base file for the enemy so that we don't need to change the code we already had.

export class Enemy extends Character {
    //show code in VSCode
}

Enemy.js

After created Enemy.js, we created a file named Boss.js that extends the Enemy class. Here, we will have the default properties of an enemy, and we will add our own to fit the boss.

import  Enemy  from  './Enemy.js'
export  class  Boss  extends  Enemy  {
    //show in VSCode
}

Boss.js

TianbinLiu commented 5 months ago

RandomEvent:

First, creating a RandomEvent.js

import  GameControl  from  './GameControl.js';

let  randomNum  =  Math.floor(Math.random() *  8) +  3; // random num from 3 to 10 sec

let  startRandomEvent  =  setTimeout(function  request() {
GameControl.startRandomEvent("boss")
randomNum  =  Math.floor(Math.random() *  8) +  3; // reset the random num from 3 to 10 sec
startRandomEvent  =  setTimeout(request, randomNum  *  1000);
}, randomNum  *  1000); //each random event will happen each 3 to 10 sec

To understand how the random event work, first we need to know what is the function "setTimeout()"

The setTimeout(func, delay) Javascript method is used to call a function after a certain period of time. The time after which the function will be called is given by the user in milliseconds.

Here, we first set up a variable called "randomNum" which will be assigned a random number from 3 to 10 (represent 3 to 10 sec after the game starts) And we have two setTimeout(), one inside another. This causes setTimeout() to run forever and cause the random event (the code inside the setTimeout() function) to run repeatedly and randomly for each 3 to 10 sec.

Change GameControl.js:

Before:

    startRandomEvent() {
        this.randomEventState = 1;
        this.randomEventId = Math.floor(Math.random() * 3) + 1; //The number multiplied by Math.random() is the number of possible events.
        /**Random Event Key
         * 1: Inverts the Color of the Background
         * 2: Time Stops all Goombas    
         * 3: Kills a Random Goomba
        */
    },

after:

    startRandomEvent(event) {
        if(event === "game"){ //game random event
            this.randomEventState = 1;
            this.randomEventId = Math.floor(Math.random() * 3) + 1; //The number multiplied by Math.random() is the number of possible events.
            /**Random Event Key
             * 1: Inverts the Color of the Background
             * 2: Time Stops all Goombas
             * 3: Kills a Random Goomba
            */
        }
        else if(event === "boss"){ //zombie event
            this.randomEventState = 2;
            this.randomEventId = Math.floor(Math.random() * 3) + 1; //The number multiplied by Math.random() is the number of possible events.
            /**Random Event Key
             * 1: Stop the Zombie
             * 2: Let the Zombie to walk left
             * 3: Let the Zombie to walk right
            */
        }
    },

Because the game already has a Global random event, so to make a random event for a specific object, we can assign the original function an "input" called "event". So that for each specific input to the function, the game will run a specific if-statement inside the function and change the variable to a specific number which will run the specific random event we want.

        if (GameControl.randomEventId === 1 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.direction = "idle"; 
            GameControl.endRandomEvent();
        }
        if (GameControl.randomEventId === 2 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.direction = "a"; 
            GameControl.endRandomEvent();
        }
        if (GameControl.randomEventId === 3 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.direction = "d"; 
            GameControl.endRandomEvent();
        }

random event demo

TianbinLiu commented 5 months ago

Apply Finite State Machine to Enemy.

initEnvironmentState = {
        // Enemy
        animation: 'right', //current animation
        direction: 'right', //facing direction, only contain left or right
        isDying: false,
};

this.state = {...this.initEnvironmentState}; // Enemy and environment states 

Just like the player, we can set up a Finite State Machine to Enemy. we create a variable/object called 'initEnvironmentState' that contains a list of variables, in this case, is 'animation', 'direction', and 'isDying". And then inside the constructor, we set up a property called "this.state" and assign 'initEnvironmentState' to it. So that if we call 'this.state.animation', it will return the current animation of the enemy. Same as 'this.state.direction', and 'this.state.isDying'. It helps us to organize the property(grouping the properties that have similar purposes)

Single Responsibility Principle

In software engineering, the Single Responsibility Principle (SRP) is one of the five SOLID principles, emphasizing that a class should have only one reason to change. This principle plays a key role in creating maintainable and scalable code, particularly when you're working with class hierarchies and inheritance.

Let's examine how refactoring a method to adhere to SRP can simplify code maintenance and improve flexibility, especially when extending a class. Here's the original code snippet with a complex update() method:

update() {
    super.update();
    this.setAnimation(this.state.animation);

    // Check for boundaries
    if (this.x <= this.minPosition || (this.x + this.canvasWidth >= this.maxPosition)) {
        if (this.state.direction === "left") {
            this.state.animation = "right";
            this.state.direction = "right";
        } else if (this.state.direction === "right") {
            this.state.animation = "left";
            this.state.direction = "left";
        }
    }

    // Update movement
    if (this.state.animation === "right") {
        this.speed = Math.abs(this.speed);
    } else if (this.state.animation === "left") {
        this.speed = -Math.abs(this.speed);
    } else if (this.state.animation === "idle") {
        this.speed = 0;
    } else if (this.state.animation === "death") {
        this.speed = 0;
    }

    // Move the enemy
    this.x += this.speed;

    this.playerBottomCollision = false;
}

This code does many things at once: setting animations, checking boundaries, and updating movement. To improve it, we can extract specific responsibilities into dedicated methods, as shown below:

checkBoundaries() {
    if (this.x <= this.minPosition || (this.x + this.canvasWidth >= this.maxPosition)) {
        if (this.state.direction === "left") {
            this.state.animation = "right";
            this.state.direction = "right";
        } else if (this.state.direction === "right") {
            this.state.animation = "left";
            this.state.direction = "left";
        }
    }
}

updateMovement() {
    if (this.state.animation === "right") {
        this.speed = Math.abs(this.speed);
    } else if (this.state.animation === "left") {
        this.speed = -Math.abs(this.speed);
    } else {
        this.speed = 0;
    }

    // Move the enemy
    this.x += this.speed;

    this.playerBottomCollision = false;
}

update() {
    super.update();
    this.setAnimation(this.state.animation);

    this.checkBoundaries();
    this.updateMovement();
}

Now, each method has a single responsibility, making it easier to read, understand, and maintain. This refactoring provides a few key benefits:

For example, if we have a new Enemy that extends the Enemy class, and we want it to have a new movement instead of using the old one. We can just overwrite the method "updateMovement()" by creating a function that has the same name called "updateMovement()" and just put the code we want to overwrite into the new function.

We don't need to go back and change the Enemy class such as adding an if-statement to the code inside the update() function that manages the movement.

TianbinLiu commented 5 months ago

Death animation

If we want an Enemy to play a Death animation after it has been hit on top by the player. We can't just simply change the animation to "death". Because different devices have a unique speed to play the animation frame by frame, so if we just change the animation to "death" and use a setTimeout() function and destroy function to set up a delayed death, hoping it will play the completed animation before it was destroyed, probably it won't work and for some devices, the death animation may play twice and for other it not even able to play once.

Also if we just change the animation to "death", the frameX may not start exactly at 0 but start at the frameX where the last animation had just played.

So this means, we need first to let the Enemy be destroyed exactly after all frames had been played once. Also, we need to reset the frameX to 0 when the Enemy is hit by the player.

if(this.collisionData.touchPoints.other.bottom && this.immune == 0){
                this.state.animation = "death";
                if(!this.state.isDying && this.state.animation == "death"){
                    this.frameX = 0;
                }
                this.state.isDying = true;
                GameEnv.invincible = true;
                GameEnv.goombaBounce = true;
                GameEnv.playSound("goombaDeath");
            }

We can add an if-statement to check if the enemy has been hit on top by the player if it is, then we set the animation to "death", and frameX to 0 by checking the property "this.state.isDying" (Finite State Machine). We turn the this.state.isDying to true for the next check.

    //overwrite the method
    updateFrameX() {
        // Update animation frameX of the object
        if(!this.state.isDying || this.state.animation != "death"){
            if (this.frameX < this.maxFrame) {
                if(this.counter > 0){
                    this.frameX = this.frameX; 
                    this.counter--;
                }
                else{
                    this.frameX++
                    this.counter = this.animationSpeed;
                }
            } else {
                this.frameX = this.minFrame;
            }
        }
        else if(this.state.isDying && this.state.animation == "death"){
            this.animationSpeed = 50;
            if (this.frameX < this.maxFrame) {
                if(this.counter > 0){
                    this.frameX = this.frameX; 
                    this.counter--;
                }
                else{
                    this.frameX++
                    this.counter = this.animationSpeed;
                }
            } else {
                this.destroy();
            }
        }

    }

We can overwrite the function updateFrameX() because the original one does not meet our needs anymore. In this new one, we are checking if the enemy has died (checking if "this.state.isDying" is turned to true) If it is, it will run over the death animation slowly (set this.animationSpeed = 50;) and destroy by calling the function destroy() after all frames have been run once.

TianbinLiu commented 5 months ago

Boss Level

After viewing all the knowledge above, we now have all the component to create a "Boss level".

Steps/requirements:

  1. Boss's sprite sheet, background image
  2. Enemy.js
  3. Boss.js (extends the Enemy class from Enemy.js)
  4. Random Event (RandomEvent.js)
  5. Apply changes to GameSetup.js, GameControl.js

1. Sprite Sheet

Image Sprite sheet is needed to create an Enemy with animation, without it we will just have a single image moving around like Goomba.

2. Enemy.js

Above, we talked about the Finite State Machine and the Single Responsibility Principle within Enemy.js. With that Enemy.js, we can easily create an Enemy with animation by extending the Enemy class. And also add/change our own Enemy feature by overwriting the method and adding our own class properties.

3. Boss.js

For our Boss Enemy, the differences between the Boss and normal Enemy are the random event, death and attack animation, and also the control of animation speed.

        this.animationSpeed = data?.animationSpeed || 1; //higher "animationSpeed" means slower animation
        this.counter = data?.animationSpeed; 

For "the control of animation speed", we notice that if we add the animation to the Enemy, the animation will play too fast and look weird. So we add two properties to the Boss and overwrite the animation method for controlling the speed.

            if (this.frameX < this.maxFrame) {
                if(this.counter > 0){
                    this.frameX = this.frameX; 
                    this.counter--;
                }
                else{
                    this.frameX++
                    this.counter = this.animationSpeed;
                }
            } else {
                this.frameX = this.minFrame;
            }
        }

We use the counter to let each frame play multiple times, the greater the counter, the slower the animation speed, because each frames will play more times. (If counter = 10, that means each frame will repeat 10 times until it goes to the next frames)

Therefore, we can set up the animation speed by setting the counter inside the GameSetup.js

      boss: {
        src: "/images/platformer/sprites/boss.png",
        width: 64,
        height: 64,
        scaleSize: 320,
        speedRatio: 0.6,
        animationSpeed: 6,  //right here
        idleL: { row: 9, frames: 0, idleFrame: { column: 1, frames: 0 } },
        idleR: { row: 11, frames: 0, idleFrame: { column: 1, frames: 0 } },
        left: { row: 9, frames: 8, idleFrame: { column: 7, frames: 0 } },
        right: { row: 11, frames: 8, idleFrame: { column: 7, frames: 0 } },
        attackL: { row: 13, frames: 5 },
        attackR: { row: 15, frames: 5 },
        death: { row: 20, frames: 5 },
      },

For the "death animation" and "Random Event", see the last comment above.

We play to make the RandomEvent a method and added to Enemy.js so that everyone can easily set up their own Random event by calling this RandomEvent method.

Lastly, Apply changes to GameSetup.js, GameControl.js

If you want a Random Event, you need also to apply changes to GameControl.js:

GameControl.js:

    startRandomEvent(event) {
        if(event === "game"){ //game random event
            this.randomEventState = 1;
            this.randomEventId = Math.floor(Math.random() * 3) + 1; //The number multiplied by Math.random() is the number of possible events.
            /**Random Event Key
             * 1: Inverts the Color of the Background
             * 2: Time Stops all Goombas
             * 3: Kills a Random Goomba
            */
        }
        else if(event === "boss"){ //zombie event
            this.randomEventState = 2;
            this.randomEventId = Math.floor(Math.random() * 4) + 1; //The number multiplied by Math.random() is the number of possible events.
            /**Random Event Key
             * 1: Stop the boss and let it face left
             * 4: Stop the boss and let it face left
             * 2: Let the boss to walk left
             * 3: Let the boss to walk right
            */
        }
    },

Boss.js:

    randomEvent(){
        if (GameControl.randomEventId === 1 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.state.direction = "left";
            this.state.animation = "idleL"; 
            GameControl.endRandomEvent();
        }
        else if (GameControl.randomEventId === 2 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.state.direction = "right";
            this.state.animation = "idleR"; 
            GameControl.endRandomEvent();
        }
        else if (GameControl.randomEventId === 3 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.state.direction = "left";
            this.state.animation = "left"; 
            GameControl.endRandomEvent();
        }
        else if (GameControl.randomEventId === 4 && GameControl.randomEventState === 2){ //event: stop the zombie
            this.state.direction = "right";
            this.state.animation = "right"; 
            GameControl.endRandomEvent();
        }
    }

Just by adding new if-statements to both your Enemy file and GameControl, changing the event ID to your enemy ID, using the Random Event method we will make later, and also changing this.randomEventState to a different number, you can have your own Random Event.

If you want to add a new Enemy, you need to also add it to the GameSetup (I believe everyone can handle that)