12
\$\begingroup\$

I have made a space shooter game in pygame with over 800 lines of code (including blank lines). The aim of the game is to kill all of the ships in the game. You start with a small spaceship and one enemy. When you kill an enemy, two more spawn if there are less than 40 of them, otherwise one spawns. When you kill a spaceship, you get ammo, health or fuel back and 1 xp. As you get more xp, your ship upgrades. When you reach max. upgrade, no more enemies spawn.

Controls:

  • mouse: aims ship towards your mouse, uses fuel
  • left click: propels ship in direction ship is facing, uses fuel
  • right click: shoots in direction ship is facing, uses ammo

Code:

things.py

import math
import pygame
import os

os.chdir(os.path.dirname(os.path.abspath(__file__)))

MAX_AREA = 30000  # 50000
BULLET_DAMAGE = 1
GUN_TIME = 8
BULLET_START_POS = pygame.Vector2(4, 0)
BULLET_SPEED = 40
BULLET_LIFE = 100
FUEL_USAGE_ACCELERATION = 1
FUEL_USAGE_TURN = 0.1

SHIP_SCALE = 4
GUN_POSITIONS = [
    [pygame.Vector2(6, 4), pygame.Vector2(6, -4)],
    [pygame.Vector2(4, 4), pygame.Vector2(4, -4), pygame.Vector2(19, 0)],
    [pygame.Vector2(4, 4), pygame.Vector2(4, -4), pygame.Vector2(19, 0)],
    [
        pygame.Vector2(4, 4),
        pygame.Vector2(4, -4),
        pygame.Vector2(19, 0),
        pygame.Vector2(8, 7),
        pygame.Vector2(8, -7),
    ],
    [
        pygame.Vector2(4, 4),
        pygame.Vector2(4, -4),
        pygame.Vector2(19, 0),
        pygame.Vector2(8, 7),
        pygame.Vector2(8, -7),
        pygame.Vector2(2, 10),
        pygame.Vector2(2, -10),
    ],
]

GUN_UPGRADE_MULTIPLIERS = 80  # (bullet_speed, bullet_life)
LEVEL_INFO = [
    (3, 3, 1),
    (3, 3, 1),
    (5, 5, 2),
    (5, 5, 2),
    (7, 5, 2),
]  # (bodies, rockets, turning_rockets)


def level_info_multiplier(info, multiplier):
    return lambda level: LEVEL_INFO[level][info] * multiplier


rocket_power = level_info_multiplier(1, 0.1)
ammo_storage = level_info_multiplier(0, 250)
turning_rocket_power = level_info_multiplier(2, 3)
health = level_info_multiplier(0, 20)
ship_damage = level_info_multiplier(0, 30)
fuel_storage = level_info_multiplier(0, 1250)


def clamp(n, min_n, max_n):
    return min(max(n, min_n), max_n)


def import_image(image_name, scale):
    image = pygame.image.load(image_name).convert_alpha()
    target_size = (int(image.get_width() * scale), int(image.get_height() * scale))
    return pygame.transform.scale(image, target_size)


def dir_dis_to_xy(direction, distance):
    return pygame.Vector2(
        (distance * math.cos(math.radians(direction))),
        -(distance * math.sin(math.radians(direction))),
    )


def xy_to_dir_dis(xy):
    return math.degrees(math.atan2(xy.y, xy.x)), math.sqrt(
        (0 - xy.x) ** 2 + (0 - xy.y) ** 2
    )


class Thing:
    def __init__(self, image, spawn_pos=pygame.Vector2(0), spawn_direction=0):
        self.image = image
        self.pos = spawn_pos
        self.direction = spawn_direction
        self.size = pygame.Vector2(0)
        self.draw_pos = pygame.Vector2(0)
        self.rot_center()

    def draw(self, screen, pos=None, offset=pygame.Vector2(0)):
        self.rot_center(pos=pos)
        screen.blit(self.rotated_image, self.draw_pos + offset)

    def rot_center(self, pos=None):
        self.rotated_image = pygame.transform.rotate(self.image, self.direction)
        if pos != None:
            self.rect = self.rotated_image.get_rect(center=pos)
        else:
            self.rect = self.rotated_image.get_rect(center=self.pos)
        self.size.update(self.rect.size)
        self.draw_pos.update(self.rect.topleft)
        self.mask = pygame.mask.from_surface(self.rotated_image)

    def object_mask_collision(self, other):
        pos = (other.pos - self.pos) - ((other.size / 2) - (self.size / 2))
        return bool(self.mask.overlap_area(other.mask, (int(pos.x), int(pos.y))))


