Pair Review Blog 1
How we incorporated JS Objects, FSM, and SRP into our code.
Significant Feature Enhancement
As part of the animations group, we have made significant enchancements to the Player’s movement animations, including sliding and arc jumping. We have also created a new game level with accompning sprites and created new backgrounds. This blog shows how we incorporated JavaScript Objects, Finite State Machines, and Single Responsibility Principle in our changes over the past few weeks.
JavaScript Objects
A JavaScript object is like a container that holds related pieces of information together. For example, you could have a name like “Snowman” and a piece of information related to that such as the hitbox. So, now that we understand what an object is, we can add it into our games. We added a game object namead “snowman”. Which we extended off of the enemy.js file for our new game level. Below is the code that we used to define snowman and what it
Snowman: {
src: "/images/platformer/sprites/snowman.png",
width: 308,
height: 327,
scaleSize: 60,
speedRatio: 0.7,
xPercentage: 0.6,
hitbox: { widthPercentage: 0.0, heightPercentage: 0.2 },
left: { row: 0, frames: 0, idleFrame: { column: 0, frames: 0 } }, // Left Movement
idle: { row: 0, frames: 0 }, // Stop the movement
right: { row: 0, frames: 0, idleFrame: { column: 0, frames: 0 } }, // Right Movement
},
Snowman is then later called in our level setup:
{ name: 'snowman', id: 'snowman', class: Snowman, data: this.assets.enemies.Snowman, xPercentage: 0.2, minPosition: 0.1, difficulties: ["normal", "hard", "impossible"] },
Here, we are creating an object. Lets see what this object can do, and how it does it.
- name: Snowman
- id: snowman (used to call the object in other files like gameSetup)
- class: defines the properties of the object (It is defined as a snowman.)
- data: The coordinates and other properties of the object such as hitbox.
- Xpercentage: horizontal position of object relative to the size of the canvas.
- Xminposition: The movement minimum that the object can move (be present at) relative to the size of the canvas. So, we see what an object does and how it functions using the many values that are related to it.
Finite State Machines
A Finite State Machine (FSM) is a machine that has different “states”. By states we mean that they are different phases that something can be in. For example, think of a traffic light: it has states like “green,” “yellow,” and “red,” and it changes from one state to another. There can only be one state active at once.
Code Example
In this past sprint, we changed the PlayerBase.js file to allow the player to have smooth animations. Lets take an example; in playerbase.js, the updateMovement function controls how the player moves when it is in two different states, jumping or idle. UpdateAnimationState funtion assignes different states based on key inputs then updateAnimations function switches the states based on then key states. The states are:
- Idle
- Jump
- Walk
- Run
Each of these states have an action related to them as idle will make the character freeze in place.
updateMovement() {
switch (this.state.animation) {
case 'idle':
break;
case 'jump':
// Check condition for player to jump
if (this.state.movement.up && !this.state.movement.falling) {
// jump
GameEnv.playSound("PlayerJump");
this.yv = this.bottom * -0.06;
// start falling
this.state.movement.falling = true;
}
// break is purposely omitted to allow default case to run
default:
// Player is moving left
if (this.state.direction === 'left' && this.state.movement.left && 'a' in this.pressedKeys) {
// Decrease the player's x position according to run or walk animation and related speed
if (this.state.animation === 'run') {
this.xv -= this.runSpeed;
} else {
this.xv -= this.speed;
}
// Player is moving right
} else if (this.state.direction === 'right' && this.state.movement.right && 'd' in this.pressedKeys){
// Increase the player's x position according to run or walk animation and related speed
if (this.state.animation === 'run') {
this.xv += this.runSpeed;
} else {
this.xv += this.speed;
}
}
}
this.xv *= 0.875;
this.x += this.xv;
this.setX(this.x);
this.yv *= 0.875;
this.y += this.yv;
this.setY(this.y);
}
updateAnimation() {
switch (this.state.animation) {
case 'idle':
this.setSpriteAnimation(this.playerData.idle[this.state.direction]);
break;
case 'walk':
this.setSpriteAnimation(this.playerData.walk[this.state.direction]);
break;
case 'run':
this.setSpriteAnimation(this.playerData.run[this.state.direction]);
break;
case 'jump':
this.setSpriteAnimation(this.playerData.jump[this.state.direction]);
break;
default:
console.error(`Invalid state: ${this.state.animation}`);
}
}
updateAnimationState(key) {
switch (key) {
case 'a':
case 'd':
this.state.animation = 'walk';
break;
case 'w':
if (this.state.movement.up == false) {
this.state.movement.up = true;
this.state.animation = 'jump';
}
break;
case 's':
if ("a" in this.pressedKeys || "d" in this.pressedKeys) {
this.state.animation = 'run';
}
break;
default:
this.state.animation = 'idle';
break;
}
}
We can see the different states being assigned to different rows on the sprites sheet for the player, which changes the animations based on the states.
whitemario: {
src: "/images/platformer/sprites/white_mario.png",
width: 256,
height: 256,
scaleSize: 80,
speedRatio: 0.7,
idle: {
left: { row: 1, frames: 15 },
right: { row: 0, frames: 15 },
},
walk: {
left: { row: 3, frames: 7 },
right: { row: 2, frames: 7 },
},
run: {
left: { row: 5, frames: 15 },
right: { row: 4, frames: 15 },
},
jump: {
left: { row: 11, frames: 15 },
right: { row: 10, frames: 15 },
},
hitbox: { widthPercentage: 0.3, heightPercentage: 0.8 }
},
Single Responsibility Principle
This is the principle that states that a class should have only one reason to change, meaning it should have only one job or responsibility. Basically, this means each function should only do one thing. This principle divides the system into distinct features that do not overlap in functionality.
Example Code
Below is an example of the Single Responsibility princle in action in the PlayerWinter.js file, which runs the player in our new game level. The handlePlayerReaction function handles the player collision bewtween obstables on the level. Instead of the having one method to say a certain thing when the player colides with anything on the level, the function is split into different cases (which is also a finite state machine), cabin and snowman, which have different reactions if the player colides with them.
Benifits of SRP
The Single Responsibilty principle not only makes it eaiser to read and understand code, but also makes it easier to change certain parts of the code since you can add on to specific SRP functions without affecting other parts of the code. SRP also makes it easier to debug since you can easily narrow out one part of the code to fix rather than having a huge function. A lot of the time, when a large function breaks, it is only a small part of the code that is actually broken, which can be very annoying to sort through a bunch of working code trying to find what doesnt work, but with SRP in mind, the functions are much smaller and easier to narrow out the broken lines when faced with an error.
handlePlayerReaction() {
super.handlePlayerReaction(); // calls the super class method
// handles additional player reactions
switch (this.state.collision) {
case "cabin":
// 1. Caught in tube
if (this.collisionData.touchPoints.this.top && this.collisionData.touchPoints.other.bottom) {
// Position player in the center of the tube
this.x = this.collisionData.newX;
// Using natural gravity wait for player to reach floor
if (Math.abs(this.y - this.bottom) <= GameEnv.gravity) {
// Force end of level condition
this.x = GameEnv.innerWidth + 1;
}
// 2. Collision between player right and cabin
} else if (this.collisionData.touchPoints.this.right) {
this.state.movement.right = false;
this.state.movement.left = true;
// 3. Collision between player left and cabin
} else if (this.collisionData.touchPoints.this.left) {
this.state.movement.left = false;
this.state.movement.right = true;
}
break;
case "snowman":
if (this.collisionData.touchPoints.this.top && this.collisionData.touchPoints.other.bottom && this.state.isDying == false) {
if (GameEnv.goombaBounce === true) {
GameEnv.goombaBounce = false;
this.y = this.y - 100;
}
if (GameEnv.goombaBounce1 === true) {
GameEnv.goombaBounce1 = false;
this.y = this.y - 250
}
} else if (this.collisionData.touchPoints.this.right || this.collisionData.touchPoints.this.left) {
if (GameEnv.difficulty === "normal" || GameEnv.difficulty === "hard") {
if (this.state.isDying == false) {
this.state.isDying = true;
this.canvas.style.transition = "transform 0.5s";
this.canvas.style.transform = "rotate(-90deg) translate(-26px, 0%)";
GameEnv.playSound("PlayerDeath");
setTimeout(async() => {
await GameControl.transitionToLevel(GameEnv.levels[GameEnv.levels.indexOf(GameEnv.currentLevel)]);
}, 900);
}
} else if (GameEnv.difficulty === "easy" && this.collisionData.touchPoints.this.right) {
this.x -= 10;
} else if (GameEnv.difficulty === "easy" && this.collisionData.touchPoints.this.left) {
this.x += 10;
}
}