In this post, the following features added to the game engine will be covered:
- Adding music to the game.
- Adding animated sprites.
- Fixed the distortion of wall textures if the player stands too close to them.
- Changed sprite scaling to handle height and width independently.
- Added Interactive sprites (Doors)
Music
To add music the following lines of code was added to the main.py file:
pygame.mixer.music.set_volume(0.05)
pygame.mixer.music.load('assets/audio/music/Future Ramen_CPV1_Nexus Nights_Master_24_48k.mp3')
pygame.mixer.music.play(-1)
The ‘-1’ parameter in the play function sets the music to loop, so when the track has completed playing, it will start playing from the beginning again.
Animated Sprites and Scaling of Sprites
To facilitate the additional values required to implement animated sprites as well as separate width and height scaling, the definition of the parameters of each sprite is now handled in a dictionary as below:
self.list_of_sprites = {
'barrel': {
'sprite': pygame.image.load('assets/images/sprites/objects/barrel_fire/0.png').convert_alpha(),
'viewing_angles': None,
'shift': 0.8,
'scale': (0.8, 0.8),
'animation': deque(
[pygame.image.load(f'assets/images/sprites/objects/barrel_fire/{i}.png').convert_alpha() for i in
range(6)]),
'animation_distance': 2000,
'animation_speed': 10,
'type': 'barrel',
'interactive': False,
'interaction_sound': None,
},
'zombie360': {
'sprite': [pygame.image.load(f'assets/images/sprites/enemy/zombie/{i}.png').convert_alpha() for i in
range(4)],
'viewing_angles': True,
'shift': 0.6,
'scale': (1.1, 1.1),
'animation': [],
'animation_distance': 0,
'animation_speed': 0,
'type': 'zombie',
'interactive': False,
'interaction_sound': None,
},
'car': {
'sprite': pygame.image.load(f'assets/images/sprites/objects/car.png').convert_alpha(),
'viewing_angles': False,
'shift': 0.3,
'scale': (2.0, 2.0),
'animation': [],
'animation_distance': 0,
'animation_speed': 0,
'type': 'car',
'interactive': False,
'interaction_sound': None,
},
'blank': {
'sprite': [pygame.image.load(f'assets/images/sprites/enemy/blank/{i}.png').convert_alpha() for i in
range(8)],
'viewing_angles': True,
'shift': 0.6,
'scale': (1.0, 1.4),
'animation': [],
'animation_distance': 0,
'animation_speed': 0,
'type': 'blank',
'interactive': False,
'interaction_sound': None,
},
'sprite_door_y_axis': {
'sprite': [pygame.image.load(f'assets/images/sprites/objects/door_v/{i}.png').convert_alpha() for i in range(16)],
'viewing_angles': True,
'shift': 0.01,
'scale': (2.4, 1.4),
'animation': [],
'animation_distance': 0,
'animation_speed': 0,
'type': 'door_y_axis',
'interactive': True,
'interaction_sound': pygame.mixer.Sound('assets/audio/door.wav'),
},
'sprite_door_x_axis': {
'sprite': [pygame.image.load(f'assets/images/sprites/objects/door_h/{i}.png').convert_alpha() for i in range(16)],
'viewing_angles': True,
'shift': 0.01,
'scale': (2.4, 1.4),
'animation': [],
'animation_distance': 0,
'animation_speed': 0,
'type': 'door_x_axis',
'interactive': True,
'interaction_sound': pygame.mixer.Sound('assets/audio/door.wav'),
},
}
scale is now a tuple containing a value for width and height scaling values separately.
Additionally, the following values were added, which are related to animating of sprites:
animation – if the sprite is an animated sprite, this will contain a list of images used in rendering the animation. The images used for the animation are loaded into a double-ended queue. This is a queue structure where data can be added and removed from the queue at both ends.
animation_distance – at which distance from the player the animation will start being rendered.
animation_speed – the speed at which the animation will be played.
type – used to determine the type of the sprite.
The next two variables will be used later in this post when we discuss interactive sprites (doors), they are:
interactive – which is set for whether a sprite can be interacted with or not.
interaction_sound – This stores an audio file that will be triggered if interaction with the sprite is triggered.
The implementation of how sprites are scaled has been changed to scale the width and height of the sprite separately, this will allow for more accurate scaling as well as fixing distortion of sprites that have a non-symmetrical aspect ratio.
The below cade has been added in the sprite.py file:
sprite_width = int(projected_height * self.scale[0])
sprite_height = int(projected_height * self.scale[1])
half_sprite_width = sprite_width // 2
half_sprite_height = sprite_height // 2
shift = half_sprite_height * self.shift
And when the sprite is returned by the locate_sprite function, the x and y values are now determined as follows:
return {'image': sprite, 'x': (current_ray * SCALE - half_sprite_width),
'y': (HALF_HEIGHT - half_sprite_height + shift), 'distance': self.distance_to_sprite}
The following logic has been added to the locate_sprite function in the sprite.py file to play the animation:
if self.animation and self.distance_to_sprite < self.animation_dist:
self.sprite_object = self.animation[0]
if self.animation_count < self.animation_speed:
self.animation_count += 1
else:
self.animation.rotate()
self.animation_count = 0
In the function above, the current sprite object that will be rendered to the screen is set to the first object in the double-ended queue, and if the sprite animation speed has been exceeded, the double-ended queue will then be rotated, i.e., the first item in the double-ended queue will be moved to the back of the queue.
Fix for the Distortion of Wall Textures
There was a distortion of wall textures that occurred if the player moved too close to the walls. The issue resulted because the wall height was larger than the screen height at that point, and this was rectified by modifying the raycasting function as per below:
projected_height = int(WALL_HEIGHT / depth)
if projected_height > resY:
texture_height = TEXTURE_HEIGHT / (projected_height / resY)
wall_column = textures[texture].subsurface(offset * TEXTURE_SCALE,
(TEXTURE_HEIGHT // 2) - texture_height // 2,
TEXTURE_SCALE, texture_height)
wall_column = pygame.transform.scale(wall_column, (SCALE, resY))
wall_position = (ray * SCALE, 0)
else:
wall_column = textures[texture].subsurface(offset * TEXTURE_SCALE, 0, TEXTURE_SCALE, TEXTURE_HEIGHT)
wall_column = pygame.transform.scale(wall_column, (SCALE, projected_height))
wall_position = (ray * SCALE, HALF_HEIGHT - projected_height // 2)
x, y = wall_position
walls.append(
{'image': wall_column, 'x': x, 'y': y, 'distance': depth})
Interactive Doors (with Sound)
To implement interactivity in the game world, a few changes have to be implemented.
Firstly a new variable needed to be added to the Player class called interact. This is a Boolean value that will be set to true if the player presses the ‘e’ key. here is the updated player.py file:
from common import *
from map import *
class Player:
def __init__(self):
player_pos = ((map_width / 2), (map_height / 2))
self.x, self.y = player_pos
self.angle = player_angle
self.sensitivity = 0.001
self.step_sound = pygame.mixer.Sound('assets/audio/footstep.wav')
self.interact = False
pygame.mixer.Channel(2).set_volume(0.2)
@property
def pos(self):
return (self.x, self.y)
def movement(self, sprite_map):
self.keys_control(sprite_map)
self.mouse_control()
self.angle %= DOUBLE_PI # Convert player angle to 0-360 degree values
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
def keys_control(self,sprite_map):
sin_a = math.sin(self.angle)
cos_a = math.cos(self.angle)
keys = pygame.key.get_pressed()
if keys[pygame.K_ESCAPE]:
exit()
if keys[pygame.K_w]:
nx = self.x + player_speed * cos_a
ny = self.y + player_speed * sin_a
self.check_collision(nx, ny, sprite_map)
if keys[pygame.K_s]:
nx = self.x + -player_speed * cos_a
ny = self.y + -player_speed * sin_a
self.check_collision(nx, ny, sprite_map)
if keys[pygame.K_a]:
nx = self.x + player_speed * sin_a
ny = self.y + -player_speed * cos_a
self.check_collision(nx, ny, sprite_map)
if keys[pygame.K_d]:
nx = self.x + -player_speed * sin_a
ny = self.y + player_speed * cos_a
self.check_collision(nx, ny, sprite_map)
if keys[pygame.K_e]:
self.interact = True
if keys[pygame.K_LEFT]:
self.angle -= 0.02
if keys[pygame.K_RIGHT]:
self.angle += 0.02
def mouse_control(self):
if pygame.mouse.get_focused():
difference = pygame.mouse.get_pos()[0] - HALF_WIDTH
pygame.mouse.set_pos((HALF_WIDTH, HALF_HEIGHT))
self.angle += difference * self.sensitivity
Next, we need to implement a new class called Interaction. This class is implemented in the interactions.py file.
In this class, a function called interaction_world_objects is defined. This function first checks if the player has pressed the interact button (‘e’) and, if so, iterates through each sprite in the game world, checking that the sprite’s distance from the player is within range. If the sprite is in range and it is an interactive sprite, the sprites interact_trigger variable will be set to true.
Here is the code contained in the interactions.py file:
from settings import *
from common import *
class Interactions:
def __init__(self, player, sprites, drawing):
self.player = player
self.sprites = sprites
self.drawing = drawing
def interaction_world_objects(self):
if self.player.interact:
for obj in sorted(self.sprites.list_of_sprites, key=lambda obj: obj.distance_to_sprite):
px, py = align_grid(self.player.x, self.player.y)
sx, sy = align_grid(obj.x, obj.y)
x_dist = px - sx
y_dist = py - sy
print('x distance : ' + str(x_dist))
print('y distance : ' + str(y_dist))
if obj.interactive:
if ((-INTERACTION_RANGE <= x_dist <= INTERACTION_RANGE) and (
-INTERACTION_RANGE <= y_dist <= INTERACTION_RANGE)) and not obj.interact_trigger:
obj.interact_trigger = True
Lastly, the sprite.py file needs to be updated. First, a check must be done in the locate_sprite function to see if the sprite’s interact_trigger value has been set to true:
if self.interact_trigger:
self.interact()
if self.interaction_sound and not self.delete:
if not pygame.mixer.Channel(3).get_busy():
pygame.mixer.Channel(3).play(pygame.mixer.Sound(self.interaction_sound))
This calls the sprite’s interact function and plays the audio file associated with the sprites interaction.
The interact function as shown below determines the type of the sprite and performs some action based thereon:
def interact(self):
if self.type == 'door_y_axis':
self.y -= 1
if abs(self.y - self.previous_position_y) > GRID_BLOCK:
self.delete = True
elif self.type == 'door_x_axis':
self.x -= 1
if abs(self.x - self.previous_position_x) > GRID_BLOCK:
self.delete = True
In the event of the x-axis and y-axis doors, the function moves the sprite to the side, creating the effect of a door opening.
The source code for everything discussed in the post can be downloaded here and the executable here.
The next thing to be implemented is NPC characters that move around the game world. Check for future posts on this topic.