class Bullet(Thing):
    def __init__(self, image, spawn_pos, direction, speed, time_alive):
        super().__init__(image, spawn_pos, direction)
        self.velocity = dir_dis_to_xy(self.direction, speed)
        self.time_alive = time_alive

    def update(self):
        self.pos += self.velocity
        self.time_alive -= 1


class Spaceship(Thing):
    def __init__(self, images, level, spawn_pos=pygame.Vector2(0), spawn_direction=0):
        super().__init__(images[level], spawn_pos, spawn_direction)
        self.level = level
        self.images = images
        self.level_to_stats()

        self.fire_rate = 0
        self.gun_timer = 0

        self.acceleration = 0
        self.velocity = pygame.Vector2(0)
        self.turn = 0

        self.ammo = self.max_ammo
        self.health = self.max_health
        self.fuel = self.max_fuel

    def level_to_stats(self):
        self.image = self.images[self.level]
        self.rocket_power = rocket_power(self.level)
        self.max_ammo = ammo_storage(self.level)
        self.turning_power = turning_rocket_power(self.level)
        self.max_health = health(self.level)
        self.ship_damage = ship_damage(self.level)
        self.bullet_speed = BULLET_SPEED
        self.bullet_life = BULLET_LIFE
        self.guns_activated = self.level
        self.max_fuel = fuel_storage(self.level)

    def update(self):
        fuel_usage = (abs(self.turn) * FUEL_USAGE_TURN) + (
            self.acceleration * FUEL_USAGE_ACCELERATION
        )
        if self.fuel - fuel_usage >= 0:
            self.fuel -= fuel_usage
            self.direction += self.turn
            self.velocity += dir_dis_to_xy(self.direction, self.acceleration)
        self.pos += self.velocity

        bullets_to_spawn = []

        self.gun_timer += self.fire_rate
        if self.gun_timer >= GUN_TIME:
            self.gun_timer = 0

            for gun_pos in GUN_POSITIONS[self.guns_activated]:
                if self.ammo:
                    self.ammo -= 1
                    direction, distance = xy_to_dir_dis(
                        (gun_pos * SHIP_SCALE) + BULLET_START_POS
                    )
                    bullets_to_spawn.append(
                        Bullet(
                            bullet_image,
                            self.pos
                            + dir_dis_to_xy(direction + self.direction, distance),
                            self.direction,
                            self.bullet_speed,
                            self.bullet_life,
                        )
                    )

        return bullets_to_spawn

    def control(self, acceleration, turn_amount, fire_rate, guns_activated=None):
        self.turn = clamp(turn_amount, -self.turning_power, self.turning_power)
        self.acceleration = clamp(acceleration, 0, self.rocket_power)
        self.fire_rate = clamp(fire_rate, 0, 1)
        if guns_activated != None:
            if guns_activated <= self.level:
                self.guns_activated = guns_activated


spaceship_images = [
    import_image(image_name, SHIP_SCALE)
    for image_name in [
        "rocket_stage1.png",
        "rocket_stage2.png",
        "rocket_stage3.png",
        "rocket_stage4.png",
        "rocket_stage5.png",
    ]
]
enemy_spaceship_images = [
    import_image(image_name, SHIP_SCALE)
    for image_name in [
        "enemy_stage1.png",
        "enemy_stage2.png",
        "enemy_stage3.png",
        "enemy_stage4.png",
        "enemy_stage5.png",
    ]
]
bullet_image = import_image("bullet.png", int(SHIP_SCALE / 2))

space_shooter.py

import pygame
import os
import random
from win32api import GetSystemMetrics

os.chdir(os.path.dirname(os.path.abspath(__file__)))

TITLE = "Space Shooter"
sys_met = tuple(map(GetSystemMetrics, (0, 1)))
SIZE = (sys_met[0], sys_met[1] - 60)  # (1000, 600)


def turn_to_angle(angle, turn_to_angle):
    turn = -(angle - turn_to_angle)

    while turn > 180:
        turn -= 360
    while turn < -180:
        turn += 360

    return turn


def stars_gen(density, min_size, max_size, star_image, size):
    result = pygame.Surface(size, flags=pygame.SRCALPHA).convert_alpha()
    star_width, star_height = star_image.get_size()
    for x in range(0, int(size.x)):
        for y in range(0, int(size.y)):
            if random.random() < density:
                scale = random.uniform(min_size, max_size)
                blit_star = pygame.transform.scale(
                    star_image, (int(star_width * scale), int(star_height * scale))
                )
                result.blit(blit_star, (x, y))
    return result


