
In this post, we will cover the following:
- Fixing a KeyError bug related to the door sprites.
- Refactoring the player collision detection algorithm so that enemies and other non-playable characters can also use it.
- 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.