You must work either on your own or with one partner. If you work with a partner you must first register as a group in CMS and then submit your work as a group. Adhere to the Code of Academic Integrity. For a group, “you” below refers to “your group.” You may discuss background issues and general strategies with others and seek help from the course staff, but the work that you submit must be your own. In particular, you may discuss general ideas with others but you may not work out the detailed solutions with others. It is not OK for you to see or hear another student’s code and it is certainly not OK to copy code from another person or from published/Internet sources. If you feel that you cannot complete the assignment on you own, seek help from the course staff.
Part A of this project involves the completion of a game and will solidify your understanding of object-oriented programming, as well as give you practice on developing code incrementally—one class (or even one method) at a time—and testing your work along the way. We have already implemented much of the graphical user interface (GUI) for the game (using object-oriented “handle graphics”); you will focus on the class definitions and testing.
As usual, use only the functions and constructs learned so far in the course or discussed in this assignment.
The objective of the player (marked “P” in the figure below) is to escape from a cave that contains several rooms. Somewhere within the cave, however, is a malicious monster (marked “M”) who will attempt to impede the player’s progress! The player will begin in a room located in the bottom-left corner of the cave and will have to move through the rooms (while avoiding the monster) to reach the exit, which is at the top-right corner of the cave.
The player has a finite number of health points, which may decrease along the player’s journey to the exit depending on the kinds of rooms they enter. Normal rooms do not cause the player to lose health points. Poisonous rooms (shown in green below) cause the player to lose health points after each move until the player has been “cured” (which happens with a certain probability). There are also trap rooms (shown in dark red) that cause the player’s health points to decrease by some fixed amount and immediately end the player’s turn. The player’s quest will end abruptly if the monster catches them by moving into the same room, or if their health points are fully depleted.
The player makes up to two moves on each turn by clicking while the monster moves just once. If the player enters a poisonous or trap room, the room will change color so that you, the player, will know to avoid it from then on!
We are providing you with the design of the classes whose methods you will need to implement in this project. In further sections of this document are detailed descriptions of each class.
There are a few nouns used broadly in the game description, including player, monster, and room. These are the different entities that interact with each other in the game, and can therefore be thought of as classes in our design. Some nouns can be organized into broader categories; for example, while a player and a monster are different, they are still both characters in our game and share a few characteristics. So, we may design a Character
class and then have two subclasses Player
and Monster
. Similarly, rooms with poison or traps are special kinds of rooms and can therefore be modeled as subclasses of a Room
class.
Our design involves eight classes, shown in the diagram below. You will need to modify five of them (the ones with members marked in bold).
These classes, and their public methods, form the application programming interface, or API, for this game. The class diagram serves as a “road map” for the capabilities of the objects that will make up the program. Knowing what features are—and aren’t—available helps to guide your problem-solving process. Study this diagram now to get an overview, and return to it often as you read the code in detail to see how things interact. Note that this diagram only shows one kind of relationship among objects: inheritance (“is-a”). You can draw additional diagrams for other kinds of relationships, such as composition (“has-a”). A Map
has-an array of Room
s, for example.
Where should you start? There’s already a lot going on in the given code, and while you will eventually need to understand all of it, it helps to work through it a little bit at a time in a systematic order. Let’s take a top-down approach—start by looking at the properties of the Game
class, then reading the implementation of its run()
method.
This is the top-level code that runs the game. See how simple the main loop is?
% Loop until game-over condition
while alive && any(self.player.getPos() ~= self.map.getExitPos())
% Active character takes action and responds to environment
c.sense(self)
c.move()
c.update()
% Check if player has been eaten or mortally wounded
% ...
% Determine which character gets to move next
% ...
end
By leveraging modular design, most of the complexity has been delegated to other classes, who manage their own data. The game itself just needs to check the game-over conditions and change whose turn it is (player or monster). When invoking characters’ actions, the game is completely agnostic about whether the active character c is a player or a monster. In fact, the character actions of sense, move, and update are the same three steps that are used in robot (and spacecraft) simulators: read sensor data, control actuators, and evolve the system and its environment forward in time. By focusing on abstract behaviors rather than specific procedures, useful patterns can be reapplied in many different contexts.
Of course, this high-level abstraction comes at a cost: we don’t immediately know everything that is happening in these three steps. So next you should trace the code through other classes. Look first at how the Character
class specifies the behavior of these methods, then look at the class diagram to see which are overridden by Player
and Monster
, and read their specifications to understand how the base behavior should be customized for their roles. As you trace, you’ll see mention of Room
s; that would be a good class to study next.
Do not skip the tracing step! This project is as much about reading code as it is about writing code—you need to have a good grasp of the API so that you know what kinds of object interactions are possible. Once you have a reasonable understanding of how the classes are intended to work, it’s time to start implementing the missing features. Which class should you work on first? Look for one that is mostly independent of other unimplemented classes. For example, Player
only depends generically on Room
(which is given), but the subclasses of Room
depend specifically on Player
; therefore, Player
is a good place to start. (For reference, the work to be done in Game
depends on the subclasses of Room
, and Monster
depends on Game
, so you might want to put those off until later.)
Your task is to complete all of the code marked with “% TODO
”. When adding new properties or methods, be sure to document them with a specification. Do not add additional properties or methods beyond those required by the assignment. Below we describe some of the required behavior in more detail, but first we need to discuss how to test your work as you go along.
You will not be able to play the game until all of the TODOs have been implemented. However, deferring testing until a big integration test at the end is a bad idea (this is true for any engineering system, not just software). You need a way to test each of your changes as you go along; this is called unit testing, since each test focuses on only a small “unit” of functionality.
Look at the file testP6.m—after setting up a figure and a Map
with a generic Room
, it constructs a Player
object and checks how much health it starts with. As soon as you have implemented Player
’s constructor, run this test and make sure the results are as expected. Next, look at the TODOs in this file: they describe unit tests for most of the functions you have to implement in Player
and in the subclasses of Room
; the “coverage” line describes what functionality is required before it makes sense to run the test. As soon as you implement each piece of functionality, write the corresponding test and run testP6 to make sure it works (and that no previous features broke in the process).
The example test prints out the actual vs. expected value and relies on a human reading the output to verify that the behavior was correct. Alternatively, you may check the result in code and raise an error()
if it does not match the expected value. Choose whichever testing style you are most comfortable with.
Some functions are a bit trickier to test in an automated fashion. For example, Player.move()
requires the user to click the mouse, and Player.update()
depends on random numbers. In these situations, it may be more convenient to verify the behavior interactively, rather than checking a single result. We encourage you to document such testing in your unit test script (so it’s easy to repeat), but there are no TODOs associated with these tests. You are also welcome (and encouraged) to write additional unit tests beyond the minimum required ones.
Once all methods and classes have been tested in relative isolation, then it becomes time for integration testing, with the goal of verifying that the interactions between objects are as desired. See Playing the game below.
The Character
class is an abstract base class—you will only ever construct subclasses of Character
(like Player
or Monster
), never a generic Character
itself. Nevertheless, it provides a lot of useful functionality.
A Character
knows how to draw itself with a colored box and a small text label (one letter). It knows which room it is currently in, and it knows how to move between rooms. It also tracks how many moves it can still make in its current turn, and it has the ability to reset that allowance at the start of a new turn.
When moving, the Character
class defers to its derived class to decide what direction to move in. It assumes that its derived class will implement a nextDir()
method that returns a vector of length 2 indicating how far to move horizontally and vertically—a return value of [0,1]
means “move 0 columns to the right and 1 row up.” Most characters, including Player
and Monster
, can only move between adjacent rooms (diagonals included), so the elements returned by nextDir()
are any combination of -1, 0, and 1.
In addition to nextDir()
, subclasses can customize the behavior of three Character
actions: sense()
, move()
, and update()
. The sense()
method accepts a Game
as an input argument, allowing the character to query the overall game state via its public methods (only one such method is provided here: getPlayerPos()
). move()
takes care of moving the character to a new room; usually the base class behavior is sufficient, but it may be overridden if additional action is required as soon as a room is entered. update()
is used to specify behavior that should occur due to the passage of time (rather than being triggered by movement to a location).
A Player
is a Character
with the following tweaks:
Player
should be shown graphically as a blue box containing the letter “P”.Player
keeps track of how many “health points” they have (initially 100).Player
may be damaged, depleting health points by a specified amount.Player
may be poisoned, in which case they lose a configured number of health points every time they update()
.Player
has a configurable probability of curing their “poisoned” status every time they update()
.Player
may move twice in a turn; this movement allowance should be replenished in the startTurn()
method.Player
moves in the direction specified by the user when they click their mouse; this direction is determined in the nextDir()
method (already implemented).Player
visits every room that they move into.Most of the logic required to implement this behavior is trivial—often just one or two lines of code. Note that visiting rooms requires overriding the move()
method inherited from class Character
; however, we still want to do everything that the inherited move()
method does, and you should do that without copy-pasting any code.
Observe that the player never needs to know what kind of Room
they are visiting—the effect of visiting a room is delegated to the room itself, leveraging polymorphism to run the appropriate code for the type of room the player is in.
A Monster
is a Character
with the following tweaks:
Monster
should be shown graphically as a red box containing the letter “M”.Monster
can sense where the player is on the map in sense()
.nextDir()
, a Monster
looks at how close it is to the exit room and compares that with how close the player is to the exit. If the monster is closer to the exit than the player, then it will move to attack, advancing towards the player by one space. Otherwise, it moves towards the exit by one space.Implementing the monster’s movement policy is slightly more complicated than most of the other tasks in this project. Make sure the elements returned by nextDir()
are all either -1, 0, or 1. The built-in sign()
function might be useful for this: it returns -1, 0, or 1 for each argument that is negative, zero, or positive, respectively; see Player
for an example (but you do not need to use it if you prefer a different approach—never submit code unless you understand how it works).
A Room
is simply a location that a Character
can enter. It has a position within a rectangular grid, specified by a column and row number (integers ≥ 1, [x,y] ordering, positive y goes up—think Cartesian plots, not matrices) stored in a vector. When a player (specifically a Player
, not a general Character
) visits the room, the room may perform some action on the player in its visit()
method; a generic room does not perform any action when visited, but subclasses of Room
may.
Rooms are drawn as colored squares. The color may be changed at any time, and a text label may also be added to a room.
A PoisonRoom
is a Room
that poisons any Player
who visits it. Once it has been visited, the player knows that the room is hazardous, so its color should change to green.
A TrapRoom
is a Room
that damages any Player
who visits it by an amount specified when the room is constructed. Visiting such a room also immediately ends the player’s turn by setting their number of remaining moves to zero. Once it has been visited, the player knows that the room is hazardous, so its color should change to red.
A Map
is a collection of rooms arranged in a rectangular grid. With a Map
, it is easy to look up a room given its position in the grid (column and row numbers stored in a vector). A Map
also knows the dimensions of the grid (which Character
s can look up to avoid moving out-of-bounds), and it knows which position corresponds to the exit room.
Room
s can be added to a Map
one at a time; since a Room
knows its own position, the Map
does not otherwise need to be told where to place it in the grid. Internally, Room
s are stored in a 2D cell array so that subclasses of Room
can also be accommodated.
Finally, a Game
manages all of the objects involved in playing one round of the game; as mentioned before, it sits at the top of our top-down design. Its constructor’s job is to set up the figure window for graphics, assemble the map, and create the two character objects. The run()
method then alternates between these two characters, asking the “active” character to move until it runs out of moves, at which point its turn ends and the other character becomes “active”. The Game
is responsible for checking end-game conditions, since these usually involve conditions between multiple objects (are the player and monster in the same room? is the player in the exit room?). It also interfaces with the user by displaying useful information in the figure title.
Occasionally classes other than Game
need a “global” view of the entire game state to do their work (for example, Character.sense()
). A Game
therefore has public methods to provide whatever information those other classes are allowed to see (for example, the player’s position).
When setting up the map, the game must decide which rooms should be hazardous (traps or poison). For this assignment, each room should have a 10% probability of being a trap room dealing 20 damage and a 20% probability of being a poison room. The start and exit rooms must never be hazardous.
Now you get to play the game! Here is an example run:
g = Game(5) % Construct a 5x5 game
g.run() % Start the game
That’s all that’s required for this project! But to appreciate the implications of object-oriented programming on the maintainability of software, it’s good to at least consider the work that would be required to add new features. Suppose, for example, that we wanted to add a “healing room” that restores health points when visited by a player. How much new code would you need to write? Or perhaps more importantly, how much existing code would not need to change? Adding a new subclass doesn’t require changing any other files except where objects of the new class are to be created. If you were working on a large team, this independence of modules might be a big benefit in terms of distributing work.
Brainstorm your own changes to this game that would be easy to implement in the context of its design. Could you have two players race for the exit? Could you have a computer-controlled player and a human-controlled monster? If you have a fun idea, share it on the class discussion board! Implementing such changes is a good way to practice and exercise your object-oriented programming skills.
Have fun playing the game! And of course, submit your files on CMS (after registering your group). They should include Player.m, Monster.m, TrapRoom.m, PoisonRoom.m, Game.m, and testP6.m, as well as the files from part B.