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;
                    }
                }

Game Control Code

The GameSetter{level_name}.js files for each level inside platformer.3x are responsible for the javascript objects that run the game levels. An object literal is a way to define and create objects in programming languages like JavaScript, using a list of key-value pairs enclosed in curly braces. It’s a straightforward and concise method for creating objects without needing to define a separate class or constructor function. This is how Gamesetup.js defines objets to works with javascript objects.

Lets take our winter level as an example. The objective is to define objects for a GameLevel called “Winter” which is later called in GameSetup.js. First we define the objects in assets. Lets like the finish line cabin as an example.

const assets = {  
	obstacles: {
		cabin: {
			src: "/images/platformer/obstacles/cabin.png",
			hitbox: { widthPercentage: 0.5, heightPercentage: 0.5 },
			width: 300,
			height: 300,
			scaleSize: 150,
		}
	}
}

This code defines the javascript object inside of the game level allowing for the finish line to have the correct properties. The properties are listed in the code which are:

  • Image File location

  • Width and Height of Image

  • Scale Size of Image inside of the Game

  • Hitbox of the Cabin

The objects are then initialized later in the file:

const  objects  = [
	{ name:  'winter', id:  'background', class:  BackgroundParallax, data:  assets.backgrounds.winter },
	{ name:  'snow', id:  'background', class:  BackgroundSnow, data:  assets.backgrounds.snow },
	{ name:  'snowyfloor', id:  'platform', class:  Platform, data:  assets.platforms.snowyfloor },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.2, yPercentage:  0.82 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.2368, yPercentage:  0.82 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.2736, yPercentage:  0.82 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.3104, yPercentage:  0.82 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.3472, yPercentage:  0.82 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.384, yPercentage:  0.74 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.4208, yPercentage:  0.66 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.5090, yPercentage:  0.56 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.5090, yPercentage:  0.48 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.5090, yPercentage:  0.40 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.5090, yPercentage:  0.32 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.69, yPercentage:  0.76 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.655, yPercentage:  0.68 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.62, yPercentage:  0.68 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.72, yPercentage:  0.76 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.755, yPercentage:  1 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.755, yPercentage:  0.92 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.755, yPercentage:  0.84 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.625, yPercentage:  0.92 },
	{ name:  'blocks', id:  'jumpPlatform', class:  BlockPlatform, data:  assets.platforms.snowywood, xPercentage:  0.625, yPercentage:  1 },
	{ name:  'snowflake', id:  'coin', class:  Coin, data:  assets.obstacles.snowflake, xPercentage:  0.2100, yPercentage:  0.72 },
	{ name:  'snowflake', id:  'coin', class:  Coin, data:  assets.obstacles.snowflake, xPercentage:  0.2619, yPercentage:  0.72 },
	{ name:  'snowflake', id:  'coin', class:  Coin, data:  assets.obstacles.snowflake, xPercentage:  0.3136, yPercentage:  0.72 },
	{ name:  'owl', id:  'owl', class:  Owl, data:  assets.enemies.Owl, xPercentage:  0.3, minPosition:  0.05 },
	{ name:  'owl', id:  'owl', class:  Owl, data:  assets.enemies.Owl, xPercentage:  0.8, minPosition:  0.05 },
	{ name:  'snowman', id:  'snowman', class:  Snowman, data:  assets.enemies.Snowman, xPercentage:  0.2, minPosition:  0.1, difficulties: ["normal", "hard", "impossible"] },
	{ name:  'snowman', id:  'snowman', class:  Snowman, data:  assets.enemies.Snowman, xPercentage:  0.35, minPosition:  0.1, difficulties: ["normal", "hard", "impossible"] },
	{ name:  'snowman', id:  'snowman', class:  Snowman, data:  assets.enemies.Snowman, xPercentage:  0.5, minPosition:  0.1, difficulties: ["normal", "hard", "impossible"] },
	{ name:  'mario', id:  'player', class:  PlayerWinter, data:  assets.players.whitemario },
	{ name:  'cabin', id:  'finishline', class:  FinishLine, data:  assets.obstacles.cabin, xPercentage:  0.85, yPercentage:  0.795 },
	{ name:  'tubeU', id:  'minifinishline', class:  FinishLine, data:  assets.obstacles.tubeU, xPercentage:  0.675, yPercentage:  0.9 },
	{ name:  'quidditchEnd', id:  'background', class:  BackgroundTransitions, data:  assets.transitions.quidditchEnd },
];

const  GameSetterWinter  = {
	tag:  'Winter',
	assets:  assets,
	objects:  objects
};

Here, the cabin is initialized with more attributes like xPercentage and yPercentage to determine its location on the page.

We then save all of the game level, with the tag being a name used to identify the level, the assets being the images and spritesheets defined above, and objects being the JavaScript objects that we made.

Then it is called in GameSetup.js

GameLevelSetup(GameSetterWinter, this.path, this.playerOffScreenCallBack);

After the object is defined and referenced inside of Gamesetup.js, it will be constructed inside of GameLevel.js. The GameLevel class is defined with a constructor function that takes an object (levelObject) containing properties for the level. Inside the constructor, the properties of the level such as tag, passive, isComplete, and gameObjects are initialized.

