Open CyberLord09 opened 5 months ago
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.
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();
}
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)
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:
Enemy
, you can easily override individual methods like updateMovement()
to change behavior without needing to modify the original class. This flexibility is crucial in object-oriented programming, allowing for specialized behavior in subclasses.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.
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.
After viewing all the knowledge above, we now have all the component to create a "Boss level".
Steps/requirements:
Sprite sheet is needed to create an Enemy with animation, without it we will just have a single image moving around like Goomba.
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.
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.
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)
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.
Our Spritesheet
First, we added code to GameSetup.js so that there is a Enemy called boss with its properties and spritesheet.
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.
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.
Boss.js