class Label:
    def __init__(self, pos, size, color, text, text_color):
        self.font = pygame.font.SysFont("monospace", 15)
        self.rect = pygame.Rect(pos, size)
        self.text_color = text_color
        self.color = color
        self.image = pygame.Surface(size).convert_alpha()
        self.image.fill(self.color)
        text_image = self.font.render(text, 1, self.text_color).convert_alpha()
        text_rect = text_image.get_rect(
            center=pygame.math.Vector2(self.image.get_size()) / 2
        )
        self.image.blit(text_image, text_rect)

    def draw(self, win):
        win.blit(self.image, self.rect)


class button(Label):
    def __init__(self, pos, size, color, text, text_color):
        super().__init__(pos, size, color, text, text_color)

    def is_hovered_over(self, point):
        return self.rect.collidepoint(point)


class Stat(Label):
    def __init__(self, pos, size, color, text, text_color):
        super().__init__(pos, size, color, text, text_color)

    def update_text(self, new_text):
        text_image = self.font.render(new_text, 1, self.text_color).convert_alpha()
        self.image.fill(self.color)
        text_rect = text_image.get_rect(
            center=pygame.math.Vector2(self.image.get_size()) / 2
        )
        self.image.blit(text_image, text_rect)


class InfoBar:
    def __init__(self, pos, size, bar_color, background_color, start_value=1):
        self.pos = pos
        self.size = size
        self.value = start_value
        self.background_color = background_color
        self.bar_color = bar_color
        self.image = pygame.Surface(size).convert_alpha()
        self.bar = pygame.Surface(size).convert_alpha()
        self.bar.fill(self.bar_color)
        self.update_value(new_value=start_value)

    def update_value(self, change_value_by=0, new_value=0):
        if change_value_by:
            self.value += change_value_by
        else:
            self.value = new_value
        self.image.fill(self.background_color)
        self.image.blit(self.bar, (-self.value * self.size.x, 0))

    def draw(self, win):
        win.blit(self.image, self.pos)


class space:
    def __init__(self, space_image, screen, pos):
        self.screen = screen
        self.space_image = space_image

    def update(self, pos):
        screen_size = pygame.Vector2(self.screen.get_size())
        pos = pygame.Vector2((pos.x % screen_size.x), (pos.y % screen_size.y))

        scroll_surf = pygame.Surface(screen_size).convert_alpha()
        scroll_surf.blit(self.space_image, pos)
        if pos.x > 0:
            scroll_surf.blit(self.space_image, pos - pygame.Vector2(screen_size.x, 0))
        else:
            scroll_surf.blit(self.space_image, pos + pygame.Vector2(screen_size.x, 0))
        if pos.y > 0:
            scroll_surf.blit(self.space_image, pos - pygame.Vector2(0, screen_size.y))
        else:
            scroll_surf.blit(self.space_image, pos + pygame.Vector2(0, screen_size.y))
        if pos.x > 0 and pos.y > 0:
            scroll_surf.blit(
                self.space_image, pos - pygame.Vector2(screen_size.x, screen_size.y)
            )
        self.screen.blit(scroll_surf, (0, 0))


