JustToThePoint English Website Version
JustToThePoint en español
JustToThePoint in Thai

Programming a Pac-Man in Python

Introduction

Pac-Man eats pellets because he has to, fruit because he should, and ghosts because they remind him that death is the only true escape from this hellish maze, Connor Choadsworth

Pac-Man is a 1980 maze action video game developed and released by Namco for arcades. It enjoyed widespread commercial success and it is also commonly considered as one of the greatest video games of all time. Pac-Man is a 1980 maze action video game developed and released by Namco for arcades.

The Node and Maze classes

Let’s start by implementing the maze. This code is inspired by PACMANCODE, but there are many substancial differences. We will use our class myVector.

import pygame
from vector import myVector 
from settings import *
import numpy as np

The maze is composed of nodes. Each node has a position (x, y) and can be connected to five other nodes in four directions (UP, DOWN, LEFT, and RIGHT) or through a portal. Finally, the node can be part of the ghosts’ home.

class Node(object):   
    def __init__(self, x, y, isGhostHome=False):
        self.position = myVector(x, y)
        self.neighbors = {UP:None, DOWN:None, LEFT:None, RIGHT:None, PORTAL:None}
        self.isGhostHome = isGhostHome

class Maze(object):
    def __init__(self, level):
        self.level = level
        self.nodes = {} # The Maze's attribute "nodes" is a dictionary that contains all the game's nodes.
        self.nodeSymbols = ['+', 'P', 'n', 'G']
        self.pathSymbols = ['.', '-', '|', 'p']
        self.homekey = None
        self.buildMaze()

   def buildMaze(self):
        """ It builds or generates a maze from the file [MAZEFILE](https://pacmancode.com/graphical-mazes)."""
        # The only difference is the 18th row...
        # G - - - - - + - - n 5 X X X X X X 5 n - - + - - - - - G
        lines = self.readMazeFile() # Firstly, we read the file.
        self.createNodeTable(lines) # Secondly, we create the nodes.
        self.connectHorizontally(lines) # Thirdly, we connect the nodes horizontally.
        self.connectVertically(lines) # Finally, we connect the nodes vertically.

![Building the maze](/code/images/Maze2.jpg ./code/images/Maze3.jpg ./code/images/Maze.jpg ./code/images/Maze3.png ./code/images/Maze2.png ./code/images/Maze.png ./education/images/Maze2.jpg ./education/images/Maze3.jpg ./education/images/Maze.jpg ./education/images/Maze3.png ./education/images/Maze2.png ./education/images/Maze.png)

    def readMazeFile(self):
    # It reads the file containing the [maze](https://pacmancode.com/graphical-mazes), removes all the spaces, and returns a list containing each line in the file as a list element. 
        with open(MAZEFILE) as f:
            lines = f.readlines()
        lines = [line.replace(' ', '') for line in lines]
        return lines

    def createNodeTable(self, lines, xoffset=0, yoffset=0, isHome=False):
    # It creates all the maze's nodes.
        for row, line in enumerate(lines):
            for col, character in enumerate(line):
                if character in self.nodeSymbols:
                    x, y = self.convertPixel(col+xoffset, row+yoffset)
                    self.nodes[(x, y)] = Node(x, y, isHome)
                    
    def convertPixel(self, x, y):
    # It converts a character's position (aka maze coordinates: row and column) in our text file to actual pixel values on the screen by multiplying x and y by TILEWIDITH and TILEHEIGHT respectively. 
        return x * TILEWIDTH, y * TILEHEIGHT

    def connectHorizontally(self, lines, xoffset=0, yoffset=0):    
    # It connects the game's nodes horizontally.    
      for row, line in enumerate(lines):
        key = None
          for col, character in enumerate(line):
            if character in self.nodeSymbols:
              if key is None: # We have not found a node in this row yet.
                 key = self.convertPixel(col+xoffset, row+yoffset)
              else: # key is a node previously found, so we connect it to our new node. 
                 otherkey = self.convertPixel(col+xoffset, row+yoffset)
                 self.nodes[key].neighbors[RIGHT] = self.nodes[otherkey] # Observe that we are moving from left to right, i.e., key is on the left.
                 self.nodes[otherkey].neighbors[LEFT] = self.nodes[key]
                 key = otherkey

              if character == "G": # We are using the symbol "G" for gateway or portal. One important part of any Pac-Man game is the ability to move from one side of the screen to the other side through portals.
                if col==0:
                  self.setPortal((col, row), (len(line)-2, row))
                else:
                  self.setPortal((0, row), (col, row))

            elif character not in self.pathSymbols:
              key = None

    def connectVertically(self, lines, xoffset=0, yoffset=0):
    # It connects the game's nodes vertically. First, we transpose the list "lines", so from now on we are reading the original file vertically, and we need to consider that the "key" node is on top of "otherkey" (*) 
      lines = map(lambda *a: list(a), *lines) # It transposes the list "lines".
      for col, line in enumerate(lines): 
        key = None 
        for row, character in enumerate(line):
          if character in self.nodeSymbols:
            if key is None: # We have not found a node in this column yet.
              key = self.convertPixel(col+xoffset, row+yoffset)
            else: # key is a node previously found, so we connect it to our new node.
              otherkey = self.convertPixel(col+xoffset, row+yoffset)
              self.nodes[key].neighbors[DOWN] = self.nodes[otherkey] # key is on top of otherkey (*)
              self.nodes[otherkey].neighbors[UP] = self.nodes[key]
              key = otherkey
          elif character not in self.pathSymbols:
            key = None

