Game Features 3

Polymorphism, Trees, and Testing


Overview

Game Features 3 Overview


Game Features 3 assesses Polymorphism, Trees, and Testing. You will continue to build functionality for the Game Engine. This will be done on top of the code written for the previous tasks; you should not reclone or remove said code for this task or any future Game Features tasks.

This document is split into three sections for convenience. The first two of these sections will have a graded testing component. It is highly suggested you complete this testing component first to guide your implementation. You should not expect much, if any partial credit for buggy implementations. The third section does not explicitly require testing. You are nonetheless expected to thoroughly test it.

You'll need to create a class named TestTask3 in your tests package for this assignment. We have provided some tests for the various Decision and Collectible subclasses, as well as the LevelParser updates. They can be accessed at this link: TestTask3.java. You can open this link in a new tab to copy and paste the content into your project, or right click and select "save link as", then download the file to the correct location within the project.

Inventory

Overview


In this section, you'll be testing and implementing an inventory system and collectible items via polymorphism. You will be given a specification for this inventory system that precisely defines the behavior of it, which should guide your development of tests.

For this portion, you will be writing code in the following classes which already exist:

  • Collectible, located in the app.gameengine.model.gameobjects package.
  • MagicPickup, AxePickup, and PotionPickup, located in the app.games.commonobjects package.
  • Player, located in the app.gameengine.model.gameobjects package.
  • LevelParser, located in the app.gameengine package.

You will also be writing code in the TestTask3 class as described in the "Game Features 3 Overview"



Specification