def handle_events():
    spaceships_to_delete = []
    for n, spaceship in enumerate(spaceships):
        if n == player:
            if spaceship.health <= 0:
                return True
        else:
            if spaceship.health <= 0:
                spaceships_to_delete.append(n)
                spaceships[player].xp += 1
                if (
                    spaceships[player].health + spaceships[player].max_health / 5
                    < spaceships[player].max_health
                ):
                    spaceships[player].health += spaceships[player].max_health / 5
                values = [
                    getattr(spaceships[player], r_t[1])
                    / getattr(spaceships[player], r_t[0])
                    for r_t in player_reward_types
                ]
                reward_type = player_reward_types[values.index(min(values))]
                max_value = getattr(spaceships[player], reward_type[0])
                to_add = getattr(spaceships[player], reward_type[1]) + (max_value / 5)
                before = getattr(spaceships[player], reward_type[1])
                if to_add < max_value:
                    setattr(spaceships[player], reward_type[1], to_add)
                    after = getattr(spaceships[player], reward_type[1])
                    print("+", after - before, reward_type[1])

    spaceships_to_delete.reverse()
    for n in spaceships_to_delete:
        spaceships.pop(n)
    if (
        spaceships[player].level != 4
        and spaceships[player].xp > ((spaceships[player].level + 1) * 4) - 1
    ):
        spaceships[player].xp = 0
        spaceships[player].level += 1
        spaceships[player].level_to_stats()
    if spaceships[player].xp < (spaceships[player].level + 1) * 4:
        for n in spaceships_to_delete:
            spaceships.append(
                things.Spaceship(
                    things.enemy_spaceship_images,
                    spaceships[player].level,
                    spawn_pos=pygame.Vector2(
                        random.randrange(
                            int(-things.MAX_AREA), int(things.MAX_AREA), 1000
                        ),
                        random.randrange(
                            int(-things.MAX_AREA), int(things.MAX_AREA), 1000
                        ),
                    ),
                    spawn_direction=random.randrange(0, 360),
                )
            )
            if len(spaceships) < 40:
                spaceships.append(
                    things.Spaceship(
                        things.enemy_spaceship_images,
                        spaceships[player].level,
                        spawn_pos=pygame.Vector2(
                            random.randrange(
                                int(-things.MAX_AREA), int(things.MAX_AREA), 1000
                            ),
                            random.randrange(
                                int(-things.MAX_AREA), int(things.MAX_AREA), 1000
                            ),
                        ),
                        spawn_direction=random.randrange(0, 360),
                    )
                )

    bullets_to_delete = []
    for n, bullet in enumerate(bullets):
        if bullet.time_alive <= 0:
            bullets_to_delete.append(n)
    bullets_to_delete.reverse()
    for n in bullets_to_delete:
        bullets.pop(n)

    for event in pygame.event.get():
        if event.type == pygame.QUIT:
            return True
        # if event.type == pygame.KEYDOWN:
        # if event.key == pygame.K_q:
        #    spaceships[1].health = 0
        # if event.key == pygame.K_q:
        #    spaceships[player].level -= 1
        #    spaceships[player].level_to_stats()
        # if event.key == pygame.K_e:
        #    spaceships[player].level += 1
        #    spaceships[player].level_to_stats()

    left, middle, right = pygame.mouse.get_pressed()
    mouse_pos = pygame.mouse.get_pos()
    mouse_dir = -things.xy_to_dir_dis(
        mouse_pos - pygame.Vector2(spaceships[player].rect.center)
    )[0]

    acceleration = 0
    turn_amount = 0
    fire_rate = 0
    guns_activated = None

    if left:
        acceleration = spaceships[player].rocket_power
    if right:
        fire_rate = 1
    turn_amount = turn_to_angle(spaceships[player].direction, mouse_dir)

    keys = pygame.key.get_pressed()
    if keys[pygame.K_1]:
        guns_activated = 0
    if keys[pygame.K_2]:
        guns_activated = 1
    if keys[pygame.K_3]:
        guns_activated = 3
    if keys[pygame.K_4]:
        guns_activated = 4

    # print((turn_to_angle(mouse_dir, spaceship.direction)), (turn_to_angle(head_to, spaceship.direction))+spaceship.direction, head_to, spaceship.direction)
    spaceships[player].control(
        acceleration, turn_amount, fire_rate, guns_activated=guns_activated
    )

    for spaceship in spaceships:
        if spaceship != spaceships[player]:
            head_to = 0
            acceleration = 0
            fire_rate = 0
            distance = things.xy_to_dir_dis(spaceships[player].pos - spaceship.pos)[1]
            if distance < 10000:
                if things.xy_to_dir_dis(
                    spaceship.velocity
                    + things.dir_dis_to_xy(spaceship.direction, spaceship.rocket_power)
                )[1] < (((spaceship.level + 3) / 3) * 8):
                    acceleration = spaceship.rocket_power
                else:
                    acceleration = 0
                head_to = -things.xy_to_dir_dis(spaceship.pos - spaceships[player].pos)[
                    0
                ]
                if distance < 2000:
                    fire_rate = 0.3
                spaceship.control(
                    acceleration, turn_to_angle(head_to, spaceship.direction), fire_rate
                )
            else:
                if (
                    things.xy_to_dir_dis(
                        spaceship.velocity
                        + things.dir_dis_to_xy(
                            spaceship.direction, spaceship.rocket_power
                        )
                    )[1]
                    < 5
                ):
                    acceleration = spaceship.rocket_power / 10
                head_to = -things.xy_to_dir_dis(spaceship.pos)[0]
                # print((turn_to_angle(head_to, spaceship.direction)), (turn_to_angle(head_to, spaceship.direction))+spaceship.direction, head_to, spaceship.direction)
                # print(spaceship.direction)
                spaceship.control(
                    acceleration,
                    (turn_to_angle(head_to, spaceship.direction) / 100),
                    fire_rate,
                )
                # print(spaceship.direction)

    return False


