HEADPHONE MODIFICATIONS – SAMSON SR-850

In this post, we will delve into the world of headphone modifications by performing numerous modifications on the Samson SR-850. The Samson SR-850 is an inexpensive and relatively good set of reference headphones with two significant shortfalls: the SR-850 has a fixed, non-removable cable, and the SR-850 has a slightly tremble-heavy sound. We will be addressing these shortfalls by performing the following modifications:

  • Detachable cable modification
  • Filter Swap (to improve the headphone sound profile to a more neutral sound)
  • Earpad swap (for improved comfort)

Two components will be required for the detachable cable modification: a 3.5mm female jack and a new 3D-printed headphone end cap. 3.5mm Female jacks can be found on Amazon, and the STL for the new end cap can be downloaded here.

The new end cap will replace the end cap on the side of the headphone where the 3.5mm cable is connected.

The modification is performed by removing the end cap on the side where the cable is attached. This is done by carefully removing the nameplate on the end cap and unscrewing the single screw underneath it. 

This will expose the wiring of where the 3.5mm cable is connected to the headphone drivers. The cable can then be de-soldered and the 3.5mm female jack connected as shown in the below illustration:

After soldering the female jack, as shown above, insert the jack into the new 3D-printed end cap and re-assemble the end cap using the original screw, then reattach the nameplate (some glue may be required to attach the nameplate). Depending on the 3.5mm female jack used, some minor cutting away of the end cap internal structure may be required. Additionally, it is a good idea to secure the 3.5mm female jack in the end cap with a dot of hot glue to increase the jack rigidity.

The following two modifications can be completed at the same time. The first step is to remove the ear pads from the headphone, which will remove the filters as well as they are located inside the ear pads ring. The next step is to insert the new filters into the new ear pads and then install the new ear pads onto the headphones. The filters I selected are slightly thicker than the original SR-850 filters. This will soften the tremble of the SR-850, which tends to be exaggerated in its sound profile and move it towards a more neutral sound profile.

The ear pads I selected are the Transtek Velour Black replacement ear pads for the Samson SR-850. They are 30mm thick, breathable memory foam ear pads that offer an extremely comfortable wearing experience. These ear pads slightly reduce the headphones’ sound stage but significantly increase their comfort, so a worthwhile tradeoff, in my opinion.

Here are some photos of the end results:

Headphone modification is an interesting and rewarding pastime that is rather addictive. I have modified all the sets of headphones I use to varying degrees to improve the experience and enjoyment I get from them.

HEADPHONE MODIFICATIONS – SAMSON SR-850

3D PRINTING REVIEW – FILAMENT FRIDAY BED LEVELER

The Filament Friday Bed Leveler is an ingenious little device designed by Chuck Hellebuyck (From the YouTube Channel Filament Friday) to assist with the bed levelling of any FDM 3D printer by making the process easy and consistent.

The device is available on Amazon with two options: a slightly more expensive pre-assembled device or an unassembled DIY version (which I opted for). However, both versions require some 3D-printed parts to be supplied by the customer.

Assembly of the DIY version is straightforward, with the only tools required being a soldering iron, wire cutters and pliers.

Just note that although the leveler supports any FDM printer, in theory, the gcode required to use the device is only available for a limited set of printers. It is, however, possible to modify the supplied gcode to work with any printer and here is the gcode I have changed to work with my Wanhao i3 Mini (This gcode will work for any printer with the same size print bed like the Monoprice i3 Mini).

To use the Filament Friday Bed Leveler the following four-step process is utilised:

Step 1:

Copy the Filament Friday Bed Leveler gcode file to an SD card and print the file on your 3D printer. This will result in the print head moving to the first corner and waiting for a pre-defined time (as configured in the gcode) before moving to the next corner.

Step 2:

Insert the bed leveler under the print head with the nozzle centred on the cross printed on the top of the device.

Step 3:

Loosen\Tighten the bed levelling screws of the corresponding corner until the LED on the bed leveler barely lights up. (The LED should be dimly lit if the LED  is brightly lit, the nozzle is too close to the bed.)

Step 4:

Repeat steps 2 and 3 for all the corners of the print bed. For the best results, repeat the entire process one additional time.

The Filament Friday Bed Leveler is a great and inexpensive little device that takes the guesswork out of bed levelling, and for the price, you cannot go wrong by adding it to your 3D printing toolset.

3D PRINTING REVIEW – FILAMENT FRIDAY BED LEVELER

2022 3D Printer Upgrades

At the beginning of 2022, I performed various upgrades to my Wanhao i3 Mini 3D printer, mostly related to the hotend, cooling and filament delivery.

I replaced the heat break and the nozzle from a hot end perspective. In addition, I replaced the standard stainless steel heat break with the Yunbotong V6 bi-metal heat break, which is a titanium alloy heat break with a copper plated throat that is E3D V6 compatible. The difference between the two materials (i.e. the titanium alloy and the copper) results in a difference in thermal conductivity, which improves heat dissipation efficiency.


I replaced the standard copper nozzle with a titanium nozzle, which offers higher durability and thus can deliver higher quality prints for longer.

