5 PacMan
If interfaces and classes were all we needed to design object-oriented programs, our job would be done. We can successfully design small programs so far, but as our programs grow larger, we’ll find that we need additional abstraction mechanisms to keep our code understandable. So let’s build a larger system: we’ll write a simplified version of PacMan, using the skills we currently have. And as we extend the game to include additional features, we’ll introduce the new concepts that will keep the code clear.
5.1 Basic game mechanics
The first step in designing any program is to understand what information it must represent and manipulate.
What information is necessary for PacMan?
Brainstorming the basic gameplay of PacMan, we probably need to represent:
The playing area
- The player, which might include
The player’s position
The player’s score
The player’s remaining lives
- The ghosts, which can be
Angry ghosts, that chase the player, or
Scared ghosts, that run away from the player
- In-game items, which might include
The food dots
The powerups, that make ghosts scared
Bonus fruit
The walls and obstacles
Any extra features we want to represent
So our complete game will be structured something like
<playing-area> |
<in-game-items> |
<walls> |
<extras> |
5.2 Defining the playing space
For now, our playing area will just consist of an empty rectangular board: we’ll add walls and the ability to “loop around” from one side of the board to the other later. To represent this area, we’ll separate the logical board size from the physical size, just as we did with the rocket example in Objects = Data + Function:
(define BOARD-WIDTH 20) ;; cells |
(define BOARD-HEIGHT 30) ;; cells |
(define SCALE-X 20) ;; px/cell |
(define SCALE-Y 20) ;; px/cell |
(define mtScene |
(empty-scene (* BOARD-WIDTH SCALE-X) |
(* BOARD-HEIGHT SCALE-Y))) |
5.3 The player
What game state is relevant to the player? We need to keep track of its position, its score, how many lives it has, and so on. Should these pieces of information be included in the definition of the player% class, or the world% class?
Which do you think will be easier to work with as we develop the game?
If we keep the player’s position in the world% class, then the world must be responsible for moving the player around. By similar reasoning, the world would need to be responsible for moving the ghosts around. But such movements are complicated (players, angry ghosts and scared ghosts all move differently), and placing all the code in the world% class would clutter it up. If we keep the player’s coordinates stored as fields within the player% class, then when the world needs to move the player, it might be as simple as invoking (the-player . move), and similarly for the ghosts. All the logic for how players and ghosts move would be placed in the most relevant classes.
This decision is one instance of a general pattern we will see many times: we are delegating responsibility over some computation (moving players and ghosts) from methods in one class (the world% object) to others (the player% class and the ghost classes).
Applying this reasoning again to the player’s score and lives, we can represent them as fields in the player class as well.
So our initial design for the player class is
;; A Player is (new player% Natural Natural Natural Natural) |
(define-class player% |
(fields x y score lives) |
... |
) |
5.4 Delegation
TODO
5.5 Ghosts
Let’s start by just modeling angry ghosts, and come back to scared ghosts after we have the simplest possible skeleton of a game running. We don’t yet know our full data definitions, but we can at least say
;; A Ghost is one of |
;; -- (new angry-ghost% ...) |
;; -- (new scared-ghost% ...) |
We know there will be multiple ghosts, distinguishable by their colors and positions, so our first design for our angry-ghost% class is
(define-class angry-ghost% |
(fields x y color) |
... |
) |
5.6 Drawing the game
So far, our world just contains a player and a bunch of ghosts, and we know eventually there will be a bunch of items as well. This means we need an interface for working with a list of ghosts:
;; A List of Ghosts is one of |
;; -- (new consLoG% Ghost LoG) |
;; -- (new mtLoG%) |
(define-class consLoG% |
(fields first rest) |
...) |
(define-class mtLoG% |
(fields) |
...) |
And an interface for working with a list of items:
;; A List of Items is one of |
;; -- (new consLoI% Item LoG) |
;; -- (new mtLoI%) |
(define-class consLoI% |
(fields first rest) |
...) |
(define-class mtLoI% |
(fields) |
...) |
Now we can define our world:
;; A World is (new world% Player LoG LoI) |
(define-class world% |
(fields player ghosts items) |
<world-drawing> |
<world-tick> |
<world-key-handling> |
) |
Let’s get drawing! We know that the world must implement a method to-draw: -> Scene, and that it must somehow draw all its subcomponents into a single Scene.
5.7 Changing Places
5.7.1 Player in motion, or, I ain’t afraid of no ghosts!
(define PLAYER-SPEED 2) |
(define GHOSTS-SPEED 1) |
Our player object needs to respond to keypresses, so that it moves when the users presses an arrow key. Following the design recipe, we start with examples of expected motion:
(check-expect ((new player% 3 5 40 1) . on-key "up") (new player% 3 (- 5 PLAYER-SPEED) 40 1)) (check-expect ((new player% 3 5 40 1) . on-key "down") (new player% 3 (+ 5 PLAYER-SPEED) 40 1)) (check-expect ((new player% 6 5 40 1) . on-key "left") (new player% (- 6 PLAYER-SPEED) 5 40 1)) (check-expect ((new player% 6 5 40 1) . on-key "right") (new player% (+ 6 PLAYER-SPEED) 5 40 1))
The template for the on-key method comes straight from our class definition: all we have available so far are our fields.
(define (on-key k) ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...)
We know that we have to handle four different keys, so we refine the template:
(define (on-key k) (cond [(string=? k "up") ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...] [(string=? k "down") ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...] [(string=? k "left") ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...] [(string=? k "right") ... (this . x) ... (this . y) ... (this . score) ... (this . lives) ...] [else ... ]))
Looking at the expected outputs guides our implementation:
(define (on-key k) (cond [(string=? k "up") (new player% (this . x) (- (this . y) PLAYER-SPEED) (this . score) (this . lives))] [(string=? k "down") (new player% (this . x) (+ (this . y) PLAYER-SPEED) (this . score) (this . lives))] [(string=? k "left") (new player% (- (this . x) PLAYER-SPEED) (this . y) (this . score) (this . lives))] [(string=? k "right") (new player% (+ (this . x) PLAYER-SPEED) (this . y) (this . score) (this . lives))] [else this ]))
Try running the game right now. Does it do what you expect?
Our code happily allows the player to exit the playing field. Our tests must be woefully incomplete, or they would have caught this behavior.
Add more tests to confirm that the player can never move outside the playing field
We clearly need to revise our implementation of on-key. We need to restrict the player’s position so that it is always within bounds. But wait...
Our player has a position, and can change positions over time. Our ghosts also have positions, and also can change positions over time. Leaving aside how players and ghosts choose where to move, should we implement the position-manipulating logic in players and ghosts? No! Instead, as we just discussed, we should factor out our position-related code into a posn% class and delegate the details of representing motion to that class.
(define-class% posn% |
(fields x y) |
) |
Now we can revise our representation of player% to use a posn% instead of x and y fields:
;; A Player is (new player% Posn Natural Natural) |
(define-class player% |
(fields p score lives) |
) |
How might we refactor on-key? Each of the branches in that method do nearly the same thing: create a new player% with a position that has moved by some increment in some direction. Following the design recipe for abstraction, this suggests we might define a velocity% class, and add a move method to posn% that takes a vel%ocity and produces a new posn%:
;; A Velocity is (new vel% Natural Natural) |
(define-class velocity% |
(fields vx vy) |
... |
) |
;; move: Velocity -> Posn |
;; move creates a new Posn by adding the given Velocity to the current Posn |
(define (move v) |
(new posn% (+ (this #,dot x) (v #,dot vx)) (+ (this #,dot y) (v #,dot vy)))) |
(define (on-key k) (cond [(string=? k "up") (new player% (this . p . move (new vel% 0 (- PLAYER-SPEED))) (this . score) (this . lives))] [(string=? k "down") (new player% (this . p . move (new vel% 0 PLAYER-SPEED)) (this . score) (this . lives))] [(string=? k "left") (new player% (this . p . move (new vel% (- PLAYER-SPEED) 0)) (this . score) (this . lives))] [(string=? k "right") (new player% (this . p . move (new vel% PLAYER-SPEED 0) (this . score) (this . lives)))] [else this ]))
This is better, but still has not solved the problem of moving off the board. But notice that
now our positions are all posn%s —
(define (on-key k) |
(local [(raw-moved-pos |
(cond |
[(string=? k "up") (this . p . move (new vel% 0 (- PLAYER-SPEED)))] |
[(string=? k "down") (this . p . move (new vel% 0 PLAYER-SPEED))] |
[(string=? k "left") (this . p . move (new vel% (- PLAYER-SPEED) 0))] |
[(string=? k "right") (this . p . move (new vel% PLAYER-SPEED 0))] |
[else (this . p)]))] |
(new player% (raw-moved-pos . clamp 0 0 BOARD-WIDTH BOARD-HEIGHT) |
(this . score) (this . lives)))) |
Much better. What should clamp do?
(check-expect ((new posn% 40 10) . clamp 0 0 10 10) (new posn% 10 10)) (check-expect ((new posn% -5 10) . clamp 0 0 5 5) (new posn% 0 5)) (check-expect ((new posn% 5 5) . clamp 1 1 10 10) (new posn% 5 5))
;; clamp : Number Number Number Number -> Posn |
;; relocates the current point to within the provided rectangle |
(define (clamp min-x min-y max-x max-y) |
(new posn% |
(min (max (this . x) min-x) max-x) |
(min (max (this . y) min-y) max-y))) |
5.8 World-building
(big-bang the-world) |