def draw():
    mini_map.fill((0, 0, 0))

    follow = player
    draw_offset = (-spaceships[follow].pos) + win_center
    space_background.update(draw_offset)
    for n, spaceship in enumerate(spaceships):
        mini_map_spaceship_pos = ((spaceship.pos / mini_map_size) / 2) + mini_map_middle
        if n == follow:
            spaceship.draw(screen, pos=win_center)
            mini_map.blit(mini_map_dots["player"], mini_map_spaceship_pos)
        else:
            spaceship.draw(screen, offset=(draw_offset))
            mini_map.blit(mini_map_dots["enemy"], mini_map_spaceship_pos)

    for bullet in bullets:
        bullet.draw(screen, offset=(draw_offset))

    for label in labels:
        label.draw(screen)
    for label, bar in info_bars.values():
        label.draw(screen)
        bar.draw(screen)
    for label, text in info_text.values():
        label.draw(screen)
        text.draw(screen)

    screen.blit(mini_map, mini_map_draw_pos)


def game_logic():
    for bullet in bullets:
        bullet.update()

        if (
            bullet.pos.x > things.MAX_AREA
            or bullet.pos.x < -things.MAX_AREA
            or bullet.pos.y > things.MAX_AREA
            or bullet.pos.y < -things.MAX_AREA
        ):
            bullet.time_alive = 0
            continue
        for spaceship in spaceships:
            if bullet.object_mask_collision(spaceship):
                spaceship.health -= things.BULLET_DAMAGE
                bullet.time_alive = 0

    for n, spaceship in enumerate(spaceships):
        for bullet in spaceship.update():
            bullets.append(bullet)

        if (
            spaceship.pos.x > things.MAX_AREA
            or spaceship.pos.x < -things.MAX_AREA
            or spaceship.pos.y > things.MAX_AREA
            or spaceship.pos.y < -things.MAX_AREA
        ):
            spaceship.health = 0

    for n, spaceship in enumerate(spaceships):
        collide_checks = spaceships.copy()
        collide_checks.pop(n)

        for other in collide_checks:
            if spaceship.object_mask_collision(other):
                spaceship.health -= other.ship_damage

    info_bars["ammo"][1].update_value(
        new_value=(-(spaceships[player].ammo / spaceships[player].max_ammo)) + 1
    )
    info_bars["health"][1].update_value(
        new_value=(-(spaceships[player].health / spaceships[player].max_health)) + 1
    )
    info_bars["fuel"][1].update_value(
        new_value=(-(spaceships[player].fuel / spaceships[player].max_fuel)) + 1
    )
    info_text["speed"][1].update_text(
        str(int(things.xy_to_dir_dis(spaceships[player].velocity)[1]))
    )
    info_text["pos"][1].update_text(
        str((int(spaceships[player].pos.x), int(spaceships[player].pos.y)))
    )
    info_text["guns activated"][1].update_text(
        str(spaceships[player].guns_activated + 1)
    )
    info_text["ship level"][1].update_text(str(spaceships[player].level + 1))
    info_text["xp"][1].update_text(str(spaceships[player].xp))


def run_game():
    while True:
        if handle_events():
            break

        game_logic()
        draw()

        pygame.display.update(window_rect)
        clock.tick(30)

    pygame.quit()


