diff --git a/asteroid.py b/asteroid.py new file mode 100644 index 0000000..05a75ea --- /dev/null +++ b/asteroid.py @@ -0,0 +1,38 @@ +import random + +import pygame + +from circleshape import CircleShape +from constants import ASTEROID_MIN_RADIUS, LINE_WIDTH +from logger import log_event + + +class Asteroid(CircleShape): + def __init__(self, x: float, y: float, radius: float) -> None: + super().__init__(x, y, radius) + + def draw(self, screen: pygame.Surface) -> None: + pygame.draw.circle(screen, "white", self.position, self.radius, LINE_WIDTH) + + def update(self, dt: float) -> None: + self.position += self.velocity * dt + + def split(self) -> None: + self.kill() + + if self.radius <= ASTEROID_MIN_RADIUS: + return + + log_event("asteroid_split") + + angle = random.uniform(20, 50) + + v1 = self.velocity.rotate(angle) + v2 = self.velocity.rotate(-angle) + r = self.radius - ASTEROID_MIN_RADIUS + + a1 = Asteroid(self.position.x, self.position.y, r) + a1.velocity = v1 * 1.2 + + a2 = Asteroid(self.position.x, self.position.y, r) + a2.velocity = v2 * 1.2 diff --git a/asteroidfield.py b/asteroidfield.py new file mode 100644 index 0000000..d88a0c9 --- /dev/null +++ b/asteroidfield.py @@ -0,0 +1,59 @@ +import random +from collections.abc import Callable + +import pygame + +from asteroid import Asteroid +from constants import * + +Edge = tuple[pygame.Vector2, Callable[[float], pygame.Vector2]] + + +class AsteroidField(pygame.sprite.Sprite): + containers: pygame.sprite.Group + + edges: list[Edge] = [ + ( + pygame.Vector2(1, 0), + lambda y: pygame.Vector2(-ASTEROID_MAX_RADIUS, y * SCREEN_HEIGHT), + ), + ( + pygame.Vector2(-1, 0), + lambda y: pygame.Vector2( + SCREEN_WIDTH + ASTEROID_MAX_RADIUS, y * SCREEN_HEIGHT + ), + ), + ( + pygame.Vector2(0, 1), + lambda x: pygame.Vector2(x * SCREEN_WIDTH, -ASTEROID_MAX_RADIUS), + ), + ( + pygame.Vector2(0, -1), + lambda x: pygame.Vector2( + x * SCREEN_WIDTH, SCREEN_HEIGHT + ASTEROID_MAX_RADIUS + ), + ), + ] + + def __init__(self) -> None: + pygame.sprite.Sprite.__init__(self, self.containers) + self.spawn_timer = 0.0 + + def spawn( + self, radius: float, position: pygame.Vector2, velocity: pygame.Vector2 + ) -> None: + asteroid = Asteroid(position.x, position.y, radius) + asteroid.velocity = velocity + + def update(self, dt: float) -> None: + self.spawn_timer += dt + if self.spawn_timer > ASTEROID_SPAWN_RATE_SECONDS: + self.spawn_timer = 0 + + edge = random.choice(self.edges) + speed = random.randint(40, 100) + velocity = edge[0] * speed + velocity = velocity.rotate(random.randint(-30, 30)) + position = edge[1](random.uniform(0, 1)) + kind = random.randint(1, ASTEROID_KINDS) + self.spawn(ASTEROID_MIN_RADIUS * kind, position, velocity) diff --git a/circleshape.py b/circleshape.py new file mode 100644 index 0000000..1441e13 --- /dev/null +++ b/circleshape.py @@ -0,0 +1,28 @@ +from typing import Self + +import pygame + + +class CircleShape(pygame.sprite.Sprite): + containers: tuple[pygame.sprite.Group, ...] + + def __init__(self, x: float, y: float, radius: float) -> None: + if hasattr(self, "containers"): + super().__init__(*self.containers) + else: + super().__init__() + + self.position: pygame.Vector2 = pygame.Vector2(x, y) + self.velocity = pygame.Vector2(0, 0) + self.radius = radius + + def draw(self, screen: pygame.Surface) -> None: + pass + + def update(self, dt: float) -> None: + pass + + def collides_with(self, other: Self) -> bool: + d = self.position.distance_to(other.position) + r = self.radius + other.radius + return d < r diff --git a/constants.py b/constants.py index cb8a938..d0428dd 100644 --- a/constants.py +++ b/constants.py @@ -1,3 +1,13 @@ SCREEN_WIDTH = 1280 SCREEN_HEIGHT = 720 - +PLAYER_RADIUS = 20 +LINE_WIDTH = 2 +PLAYER_TURN_SPEED = 300 +PLAYER_SPEED = 200 +ASTEROID_MIN_RADIUS = 20 +ASTEROID_KINDS = 3 +ASTEROID_SPAWN_RATE_SECONDS = 0.8 +ASTEROID_MAX_RADIUS = ASTEROID_MIN_RADIUS * ASTEROID_KINDS +SHOT_RADIUS = 5 +PLAYER_SHOOT_SPEED = 500 +PLAYER_SHOOT_COOLDOWN_SECONDS = 0.3 diff --git a/game_events.jsonl b/game_events.jsonl new file mode 100644 index 0000000..6a8f7b3 --- /dev/null +++ b/game_events.jsonl @@ -0,0 +1,56 @@ +{"timestamp": "09:34:03.094", "elapsed_s": 4, "frame": 265, "type": "asteroid_shot"} +{"timestamp": "09:34:03.094", "elapsed_s": 4, "frame": 265, "type": "asteroid_split"} +{"timestamp": "09:34:05.593", "elapsed_s": 6, "frame": 421, "type": "asteroid_shot"} +{"timestamp": "09:34:08.949", "elapsed_s": 10, "frame": 632, "type": "asteroid_shot"} +{"timestamp": "09:34:08.949", "elapsed_s": 10, "frame": 632, "type": "asteroid_split"} +{"timestamp": "09:34:09.298", "elapsed_s": 10, "frame": 654, "type": "asteroid_shot"} +{"timestamp": "09:34:09.298", "elapsed_s": 10, "frame": 654, "type": "asteroid_split"} +{"timestamp": "09:34:09.667", "elapsed_s": 10, "frame": 677, "type": "asteroid_shot"} +{"timestamp": "09:34:10.815", "elapsed_s": 11, "frame": 749, "type": "asteroid_shot"} +{"timestamp": "09:34:10.816", "elapsed_s": 11, "frame": 749, "type": "asteroid_split"} +{"timestamp": "09:34:10.816", "elapsed_s": 11, "frame": 749, "type": "asteroid_shot"} +{"timestamp": "09:34:11.967", "elapsed_s": 13, "frame": 821, "type": "asteroid_shot"} +{"timestamp": "09:34:11.967", "elapsed_s": 13, "frame": 821, "type": "asteroid_split"} +{"timestamp": "09:34:13.025", "elapsed_s": 14, "frame": 887, "type": "asteroid_shot"} +{"timestamp": "09:34:13.025", "elapsed_s": 14, "frame": 887, "type": "asteroid_split"} +{"timestamp": "09:34:13.330", "elapsed_s": 14, "frame": 906, "type": "asteroid_shot"} +{"timestamp": "09:34:14.554", "elapsed_s": 15, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:15.018", "elapsed_s": 16, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:15.018", "elapsed_s": 16, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:15.354", "elapsed_s": 16, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:16.613", "elapsed_s": 17, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:17.905", "elapsed_s": 19, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:17.905", "elapsed_s": 19, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:18.729", "elapsed_s": 19, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:18.760", "elapsed_s": 19, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:18.760", "elapsed_s": 19, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:19.825", "elapsed_s": 20, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:19.841", "elapsed_s": 20, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:19.841", "elapsed_s": 20, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:20.112", "elapsed_s": 21, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:20.112", "elapsed_s": 21, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:20.144", "elapsed_s": 21, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:20.144", "elapsed_s": 21, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:20.527", "elapsed_s": 21, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:20.527", "elapsed_s": 21, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:21.234", "elapsed_s": 22, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:21.234", "elapsed_s": 22, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:21.314", "elapsed_s": 22, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:22.701", "elapsed_s": 23, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:22.701", "elapsed_s": 23, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:22.972", "elapsed_s": 24, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:22.972", "elapsed_s": 24, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:23.261", "elapsed_s": 24, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:23.647", "elapsed_s": 24, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:23.647", "elapsed_s": 24, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:24.028", "elapsed_s": 25, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:24.333", "elapsed_s": 25, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:24.813", "elapsed_s": 25, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:24.814", "elapsed_s": 25, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:25.435", "elapsed_s": 26, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:25.435", "elapsed_s": 26, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:25.916", "elapsed_s": 27, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:25.916", "elapsed_s": 27, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:25.995", "elapsed_s": 27, "frame": 961, "type": "asteroid_shot"} +{"timestamp": "09:34:25.995", "elapsed_s": 27, "frame": 961, "type": "asteroid_split"} +{"timestamp": "09:34:26.312", "elapsed_s": 27, "frame": 961, "type": "player_hit"} diff --git a/main.py b/main.py index 808eb72..07ec514 100644 --- a/main.py +++ b/main.py @@ -1,18 +1,43 @@ """Entrypoint for asteroids game""" +import sys + import pygame +from asteroid import Asteroid +from asteroidfield import AsteroidField from constants import SCREEN_HEIGHT, SCREEN_WIDTH -from logger import log_state +from logger import log_event, log_state +from player import Player +from shot import Shot + def main(): """Main method for asteroids game""" + print(f"Starting Asteroids with pygame version: {pygame.version.ver}") print(f"Screen width: {SCREEN_WIDTH}") print(f"Screen height: {SCREEN_HEIGHT}") + pygame.init() + clock = pygame.time.Clock() + dt = 0.0 + screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) + asteroids = pygame.sprite.Group() + updatable = pygame.sprite.Group() + drawable = pygame.sprite.Group() + shots = pygame.sprite.Group() + + Player.containers = (updatable, drawable) + Asteroid.containers = (asteroids, updatable, drawable) + AsteroidField.containers = (updatable) + Shot.containers = (updatable, drawable, shots) + + player = Player(SCREEN_WIDTH / 2, SCREEN_HEIGHT / 2) + AsteroidField() + while True: log_state() @@ -20,8 +45,29 @@ def main(): if event.type == pygame.QUIT: return + for u in updatable: + u.update(dt) + + for a in asteroids: + if a.collides_with(player): + log_event("player_hit") + print("Game over!") + sys.exit() + + for s in shots: + if a.collides_with(s): + log_event("asteroid_shot") + s.kill() + a.split() + screen.fill("black") + + for d in drawable: + d.draw(screen) + pygame.display.flip() + dt = clock.tick(60) / 1000 + if __name__ == "__main__": main() diff --git a/player.py b/player.py new file mode 100644 index 0000000..b8afe93 --- /dev/null +++ b/player.py @@ -0,0 +1,60 @@ +import pygame + +from circleshape import CircleShape +from constants import (LINE_WIDTH, PLAYER_RADIUS, PLAYER_SHOOT_COOLDOWN_SECONDS, PLAYER_SHOOT_SPEED, + PLAYER_SPEED, PLAYER_TURN_SPEED, SHOT_RADIUS) +from shot import Shot + + +class Player(CircleShape): + def __init__(self, x, y) -> None: + super().__init__(x, y, PLAYER_RADIUS) + self.rotation = 0 + self.cooldown = 0 + + def triangle(self) -> list[pygame.Vector2]: + forward = pygame.Vector2(0, 1).rotate(self.rotation) + right = pygame.Vector2(0, 1).rotate(self.rotation + 90) * self.radius / 1.5 + a = self.position + forward * self.radius + b = self.position - forward * self.radius - right + c = self.position - forward * self.radius + right + return [a, b, c] + + def draw(self, screen: pygame.Surface) -> None: + pygame.draw.polygon(screen, "white", self.triangle(), LINE_WIDTH) + + def rotate(self, dt: float) -> None: + self.rotation += PLAYER_TURN_SPEED * dt + + def update(self, dt: float) -> None: + self.cooldown -= dt + keys = pygame.key.get_pressed() + + if keys[pygame.K_a]: + self.rotate(-dt) + + if keys[pygame.K_d]: + self.rotate(dt) + + if keys[pygame.K_s]: + self.move(dt) + + if keys[pygame.K_w]: + self.move(-dt) + + if keys[pygame.K_SPACE]: + self.shoot() + + def move(self, dt: float) -> None: + unit_vector = pygame.Vector2(0, 1) + rotated_vector = unit_vector.rotate(self.rotation) + rotated_with_speed_vector = rotated_vector * PLAYER_SPEED * dt + self.position += rotated_with_speed_vector + + def shoot(self): + if self.cooldown > 0: + return + + s = Shot(self.position.x, self.position.y, SHOT_RADIUS) + s.velocity = pygame.Vector2(0, 1).rotate(self.rotation) * PLAYER_SHOOT_SPEED + self.cooldown = PLAYER_SHOOT_COOLDOWN_SECONDS diff --git a/pyproject.toml b/pyproject.toml index e933350..fc62c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,3 +7,7 @@ requires-python = ">=3.13" dependencies = [ "pygame==2.6.1", ] + +[tool.pyright] +include = ["."] +extraPaths = ["."] diff --git a/shot.py b/shot.py new file mode 100644 index 0000000..873c35f --- /dev/null +++ b/shot.py @@ -0,0 +1,14 @@ +import pygame +from circleshape import CircleShape +from constants import LINE_WIDTH + + +class Shot(CircleShape): + def __init__(self, x: float, y: float, radius: float) -> None: + super().__init__(x, y, radius) + + def draw(self, screen: pygame.Surface) -> None: + pygame.draw.circle(screen, "white", self.position, self.radius, LINE_WIDTH) + + def update(self, dt: float) -> None: + self.position += self.velocity * dt