I have been coding 'Pac-Man' with my 16yo. A hopefully not too boring project to help improve his Python coding. We have just moved the 'Ghosts' into a class which was a good first introduction to objects for him.
My coding is far from perfect, and especially not in Python. Which was only just being invented when I was learning this stuff. I am looking for feedback on how to improve the professionalism of his code:
- how to be more 'pythonic'
- how to enable him to add more features to this project in the future
Clearly he has a long way to go. So looking for minor steps forwards that we can understand and work with rather than a whole sale rewrite please :)
Ultimately I would like to get him to implement some search algorithms for the ghosts - (BFS, A*, etc.), so ensuring the current structure is fit to do that within would be good.
Code provided below. Or as a zip with the textures etc. Currently the code runs but we have not got lives, ghosts killing pacman, levels, etc. coded yet.
#imports
import pygame
import os
import time
import math as maths
#Constants
# Text Positioning
CENTRE_MID = 1
LEFT_MID = 2
RIGHT_MID = 3
CENTRE_TOP = 4
LEFT_TOP = 5
RIGHT_TOP = 6
CENTRE_BOT = 7
LEFT_BOT = 8
RIGHT_BOT = 9
#Pacman Orientation
UP = 10
RIGHT = 11
LEFT = 12
DOWN = 13
HYPERJUMPALLOWED = True
HYPERJUMPNOTALLOWED = False
PIXEL = 20
FRAMERATE = 1
# DX and DY for each direction
NORTH = (0, -PIXEL)
SOUTH = (0, PIXEL)
EAST = (PIXEL, 0)
WEST = (-PIXEL, 0)
YELLOW = (255, 255, 102)
PALEYELLOW = (128, 128, 51)
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
BLUE = (0, 0, 255)
RED = (255,0,0)
BOARDPIXELWIDTH = 200
BOARDPIXELHEIGHT = 200
#Global Variables
gameOver = False
win = False
score = 0
#Dictionary mapping between board chars and gif's to display.
char_to_image = {'.' : pygame.transform.scale(pygame.image.load('pellet.gif'), (PIXEL, PIXEL)),
'=' : pygame.transform.scale(pygame.image.load('wall-nub.gif'), (PIXEL, PIXEL)),
'=T' : pygame.transform.scale(pygame.image.load('wall-end-b.gif'), (PIXEL, PIXEL)),
'=R' : pygame.transform.scale(pygame.image.load('wall-end-l.gif'), (PIXEL, PIXEL)),
'=L' : pygame.transform.scale(pygame.image.load('wall-end-r.gif'), (PIXEL, PIXEL)),
'=B' : pygame.transform.scale(pygame.image.load('wall-end-t.gif'), (PIXEL, PIXEL)) ,
'=TR' : pygame.transform.scale(pygame.image.load('wall-corner-ll.gif'), (PIXEL, PIXEL)),
'=TL' : pygame.transform.scale(pygame.image.load('wall-corner-lr.gif'), (PIXEL, PIXEL)),
'=BR' : pygame.transform.scale(pygame.image.load('wall-corner-ul.gif'), (PIXEL, PIXEL)),
'=BL' : pygame.transform.scale(pygame.image.load('wall-corner-ur.gif'), (PIXEL, PIXEL)),
'=TB' : pygame.transform.scale(pygame.image.load('wall-straight-vert.gif'), (PIXEL, PIXEL)),
'=RL' : pygame.transform.scale(pygame.image.load('wall-straight-horiz.gif'), (PIXEL, PIXEL)),
'=LTR' : pygame.transform.scale(pygame.image.load('wall-t-bottom.gif'), (PIXEL, PIXEL)),
'=TRB' : pygame.transform.scale(pygame.image.load('wall-t-left.gif'), (PIXEL, PIXEL)),
'=BLT' : pygame.transform.scale(pygame.image.load('wall-t-right.gif'), (PIXEL, PIXEL)),
'=RBL' : pygame.transform.scale(pygame.image.load('wall-t-top.gif'), (PIXEL, PIXEL)),
'=TRLB' : pygame.transform.scale(pygame.image.load('wall-x.gif'), (PIXEL, PIXEL)),
'U' : pygame.transform.scale(pygame.image.load('pacman-u 4.gif'), (PIXEL, PIXEL)),
'R' : pygame.transform.scale(pygame.image.load('pacman-r 4.gif'), (PIXEL, PIXEL)),
'L' : pygame.transform.scale(pygame.image.load('pacman-l 4.gif'), (PIXEL, PIXEL)),
'D' : pygame.transform.scale(pygame.image.load('pacman-d 4.gif'), (PIXEL, PIXEL)),
'!P' : pygame.transform.scale(pygame.image.load('Pinky.gif'), (PIXEL, PIXEL)),
'!P.' : pygame.transform.scale(pygame.image.load('Pinky.gif'), (PIXEL, PIXEL)),
'!B' : pygame.transform.scale(pygame.image.load('Blinky.gif'), (PIXEL, PIXEL)),
'!B.' : pygame.transform.scale(pygame.image.load('Blinky.gif'), (PIXEL, PIXEL)),
'!I' : pygame.transform.scale(pygame.image.load('Inky.gif'), (PIXEL, PIXEL)),
'!I.' : pygame.transform.scale(pygame.image.load('Inky.gif'), (PIXEL, PIXEL)),
'!C' : pygame.transform.scale(pygame.image.load('Clyde.gif'), (PIXEL, PIXEL)),
'!C.' : pygame.transform.scale(pygame.image.load('Clyde.gif'), (PIXEL, PIXEL)),
}
#Class stuff
class Ghost:
def __init__(self, ghostPixelX, ghostPixelY, sprite):
print("Init " + sprite)
self.ghostPixelX = ghostPixelX
self.ghostPixelY = ghostPixelY
self.sprite = sprite
def draw(self):
#print("draw " + self.sprite)
dis.blit(char_to_image[self.sprite], (self.ghostPixelX, self.ghostPixelY))
def erase(self):
#print("erase " + self.sprite)
# Erase Ghost by drawing black rectangle over it
pygame.draw.rect(dis, BLACK, [self.ghostPixelX, self.ghostPixelY, PIXEL, PIXEL])
boardX = int(self.ghostPixelX/PIXEL)
boardY = int(self.ghostPixelY/PIXEL)
# If the space contains food, redraw the food
if "." in board[boardY][boardX]:
dis.blit(char_to_image["."], (self.ghostPixelX, self.ghostPixelY))
def move(self, pacManPixelX, pacManPixelY):
#print("PreMove: " + str(self.sprite) + " " + str(self.ghostPixelX) + " " + str(self.ghostPixelY))
#if score moves, so does directions
#Sorts directions to which direction is best to take
directions = [NORTH, EAST, SOUTH, WEST]
score = ["","","",""] #Which move is best
#Calculate distance between Ghost and PacMan
pixelDistanceX = pacManPixelX - self.ghostPixelX
pixelDistanceY = pacManPixelY - self.ghostPixelY
pixelDistance = maths.sqrt(pixelDistanceX**2 + pixelDistanceY**2)
#Calculate distance between Ghost and PacMan after a move in each direction
for i, direction in enumerate(directions):
ghostDX, ghostDY = direction
newGhostPixelX = self.ghostPixelX + ghostDX
newGhostPixelY = self.ghostPixelY + ghostDY
newPixelDistanceX = pacManPixelX - newGhostPixelX
newPixelDistanceY = pacManPixelY - newGhostPixelY
newPixelDistance = maths.sqrt(newPixelDistanceX**2 + newPixelDistanceY**2)
#Store how much better (closer) or worse (further away) the move would take the ghost from PacMan
score[i] = pixelDistance - newPixelDistance
#Insertion sort O(n)
#Iterates through the list for the next number to sort (start at pos 1)
for index in range(1, len(score)):
currentEntry = score[index]
currentEntryDir = directions[index]
position = index
#Iterates through the list for the number to swap
while position > 0 and score[position-1] > currentEntry:
#Copies the lower position into the original position, overwriting it
score[position] = score[position-1]
directions[position] = directions[position-1]
position = position - 1
#puts the stored value from position, into the final lower position
score[position] = currentEntry
directions[position] = currentEntryDir
# Take the now sorted list of moves, trying each one in turn and take the best move possible
for direction in reversed(directions):
ghostDX, ghostDY = direction
newGhostPixelX = self.ghostPixelX + ghostDX
newGhostPixelY = self.ghostPixelY + ghostDY
# Ghosts cant hyperjump
if newGhostPixelX >= 0 and newGhostPixelX < BOARDPIXELWIDTH and newGhostPixelY >= 0 and newGhostPixelX < BOARDPIXELHEIGHT:
# Ghosts can't go through walls
if TestMove(newGhostPixelX, newGhostPixelY, HYPERJUMPNOTALLOWED):
#print(direction)
self.ghostPixelX = newGhostPixelX
self.ghostPixelY = newGhostPixelY
#print("PostMove: " + str(self.sprite) + " " + str(self.ghostPixelX) + " " + str(self.ghostPixelY))
print("")
return
#Functions
# Load Board from a file in current directory
# Boards are text files called "board-X.txt"
def LoadBoard():
#ToDo load board from file
#10 x 10 Board
board = [['=BR', '=RL', '=RL', '=L', 'O', '.', '=R', '=RL', '=RL', '=BL'],
['=TB', '!B.', '.', '.', '.', '.', '.', '.', '!I.', '=TB'],
['=TB', '.', '=BR', '=L', '.', '.', '=R', '=BL', '.', '=TB'],
['=T', '.', '=T', '.', '.', '.', '.', '=T', '.', '=T'],
['.', '.', '.', '.', '.', 'U', '.', '.', '.', 'O'],
['O', '.', '.', '.', '.', '.', '.', '.', '.', '.'],
['=B', '.', '=B', '.', '.', '.', '.', '=B', '.', '=B'],
['=TB', '.', '=TR', '=L', '.', '.', '=R', '=TL', '.', '=TB'],
['=TB', '!C.', '.', '.', '.', '.', '.', '.', '!P.', '=TB'],
['=TR', '=RL', '=RL', '=L', '.', 'O', '=R', '=RL', '=RL', '=TL']]
global foodTotal
global pacManPixelX, pacManPixelY, pacManFacing, pacManDX, pacManDY
global Pinky, Blinky, Inky, Clyde
foodTotal = 0
pacManPixelX = pacManPixelY = pacManDX = pacManDY = 0
pacManFacing = UP
#ToDo Load Board Pixel Width and Height here and delete from top of this file
for boardY, line in enumerate(board):
for boardX, symbol in enumerate(line):
if symbol == ".":
foodTotal +=1 # Count how much food we start with
elif symbol == "!P." or symbol == "!P": #Which Ghost is it?
Pinky = Ghost(boardX * PIXEL, boardY * PIXEL, "!P") #Create the ghost!
elif symbol == "!B." or symbol == "!B":
Blinky = Ghost(boardX * PIXEL, boardY * PIXEL, "!B")
elif symbol == "!I." or symbol == "!I":
Inky = Ghost(boardX * PIXEL, boardY * PIXEL, "!I")
elif symbol == "!C." or symbol == "!C":
Clyde = Ghost(boardX * PIXEL, boardY * PIXEL, "!C")
elif symbol == "U":
pacManPixelX = boardX * PIXEL # Get PacMan starting position
pacManPixelY = boardY * PIXEL
return board
#Draw Board
def DrawBoard():
for y, line in enumerate(board):
# Convert from board PIXEL to real PIXEL
y *= PIXEL
for x, symbol in enumerate(line):
# Convert from board PIXEL to real PIXEL
x *= PIXEL
# Convert board chars to gif filename using dictionary
if symbol != "O":
dis.blit(char_to_image[symbol], (x, y))
#Test if Character can move to new location
def TestMove(newPixelX, newPixelY, hyperJumpAllowed):
#TODO This is used for Ghosts and PacMan, Ghosts are not allowed to move in to a square already occupied by a Ghost
# Pacman is, but then will die
if newPixelX >= BOARDPIXELWIDTH or newPixelY >= BOARDPIXELHEIGHT or newPixelX < 0 or newPixelY < 0:
if (hyperJumpAllowed):
#If move would be a HyperJump, and HypeJumps are allowed then move must be ok
return True
else:
#If move would be a HyperJump, and HypeJumps are not allowed then move must not be ok
return False
newBoardX = int(newPixelX/PIXEL)
newBoardY = int(newPixelY/PIXEL)
#Test if move would end up in a wall
if "=" in board[newBoardY][newBoardX]:
return False
else:
return True
#Move PacMan to new location, but dont draw the update
def MovePacMan(pixelX, pixelY, dPixelX, dPixelY, facing):
# Move PacMan
newPixelX = pixelX + dPixelX
newPixelY = pixelY + dPixelY
# Check if move needs to be a HyperJump and if so HyperJump
if (newPixelX >= BOARDPIXELWIDTH):
newPixelX = 0
elif (newPixelX < 0):
newPixelX = BOARDPIXELWIDTH - PIXEL
if (newPixelY >= BOARDPIXELHEIGHT):
newPixelY = 0
elif (newPixelY < 0):
newPixelY = BOARDPIXELHEIGHT - PIXEL
return newPixelX, newPixelY
def moveGhosts(pacManPixelX, pacManPixelY):
Pinky.move(pacManPixelX, pacManPixelY)
Blinky.move(pacManPixelX, pacManPixelY)
Inky.move(pacManPixelX, pacManPixelY)
Clyde.move(pacManPixelX, pacManPixelY)
def eraseGhosts():
Pinky.erase()
Blinky.erase()
Inky.erase()
Clyde.erase()
def ErasePacMan(pixelX, pixelY):
# Erase PacMan from old position by drawing black rectangle over it
pygame.draw.rect(dis, BLACK, [pixelX, pixelY, PIXEL, PIXEL])
def drawGhosts():
Pinky.draw()
Blinky.draw()
Inky.draw()
Clyde.draw()
#Draw PacMan at a new position
def DrawPacMan(pixelX, pixelY, facing):
# Draw PacMan at new position
if facing == UP:
dis.blit(char_to_image['U'], (pixelX, pixelY))
elif facing == DOWN:
dis.blit(char_to_image['D'], (pixelX, pixelY))
elif facing == LEFT:
dis.blit(char_to_image['L'], (pixelX, pixelY))
elif facing == RIGHT:
dis.blit(char_to_image['R'], (pixelX, pixelY))
# Remove food at new board position
board[int(pixelY / PIXEL)][int(pixelX / PIXEL)] = "O"
#Play sounds as PacMan eats
def PlaySound(pixelX, pixelY):
boardX = int(pixelX / PIXEL)
boardY = int(pixelY / PIXEL)
#Play sound if new position has food
if board[boardY][boardX] == ".":
# Alternate between two different sounds
if (boardX + boardY) % 2 == 0:
food1Sound.play()
else:
food2Sound.play()
else:
defaultSound.play()
def message(msg, color, pixelX, pixelY, fontSize, align):
#Setup font
font_style = pygame.font.SysFont("bahnschrift", fontSize)
# Render text ont a surface
msgRendered = font_style.render(msg, True, color)
# Get size of surface
msgPixelWidth, msgPixelHeight = msgRendered.get_size()
# Change position to draw in relation to align
if align == CENTRE_MID:
pixelX = pixelX - (msgPixelWidth / 2)
pixelY = pixelY - (msgPixelHeight / 2)
elif align == CENTRE_TOP:
pixelX = pixelX - (msgPixelWidth / 2)
dis.blit(msgRendered, [pixelX, pixelY])
#Main Code
pygame.init()
#Setup display and pygame clock
dis = pygame.display.set_mode((BOARDPIXELWIDTH, BOARDPIXELHEIGHT + ( 2 * PIXEL)))
pygame.display.set_caption('Pac-man by ME')
clock = pygame.time.Clock()
#Setup Sounds
if os.path.isfile("1-pellet1.wav") and os.path.isfile("1-pellet2.wav") and os.path.isfile("1-default.wav"):
sound = True
food1Sound = pygame.mixer.Sound("1-pellet1.wav")
food2Sound = pygame.mixer.Sound("1-pellet2.wav")
defaultSound = pygame.mixer.Sound("1-default.wav")
else:
print("Warning: Sound files not found, not playing sounds.")
sound = False
#Load board from file
#ToDo Load random board or different board each level
board = LoadBoard()
#Draw Board
DrawBoard()
pygame.display.flip()
#Game Loop
while not gameOver:
for event in pygame.event.get():
#Allows quitting
if event.type == pygame.QUIT:
gameOver = True
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_LEFT:
pacManFacing = LEFT
pacManDX, pacManDY = WEST
#pacManDX = -PIXEL
#pacManDY = 0
elif event.key == pygame.K_RIGHT:
pacManFacing = RIGHT
pacManDX, pacManDY = EAST
#pacManDX = PIXEL
#pacManDY = 0
elif event.key == pygame.K_UP:
pacManFacing = UP
pacManDX, pacManDY = NORTH
#pacManDY = -PIXEL
#pacManDX = 0
elif event.key == pygame.K_DOWN:
pacManFacing = DOWN
pacManDX, pacManDY = SOUTH
#pacManDY = PIXEL
#pacManDX = 0
#Can we move to new position?
if TestMove(pacManPixelX + pacManDX, pacManPixelY + pacManDY, HYPERJUMPALLOWED):
#Erase PacMan
ErasePacMan(pacManPixelX, pacManPixelY)
#Calculate new position
pacManPixelX, pacManPixelY = MovePacMan(pacManPixelX, pacManPixelY, pacManDX, pacManDY, pacManFacing)
#print("pacManPixelX " + str(pacManPixelX) + " pacManPixelY " + str(pacManPixelY))
if board[int(pacManPixelY / PIXEL)][int(pacManPixelX / PIXEL)] == ".":
score+=1
foodTotal-=1
#Sound
if sound:
PlaySound(pacManPixelX, pacManPixelY)
#Draw the turn and remove food
DrawPacMan(pacManPixelX, pacManPixelY, pacManFacing)
#Update the score
pygame.draw.rect(dis, BLACK, [0, BOARDPIXELHEIGHT, BOARDPIXELWIDTH, PIXEL])
message(("You're score is " +str(score)), RED, 0, (BOARDPIXELHEIGHT), 15, LEFT_TOP)
# Ghosts
eraseGhosts()
#Calculate new Ghost position
moveGhosts(pacManPixelX, pacManPixelY)
#Draw new Ghost positions on the screen
drawGhosts()
pygame.display.update()
#TODO Has the ghost caughtPacMan, if so pacman looses 1 of 3 lives.
# So need lives system - 3 pacmen bottom right of screen that get 'used up' each time one dies
# What happens when Pacman dies? Ghosts get reset, pacman gets reset, score -10 and then carry on?
# Hint, pac man moves first, so when each ghost moves you can test if it has hit pacman
#if ghostPixelX == pacManPixelX and ghostPixelY == pacManPixelY:
# gameOver = True
#Win
if foodTotal == 0:
gameOver = True
win = True
#Tick the clock
clock.tick(FRAMERATE)
if win == True:
pygame.draw.rect(dis, YELLOW, [0, 0, BOARDPIXELWIDTH, BOARDPIXELHEIGHT])
message(("You Win!"), RED, BOARDPIXELWIDTH / 2, BOARDPIXELHEIGHT / 2, 15, CENTRE_MID)
message(("This message will dissapear in 5 seconds"), RED, (BOARDPIXELWIDTH / 2), (BOARDPIXELHEIGHT / 2 + PIXEL), 10, CENTRE_TOP)
pygame.display.update()
time.sleep(5)
else:
pygame.draw.rect(dis, RED, [0, 0, BOARDPIXELWIDTH, BOARDPIXELHEIGHT])
message(("You Lose!"), YELLOW, BOARDPIXELWIDTH / 2, BOARDPIXELHEIGHT / 2, 15, CENTRE_MID)
message(("This message will dissapear in 5 seconds"), YELLOW, (BOARDPIXELWIDTH / 2), (BOARDPIXELHEIGHT / 2 + PIXEL), 10, CENTRE_TOP)
pygame.display.update()
time.sleep(5)
pygame.quit()
quit()