├── .gitignore ├── README.md ├── asteroid.py ├── asteroids.gif ├── bullet.py ├── collisions.py ├── constants.py ├── main.py ├── ship.py ├── sound.py ├── sound.pyxres └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | score.txt 7 | out/ 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | share/python-wheels/ 27 | *.egg-info/ 28 | .installed.cfg 29 | *.egg 30 | MANIFEST 31 | 32 | # PyInstaller 33 | # Usually these files are written by a python script from a template 34 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 35 | *.manifest 36 | *.spec 37 | 38 | # Installer logs 39 | pip-log.txt 40 | pip-delete-this-directory.txt 41 | 42 | # Unit test / coverage reports 43 | htmlcov/ 44 | .tox/ 45 | .nox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | nosetests.xml 50 | coverage.xml 51 | *.cover 52 | *.py,cover 53 | .hypothesis/ 54 | .pytest_cache/ 55 | cover/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | .pybuilder/ 79 | target/ 80 | 81 | # Jupyter Notebook 82 | .ipynb_checkpoints 83 | 84 | # IPython 85 | profile_default/ 86 | ipython_config.py 87 | 88 | # pyenv 89 | # For a library or package, you might want to ignore these files since the code is 90 | # intended to run in multiple environments; otherwise, check them in: 91 | # .python-version 92 | 93 | # pipenv 94 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 95 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 96 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 97 | # install all needed dependencies. 98 | #Pipfile.lock 99 | 100 | # poetry 101 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 102 | # This is especially recommended for binary packages to ensure reproducibility, and is more 103 | # commonly ignored for libraries. 104 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 105 | #poetry.lock 106 | 107 | # pdm 108 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 109 | #pdm.lock 110 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 111 | # in version control. 112 | # https://pdm.fming.dev/#use-with-ide 113 | .pdm.toml 114 | 115 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 116 | __pypackages__/ 117 | 118 | # Celery stuff 119 | celerybeat-schedule 120 | celerybeat.pid 121 | 122 | # SageMath parsed files 123 | *.sage.py 124 | 125 | # Environments 126 | .env 127 | .venv 128 | env/ 129 | venv/ 130 | ENV/ 131 | env.bak/ 132 | venv.bak/ 133 | 134 | # Spyder project settings 135 | .spyderproject 136 | .spyproject 137 | 138 | # Rope project settings 139 | .ropeproject 140 | 141 | # mkdocs documentation 142 | /site 143 | 144 | # mypy 145 | .mypy_cache/ 146 | .dmypy.json 147 | dmypy.json 148 | 149 | # Pyre type checker 150 | .pyre/ 151 | 152 | # pytype static type analyzer 153 | .pytype/ 154 | 155 | # Cython debug symbols 156 | cython_debug/ 157 | 158 | # PyCharm 159 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 160 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 161 | # and can be added to the global gitignore or merged into this file. For a more nuclear 162 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 163 | #.idea/ 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Asteroids # 2 | ## The classic game of asteroids implemented in [Pyxel](https://github.com/kitao/pyxel)! ## 3 | 4 | Controls are **→** & **←** for turning, **↑** for acceleration and **space** for shooting! 5 | 6 | **Q**: Quit the game 7 | **R**: Restart the game 8 | 9 | Requires pyxel version == 1.9.18 10 | 11 | ![Screenshot!](https://github.com/timbledum/asteroids/blob/master/asteroids.gif) 12 | 13 | ## Features: ## 14 | 15 | 1. Moving! 16 | 2. Shooting! 17 | 3. Enemies (asteroids)! 18 | 4. Sound effects! 19 | 5. High scores! 20 | 6. More? 21 | 22 | ## Installation ## 23 | 24 | 1. Install [Python 3](https://www.python.org) 25 | 2. Install [Pyxel](https://github.com/kitao/pyxel) using their instructions 26 | 3. Clone or copy this repository 27 | 4. `python3 main.py` at the command line (if on windows use `py main.py`). 28 | 29 | 30 | ## Changelog ## 31 | 32 | * 12 April 2020: corrected resource file for compatability with newer versions of pyxel, correct colour choices to better match the new palette in pyxel 1.3.0 onwards. 33 | * 15 August 2023: updated init to match new pyxel versoin. 34 | 35 | By Marcus Croucher - 2023 :) -------------------------------------------------------------------------------- /asteroid.py: -------------------------------------------------------------------------------- 1 | """The asteroid class definition.""" 2 | 3 | 4 | import math 5 | import random 6 | 7 | import pyxel 8 | 9 | from utils import check_bounds, rotate_around_origin, Point 10 | import constants 11 | 12 | 13 | class Asteroid: 14 | """The asteroid class. 15 | 16 | The asteroid class describes the behaviour and rendering of the asteroids. This includes: 17 | - initial creation 18 | - spawning of new asteroids 19 | - splitting of asteroids into smaller asteroids 20 | - movement and rotation 21 | 22 | There are three different asteroid shapes described in the constants file. 23 | 24 | On the class level, it also keeps track of all asteroids in play (and can 25 | render all at once), and the asteroid score.""" 26 | 27 | asteroids = [] 28 | asteroid_score = 0 29 | 30 | def __init__( 31 | self, 32 | size=constants.ASTERPOD_INITIAL_SIZE, 33 | radius=constants.ASTEROID_RADIUS, 34 | position=None, 35 | ): 36 | """Initialise the asteroid, including the position, size, and points. 37 | 38 | By default, the asteroid is the largest size and randomly placed, but 39 | this is overriden for smaller asteroids.""" 40 | 41 | self.colour = constants.ASTEROID_COLOUR 42 | self.size = size 43 | self.radius = radius 44 | 45 | self.init_position(position) 46 | 47 | self.direction = random.random() * math.pi * 2 48 | self.velocity = rotate_around_origin( 49 | (0, -constants.ASTEROID_VELOCITY), self.direction 50 | ) 51 | 52 | self.spin_direction = random.choice((-1, 1)) 53 | 54 | asteroid_points = random.choice(constants.ASTEROID_SHAPES) 55 | scale = radius / constants.ASTEROID_RADIUS 56 | 57 | self.points = [] 58 | for x, y in asteroid_points: 59 | point_new = Point(x * scale, y * scale) 60 | point_new.rotate_point(self.direction) 61 | self.points.append(point_new) 62 | 63 | Asteroid.asteroids.append(self) 64 | 65 | def init_position(self, position): 66 | """Create the position, either as defined, or random. 67 | 68 | If the position is random, positions are tried until one 69 | is found which doesn't overlap the ship.""" 70 | 71 | if position: 72 | x, y = position 73 | self.x = x 74 | self.y = y 75 | else: 76 | ship_x = Asteroid.ship.x 77 | ship_y = Asteroid.ship.y 78 | 79 | while True: 80 | self.x = random.randint(0, pyxel.width) 81 | self.y = random.randint(0, pyxel.height) 82 | 83 | if ( 84 | math.hypot(self.x - ship_x, self.y - ship_y) 85 | > constants.ASTEROID_SPAWN_BUFFER 86 | ): 87 | break 88 | 89 | @classmethod 90 | def init_class(cls, ship): 91 | """An initial method called before the asteroids are first placed to 92 | give the class the ship position for reference.""" 93 | cls.ship = ship 94 | 95 | def update(self): 96 | """Update the position and rotation of the asteroid. Also checks bounds.""" 97 | 98 | rotation_angle = constants.ASTEROID_ROTATION * self.spin_direction 99 | 100 | for point in self.points: 101 | point.rotate_point(rotation_angle) 102 | 103 | x_vol, y_vol = self.velocity 104 | self.x += x_vol 105 | self.y += y_vol 106 | 107 | self.x = check_bounds(self.x, pyxel.width, constants.ASTEROID_BUFFER) 108 | self.y = check_bounds(self.y, pyxel.height, constants.ASTEROID_BUFFER) 109 | 110 | def destroy(self): 111 | """Destroy asteroid and place new smaller asteroids if appropriate.""" 112 | if self.size > 0: 113 | for _ in range(constants.ASTEROID_SPLITS): 114 | Asteroid(self.size - 1, self.radius / 2, (self.x, self.y)) 115 | 116 | Asteroid.asteroid_score += 1 117 | Asteroid.asteroids.remove(self) 118 | del self 119 | 120 | def display(self): 121 | """Display the asteroid by iterating through the points and drawing lines.""" 122 | for point1, point2 in zip(self.points, self.points[1:] + [self.points[0]]): 123 | pyxel.line( 124 | x1=point1.x + self.x, 125 | y1=point1.y + self.y, 126 | x2=point2.x + self.x, 127 | y2=point2.y + self.y, 128 | col=self.colour, 129 | ) 130 | 131 | @staticmethod 132 | def initiate_game(): 133 | """Place the initial three asteroids and reset score.""" 134 | Asteroid.asteroids.clear() 135 | Asteroid.asteroid_score = 0 136 | for _ in range(constants.ASTEROID_INITIAL_QUANTITY): 137 | Asteroid() 138 | 139 | @staticmethod 140 | def update_all(): 141 | """Convenience function to update all asteroids.""" 142 | for asteroid in Asteroid.asteroids: 143 | asteroid.update() 144 | 145 | @staticmethod 146 | def display_all(): 147 | """Convenience function to display all asteroids.""" 148 | for asteroid in Asteroid.asteroids: 149 | asteroid.display() 150 | -------------------------------------------------------------------------------- /asteroids.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timbledum/asteroids/3dd1440f21c3539de91e2132c2edcaf30fad4c74/asteroids.gif -------------------------------------------------------------------------------- /bullet.py: -------------------------------------------------------------------------------- 1 | """The bullet class.""" 2 | 3 | 4 | import pyxel 5 | import constants 6 | 7 | 8 | class Bullet: 9 | """The bullet class. 10 | 11 | The bullet class describes the behaviour and rendering of the bullets. This includes: 12 | - initial creation 13 | - movement 14 | 15 | On the class level, it also keeps track of all bullets in play (and can 16 | render all at once).""" 17 | 18 | bullets = [] 19 | radius = constants.BULLET_RADIUS 20 | 21 | def __init__(self, x, y, velocity_x, velocity_y, colour): 22 | """Set the bullet key variables and and the bullet to the collection of bullets.""" 23 | self.x = x 24 | self.y = y 25 | self.velocity_x = velocity_x 26 | self.velocity_y = velocity_y 27 | self.colour = colour 28 | 29 | Bullet.bullets.append(self) 30 | 31 | def update(self): 32 | """Update the position based on the velocity, and destroy if out of bounds.""" 33 | self.x += self.velocity_x 34 | self.y += self.velocity_y 35 | 36 | if ( 37 | (self.x < 0) 38 | or (self.y < 0) 39 | or (self.x > pyxel.width) 40 | or (self.y > pyxel.width) 41 | ): 42 | self.destroy() 43 | 44 | def destroy(self): 45 | """Remove the current bullet from the collection.""" 46 | Bullet.bullets.remove(self) 47 | del self 48 | 49 | def display(self): 50 | """Display the bullet as a line.""" 51 | pyxel.line( 52 | x1=self.x, 53 | y1=self.y, 54 | x2=self.x + self.velocity_x, 55 | y2=self.y + self.velocity_y, 56 | col=self.colour, 57 | ) 58 | 59 | @staticmethod 60 | def update_all(): 61 | """Convenience function to update all bullets.""" 62 | for bullet in Bullet.bullets: 63 | bullet.update() 64 | 65 | @staticmethod 66 | def display_all(): 67 | """Convenience function to display all bullets.""" 68 | for bullet in Bullet.bullets: 69 | bullet.display() 70 | -------------------------------------------------------------------------------- /collisions.py: -------------------------------------------------------------------------------- 1 | """Module for keeping track of and detecting collisions.""" 2 | 3 | from itertools import product 4 | import sound 5 | 6 | 7 | def detect_collision(object1, object2): 8 | """Detects whether a collision has been made between two objects. 9 | 10 | Assumes both objects are circles and have x, y and radius attributes.""" 11 | 12 | x_dist = object1.x - object2.x 13 | y_dist = object1.y - object2.y 14 | 15 | total_radius = object1.radius + object2.radius 16 | return (x_dist * x_dist + y_dist * y_dist) < (total_radius * total_radius) 17 | 18 | 19 | def detect_ship_asteroid_collisions(ship, asteroid_class): 20 | """Iterate between the asteroids to detect collisions between the ship and asteroids.""" 21 | test_cases = ((ship, asteroid) for asteroid in asteroid_class.asteroids) 22 | return any((detect_collision(*test_case) for test_case in test_cases)) 23 | 24 | 25 | def return_first_match(element, lst, test): 26 | """Helper function to find the first match in a list based on a custom test.""" 27 | for i in lst: 28 | if test(element, i): 29 | return i 30 | 31 | 32 | def detect_bullet_asteroid_collisions(bullet_class, asteroid_class): 33 | """Detect collisions between bullets and asteroids. 34 | 35 | For each asteroid, search through bullets for a collision. If there 36 | is, delete both the asteroid and the bullet.""" 37 | 38 | asteroid_destroy_list = [] 39 | for asteroid in asteroid_class.asteroids.copy(): 40 | bullet = return_first_match(asteroid, bullet_class.bullets, detect_collision) 41 | if bullet: 42 | bullet.destroy() 43 | asteroid.destroy() 44 | sound.hit() 45 | -------------------------------------------------------------------------------- /constants.py: -------------------------------------------------------------------------------- 1 | """Module to contain constants.""" 2 | 3 | # General constants # 4 | BACKGROUND_COLOUR = 1 5 | SCORE_COLOUR = 7 6 | DEATH_COLOUR = 0 7 | DEATH_STRIP_COLOUR = 8 8 | DEATH_TEXT_COLOUR = 0 9 | DEATH_HEIGHT = 134 10 | 11 | INITIAL_SPAWN_FREQUENCY = 300 12 | SPAWN_FREQUENCY_MOVEMENT = 0.9 13 | 14 | HIGH_SCORE_FILE = "score.txt" 15 | SOUND_FILE = "sound.pyxres" 16 | 17 | # Ship related constants # 18 | SHIP_COLOUR = 6 19 | SHIP_INITIAL_POSITION = (100, 100) 20 | SHIP_POINTS = [(0, -8), (4, 4), (0, 2), (-4, 4)] 21 | SHIP_ACCELERATION_POINTS = 6, 13 22 | SHIP_ACCELERATION_COLOUR = 10 23 | ROTATION = 0.1 24 | DRAG = 0.98 25 | ACCELERATION = 0.4 26 | MAX_ACCELERATION = 6 27 | SHIP_RADIUS = 4 28 | BUFFER = 7 29 | SHIP_DRIFT_VELOCITY = 0.6 30 | SHIP_BREAKUP_ROTATION = 0.01 31 | SHIP_BREAKUP_DRAG = 0.997 32 | 33 | # Bullet related constants # 34 | BULLET_COLOUR = 14 35 | BULLET_VELOCITY = 5 36 | BULLET_RADIUS = 1 37 | BULLET_SHOOT_FREQUENCY = 5 38 | 39 | # Asteriod related constants # 40 | ASTEROID_COLOUR = 13 41 | ASTEROID_INITIAL_QUANTITY = 3 42 | ASTEROID_ROTATION = 0.02 43 | ASTEROID_RADIUS = 16 44 | ASTERPOD_INITIAL_SIZE = 2 45 | ASTEROID_SPLITS = 3 46 | ASTEROID_VELOCITY = 0.7 47 | ASTEROID_BUFFER = 16 48 | ASTEROID_SPAWN_BUFFER = SHIP_RADIUS + ASTEROID_RADIUS * 2 49 | 50 | ASTEROID_SHAPES = [ 51 | [ 52 | (0, 15), 53 | (4, 9), 54 | (11, 5), 55 | (15, 1), 56 | (5, -3), 57 | (0, -14), 58 | (-6, -4), 59 | (-17, -4), 60 | (-12, 9), 61 | ], 62 | [ 63 | (1, 16), 64 | (6, 12), 65 | (6, 6), 66 | (17, 2), 67 | (9, -12), 68 | (1, -17), 69 | (-4, -2), 70 | (-18, -4), 71 | (-11, 8), 72 | ], 73 | [ 74 | (0, 17), 75 | (7, 10), 76 | (4, 8), 77 | (14, -1), 78 | (5, -2), 79 | (1, -16), 80 | (-6, -2), 81 | (-16, -4), 82 | (-11, 6), 83 | ], 84 | ] 85 | -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- 1 | """# Asteroids # 2 | ## The classic game of asteroids implemented in [Pyxel](https://github.com/kitao/pyxel)! ## 3 | 4 | Controls are **→** & **←** for turning, **↑** for acceleration and **space** for shooting! 5 | 6 | **Q**: Quit the game 7 | 8 | ![Screenshot!](https://github.com/timbledum/asteroids/blob/master/asteroids.gif) 9 | 10 | ## Features: ## 11 | 12 | 1. Moving! 13 | 2. Shooting! 14 | 3. Enemies (asteroids)! 15 | 4. Sound effects! 16 | 5. High scores! 17 | 6. More? 18 | 19 | ## Installation ## 20 | 21 | 1. Install [Python 3](https://www.python.org) 22 | 2. Install [Pyxel](https://github.com/kitao/pyxel) using their instructions 23 | 3. Clone or copy this repository 24 | 4. `python3 main.py` at the command line (if on windows use `py main.py`). 25 | 26 | 27 | """ 28 | 29 | import pyxel 30 | 31 | from asteroid import Asteroid 32 | from bullet import Bullet 33 | import collisions 34 | import constants 35 | from utils import center_text, get_highscore, save_highscore 36 | from ship import Ship, ShipBreakup 37 | import sound 38 | 39 | 40 | class Game: 41 | """Manage the game state and various classes.""" 42 | 43 | def __init__(self): 44 | """Initialise pyxel and various classes and variables (one off).""" 45 | 46 | pyxel.init(200, 200, display_scale=2) 47 | self.ship = Ship(*constants.SHIP_INITIAL_POSITION, constants.SHIP_COLOUR) 48 | Asteroid.init_class(self.ship) 49 | sound.init_music() 50 | 51 | self.reset_game() 52 | self.high_score = get_highscore(constants.HIGH_SCORE_FILE) 53 | 54 | pyxel.run(self.update, self.draw) 55 | 56 | def reset_game(self): 57 | """Initialise start of game state (reset ship position, score, and asteroids).""" 58 | self.ship.reset() 59 | Asteroid.initiate_game() 60 | self.death = False 61 | self.spawn_speed = constants.INITIAL_SPAWN_FREQUENCY 62 | self.next_spawn = pyxel.frame_count + self.spawn_speed 63 | 64 | def update(self): 65 | """Update the game state, including the asteroids, ship and bullets.""" 66 | self.check_input() 67 | 68 | Bullet.update_all() 69 | Asteroid.update_all() 70 | 71 | if not self.death: 72 | self.ship.update_position() 73 | self.check_spawn_asteroid() 74 | self.check_collisions() 75 | else: 76 | self.ship_breakup.update() 77 | 78 | def check_input(self): 79 | """Check for input and modify the game state accordingly.""" 80 | if not self.death: 81 | if pyxel.btn(pyxel.KEY_UP): 82 | if not self.ship.accelerating: 83 | sound.start_accelerate() 84 | self.ship.accelerate() 85 | else: 86 | if self.ship.accelerating: 87 | sound.stop_accelerate() 88 | self.ship.accelerating = False 89 | 90 | if pyxel.btnp(pyxel.KEY_SPACE, 0, constants.BULLET_SHOOT_FREQUENCY): 91 | self.ship.shoot() 92 | 93 | if pyxel.btn(pyxel.KEY_SPACE): 94 | self.ship.yes_shoot() 95 | else: 96 | self.ship.no_shoot() 97 | 98 | if pyxel.btn(pyxel.KEY_LEFT): 99 | self.ship.rotate("l") 100 | elif pyxel.btn(pyxel.KEY_RIGHT): 101 | self.ship.rotate("r") 102 | elif pyxel.btnp(pyxel.KEY_R): 103 | self.reset_game() 104 | 105 | if pyxel.btnp(pyxel.KEY_Q) or pyxel.btnp(pyxel.KEY_ESCAPE): 106 | pyxel.quit() 107 | 108 | def check_collisions(self): 109 | """Check for collisions between the ship and asteroids, and the bullet and asteroids.""" 110 | if collisions.detect_ship_asteroid_collisions(self.ship, Asteroid): 111 | self.death_event() 112 | 113 | collisions.detect_bullet_asteroid_collisions(Bullet, Asteroid) 114 | 115 | def death_event(self): 116 | """Modify game state for when the ship hits and asteroid.""" 117 | self.ship.destroy() 118 | self.ship_breakup = ShipBreakup(self.ship) 119 | self.death = True 120 | sound.death() 121 | 122 | if Asteroid.asteroid_score > self.high_score: 123 | self.high_score = Asteroid.asteroid_score 124 | save_highscore(constants.HIGH_SCORE_FILE, self.high_score) 125 | 126 | def check_spawn_asteroid(self): 127 | """Keep track of the time and spawn new asteroids when appropriate. 128 | 129 | Asteroids spawn on a reducing time scale (time decreases by a certain percentage each time.""" 130 | if pyxel.frame_count >= self.next_spawn: 131 | Asteroid() 132 | self.next_spawn += self.spawn_speed 133 | self.spawn_speed *= constants.SPAWN_FREQUENCY_MOVEMENT 134 | sound.spawn() 135 | 136 | def draw(self): 137 | """Master method for drawing the board. Mainly calls the display methods of the various classes.""" 138 | background_colour = ( 139 | constants.BACKGROUND_COLOUR if not self.death else constants.DEATH_COLOUR 140 | ) 141 | 142 | pyxel.cls(background_colour) 143 | Bullet.display_all() 144 | Asteroid.display_all() 145 | if not self.death: 146 | self.ship.display() 147 | self.draw_score() 148 | else: 149 | self.ship_breakup.display() 150 | self.draw_death() 151 | 152 | def draw_score(self): 153 | """Draw the score and the high score at the top.""" 154 | 155 | score = "{:04}".format(Asteroid.asteroid_score) 156 | high_score = "HS:{:04}".format(self.high_score) 157 | high_score_x = pyxel.width - 2 - (7 * pyxel.FONT_WIDTH) 158 | 159 | pyxel.text(3, 3, score, constants.SCORE_COLOUR) 160 | pyxel.text(high_score_x, 3, high_score, constants.SCORE_COLOUR) 161 | 162 | def draw_death(self): 163 | """Draw the display text for the end of the game with the score.""" 164 | display_text = ["YOU DIED"] 165 | display_text.append("Your score is {:04}".format(Asteroid.asteroid_score)) 166 | if Asteroid.asteroid_score == self.high_score: 167 | display_text.append("YOU HAVE A NEW HIGH SCORE!") 168 | else: 169 | display_text.append("The high score is {:04}".format(self.high_score)) 170 | display_text.append("(Q)uit or (R)estart") 171 | 172 | text_area_height = len(display_text) * (pyxel.FONT_HEIGHT + 2) - 2 173 | pyxel.rect( 174 | x=0, 175 | y=constants.DEATH_HEIGHT - 2, 176 | w=pyxel.width, 177 | h=text_area_height + 4, 178 | col=constants.DEATH_STRIP_COLOUR, 179 | ) 180 | 181 | for i, text in enumerate(display_text): 182 | y_offset = (pyxel.FONT_HEIGHT + 2) * i 183 | text_x = center_text(text, pyxel.width, pyxel.FONT_WIDTH) 184 | pyxel.text( 185 | text_x, 186 | constants.DEATH_HEIGHT + y_offset, 187 | text, 188 | constants.DEATH_TEXT_COLOUR, 189 | ) 190 | 191 | 192 | if __name__ == "__main__": 193 | Game() 194 | -------------------------------------------------------------------------------- /ship.py: -------------------------------------------------------------------------------- 1 | """The ship module. 2 | 3 | Defines the ship class, and the ship-breakup class (for when 4 | death happens).""" 5 | 6 | import math 7 | import random 8 | 9 | import pyxel 10 | 11 | from bullet import Bullet 12 | from utils import check_bounds, rotate_around_origin, Point 13 | 14 | import constants 15 | import sound 16 | 17 | 18 | class Ship: 19 | """The ship class. 20 | 21 | The ship class describes the behaviour and rendering of the ships. This includes: 22 | - initial creation 23 | - resetting on new game 24 | - control (rotation, acceleration and shooting) 25 | """ 26 | 27 | radius = constants.SHIP_RADIUS 28 | 29 | def __init__(self, x, y, colour): 30 | """Set up initial variables.""" 31 | self.starting_x = x 32 | self.starting_y = y 33 | self.starting_colour = colour 34 | self.reset() 35 | self.accelerating = False 36 | self.shooting = False 37 | 38 | def reset(self): 39 | """Reset the game specific variables (position, momentum and direction).""" 40 | self.x = self.starting_x 41 | self.y = self.starting_y 42 | self.colour = self.starting_colour 43 | self.direction = 0 44 | self.momentum_x = 0 45 | self.momentum_y = 0 46 | 47 | self.points = [] 48 | for point in constants.SHIP_POINTS: 49 | self.points.append(Point(*point)) 50 | 51 | def rotate(self, direction): 52 | """Rotate the ship based on user input.""" 53 | if direction == "l": 54 | multipler = 1 55 | elif direction == "r": 56 | multipler = -1 57 | else: 58 | raise ValueError("Direction must be the 'l'eft or 'r'ight") 59 | 60 | rotation_angle = constants.ROTATION * multipler 61 | 62 | for point in self.points: 63 | point.rotate_point(rotation_angle) 64 | 65 | self.direction += rotation_angle 66 | 67 | def accelerate(self): 68 | """Increase the velocity (to a maximum) of the ship based on user input.""" 69 | 70 | self.accelerating = True 71 | 72 | acc_x, acc_y = rotate_around_origin( 73 | (0, -constants.ACCELERATION), self.direction 74 | ) 75 | self.momentum_x += acc_x 76 | self.momentum_y += acc_y 77 | 78 | acceleration = math.hypot(self.momentum_x, self.momentum_y) 79 | if acceleration > constants.MAX_ACCELERATION: 80 | scale = constants.MAX_ACCELERATION / acceleration 81 | self.momentum_x *= scale 82 | self.momentum_y *= scale 83 | assert ( 84 | round(math.hypot(self.momentum_x, self.momentum_y), 0) 85 | == constants.MAX_ACCELERATION 86 | ) 87 | 88 | def shoot(self): 89 | """Create a bullet based on the ship's direction and position.""" 90 | vel_x, vel_y = rotate_around_origin( 91 | (0, -constants.BULLET_VELOCITY), self.direction 92 | ) 93 | ship_tip = self.points[0] 94 | Bullet( 95 | self.points[0].x + self.x, 96 | self.points[0].y + self.y, 97 | vel_x, 98 | vel_y, 99 | constants.BULLET_COLOUR, 100 | ) 101 | 102 | def yes_shoot(self): 103 | """Start the shoot sound.""" 104 | if not self.shooting: 105 | sound.start_shoot() 106 | self.shooting = True 107 | 108 | def no_shoot(self): 109 | """End the shoot sound.""" 110 | if self.shooting: 111 | sound.stop_shoot() 112 | self.shooting = False 113 | 114 | def destroy(self): 115 | """Destroy the ship (does nothing at this point).""" 116 | pass 117 | 118 | def update_position(self): 119 | """Update the position and reduce the velocity.""" 120 | self.x += self.momentum_x 121 | self.y += self.momentum_y 122 | self.momentum_x *= constants.DRAG 123 | self.momentum_y *= constants.DRAG 124 | 125 | self.x = check_bounds(self.x, pyxel.width, constants.BUFFER) 126 | self.y = check_bounds(self.y, pyxel.height, constants.BUFFER) 127 | 128 | def display(self): 129 | """Display lines between each point and display the exhaust if accelerating.""" 130 | 131 | for point1, point2 in zip(self.points, self.points[1:] + [self.points[0]]): 132 | pyxel.line( 133 | x1=point1.x + self.x, 134 | y1=point1.y + self.y, 135 | x2=point2.x + self.x, 136 | y2=point2.y + self.y, 137 | col=self.colour, 138 | ) 139 | 140 | if self.accelerating: 141 | self.display_acceleration() 142 | 143 | def display_acceleration(self): 144 | """Display the exhaust if accelerating.""" 145 | x1, y1 = rotate_around_origin( 146 | (0, constants.SHIP_ACCELERATION_POINTS[0]), self.direction 147 | ) 148 | x2, y2 = rotate_around_origin( 149 | (0, constants.SHIP_ACCELERATION_POINTS[1]), self.direction 150 | ) 151 | pyxel.line( 152 | x1=x1 + self.x, 153 | y1=y1 + self.y, 154 | x2=x2 + self.x, 155 | y2=y2 + self.y, 156 | col=constants.SHIP_ACCELERATION_COLOUR, 157 | ) 158 | 159 | 160 | class ShipBreakup: 161 | """A class based on the ship on death which displays the various segements 162 | drifting aimlessly in space.""" 163 | 164 | def __init__(self, ship): 165 | """Coppies key parameters from ship and constructs the lines to drift.""" 166 | self.segments = [] 167 | 168 | def random_velocity(): 169 | """Helper function to determine a random velocity.""" 170 | direction = random.random() * math.pi * 2 171 | velocity = rotate_around_origin( 172 | (0, -constants.SHIP_DRIFT_VELOCITY), direction 173 | ) 174 | return velocity 175 | 176 | for point1, point2 in zip(ship.points, ship.points[1:] + [ship.points[0]]): 177 | rvel_x, rvel_y = random_velocity() 178 | line_velocity = (rvel_x + ship.momentum_x, rvel_y + ship.momentum_y) 179 | spin = constants.SHIP_BREAKUP_ROTATION * random.choice((-1, 1)) 180 | 181 | line = Line.line_from_two_points( 182 | point1.x + ship.x, 183 | point1.y + ship.y, 184 | point2.x + ship.x, 185 | point2.y + ship.y, 186 | velocity=line_velocity, 187 | spin=spin, 188 | colour=ship.colour, 189 | ) 190 | self.segments.append(line) 191 | 192 | def update(self): 193 | """Drift the ship segments.""" 194 | for segment in self.segments: 195 | segment.update() 196 | 197 | def display(self): 198 | """Display lines between each point.""" 199 | for segment in self.segments: 200 | segment.display() 201 | 202 | 203 | class Line: 204 | """Class to contain a rotating line segment.""" 205 | 206 | def __init__(self, x, y, length, direction, velocity, spin, colour): 207 | """Initialise variables and set ends of the line. 208 | 209 | The provided x and y are of the center point.""" 210 | self.x = x 211 | self.y = y 212 | self.length = length 213 | self.direction = direction 214 | self.vel_x, self.vel_y = velocity 215 | self.spin = spin 216 | self.colour = colour 217 | 218 | self.points = [] 219 | for end in (1, -1): 220 | self.points.append(Point(0, end * self.length / 2)) 221 | 222 | self.rotate(direction) 223 | 224 | @classmethod 225 | def line_from_two_points(cls, x1, y1, x2, y2, velocity, spin, colour): 226 | """Construct a line from two points rather than a point, a direction, and a length.""" 227 | x = sum((x1, x2)) / 2 228 | y = sum((y1, y2)) / 2 229 | length = math.hypot(x1 - x2, y1 - y2) 230 | direction = -math.atan2(y1 - y2, x1 - x2) - math.pi / 2 231 | return cls(x, y, length, direction, velocity, spin, colour) 232 | 233 | def rotate(self, radians): 234 | """Rotate both points around center.""" 235 | for point in self.points: 236 | point.rotate_point(radians) 237 | 238 | def update(self): 239 | """Update the position and rotate.""" 240 | self.x += self.vel_x 241 | self.y += self.vel_y 242 | 243 | self.vel_x *= constants.SHIP_BREAKUP_DRAG 244 | self.vel_y *= constants.SHIP_BREAKUP_DRAG 245 | 246 | self.x = check_bounds(self.x, pyxel.width, constants.BUFFER) 247 | self.y = check_bounds(self.y, pyxel.height, constants.BUFFER) 248 | 249 | self.rotate(self.spin) 250 | 251 | def display(self): 252 | """Display lines between each point and display the exhaust if accelerating.""" 253 | point1, point2 = self.points 254 | pyxel.line( 255 | x1=point1.x + self.x, 256 | y1=point1.y + self.y, 257 | x2=point2.x + self.x, 258 | y2=point2.y + self.y, 259 | col=self.colour, 260 | ) 261 | -------------------------------------------------------------------------------- /sound.py: -------------------------------------------------------------------------------- 1 | """Module with convenience functions for triggering various sound events.""" 2 | 3 | from pathlib import Path 4 | 5 | import pyxel 6 | 7 | import constants 8 | 9 | 10 | def init_music(): 11 | file = Path(__file__).parent / constants.SOUND_FILE 12 | pyxel.load(str(file)) 13 | 14 | 15 | def start_shoot(): 16 | pyxel.play(0, 0, loop=True) 17 | 18 | 19 | def stop_shoot(): 20 | pyxel.stop(0) 21 | 22 | 23 | def start_accelerate(): 24 | pyxel.play(1, 2, loop=True) 25 | 26 | 27 | def stop_accelerate(): 28 | pyxel.stop(1) 29 | 30 | 31 | def spawn(): 32 | pyxel.play(2, 1) 33 | 34 | 35 | def hit(): 36 | pyxel.play(2, 4) 37 | 38 | 39 | def death(): 40 | pyxel.stop() 41 | pyxel.play(2, 3) 42 | -------------------------------------------------------------------------------- /sound.pyxres: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timbledum/asteroids/3dd1440f21c3539de91e2132c2edcaf30fad4c74/sound.pyxres -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | """Module of utilities. Contains utility functions for: 2 | - rotation 3 | - bounds checking 4 | - text manipulation 5 | - disk access (persistance) 6 | - mini Point class (which can rotate around an origin) 7 | """ 8 | 9 | 10 | import math 11 | from pathlib import Path 12 | 13 | 14 | def check_bounds(position, limit, buffer): 15 | """Check whether a co-ordinate is within a limit (including a buffer). 16 | 17 | One dimensional, and assumes the lower limit is 0 (less the buffer).""" 18 | 19 | if position < 0 - buffer: 20 | return limit + buffer 21 | elif position > limit + buffer: 22 | return -buffer 23 | else: 24 | return position 25 | 26 | 27 | def rotate_around_origin(xy, radians): 28 | """Rotate a point around the origin. 29 | 30 | Taken from https://ls3.io/post/rotate_a_2d_coordinate_around_a_point_in_python/""" 31 | x, y = xy 32 | xx = x * math.cos(radians) + y * math.sin(radians) 33 | yy = -x * math.sin(radians) + y * math.cos(radians) 34 | return xx, yy 35 | 36 | 37 | def center_text(text, page_width, char_width): 38 | """Helper function for calcuating the start x value for centered text.""" 39 | 40 | text_width = len(text) * char_width 41 | return (page_width - text_width) // 2 42 | 43 | 44 | def get_highscore(filename): 45 | """Get the highscore (integer) from a text file.""" 46 | file = Path(__file__).parent / filename 47 | try: 48 | high_score = int(file.read_text()) 49 | except FileNotFoundError: 50 | high_score = 0 51 | except ValueError: 52 | raise ValueError( 53 | "File contents does not evaluate to string – highscore file corrupted." 54 | ) 55 | return high_score 56 | 57 | 58 | def save_highscore(filename, high_score): 59 | """Save an integer to a text file in the same directory as this file.""" 60 | file = Path(__file__).parent / filename 61 | file.write_text(str(high_score)) 62 | 63 | 64 | class Point: 65 | """Class to capture points in an entity with the rotate helper method included.""" 66 | 67 | def __init__(self, x, y): 68 | """Initiate variables.""" 69 | self.x = x 70 | self.y = y 71 | 72 | def rotate_point(self, radians): 73 | """Rotate the point around the origin.""" 74 | self.x, self.y = rotate_around_origin((self.x, self.y), radians) 75 | --------------------------------------------------------------------------------