if __name__ == "__main__":
    pygame.init()
    screen = pygame.display.set_mode(SIZE)
    import things

    mini_map_size = 200
    mini_map_middle = (
        pygame.Vector2(things.MAX_AREA, things.MAX_AREA) / mini_map_size
    ) / 2
    mini_map = pygame.Surface(
        pygame.Vector2(things.MAX_AREA, things.MAX_AREA) / mini_map_size
    ).convert_alpha()
    mini_map_dots = {
        "player": pygame.Surface(pygame.Vector2(1, 1)).convert_alpha(),
        "enemy": pygame.Surface(pygame.Vector2(1, 1)).convert_alpha(),
    }
    mini_map_dots["player"].fill((0, 200, 0))
    mini_map_dots["enemy"].fill((200, 0, 0))
    mini_map_draw_pos = pygame.Vector2(10, 10)
    window_rect = pygame.Rect((0, 0), SIZE)
    win_center = pygame.Vector2(window_rect.center)
    clock = pygame.time.Clock()
    pygame.display.set_caption(TITLE)
    pygame.event.set_allowed([pygame.KEYDOWN, pygame.QUIT])

    stars = pygame.Surface(pygame.Vector2(screen.get_size())).convert_alpha()
    stars.fill((10, 10, 13))
    stars.blit(
        stars_gen(
            0.0001,
            0.4,
            0.9,
            things.import_image("star.png", 1),
            pygame.Vector2(screen.get_size()),
        ),
        (0, 0),
    )
    space_background = space(stars, screen, pygame.Vector2(0))

    bullets = []
    player = 0
    spaceships = [
        things.Spaceship(things.spaceship_images, 0, spawn_pos=pygame.Vector2(0, 0))
    ]
    spaceships.append(
        things.Spaceship(
            things.enemy_spaceship_images,
            spaceships[player].level,
            spawn_pos=pygame.Vector2(
                random.randrange(int(-things.MAX_AREA), int(things.MAX_AREA), 1000),
                random.randrange(int(-things.MAX_AREA), int(things.MAX_AREA), 1000),
            ),
            spawn_direction=random.randrange(0, 360),
        )
    )
    # for x in range(int(-things.MAX_AREA), int(things.MAX_AREA), 1000):
    # for y in range(int(-things.MAX_AREA), int(things.MAX_AREA), 1000):
    # if random.random() < 0.002:
    # spaceships.append(things.Spaceship(things.enemy_spaceship_images,
    # spaceships[player].level,
    # spawn_pos=pygame.Vector2(x, y),
    # spawn_direction=random.randrange(0, 360)))

    player_reward_types = (
        ("max_health", "health"),
        ("max_ammo", "ammo"),
        ("max_fuel", "fuel"),
    )
    spaceships[player].xp = 0

    # (label, info_bar)
    info_bars = {
        "ammo": (
            Label(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 65),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "ammo",
                (20, 20, 20),
            ),
            InfoBar(
                pygame.Vector2(SIZE[0] - 100, SIZE[1] - 40),
                pygame.Vector2(80, 20),
                (0, 0, 100),
                (100, 100, 100),
                0,
            ),
        ),
        "health": (
            Label(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 125),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "health",
                (20, 20, 20),
            ),
            InfoBar(
                pygame.Vector2(SIZE[0] - 100, SIZE[1] - 100),
                pygame.Vector2(80, 20),
                (0, 0, 100),
                (100, 100, 100),
                0,
            ),
        ),
        "fuel": (
            Label(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 185),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "fuel",
                (20, 20, 20),
            ),
            InfoBar(
                pygame.Vector2(SIZE[0] - 100, SIZE[1] - 160),
                pygame.Vector2(80, 20),
                (0, 0, 100),
                (100, 100, 100),
                0,
            ),
        ),
    }
    # (label, info_text)
    info_text = {
        "speed": (
            Label(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 245),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "speed",
                (20, 20, 20),
            ),
            Stat(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 220),
                pygame.Vector2(60, 20),
                (200, 200, 200),
                "0",
                (20, 20, 20),
            ),
        ),
        "pos": (
            Label(
                pygame.Vector2(70, SIZE[1] - 65),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "pos",
                (20, 20, 20),
            ),
            Stat(
                pygame.Vector2(20, SIZE[1] - 40),
                pygame.Vector2(160, 20),
                (200, 200, 200),
                "(0, 0)",
                (20, 20, 20),
            ),
        ),
        "guns activated": (
            Label(
                pygame.Vector2(30, SIZE[1] - 125),
                pygame.Vector2(140, 16),
                (200, 200, 200),
                "guns activated",
                (20, 20, 20),
            ),
            Stat(
                pygame.Vector2(70, SIZE[1] - 100),
                pygame.Vector2(60, 20),
                (200, 200, 200),
                "0",
                (20, 20, 20),
            ),
        ),
        "ship level": (
            Label(
                pygame.Vector2(50, SIZE[1] - 185),
                pygame.Vector2(100, 16),
                (200, 200, 200),
                "ship level",
                (20, 20, 20),
            ),
            Stat(
                pygame.Vector2(70, SIZE[1] - 160),
                pygame.Vector2(60, 20),
                (200, 200, 200),
                "0",
                (20, 20, 20),
            ),
        ),
        "xp": (
            Label(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 305),
                pygame.Vector2(60, 16),
                (200, 200, 200),
                "xp",
                (20, 20, 20),
            ),
            Stat(
                pygame.Vector2(SIZE[0] - 90, SIZE[1] - 280),
                pygame.Vector2(60, 20),
                (200, 200, 200),
                "0",
                (20, 20, 20),
            ),
        ),
    }
    labels = (
        []
    )  # Label(pygame.Vector2(0, 0), pygame.Vector2(60, 20), (200, 200, 200), "hi", (20, 20, 20))]

    run_game()

