In this post, the addition of the following features to the game engine will be covered:
- General Enhancements (Making the numbers of rays scale based on the resolution and splitting the raycasting and drawing functionality into separate functions).
- Addition of Sound (for now, only footsteps).
- Hiding the mouse cursor.
- Add a fullscreen flag (set to run game in fullscreen or window mode).
- Adding static flat sprites (same image from all directions).
- Convert player angle to 0-360 degree angle (removing the potential for negative values).
- Add collision detection with sprites.
- Implement a Z-buffer.
- Add multi-angle sprites (different images from different viewing angles).
General Enhancements
To scale the number of rays to the resolution the following logic is added in the settings.py file:
NUM_RAYS = int(resX / 4) # Would work with all standard resolutions
All logic related to drawing images to the screen has now been removed from the raycasting function and moved to the drawing.py file. This is done for future extensibility and to facilitate the drawing of items other than walls.
Sound
In order to add the sound of footsteps, the sound clip needs to be loaded into a variable:
step_sound = pygame.mixer.Sound('assets/audio/footstep.wav')
And then, every time the player moves, the clip is played, but first, a check is done to ensure the sound is not already playing. This is to avoid the sound playing over itself, resulting in an audio mess:
if not pygame.mixer.Channel(2).get_busy():
pygame.mixer.Channel(2).play(pygame.mixer.Sound(step_sound))
Hide Mouse Cursor and Fullscreen
To hide the mouse cursor and add a fullscreen flag, the following code was added to the main.py file:
pygame.mouse.set_visible(False)
screen = pygame.display.set_mode((resX, resY), SET_FULLSCREEN)
With the SET_FULLSCREEN flag begging defined and set in the settings.py file.
Here is the source code with the changes up to this point.
Static Sprites
The next major thing added was static sprites.
Let us now examine how sprites are rendered in the game engine.
Sprites are image files (png files with transparency) that are scaled and positioned to create the appearance of a tangible object in the pseudo-3D world.
The image below illustrates the values at play for determining the sprite positioning and scaling:
Thus
gamma (γ) = theta (θ) – player angle(a)
and
theta (θ) = atan2 (player y, player x)
Where atan2 is used to determine the arctangent of point (y, x) in radians, it has a potential value between -π and π.
The distance from the player to the sprite is calculated as follows:
Distance to Sprite (d) = sqrt(player x ** 2 + player y ** 2) * cos((HALF_FOV – current_ray * DELTA_ANGLE)
where
current_ray = CENTER_RAY + delta_rays
and
delta_rays = int(gamma / DELTA_ANGLE)
and
CENTER_RAY = NUM_RAYS // 2 – 1
Here is the code of how this is implemented:
def locate_sprite(self, player):
dx, dy = self.x - player.x, self.y - player.y
distance_to_sprite = math.sqrt(dx ** 2 + dy ** 2)
theta = math.atan2(dy, dx)
gamma = theta - player.angle
if dx > 0 and 180 <= math.degrees(player.angle) <= 360 or dx < 0 and dy < 0:
gamma += DOUBLE_PI
delta_rays = int(gamma / DELTA_ANGLE)
current_ray = CENTER_RAY + delta_rays
distance_to_sprite *= math.cos(HALF_FOV - current_ray * DELTA_ANGLE)
sprite_ray = current_ray + SPRITE_RAYS
if 0 <= sprite_ray <= SPRITE_RAYS_RANGE and distance_to_sprite > 30:
projected_height = min(int(WALL_HEIGHT / distance_to_sprite * self.scale), resY*2)
half_projected_height = projected_height // 2
shift = half_projected_height * self.shift
sprite = pygame.transform.scale(self.sprite_object, (projected_height, projected_height))
return {'image': sprite, 'x': (current_ray * SCALE - half_projected_height), 'y': (HALF_HEIGHT - half_projected_height + shift), 'distance': distance_to_sprite}
else:
return None
This logic for this is implemented in the sprite.py file.
For the above logic to function, the player.angle needs to have a value of 0 to 360. This is done by adding the following line to the movement function in the Player class:
self.angle %= DOUBLE_PI
Here is the source code with static sprite feature added.
Sprite Collision Detection
The next feature added was collision detection with sprites. This functions in the same way as collision detection with walls.
A new dictionary similar to world_map was created called sprite_map, this is used to store the location of all sprites in the game world.
self.sprite_map = {} # used for collision detection with sprites
sprite_location = common.align_grid(sprite.x, sprite.y)
self.sprite_map[sprite_location] = 'sprite'
Next, the player collision detection function was updated as below:
def check_collision(self, new_x, new_y, sprite_map):
player_location = align_grid(new_x, new_y)
if player_location in world_map or player_location in sprite_map:
# collision
print("Center Collision" + str(new_x) + " " + str(new_y))
return
player_location = align_grid(new_x - HALF_PLAYER_MARGIN, new_y - HALF_PLAYER_MARGIN)
if player_location in world_map or player_location in sprite_map:
# collision
print("Top Left Corner Collision" + str(new_x) + " " + str(new_y))
return
player_location = align_grid(new_x + HALF_PLAYER_MARGIN, new_y - HALF_PLAYER_MARGIN)
if player_location in world_map or player_location in sprite_map:
# collision
print("Top Right Corner Collision" + str(new_x) + " " + str(new_y))
return
player_location = align_grid(new_x - HALF_PLAYER_MARGIN, new_y + HALF_PLAYER_MARGIN)
if player_location in world_map or player_location in sprite_map:
# collision
print("Bottom Left Corner Collision" + str(new_x) + " " + str(new_y))
return
player_location = align_grid(new_x + HALF_PLAYER_MARGIN, new_y + HALF_PLAYER_MARGIN)
if player_location in world_map or player_location in sprite_map:
# collision
print("Bottom Right Corner Collision" + str(new_x) + " " + str(new_y))
return
if not pygame.mixer.Channel(2).get_busy():
pygame.mixer.Channel(2).play(pygame.mixer.Sound(self.step_sound))
self.x = new_x
self.y = new_y
Here is the source code with sprite collision detection implemented.
Z-Buffer
A Z-buffer is implemented as a storage location for all items (walls and sprites) that have to be drawn to the screen. The content of the Z-buffer is sorted by depth, ensuring that items are rendered in the correct sequence, and items behind other items are thus not visible to the player.
The Z-buffer is implemented as a list of dictionaries, with the structure of the dictionary defined as follows:
{'image': value, 'x': value, 'y':value, 'distance': value}
All walls and sprites to be drawn to the screen are added to the Z-buffer and sorted by distance from the player, starting with the items with the largest distance.
In the drawing.py file the following method is then used to sort the Z-buffer and draw its contents to the screen:
def world(self, zbuffer):
zbuffer = sorted(zbuffer, key=lambda k: k['distance'], reverse=True)
# Sort items by distance to ensure they are drawn in correct sequence, i.e. an item is not drawn in front
# another if it is closer than other object.
for item in zbuffer:
self.screen.blit(item['image'], (item['x'], item['y']))
Multi-Angle Sprites
Multi-angle sprites are sprites where the image rendered to the screen changes based on the player’s viewing angle. This gives the illusion of a 3D object with a front, sides, and a back.
Instead of loading a single image for the sprite, multiple images are loaded into a list as follows:
[pygame.image.load(f'assets/images/sprites/enemy/zombie/{i}.png').convert_alpha()for i in range(4)]
In the above code snippet, four images are loaded (front, left, right, and back), which will result in a choppy rotation effect. Ideally, at least eight images (i.e., angles) would be used for a smoother effect.
In the constructor of the SpriteBase class the following is added:
if not static:
sprite_angle_delta = int(360 / len(self.sprite_object)) # Used to determine at what degree angle to
# change the sprite image- this is based on the number of images loaded for the item.
self.sprite_angles = [frozenset(range(i, i + sprite_angle_delta)) for i in range(0, 360, sprite_angle_delta)]
self.sprite_positions = {angle: pos for angle, pos in zip(self.sprite_angles, self.sprite_object)}
self.sprite_object = sprite_object[0] # set a default image until correct one is selected
This is used to set the angles at which the image should be changed based on the number of images present and also set the sprite position based on the different angles.
The only other code needed to make this function is adding the following to the locate_sprite function in the SpriteBase class :
if not self.static:
if theta < 0:
theta += DOUBLE_PI
theta = 360 - int(math.degrees(theta))
for angles in self.sprite_angles:
if theta in angles:
self.sprite_object = self.sprite_positions[angles]
break
The above code selects the correct image from the list based on the player’s viewing angle.
Here is the complete sprite.py file with all the above changes included:
import common
from settings import *
class Sprites:
def __init__(self):
self.sprite_types = {
'clock': pygame.image.load('assets/images/sprites/objects/Clock.png').convert_alpha(),
'zombie': pygame.image.load('assets/images/sprites/enemy/zombie.png').convert_alpha(),
'zombie360': [pygame.image.load(f'assets/images/sprites/enemy/zombie/{i}.png').convert_alpha()for i in range(4)],
}
self.list_of_sprites = [
SpriteBase(self.sprite_types['clock'], True, (5, 10), 0.6, 1.1),
SpriteBase(self.sprite_types['zombie'], True, (5, 12), 0.6, 1.1),
SpriteBase(self.sprite_types['zombie360'], False, (14, 10), 0.6, 1.1),
]
self.update_sprite_map()
def update_sprite_map(self):
self.sprite_map = {} # used for collision detection with sprites - this will need to move when sprites can move
for sprite in self.list_of_sprites:
sprite_location = common.align_grid(sprite.x, sprite.y)
self.sprite_map[sprite_location] = 'sprite'
class SpriteBase:
def __init__(self, sprite_object, static, pos, shift, scale):
self.sprite_object = sprite_object
self.static = static
self.pos = self.x, self.y = pos[0] * GRID_BLOCK, pos[1] * GRID_BLOCK
self.shift = shift
self.scale = scale
if not static:
sprite_angle_delta = int(360 / len(self.sprite_object)) # Used to determine at what degree angle to
# change the sprite image- this is based on the number of images loaded for the item.
self.sprite_angles = [frozenset(range(i, i + sprite_angle_delta)) for i in range(0, 360, sprite_angle_delta)]
self.sprite_positions = {angle: pos for angle, pos in zip(self.sprite_angles, self.sprite_object)}
self.sprite_object = sprite_object[0] # set a default image until correct one is selected
def locate_sprite(self, player):
dx, dy = self.x - player.x, self.y - player.y
distance_to_sprite = math.sqrt(dx ** 2 + dy ** 2)
theta = math.atan2(dy, dx)
gamma = theta - player.angle
if dx > 0 and 180 <= math.degrees(player.angle) <= 360 or dx < 0 and dy < 0:
gamma += DOUBLE_PI
delta_rays = int(gamma / DELTA_ANGLE)
current_ray = CENTER_RAY + delta_rays
distance_to_sprite *= math.cos(HALF_FOV - current_ray * DELTA_ANGLE)
sprite_ray = current_ray + SPRITE_RAYS
if 0 <= sprite_ray <= SPRITE_RAYS_RANGE and distance_to_sprite > 30:
projected_height = min(int(WALL_HEIGHT / distance_to_sprite * self.scale), resY*2)
half_projected_height = projected_height // 2
shift = half_projected_height * self.shift
if not self.static:
if theta < 0:
theta += DOUBLE_PI
theta = 360 - int(math.degrees(theta))
for angles in self.sprite_angles:
if theta in angles:
self.sprite_object = self.sprite_positions[angles]
break
sprite = pygame.transform.scale(self.sprite_object, (projected_height, projected_height))
return {'image': sprite, 'x': (current_ray * SCALE - half_projected_height), 'y': (HALF_HEIGHT - half_projected_height + shift), 'distance': distance_to_sprite}
else:
return None
Here is the source code with the Z-buffer and multi-angle sprites implemented.
Load Maps From File
To make changing the map and loading different maps easier, the map layout is now defined in a text file and loaded when needed.
The map.py file has been modified as per below:
from settings import *
game_map = []
with open('map/map01.txt') as f:
for line in f:
game_map.append(line.strip())
# map size
map_height = len(game_map) * GRID_BLOCK
map_width = len(game_map[0]) * GRID_BLOCK
world_map = {}
for j, row in enumerate(game_map):
for i, char in enumerate(row):
if char != '0':
if char == '1':
world_map[(i * GRID_BLOCK, j * GRID_BLOCK)] = '1'
elif char == '2':
world_map[(i * GRID_BLOCK, j * GRID_BLOCK)] = '2'
elif char == '3':
world_map[(i * GRID_BLOCK, j * GRID_BLOCK)] = '3'
The line.strip() function is used to remove the newline character from the end of each line.
The text file where the map is defined is shown in the image below:

Here is the source code where map loading from a file is implemented.
The next things I am going to be working on is adding moving sprites and also adding interactive elements to the game world, including doors that open and close. Keep an eye out for future posts that will cover new features I have implemented.