![Programming PacMan](/code/images/Maze2.jpg ./code/images/Maze2.png ./education/images/Maze2.jpg ./education/images/Maze2.png)

    # It creates a portal between two nodes given their maze coordinates. All maze coordinates are written as (col, row).
    def setPortal(self, pair1, pair2):
        key1 = self.convertPixel(*pair1)
        key2 = self.convertPixel(*pair2)
        if key1 in self.nodes.keys() and key2 in self.nodes.keys():
            self.nodes[key1].neighbors[PORTAL] = self.nodes[key2]
            self.nodes[key2].neighbors[PORTAL] = self.nodes[key1]

    # It returns the node, if any, that corresponds to the maze coordinates given as parameters.
    def getNodeFromTiles(self, col, row):
        x, y = self.convertPixel(col, row)
        if (x, y) in self.nodes.keys():
            return self.nodes[(x, y)]
        return None

    # It returns the node coordinates, if any, that corresponds to the pixel coordinates given as parameters.
    def getNodeFromPosition(self, col, row):
        for x in range(NCOLS):
            for y in range(NROWS):
                if abs(col - x * TILEWIDTH)<=TILEWIDTH and abs(row - y * TILEWIDTH)<=TILEHEIGHT:
                    return (y, x)
        return None

There’s No Place Like Home

Even ghosts have homes. createrHomeGhost builds the ghosts home. It will be called from the Game’s method startGame: self.homekey = self.myMaze.createHomeGhost(*HOMEGHOSTS). It returns the node that is the entry point to the ghosts home.

   def createHomeGhost(self, xoffset, yoffset):
        # It uses a 5*5 matrix (HOMEMAZE) containing the symbols for the nodes and paths to build the ghosts home. 
        self.createNodeTable(HOMEMAZE, xoffset, yoffset, True) # Please take notice that the four argument is True indicating that these nodes are part of our ghosts' home.
        self.connectHorizontally(HOMEMAZE, xoffset, yoffset)
        self.connectVertically(HOMEMAZE, xoffset, yoffset)

Maze settings (this is a section of the setting.py file)

MAZEFILE="myMaze.txt" 
HOMEMAZE = [['X','X','+','X','X'], ['X','X','.','X','X'],['+','X','.','X','+'],['+','.','+','.','+'], ['+','X','X','X','+']]
HOMEGHOSTS = (11.5, 14) 
SPAWNGHOST = (2+11.5, 3+14) 
CONNECTLEFT = (12, 14) 
CONNECTRIGHT = (15, 14)

Observing the maze settings in the settings.py, we can figure out why we need to add an offset of two in the x-coordinate.

![There is no place like home](/code/images/Maze3.jpg ./code/images/Maze3.png ./education/images/Maze3.jpg ./education/images/Maze3.png)

# The next lines allow us to connect the ghosts home with the rest of the maze.
        self.homekey = self.convertPixel(xoffset+2, yoffset)
        self.connectHomeNodes(CONNECTLEFT, LEFT)
        self.connectHomeNodes(CONNECTRIGHT, RIGHT)
        return self.homekey
   
   def connectHomeNodes(self, otherkey, direction):     
        key = self.convertPixel(*otherkey)
        self.nodes[self.homekey].neighbors[direction] = self.nodes[key]
        self.nodes[key].neighbors[direction*-1] = self.nodes[self.homekey]