Screenshots:

finding him...

finding him...

there he is!

there he is!

got him!

got him!

\$\endgroup\$
2
  • 3
    \$\begingroup\$ One of my first projects was a space game! Cool question, I have verified the code works. Would be cool if you had a GitHub link to share the PNGs to play with :) (if you want to ofc) \$\endgroup\$
    – Peilonrayz
    Commented Apr 1 at 1:45
  • 2
    \$\begingroup\$ @Peilonrayz I don't use github, here is a filemail link (7 days expiry). how far can you get?? \$\endgroup\$
    – coder
    Commented Apr 1 at 4:35

2 Answers 2

9
\$\begingroup\$

The graphics are beautiful. I appreciate the attention to details, like the background stars.

assets directory

This certainly is not bad:

os.chdir(os.path.dirname(os.path.abspath(__file__)))

It's not like there's something else that was going to care about $PWD. But I'm afraid that global variables make me a little nervous, like time.time(), PRNG state, and PWD.

As an unrelated detail, the Path API tends to be more concise and convenient than the old os.path.* routines.

Standard idiom would start with

source_folder = Path(__file__).parent.resolve()

from which we might assign assets, and continue on with
assets / "rocket_stage1.png", etc.

manifest constants

You have a lot of them, and they all look great. Thank you for that.

nit: Delete # 50000 on MAX_AREA, as it's no longer needed. Consider spelling the value 30_000, so it's easier to read.

from ... import ...

import pygame

Clearly this works. But consider beefing it up with from pygame import Vector2 and similar, so we can make some repeated expressions a bit shorter.

gun positions

GUN_POSITIONS has lots of magic numbers such as 6 and 19, which is Fine. What is missing is a helpful github URL showing a blown up graphic, so we can understand how (6, 4) and (19, 0) fit into the graphic that end users will see.

At some point, we might wish to revise that graphic, perhaps because a Level 2 spacecraft has 50% more firepower sprouting from its wings. And perhaps the Art Director will approve the final design. Tying these coordinates to a specific creative image would help the process.

namedtuple

LEVEL_INFO = [
    (3, 3, 1),
    (3, 3, 1),
    (5, 5, 2),
    (5, 5, 2),
    (7, 5, 2),
]  # (bodies, rockets, turning_rockets)

Thank you for that very helpful comment.

As it stands, the first parameter to level_info_multiplier is cryptic and is in need of a MANIFEST_CONSTANT, but we have better approaches available.

Consider defining this:

from collections import namedtuple

Level = namedtuple("Level", "bodies, rockets, turning_rockets")

Then we don't need a comment.

And we can refer to .bodies rather than a cryptic ...[0] info subscript.

BTW, the clamp helper is concise and beautiful. Well, generally import_image, dir_dis_to_xy, and xy_to_dir_dis are all very nice helpers. In dir_dis_to_xy I guess pygame has an inconvenient "upside down" convention on Y coords that you have to roll with, sigh. In xy_to_dir_dis I confess I don't see any reason for negating before squaring; I just don't see that there's some story we're advancing there. Whether you negate or not, the square will be positive.

catalog of object attributes

    def rot_center(self, ...):
        self.rotated_image = ...

The __init__ constructor did not mention that attribute.

Clearly it's not necessary, but it is considered polite to introduce all attributes in the constructor. It gives a maintenance engineer a heads-up about what to watch for. Consider defaulting to None, or maybe have a helper return a tuple, which an assignment can unpack.

            self.rect = ...
        ...
        self.mask = ...

Oh, dear! I see there's more to worry about.

singleton address

        if pos != None:

As a weird python community thing, please prefer if pos is not None:. Or use ruff or some other linter to help you with odd cases like that.

Also, here it would be simpler to test the positive case and deal with that first.