constructor(levelObject) {
        // The levelObjects property stores the levelObject parameter.
        this.levelObjects = levelObject;        
        // The tag is a friendly name used to identify the level.
        this.tag = levelObject?.tag;
        // The passive property determines if the level is passive (i.e., not playable).
        this.passive = levelObject?.passive;
        // The isComplete property is a function that determines if the level is complete.
        // build conditions to make determination of complete (e.g., all enemies defeated, player reached the end of the screen, etc.)
        this.isComplete = levelObject?.callback;
        // The gameObjects property is an array of the game objects for this level.
        this.gameObjects = this.levelObjects?.objects || [];
        // Each GameLevel instance is stored in the GameEnv.levels array.
        GameEnv.levels.push(this);
    }

Under the load function, Gamelevel.js will call all of the objects that are inside of the code for the level and construct them using the code below:

try {
	var  objFile  =  null;
	for (const  obj  of  this.gameObjects) {
		if (obj.data.file) {
			// Load the image for the game object.
			objFile  =  obj.data.file;
			console.log(objFile);
			obj.image  =  await  this.loadImage(obj.data.file);
			// Create a new canvas for the game object.
			const  canvas  =  document.createElement("canvas");
			canvas.id  =  obj.id;
			document.querySelector("#canvasContainer").appendChild(canvas);
			// Create a new instance of the game object.
			new  obj.class(canvas, obj.image, obj.data, obj.xPercentage, obj.yPercentage, obj.name, obj.minPosition);
		}
	}
}

Game level to and Array of GameLevels

The game levels are put into an array of gamelevels in GameEnv.js.

* @property  {Array}  levels - used by GameControl
static levels = [];

Whenever a new GameLevel instance is created, it’s added to this array. In the constructor of the GameLevel class, after initializing the properties, the instance of GameLevel (this) is pushed into the GameEnv.levels array which means that whenever a new level is created, it automatically becomes a part of the levels array within GameEnv.

GameLevel and GameControl

The code below shows how the game creates multiple different levels and puts them into a game array. This shows how the level is constructed and the game objects are added inside of the Game levels. This is where our game is called in Gamelevel.js.

 * Transitions to a new level. Destroys the current level and loads the new level.
 * @param {Object} newLevel - The new level to transition to.
 
async transitionToLevel(newLevel) {
    this.inTransition = true;
    // Destroy existing game objects
    GameEnv.destroy();
    // Load GameLevel objects
    if (GameEnv.currentLevel !== newLevel) {
        GameEnv.claimedCoinIds = [];
    }
    await newLevel.load();
    GameEnv.currentLevel = newLevel;
    // Update invert property
    GameEnv.setInvert();
    // Trigger a resize to redraw canvas elements
    window.dispatchEvent(new Event('resize'));
    this.inTransition = false;
}

Here, transitionToLevel() takes a parameter newLevel, which is an instance of the GameLevel class. The code await newLevel.load(); is used to wait for each level. As it states, the code waits for the player to complete the level before the newLevel. It calls the load() method on the newLevel object to load the game objects for the new level.

GameLoop in GameControl

The gameLoop() function is responsible for updating the game state, checking for level completion, and transitioning between levels as necessary.

gameLoop() {
// Turn game loop off during transitions
	if (!this.inTransition) {
		// Get current level
		GameEnv.update();
		const currentLevel = GameEnv.currentLevel;
		// currentLevel is defined
		if (currentLevel) {
			// run the isComplete callback function
			if (currentLevel.isComplete && currentLevel.isComplete()) {
				const currentIndex = GameEnv.levels.indexOf(currentLevel);
				// next index is in bounds
				if (currentIndex !== -1 && currentIndex + 1 < GameEnv.levels.length) {
					// transition to the next level
					this.transitionToLevel(GameEnv.levels[currentIndex + 1]);
				}
			}
		// currentLevel is null, (ie start or restart game)
		} else {
			// transition to beginning of game
			this.transitionToLevel(GameEnv.levels[0]);
		}
	}
	// recycle gameLoop, aka recursion
	requestAnimationFrame(this.gameLoop.bind(this));
},

Inside this method, the GameEnv.update() method is called which is responsible for drawing each GameObject on the canvas.

static update() {
        // Update game state, including all game objects
        // if statement prevents game from updating upon player death
        if (GameEnv.player === null || GameEnv.player.state.isDying === false) {
            for (const gameObject of GameEnv.gameObjects) {
                gameObject.update();
                gameObject.serialize();
                gameObject.draw();
            } 
        }
    }

Below is a breakdown of the GameLoop:

  1. Turn off game loop during transitions: -If the game is currently in transition, the game loop is turned off temporarily.
  2. Get the current level from the GameEnv module.

  3. The GameEnv.update() method is called to update the game environment.

  4. Check if the current level is complete: -If the current level is defined and has an isComplete() callback function, this function is called.
  5. Transition to the next level if the current level is complete: -If the current level is complete, and there is a next level in the GameEnv.levels array, the game transitions to the next level using the transitionToLevel() method stated earlier.
  6. Transition to the beginning of the game if the current level is null: -If the current level is null, indicating the game is either starting or restarting, the game transitions to the beginning of the game by loading the first level from the GameEnv.levels array.
  7. Continue looping the game: -The function schedules itself to run again by calling requestAnimationFrame(this.gameLoop.bind(this)). This runs the game at a constant frame rate determined by the browser’s rendering capabilities.

DrawIO

CollegeBlogArticulation