By 3d printing and using the dual fan mount linked here, I was able to add an additional fan and thus double the hot end cooling capacity. Additionally, I replaced the standard inexpensive 24V 40mm fan with two Noctua nf-a4x20 flx fans. The nf-a4x20 flx is a premium quiet fan, with award-winning cooling and acoustic performance. This upgrade significantly improved the cooling of both the hot end and the print area and has dramatically improved print quality.

Lastly, regarding filament delivery, I replaced the standard PTFE Bowden tubing with Capricorn PTFE Bowden tubing. Capricorn PTFE Bowden tubing contains additional high lubricity additives that make it the lowest friction Bowden tubing on the market. This lower friction results in improved responsiveness and less slippage, which results in better print quality.

The upgrades mentioned above have been very successful and have greatly improved the print quality I was able to produce with my 3d printer.

2022 3D Printer Upgrades

3D PRINTING REVIEW – ESUN ESILK PLA FILAMENT

The eSUN eSilk PLA filaments are a range of PLA-based filaments that result in 3D prints that have a silky smooth finish. The eSilk range is available in numerous colors, and the gold color was used for this review.

The eSilk PLA filaments claim to print exactly the same as normal PLA filaments with a recommended print temperature of 190~220℃ and no heated bed required, however, I found the filament softer than normal PLA and more prone to clogging. I found that I got the best and most reliable results printing at 185℃.

The mechanical properties of the eSilk filament were almost identical to normal PLA once printed with good toughness and detail, with the only noticeable difference in the finish which has the appearance of a shiny silky luster, similar to brushed gold.

The eSUN eSilk PLA filaments retail for $24.99 for a 1KG spool and if a silky finish is required the filament checks all the boxes.

3D PRINTING REVIEW – ESUN ESILK PLA FILAMENT

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

The following changes will be covered in this post:

  • Improved skybox.
  • Bugfix for a bug related to the rendering of walls introduced with functionality to look up and down.
  • Red transparent screen effect added to act as a damage indicator for the player when enemy contact occurs.

Skybox Improvement


Due to the pattern and size of the image used for the skybox, an issue would occur where the image would suddenly switch to a different position. Although not game-breaking, it was somewhat jarring. To improve this, two changes needed to be made to the image used for the skybox:

  • the Image X (horizontal) resolution needed to be changed to be the same as the display window resolution (In this case, 1920 pixels).
  • The image needed to be replaced with a seamless image, i.e., the two sides of the image aligned to create an infinitely repeating pattern of clouds.

Wall Rendering Bugfix
A bug was introduced with the functionality for the player to look up and down that caused the rendering of walls to get miss-aligned if the player’s point of view was not vertically centered and the player was close to the wall in question. The image below shows an example of how the bug manifests:

This results from the game engine’s limitations and the lack of a z-axis for proper spatial positioning of items. To get around this, I added auto vertical centering of the player’s field of view every time the player moves. This will not completely fix the issue but will make it occur far less frequently.

To implement this change I added the following method in the Player class (in the playert.py file):

def level_out_view(self):
        if (self.HALF_HEIGHT - BASE_HALF_HEIGHT) > 50:
            self.HALF_HEIGHT -= 50
        elif self.HALF_HEIGHT - BASE_HALF_HEIGHT < -50:
            self.HALF_HEIGHT += 50
        else:
            self.HALF_HEIGHT = BASE_HALF_HEIGHT

And updated the keys_control method in the player class as follows:

def keys_control(self, object_map,enemy_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)
            self.level_out_view()
        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)
            self.level_out_view()
        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)
            self.level_out_view()
        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)
            self.level_out_view()
        if keys[pygame.K_e]:
            self.interact = True
            self.level_out_view()
        if keys[pygame.K_LEFT]:
            self.angle -= 0.02
            self.level_out_view()
        if keys[pygame.K_RIGHT]:
            self.angle += 0.02
            self.level_out_view()

Player Visual Damage Indicator
A Visual Damage Indicator is a way to let the player know he is taking damage. This will become more relevant at a later stage when the concept of health points is implemented, but for now, it provides a way of showing when the enemy is in touching range of the player.
The number of enemies has also been increased to three to increase the chances of a damage event.


The Visual Damage Indicator is implemented by drawing a semi-transparent red rectangle over the screen whenever a collision between the player and the enemy is detected.

To check for these collisions a new function was added in the common.py file as below:

def check_collision_enemy(x, y, map_to_check, margin):
    location = align_grid(x, y)
    if location in map_to_check:
        #  collision
        return True

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

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

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

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

    return False

This function is called from the keys_control function in player class:

self.hurt = check_collision_enemy(self.x, self.y, enemy_map, HALF_PLAYER_MARGIN)

In the drawing.py file the background method in the Drawing class was updated as follows:

def background(self, angle, half_height, hurt):
        sky_offset = -1 * math.degrees(angle) % resX
        print (sky_offset)
        self.screen.blit(self.textures['S'], (sky_offset, 0))
        self.screen.blit(self.textures['S'], (sky_offset - self.textures['S'].get_width(), 0))
        self.screen.blit(self.textures['S'], (sky_offset + self.textures['S'].get_width(), 0))
        pygame.draw.rect(self.screen, GREY, (0, half_height, resX, resY))
        if(hurt):
            RED_HIGHLIGHT = (240, 50, 50, 100)
            damage_screen = pygame.Surface((resX, resY)).convert_alpha()
            damage_screen.fill(RED_HIGHLIGHT)
            self.screen.blit(damage_screen, (0, 0, resX, resY))

RED_HIGHLIGHT is a Tuple with four values stored in it. The first three values represent the RGB color code, and the last value indicates transparency level, with 0 being completely transparent and 255 completely opaque.
The convert_alpha method tells Pygame to draw the rectangle to the screen applying the transparency effect.

Here is a video of the effect in action:

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 6

Make a Rubber Ducky with a Raspberry Pico

I am taking a slight detour from the Raycasting series of posts (don’t worry, the next post in the series is coming soon) to cover another small project I have been working on, creating a Rubber Duckly using a Raspberry Pico and CircuitPython.


A Rubber Ducky is a keystroke injection tool that is often disguised as a USB flash drive to trick an unsuspecting victim into plugging it into their computer. The computer recognizes the Rubber Ducky as a USB keyboard (and mouse if required), and when it is plugged in, it executes a sequence of pre-programmed keystrokes, which will be executed against the target computer, as if the user did it. This attack thus exploits the security roles and permissions assigned to the user logged in at the time.
This is a good time to note that using a Rubber Ducky for dubious intents is illegal and a terrible idea, and I take no responsibility for the consequences if anyone chooses to use what they learn here to commit such acts.

To create the Rubber Ducky described in this post, you will need four things:
1. A Rasberry Pico
2. A Micro USB Cable
3. CircuitPython
4. The Adafruit HID Library of CircuitPython

First, you will need to install CircuitPython on your Raspberry Pico. This link will provide all the instructions and downloads you will require to do this.
Next, you will need to install the Adafruit HID Library. Instructions on how to do this can be found here.

Now that all the pre-requisites are installed and configured, the source code below can be deployed using the process described in the first link. The Source code below executes a sequence of keystrokes that opens Notepad on the target computer and type out a message. Just note that the keystrokes are slowed down significantly to make what is happening visible to the user Typically, this will not be done with a Rubber Ducky.

import board
import digitalio
import time
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

kbd = Keyboard(usb_hid.devices)

led = digitalio.DigitalInOut(board.LED)
led.direction = digitalio.Direction.OUTPUT
led.value = True
time.sleep(10)
while True:
    kbd.press(Keycode.GUI, Keycode.R)
    time.sleep(.09)
    kbd.release_all()
    kbd.press(Keycode.N)
    time.sleep(.09)
    kbd.release(Keycode.N)
    time.sleep(.09)
    kbd.press(Keycode.O)
    time.sleep(.09)
    kbd.release(Keycode.O)
    time.sleep(.09)
    kbd.press(Keycode.T)
    time.sleep(.09)
    kbd.release(Keycode.T)
    time.sleep(.09)
    kbd.press(Keycode.E)
    time.sleep(.09)
    kbd.release(Keycode.E)
    time.sleep(.09)
    kbd.press(Keycode.P)
    time.sleep(.09)
    kbd.release(Keycode.P)
    time.sleep(.09)
    kbd.press(Keycode.A)
    time.sleep(.09)
    kbd.release(Keycode.A)
    time.sleep(.09)
    kbd.press(Keycode.D)
    time.sleep(.09)
    kbd.release(Keycode.D)
    time.sleep(.09)
    kbd.press(Keycode.ENTER)
    time.sleep(.09)
    kbd.release(Keycode.ENTER)
    time.sleep(.09)

    kbd.press(Keycode.H)
    time.sleep(.09)
    kbd.release(Keycode.H)
    time.sleep(.09)
    kbd.press(Keycode.E)
    time.sleep(.09)
    kbd.release(Keycode.E)
    time.sleep(.09)
    kbd.press(Keycode.L)
    time.sleep(.09)
    kbd.release(Keycode.L)
    time.sleep(.09)
    kbd.press(Keycode.L)
    time.sleep(.09)
    kbd.release(Keycode.L)
    time.sleep(.09)
    kbd.press(Keycode.O)
    time.sleep(.09)
    kbd.release(Keycode.O)
    time.sleep(.09)
    kbd.press(Keycode.ENTER)
    time.sleep(.09)
    kbd.release(Keycode.ENTER)
    time.sleep(100)

led.value = False

Here is a video of the Rubbert Ducky in Action:

Make a Rubber Ducky with a Raspberry Pico

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

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

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

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

  1. General Enhancements (Making the numbers of rays scale based on the resolution and splitting the raycasting and drawing functionality into separate functions).
  2. Addition of Sound (for now, only footsteps).
  3. Hiding the mouse cursor.
  4. Add a fullscreen flag (set to run game in fullscreen or window mode).
  5. Adding static flat sprites (same image from all directions).
  6. Convert player angle to 0-360 degree angle (removing the potential for negative values).
  7. Add collision detection with sprites.
  8. Implement a Z-buffer.
  9. 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.

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