Better, unconditionally assign this:

    self.rect = self.rotated_image.get_rect(center=pos or self.pos)

The Thing inheritance for Bullet and Spaceship is very nice. Good use of super()!

vector vs scalar

I found this slightly odd:

class Spaceship(Thing):
    def __init__( ... ):
        ...
        self.acceleration = 0
        self.velocity = pygame.Vector2(0)

We're simplifying, sure, that's cool. Just saying that I felt an acceleration would be a vector quantity, pointing in a similar direction to velocity, whatever.

In version 2, maybe spaceships operate near massive objects, where acceleration always points down the gravity well....

short functions

Let me just take a minute to complement you on your beautifully short, comprehensible methods. They do One thing, Well.

Also, helpers like clamp() make some calling code admirably concise.

modulo

In turn_to_angle I'm not crazy about this:

    while turn > 180:
        turn -= 360
    while turn < -180:
        turn += 360

That seems like what the % mod operator is for.

Also, I'm not happy with the post-condition; I feel it's on the sloppy side.

We would like to

        assert -180 <= turn < 180

Or, at least I think I have seen that [-180, 180) half-open interval as a standard convention.

But the < > inequalities don't align with <=.

Also, I like stars_gen, it is aesthetically pleasing.

PEP-8

class space:

I don't understand what's going on with that name. Call it Space, please.

sgn

        if pos.x > 0:
            scroll_surf.blit(self.space_image, pos - pygame.Vector2(screen_size.x, 0))
        else:
            scroll_surf.blit(self.space_image, pos + pygame.Vector2(screen_size.x, 0))

We have a ± there.

Using copysign(), it seems like we could collapse those lines down to one.

Similarly for the Y direction.

handle_events

In the space class, it turns out that handle_events is Too Long. How do we know? Try to read the signature and also the last line of the method, without vertically scrolling. Yup, that's right, you can't. It's Too Long. There are several opportunities to Extract Helper.

Also, the Magic Number 5 is slightly worrying. It wants a MANIFEST_CONSTANT name, please.

Here is another troubling magic number:

        spaceships[player].level != 4

getattr

                max_value = getattr(spaceships[player], ...
                to_add = getattr(spaceships[player], ...
                before = getattr(spaceships[player], ...

Not sure if this is quite a Code Smell. But it certainly does seem inconvenient.

There are so many solutions. Make those objects namedtuples, or dataclasses, or give them custom behavior.

Usually we resort to getattr() when there is a fixed Public API which we're not at liberty to change. But that's not your situation at all. Alter the underlying implementation, for the convenience of the caller. That's how excellent Public APIs are forged!

Similarly with the subsequent setattr( ... ).

break

def run_game():
    while True:
        if handle_events():
            break

That works, but I'm not crazy about it. Consider switching to

def run_game():
    while not handle_events():
        ...

imports up top

This troubles me:

if __name__ == "__main__":
    ...
    import things

Move the import to top-of-file, please. We're not saving anything by conditionally importing it.

Also, the code within this __main__ guard is entirely Too Long, and should appear within def main():, hopefully with several helpers.

\$\endgroup\$
2
\$\begingroup\$

In addition to the previous thorough answer, in the things.py code, you could reduce repetition in creating the lists of file names using a loop in a function.

for image_name in [
    "rocket_stage1.png",
    "rocket_stage2.png",
    "rocket_stage3.png",
    "rocket_stage4.png",
    "rocket_stage5.png",
]

Could be:

def get_files(name):
    image_name = []
    for i in range(1, 6):
        image_name.append(f'{name}_stage{i}.png')
    return image_name

# just for demo purposes
print(get_files('rocket'))
print(get_files('enemy'))

The space_shooter.py file has a couple groups of lines that look like commented-out code, such as:

    # if event.type == pygame.KEYDOWN:
    # if event.key == pygame.K_q:
    #    spaceships[1].health = 0
    # if event.key == pygame.K_q:

Either add more comments to describe why they are there or just delete them to reduce clutter.

\$\endgroup\$
1
  • \$\begingroup\$ Please don't recommend append of a simple expression in a loop, even though here it hardly can impact performance: return [f"{name}_stage{i}.png" for i in range(1, 6)] is just easier to comprehend. And a list called image_name (singular) sounds extremely weird to me. \$\endgroup\$
    – STerliakov
    Commented Apr 4 at 23:11

Not the answer you're looking for? Browse other questions tagged or ask your own question.