CS102 Lab 3:
Multi-Threaded Applications

Assigned: Tuesday, February 16
Demonstration (10 points) in lab section: Monday, March 15
Hard copy of code (20 points) due to the CS102 mailboxes: Tuesday, March 16

Goals:

By the end of this lab, you should...


Motivation and Overview:

Concurrent processing is an important part of a wide variety of computer applications, particularly graphical applications in which many things happen on the screen at the same time. In this lab, you will implement your own multithreaded version of the game Pacman, in which an animated creature (Pacman) is guided by the player through a maze.

The object of the game is for Pacman to eat as much of the "food" on the game board as possible, without running into the monsters that are also roaming the board. Each food item eaten scores 1 point for the player. If Pacman occupies the same board square as any monster, then Pacman loses a life. After all lives are used, the game is over.

There are a few more details, as follows. On the board, you'll notice a few larger food items (we'll call them "power pills"). Each power pill eaten scores 20 points. When Pacman eats one of these pills, the monsters appear as "ghosts" for a period of 10 seconds. During this time, Pacman is free to run into them, which causes them to disappear and then materialize in their starting positions in the center of the board.

When Pacman has eaten all the food on the board (including all the power pills), Pacman gets an extra life (up to a maximum of 5) and the board is reset. Pacman is returned to the starting position, all the food and power pills show up again, and the monsters are returned to their starting positions.

There will be many threads involved in this application, and it's important to plan carefully so that they all interact correctly and the game works properly. These threads will include:


Before starting:

Download the provided project folder by clicking on the button in the online version of this assignment.

[[[DOWNLOAD PROJECT FOLDER FOR PC]]]

In the project folder, you will find several useful classes. Read and understand the code and documentation for each. Ask if you have questions about any of these classes.

The project folder also contains a folder called images, in which you'll find images of the Pacman character, as well as a monster image and a ghost image. You can create new images if you like, but these were provided to save you time. Feel free to use them.

A word of advice: Be mindful of synchronization concerns so that you avoid threads seeing inconsistent states. At the same time, be careful to avoid deadlock, as discussed in class.


