
The time has come to refactor our code! Okay, please don't fall asleep here, I can't keep things simple on this blog unless we refactor stuff together (!)...
In our previous tutorial, we made a game town with a sprite that could walk around and talk to people. Now I could build further and further on this, but this will lead to a mess of a code. So before we take more steps forward, we need to take a few steps back and refactor our code.
In this edition, we will focus on:
- Spinning off bits of code into separate libraries, to simplify main.py and especially the main loop in it.
- Reworking the background image into something more extensible.
Our starting point is the GameWorld-Town repl.it that we made in the last tutorial. Because we are going to modify quite a lot of code, I'm going to keep my explanations a bit higher level than before, but I hope you can take along some key refactoring tricks from all this :).
Spinning off the NPCs into a separate library
In our previous tutorial we created and visualized a set of three NPCs, and even created a class for it in the main file. However, there is still a lot of technical details in the main repl.it, which I'd like to have separate. To do so, I make a new NPC.py file with the following contents first:
import pygame
class NPC:
def __init__(self, x, y, txt, img):
self.x = x
self.y = y
self.txt = txt
self.img = img
self.sprite = pygame.image.load(img)
def collides(self, x, y):
"""
Return: True if sprite overlaps with (x,y),
False otherwise.
"""
if self.x - 24 < x < self.x + 24 and self.y - 48 < y < self.y + 48:
return True
return False
This class defines the NPC object in more detail. In addition to having the original fields, I also incorporated the code which detects whether the NPC collides (overlaps) with an object that's located at coordinates (x,y). That way, we can leave out those details (which are not likely to change anytime soon) out of the main file.
For convenience I also want to make a class to run functions on all the NPCs in the town at the same time. To do that, I developed this NPCs class:
class NPCs:
def __init__(self):
self.npcs = []
def add(self, x, y, txt, img):
self.npcs = self.npcs + [NPC(x, y, txt, img)]
def detect_collision(self, px, py):
for k, e in enumerate(self.npcs):
if e.collides(px, py):
return k
return -1
def show(self, screen, x, y, width, height):
for i in self.npcs:
screen.blit(i.sprite, (i.x-x, i.y-y), area=(0, 100, 48, 48))
def txt(self, index):
return self.npcs[index].txt
This class has the following functionalities:
- It can create an empty array of NPCs.
- It can add an NPC easily, with a direct
add()
function.
- It can detect whether an object at (x,y) collides with any of the NPCs with a single function.
- It can render all the NPCs to the screen.
- It can pass back the text that corresponds to a given NPC ID number (this is passed back by the
detect_collision()
function).
Another big chunk of code in our original file was dedicated to the keypresses for movement, so let's spin that out to a separate file next!
Move Controls
To do this, I make a new file called MoveControls.py. Instead of making a class (which seems unnecessary), I simply make one large function that handles all the controls here. So the file then contains the following:
import pygame
def GetUpdatedMove(x, y, map_width, map_height):
keys = pygame.key.get_pressed()
newx = x
newy = y
newf = 0
if keys[pygame.K_LEFT]:
newx -= 5
newx = max(0, newx)
newf = 3
if keys[pygame.K_RIGHT]:
newx += 5
newx = min(newx, map_width)
newf = 1
if keys[pygame.K_UP]:
newy -= 5
newy = max(0, newy)
newf = 0
if keys[pygame.K_DOWN]:
newy += 5
newy = min(newy, map_height)
newf = 2
return newx, newy, newf
Now what I did here is remove the collision part (we refactored that in NPCs.py), but instead just return intended locations and facings whenever keys are pressed. We will only accept the new locations if there is no collision occuring, which we will check for in the main file.
Although I intended to just simplify the code, I actually improved it because the previous version allowed players to move diagonally into obstables, whereas this one doesn't. Sometimes simplifying code indeed can come with side-benefits :).
Next up is that background image...
Tiles

