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

The following additions and changes to the game engine will be covered:

  • Adding the ability for the player to look up and down. (As requested by Matthew Matkava)
  • Addition of basic spatial sound to the enemy.
  • Bugfix that relates to the player footstep sounds.

Player Looking Up and Down

First, we will look at adding the ability for the player to look up and down, or more accurately, add the illusion of looking up and down. As mentioned in the first post in this series, the game engine being developed is not actually 3D but rather a pseudo-3D rendering of a 2D game world. This means, in essence, that there is no Z-axis (up and down) in the game world. Thus the player looking up and down is simply an illusion and does not affect the game in any way.

All sprite and walls rendered in the game engine, as well as the point where the skybox ends and the floor starts, use a pre-defined horizon as a reference point to determine the position and height of the items to be drawn to the screen, up to this point the horizon used was half of the game window resolution and was defined in the settings.py file with the name HALF_HEIGHT:

HALF_HEIGHT = resY // 2

To create the illusion of the player looking up and down, we will move this horizon up and down based on the users’ inputs:

The first thing we need to do is rename HALF_HEIGHT in the settings.py file, as it will still be required but only to determine the center of the game window:

BASE_HALF_HEIGHT = resY // 2

Next, a new horizon value needs to be declared, and this will be done inside the Player class in the player.py file as it will now be under the control of the player, so inside the Player __init__ method, the following has been added:

self.HALF_HEIGHT = BASE_HALF_HEIGHT

BASE_HALF_HEIGHT is set as a starting value, ensuring that the game starts with the player looking straightforward.

The mouse movement function in the Player class was updated to move newly defined Horizon value based on mouse movement:

def mouse_control(self):
    if pygame.mouse.get_focused():
        difference = pygame.mouse.get_pos()
        difference_x = difference[0] - HALF_WIDTH
        difference_y = difference[1] - BASE_HALF_HEIGHT
        pygame.mouse.set_pos((HALF_WIDTH, BASE_HALF_HEIGHT))
        self.angle += difference_x * self.sensitivity
        if (resY - resY / 4) >= self.HALF_HEIGHT >= resY/4:
           self.HALF_HEIGHT -= difference_y * self.look_sensitivity
        elif (resY - resY / 4) <= self.HALF_HEIGHT:
           self.HALF_HEIGHT = (resY - resY / 4)
        elif self.HALF_HEIGHT <= resY/4:
           self.HALF_HEIGHT = resY/4

Top and bottom boundaries are set to prevent the player from looking too far up and down, which can result in issues in the game engine.

All references to the original HALF_HEIGHT value defined in the settings.py file need to be changed to use the new Player.HALF_HEIGHT value. The locations of these references are as follows:

  • The background method in the Drawing class (prawing.py)
  • raycasting function (raycasting.py)
  • locate_sprite method in the SpriteBase class (sprite.py)

Basic Enemy Spatial Sound

Next, let us look at adding basic spatial sound to the enemy. The idea behind this implementation is that the sound the enemy makes gets louder as it gets closer to the player. This is implemented in the following method located in the Enemy class (enemy.py):

def play_sound(self,  distance):
    if not pygame.mixer.Channel(4).get_busy():
       volume = (1 / distance)*10
       pygame.mixer.Channel(4).set_volume(volume)
       pygame.mixer.Channel(4).play(pygame.mixer.Sound(self.sound))

Where the distance variable value is set to the distance between the enemy and the player.
The play_sound method is then called from the move function of the player class as per the code below:

def move(self, player, object_map, distance):
    new_x, new_y = player.x, player.y
    if self.activated:
        if player.x > self.x:
            new_x = self.x + ENEMY_SPEED
        elif player.x < self.x:
            new_x = self.x - ENEMY_SPEED

        if player.y > self.y:
            new_y = self.y + ENEMY_SPEED
        elif player.y < self.y:
            new_y = self.y - ENEMY_SPEED

        self.x, self.y = check_collision(self.x, self.y, new_x, new_y, object_map, ENEMY_MARGIN)
        if (self.x == new_x) or (self.y == new_y):
            self.moving = True
            self.play_sound(distance)
        else:
            self.moving = False

This will result in the enemy, while moving, making a sound with a loudness inversely proportional to the distance to the player.

Footstep Sound Bugfix

A bug in the previous version of the code resulted in the player only making footstep sounds if the player was not moving due to a collision with an object. The code below fixes this issue by checking if the players x or y positions changed, and if any of the two values have changed, the footstep sound will be played:

def keys_control(self, object_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.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
        if nx == self.x or ny == self.y:
            self.play_sound(self.step_sound)
    if keys[pygame.K_s]:
        nx = self.x + -player_speed * cos_a
        ny = self.y + -player_speed * sin_a
        self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
        if nx == self.x or ny == self.y:
            self.play_sound(self.step_sound)
    if keys[pygame.K_a]:
        nx = self.x + player_speed * sin_a
        ny = self.y + -player_speed * cos_a
        self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
        if nx == self.x or ny == self.y:
            self.play_sound(self.step_sound)
    if keys[pygame.K_d]:
        nx = self.x + -player_speed * sin_a
        ny = self.y + player_speed * cos_a
        self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
        if nx == self.x or ny == self.y:
            self.play_sound(self.step_sound)
    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

The source code for everything discussed in the post can be downloaded here and the executable here.

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

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

In this post, we will cover the following:

  1. Fixing a KeyError bug related to the door sprites.
  2. Refactoring the player collision detection algorithm so that enemies and other non-playable characters can also use it.
  3. Adding very basic enemy artificial intelligence and adding movement animation to the enemy, so the effect of a walking character is created.

Door Sprite KeyError Bug

Because doors had 16 angles, and angle to change the sprite image was calculated with:

sprite_angle_delta = int(360 / len(self.sprite_object)) 

So for 16 images, this would result in 22.5 degrees. The decimal 0.5 would be dropped because we use int(), and all operations dependent on the sprite_angle_delta uses an integer number.

This decimal loss results in a dead zone between 352 and 360 degrees that caused the KeyError.

To fix this, the number of sprite images was reduced to 8, as 16 was unnecessary for the purposes we require in this scenario.

Alternatively, the sprite_angle_delta could have been changed to a float variable, and all the dependent operations could have been modified accordingly to facilitate this. However, this would have added unnecessary complexity for the functionality required in the game.

Refactoring of Collision Detection Algorithm to be More Generic and Reusable

Firstly, the check_collision function was moved out of the Player class and into the common.py file. Next, the function was refactored as per the code below so that it returns either the existing x and y values (before the move) if a collision occurred or the new x and y values (after the move) if no collision was detected:

def check_collision(x, y, new_x, new_y, map_to_check, margin):
    location = align_grid(new_x, new_y)
    if location in map_to_check:
        #  collision
        return x, y

    location = align_grid(new_x - margin, new_y - margin)
    if location in map_to_check:
        #  collision
        return x, y

    location = align_grid(new_x + margin, new_y - margin)
    if location in map_to_check:
        #  collision
        return x, y

    location = align_grid(new_x - margin, new_y + margin)
    if location in map_to_check:
        #  collision
        return x, y

    location = align_grid(new_x + margin, new_y + margin)
    if location in map_to_check:
        #  collision
        return x, y

    return new_x, new_y

The Player keys_control method was modified as per below to facilitate the new check_collision function:

def keys_control(self, object_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.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
            if nx != self.x and ny != self.y:
                self.play_sound(self.step_sound)
        if keys[pygame.K_s]:
            nx = self.x + -player_speed * cos_a
            ny = self.y + -player_speed * sin_a
            self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
            if nx != self.x and ny != self.y:
                self.play_sound(self.step_sound)
        if keys[pygame.K_a]:
            nx = self.x + player_speed * sin_a
            ny = self.y + -player_speed * cos_a
            self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
            if nx != self.x and ny != self.y:
                self.play_sound(self.step_sound)
        if keys[pygame.K_d]:
            nx = self.x + -player_speed * sin_a
            ny = self.y + player_speed * cos_a
            self.x, self.y = check_collision(self.x, self.y, nx, ny, object_map, HALF_PLAYER_MARGIN)
            if nx != self.x and ny != self.y:
                self.play_sound(self.step_sound)
        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

Where object_map is passed in from the main.py file and is created as follows:

object_map = {**sprites.sprite_map, **world_map}

object_map is thus a new dictionary that contains the values of the sprite_map and world_map dictionaries combined.

The check_collision function can now be easily used by enemies as well.

Basic Enemy Artificial Intelligence and Enemy Walking Animation

The enemy will, for now, only have very basic behavior and will try to move towards the player except if an obstacle is in the way.

A new Enemy class was created to accommodate this and is located in a new file called enemy.py.
The contents of the enemy.py file:

 from common import *


class Enemy:
    def __init__(self, x, y, subtype):
        self.x = x
        self.y = y
        self.subtype = subtype
        self.activated = False
        self.moving = False

    def move(self, player, object_map):
        new_x, new_y = player.x, player.y
        if self.activated:
            if player.x > self.x:
                new_x = self.x + ENEMY_SPEED
            elif player.x < self.x:
                new_x = self.x - ENEMY_SPEED

            if player.y > self.y:
                new_y = self.y + ENEMY_SPEED
            elif player.y < self.y:
                new_y = self.y - ENEMY_SPEED

            self.x, self.y = check_collision(self.x, self.y, new_x, new_y, object_map, ENEMY_MARGIN)
            if (self.x == new_x) or (self.y == new_y):
                self.moving = True
            else:
                self.moving = False

Sprites have also now been given types and subtypes to help assign appropriate behavior. Sprites are now configured as per this code:

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': 'object',
        'subtype': 'barrel',
        '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': 'object',
        'subtype': 'car',
        'interactive': False,
        'interaction_sound': None,
      },
      'blank': {
        'sprite': [pygame.image.load(f'assets/images/sprites/enemy/blank/stand/{i}.png').convert_alpha() for i
               in
               range(8)],
        'viewing_angles': True,
        'shift': 0.1,
        'scale': (1.0, 1.0),
        'animation': deque(
          [pygame.image.load(f'assets/images/sprites/enemy/blank/walk/{i}.png').convert_alpha() for i in
           range(8)]),
        'animation_distance': 3000,
        'animation_speed': 6,
        'type': 'enemy',
        'subtype': '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(8)],
        'viewing_angles': True,
        'shift': 0.01,
        'scale': (2.4, 1.4),
        'animation': [],
        'animation_distance': 0,
        'animation_speed': 0,
        'type': 'door',
        'subtype': '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(8)],
        'viewing_angles': True,
        'shift': 0.01,
        'scale': (2.4, 1.4),
        'animation': [],
        'animation_distance': 0,
        'animation_speed': 0,
        'type': 'door',
        'subtype': 'door_x_axis',
        'interactive': True,
        'interaction_sound': pygame.mixer.Sound('assets/audio/door.wav'),
      },
    }

