Prg2/pacman (applying design patterns)
- This is part of Programming 2 2563
เนื้อหา
Overview
In this assignment, you and your friend will apply design patterns to the provided Pacman code. This version of the Pacman game is a 2-player game, where each player tries to score as many points as possible. The first Pacman is controlled by WASD keys, while the other Pacman is controlled by IJKL keys.
Understanding the current code
There are three main classes:
- PacmanGame
- Pacman
- Maze
PacmanGame
PacmanGame is the main class. It creates a Maze and 2 Pacman's. It feeds two Pacman's with user inputs. The following is its on_key_pressed method, which looks pretty ugly.
def on_key_pressed(self, event):
if event.char.upper() == 'A':
self.pacman1.set_next_direction(DIR_LEFT)
elif event.char.upper() == 'W':
self.pacman1.set_next_direction(DIR_UP)
elif event.char.upper() == 'S':
self.pacman1.set_next_direction(DIR_DOWN)
elif event.char.upper() == 'D':
self.pacman1.set_next_direction(DIR_RIGHT)
if event.char.upper() == 'J':
self.pacman2.set_next_direction(DIR_LEFT)
elif event.char.upper() == 'I':
self.pacman2.set_next_direction(DIR_UP)
elif event.char.upper() == 'K':
self.pacman2.set_next_direction(DIR_DOWN)
elif event.char.upper() == 'L':
self.pacman2.set_next_direction(DIR_RIGHT)
While the current game is playable, the scoring function does not work. You will use the Observer pattern to implement score updates.
Pacman
A Pacman is the key object. It works with the Maze to navigate (to avoid running into walls) and eats dots from the Maze. It also handles movement control. The Pacman would continue moving under the recent direction until it hits the walls or a new directional key is pressed. The Pacman does not change directly immediately, but it waits until it arrives at the centers of the "blocks".
Its main functionality can be understood with the following body of its update method. Note the the Pacman maintains its location as an xy co-ordinate, but it communicates with the Maze mostly with row-column positions. Therefore, it has to call maze.xy_to_rc to do the conversion. You can also see that its main logic runs inside the is_at_center if-block.
def update(self):
if self.maze.is_at_center(self.x, self.y):
r, c = self.maze.xy_to_rc(self.x, self.y)
if self.maze.has_dot_at(r, c):
self.maze.eat_dot_at(r, c)
if self.maze.is_movable_direction(r, c, self.next_direction):
self.direction = self.next_direction
else:
self.direction = DIR_STILL
self.x += PACMAN_SPEED * DIR_OFFSET[self.direction][0]
self.y += PACMAN_SPEED * DIR_OFFSET[self.direction][1]
This game maintains two Pacman's. Since each Pacman's behavior is controlled purely inside each object, having two Pacman is not a very difficult task. (You can even have more Pacman's if you like.)
Maze
Maze (in maze.py) is a class for handling the maze including wall checking, book keeping on dots, and also for displaying the maze in the canvas itself. It uses a lot of Dot's and Wall's sprites.
class Dot(Sprite):
def __init__(self, app, x, y):
super().__init__(app, 'images/dot.png', x, y)
self.is_eaten = False
def get_eaten(self):
self.is_eaten = True
self.hide()
class Wall(Sprite):
def __init__(self, app, x, y):
super().__init__(app, 'images/wall.png', x, y)
Notes: If you recall, each Sprite also creates a tk.PhotoImage, with the same photos. You will use the Flyweight pattern to reduce the number of PhotoImage objects needed.
Getting started
This is a two-student work (so that you can also practice git, again). So start by finding an assignment partner. One of the student should use the following template as a starter:
- Template: tk-pacman
After creating a new repository, the owner of the repository should add another student as a collaborator. This is the same steps as in the previous assignment (see how to add collaborators here).
The descriptions of the assignment below are marked with Dev 1 and Dev 2. This is a guideline for dividing works, but your team can split the work differently; however, you should make sure that each of the team member has an opportunity to help implement at least one design pattern.
Dev 1: Observer pattern (for scoring)
- Read about the pattern here.
Create the feature branch
As in our previous assignment, we will use git to collaborate extensively. We shall create a new branch called scoring.
git branch scoring git checkout scoring
Or just
git checkout -b scoring
After you are done, we will merge this branch to main branch.
Getting started
First note that the score texts are in PacmanGame, while the object that knows that a dot is being eaten is the Pacman itself. If we allows the Pacman to communicate directly to the score text (to update it), it would be a total mess, as the Pacman has to know too many things.
We would apply the observer pattern to let the PacmanGame registers functions to Pacman's so that the Pacman's can notify the PacmanGame when they eat dots.
We start by adding attributes for Pacman scores and a method update_scores to update the score texts:
class PacmanGame(GameApp):
# ..
def init_game(self):
# ..
self.pacman1_score = 0
self.pacman2_score = 0
def update_scores(self):
self.pacman1_score_text.set_text(f'P1: {self.pacman1_score}')
self.pacman2_score_text.set_text(f'P2: {self.pacman2_score}')
Notes: If you use older versions of Python, the f-format string might not work.
We add another two methods to be called by the Pacman's to update their scores.
class PacmanGame(GameApp):
# ..
def dot_eaten_by_pacman1(self):
self.pacman1_score += 1
self.update_scores()
def dot_eaten_by_pacman2(self):
self.pacman2_score += 1
self.update_scores()
Implementing the observer pattern
Your task: It is your task to implement the observer pattern.
First, we add an attributes in Pacman for keeping observers. Our observers would be just functions to be called, so we add attribute dot_eaten_observers to the Pacman. Remarks: we keep a list of observers, so you have to iterate the list to call them all.
class Pacman(Sprite):
def __init__(self, app, maze, r, c):
# ...
self.dot_eaten_observers = []
# ...
In the Pacman class, you should a a code to call the observers when the Pacman eats the dot:
class Pacman(Sprite):
# ...
def update(self):
if self.maze.is_at_center(self.x, self.y):
# ...
if self.maze.has_dot_at(r, c):
self.maze.eat_dot_at(r, c)
# TODO:
# - call all the observers here
# ...
Finally, you have to register the observers in PacmanGame. You can directly append the functions to the observer lists, or you can write a method for registering.
class PacmanGame(GameApp):
def init_game(self):
# ...
# TODO:
# - register self.dot_eaten_by_pacman1 to self.pacman1's observers
# - register self.dot_eaten_by_pacman2 to self.pacman2's observers
Try to run the game to see if the scores for both Pacman are updated.
You are done with the feature. It's time to merge your work to the main branch. Make sure you have committed your work. Then you can use the following command:
git checkout main
to check out the main branch
git pull
to update your local copy to the latest version in github, and then
git merge scoring
to merge your work into main.
If everything works, you should push your work to github. If you find a conflict, try to resolve it then commit and push. If you have trouble resolving the conflict, please call the TAs. Don't do "--force" unless you understand what you are doing.
Dev 2: Command pattern
- Read about the pattern here.
Create the feature branch
As in our previous assignment, we will use git to collaborate extensively. We shall create a new branch called scoring.
git branch command git checkout command
Or just
git checkout -b command
After you are done, we will merge this branch to main branch.
Implementing the pattern
Your task: You have to implement the Command pattern to clean up the on_key_pressed method.
We would deal with the messy on_key_pressed. First we would create the "commands" for all 8 actions the we have to response. We will create a method for returning the function for setting the next direction to the pacman. This function get_pacman_next_direction_function is a higher-order function that returns a "command" function we need.
class PacmanGame(GameApp):
# ...
def get_pacman_next_direction_function(self, pacman, next_direction):
def f():
pacman.set_next_direction(next_direction)
return f
We then create a key mapping for these commands in init_game
class PacmanGame(GameApp):
def init_game(self):
# ...
self.command_map = {
'W': self.get_pacman_next_direction_function(self.pacman1, DIR_UP),
# TODO:
# - add all other commands to the command_map
}
We are ready to change the on_key_pressed method:
class PacmanGame(GameApp):
# ...
def on_key_pressed(self, event):
ch = event.char.upper()
# TODO:
# - check if ch is in self.command_map, if it is in the map, call the function.
Note: the value in self.command_map is a function, so you can call it directly, e.g., you can write self.command_map['W']() to call the command for W
Try to see if you can still control both Pacman's.
You are done with the feature. It's time to merge your work to the main branch. Make sure you have committed your work. Then you can use the following command:
git checkout main
to check out the main branch
git pull
to update your local copy to the latest version in github, and then
git merge command
to merge your work into main.
If everything works, you should push your work to github. If you find a conflict, try to resolve it then commit and push. If you have trouble resolving the conflict, please call the TAs. Don't do "--force" unless you understand what you are doing.
Synchronizing your work
One of your team member would have get the branch done first, so the git pull/push would be successful. If you finish later, you might have to handle merge conflicts. Be careful to resolve the conflict. Commit the work and push it to github when you are done. If you have problems, you should call the TA's.
Dev 1: Flyweight pattern
- Read about the pattern here.
Your task: You have to implement the Flyweight pattern to reduce the number of duplicated PhotoImage objects.
Git branch: You should start a new branch to work on this pattern.
We will decouple the tk.PhotoImage creation from the Sprite. Let's work in gamelib.py, and update Sprite class as follows.
class Sprite(GameCanvasElement):
def __init__(self, game_app, image_filename, x=0, y=0, photo_image=None):
self.image_filename = image_filename
self.photo_image = photo_image # ---- add this line
super().__init__(game_app, x, y)
def init_canvas_object(self):
if not self.photo_image: # ---- add this line
self.photo_image = tk.PhotoImage(file=self.image_filename) # ---- update this line
self.canvas_object_id = self.canvas.create_image(
self.x,
self.y,
image=self.photo_image)
In the code above, we allow the creator of the Sprite to reuse the tk.PhotoImage.
Now, let's work in maze.py. We will have to create tk.PhotoImage, so don't forget to import tk at the top of the file.
import tkinter as tk
Then, update the Dot and Wall class to take photo_image as optional arguments.
class Dot(Sprite):
def __init__(self, app, x, y, photo_image=None):
super().__init__(app, 'images/dot.png', x, y, photo_image=photo_image)
self.is_eaten = False
# ...
class Wall(Sprite):
def __init__(self, app, x, y, photo_image=None):
super().__init__(app, 'images/wall.png', x, y, photo_image=photo_image)
Let's create the shared PhotoImage and put that the the sprites. You have to fix the object creation lines below. (See TODO.)
class Maze:
# ...
def init_maze_sprites(self):
# ...
self.wall_image = tk.PhotoImage(file='images/wall.png') # --- create the photo
self.dot_image = tk.PhotoImage(file='images/dot.png') # --- create the photo
for i in range(self.get_height()):
for j in range(self.get_width()):
x, y = self.piece_center(i, j)
if self.has_wall_at(i, j):
# TODO:
# -- fix the line below to take the self.wall_image
wall = Wall(self.app, x, y)
self.walls.append(wall)
if self.has_dot_at(i, j):
# TODO:
# -- fix the line below to take the self.dot_image
dot = Dot(self.app, x, y)
self.dots[(i,j)] = dot
Try to see the code still works.
Notes: For this case, the memory improvement we get from the Flyweight pattern is about 4MB. It might be more than this if we need more sprites.
You are done with the feature. Don't forget to merge to the main branch and push. (And fix the conflict, if any.)
Dev 2: State pattern
- Read about the pattern here.
We will make the game a bit more fun to play. Currently, both Pacman runs at the same speed. We will randomly upgrade a Pacman when it eats a dot so that it moves twice faster. This upgrade would last for 50 steps. First, let's get the feature done without the State pattern.
Git branch: You should start a new branch to work on this pattern.
We will use random module, so let's import it (at the beginning of main.py).
import random
We add attributes for keeping the state of the Pacman.
class Pacman(Sprite):
def __init__(self, app, maze, r, c):
# ...
self.is_super_speed = False
self.super_speed_counter = 0
Our update becomes very messy with additional state logic codes.
class Pacman(Sprite):
# ...
def update(self):
if self.maze.is_at_center(self.x, self.y):
# ...
if self.maze.has_dot_at(r, c):
# ...
#
# NOTES: we randomly set is_super_speed with probability 0.1, we also restart the counter
#
if random.random() < 0.1:
if not self.is_super_speed:
self.is_super_speed = True
self.super_speed_counter = 0
# ...
# NOTES: we update the location with the new speed variable
#
if self.is_super_speed:
speed = 2 * PACMAN_SPEED
self.super_speed_counter += 1
if self.super_speed_counter > 50:
self.is_super_speed = False
else:
speed = PACMAN_SPEED
# NOTES: don't for get to fix this line (previously, it updates with only PACMAN_SPEED
#
self.x += speed * DIR_OFFSET[self.direction][0]
self.y += speed * DIR_OFFSET[self.direction][1]
Applying the State pattern
Your task: You have to implement the State pattern for the Pacman
- Notes: this will be a challenging task. If you haven't watch the last clip on State pattern, it would be nice to review that.
We would add two states:
class NormalPacmanState:
def __init__(self, pacman):
self.pacman = pacman
# ...
class SuperPacmanState:
def __init__(self, pacman):
self.pacman = pacman
# ...
To apply the state pattern, we need to figure out what each state can do. If you look at the update method. There are two operations that depends on the states of the Pacman, as noted below:
############ CAREFUL!! this is for analyzing the code... not adding codes #########
class Pacman(Sprite):
# ...
def update(self):
if self.maze.is_at_center(self.x, self.y):
r, c = self.maze.xy_to_rc(self.x, self.y)
if self.maze.has_dot_at(r, c):
self.maze.eat_dot_at(r, c)
############################################
############################################
## ##
## 1st operation: randomly change states ##
## ##
############################################
############################################
if random.random() < 0.1:
if not self.is_super_speed:
self.is_super_speed = True
self.super_speed_counter = 0
###########################################
###########################################
if self.maze.is_movable_direction(r, c, self.next_direction):
self.direction = self.next_direction
else:
self.direction = DIR_STILL
############################################
############################################
## ##
## 2nd operation: move the pacman ##
## ##
############################################
############################################
if self.is_super_speed:
speed = 2 * PACMAN_SPEED
self.super_speed_counter += 1
if self.super_speed_counter > 50:
self.is_super_speed = False
else:
speed = PACMAN_SPEED
self.x += speed * DIR_OFFSET[self.direction][0]
self.y += speed * DIR_OFFSET[self.direction][1]
###########################################
###########################################
Therefore, we would encapsulate both operations in the state, and change update to:
class Pacman(Sprite):
# ...
def update(self):
if self.maze.is_at_center(self.x, self.y):
r, c = self.maze.xy_to_rc(self.x, self.y)
if self.maze.has_dot_at(r, c):
self.maze.eat_dot_at(r, c)
## Notes: 1st operation
##
self.state.random_upgrade()
# ...
## Notes: 2nd operation
##
self.state.move_pacman()
We start with NormalPacmanState, in __init__
class Pacman(Sprite):
def __init__(self, app, maze, r, c):
# ...
# DELETE: you can delete these attributes
# self.is_super_speed = False
# self.super_speed_counter = 0
# ...
self.state = NormalPacmanState(self)
Finally, let's implement the states:
class NormalPacmanState:
def __init__(self, pacman):
self.pacman = pacman
def random_upgrade(self):
if random.random() < 0.1:
self.pacman.state = SuperPacmanState(self.pacman)
def move_pacman(self):
# TODO:
# - update the pacman's location with normal speed
pass
class SuperPacmanState:
def __init__(self, pacman):
self.pacman = pacman
self.counter = 0
def random_upgrade(self):
pass
def move_pacman(self):
# TODO:
# - update the pacman's location with super speed
# - update the counter, if the counter >= 50, set state back to NormalPacmanState
pass
Try to see the code still works.
You are done with the feature. Don't forget to merge to the main branch and push. (And fix the conflict, if any.)
Optional work
You can also work on the following tasks to improve the game.
- It would be nice to have different image for the two Pacman's.
- It would also be nice to have different sprice when the pacman runs at super speed.