• Utilizing Single Responsibility Principle
  • Game Control Code
  • Implementing and Experimenting with JavaScript Objects with our game level (Ancient Greece)

    To our best understanding, Javascript objects are literally just objects with different properties. In our case, the game has many objects including the floor, the background, the player, the enemies, the blocks, and much more! These objects also have their own properties.

    { name: 'sandstone', id: 'jumpPlatform', class: BlockPlatform, data: this.assets.platforms.sandstone, xPercentage: 0.6, yPercentage: 0.34 },
    

    In the above code here, we can see the object with its own properties:

    • *name: The name property sets a name for the object. In this case, it’s set to ‘sandstone’.
    • id: The id property specifies an identifier for the object. Here, it’s set to ‘jumpPlatform’.
    • class: The class property likely the class or “type” of the object. In this case, it’s set to ‘BlockPlatform’.
    • data: The data property holds some data associated with the object.
    • xPercentage: This property represents the horizontal position of the object as a percentage of the screen width/container. Here, it’s set to 0.6, indicating 40% off from the right side, or 60% off from the left side.
    • yPercentage: Similarly to x-percentage, this property represents the vertical position of the object as a percentage of the screen width/container.It’s set to 0.34, indicating 34%. This means that the object is placed at 66% off from the bottom of the screen, or 34% off from the top of the screen.
    // Define an object
    let myObject = {
        name: "Team 1",
        memberCount: 6,
        class: "CSSE2",
        memberNames: ["Anvay", "Yash", "Mihir", "Tianbin", "Quinn", "Lily"],
        classroomLocation: {
          location: "Del Norte High School",
          building: "A",
          classNumber: "101"
        },
      };
      
      // Function to print all properties of the object
      function printProperties(obj) {
        console.log("Properties of the object:");
        for (let prop in obj) {
          if (typeof obj[prop] === 'object') {
            console.log(`${prop}:`);
            for (let subProp in obj[prop]) {
              console.log(`  ${subProp}: ${obj[prop][subProp]}`);
            }
          } else {
            console.log(`${prop}: ${obj[prop]}`);
          }
        }
      }
      
      // Print all properties of the object
      printProperties(myObject);
      
    

    Utilizing Finite-State Machines (FSM)

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

    Code Breakdown

    1. Switch Statement:
      • The switch statement evaluates this.state.animation to determine the current animation state.
    2. Case ‘idle’:
      case 'idle':
          this.setSpriteAnimation(this.playerData.idle[this.state.direction]);
          break;
      
      • If the current state is ‘idle’, it calls setSpriteAnimation with the idle animation data.
      • this.playerData.idle[this.state.direction] accesses the idle animation data for the current direction (e.g., ‘left’ or ‘right’).
    3. Case ‘walk’:
      case 'walk':
          this.setSpriteAnimation(this.playerData.walk[this.state.direction]);
          break;
      
      • If the current state is ‘walk’, it calls setSpriteAnimation with the walk animation data.
      • this.playerData.walk[this.state.direction] accesses the walk animation data for the current direction.
    4. Case ‘run’:
      case 'run':
          this.setSpriteAnimation(this.playerData.run[this.state.direction]);
          break;
      
      • If the current state is ‘run’, it calls setSpriteAnimation with the run animation data.
      • this.playerData.run[this.state.direction] accesses the run animation data for the current direction.
    5. Case ‘jump’:
      case 'jump':
          this.setSpriteAnimation(this.playerData.jump[this.state.direction]);
          break;
      
      • If the current state is ‘jump’, it calls setSpriteAnimation with the jump animation data.
      • this.playerData.jump[this.state.direction] accesses the jump animation data for the current direction.
    6. Default Case:
      default:
          console.error(`Invalid state: ${this.state.animation}`);
      
      • If the current animation state does not match any of the defined cases (‘idle’, ‘walk’, ‘run’, ‘jump’), it logs an error message to the console indicating an invalid state.

    Utilizing Single Responsibility Principle

    Single Responsiblity Principle is when a method or function has one responibility in code so that debugging and extending classes becomes much easier.

    In our code, heres how we implemented it:

        checkBoundaries(){
            // 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";
                }
            };
        }
    
        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 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;
        }
    
        update() {
            super.update();
    
            this.setAnimation(this.state.animation);
            
            this.checkBoundaries();
    
            this.updateMovement();
    
        }
    

    Before, all of this used to be one function with many responsibilities but now, the reponsibilites are split up so that when we extend the class, we dont have to copy paste all of the code in the update function when we only want to change one small thing.

    Game Control Code

    How do the GameObjects become a GameLevel?

    At the bottom of the definition of all game objects, there is the following line of code

    new  GameLevel({ tag:  "ancient greece",  callback:  this.playerOffScreenCallBack,  objects:  greeceGameObjects });
    
    • GameLevel creation.
      • Observe the “new GameLevel” at the end of this code block
      • ancient greece is the name given to this GameLevel.
      • callback contains code that is used to check the game status from GameControl. If this condition is met, an interrupt is sent to the game, to allow behavior injection into the game level (like stop or reset level).
      • objects contains the association of the GameObjects to the GameLevel
    • This definition calls GameLevel class in the GameLevel.js file.

    Here is the GameLevel class:

    class GameLevel {
        /**
         * Creates a new GameLevel.
         * @param {Object} levelObject - An object containing the properties for the level.
         */
        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.
            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);
        }
    }
    

    The GameLevel class can be broken down as follows:

    The GameLevel class is defined to manage different levels in a game. It takes a single parameter, levelObject, which is an object containing properties related to the level.

    1. this.levelObjects stores the entire levelObject parameter. This will keep all the properties of the level object for future reference.

    2. this.tag extracts the tag property from levelObject, which is a friendly name used to identify the level. The operator (?.) ensures that if levelObject is null or undefined, it won’t cause an error.

    3. this.passive extracts the passive property from levelObject. This boolean value indicates whether the level is passive (non-playable) or active (playable).

    4. this.isComplete extracts the callback property from levelObject. This is expected to be a function that determines if the level is complete. It can include conditions such as all enemies being defeated or the player reaching the end of the screen.

    5. this.gameObjects extracts the objects array from levelObject. This array contains all the game objects associated with this level. If objects is undefined, it defaults to an empty array.

    6. GameEnv.levels.push(this) adds the current instance of GameLevel to the GameEnv.levels array. This allows the game environment to keep track of all levels.

    Additionally, the gameObjects are also added to an empty array in the GameEnv.js file where they are then appended to the game.

    static gameObjects = [];

    The GameLevel file also has an asynchronus load function. The purpouse of this asynchronus function is to load in the the GameObjects while other important tasks are also running. This function has a a try-catch block thats pictured 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);
            }
        }
    } catch (error) {
        console.error('Failed to load one or more GameLevel objects: ' + objFile, error);
    }
    
    • try-catch Block: Game objects are also loaded into the game using a try-catch block. This block is used to handle potential errors during asynchronous operations.

    Lets break it down:

    • for (const obj of this.gameObjects): Iterates over each game object in the array of GameObjects.

    • if (obj.data.file): Checks if the game object has a file associated with it.

      • objFile = obj.data.file: If it does have this file, it assigns the file path to objFile and logs it to the console.

    +++++++++++++++++++++++++

    obj.image = await this.loadImage(obj.data.file);
    
    • await this.loadImage(obj.data.file): Asynchronously loads the image from the specified file path and assigns it to obj.image.

    +++++++++++++++++++++++++

    const canvas = document.createElement("canvas");
    canvas.id = obj.id;
    document.querySelector("#canvasContainer").appendChild(canvas);
    
    • document.createElement(“canvas”): Creates a new canvas element.
    • canvas.id = obj.id: Sets a canvas ID to match the game object’s ID.
    • document.querySelector(“#canvasContainer”).appendChild(canvas): Appends the new canvas to the element with the ID canvasContainer.

    +++++++++++++++++++++++++

    new obj.class(canvas, obj.image, obj.data, obj.xPercentage, obj.yPercentage, obj.name, obj.minPosition);
    
    • new obj.class(…): Creates a new instance of the game object using its class constructor.
      • Parameters:
        • canvas: The newly created canvas element.
        • obj.image: The loaded image.
        • obj.data: The game object data.
        • obj.xPercentage, obj.yPercentage: Position percentages.
        • obj.name: Name of the game object.
        • obj.minPosition: Minimum position (if any).

    +++++++++++++++++++++++++

    } catch (error) {
        console.error('Failed to load one or more GameLevel objects: ' + objFile, error);
    }
    
    • catch (error): Catches any errors that occur during the loading process.
    • console.error(…): Logs an error message along with the current file (objFile) and the error details.

    An example of our unique GameSetup collection of JavaScript Objects

    // Greece Game Level definition...
    const greeceGameObjects = [
    // GameObject(s), the order is important to z-index...
    { name: 'greece', id: 'background', class: Background, data: this.assets.backgrounds.greece },
    { name: 'grass', id: 'platform', class: Platform, data: this.assets.platforms.grass },
    
    **//all the sandstones go here but we removed them to make the code easier to read**
    
    { name: 'cerberus', id: 'cerberus', class: Cerberus, data: this.assets.enemies.cerberus, xPercentage: 0.2, minPosition: 0.09, difficulties: ["normal", "hard", "impossible"] },
    { name: 'cerberus', id: 'cerberus', class: Cerberus, data: this.assets.enemies.cerberus, xPercentage: 0.2, minPosition: 0.09, difficulties: ["normal", "hard", "impossible"] },
    { name: 'cerberus', id: 'cerberus', class: Cerberus, data: this.assets.enemies.cerberus, xPercentage: 0.5, minPosition: 0.3, difficulties: ["normal", "hard", "impossible"] },
    { name: 'cerberus', id: 'cerberus', class: Cerberus, data: this.assets.enemies.cerberus, xPercentage: 0.7, minPosition: 0.1, difficulties: ["normal", "hard", "impossible"] },//this special name is used for random event 2 to make sure that only one of the Goombas ends the random event
    { name: 'dragon', id: 'dragon', class: Dragon, data: this.assets.enemies.dragon, xPercentage: 0.5, minPosition: 0.05 },
    { name: 'knight', id: 'player', class: PlayerGreece, data: this.assets.players.knight },
    { name: 'flyingIsland', id: 'flyingIsland', class: FlyingIsland, data: this.assets.platforms.island, xPercentage: 0.82, yPercentage: 0.55 },
    { name: 'tubeU', id: 'minifinishline', class: FinishLine, data: this.assets.obstacles.tubeU, xPercentage: 0.66, yPercentage: 0.71 },
    { name: 'flag', id: 'finishline', class: FinishLine, data: this.assets.obstacles.flag, xPercentage: 0.875, yPercentage: 0.21 },
    { name: 'flyingIsland', id: 'flyingIsland', class: FlyingIsland, data: this.assets.platforms.island, xPercentage: 0.82, yPercentage: 0.55 },
    { name: 'hillsEnd', id: 'background', class: BackgroundTransitions, data: this.assets.transitions.hillsEnd },
    { name: 'lava', id: 'lava', class: Lava, data: this.assets.platforms.lava, xPercentage: 0, yPercentage: 1 },
    { name: 'lava', id: 'lava', class: Lava, data: this.assets.platforms.lava, xPercentage: 0, yPercentage: 1 },
    ];
    
    // Greece Game Level added to the GameEnv ...
    new GameLevel({ tag: "ancient greece", callback: this.playerOffScreenCallBack, objects: greeceGameObjects });
    

    GameEnv Array Of Levels

    As explained earlier, GameEnv.levels.push(this) adds the current instance of GameLevel to the GameEnv.levels array. This allows the game environment to keep track of all levels. This happens in the GameLevel.js file.

    The array is placed in GameEnv.js as shown:

    static  levels  = [];
    

    This helps with how transitions were previously coded, using array index.

    Transitioning to the next level

    Below is the game control function that transitions to the next level. It has been split into pieces to perform a deeper code analysis.

    async transitionToLevel(newLevel) {
        this.inTransition = true;
    
    • This creates an asynchronous function called transitionToLevel
    • It takes thea parameter of newLevel, which is an object that is created in GameControl.js
    • If the game is transitioning (which it is, currently), this.inTransition is set to true. Once the transition is completed, this.inTransition is set to false.

    +++++++++++++++++++++++++

        GameEnv.destroy();
    
    • In GameEnv.js, a method called destroy is created. It works by emptying the gameObjects array in reverse order, in order to fully clear out gameObjects. Shown below.
        static destroy() {
            for (var i = GameEnv.gameObjects.length - 1; i >= 0; i--) {
                const gameObject = GameEnv.gameObjects[i];
                gameObject.destroy();
            }
            GameEnv.gameObjects = [];
        }
      
    • Using the destroy method during transition is necessary, as eventually, the gameObjects array is going to be repopulated with more objects. At the same time, we also don’t want objects from previous levels overflowing.

    +++++++++++++++++++++++++

        if (GameEnv.currentLevel !== newLevel) {
            GameEnv.claimedCoinIds = [];
        }
        await newLevel.load();
        GameEnv.currentLevel = newLevel;
    
    • First, an if statement is implemented to ensure that the current level is not the new level. If this is true, the array of the claimedCoinIds is cleared.
    • Then, all of the game objects in the newlevel are loaded in, using newLevel.load()
    • Lastly, the currentLevel value is set to the newLevel value

    +++++++++++++++++++++++++

        GameEnv.setInvert();
    
    • Now, the invert property is updated using GameEnv.setInvert()
    • This cross-checks the local storage key to ensure that the isInverted value is what the user has set it to be

    +++++++++++++++++++++++++

        window.dispatchEvent(new Event('resize'));
        this.inTransition = false;
    },
    
    • Lastly, the dimensions of the game environment are updated, using the resize event. Then, the inTransition value is set to false.

    This function is activated in the game loop. First, if the state is set to in transition, the game loop becomes deactivated (stopped). Then, the assigned index value of the current level is acquired. After doing this, 1 is added to that value, and then the game transitions to the next level accordingly.

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

    How does GameLoop call GameEnv method to update and draw objects

    Now that it’s clear how transitions work in the GameLoop, it’s important to identify that GameEnv update function is called to update and draw each GameObject. This code in the GameControl file is responsible for this:

    GameEnv.update();

    Now what this does is that it actually runs quite a complicated tree of functions. Here’s how the structure is:

    • The GameEnv.update(); function is called in GameControl. This runs the GameEnv update function.
    • This is the update function from GameEnv

      javascript static update() { if (GameEnv.player === null || GameEnv.player.state.isDying === false) { for (const gameObject of GameEnv.gameObjects) { gameObject.update(); gameObject.serialize(); gameObject.draw(); } } }

    • This static update function runs an if statement that checks if the player is null or the player is not dying. If either of these are true it will continue
    • For every gameObjects in the array of the gameObjects, it will run the object’s own update, serialize, and draw function.