The update_sprite_map method has been modified to include enemy flags for where enemies are located. This will be used in the future when enemies can damage the player:

def update_sprite_map(self):
    self.sprite_map = {} # used for collision detection with sprites - this will need to move when sprites can move
    self.enemy_map = {}
    for sprite in self.list_of_sprites:
      if not sprite.delete and sprite.type != 'enemy':
        sprite_location = common.align_grid(sprite.x, sprite.y)
        self.sprite_map[sprite_location] = 'sprite'
      elif not sprite.delete and sprite.type == 'enemy':
        enemy_location = common.align_grid(sprite.x, sprite.y)
        self.enemy_map[enemy_location] = 'enemy'

The SpriteBase __init__, and locate_sprite methods had to be modified to implement the new enemy class and also implement logic to determine if the enemy is moving so that the images loaded under the animation variable could be used to create a walking animation.

Here is the code of the __init__, and locate_sprite methods:

def __init__(self, parameters, pos):
    self.sprite_object = parameters['sprite']
    self.shift = parameters['shift']
    self.scale = parameters['scale']
    self.animation = parameters['animation'].copy()
    self.animation_distance = parameters['animation_distance']
    self.animation_speed = parameters['animation_speed']
    self.type = parameters['type']
    self.subtype = parameters['subtype']
    self.viewing_angles = parameters['viewing_angles']
    self.animation_count = 0
    self.pos = self.x, self.y = pos[0] * GRID_BLOCK, pos[1] * GRID_BLOCK
    self.interact_trigger = False
    self.previous_position_y = self.y
    self.previous_position_x = self.x
    self.delete = False
    self.interactive = parameters['interactive']
    self.interaction_sound = parameters['interaction_sound']
    if self.type == 'enemy':
      self.object = Enemy(self.x, self.y, self.subtype)
    else:
      self.object = None

    if self.viewing_angles:
      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 = self.sprite_object[0] # set a default image until correct one is selected

  def locate_sprite(self, player, object_map):
    if self.object:
      self.object.move(player, object_map)
    dx, dy = self.x - player.x, self.y - player.y
    self.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
    self.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 self.distance_to_sprite > 30:
      projected_height = min(int(WALL_HEIGHT / self.distance_to_sprite), resY * 2)
      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

      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))

      if self.viewing_angles:
        if theta < 0:
          theta += DOUBLE_PI
        theta = 360 - int(math.degrees(theta))

        if self.type == "enemy":
          if self.object.activated:
            theta = 0

        for angles in self.sprite_angles:
          if theta in angles:
            self.sprite_object = self.sprite_positions[angles]
            break

      if self.animation and self.distance_to_sprite < self.animation_distance:
        if self.type == 'enemy':
          if self.object.moving:
            self.sprite_object = self.animation[0]
        else:
          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

      sprite = pygame.transform.scale(self.sprite_object, (sprite_width, sprite_height))
      if not self.delete:
        if (self.type == 'enemy') and self.object:
          self.object.activated = True
          self.pos = self.x, self.y = self.object.x, self.object.y

        return {'image': sprite, 'x': (current_ray * SCALE - half_sprite_width),
            'y': (HALF_HEIGHT - half_sprite_height + shift), 'distance': self.distance_to_sprite}
      else:
        if (self.type == 'enemy') and self.object:
          self.object.activated = False
          self.pos = self.x, self.y = self.object.x, self.object.y
        None
    else:
      return None

The source code for everything discussed in the post can be downloaded here and the executable here.

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