diff --git a/ast_object.py b/ast_object.py new file mode 100644 index 0000000..7422df7 --- /dev/null +++ b/ast_object.py @@ -0,0 +1,11 @@ +class AstObject: + def wrap_around_screen(self): + """Wrap around screen.""" + if self.pos.x > self.settings.screen_size[0]: + self.pos.x = 0 + if self.pos.x < 0: + self.pos.x = self.settings.screen_size[0] + if self.pos.y <= 0: + self.pos.y = self.settings.screen_size[1] + if self.pos.y > self.settings.screen_size[1]: + self.pos.y = 0 diff --git a/asteroids.py b/asteroids.py index 6b847e2..b5db4a6 100644 --- a/asteroids.py +++ b/asteroids.py @@ -3,7 +3,11 @@ import sys from settings import Settings +from game_stats import GameStats +from scoreboard import Scoreboard from ship import Ship +from space_rock import SpaceRock +from button import Button class AsteroidsGame: @@ -14,32 +18,130 @@ def __init__(self): pg.init() self.settings = Settings() + self.stats = GameStats(self) self.screen = pg.display.set_mode( self.settings.screen_size) + self.sb = Scoreboard(self) + + self.screen_rect = self.screen.get_rect() pg.display.set_caption("Asteroids") self.clock = pg.time.Clock() + self.play_button = Button(self, "Play") + self.star_coords = [] self._make_stars() self.ship = Ship(self) + self.bullets = pg.sprite.Group() + self.rocks = pg.sprite.Group() + + # self._prepare_game(reset=True) + def run_game(self): """Start the main loop for the game""" while True: self._check_events() + if self.stats.game_active: + self.ship.update() + self.bullets.update() + self.rocks.update() + self._check_collisions() + self._update_screen() self.clock.tick(self.settings.fps) def _check_events(self): - """Respond to kwypresses and mouse events""" + """Respond to keypresses and mouse events""" for event in pg.event.get(): if event.type == pg.QUIT: pg.quit() sys.exit() - self.ship.update() + elif event.type == pg.KEYDOWN: + self._check_keydown_events(event) + elif event.type == pg.MOUSEBUTTONDOWN: + mouse_pos = pg.mouse.get_pos() + self._check_play_button(mouse_pos) + + def _check_play_button(self, mouse_pos): + """Start game when player clicks Play""" + clicked = self.play_button.rect.collidepoint(mouse_pos) + if clicked and not self.stats.game_active: + self._prepare_game(reset=True) + + def _check_keydown_events(self, event): + """Respond to keypresses""" + if event.key == pg.K_q: + pg.quit() + sys.exit() + elif event.key == pg.K_SPACE: + self.ship.fire() + + def _check_collisions(self): + self._check_bullet_collisions() + self._check_rock_collisions() + + def _check_bullet_collisions(self): + # check for bullets that hit the ship + # bullet = pg.sprite.spritecollideany(self.ship, self.bullets) + # if bullet: + # self._ship_hit() + # * Actually, the ship's bullets should never be able to hit the ship. + # * Skipping detection for now. + + rock_hits = pg.sprite.groupcollide( + self.bullets, self.rocks, True, False) + if rock_hits: + for rocks in rock_hits.values(): + for rock in rocks: + print(f"Rock hit! (HP: {rock.hp})") + rock.hp -= self.settings.bullet_dmg + + def check_rocks_left(self): + """Check to see if any rocks are left in the current level""" + if len(self.rocks) == 0: + self._prepare_game() # iterates to next level + pg.time.delay(500) + print(len(self.rocks)) + + def _check_rock_collisions(self): + # check for rocks that hit the ship + rock = pg.sprite.spritecollideany(self.ship, self.rocks) + if rock and not self.ship.invuln: + self._ship_hit() + rock.hp -= self.settings.bullet_dmg * 4 + # basically, destroy the rock that hit the ship + + # check for rocks hitting each other + # rock_hits = pg.sprite.groupcollide( + # self.rocks, self.rocks, False, False, self._collide_if_not_self) + # if rock_hits: + # for left_rock, hits in rock_hits.items(): + # for rock in hits: + # if not rock.ignore_collisions: + # rock.direction.reflect_ip(left_rock.pos - rock.pos) + # print("reflected") + # rock.ignore_collide() + + def _collide_if_not_self(self, left, right): + if left != right: + return pg.sprite.collide_rect(left, right) + return False + + def _ship_hit(self): + """Respond to the ship being hit""" + self.stats.ships_left -= 1 + self.sb.prep_ships() + if self.stats.ships_left >= 1: + # move ship back to center of screen + self.ship.center_ship() + else: + # game over! + self.stats.game_active = False + pg.mouse.set_visible(True) def _update_screen(self): """Update images on the screen and flip to the new screen""" @@ -48,7 +150,14 @@ def _update_screen(self): self.screen.fill(self.settings.bg_color) self._draw_stars() - self.ship.draw() + if self.stats.game_active: + self.ship.draw() + self.bullets.draw(self.screen) + self.rocks.draw(self.screen) + else: + self.play_button.draw_button() + + self.sb.show_score() # make the most recently drawn screen visible pg.display.flip() @@ -64,6 +173,47 @@ def _draw_stars(self): pg.draw.circle(self.screen, self.settings.star_color, coord, self.settings.star_size, 0) # 0 is filled + def _spawn_rocks_fixed(self): + + rocks_pos = [ + (self.screen_rect.centerx / 2, self.screen_rect.centery / 2), + (self.screen_rect.centerx * 2, self.screen_rect.centery * 2), + (self.screen_rect.centerx / 2, self.screen_rect.centery * 2), + (self.screen_rect.centerx * 2, self.screen_rect.centery / 2) + ] + for pos in rocks_pos: + new_rock = SpaceRock(self, pos) + self.rocks.add(new_rock) + + def _spawn_rocks_random(self): + for _ in range(self.settings.rocks_per_level): + pos = ( + random.randint(0, self.settings.screen_size[0]), + random.randint(0, self.settings.screen_size[1]) + ) + new_rock = SpaceRock(self, pos) + self.rocks.add(new_rock) + + def _prepare_game(self, reset=False): + """Prepare the game""" + if reset: + self.stats.game_active = True + self.stats.reset_stats() + self.settings.initialize_dynamic_settings() + self.bullets.empty() + self.rocks.empty() + pg.mouse.set_visible(False) + self.sb.prep_score() + self.sb.prep_level() + self.sb.prep_ships() + else: + self.settings.increment_dynamic_settings() + self.stats.level += 1 + self.sb.prep_level() + + self._spawn_rocks_random() + self.ship.center_ship() + if __name__ == "__main__": ast = AsteroidsGame() diff --git a/bullet.py b/bullet.py new file mode 100644 index 0000000..9b3788e --- /dev/null +++ b/bullet.py @@ -0,0 +1,63 @@ +from __future__ import annotations +# for ide type hinting +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from asteroids import AsteroidsGame + +from ast_object import AstObject +import pygame as pg +from pygame.sprite import Sprite +from pygame.math import Vector2 as vec +from pygame.transform import rotozoom + + +class Bullet(Sprite, AstObject): + """A class to manage bullets fired by the ship""" + + def __init__(self, game: AsteroidsGame): + """Create a bullet at the ship's current position""" + super().__init__() + self.screen = game.screen + self.settings = game.settings + self.color = self.settings.bullet_color + + # rotate the bullet to the same angle as the ship + self.original_image = pg.Surface( + (self.settings.bullet_width, self.settings.bullet_height)) + self.original_image.fill(self.color) + + self.image = self.original_image.copy() + + self.rect = self.image.get_rect() + self.rect.center = game.ship.pos + self.pos = vec(self.rect.center) + self.direction = game.ship.direction + self.vel = self.direction * self.settings.bullet_speed + self.angle = game.ship.angle + + self.image = pg.transform.rotate(self.original_image, self.angle) + + self.distance_travelled = 0.0 + + def update(self): + self.wrap_around_screen() + + # self._move() + + self.pos += self.vel + self.rect.center = self.pos + + # add to ship's total distance travelled + self.distance_travelled += self.vel.length() + self._check_expiration() + + def _check_expiration(self): + if (self.distance_travelled >= + self.settings.bullet_speed * self.settings.bullet_lifetime): + self.kill() + + def draw(self): + angle = self.direction.angle_to(self.settings.VECTOR_UP) + self.image = rotozoom(self.original_image, angle, 1) + blit_pos = self.pos - vec(self.image.get_size()) * 0.5 + self.screen.blit(self.image, blit_pos) diff --git a/button.py b/button.py new file mode 100644 index 0000000..a140099 --- /dev/null +++ b/button.py @@ -0,0 +1,41 @@ +from __future__ import annotations +# for ide type hinting +from typing import TYPE_CHECKING, Tuple +if TYPE_CHECKING: + from asteroids import AsteroidsGame + +import pygame as pg +from pygame.font import SysFont + + +class Button: + def __init__(self, game: AsteroidsGame, text): + """Init button attributes""" + + self.game = game + self.screen = game.screen + self.screen_rect = self.screen.get_rect() + + # dimensions and properties of the button + self.width = 250 + self.height = 50 + self.button_color = (107, 208, 255) + self.text_color = (0, 0, 0) + self.font = SysFont(None, 48) + + # build the rect + self.rect = pg.Rect(0, 0, self.width, self.height) + self.rect.center = self.screen_rect.center + + self._prep_text(text) + + def _prep_text(self, text): + self.text_image = self.font.render( + text, True, self.text_color, self.button_color) + self.text_image_rect = self.text_image.get_rect() + self.text_image_rect.center = self.rect.center + + def draw_button(self): + """Display the button""" + self.screen.fill(self.button_color, self.rect) + self.screen.blit(self.text_image, self.text_image_rect) diff --git a/game_stats.py b/game_stats.py new file mode 100644 index 0000000..8d81699 --- /dev/null +++ b/game_stats.py @@ -0,0 +1,14 @@ +class GameStats: + """Track statistics for Asteroids.""" + + def __init__(self, game): + self.game = game + self.reset_stats() + + # start in an inactive state + self.game_active = False + + def reset_stats(self): + self.score = 0 + self.level = 1 + self.ships_left = self.game.settings.max_ships diff --git a/media/ast-rock-1.png b/media/ast-rock-1.png new file mode 100644 index 0000000..c3046f0 Binary files /dev/null and b/media/ast-rock-1.png differ diff --git a/media/ast-rock-2.png b/media/ast-rock-2.png new file mode 100644 index 0000000..8b99ee8 Binary files /dev/null and b/media/ast-rock-2.png differ diff --git a/media/ast-rock-3.png b/media/ast-rock-3.png new file mode 100644 index 0000000..990ef1c Binary files /dev/null and b/media/ast-rock-3.png differ diff --git a/scoreboard.py b/scoreboard.py new file mode 100644 index 0000000..30a99ab --- /dev/null +++ b/scoreboard.py @@ -0,0 +1,66 @@ +from __future__ import annotations +# for ide type hinting +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from asteroids import AsteroidsGame + +import pygame as pg +from pygame.sprite import Group +from ship import Ship + + +class Scoreboard: + """A class to report scoring information""" + + def __init__(self, game: AsteroidsGame): + self.game = game + self.screen = game.screen + self.screen_rect = self.screen.get_rect() + self.settings = game.settings + self.stats = game.stats + + # font settings + self.text_color = (107, 208, 255) + self.font = pg.font.SysFont(None, 48) + + # prepare the initial score images + self.prep_score() + self.prep_level() + self.prep_ships() + + def prep_score(self): + """Turn the score into a rendered image""" + rounded_score = int(round(self.stats.score, -1)) + + score_str = "{:,}".format(rounded_score) + self.score_image = self.font.render(score_str, True, self.text_color) + + # display at top right of the screen + self.score_rect = self.score_image.get_rect() + self.score_rect.right = self.screen_rect.right - 20 + self.score_rect.top = 20 + + def prep_level(self): + """Turn the level into a rendered image""" + level_str = f"Level {self.stats.level}" + self.level_image = self.font.render(level_str, True, self.text_color) + + # position the level at the top center of the screen + self.level_rect = self.level_image.get_rect() + self.level_rect.centerx = self.screen_rect.centerx + self.level_rect.top = self.score_rect.top + + def prep_ships(self): + """Show how many ships are left""" + self.ships = Group() + for num in range(self.stats.ships_left): + ship = Ship(self.game, False) + ship.rect.x = 10 + num * (ship.rect.width + 10) + ship.rect.y = 10 + self.ships.add(ship) + + def show_score(self): + """Draw info to the screen""" + self.screen.blit(self.score_image, self.score_rect) + self.screen.blit(self.level_image, self.level_rect) + self.ships.draw(self.screen) diff --git a/settings.py b/settings.py index cb84fe4..f6a98c5 100644 --- a/settings.py +++ b/settings.py @@ -12,13 +12,43 @@ def __init__(self): self.fps = 60 # self.ship_size = (57, 68) + self.max_ships = 3 self.ship_size = (42, 50) - self.ship_max_speed = 2.5 - self.ship_acceleration = 5 / 100 - self.ship_turn_speed = 2 + self.ship_max_speed = 4 + self.ship_acceleration = 8.5 / 100 + self.ship_turn_speed = 3.5 + self.ship_fire_rate = 0.25 # in seconds + self.ship_invuln_time = 5 # in seconds self.STAR_COUNT = math.floor((math.sqrt( self.screen_size[0] * self.screen_size[1]) / 10 / 1.5) - * self.star_density) + * self.star_density) self.VECTOR_UP = Vector2(0, -1) + + self.bullet_color = (255, 255, 255) + self.bullet_width = 4 + self.bullet_height = 4 + self.bullet_speed = 10 + self.bullet_lifetime = 60 + self.bullet_dmg = 20 + + self.rock_speed_mult = 0.25 + self.rocks_per_level = 4 + self.rocks_size = [ + (150, 150), + (100, 100), + (50, 50) + ] + + self.initialize_dynamic_settings() + + def initialize_dynamic_settings(self): + self.rock_base_speed = 1.5 + self.rock_base_hp = 10 + self.rock_points = 100 # points / rock_size + + def increment_dynamic_settings(self): + self.rock_base_speed += 0.25 + self.rock_base_hp += 3 + self.rock_points *= 1.1 diff --git a/ship.py b/ship.py index f478dd1..7514a3d 100644 --- a/ship.py +++ b/ship.py @@ -3,24 +3,29 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: from asteroids import AsteroidsGame + +from ast_object import AstObject +from bullet import Bullet import pygame as pg from pygame.math import Vector2 as vec from pygame.transform import rotozoom +from pygame.sprite import Sprite -class Ship(pg.sprite.Sprite): +class Ship(Sprite, AstObject): """A class to manage the ship.""" - def __init__(self, game: AsteroidsGame): + def __init__(self, game: AsteroidsGame, player=True): """Initialize the ship and set its starting position.""" + super().__init__() self.game = game self.settings = game.settings self.screen = game.screen self.screen_rect = self.screen.get_rect() - image = pg.image.load("media/ast-ship.png") + image = pg.image.load("media/ast-ship.png").convert_alpha() self.image = pg.transform.scale(image, self.settings.ship_size) - image_move = pg.image.load("media/ast-ship-moving.png") + image_move = pg.image.load("media/ast-ship-moving.png").convert_alpha() self.image_move = pg.transform.scale( image_move, self.settings.ship_size) self.original_image = self.image.copy() @@ -28,17 +33,29 @@ def __init__(self, game: AsteroidsGame): self.rect = self.image.get_rect() - self.rect.center = self.screen_rect.center - self.pos = vec(self.rect.center) - self.vel = vec(0, 0) - self.direction = vec(0, -1) - self.angle = 0 + # self.rect.center = self.screen_rect.center + # self.pos = vec(self.rect.center) + # self.vel = vec(0, 0) + # self.direction = vec(0, -1) + # self.angle = 0 self.turn_speed = 0 self.accelerating = False + # allow a bullet to be fired instantly + self.last_fired_time = (pg.time.get_ticks() - + self.settings.ship_fire_rate) + + self.invuln = False + self.player = player # False for Lives display + if player: + self.center_ship() + def update(self): """Move according to the ship's velocity, angle, and turn speed""" + if not self.player: + return + self.wrap_around_screen() keys = pg.key.get_pressed() # doing it this way lets acceleration build up while the key is held @@ -68,6 +85,12 @@ def update(self): self.pos += self.vel self.rect.center = self.pos + # if the ship is invulnerable, check if it's time to stop invuln + if self.invuln: + if (pg.time.get_ticks() - self.invuln_start >= + self.settings.ship_invuln_time * 1000): + self.invuln = False + def _move(self): self.vel += self.direction * self.settings.ship_acceleration @@ -79,26 +102,44 @@ def _rotate(self): elif self.angle < 0: self.angle += 360 - # refactor to new reusable method - def wrap_around_screen(self): - """Wrap around screen.""" - if self.pos.x > self.settings.screen_size[0]: - self.pos.x = 0 - if self.pos.x < 0: - self.pos.x = self.settings.screen_size[0] - if self.pos.y <= 0: - self.pos.y = self.settings.screen_size[1] - if self.pos.y > self.settings.screen_size[1]: - self.pos.y = 0 - # is this the best way? def draw(self): """Draw the ship at its current location""" angle = self.direction.angle_to(self.settings.VECTOR_UP) self.image = rotozoom(self.original_image, angle, 1) self.image_move = rotozoom(self.original_image_move, angle, 1) + + # make ship appear transparent if invuln + if self.invuln: + self.image.set_alpha(128) + self.image_move.set_alpha(128) + blit_pos = self.pos - vec(self.image.get_size()) * 0.5 if self.accelerating: self.screen.blit(self.image_move, blit_pos) else: self.screen.blit(self.image, blit_pos) + + def fire(self): + """Fire a bullet if the ship is ready to fire""" + current_time = pg.time.get_ticks() + if (current_time - self.last_fired_time > + self.settings.ship_fire_rate * 1000): + self.last_fired_time = current_time + new_bullet = Bullet(self.game) + self.game.bullets.add(new_bullet) + + # firing a bullet cancels invuln + if self.invuln: + self.invuln = False + + def center_ship(self): + """Reset the ship to the center of the screen""" + self.rect.center = self.screen_rect.center + self.pos = vec(self.rect.center) + self.vel = vec(0, 0) + self.direction = vec(0, -1) + self.angle = 0 + + self.invuln = True + self.invuln_start = pg.time.get_ticks() diff --git a/space_rock.py b/space_rock.py new file mode 100644 index 0000000..22e859f --- /dev/null +++ b/space_rock.py @@ -0,0 +1,118 @@ +from __future__ import annotations +# for ide type hinting +from typing import TYPE_CHECKING, Tuple +if TYPE_CHECKING: + from asteroids import AsteroidsGame + +from ast_object import AstObject +import pygame as pg +from pygame.sprite import Sprite +from pygame.math import Vector2 as vec +import random + + +class SpaceRock(Sprite, AstObject): + """A class to manage the space rocks.""" + + def __init__(self, game: AsteroidsGame, rock_pos: Tuple[int, int] = None, + rock_size=1, parent: SpaceRock = None, rotate=0): + """Create a new space rock with a given size + + Parameters + ---------- + game : AsteroidsGame + The game instance + rock_size : int + 1 = largest, 3 = smallest + """ + super().__init__() + self.game = game + self.screen = game.screen + self.screen_rect = self.screen.get_rect() + self.settings = game.settings + self.size = rock_size + + image = pg.image.load(f"media/ast-rock-{self.size}.png") + self.image = pg.transform.scale(image, + self.settings.rocks_size[self.size-1]) + + self.rect = self.image.get_rect() + + # # fixed position (for testing) + # self.rect.centerx = self.screen_rect.centerx / 2 + # self.rect.centery = self.screen_rect.centery / 2 + + if rock_pos: + self.rect.centerx = rock_pos[0] + self.rect.centery = rock_pos[1] + self.pos = vec(self.rect.center) + + # set random direction + self.direction = vec(0, 0) + self.direction.x = random.random() + self.direction.y = random.random() + # self.direction.normalize() + + # inherit certain values from the parent (if applicable) + if parent: + self.pos = parent.pos.copy() + self.direction = parent.direction.copy() + self.direction.rotate_ip(rotate) + + # set speed + self.vel = (self.settings.rock_base_speed + + (random.uniform(0, self.settings.rock_speed_mult) * + self.size)) + + # set hp + self.hp = self.settings.rock_base_hp + ( + (4 - self.size) * self.settings.rock_base_hp) + + # make rock ignore collisions with itself for a few ticks + self.ignore_collide() + + def update(self): + self.wrap_around_screen() + + self.pos += (self.direction * self.vel) + self.rect.center = self.pos + + if self.hp <= 0: + # split rock into multiple smaller rocks + self.game.stats.score += self.settings.rock_points / self.size + self.game.sb.prep_score() + self._split() + self.kill() + self.game.check_rocks_left() + + if self.ignore_collisions: + if pg.time.get_ticks() - self.ignore_start_time > 5: + self.ignore_collisions = False + print("reflection allowed") + + def ignore_collide(self): + self.ignore_collisions = True + self.ignore_start_time = pg.time.get_ticks() + + def _split(self): + # ! STUB ! + if self.size < 3: + rock1 = SpaceRock(self.game, None, self.size+1, + self, random.randint(15, 90)) + rock2 = SpaceRock(self.game, None, self.size+1, + self, -random.randint(15, 90)) + + # position rocks based on direction of self + rock1.pos += (rock1.direction * + (self.settings.rocks_size[self.size-1][0] / 2.5)) + rock2.pos -= (rock1.direction * + (self.settings.rocks_size[self.size-1][0] / 2.5)) + + rock1.rect.center = rock1.pos + rock2.rect.center = rock2.pos + + self.game.rocks.add(rock1) + self.game.rocks.add(rock2) + + def draw(self): + self.screen.blit(self.image, self.rect)