“Hey, where did your cool background image go? :(”
Yes, it's gone. Background images are nice, but it'll be very tedious to draw complete towns time and time again. What is much easier is to draw tiles (say of 48x48 pixels) and then to stitch them together to form a map (see above). Now, I kept the tiles super-simple (they're just colored squares) because I want to do a separate Coil tutorial on that later on. But at least as part of this refactoring, I can get some essential data structures for it in place :).
So the file is called Tiles.py, and I'll go through it in a bit more detail as it is new code. First we need two simple imports:
import numpy as np
import pygame
Next, we declare a class to contain all the tiles, with a constructor function:
class Tiles:
def __init__(self, xsize=40, ysize=40):
This function will construct a tilemap of xsize, ysize, that we can render to the screen, and that will contain an ID for each tile:
self.tiles = np.zeros((xsize, ysize), dtype=int)
Later on, the ID may map to all sorts of cool tile objects, but to keep it simple we now just map the ID numbers to a small color map:
self.tile_colors = [(0,255,0),(128,128,0),(0,0,255)]
Oh, and we want to have the tilesize defined as a constant too:
self.tilesize = 48
Now we can construct a Tiles object, we should also have a function to easily render it to the screen. This we will call show():
def show(self, screen, x, y, width, height):
First we calculate which tiles we actually need to show on the screen. This is based on the screen resolution (width x height) as well as the x and y position in world coordinates of the top left corner of the screen:
txstart = int(x/self.tilesize)
txend = int(np.ceil((x + width) / self.tilesize))
tystart = int(y/self.tilesize)
tyend = int(np.ceil((y + height) / self.tilesize))
Next, we build a loop to go through those tiles:
for i in range(txstart, txend):
for j in range(tystart, tyend):
In the loop, we calculate the starting x position (xtile) and y position of each tile, as well as its corresponding color:
xtile = i * self.tilesize - x
ytile = j * self.tilesize - y
color = self.tile_colors[self.tiles[i, j]]
And finally, we create a rectangle and draw it!
rect = pygame.Rect(xtile, ytile, self.tilesize, self.tilesize)
pygame.draw.rect(screen, color, rect)
With all that in place, we can now go back to the main file.
Main File Revamped!
Our revamped main.py file now looks as follows. We start with some imports:
import pygame
import time
import numpy as np
from NPC import NPCs
from Tiles import Tiles
import MoveControls
, followed by some initializations we've done before:
pygame.init()
width, height = 800, 600
backgroundColor = 0, 0, 0
screen = pygame.display.set_mode((width, height))
screen.fill(backgroundColor)
pl_img = pygame.image.load('seolean.gif')
map_width = 1920
map_height = 1920
tilesize = 48
Now, I've added some code to create a few colored tiles to the map. It's very basic, just for adding some slight variety...
# We add stuff!
tiles = Tiles(40, 40)
tiles.tiles[4, 4] = 2 # a small pond
tiles.tiles[:, 19] = 2 # a straight river
tiles.tiles[23:27, 23:27] = 1 # a dirt patch
tiles.tiles[6:7, 33:35] = 2 # a small lake
and of course, we want to add the NPCs again, which we now do like this (I hope this is more readable than the old code...).
npcs = NPCs()
npcs.add(400, 600, "Hello!", "seolean.gif")
npcs.add(900, 600, "Stay on your guard.","guard.png")
npcs.add(500, 1100, "I am doing archery, watch out for my arrows!", "archer.png")
We have some more constants for the Player and the font (which we didn't spin off in a separate file...yet).
x = 0
y = 0
px = 0
py = 0
f = 2 # 0 up, 1 right, 2 down, 3 left
show_text = False
npc_text = ""
pygame.font.init()
myfont = pygame.font.SysFont('Comic Sans MS', 30)
And I also made separate drawing functions for both the player and the text box, just to make the main loop look even simpler:
def DrawTextBox(screen, text):
pygame.draw.rect(screen, [136,136,136], pygame.Rect(100,520,600,75))
text_surface = myfont.render(text, False, (0, 0, 0))
screen.blit(text_surface, (105,525))
def DrawPlayer(screen, x, y, px, py):
screen.blit(pl_img, (px - x, py - y), area=(0, f * 50, 48, 48))
Now, here is the main loop. The old one was 59 lines, but you'll find this one is a lot shorter and simpler. And because many complicated things will be added around the main loop, I especially wanted to simplify this part in my refactoring :). Here is what it looks like:
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
break
# 1. movement and collision
newx, newy, f = MoveControls.GetUpdatedMove(px, py, map_width-tilesize+1, map_height-tilesize+1)
coll = npcs.detect_collision(newx, newy)
if coll == -1:
px = newx
py = newy
show_text = False
else:
show_text = True
npc_text = (npcs.txt(coll))
# 2. calculate screen offset
x = max(0, min(px - int(width/2), map_width-width))
y = max(0, min(py - int(height/2), map_height-height))
# 3. draw and render
tiles.show(screen, x, y, width, height)
DrawPlayer(screen, x, y, px, py)
npcs.show(screen, x, y, width, height)
if show_text:
DrawTextBox(screen, npc_text)
pygame.display.flip()
time.sleep(20 / 1000)
It's 30 lines in total (about half the size), and if you have followed the previous tutorial as well as this one, you'll find that it's now pretty much self-explanatory.
Closing thoughts
Making a more modular code and a simpler main loop was exactly what I intended to achieve, and I hope you spotted some nice refactoring tricks along the way.
When refactoring, there are a few simple aspects to keep in mind:
- Make sure that you spin off parts of the code that are relatively self-contained.
- Make sure you simplify that part of the code that you think you will be interacting with / developing on the most.
- Always test as you refactor, don't wait until the end.
- There's no need to be wedded to conventions. Object orientation is often useful, but sometimes a single method (like with the MoveControls) can do the trick to. Also, because of the layout of repl.it, I actually find that 2-space indentation gives more readable code, even though coding convention enforcement plugins keep shouting at me to do 4-space indentation ;).
With all that being said, all that remains from me is a link to the Repl (see below), and a bit of a preview about future editions for the subscribers. See you next time! :)
Credits
Header image courtesy of Wikipedia user Xarawn, showcasing the role of refactoring.
Appendix A: The Repl
Here it is :).