Entities

There are many objects in PacMan, such as the four ghosts, Pac-Man, and the fruits, that share attributes and even some functionality. Let’s create an abstract class to be able to reuse as much code as possible.

import pygame
from pygame.locals import *
from vector import myVector
from settings import *
from random import randint
import copy
import maze
import search

class Entity(object):
    # All entities have a name, a speed, and they could move in the maze in four directions or stay stationary.  
    def __init__(self, node, maze = None):
        self.name = None
        self.directions = {UP:myVector(0, -1),DOWN:myVector(0, 1), 
                          LEFT:myVector(-1, 0), RIGHT:myVector(1, 0), STOP:myVector()}
        self.direction = STOP
        self.maze = maze
        self.setSpeed(NORMAL_SPEED)
        self.radius = 10
        self.collideRadius = 10
        self.target = node
        self.visible = True
        self.goal = None
        self.directionMethod = self.randomDirection # By default, when an entity reaches a node, it will choose a random direction to go next.  
        self.setStartNode(node)
        self.image = None

    # This is used for an "entity" (ghost, Pac-Man) to position itself between two maze's nodes.
    def setBetweenNodes(self, direction):
        if self.node.neighbors[direction] is not None:
            self.target = self.node.neighbors[direction]
            self.position = (self.node.position + self.target.position) / 2.0

    def setStartNode(self, node):
        self.node = node
        self.startNode = node
        self.target = node
        self.setPosition()

    def setPosition(self):
        self.position = copy.copy(self.node.position)
    
    # It checks if the "direction" passed as an argument is a valid direction to go next. Please observe that Pac-Man is not allowed to go to the ghosts' home.      
    def validDirection(self, direction):
        if direction is STOP:
            return False
        if self.node.neighbors[direction] is None:
            return False
        if self.name == "PACMAN" and self.node.neighbors[direction].isHome:
            return False

        return True
    
    # If direction is a valid one for the entity to go next, it returns the node that this direction leads from the current node.
    def getNewTarget(self, direction):
        if self.validDirection(direction):
            return self.node.neighbors[direction]
        return self.node

    # It checks if the entity has already overshot its target node.
    def overshotTarget(self):
        if self.target is not None:
            vec1 = self.target.position - self.node.position
            vec2 = self.position - self.node.position
            return vec2.magnitude() >= vec1.magnitude()
        return False

    # If direction is not STOP and is indeed the opposite direction from the current direction that the entity is moving in, it reverses its direction and updates (swaps) its node and target.
    def reverseDirection(self, direction):
        if direction is not STOP:
            if direction == self.direction * -1:
                self.direction *= -1
                self.node, self.target = self.target, self.node
    
    # It sets a new speed for the entity.    
    def setSpeed(self, mode=NORMAL_SPEED):
        if mode==NORMAL_SPEED:
            self.speed = 100 * TILEWIDTH / 16
        elif mode==SUPER_SPEED:
            self.speed = 125 * TILEWIDTH / 16
        elif mode==LOW_SPEED:
            self.speed = 75 * TILEWIDTH / 16
    
    # If the entity has an image and is "visible", it renders it with its position adjusted to the node's size. Otherwise, it draws a circle. 
    def render(self, screen):
        if self.visible:
            if self.image is not None:
                p = self.position - myVector(TILEWIDTH, TILEHEIGHT)/2
                screen.blit(self.image, p.getVector())
            else:
                p = self.position.getVectorAsInt()
                pygame.draw.circle(screen, self.color, p, self.radius)

The following method updates the entity’s position. Remember that s = s0 + v dt + 1/a(dt)2. Our entities will always move at full speed, so a = 0 and s = s0 + (self.directions[self.direction]*self.speed) * dt.