Part I: An animation thread that runs continuously

  1. Read the code in the provided file Picture.java.

  2. Open the file AnimatedPicture.java. Note that this class extends Picture and implements Runnable.

  3. Your constructor should take the same parameters as the parent constructor, except that your constructor should also take in an array of arrays of file names. Each array will contain a sequence of file names for the images that should be shown in a animation sequence. You'll have an array of arrays so that you can specify multiple animation sequences and be able to switch among them quickly. (In the Pacman game, you'll have 4 animation sequences for the Pacman character: one for when Pacman is moving right, one for moving left, one for up, and one for down. You'll also have three animation sequences for the monsters, which will be explained later.) The constructor should store the array of arrays of Strings in an instance variable, and it should also create and save an identically sized array of arrays of Image references. The constructor should complete the initialization by filling in the Image arrays, loading the images from the files with the names given in the array of arrays passed to the consturctor.

  4. Add a method to your class that can be used to specify which sequence should be currently displayed. The animation sequence number (the index of the array) should be passed in as a parameter and saved inside your object in an instance variable.

  5. Add another method that can be used to specify the rate, in frames per second. Internally, use this value to calculate a delay for sleeping between frames.

  6. Implement the public void run() method to contain a simple loop that steps through the current animation sequence by repeatedly calling the setImage method inherited from Picture and supplying the images of the animation sequence in order. When you get to the end of the sequence, the next image should be the first one in the sequence. (The mod (%) operator will be useful for computing this index.) Note that your loop should run forever.

  7. In Startup.java test your AnimatedPicture class by instantiating it, passing it to the Thread constructor, and starting the thread. (Be sure to add the AnimatedPicture object to the gamePanel or you won't see it!) To save you typing the array of arrays of filenames, the ones you need are already defined as pacmanSequences and monsterSequences in the Startup.java file. In your testing, make sure you can switch among the different animation sequences. (Ensure that your run() method would still work if the animation sequences were of different lengths. Think about what would happen if your running thread was near the end of a long sequence and another thread changed the animation sequence to an index of a short sequence.)


Part II: A SpriteMover thread that suspends and resumes:

In class, we discussed a ComponentMover whose run method terminated when the destination was reached. For this lab, you'll want the characters to move throughout the game, so you'll create a SpriteMover in which the thread continuously, but suspends itself when the component reaches its destination. Then, when a new target is set, the thread should resume. Your calculations will be simpler than the ones for the ComponentMover done in lecture, because your SpriteMover will only be concerned with motion that is straght up, down, left, or right (and not along arbitrary angles).

  1. In a new file called SpriteMover.java, define a class called SpriteMover that extends Thread. Your constructor should take as parameters the AnimatedPicture object to be moved, the BoardMap (so it can determine where the walls are for calculating the destination), the starting grid location on the game board for the component (the file BoardMap.java defines constants for recommended starting locations), the game board square size in pixels (available from an accessor in BoardImage), and a boolean value indicating whether or not directional animation is desired (we'll explain that later). Save all the parameters in instance variables. You can decide what other instance variables you'll need.

  2. In SpriteMover, provide the following methods:

  3. To test your SpriteMover, you can create one in the Startup.java file using an AnimatedPicture with the pacman animation sequences, and and start it running. However, before it will do anything interesting, you'll need to make it change direction. Therefore, add a KeyListener to the frame, and have it listen for all key press events. When the user presses an arrow key, have your listener call the setDirectionRequest method on your SpriteMover object, providing the appropriate x and y values. (To determine the integer values reported in KeyEvent for the keys you want to use, you can look them up in Flanagan, or you can find out through experimentation by having your listener first just print out the integer value for each keystroke.) Make sure that the frame is selected, or your keystrokes will be ignored. If you're still not getting keystroke events, try calling the requestFocus() method on your frame.

    Test thoroughly. Make sure that your SpriteMover detects the walls properly and that the animation direction works properly.


Part III: Controlling one thread from another

The monsters in the game need to move on their own. To achieve this, you'll define a thread that has the "brains" of a monster:

  1. In a new file called MonsterBrains.java, define a thread that takes a SpriteMover into its constructor and saves it in an instance variable.

  2. Define the run() method for the MonsterBrains class to continously call setDirectionRequest on the SpriteMover, say 5 or 10 times per second. The particular direction should be chosen randomly. Make sure the direction is legal so you dont send the monster off on a diagonal. The nextInt() method of java.util.Random may be helpful here in conjunction with the mod (%) operator.

  3. In your Startup.java file, create an AnimatedPicture using the monster animation sequence and add it to the game panel. Then create a SpriteMover for it (being sure that directional animation is turned off), and start the SpriteMover running. Finally, create a MonsterBrains thread for the SpriteMover, and start the MonsterBrains thread running. Your monster should wander around on the game board.

  4. Now, put a loop around the code you wrote in step 3 above, so that five monsters, each with their own SpriteMover and MonsterBrain objects, are created. You should see five monsters wandering around the board now. (Note: If you create a new Random object for each MonsterBrains object, they'll each get the same sequence of random numbers and might move together. To avoid this, put the Random object in a static variable of the MonsterBrain class, so they all share the same random number generator and therefore will get different values from the same sequence.)


Part IV: Communication through shared objects

At this point, you have Pacman under user control and monsters move in random directions, but Pacman and the monsters do not interact. We need to have a way for the monsters to be aware of Pacman's position so that appropriate action can take place when collisions occur. We will accomplish this through object sharing.

Since the SpriteMovers all all have a reference to the BoardMap object, we can use that object as a communication center, where information from one thread can be stored for later observation by another thread. However, since Pacman and the monsters must behave differently with respect to that data, we'll create two subclasses of SpriteMover, called PacmanMover and MonsterMover, in which we can define the differences in their behavior. Then we'll add some instance variables to the BoardMap class, with appropriate mutators and accessors, so that the two different kinds of SpriteMovers can communicate through the BoardMap object.

  1. Create a new file called PacmanMover.java. Inside, define a PacmanMover class that extends SpriteMover. Recall that the SpriteMover had a method called checkMap that is called at each grid square. However, the method doesn't do anything. Let PacmanMover override the method to test the status of the current grid square and, if it contains a piece of food, set the grid square to be empty so that pacman appears to consume the food. Add an instance variable called score inside the PacmanMover, and increment it every time Pacman consumes piece of food.

  2. In your Startup.java file, create a PacmanMover instead of a SpriteMover for handling the Pacman motion. Also, create a java.awt.Label object and add it on the bottom of the frame (in the BorderLayout.SOUTH position). Pass this object to the PacmanMover, either in the constructor or in a separate method. Modify the PacmanMover to call setText to report the score in the label whenever the score is updated.

  3. Execute the program and make sure Pacman is eating food and the score is being updated accordingly.

  4. Now let's add some more information to the BoardMap class, to keep track of Pacman's current position.

    Inside of BoardMap, define a top-level (static) inner class called OverlapChecker. Its setLocation method should take two parameters, a Rectangle and a PacmanMover, and store them in insance variables. Its overlaps method should take in another Rectangle as a parameter and call Rectangle's provided intersects method to determine if the two rectangles overlap. If they do intersect, it should set both instance variables to null and return the PacmanMover. If they don't intersect, it should just return null.

    Add an instance variable to keep an OverlapChecker object inside of BoardMap. Initialize it to a new OverlapChecker in BoardMap constructor, and provide an accessor that returns the OverlapChecker so that other objects can use it.

  5. Now, go back to PacmanMover. In the constructor, get the OverlapChecker from the BoardMap and save it inside the PacmanMover for later use (the type is BoardMap.OverlapChecker). Then, override the performNotification method in PacmanMover so that it informs the OverlapChecker of Pacman's current position. (Note: Use the getBounds method of Component to get a Rectangle that defines the current position of the component on the screen.)

    At this point, the OverlapChecker inside of the BoardMap object knows about Pacman's current position at all times. So, we need to create a MonsterMover that pays attention to this information. In a new file called MonsterMover.java, create MonsterMover class that extends SpriteMover. In the constructor, get the OverlapChecker from the BoardMap and save it in an instance variable. Override the checkMap method inside MonsterMover to call the overlaps method on the OverlapChecker, passing in the monster's current position. If a PacmanMover is returned, we know there was a collision. For now, just call reset on the Pacman mover to put Pacman back at the starting position when there is a collision.

  6. In your Startup.java file, create a MonsterMover instead of a SpriteMover for handling the monster motion. Execute the program and check that collisions are being detected and Pacman is going back to the start position when they occur.

    Add an instance variable called lives inside the PacmanMover, and decrement it every time it is reset. You can accomplish this by overriding the reset method... just remember to also call the reset method of the superclass. In Startup.java, create a java.awt.Label object and add it at the top of the frame (in the BorderLayout.NORTH position). Pass this object to the PacmanMover, either in the constructor or in a separate method. Modify the PacmanMover to call setText to report the number of lives in the label initially, and whenever the number of lives is updated. You can start out with 3 lives. When the number of lives falls to zero, stop the thread so that Pacman doesn't continue to move.

  7. Test that things work as expected.


Part V: A timer thread

At this point, the game should be playable, but Pacman doesn't stand much of a chance against the monsters. When Pacman consumes a "power pill," we want the monsters to become ghosts for a period of 10 seconds. Then, after an additional two-second warning, they will return to their normal monster status. During those 12 seconds, Pacman can collide with the ghosts and send them back to their starting positions.

  1. For sharing purposes, create an integer variable called monsterStatus inside of BoardMap, and provide an accessor and mutator method to get and set the current monster status. BoardMap already defines constants for the three possible status flags: MONSTER (0), GHOST (1), and TRANSITION (2).

  2. In MonsterMover, modify the checkMap method so that it looks at the shared monsterStatus variable, and changes animation sequence number in the AnimatedPicture for the monster according to the status flag. That way, the player can tell the current status of the monster. (During the transition period, the animation sequence contains both the monster and ghost images, so the monster will appear to flash between them. When you create the monster AnimatedPicture, set the frame rate low enough --- say 4 frames per second --- so you can see the flashing.) Also in checkMap, have the MonsterMover decide whether to reset the Pacman mover or reset itself, depending on the current monsterStatus. When the MonsterMover resets itself, it should call a capturedGhost method on the PacmanMover (which you need to write) so that the PacmanMover can add 50 points to the player's score.

  3. Now, we need to create a timer that will set the monster status appropriately. Inside of PacmanMover, define a member class called GhostTimer that extends Thread. The run() method should be an infinite loop. Inside the loop, there should be a try block that should first set the monsterStatus in the BoardMap to be GHOST. Then, it should sleep 10 seconds. Then, it should set the status to TRANSITION. Then, sleep 2 more seconds. Finally, it should set the status to MONSTER and suspend itself. The catch block should just ignore any InterruptedException. You should also provide a method in TimerThread called reset that resets the timer by calling both interrupt and then resume on the thread itself (this).

    Why call interrupt? Think about what would happen if Pacman eats a power pill while the monsters are ghosts. Simply resuming the thread would not give Pacman any extra time. However, interrupting such a thread would cause the current iteration of the loop to end, thereby resetting the timer to the beginning of its cycle.

  4. In PacmanMover, create and start a GhostTimer, and save it in an instance variable. Modify checkMap to reset the GhostTimer whenever Pacman eats a PowerPill. Also, let the PowerPill score 20 points.

    While you're there, also modify checkMap to notice if the board has been cleared (there's an accessor for this already in BoardMap). If so, call reset on both the board and the Pacman mover, and give Pacman a bonus life (up to a maximum of five lives). You may want to call super.reset here, because you've already overridden the local reset method to subtract a life, but be sure to update the text label regardless, so the player sees how many lives remain. (Note: In the "real" Pacman game, the monsters go back to start when the board is cleared, but we're not requiring you to do that. You can leave them roaming around wherever they happen to be.)

  5. At this point, your game should be fully playable. Test thorougly, but don't get addicted.

Important: Remember that because the interleaving of threads is unpredictable, no amount of testing is a guarantee. You need to reason about the program in order to prove that bad things won't happen. Spend time looking through your code at access protection modifiers, and also synchronization modifiers. Make sure that critical data access is synchronized, so that a thread can't see an inconsistent state. Also, ensure that deadlock can't occur. (For example, it's unlikely that you want suspend and resume calls for a thread inside of synchronized methods, because if you suspend inside a synchronized thread, you continue to hold the lock, so no other thread could get into the other synchronized method in which resume is called.)


Extra Credit Options:

If you want to add some special features to your game, here are three options. You will be awarded one extra credit point on the lab for each one of these items that you correctly implement and show during your demo.


Demonstration:

In your lab section on March 15, you will demonstrate your complete working lab. Have a completed CS102 cover sheet ready for the TA to record your demo grade and demo comments (what worked and didn't work).


Hard Copy:

After your demo, take some time to clean up your code, add documentation, and generally make it beautiful. If there was a problem during the demo, you should mark on your code where you think the problem is. If you have time, you can try fixing it, and you should describe how you fixed it and whether or not you were able to get it to work. However, you will not be able to replace your demo grade by doing another demo unless you use a late coupon or a rewrite coupon. By 5:00pm on March 16, turn in your cover sheet (with the demo grade recorded on it) and a printed copy of all your code to the CS102 mailbox.

Kenneth J. Goldman (kjg@cs.wustl.edu)