Read through this entire specification before you begin your implementation. You should set up the Collectible class first by stubbing out (i.e. creating methods that don't yet function) the specified methods in the Player class (some of them do not exist yet), write tests for said methods in the Player class, then finally implement them.

Alongside your unit tests, you can test the functionality of your implementation in the sample game.

  • Collectible: Navigate to the app.gameengine.model.gameobjects.Collectible class, which is an abstract class representing an object that can be picked up and used by the player. Currently this class contains very little, but you will add extra functionality by adding methods and extending this class.
    • The constructor takes four parameters, two of which are passed to the super constructor. Store the remaining parameters in instance variables.
    • Create a method named getItemID (note the capital "ID"), which is a getter for the itemID instance variable. It should take no parameters and return a String.
    • Create an abstract method named use, which takes a Level as a parameter and returns void. This represents what occurs when the Collectible is used in game by the player.
    • Override the collideWithDynamicObject method, which takes one DynamicGameObject parameter and returns void. If the dynamic object is the player, add this object to the player's inventory, and destroy this object.
      • Remember that you can tell if any kind of GameObject is a Player object by calling the isPlayer method on it.
      • You should add the object to the player's inventory with the addInventoryItem method. This method likely does not exist yet. Read ahead to see the specification for that method.
      • Since this method exists in the Player class instead of the DynamicGameObject class, you cannot call it on the parameter. Instead, call getPlayer on the game that you stored as an instance variable.
      • You should destroy the object by calling the destroy method on it. This will remove it from the level, so that it is not rendered on the screen anymore and will not continue colliding with the player.
      • If the object is not the player, this method should do nothing.
  • AxePickup: navigate to the app.games.commonobjects.AxePickup class, which is a pickup that should allow the player to throw axe projectiles at enemies. Notice that this class extends Collectible, and that the name passed to the super constructor is "Axe". Add the following functionality.
    • Create an instance variable of type Timer. Use the app.gameengine.utils.Timer class, not one of the built-in Java timers. In the constructor, assign this instance variable to a new Timer with a cooldown of 0.25.
      • The Timer class allows objects to limit how frequently they can use used. In this case, you will use the timer to limit how frequently axes can be thrown. You should look through the methods in this class, but do not need to fully understand them.
    • Create a getter for the Timer instance variable called getTimer, which takes no parameters and returns a Timer.
    • Override and implement the use method from the Collectible class. If the cooldown period on the timer is up, this method should make the player fire an axe projectile.
      • To check if the timer cooldown has expired, use the Timer.check method on your instance variable. You should read the Javadoc comment to understand how to use this method. Note that you do not care how many times the timer has overflown, just that it has.
      • To fire a projectile, use the Level parameter and call getPlayer to get the Player object. On this Player, call fireProjectile, which takes three arguments. The first argument is the projectile to fire, which should be a new PlayerAxeProjectile object. The location of this projectile does not matter, as it will be set within the fireProjectile method. The second argument is the speed the projectile will be fired at, which should be set to exactly 5. The third argument should be the Level parameter.
    • Override the update method, which has parameters of type double and Level, representing the time since the last update and the current level, respectively, and returns void.
      • Call the super update method, passing the parameters as arguments.
      • Call Timer.advance on the Timer instance variable, passing in the double parameter dt. This will ensure that the cooldown actually decreases and the pickup can be used.
  • MagicPickup: Navigate to the app.games.commonobjects.MagicPickup class, which is a pickup that should allow the player to fire magic projectiles at enemies. Notice that this class extends Collectible, and that the name passed to the super constructor is "Magic". You should create the same instance variables and methods as in the AxePickup class. The only difference should be that in the use method, when calling fireProjectile, you should pass in a PlayerMagicProjectile object, and a speed of 10.
  • PotionPickup: Navigate to the app.games.commonobjects.PotionPickup class, which is a pickup that should allow the player to regain some health when used. Notice that this class extends Collectible, and that the name passed to the super constructor is "Health Potion". Add the following functionality.
    • Create an instance variable of type int, and in the constructor, assign this instance variable to the constructor parameter "heal". This is the amount that the potion will heal the player by.
    • Create a getter for for this instance variable called getHealAmount, which takes no parameters and returns an int.
    • Override and implement the use method from the Collectible class. Increase the health of the player by the heal instance variable, and remove the potion from the player's inventory.
      • To get the player, you can call the getPlayer method on the Level parameter.
      • To access and modify the health of the player, you can use the getHP and setHP methods respectively.
      • To remove the item from the player's inventory, you should call the removeActiveItem method. This method likely does not exist yet. Read ahead to see the specification for that method.
    • You do not need to override the update method, as this class does not possess a timer to increment.

Now you will add methods for using and manipulating the player's inventory. In the app.gameengine.model.gameobjects package, make the following additions to the Player class to implement a functioning inventory system. Some of these methods will already exist, and you should just modify them. Others you will have to create.

Note that the specification will not include specific implementation details for the following methods. For instance, it does not state an underlying data structure to use. You'll need to determine these details on your own. Any solution that satisfies the required functionality of the inventory is acceptable.

  • addInventoryItem: Create a method named addInventoryItem that takes in a Collectible and returns void. This method should add the object from the parameter to the Player's inventory.
    • Adding an item should never change the currently active item, except for when you pick up your first item
    • This new object will be considered the "last" item in the list.
  • removeActiveItem: Add a method named removeActiveItem which takes no parameters and returns void. This method should remove whatever the currently active item is from the Player's inventory.
    • Once the currently active item is removed, the currently active item should become whatever was next in the inventory. For example, if you collected an Axe, then Magic, then a Health Potion, the currently active item would be the Axe. If this method is called, the remaining items are the Magic and the Health Potion, and the active item would be the Magic.
    • If the item removed is the last item in the inventory (ie. the one most recently added), the active item would become the first item in the inventory (ie. the one least recently added). Be sure to account for this looping behavior in your code.
  • getActiveItem: Add a method named getActiveItem that takes no parameters and returns a Collectible. This method should return whichever Collectible in the inventory is active.
    • If the inventory is empty, this method should return null.
    • If an item is collected and the inventory was previously empty, that item should be the active item.
  • getActiveItemID: Add a method called getActiveItemID (note the capital "ID") that takes in no parameters and returns a String.
    • This method should return the ID of the active Collectible from the inventory, by calling the Collectible.getItemID method on it.
    • If the inventory is empty, this method should return the exact String "No item equipped".
  • cycleInventory: Add a method called cycleInventory that takes no parameters and returns void. This method will cycle through the inventory, changing the item currently marked as active.
    • This should be done in the order the objects are collected. For example, if a player collected an Axe, then Magic, then a Potion, the first call to cycleInventory will change the active item from the Axe to Magic. The second call to cycleInventory will change the active item from Magic to the Potion.
    • Once cycleInventory is called while the most recently collected item is active, the active item should be set back to the front of the inventory, to the least recently collected item. Thus in our previous example, the third call to cycleInventory will change the active item from the Potion to the Axe.
  • clearInventory: Add a method called clearInventory that takes no parameters and returns void. This method will reset the inventory to its initial state, which should be empty.
  • update: The update method already exists in this class. Add code which iterates over every item in the inventory and calls the update method on it, passing in both parameters.
    • The order in which these objects are updated does not matter, as long as each object is updated once.
  • The inventory should initially be empty.

You also need to implement the ability to use objects, which will occur in the app.gameengine.Level class.

  • actionButtonPressed: In the Level class, implement the actionButtonPressed method. The signature for this method already exists, you should simply add functionality to it.
    • This method should call the getActiveItem method on the level's player to access the currently equipped Collectible. If there is an item equipped (ie. it is not null), this method should call the use method on that item, passing in this as the argument.
    • You can access the Player for a level by calling the getPlayer method.
  • You can cycle your character's inventory in-game with the tab key. Pressing the space bar will call the Level.actionButtonPressed method, allowing you to use the active item to fire projectiles or use potions.

The final features you will add are modifications to the LevelParser class so that the kinds of collectibles can be parsed. The sample game already contains an AxePickup and a MagicPickup in a couple of the levels. If you want to use the PotionPickup in game, you can add it to one of the levels. An example of this would be adding this line to one of the level csv files: "StaticGameObject,PotionPickup,13,13,50".

You can also create levels with the level editor which contain these objects.

  • readStaticObject: Modify this method so that it can recognize and return the following objects.
    • Add a case for AxePickup objects. If the string being checked is "AxePickup", a new AxePickup object should be returned. The AxePickup constructor takes two doubles and a Game. The doubles should be the variables x and y, and the Game should be the parameter game, all of which already exist in this method.
    • Add a case for MagicPickup objects. If the string being checked is "MagicPickup", a new MagicPickup object should be returned. The MagicPickup constructor takes two doubles and a Game. The doubles should be the variables x and y, and the Game should be the parameter game, all of which already exist in this method.
    • Add a case for PotionPickup objects. If the string being checked is "PotionPickup", a new PotionPickup object should be returned. The PotionPickup constructor takes two doubles, an int, and a Game. The doubles should be the variables x and y, and the Game should be the parameter game, all of which already exist in this method. The int should be the amount to heal, which is the fifth value in the line. Refer to the example above, where the amount to heal would be 50.


Testing Requirements


As previously stated, you are given some tests for the Collectible subclasses and LevelParser updates. You are NOT given tests for the Player methods from the specification. For this portion of the task, you will be writing tests for these methods.

Autolab will run your code against "incorrect solutions" to determine if you wrote tests for a given feature. If your tests can catch all the bugs the "incorrect solutions" contain for this feature, you'll earn points for successfully testing this feature. In total there will be 20 points available for testing. Since these tests are not being ran on your own solution, you can (and are encouraged to!) submit to Autolab before you complete your implementation to check your tests and earn points for feature coverage.

Note that to earn any points for testing, all of your tests must work on a correct solution. That is, your tests must be testing the behavior correctly and follow the specification. Additionally, while you can make whatever helper methods you want in your code to aid your implementation, the tests can only contain methods that were given to you in the handout code or methods that were defined in a handout and completed as part of a task. If you include something you shouldn't, you won't be able to earn any points for testing as the grader won't be able to run the tests.

With that in mind, your objective is to write JUnit tests for the following methods from the specification. These tests should go in the TestTask3 class and should be annotated with @Test.

Note that these are NOT static methods. The class name is included for identification.

  • Player.addInventoryItem and Player.cycleInventory
    • Write tests to verify the behavior of these two methods, i.e. that repeatedly calling cycleInventory will go through every added Collectible and warp around to the beginning.
  • Player.removeActiveItem
    • Write tests to ensure that calling removeActiveItem properly removes the active item from the inventory and sets the new active item.
  • Since there's no specified method to access the entire inventory, you should verify that the inventory is being affected as expected by verifying the active item is changing as expected per these methods' specifications. You only need to check that the active item's ID matches the expected ID.


Programming Requirements


Implement the methods from the specification. You should complete the Testing Requirements before or during your implementation to aid you.

In total, your implementation will be worth 20 points. You may need to have several components of the specification fully functional before you earn any points for a given feature.



Autolab Feedback


Autolab feedback will be given in several phases. These phases dictate the order you are expected to complete this assignment. Although you can still earn points from completing these phases out of order, you are nonetheless expected to have the previous phases completed before trying to fix a later one.

  1. Running your tests on a correct solution: Your tests will be run on a correct solution to this assignment. If any of your tests crash or fail, you will earn 0 points for testing. If a test fails here but passes your implementation locally, it's likely you have a bug both in your code and in your test.
  2. Checking your tests for feature coverage: Your tests will be checked for feature coverage by determining if it can successfully catch bugs associated with incorrect solutions. For each feature your tests covered, you will earn some amount of points. Passing this phase doesn't mean your testing is fully comprehensive, but can be considered the minimum for effective testing. If you failed the previous phase, this phase will not run and you won't get any points for testing.
  3. Running my tests on your solution: Your code will be run against our tests. Once you've passed all the tests associated with some part of the specification, you'll earn some points for a correct implementation. If any feature for that part fails our tests, you won't earn any points for this part. If that happens, you should write more tests to catch that bug and fix it in your implementation.
Decision Trees

Overview


The content of this portion is primarily used in the Game Engine by Pacman. To play Pacman, during and after completing this portion, navigate to the class app.Configuration and change the GAME constant to "pacman". As the size of the Pacman level is quite large, you should also set the value of ZOOM to 2.0 or 1.0 (it can technically be non-whole, but not without serious graphical bugs).

It may look like there are a lot of classes to create and a lot of code to write for this task, but don't let this intimiate you. While that is true, most of the individual pieces are not overly complex, and have a lot of similarity between them. The amount of unique code you must write, and problems you must solve, is roughly in line with earlier tasks.

For this portion, you will be writing code in the following classes which already exist:

  • Agent, located in the app.gameengine.model.gameobjects package.
  • Ghost, located in the app.games.pacman package.

Your tests for this portion should also go in TestTask3. As stated before, you are given some tests for the Decision subclasses. These tests are not overly thorough, but should validate some of the basic behavior of the features that they test, and let you know if you are on the right track.

You will also have to create many additional classes, which will be specified in the following instructions.



Specification


As with the previous part, you should read through all the following sections before you begin to implement the methods from the specification. Note that you can receive feedback for your tests before your implementation is complete, however, you must at least create every class/method from this specification to receive feedback. You can "stub out" these methods by having them always return a fixed value, but they must exist so the grading code, and your tests, can compile and run.

Some of the methods in this task will make use of the BinaryTreeNode class, found in the app.gameengine.model.datastructures package. You are required to use this class. It is identical to the BinaryTreeNode class used in the Problem Set. Note that this is not a BST.

For this part, you will implement the following functionality:

    Create the package app.gameengine.model.ai. Remember that a package is just a folder containing Java files. This package will contain a number of files that allow the ghosts in Pacman to have dynamic behavior depending on the state of the game at any given time.

    You will be using a decision tree to determine behavior, which will be represented as a binary tree of Decision objects. These decisions will determine either which action to take, depending on the game state, or perform an action, like movement.

  • Decision: In the app.gameengine.model.ai package, create an abstract class named Decision. This represents a node in the decision tree that will either make a decision or take an action.
    • Create a constructor which takes in an Agent and a String as parameters, and store them as instance variables. These represent the Agent whose behavior is being is being controlled by this Decision, and the name of this node.
    • Create methods named getName and setName, which are a getter and setter for the String name from the constructor. The purpose of the name is primarly for testing, and for differentiating nodes of the same type within a single tree.
    • Create a method named getAgent which is a getter for the Agent from the constructor. You do not need to create a setter for this instance variable.
    • Create an abstract method named decide which returns a boolean and takes in a double and a Level, representing the time since the last call and the current level, respectively.
      • As this method is abstract, it has no implementation. In child classes, this method will use various states of the game, level, or agent to determine which action to take.
    • Create an abstract method named doAction which returns void and takes in a double and a Level, representing the time since the last call and the current level, respectively.
      • As this method is abstract, it has no implementation. In child classes, this method will take various actions to modify the state of the Agent to achieve the desired behavior. This method will only be called if it is determined that this is the action that should be taken, according to the rest of the decision tree.
  • DecisionTree: In the app.gameengine.model.ai package, create a class named DecisionTree. This class will possess a binary tree of Decision objects, to determine what behavior an Agent should take, and accordingly take that action.
    • Create a constructor that takes in a BinaryTreeNode of Decisions, and store it as an instance variable.
    • Create methods named getTree and setTree, which are a getter and setter for the BinaryTreeNode from the constructor.
    • Create a method named traverse that returns a Decision and takes a BinaryTreeNode of Decisions, a double, and a Level as parameters. The double represents the amount of time passed since the last update, and the Level is the current level.
      • This method will traverse through the tree in the parameter (not the tree in your instance variable), making decisions to go left or right by calling decide on the Decision stored in each tree node. A value of true indicates that you should "travel" to the right child node, and a value of false means travel to the left child node.
      • When a leaf node is reached (a node where both left and right children are null), the Decision value at that node should be returned. Keep in mind that the node in the parameter itself might be a leaf node.
      • When calling decide, you should pass in the double and Level from the parameters as arguments.
      • If a leaf node cannot be reached, this method should return null. This can happen if the tree is empty, or if the tree has a node with only one child. In the case of a node with only one child, the method should return null if that node's decision tells it to traverse in the opposite direction of that child. For example, if a tree has only a left node, it is not a leaf, but if the decide method returns true, you cannot traverse to the right, and should return null. This is useful for handling poor tree structures, or having a state that performs no action.
    • Create a second, overloaded traverse method that returns void and takes a double and a Level, which represent the amount of time passed since the last update and the current level, respectively.
      • This method will call the first traverse method, passing the root of the tree (the instance variable) as the first argument, and the parameters as the remaining arguments. Then call doAction on the returned Decision, again using the parameters of this method as the arguments.
      • If the returned Decision is null, this method will do nothing.

Now you will create subclasses of the Decision class which implement behavior for the ghosts in Pacman. You should look at the app.gameengine.utils.PacmanUtils class, as you will need to use these methods within your Decision subclasses. You do not need to understand all of this code, and the specific methods to use will be mentioned each time.

Though you do not need to understand the implementation of these methods, you should understand what they do. They each include a Javadoc comment that explains the purpose of the method, the parameters, and the return value. In IntelliJ, you can hover over the name of a method to read the Javadocs.

All of these classes will exist in the app.gameengine.model.ai.pacman package, which you should create.

Remember that the Decision class has two main functionalities, the decide and doAction methods. For some subclasses, only one of these will be implemented, and such classes will only make sense in certain parts of the tree. For example, a subclass which stubs out the decide method and actually implements the doAction method would only make sense as a leaf node.

Other decisions and actions are so closely related that it makes sense to include them within the same class. These classes could be placed anywhere within the tree, and in fact there will likely be duplicates, where one is used as the decision, and the other is used as the action.

  • IsActive: In the app.gameengine.model.ai.pacman package, create a class named IsActive which extends Decision. This class will represent a decision on whether a ghost is in an active state, which is either chasing the player or retreating to one corner of the level (scattering).
    • Create a constructor which takes in a Ghost and a String, which are passed to the super constructor as the Agent and name. This is possible due to polymorphism, since Ghost is a subclass of Agent. You should also store the ghost as an instance variable.
    • Override the decide method, which should return true if the Ghost from the constructor is in a chasing or scattering state, and false otherwise.
      • You can call the getState method on a Ghost to access its state. This method returns a String representing which of several states the ghost is in.
      • If the state is exactly "Chase" or "Scatter", then the ghost is considered to be in an active state, and this method should return true.
    • Override the doAction method to do nothing.
  • Idle: In the app.gameengine.model.ai.pacman package, create a class named Idle which extends Decision. This class will represent an action of bouncing up and down while the ghost is at its home location in the center of the level.
    • Create a constructor which takes in a Ghost and a String, which are passed to the super constructor as the Agent and name. You should also store the ghost as an instance variable.
    • Override the decide method to always return false.
    • Override the doAction method to do the following: If the magnitude of the ghost's velocity is greater than 0, this method should do nothing. Otherwise, you should negate both the x and y components of the ghost's orientation. Afterwards, you should call the followPath method on the Ghost object, passing in the double from the parameter.
      • You can calculate the magnitude of a vector as the square root of the sum of the squares of the x and y components. It is equivalent to the euclidean distance between the vector and (0, 0). You can (and should) use the magnitude method within the Vector2D class.
      • You should modify the ghost's orientation using the setOrientation method. Although there exists a negate method in the Vector2D class, which negates both the x and y components of a Vector2D, the setOrientation method is needed to update the sprite of the ghost.
      • This followPath method is the same as the one from Game Features 2, but is overridden by the Ghost class. So, even if you did not complete that method, this will still function as intended.
  • Chase: In the app.gameengine.model.ai.pacman package, create a class named Chase which extends Decision. This class will represent both a decision of whether a ghost is actively chasing the player, as well as the action of chasing.
    • Create a constructor which takes in a Ghost, a PacmanGame, and a String. You should pass the Ghost and String to the super constructor, and store the Ghost and PacmanGame as instance variables.
    • You should create an additional Vector2D instance variable to store the last whole-numbered location of the ghost. The initial value of this vector should be null. This will be used by several of the utilities provided, namely to ensure that the ghost does turn 180 degrees, and only changes directions when fully aligned to a tile.
    • Override the decide method to return whether the ghost is in the chasing state.
      • You can call the getState method on a Ghost to access its state. This method returns a String representing which of several states the ghost is in.
      • If the state is exactly "Chase", then the ghost is chasing the player, and this method should return true.
    • Override the doAction method. This method should use the methods in the PacmanUtils class to do the following sequence of actions. Only take these actions if the PacmanUtils.canAct method returns true, passing in the Ghost and Vector2D instance variables and the double parameter.
      1. Find the tile that the ghost is targeting by calling the PacmanUtils.getChaseTarget method. Note that you must pass in a PacmanLevel, so you cannot use the Level parameter. Instead you should call getCurrentLevel on the PacmanGame instance variable.
      2. Get the valid directions of movement for the ghost by calling the PacmanUtils.getValidDirs method, again with the game's current level and the ghost.
      3. Find the actual direction of movement by calling the PacmanUtils.getBestDirection method, passing in the valid directions, the location of the ghost, and the target Vector2D.
      4. The Vector2D returned by getBestDirection may be null. If so, skip this step. If it is not null, set the orientation of the ghost to the x and y components of that best direction. Again, you should use the setOrientation method to do so.
      5. Set your Vector2D instance variable equal to the rounded value of the ghost's location. You may call Math.round on the individual components, or use the Vector2D.round method.
      Finally, call the followPath method on the ghost object, passing in the double parameter. You should do this even if the canAct method returns false.
  • Scatter: In the app.gameengine.model.ai.pacman package, create a class named Scatter which extends Decision. This class will represent the action of scattering, which is when the ghosts retreat to the corners of the level to give the player some breathing room.
    • Create a constructor which takes in a Ghost, a PacmanGame, and a String. You should pass the Ghost and String to the super constructor, and store the Ghost and PacmanGame as instance variables.
    • You should create an additional Vector2D instance variable to store the last whole-numbered location of the ghost. The initial value of this vector should be null. This will be used by several of the utilities provided, namely to ensure that the ghost does turn 180 degrees, and only changes directions when fully aligned to a tile.
    • Override the decide method to always return false.
    • Override the doAction method. The behavior of this method should be identical to that of the Chase class, except using the PacmanUtils.getScatterTarget method instead of the PacmanUtils.getChaseTarget method.
  • Dead: In the app.gameengine.model.ai.pacman package, create a class named Dead which extends Decision. This class will represent the decision of whether a ghost has been eaten and should return to the spawn area, as well as the action of returning home.
    • Create a constructor which takes in a Ghost, a PacmanGame, and a String. You should pass the Ghost and String to the super constructor, and store the Ghost and PacmanGame as instance variables.
    • You should create an additional Vector2D instance variable to store the last whole-numbered location of the ghost. The initial value of this vector should be null. This will be used by several of the utilities provided, namely to ensure that the ghost does turn 180 degrees, and only changes directions when fully aligned to a tile.
    • Override the decide method to return whether the ghost is in the dead state.
      • You can call the getState method on a Ghost to access its state. This method returns a String representing which of several states the ghost is in.
      • If the state is exactly "Dead", then the ghost is dead, and this method should return true.
    • Override the doAction method. The behavior of this method should be identical to that of the Chase class, except using the PacmanUtils.getHomeTarget method instead of the PacmanUtils.getChaseTarget method. Note that this method only takes one argument, and does not need the ghost itself to be passed in.
  • Flee: In the app.gameengine.model.ai.pacman package, create a class named Flee which extends Decision. This class will represent the decision of whether a ghost is fleeing from the player, which happens when a power pellet has been eaten, as well as the action of running from the player, which involves moving in a random direction at each intersection.
    • Create a constructor which takes in a Ghost, a PacmanGame, and a String. You should pass the Ghost and String to the super constructor, and store the Ghost and PacmanGame as instance variables.
    • You should create an additional Vector2D instance variable to store the last whole-numbered location of the ghost. The initial value of this vector should be null. This will be used by several of the utilities provided, namely to ensure that the ghost does turn 180 degrees, and only changes directions when fully aligned to a tile.
    • Override the decide method to return whether the ghost is in the frightened state.
      • You can call the getState method on a Ghost to access its state. This method returns a String representing which of several states the ghost is in.
      • If the state is exactly "Frightened", then the ghost is fleeing, and this method should return true.
    • Override the doAction method. The behavior of this method is similar to but not exactly the same as that of the Chase class. Take the following actions if the PacmanUtils.canAct method returns true, passing in the Ghost and Vector2D instance variables and the double parameter.
      1. Get the valid directions of movement for the ghost by calling the PacmanUtils.getValidDirs method, again with the game's current level and the ghost.
      2. Find the actual direction of movement by calling the PacmanUtils.getRandomDirection method, passing in the valid directions.
      3. The Vector2D returned by getRandomDirection may be null. If so, skip this step. If it is not null, set the orientation of the ghost to the x and y components of that random direction. Again, you should use the setOrientation method to do so.
      4. Set your Vector2D instance variable equal to the rounded value of the ghost's location. You may call Math.round on the individual components, or use the Vector2D.round method.
      Finally, call the followPath method on the ghost object, passing in the double parameter. You should do this even if the canAct method returns false.

    Now that you have created the decision tree and the many decisions it will use, you can implement it into Pacman. There are a couple of classes you need to modify and add to in order to achieve this.
  • Agent: Navigate the to app.gameengine.model.gameobjects.Agent class. Each Agent will possess a DecisionTree to determine and follow their behavior.
    • Create an instance variable of type DecisionTree within this class. It will initially be null.
    • Create a getter and setter for this DecisionTree, named getDecisionTree and setDecisionTree respectively.
    • Update the update method.
      • After this method calls the super update method, check if the DecisionTree instance variable is not null. If it isn't null, call traverse on it. You should call the version of the method which does not take in a BinaryTreeNode. If the DecisionTree is null, you do not need to do anything.
      • This code can go before or after the code that handles showing paths and animations.
  • Ghost: Navigate to the app.games.pacman.Ghost class. You will modify the constructor to give ghosts a decision tree matching classic pacman behavior. You can add this anywhere in the constructor.
    • Recall that Ghost extends Enemy, which extends Agent, so Ghost has access to all of the methods in the Agent class.
    • The structure of this decision tree is as follows. The root is an IsActive node.
      • If the ghost is active (to the right), there is a Chase node, acting as a decision; if the ghost is in the chase state (to the right), there is another Chase node, this time acting as an action; if the ghost is not in the chase state (to the left), there is a Scatter node.
      • If the ghost is not active (to the left), there is a Dead node, acting as a decision; if the ghost is dead, there is another Dead node, this time acting as an action; if the ghost is not dead, there is a Flee node, acting as a decision; from here, if the ghost is fleeing, there is another Flee node, this time acting as an action; if the ghost is not fleeing, there is an Idle node.
      • The names of these decisions do not matter.
    • Refer to this image for a visual of the correct DecisionTree structure:
      visual depiction of the pacman ghost decision tree

You should now be able to play Pac-Man and see the ghosts react dynamically to your actions. Every so often, on a timer, all of the ghosts will retreat (scatter) to the corners. If you eat a power pellet (the big ones), they will turn blue and start moving randomly. If you eat them they will return home, respawn, and begin chasing you again.

You may notice ghosts occasionally getting stuck on level geometry when moving around corners. That is an unfortunate amount of jankiness within the game engine at the moment, and does not signify any issues with your implementation.



Testing Utility


In the tests package, create a class called TestDecision, which extends the Decision class. This class will be used to help you test by simplifying the behavior of the decide and doAction methods. Specifically, we want the behavior of these methods to not depend on the actual state of the game, so that you can test the DecisionTree methods without setting complex properties of the game, player, and ghosts.

The constructor should take an Agent, a String, and a boolean. The first two parameters can be passed to the super constuctor, and the last should be stored as an instance variable. This boolean will be the value returned by decide, so it will decide which direction the traversal progresses.

You should create another boolean instance variable representing whether the TestDecision has been used. It should initially be false.

Create a method called isUsed, which takes no parameters and returns a boolean. It should return the instance variable described above.

Override the decide method to return the instance variable for the traversal direction.

Override the doAction method to set the instance variable for whether it has been used to true.

If your testing utility is correct, you will earn 5 points.



Testing Requirements


The testing requirement is done in a similar matter to the "Inventory" part of the assignment. It will be worth 15 points, with points earned for each feature successfully tested.

Write JUnit tests for the following methods from the specification in the TestTask3 class. These tests should be annotated with @Test.

  • DecisionTree:
    • To test these methods, you will have to create Decision objects to populate the DecisionTree. You are highly encouraged to use your TestDecision class.
    • You must test BOTH of the traverse methods in this class.
    • Test that the overload which takes a BinaryTreeNode returns the proper Decision, or null, with a variety of trees.
    • Test that the other method calls doAction on the correct Decision node, and only on that node. Using your TestDecision class will greatly simplify this check.


Programming Requirements


Implement the methods from the specification. You may wish to complete the Testing Requirements before you begin implementation, and it's suggested that you run these tests as you implement the methods.

Your implementation of this specification is worth 20 points.



Autolab Feedback


Autolab feedback will be given in several phases. These phases dictate the order you are expected to complete this assignment. Although you can still earn points from completing these phases out of order, you are nonetheless expected to have the previous phases completed before trying to fix a later one.

  1. Testing your testing utility: Your testing utility will be checked for correctness. Passing this phase will earn you the points associated with the utility. If you do not pass this phase, you should correct your testing utility so it can be used in the testing component.
  2. Running your tests on a correct solution: Your tests will be run on a correct solution to this assignment. If any of your tests crash or fail, you will earn 0 points for testing. If a test fails here but passes your implementation locally, it's likely you have a bug both in your code and in your test.
  3. Checking your tests for feature coverage: Your tests will be checked for feature coverage by determining if it can successfully catch bugs associated with incorrect solutions. For each feature your tests covered, you will earn some amount of points. Passing this phase doesn't mean your testing is fully comprehensive, but can be considered the minimum for effective testing. If you failed the previous phase, this phase will not run and you won't get any points for testing.
  4. Running my tests on your solution: Your code will be run against our tests. Once you've passed all the tests associated with some part of the specification, you'll earn some points for a correct implementation. If any feature for that part fails our tests, you won't earn any points for this part. If that happens, you should write more tests to catch that bug and fix it in your implementation.
Scoreboard

Overview


For this portion of this task, you will implement functionality in the app.gameengine.statistics.Scoreboard class, and several classes that you will create in the app.gameengine.model.datastructures package, in order to create a functional scoreboard for several of the games. At the moment, the only games with scoreboards are Minesweeper, Pacman, and Snake. Each of these games adds a score to the scoreboard whenever you win a game.



Testing


There is no graded testing for this part. You are expected to write tests on your own to verify your functionality.



Programming Requirements


  • Comparators
    • For these classes and methods, you will need to use the app.gameengine.statistics.GameState class. It is a small, simple class, so you should look at and understand this class.
    • Navigate to the app.gameengine.model.datastructures.Comparator class. Currently, this is a concrete class with a stubbed out method called compare. Modify this class such that it is an interface instead of a class, and make this method abstract. Do not modify the rest of the method signature. This is not worth any CUs on its own, but is required for the rest of the comparator classes.
      • This will result in a compilation error in the Scoreboard class, which tries to instantiate a Comparator. You can fix this by changing this to be any of the classes which implement the Comparator below, or by removing this constructor entirely. If you remove the constructor, you should call the other constructor from within the Game class when creating the scoreboard instance variable. Any of these solutions are fine, and we won't test this constructor, or the default comparator.
    • LevelNameComparator: In the app.gameengine.model.datastructures package, create a class named LevelNameComparator. This should implement the Comparator<GameStat>.
      • Override the compare method to compare two GameStat objects based on their entry names. If the first name is lexicographically less than the second, this method should return true, and it should return false in all other cases.
      • You may find the String.compareTo method useful for performing this comparison.
      • If the two names are equivalent, this method should return false, since a String cannot be lexicographically less than itself.
    • PlaytimeComparator: In the app.gameengine.model.datastructures package, create a class named PlaytimeComparator. This should implement the Comparator<GameStat>.
      • Override the compare method to compare two GameStat objects based on their playtimes. If the first playtime is less than the second, this method should return true, and it should return false in all other cases.
      • This method should break ties by returning false.
    • ScoreComparator: In the app.gameengine.model.datastructures package, create a class named ScoreComparator. This should implement the Comparator<GameStat>.
      • Override the compare method to compare two GameStat objects based on their scores. If the first score is greater than the second, this method should return true, and it should return false in all other cases.
      • This method should break ties by returning false.
  • Scoreboard basics
    • Navigate to the app.gameengine.statistics.Scoreboard class. Modify the constructor which takes two parameters so that the Comparator is stored as an instance variable.
    • Create getters and setters for this comparator instance variable, named getComparator and setComparator respectively.
    • Create an instance variable that is a BinaryTreeNode of GameStats. This will act as a BST to store and sort the scores in a game according to the comparator.
    • Create getters and setters for this instance variable named getScoreTree and setScoreTree, respectively.
    • Complete the existing addScore method to add the input GameStat to the correct location in the tree, according to the comparator.
      • Although the comparator could potentially change during the game, invalidating the sorting of your tree, your code does not need to handle this case. You can assume that the setComparator method will only ever be called before any scores are added.
  • Scoreboard loadStats method
    • Complete the existing loadStats method to load the existing stats from previous games. This will ensure that high scores carry carry over when the game is closed and restarted. Note that this method requires a functioning implementation of the addScore method.
    • The existing statsPath instance variable will be the location of the csv file which stores the stats. Each csv file will have one score per line, as "entryName,playTime,score", which are a String and two doubles, respectively. You should read each line in this file, parse it to create a GameStat object corresponding to that line, and add it to the tree using the addScore method.
    • This process must only occur once for a given scoreboard. If this method is called more than once, every following call should do nothing.
  • Scoreboard getScoreList methods
    • Complete the existing getScoreList method to return a LinkedListNode that is the head of a sorted list containing every element in the tree, according to the comparator.
    • If the tree is null, meaning that no scores have been added, this method should return null.
    • Create an overloaded method for getScoreList which takes a BinaryTreeNode of GameStat as a parameter and returns a LinkedListNode that is the head of a sorted list containing every element in the input tree.
    • This method may assume that the input tree is a valid BST according to the class' comparator.
    • If the input tree is null, this method should return null.

If you wish to see the Scoreboard in game, you should go to the respective Game subclass for the game you wish to enable it for (e.g. MinesweeperGame) and uncomment the line from the constructor that instantiates the scoreboard. Now, when you complete a game of Minesweeper or Pacman, you should see an entry added to the scoreboard, which is accessible from the pause menu, reached by pressing the escape key. The easiest way to test this is by playing Minesweeper, and pressing escape before selecting a difficulty. This will default to the trivial difficulty, which only has 3 mines and should be quite easy (as long as you understand the rules of Minesweeper).

If you wish to add a scoreboard to other games, like snake, you can do so, but this is optional. There are a few adjustments you need to make in order for this to work. First, in the SnakeGame constructor, you should set the comparator to the desired metric. You must then choose a place to add a score to the scoreboard. It would likely make the most sense to add this in both the advanceLevel and resetCurrentLevel methods, so that both winning and losing the game save the score. In either case, you can use the getScoreboard method to get the scoreboard, and then add a new GameStat entry. You can use the getScore and getPlaytime methods of the Level class to create the GameState.



Autolab Feedback


Since there is no testing requirement for this part, Autolab feedback will only come in one phase for correctness. For each feature you pass, you will earn some amount of points. If you pass all of the features associated with this part, you will earn 20 points.