This method is called from the Game’s update method after updating dt, (dt = self.fpsClock.tick(FPS) / 1000.0), e.g., self.pacman.update(dt). self.fpsClock is a timer and this line measures the amount of time that has passed since the last time the game’s update method was called.

    def update(self, dt):
        self.position += self.directions[self.direction]*self.speed*dt
         
        if self.overshotTarget():  # If the entity has overshot the target node, then we set the target node as the current node and find the next target node. 
            self.node = self.target
            directions = self.validDirections() # It gets a list of all valid directions.
            direction = self.directionMethod(directions) # It calls the method "directionMethod" to select one direction from the previous list.  
            if self.node.neighbors[PORTAL] is not None: # If the node is a portal, we set its paired node that is linked by the portal (node.neighbors[PORTAL]) as the new current node.
                self.node = self.node.neighbors[PORTAL]
            self.target = self.getNewTarget(direction) # It tries to find a new target node for the entity.
            if self.target is not self.node: # It means that it was successful, it has just found a new target for the entity, so we update the entity's direction accordingly. 
                self.direction = direction
            else: # If it failed to find a new target, we continue in the same direction.
                self.target = self.getNewTarget(self.direction)

            self.setPosition()

    # It returns a list with all valid directions that the entity can move in. If this list is empty, it means that the only valid direction is the one from which the entity came in. 
    def validDirections(self):
        directions = []
        for key in [UP, DOWN, LEFT, RIGHT]:
            if self.validDirection(key):
                if key != self.direction * -1:
                    directions.append(key)
        if len(directions) == 0:
            directions.append(self.direction * -1)
        return directions

We are going to use three different methods for selecting a direction from all the entity’s valid directions:

def update(self, dt):
        self.position += self.directions[self.direction]*self.speed*dt

        if self.overshotTarget():
            self.node = self.target
            directions = self.validDirections()
            direction = self.directionMethod(directions)
             [...]
# Game's mode (settings.py). It selects the ghosts' AI. CLASSSIC_MODE = 0 ADVANCED_MODE = 1 STUPID_MODE = 2 
GAME_MODE = CLASSSIC_MODE

The three methods are: randomDirection, goalDirection, and astar. The randomDirection is the method used to select a direction in STUPID_MODE. It just selects or picks a random direction.

    def randomDirection(self, directions):
        return directions[randint(0, len(directions)-1)]

    # It selects the direction that minimizes the entity's distance to its goal. Typically, a ghost will want to minimize its distance to Pac-Man.
    def goalDirection(self, directions):
        distances = []
        for direction in directions:
            vec = self.node.position + self.directions[direction]*TILEWIDTH - self.goal
            distances.append(vec.magnitude())
        index = distances.index(min(distances))
        return directions[index]

It selects the direction that minimizes the distance to Pac-Man. We use A* to find the path from the entity (one of the game’s ghosts) to Pac-Man on the maze. We use the code explained in Search algorithms: BFS, DFS, A* and we have written it in search.py. If there is a solution, we calculate the length of this path and add it to a list of distances. Finally, we select the direction that provides the shortest distance to Pac-Man.

    def astar(self, directions):
      distances = []
      for direction in directions:
        vec = self.node.position + self.directions[direction]*TILEWIDTH
        x, y = self.maze.getNodeFromPosition(vec.x, vec.y)
        x2, y2 = self.maze.getNodeFromPosition(self.pacman.position.x, self.pacman.position.y)
        myMaze = search.Maze(x, y, x2, y2, NROWS, NCOLS, 0.2, MAZEFILE)
        solution = myMaze.astar()
        if solution != None:
          distances.append(len(solution.path_solution()))
        else:
          distances.append(100000)

      index = distances.index(min(distances))
      return directions[index]

    # It resets the entity to its repose state (self.direction = STOP) in its starting node.
    def reset(self):
        self.setStartNode(self.startNode)
        self.direction = STOP
        self.setSpeed(NORMAL_SPEED)
        self.visible = True

PACMAN

We are going to implement a new class called Pac-Man. Yes, I know it! Next generations will write poetry and sing songs inspired by my creativity in coming up with names for my classes, methods, and objects.

It implements Pac-Man, the yellow, pie-shaped Pac-Man character, that travels around a maze trying to eat dots and avoiding four nasty ghosts. PacMan inherits from Entity.

import pygame
from pygame.locals import *
from vector import myVector
from settings import *
import copy
import math
from entity import Entity
from sprites import PacmanSprites

