DEVELOPING A RAYCASTING ‘3D’ ENGINE GAME IN PYTHON AND PYGAME – PART 3

In this post, the following features added to the game engine will be covered:

  1. Adding music to the game.
  2. Adding animated sprites.
  3. Fixed the distortion of wall textures if the player stands too close to them.
  4. Changed sprite scaling to handle height and width independently.
  5. 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.

DEVELOPING A RAYCASTING ‘3D’ ENGINE GAME IN PYTHON AND PYGAME – PART 3

Leave a comment