DEV Community

Cover image for Adding Weapons
Eric Dong
Eric Dong

Posted on • Edited on

Adding Weapons

This is my blog part 2 of 3 for the v0.4 release of Space War RL!


Making a Common Base Class

The HumanShip class contains logic for applying screen wrap-around, velocity, and rotation. Since I want the other objects to have this same logic, I can extract this into a common base class.

class SpaceEntity(pygame.sprite.Sprite):
   ...

    def update(self, *_args, **kwargs):
        """Entrypoint for updating the player state each frame

        screen_wrap, pos, and rotation updates can be disabled if set to False
        """
        x_pos, y_pos = self.pos
        x_vel, y_vel = self.vel

        # conditions for wrapping around the screen
        if x_pos >= SCREEN_WIDTH:
            x_pos = 0
        elif x_pos <= 0:
            x_pos = SCREEN_WIDTH
        if y_pos >= SCREEN_HEIGHT:
            y_pos = 0
        elif y_pos <= 0:
            y_pos = SCREEN_HEIGHT

        # apply velocity to position
        x_pos += x_vel
        y_pos += y_vel
        self.pos = (x_pos, y_pos)
        self.rect.center = self.pos

        # update rotation to surface
        self.ang %= 360
        self.image = pygame.transform.rotate(self.surf, -self.ang)
        self.rect = self.image.get_rect(center=self.pos)

Enter fullscreen mode Exit fullscreen mode

The SpaceEntity base class represents flying objects in space.
See space_war/sim/base.py for the full source

I also have a base class to represent a weapon.

class BaseWeapon(pygame.sprite.Sprite):
    """Simple base class for weapons. All weapons have a specific duration."""

    def __init__(self, duration) -> None:
        pygame.sprite.Sprite.__init__(self)
        self.start_time = pygame.time.get_ticks()
        self.max_duration = duration

    def is_expired(self) -> bool:
        """Returns True if duration of weapon exceeds the max duration"""
        current_duration = pygame.time.get_ticks() - self.start_time
        return current_duration >= self.max_duration

    def update(self, *args: Any, **kwargs: Any):
        """Update entrypoint for weapons"""
        super().update(*args, **kwargs)
        if self.is_expired():
            self.kill()
Enter fullscreen mode Exit fullscreen mode

See space_war/sim/weapon.py for the full source

Adding Photon Torpedoes

The photon torpedoes were pretty easy to implement, since I could inherit the logic from my base class.

The graphics in the original were boring, so I drew my own torpedo.

Screenshot of photon torpedoes from emulator

Pygame comes with some utils to draw lines and polygons:

pygame.draw.polygon(self.image, "white", [[5, 0], [3, 5], [7, 5]], 1)
pygame.draw.polygon(self.image, "white", [[0, 10], [3, 9], [3, 5]], 1)
pygame.draw.polygon(self.image, "white", [[7, 5], [7, 9], [11, 10]], 1)
pygame.draw.line(self.image, "white", (3, 9), (7, 9))
pygame.draw.line(self.image, "white", (5, 5), (5, 11))
Enter fullscreen mode Exit fullscreen mode

Closeup of firing Photon Torpedoes

Each torpedo flies for its max duration unless it hits another object. There can only be a max of 7 per ship flying at a time (According to the emulator).

Adding Phasers

Adding phasers on the other hand was more complicated than I initially thought. I ended up making 3 revisions of the implementation. I started using the SpaceEntity base class and realized screen wrap-around won't work correctly. The base class assumes there is one rectangle. This works fine to represent individual ships and torpedoes. For screen wrap-around to work correctly, I need at least 2 rectangles. One is drawn to the edge of the screen and the rest continues on the other side of the screen. Another big difference from the ship and torpedo is the size of the surface. The phaser's surface is the same size as the screen. This made the calculations easier for drawing the line, since I could use absolute coordinates. The torpedo is a 12x12 surface and it is rotated based on the ship's angle upon firing.

My implementation consists of three steps:

  1. Create invisible hit detector rectangles
  2. Detect collisions with ships or other torpedoes
  3. Draw the phaser line

Closeup of firing Phasers

Putting Everything Together

Firing weapons are handled by adding a new instance of the weapon to its sprite group to manage rendering and updates.
The ship's handle_events function was getting long, so I refactored this method to call two helper methods, _handle_movement_events and _handle_firing_weapon_events.

This is what the _handle_firing_weapon_events function looks like:

def _handle_firing_weapon_events(self, event: pygame.event.Event):
    """Handles firing phasers and photon torpedoes.

    Keybindings are hard coded for now. It would be good to make this config driven.

    """
    if event.type == pygame.KEYDOWN:
        if event.key == pygame.constants.K_q:
            pygame.time.set_timer(
                self.fire_phaser_repeat_event,
                PHASER_FIRE_CD,
            )
            if not self._check_phaser_on_cooldown():
                self.phaser = Phaser(source_ship=self)
                self.phaser_group.add(self.phaser)
                self.phaser_last_fired = pygame.time.get_ticks()
        if event.key == pygame.constants.K_e:
            pygame.time.set_timer(
                self.fire_torpedoes_repeat_event,
                TORPEDO_FIRE_CD,
            )
            if (
                len(self.torpedo_group) < MAX_TORPEDOES_PER_SHIP
                and not self._check_torpedo_on_cooldown()
            ):
                self.torpedo_group.add(
                    PhotonTorpedo(
                        start_pos=self.pos,
                        start_ang=self.ang,
                        start_vel=self.vel,
                    )
                )
                self.torpedo_last_fired = pygame.time.get_ticks()

    if event.type == pygame.KEYUP:
        if event.key == pygame.constants.K_q:
            pygame.time.set_timer(self.fire_phaser_repeat_event, 0)
        if event.key == pygame.constants.K_e:
            pygame.time.set_timer(self.fire_torpedoes_repeat_event, 0)

    if event.type == self.fire_phaser_repeat_event:
        self.phaser = Phaser(source_ship=self)
        self.phaser_last_fired = pygame.time.get_ticks()
        self.phaser_group.add(self.phaser)
    if event.type == self.fire_torpedoes_repeat_event:
        if len(self.torpedo_group) < MAX_TORPEDOES_PER_SHIP:
            self.torpedo_last_fired = pygame.time.get_ticks()
            self.torpedo_group.add(
                PhotonTorpedo(
                    start_pos=self.pos,
                    start_ang=self.ang,
                    start_vel=self.vel,
                )
            )
Enter fullscreen mode Exit fullscreen mode

See space_war/sim/ship.py for the full source

The final step is to detect collisions between the weapons and ships. For this iteration, one hit with a weapon will result in death (removing the sprite from its group). Torpedoes can collide with other torpedoes and ships. Phasers can only collide with torpedoes and the other ship.

GIF of weapons demo

Lessons Learned

While implementing the weapons, I have a better understanding how inheritance works in python and the difference between the surface and rectangle. The surface is the visible portion of drawing pixels to the screen and the rectangle is the coordinate data. In this case, I'm using the rectangle for movement and collision detection. I also learned more about sprite groups and how it makes it easy to manage multiple sprite instances. You can see space_war/main.py for how I'm initializing the groups.

Next Steps

My next blog will show some physics I added for ship on ship collisions! Thank you for reading.

Top comments (0)