class Pacman(Entity):
    def __init__(self, app):
        Entity._init__(self, app.myMaze.getNodeFromTiles(15, 26), app.myMaze )
        self.name = PACMAN    
        self.color = YELLOW
        self.app = app # We need a pointer or reference to the game's object.
        self.direction = LEFT # Pac-Man is alive and is initially positioned between two maze's nodes and pointing to the left.
        self.setBetweenNodes(LEFT)
        self.alive = True
        self.sprites = PacmanSprites(self)

    def reset(self):
        Entity.reset(self)
        self.direction = LEFT
        self.setBetweenNodes(LEFT)
        self.alive = True
        self.image = self.sprites.getStartImage()
        self.sprites.reset()

    def die(self):
        self.alive = False
        self.direction = STOP

    def setStartNode(self, node):
        self.node = node
        self.startNode = node
        self.target = node
        self.setPosition()

    def eatPellets(self):
        """ It checks whether Pac-Man has collided with a pellet or not and returns the pellet or None."""
        for pellet in self.app.pellets:
            # It iterates through the game object's pellet list and checks if Pac-Man is colliding with one of them.
            if self.collideCheck(pellet):
                return pellet
        return None

    def collideGhost(self, ghost):
        """ It checks whether Pac-Man has collided with a ghost or not."""
        return self.collideCheck(ghost)

    def collideCheck(self, other):
        """ It checks whether Pac-Man has collided with "other" entity, such as a ghost, pellet, or fruit."""
        distanceSelfOther = (self.position - other.position).magnitude()
        return distanceSelfOther <= self.collideRadius + other.collideRadius

Pac-Man has collided with “other” if the distance between its position and other’s position is smaller than the sum of both entity’s collision radiuses.

Pac-Man’s collision radius should be its radius, but it looks far better if we shrink the collision radius just a little bit so it looks like the ghost is crashing against Pac-Man or the fruit or pellet is being ingested instead of just disappearing as soon as our yellow, pie-shaped Pac-Man character touches it.

    def updateDirectionTarget(self):
        # This method updates Pac-Man's direction based on the user's input. As we have seen in other tutorials, we always handle the events associated with the user's input in our game class, that's why we need a reference to the game object "self.app".
        if self.app.KEYUP:
            direction = UP
        elif self.app.KEYDOWN:
            direction = DOWN
        elif self.app.KEYLEFT:
            direction = LEFT
        elif self.app.KEYRIGHT:
            direction = RIGHT
        else:
            direction = STOP

        return direction

The following method updates Pac-Man’s position. Remember that s = s0 + v dt + 1/a(dt)2. Pac-Man always moves at full speed, so a = 0 and s = s0 + (self.directions[self.direction]*self.speed) * dt.

This method is called from the Game’s update method after updating dt, (dt = self.fpsClock.tick(FPS) / 1000.0), e.g., self.pacman.update(dt). self.fpsClock is a timer and this line measures the amount of time that has passed since the last time the game’s update method was called. This method is taken from PACMANCODE.

     def update(self, dt):
        self.sprites.update(dt)	
        self.position += self.directions[self.direction]*self.speed*dt
        direction = self.updateDirectionTarget()
        if self.overshotTarget(): # If Pac-Man has overshot the target node, then we set the target node as the current node and find the next target node. 
            self.node = self.target
            if self.node.neighbors[PORTAL] is not None: # If the node is a portal, we set its paired node that is linked by the portal (node.neighbors[PORTAL]) as the new current node.
                self.node = self.node.neighbors[PORTAL]
            self.target = self.getNewTarget(direction) # It tries to find a new target node for our main character Pac-Man.
            if self.target is not self.node: # It means that it was successful. It found a new target for Pac-Man, so we need to update its direction accordingly. 
                self.direction = direction
            else:  # It failed to find a new target; we continue in the same direction.
                self.target = self.getNewTarget(self.direction)

            if self.target is self.node: # If we find ourselves in the same node, then Pac-Man needs to stop. 
                self.direction = STOP
            self.setPosition()
        else: # If Pac-Man has not overshot the target node, then we need to check if "direction" is actually the opposite direction (the player has made a quick U-turn) and if so, update its node and target.
            self.reverseDirection(direction)
Bitcoin donation

JustToThePoint Copyright © 2011 - 2024 Anawim. ALL RIGHTS RESERVED. Bilingual e-books, articles, and videos to help your child and your entire family succeed, develop a healthy lifestyle, and have a lot of fun. Social Issues, Join us.

This website uses cookies to improve your navigation experience.
By continuing, you are consenting to our use of cookies, in accordance with our Cookies Policy and Website Terms and Conditions of use.