From b99dbb396c313f4b3130b566c0df42c10eec6084 Mon Sep 17 00:00:00 2001 From: davidovski Date: Thu, 5 Jan 2023 11:35:28 +0000 Subject: Initial Commit --- boss.py | 203 +++++++++++++++++++++++ boss_key.py | 127 +++++++++++++++ cheat_engine.py | 162 +++++++++++++++++++ config.py | 20 +++ enemy.py | 63 ++++++++ font.py | 342 +++++++++++++++++++++++++++++++++++++++ formation.py | 296 ++++++++++++++++++++++++++++++++++ formation_spawner.py | 341 +++++++++++++++++++++++++++++++++++++++ frame_counter.py | 41 +++++ game.py | 310 +++++++++++++++++++++++++++++++++++ hud.py | 152 ++++++++++++++++++ inputs.py | 133 ++++++++++++++++ leaderboard.py | 443 +++++++++++++++++++++++++++++++++++++++++++++++++++ main.py | 13 ++ menu.py | 282 ++++++++++++++++++++++++++++++++ shooter.py | 88 ++++++++++ shooter_game.py | 363 +++++++++++++++++++++++++++++++++++++++++ sprite.py | 127 +++++++++++++++ textures.py | 406 ++++++++++++++++++++++++++++++++++++++++++++++ 19 files changed, 3912 insertions(+) create mode 100644 boss.py create mode 100644 boss_key.py create mode 100644 cheat_engine.py create mode 100644 config.py create mode 100644 enemy.py create mode 100644 font.py create mode 100644 formation.py create mode 100644 formation_spawner.py create mode 100644 frame_counter.py create mode 100644 game.py create mode 100644 hud.py create mode 100644 inputs.py create mode 100644 leaderboard.py create mode 100755 main.py create mode 100644 menu.py create mode 100644 shooter.py create mode 100644 shooter_game.py create mode 100644 sprite.py create mode 100644 textures.py diff --git a/boss.py b/boss.py new file mode 100644 index 0000000..b977d68 --- /dev/null +++ b/boss.py @@ -0,0 +1,203 @@ +from dataclasses import replace +import math + +from enemy import EnemyAttributes +from formation import ( + CircleFormation, + CircleFormationAttributes, + EnemyFormation, + FormationAttributes, + FormationEnemy, +) +from game import Game + + +class CircleBossFormation(EnemyFormation): + """Enemy Formation for the circular game boss""" + + RADIUS = 10 + CYCLE_PEROID = 500 + COUNT = 6 + + def __init__(self, game: Game, attributes: EnemyAttributes): + """Initialise the circle boss + + :param game: The game which this boss belongs to + :type game: Game + :param attributes: The attributes on which to base spawned enemies + :type attributes: EnemyAttributes + """ + self.image_name = "enemy0" + self.minion_image_name = "smallenemy0" + + self.alpha = 0 + + attributes = CircleFormationAttributes( + radius=40, + period=300, + count=CircleBossFormation.COUNT, + velocity=attributes.velocity, + cooldown=attributes.cooldown, + hp=attributes.hp//2, + reward=attributes.reward*10 + ) + self.circle_formation = CircleFormation( + game, self.minion_image_name, attributes) + + super().__init__(game, self.image_name, attributes) + + def create_enemies(self): + """Spawn the boss""" + self.spawn_enemy((-4, -4, 0)) + + def tick(self, player): + """Update the boss's position + + :param player: The player which to check collision with + :type player: Player + """ + super().tick(player) + self.circle_formation.x = self.x + self.circle_formation.y = self.y + a = (self.alpha/CircleBossFormation.CYCLE_PEROID)*2*math.pi - math.pi + + r = 50*math.sin(a) - 25 + + p = math.sin(a*2)*100 + + self.circle_formation.attributes.radius = math.floor( + CircleBossFormation.RADIUS + (r if r > 0 else 0) + ) + + self.circle_formation.attributes.period = math.floor( + 400 + (p if p < 100 else 100) + ) + + self.circle_formation.tick(player) + + # When the boss is dead, the minions will all die + if len(self.sprites) == 0: + if len(self.circle_formation.sprites) > 0: + self.circle_formation.sprites[0].damage() + else: + self.destroy() + + def destroy(self): + """Remove the circle boss""" + super().destroy() + self.circle_formation.destroy() + + def hide(self): + """Hide the circle boss""" + self.circle_formation.hide() + return super().hide() + + def show(self): + """Show the circle boss""" + self.circle_formation.show() + return super().show() + + +class SnakeBossFormation(EnemyFormation): + """Enemy formation for the snake boss""" + + LENGTH = 32 + + def __init__(self, game: Game, attributes: FormationAttributes): + """Initialise the snake boss + + :param game: The game which the boss belongs to + :type game: Game + :param attributes: The attributes of which to base spawned enemies on + :type attributes: FormationAttributes + """ + self.minion_name = "smallenemy1" + self.tail_name = "smallenemy1_evil" + self.head_name = "enemy2" + + self.phase = 1 + self.phase_timer = 0 + + super().__init__(game, self.minion_name, attributes) + + def create_enemies(self): + """Spawn the snake""" + head_attributes = replace(self.attributes) + head_attributes.hp *= 100 + self.head = FormationEnemy(self.game, self.head_name, + (0, 0, 0), head_attributes) + + self.sprites.append(self.head) + + for i in range(SnakeBossFormation.LENGTH): + self.spawn_enemy((0, 0, i+1)) + + tail_attributes = replace(self.attributes) + head_attributes.hp //= 5 + self.tail = FormationEnemy(self.game, self.tail_name, + (0, 0, SnakeBossFormation.LENGTH+1), + tail_attributes) + + self.sprites.append(self.tail) + + def spawn_enemy(self, offset): + """Spawn one enemy unit of the snake + + :param offset: The offset of the enemy + """ + attributes = replace(self.attributes) + if offset[2] % 6 == 0: + attributes.cooldown = 40 + else: + attributes.cooldown = -1 + + enemy = FormationEnemy(self.game, self.image_name, offset, attributes) + self.sprites.append(enemy) + return enemy + + def position_enemy(self, enemy: FormationEnemy): + """Position the enemy on the game screen + + :param enemy: The enemy to position + :type enemy: FormationEnemy + """ + if self.phase == 2: + p = 120 / (100 + math.cos(self.phase_timer / 400)*20) * 120 + else: + p = 120 + + m = 4 + t = ((-enemy.offset_a*m) + self.game.alpha) / p + math.pi + a = self.game.w // 2 + b = self.game.h // 3 + c = 0 + + if self.phase == 2: + n = 10 - (2000 / (self.phase_timer+2000))*5 + else: + n = 5 + + enemy.set_pos(( + int(self.x + a*math.sin(n*t+c)), + int(self.y + b*math.sin(t)) + )) + + def tick(self, player): + """Update the position of the enemies + + :param player: The player which to check collision with + """ + super().tick(player) + + if self.phase == 1: + self.head.hp = self.attributes.hp*100 + if self.tail.destroyed: + if len(self.sprites) > 1: + self.sprites[-1].damage(amount=(self.attributes.hp//4)) + else: + self.head.hp = self.attributes.hp * 3 + self.phase = 2 + elif self.phase == 2: + self.phase_timer += 1 + self.head.attributes.cooldown = int( + 20 + math.sin(self.phase_timer / 50)*10) diff --git a/boss_key.py b/boss_key.py new file mode 100644 index 0000000..70a6e52 --- /dev/null +++ b/boss_key.py @@ -0,0 +1,127 @@ +from config import Config +from game import Game + + +class BossKey(): + """Object which manages the 'boss key' feature + When a key is pressed, then the screen switches to a "work" + related image + """ + + FG = "#ffaa00" + BG = "#aaaaaa" + BG2 = "#ffffff" + FG2 = "#555555" + TEXT_SIZE = 30 + + def __init__(self, game: Game, pause_callback) -> None: + """Initialises the boss key feature + + :param game: The game which to use + :type game: Game + :param pause_callback: The function to call to pause the game + :rtype: None + """ + self.game = game + self.canvas = game.canvas + self.width, self.height = game.w * Config.SCALE, game.h * Config.SCALE + self.shapes = [] + self.game.inputs.add_keypress_handler(self.on_key) + self.hidden = True + self.pause_callback = pause_callback + + def on_key(self, event): + """Handle key press events + + :param event: The key press event + """ + if event.keysym == self.game.inputs.settings.boss \ + and self.hidden: + self.pause_callback() + self.create_shapes() + self.hidden = False + return True + + if not self.hidden: + self.delete_shapes() + self.hidden = True + return True + + return False + + def create_rectangle(self, x, y, w, h, color): + """Create a rectangle object + + :param x: x coordinate + :param y: y coordinate + :param w: width + :param h: height + :param color: The colour of the rectangle + """ + self.shapes.append(self.canvas.create_rectangle( + x, y, x+w, y+h, fill=color, state="disabled")) + + def write_text(self, x, y, text): + """Create a text object + + :param x: x coordiante + :param y: y coordinate + :param text: The text used for this label + """ + self.shapes.append(self.canvas.create_text( + x, y, text=text, fill=BossKey.BG2, + font=(f"Helvetica {BossKey.TEXT_SIZE} bold"), state="disabled")) + + def create_shapes(self): + """Create all the shapes needed for the calculator""" + width = self.width + height = self.height + padding = width // 50 + + num_rows = 5 + num_cols = 4 + + grid_width = width // num_cols + grid_height = height // (num_rows+1) + + self.create_rectangle(0, 0, width, height, BossKey.BG) + self.create_rectangle(padding, + padding, + width - padding*2, + grid_height-padding*2, + BossKey.FG2) + + symbols = [ + "(", ")", "%", "AC", + "7", "8", "9", "/", + "4", "5", "6", "x", + "1", "2", "3", "-", + "0", ".", "=", "+" + ] + for row in range(num_rows): + for col in range(num_cols): + color = BossKey.FG2 + if row == 0 or col == num_cols - 1: + color = BossKey.FG + x = col*grid_width+padding + y = row*grid_height+padding+grid_height + w = grid_width-padding*2 + h = grid_height-padding*2 + self.create_rectangle(x, y, w, h, color) + + offset_x = x + padding + ( + grid_width + - padding*2 + - BossKey.TEXT_SIZE) // 2 + + offset_y = y + padding + ( + grid_height-padding * 2 + - BossKey.TEXT_SIZE) // 2 + + symbol = symbols[col + row*num_cols] + self.write_text(offset_x, offset_y, symbol) + + def delete_shapes(self): + """Remove all the shapes used for the calculator""" + for shape in self.shapes: + self.canvas.delete(shape) diff --git a/cheat_engine.py b/cheat_engine.py new file mode 100644 index 0000000..e385b74 --- /dev/null +++ b/cheat_engine.py @@ -0,0 +1,162 @@ +from typing import Callable, List + +from config import Config +from menu import Menu + + +class Cheat: + """A single cheat, to be assigned to a cheat engine""" + + def __init__(self, game, code: List[str], callback: Callable): + """Initialise the cheat + + :param game: The game which the cheat belongs to + :param code: A string of key codes to be pressed to activate the cheat + :type code: List[str] + :param callback: The function to be called when this cheat is activated + :type callback: Callable + """ + self.game = game + self.code = code + self.callback = callback + self.position = 0 + + def on_key(self, event): + """Handle a key press event + + :param event: The event that is being handled + """ + if self.position < len(self.code): + next_key = self.code[self.position] + if event.keysym == next_key: + self.position += 1 + if self.position == len(self.code): + self.callback() + self.position = 0 + else: + self.position = 0 + return False + + +class InvincibilityCheat(Cheat): + """Cheat that makes the player invincible""" + + def __init__(self, game, code: List[str]): + """Initialise the cheat + + :param game: The game which this belongs to + :param code: The combination of characters for which to + activate this cheat + :type code: List[str] + """ + super().__init__(game, code, self.toggle) + self.enabled = False + self.damage_function = None + + def toggle(self): + """Enable or disable this cheat""" + self.enabled = not self.enabled + if self.enabled: + self.game.effect_player.splash_text("Godmode on") + self.game.player.set_image(self.game.player.white_image) + self.damage_function = self.game.player.damage + self.game.player.damage = (lambda: None) + else: + self.game.effect_player.splash_text("Godmode off") + self.game.player.set_image( + self.game.texture_factory.get_image("ship")) + self.game.player.damage = self.damage_function + + +class DevModeCheat(Cheat): + """Cheat that enables 'dev mode' which: + - enables spawning menu + - key to remove all enemies + - key to stop spawning outright + """ + + def __init__(self, game, code: List[str]): + """Initialise the cheat + + :param game: The game which this belongs to + :param code: The combination of characters which activate this cheat + :type code: List[str] + """ + super().__init__(game, code, self.toggle) + self.enabled = Config.DEVMODE + + self.spawning_disabled = False + self.spawn_menu = Menu(self.game, "Spawn Menu") + for i in ("circle_boss", + "snake_boss", + "loop", + "orbital", + "rectangle", + "fleet"): + self.spawn_menu.add_item(i, self.spawn_item(i)) + + def toggle(self): + """Toggle if this mode is enabled""" + self.enabled = not self.enabled + if self.enabled: + self.game.effect_player.splash_text("devmode on") + else: + self.game.effect_player.splash_text("devmode off") + + def spawn_item(self, name): + """Spawn a named item from the menu + + :param name: The name of the formation to spawn + """ + return lambda: ( + self.spawn_menu.hide(), + getattr(self.game.formation_spawner, f"spawn_{name}")() + ) + + def on_key(self, event): + """Handle Key press events + + :param event: The key press event to handle + """ + if self.enabled: + if event.keysym == "n": + self.game.formation_spawner.clear_all() + self.game.formation_spawner.next_phase() + + if event.keysym == "c": + self.game.formation_spawner.clear_all() + + if event.keysym == "k": + if self.spawning_disabled: + self.game.effect_player.splash_text("spawning on") + self.spawning_disabled = False + self.game.formation_spawner.next_formation = 0 + else: + self.game.effect_player.splash_text("spawning off") + self.game.formation_spawner.clear_all() + self.game.formation_spawner.next_formation = -1 + + if event.keysym == "m": + self.spawn_menu.show() + + return super().on_key(event) + + +class CheatEngine: + """Object which manages cheats""" + + def __init__(self, game): + """Initialise the cheat engine + + :param game: The game which this belongs to + """ + self.game = game + self.cheats = [] + + def add_cheat(self, cheat): + """Register a cheat to the engine + + :param cheat: The cheat to be registered + """ + self.game.inputs.add_keypress_handler(cheat.on_key) + self.cheats.append(cheat) diff --git a/config.py b/config.py new file mode 100644 index 0000000..c864a31 --- /dev/null +++ b/config.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass +class Config: + """Various constants and configuration for the game""" + + WIDTH = 100 + HEIGHT = 200 + # Number of window pixels used for each game "pixel" + SCALE = 6 + + FPS = 30 + + NICK_LEN = 3 + DEVMODE = False + + LEADERBOARD_FILE = "leaderboard" + SAVE_FILE = "save" + SETTINGS_FILE = "settings" diff --git a/enemy.py b/enemy.py new file mode 100644 index 0000000..d19ad82 --- /dev/null +++ b/enemy.py @@ -0,0 +1,63 @@ +from dataclasses import dataclass + +from game import Game +from shooter import Shooter, ShooterAttributes + + +@dataclass +class EnemyAttributes(ShooterAttributes): + """Attributes of an enemy object""" + + reward: int = 100 + lazer_color: str = "red" + cooldown: int = 20 + + +class Enemy(Shooter): + """An enemy in the game""" + + def __init__(self, game: Game, image_name: str, + attributes: EnemyAttributes): + """Initialise the enemy + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use + :type image_name: str + :param attributes: The attributes of this + :type attributes: EnemyAttributes + """ + super().__init__(game, image_name, attributes) + self.attributes = attributes + + def tick(self, player): + """Check for collisions and shoot + + :param player: The player which to check collisions with + """ + super().tick() + if self.attributes.cooldown != -1: + self.shoot() + + lazer_collisions = self.collide_all(player.lazers) + if lazer_collisions != -1: + self.damage() + player.lazers[lazer_collisions].destroy() + + player_collisions = player.collide_all(self.lazers) + if player_collisions != -1: + player.damage() + self.lazers[player_collisions].destroy() + + if self.collides(player): + player.damage() + self.damage() + + def damage(self, amount=1): + """Reduce the object's health + + :param amount: + """ + super().damage(amount) + if self.destroyed: + self.game.score += self.attributes.reward diff --git a/font.py b/font.py new file mode 100644 index 0000000..9d11cd4 --- /dev/null +++ b/font.py @@ -0,0 +1,342 @@ +class Font: + """Convert a pixel font into photoimages""" + + FONT_SIZE = 5 + + # effective width of each character, including letter spacing + FONT_WIDTH = 6 + + CHARS = { + "\0": [ + " ", + " ", + " ", + " ", + " ", + ], + "0": [ + " xxx ", + "x xx", + "x x x", + "xx x", + " xxx ", + ], + "1": [ + " x ", + " xx ", + " x ", + " x ", + "xxxxx", + ], + "2": [ + "xxxx ", + " x", + " xxx ", + "x ", + "xxxxx", + ], + "3": [ + "xxxx ", + " x", + "xxxx ", + " x", + "xxxx ", + ], + "4": [ + "x x", + "x x", + "xxxxx", + " x", + " x", + ], + "5": [ + "xxxxx", + "x ", + " xxx ", + " x", + "xxxx ", + ], + "6": [ + " xxx ", + "x ", + "xxxx ", + "x x", + " xxx ", + ], + "7": [ + "xxxxx", + " x", + " x ", + " x ", + " x ", + ], + "8": [ + " xxx ", + "x x", + " xxx ", + "x x", + " xxx ", + ], + "9": [ + " xxx ", + "x x", + " xxxx", + " x", + " x", + ], + + "a": [ + " xxx ", + "x x", + "xxxxx", + "x x", + "x x", + ], + "b": [ + "xxxx ", + "x x", + "xxxx ", + "x x", + "xxxx ", + ], + "c": [ + " xxxx", + "x ", + "x ", + "x ", + " xxxx", + ], + "d": [ + "xxxx ", + "x x", + "x x", + "x x", + "xxxx ", + ], + "e": [ + "xxxxx", + "x ", + "xxxxx", + "x ", + "xxxxx", + ], + "f": [ + "xxxxx", + "x ", + "xxxxx", + "x ", + "x ", + ], + "g": [ + " xxxx", + "x ", + "x xx", + "x x", + " xxxx", + ], + "h": [ + "x x", + "x x", + "xxxxx", + "x x", + "x x", + ], + "i": [ + "xxxxx", + " x ", + " x ", + " x ", + "xxxxx", + ], + "j": [ + "xxxxx", + " x", + " x", + " x", + "xxxx ", + ], + "k": [ + "x x", + "x x ", + "xxx ", + "x x ", + "x x", + ], + "l": [ + "x ", + "x ", + "x ", + "x ", + "xxxxx", + ], + "m": [ + "x x", + "xx xx", + "x x x", + "x x", + "x x", + ], + "n": [ + "x x", + "xx x", + "x x x", + "x xx", + "x x", + ], + "o": [ + " xxx ", + "x x", + "x x", + "x x", + " xxx ", + ], + "p": [ + "xxxx ", + "x x", + "xxxx ", + "x ", + "x ", + ], + "q": [ + " xxx ", + "x x", + "x x", + "x x ", + " xx x", + ], + "r": [ + "xxxx ", + "x x", + "xxxx ", + "x x", + "x x", + ], + "s": [ + " xxxx", + "x ", + " xxx ", + " x", + "xxxx ", + ], + "t": [ + "xxxxx", + " x ", + " x ", + " x ", + " x ", + ], + "u": [ + "x x", + "x x", + "x x", + "x x", + " xxx ", + ], + "v": [ + "x x", + "x x", + "x x", + " x x ", + " x ", + ], + "w": [ + "x x", + "x x", + "x x x", + "x x x", + " x x ", + ], + "x": [ + "x x", + " x x ", + " x ", + " x x ", + "x x", + ], + "y": [ + "x x", + " x x ", + " x ", + " x ", + " x ", + ], + "z": [ + "xxxxx", + " x ", + " x ", + " x ", + "xxxxx", + ], + " ": [ + " ", + " ", + " ", + " ", + " ", + ], + ">": [ + " x ", + " x ", + " x ", + " x ", + " x ", + ], + "<": [ + " x ", + " x ", + " x ", + " x ", + " x ", + ], + } + + @staticmethod + def _create_font_texture(text, color="#fff", letter_space=1): + """Convert a font array into a game texture + + :param text: the characters used within the font + :param color: The colour to use + :param letter_space: The spacing between each letter to use + """ + string = Font._create_characters(text, letter_space) + return [ + [ + None if character == " " else color + for character in row + ] for row in string + ] + + @staticmethod + def _create_characters(text, letter_space): + """Concatenate font symbols + + :param text: The text of the font + :param letter_space: The spacing between each letter + """ + # create a list of all characters in the string + characters = [ + Font.CHARS[c] if c in Font.CHARS else Font.CHARS["\0"] + for c in text.lower() + ] + + # join each row of each character into one "character" + return [ + (" "*letter_space).join([c[row] for c in characters]) + for row in range(Font.FONT_SIZE) + ] + + @staticmethod + def load_text(texture_factory, text, color="#fff", letter_space=1): + """Create and load text into a photo image + + :param texture_factory: The texture factory used for processing + :param text: The text to convert + :param color: Color of the text + :param letter_space: Spacing between letters + """ + return texture_factory.load_texture(f"text:{text}", + Font._create_font_texture( + text, + color=color, + letter_space=letter_space) + ) diff --git a/formation.py b/formation.py new file mode 100644 index 0000000..ae13b75 --- /dev/null +++ b/formation.py @@ -0,0 +1,296 @@ +from dataclasses import dataclass +import math +from typing import List + +from enemy import Enemy, EnemyAttributes +from game import Game +from sprite import Sprite + + +@dataclass +class FormationAttributes(EnemyAttributes): + """FormationAttributes.""" + + count: int = 1 + + +class FormationEnemy(Enemy): + """An enemy that belongs to a formation""" + + def __init__(self, game: Game, image_name, offset, + attributes: EnemyAttributes): + """Initialise the enemy + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :param offset: The offset from the other enemies + :param attributes: The attributes given to this enemy + :type attributes: EnemyAttributes + """ + self.offset_x, self.offset_y, self.offset_a = offset + super().__init__(game, image_name, attributes) + + +class EnemyFormation: + """Cluster of enemies that move in a particular way""" + + def __init__(self, game: Game, image_name: str, + enemy_attributes: FormationAttributes): + """Initialise the formation + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :type image_name: str + :param enemy_attributes: The attributes to use for spawned enemies + :type enemy_attributes: FormationAttributes + """ + self.game = game + self.sprites: List[FormationEnemy] = [] + self.image_name = image_name + + self.alpha = 0 + self.attributes = enemy_attributes + + self.x, self.y = 0, 0 + self.destroyed = False + + self.create_enemies() + self.hidden = True + + def create_enemies(self): + """Spawn enemies""" + pass + + def position_enemy(self, enemy: FormationEnemy): + """Position a single enemy + + :param enemy: The enemy to position + :type enemy: FormationEnemy + """ + enemy.set_pos( + ( + int(self.x + enemy.offset_x), + int(self.y + enemy.offset_y) + ) + ) + + def spawn_enemy(self, offset): + """Spawn a single enemy + + :param offset: The offset which to apply to the enemy + """ + enemy = FormationEnemy(self.game, self.image_name, + offset, self.attributes) + self.sprites.append(enemy) + return enemy + + def tick(self, player): + """Update the positions of all enemies + + :param player: The player to check if the enemies collide with + """ + self.alpha += 1 + for enemy in self.sprites: + enemy.tick(player) + self.position_enemy(enemy) + self.sprites = Sprite.remove_destroyed(self.sprites) + if len(self.sprites) == 0: + self.destroy() + + def destroy(self): + """Delete all enemies in this formation""" + for enemy in self.sprites: + enemy.destroy() + self.sprites = [] + self.destroyed = True + + def set_pos(self, pos): + """Set the position of this formation + + :param pos: position to move to + """ + self.x, self.y = pos + + def show(self): + """Make this formation visible""" + if self.hidden: + for enemy in self.sprites: + enemy.show() + self.hidden = False + + def hide(self): + """Make this formation hidden""" + if not self.hidden: + for enemy in self.sprites: + enemy.hide() + self.hidden = True + + +@dataclass +class CircleFormationAttributes(FormationAttributes): + """Attributes for a circle formation""" + + radius: int = 40 + period: int = 300 + + +class CircleFormation(EnemyFormation): + """A circular formation of enemies, rotating in a ring""" + + def __init__(self, game: Game, image_name, + attributes: CircleFormationAttributes): + """Initialise the formation + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :param attributes: The attributes to use for spawned enemies + :type attributes: CircleFormationAttributes + """ + super().__init__(game, image_name, attributes) + self.attributes: CircleFormationAttributes + + def create_enemies(self): + """Spawn all the enemies""" + for i in range(self.attributes.count): + self.spawn_enemy((0, 0, i)) + + def position_enemy(self, enemy: FormationEnemy): + """Position a single enemy + + :param enemy: + :type enemy: FormationEnemy + """ + a = (enemy.offset_a / self.attributes.count) * \ + self.attributes.period + self.game.alpha + enemy.set_pos( + ( + int( + self.x+math.sin((-a/self.attributes.period) + * 2*math.pi) * self.attributes.radius + ), + int( + self.y+math.cos((-a/self.attributes.period) + * 2*math.pi) * self.attributes.radius + ) + ) + ) + + +class LemniscateFormation(EnemyFormation): + """An 'infinity' shape enemy formation""" + + def __init__(self, game: Game, image_name, + attributes: CircleFormationAttributes): + """Initialise the formation + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :param attributes: The attributes to use for spawned enemies + :type attributes: CircleFormationAttributes + """ + super().__init__(game, image_name, attributes) + self.attributes: CircleFormationAttributes + + def create_enemies(self): + """Spawn all enemies""" + for i in range(self.attributes.count): + self.spawn_enemy((0, 0, (i / self.attributes.count) + * self.attributes.period * 0.25)) + + def position_enemy(self, enemy: FormationEnemy): + """Position an enemy + + :param enemy: + :type enemy: FormationEnemy + """ + a = enemy.offset_a + self.game.alpha + + t = (-a/self.attributes.period)*2*math.pi + x = self.x+(self.attributes.radius * math.cos(t)) / \ + (1 + math.sin(t)**2) + y = self.y+(self.attributes.radius * math.sin(t) * math.cos(t)) / \ + (1 + math.sin(t)**2) + + enemy.set_pos( + ( + int(x), + int(y) + ) + ) + + +@dataclass +class TriangleFormationAttributes(FormationAttributes): + """Attributes for a triangular formation""" + + spacing: int = 16 + + +class TriangleFormation(EnemyFormation): + """A v-shaped formation of enemies""" + + def __init__(self, game: Game, image_name: str, + attributes: TriangleFormationAttributes): + """Initialise the formation + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :type image_name: str + :param attributes: The attributes to use for spawned enemies + :type attributes: TriangleFormationAttributes + """ + super().__init__(game, image_name, attributes) + self.attributes: TriangleFormationAttributes + + def create_enemies(self): + """Spawn all enemies in this formation""" + for i in range(self.attributes.count): + y = -((i+1) // 2)*self.attributes.spacing//2 + + # first part is multiply by 1 or -1 to determine the side + # then just have an offset for how far + x = 2*((i % 2)-0.5) * ((i+1)//2)*self.attributes.spacing + + self.spawn_enemy((x, y, 1)) + + +@dataclass +class RectangleFormationAttributes(TriangleFormationAttributes): + """Attributes for a rectangle formation""" + + width: int = 5 + height: int = 2 + + +class RectangleFormation(EnemyFormation): + """A grid-like formation of enemies""" + + def __init__(self, game: Game, image_name, + attributes: RectangleFormationAttributes): + """Initialise the formation + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for the enemy + :param attributes: The attributes to use for spawned enemies + :type attributes: RectangleFormationAttributes + """ + super().__init__(game, image_name, attributes) + self.attributes: RectangleFormationAttributes + + def create_enemies(self): + """Spawn all enemies""" + full_width = self.attributes.width * self.attributes.spacing + full_height = self.attributes.height * self.attributes.spacing + + for y in range(self.attributes.height): + offset_y = ((y+0.5)*self.attributes.spacing)-(full_height/2) + + for x in range(self.attributes.width): + offset_x = ((x+0.5)*self.attributes.spacing)-(full_width/2) + self.spawn_enemy((offset_x, offset_y, 1)) diff --git a/formation_spawner.py b/formation_spawner.py new file mode 100644 index 0000000..1f3f8d1 --- /dev/null +++ b/formation_spawner.py @@ -0,0 +1,341 @@ +import math +from random import choice, randint, random + +from boss import CircleBossFormation, SnakeBossFormation +from enemy import EnemyAttributes +from formation import ( + CircleFormation, + CircleFormationAttributes, + EnemyFormation, + FormationAttributes, + LemniscateFormation, + RectangleFormation, + RectangleFormationAttributes, + TriangleFormation, + TriangleFormationAttributes, +) + + +def wobble_pattern(formation): + """A sinusoidal movement pattern + + :param formation: Formation to move + """ + x = (1+math.sin(formation.alpha/80)) * formation.game.w/2 + y = formation.y + (1 if formation.alpha % 4 == 0 else 0) + formation.set_pos((x, y)) + + +def speed_pattern(formation): + """Quickly move the formation downwards + + :param formation: Formation to move + """ + x = formation.x + y = formation.y + 2 + formation.set_pos((x, y)) + + +def slow_pattern(formation): + """Slowly move the formation downwards + + :param formation: Formation to move + """ + x = formation.x + y = formation.y + (1 if formation.alpha % 8 == 0 else 0) + formation.set_pos((x, y)) + + +def slide_in_pattern(formation): + """Slowly move into the center of the screen and then remain there + + :param formation: Formation to move + """ + cy = formation.game.h//3 + + if formation.alpha < 400: + x = formation.x + y = (formation.alpha/400) * (cy*1.5) - (cy*0.5) + else: + x = formation.x + y = formation.y + + formation.set_pos((int(x), int(y))) + + +def no_pattern(formation): + """No movement, stay in the center + + :param formation: Formation to move + """ + formation.set_pos(( + formation.game.w // 2, + formation.game.h // 3 + )) + + +def figure_of_eight_pattern(formation): + """Move the formation in a figure of eight + + :param formation: Formation to move + """ + period = 600 + edge = 8 + radius = formation.game.h//3 - edge + cx, cy = formation.game.w//2, formation.game.h//3 + + if formation.alpha < 200: + x = formation.x + y = (formation.alpha/200) * (cy*1.5) - (cy*0.5) + else: + a = formation.alpha - 200 + t = (a/period)*2*math.pi - math.pi/2 + + y = cy + (radius * math.cos(t)) / (1 + math.sin(t)**2) + x = cx + (radius * math.sin(t) * math.cos(t)) / (1 + math.sin(t)**2) + + formation.set_pos((int(x), int(y))) + + +class FormationSpawner(): + """Object to manage spawning of enemies and phases""" + + def __init__(self, game): + """Initialise the formation spawner + + :param game: The game which this belongs to + """ + self.game = game + self.formations = [] + self.difficulty_multiplier = 0.5 + + self.next_formation = 0 + + self.phase = -1 + self.phases = [ + Phase("Phase:1", [ + self.spawn_fleet, + self.spawn_loop, + self.spawn_orbital], 10), + Phase("Boss:1", [self.spawn_circle_boss], 1), + Phase("Phase:2", [ + self.spawn_fleet, + self.spawn_loop, + self.spawn_orbital], 10, max_wave=3), + Phase("Boss:2", [self.spawn_snake_boss], 1), + ] + + self.to_spawn = 0 + self.current_reward = 1 + + def tick(self): + """Update all formations""" + for formation, update in self.formations: + formation.tick(self.game.player) + update(formation) + if formation.y > self.game.h: + formation.destroy() + self.formations = list( + filter(lambda s: not s[0].destroyed, self.formations)) + + self.spawn_next() + + def spawn_random(self): + """Spawn a random formation""" + options = [ + self.spawn_fleet, + self.spawn_loop, + self.spawn_orbital, + self.spawn_rectangle + ] + choice(options)() + + def spawn_formation(self, formation: EnemyFormation, update): + """Add a formation to the list of formations + + :param formation: Formation to add + :type formation: EnemyFormation + :param update: movement function to use for this formation + """ + update(formation) + formation.show() + self.formations.append((formation, update)) + + def spawn_circle_boss(self): + """Spawn the circle boss""" + attributes = EnemyAttributes( + hp=int(15*self.difficulty_multiplier), + reward=self.current_reward, + cooldown=50 + ) + formation = CircleBossFormation(self.game, attributes) + formation.set_pos((self.game.w//2, 0)) + update = figure_of_eight_pattern + self.spawn_formation(formation, update) + + def spawn_snake_boss(self): + """Spawn the snake boss""" + attributes = FormationAttributes( + hp=int(10*self.difficulty_multiplier), + reward=self.current_reward, + cooldown=160 + ) + + formation = SnakeBossFormation(self.game, attributes) + formation.set_pos((self.game.w//2, 0)) + update = slide_in_pattern + self.spawn_formation(formation, update) + + def spawn_fleet(self): + """Spawn the fleet formation""" + sprite = randint(6, 7) + + position = (random()*self.game.w, -32) + attributes = TriangleFormationAttributes( + hp=int(self.difficulty_multiplier), + cooldown=-1, + reward=self.current_reward, + count=randint(1, 3)*2 + 1, + spacing=8 + ) + formation = TriangleFormation( + self.game, f"smallenemy{sprite}", attributes) + formation.set_pos(position) + + update = speed_pattern + self.spawn_formation(formation, update) + + def spawn_orbital(self): + """Spawn the orbital formation""" + position = (random()*self.game.w, -32) + sprite = choice((1, 3)) + + attributes = CircleFormationAttributes( + hp=int(self.difficulty_multiplier * 2), + count=randint(3, 4)*2, + radius=randint(10, 20), + period=randint(100//int(self.difficulty_multiplier), 400), + cooldown=80, + reward=self.current_reward + + ) + + formation = CircleFormation( + self.game, f"smallenemy{sprite}", attributes) + formation.set_pos(position) + + update = wobble_pattern + formation.alpha = randint(1, 1000) + self.spawn_formation(formation, update) + + def spawn_rectangle(self): + """Spawn the rectangle formation""" + sprite = choice((0, 2)) + position = (random() * self.game.w, -32) + + attributes = RectangleFormationAttributes( + hp=int(self.difficulty_multiplier * 2), + width=randint(4, 6), + height=randint(2, 3), + cooldown=80, + reward=self.current_reward, + ) + + formation = RectangleFormation( + self.game, f"smallenemy{sprite}", attributes + ) + formation.set_pos(position) + + update = wobble_pattern + formation.alpha = randint(1, 1000) + self.spawn_formation(formation, update) + + def spawn_loop(self): + """Spawn the loop formation""" + sprite = choice((4, 5)) + position = (random()*self.game.w, -32) + attributes = CircleFormationAttributes( + count=randint(4, 8), + radius=randint(self.game.w//2, self.game.w), + period=randint(200, 300), + hp=int(self.difficulty_multiplier), + reward=self.current_reward, + cooldown=160, + ) + + formation = LemniscateFormation( + self.game, f"smallenemy{sprite}", attributes) + formation.set_pos(position) + + update = slow_pattern + self.spawn_formation(formation, update) + + def spawn_next(self): + """Spawn the next formation to be spawned""" + if self.to_spawn > 0: + if len(self.formations) < self.current_phase().max_wave: + if self.game.alpha > self.next_formation \ + and self.next_formation != -1: + self.next_formation = self.game.alpha \ + + 100 / self.difficulty_multiplier + + self.current_phase().get_spawn_function()() + self.to_spawn -= 1 + else: + if len(self.formations) == 0: + self.next_phase() + + def next_phase(self): + """Increment the phase by 1 and start the next phase""" + self.phase += 1 + self.game.save_game() + self.start_phase() + + def start_phase(self): + """Start the next phase""" + self.to_spawn = self.current_phase().duration + + self.difficulty_multiplier = (self.phase+2) * 0.5 + self.current_reward = int(2**self.difficulty_multiplier) + + self.next_formation = self.game.alpha + 100 + if self.current_phase().name: + self.game.effect_player.splash_text(self.current_phase().name) + + def current_phase(self): + """Return the current phase""" + if self.phase < len(self.phases): + return self.phases[self.phase] + + return Phase(f"Phase:{self.phase-1}", [ + self.spawn_random + ], 10 * self.difficulty_multiplier, + max_wave=int(self.difficulty_multiplier) + ) + + def clear_all(self): + """Remove all formation objects""" + for f, _ in self.formations: + f.destroy() + + +class Phase: + """Rules for which formation will be spawned""" + + def __init__(self, name, spawn_functions, duration, max_wave=2): + """__init__. + + :param name: The name of the phase + :param spawn_functions: A list of functions to use to spawn enemies + :param duration: The number of formations to spawn + before the phase is over + :param max_wave: The maximum number of formations to spawn at a time + """ + self.spawn_functions = spawn_functions + self.duration = duration + self.name = name + self.max_wave = max_wave + + def get_spawn_function(self): + """Return a random spawn function""" + return choice(self.spawn_functions) diff --git a/frame_counter.py b/frame_counter.py new file mode 100644 index 0000000..a71f915 --- /dev/null +++ b/frame_counter.py @@ -0,0 +1,41 @@ +from sys import stderr +from time import time + + +class FrameCounter: + """Creates a main loop and ensures that the framerate is static""" + + def __init__(self, canvas, target_fps): + """Initialise the frame counter + + :param canvas: The canvas which to call after on + :param target_fps: The fps to aim to achieve + """ + self.canvas = canvas + self.fps = target_fps + self.frame_time = 1 / target_fps + self.last_frame = time() + + self.current_fps = 1 + + def next_frame(self, callback): + """Calculate when the next frame should be called + + :param callback: function to call for the next frame + """ + t = time() + ft = t - self.last_frame + + delay = 0 + + if ft > self.frame_time: + if ft - self.frame_time > self.frame_time / 5: + print( + f"Help! Running {ft - self.frame_time} seconds behind!", + file=stderr) + else: + delay = self.frame_time - ft + + self.canvas.after(int(delay*1000), callback) + self.current_fps = 1 / (delay+ft) + self.last_frame = t diff --git a/game.py b/game.py new file mode 100644 index 0000000..2724503 --- /dev/null +++ b/game.py @@ -0,0 +1,310 @@ +from random import randint, random +from tkinter import Canvas, PhotoImage, Tk +from typing import List + +from config import Config +from font import Font +from frame_counter import FrameCounter +from inputs import InputController +from sprite import Sprite +from textures import TextureFactory + + +class Game: + """A generic game object""" + + def __init__(self) -> None: + """Initialise the game + """ + self.win = Tk() + game_width, game_height = ( + Config.WIDTH*Config.SCALE, Config.HEIGHT*Config.SCALE + ) + self.w, self.h = Config.WIDTH, Config.HEIGHT + + self.win.geometry(f"{game_width}x{game_height}") + self.canvas = Canvas(self.win, width=game_width, + height=game_height, bg="#000") + self.canvas.pack() + + self.texture_factory = TextureFactory(scale=Config.SCALE) + self.effect_player = EffectPlayer(self) + self.frame_counter = FrameCounter(self.canvas, Config.FPS) + + self.inputs = InputController(self) + self.sprites = [] + + self.score = 0 + + self.alpha = 0 + + def start(self): + """Start the game""" + self.loop() + self.win.mainloop() + + def tick(self): + """Update the game's sprites""" + for sprite in self.sprites: + sprite.tick() + self.effect_player.tick() + + def loop(self): + """Loop the game at a set framerate""" + self.alpha += 1 + self.tick() + self.frame_counter.next_frame(self.loop) + + def clear_all(self): + """Remove all game sprites""" + for sprite in self.sprites: + sprite.destroy() + self.sprites = [] + + +class GameSprite(Sprite): + """A sprite which belongs to a game""" + + def __init__(self, game: Game, image: PhotoImage): + """Initialise the sprite + + :param game: The game which this belongs to + :type game: Game + :param image: The image to use for the sprite + :type image: PhotoImage + """ + self.game = game + super().__init__(game.canvas, image, (0, 0)) + + def move(self, x, y): + """Move the sprite by a certain amount + + :param x: Amount of pixels to move right + :param y: Amount of pixels to move down + """ + # if the object needs to move less than a pixel + # only move it every few frames to create this effect + if abs(x) >= 1: + self.x += x + elif x != 0: + if self.game.alpha % (1/x) == 0: + self.x += 1 + + if abs(y) >= 1: + self.y += y + elif y != 0: + if self.game.alpha % (1/y) == 0: + self.y += 1 + + self.update_position() + + +class GameEffect(GameSprite): + """An effect that can be played within game""" + + def __init__(self, game: Game, image: PhotoImage, + duration=10, momentum=(0, 0)): + """Initialise the game effect + + :param game: The game which this belongs to + :type game: Game + :param image: The image to use for this + :type image: PhotoImage + :param duration: How long this effect should last for + :param momentum: Which direction to move this effect + """ + self.start_time = game.alpha + self.duration = duration + self.velocity_x, self.velocity_y = momentum + super().__init__(game, image) + + def tick(self): + """Move the effect by its momentum and remove it if its over""" + super().tick() + self.move(self.velocity_x, self.velocity_y) + + alpha = self.game.alpha - self.start_time + if self.duration != -1 and alpha > self.duration: + self.destroy() + + +class AnimatedEffect(GameEffect): + """An effect which involves animating an image""" + + def __init__(self, game: Game, images: List[PhotoImage], + frame_time=1, momentum=(0, 0)): + """Initialise the effect + + :param game: The game which this belongs to + :type game: Game + :param images: The images to use for this animation + :type images: List[PhotoImage] + :param frame_time: Length of each frame of the animation + :param momentum: Direction to move this effect + """ + self.start_time = game.alpha + self.frame_time = frame_time + self.images = images + self.frame_start = game.alpha + super().__init__(game, images[0], duration=len( + images)*frame_time, momentum=momentum) + + def tick(self): + """Animate the effect""" + super().tick() + + alpha = self.game.alpha - self.start_time + + i = int(alpha // self.frame_time) + if i < len(self.images): + self.set_image(self.images[i]) + else: + self.destroy() + + +class EffectPlayer: + """An object which concerns itself with managing the effects""" + + def __init__(self, game: Game) -> None: + """Initialise the + + :param game: The game which this belongs to + :type game: Game + """ + self.sprites = [] + self.game = game + self.explosion_frames = [] + self.star_image: PhotoImage + + def load_textures(self): + """Load effect textures""" + + self.explosion_frames = [ + self.game.texture_factory.get_image(f"explosion{i+1}") + for i in range(3) + ] + self.star_image = self.game.texture_factory.get_image("star") + + def tick(self): + """Update all effects""" + for sprite in self.sprites: + sprite.tick() + + self.sprites = Sprite.remove_destroyed(self.sprites) + + def create_stars(self): + """Initialise the stars in the background""" + for _ in range(100): + self.create_star(True) + + def create_star(self, new=False): + """Add a star to the background + + :param new: Whether this star should be added at + the top of the screen or anywhere + """ + x = randint(0, self.game.w) + if new: + y = randint(0, self.game.h) + else: + y = -1 + + speed = randint(1, 4) * 0.1 + duration = 2*self.game.h / speed + + star = GameEffect( + self.game, + self.star_image, + duration=int(duration), + momentum=(0, speed) + ) + star.set_pos((x, y)) + + star.send_to_back() + star.show() + + self.sprites.append(star) + + def create_explosion(self, position=(0, 0)): + """Create an explosion effect + + :param position: location of the explosion + """ + for _ in range(randint(1, 3)): + m = ((random()*2)-1, (random()*2)-1) + explosion_sprite = AnimatedEffect( + self.game, self.explosion_frames, frame_time=5, momentum=m) + explosion_sprite.set_pos(position) + explosion_sprite.show() + + self.sprites.append(explosion_sprite) + + def splash_text(self, text, duration=50): + """splash_text. + + :param text: + :param duration: + """ + text_img = Font.load_text(self.game.texture_factory, text) + position = ( + (self.game.w-Font.FONT_WIDTH*len(text)) // 2, + (self.game.h-Font.FONT_SIZE) // 3 + ) + + text_sprite = GameEffect( + self.game, text_img, duration=duration) + text_sprite.set_pos(position) + text_sprite.show() + self.sprites.append(text_sprite) + + +class DamageableSprite(GameSprite): + """Sprite with health points """ + + def __init__(self, game: Game, image_name: str, hp=3): + """Initialise the sprite + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for this sprite + :type image_name: str + :param hp: The number of hit points this sprite spawns with + """ + self.image = game.texture_factory.get_image(image_name) + self.white_image = game.texture_factory.get_image( + f"{image_name}:white") + + self.hp = hp + self.animation_frame = 0 + + super().__init__(game, self.image) + + def damage(self, amount=1): + """Decrease number of hit points by an amount + + :param amount: + """ + if not self.destroyed: + self.hp -= amount + self.animation_frame = 5 + if self.hp <= 0: + self.hp = 0 + self.destroy() + self.game.effect_player.create_explosion(self.get_pos()) + + def tick(self): + """Update the sprite""" + super().tick() + if self.animation_frame > 0: + self.animation_frame -= 1 + if self.animation_frame % 2 == 0: + self.set_image(self.image) + else: + self.set_image(self.white_image) + + +if __name__ == "__main__": + print("!!!") + print("This is not the main file!") + print("Pleae run\n\tpython main.py\ninstead!") + print("!!!") diff --git a/hud.py b/hud.py new file mode 100644 index 0000000..179153c --- /dev/null +++ b/hud.py @@ -0,0 +1,152 @@ +from game import Game, GameSprite +from font import Font + + +class ScoreCounterSprite(GameSprite): + """Single digit for a score counter""" + + def __init__(self, game: Game): + """Initialise the score counter + + :param game: The game which this belongs to + :type game: Game + """ + self.number_images = [] + + self.x = 0 + + for i in range(10): + self.number_images.append( + Font.load_text(game.texture_factory, str(i))) + + super().__init__(game, self.number_images[0]) + + def update_image(self): + """Update the digit""" + self.set_image(self.number_images[int(self.x % 10)]) + + def set(self, x): + """Set the image + + :param x: number to set this digit to + """ + self.x = x + self.update_image() + + +class ScoreCounter: + """Sprite to display a number""" + + def __init__(self, game: Game, num_digits, position=(0, 0)) -> None: + """__init__. + + :param game: + :type game: Game + :param num_digits: + :param position: + :rtype: None + """ + self.digits = [] + x, y = position + + self.number = 0 + + for i in range(num_digits): + sprite = ScoreCounterSprite(game) + sprite.set_pos((x+(Font.FONT_SIZE + 1)*i, y)) + self.digits.append(sprite) + + def set(self, number): + """Set the score to be displayed + + :param number: + """ + if number != self.number: + self.number = number + power = 10**len(self.digits) + for digit in self.digits: + power /= 10 + digit.set((number // power) % 10) + + def destroy(self): + """Remove this counter""" + for n in self.digits: + n.destroy() + + def send_to_front(self): + """Move this counter to the foreground""" + for d in self.digits: + d.send_to_front() + + def show(self): + """Make this counter visible""" + for d in self.digits: + d.show() + + def hide(self): + """Make this counter invisible""" + for d in self.digits: + d.hide() + + +class GameHud: + """Object to manage the items visible in the game's heads up display""" + + SCORE_DIGITS = 8 + HP_DIGITS = 2 + + def __init__(self, game) -> None: + """Initialise the HUD + + :param game: The game which this belongs to + """ + + self.game = game + self.score_counter = ScoreCounter(game, GameHud.SCORE_DIGITS, + position=( + game.w + - GameHud.SCORE_DIGITS + * (Font.FONT_SIZE+1), + 1) + ) + + self.hp_symbol = GameSprite(game, game.player.image) + self.hp_symbol.set_pos((1, 1)) + + x_image = Font.load_text(game.texture_factory, "x") + self.x_symbol = GameSprite(game, x_image) + self.x_symbol.set_pos((self.hp_symbol.x+self.hp_symbol.w+1, 1)) + + self.hp_counter = ScoreCounter(game, GameHud.HP_DIGITS, + position=(self.x_symbol.x+1 + + self.x_symbol.w, 1) + ) + + self.items = (self.score_counter, + self.hp_symbol, + self.x_symbol, + self.hp_counter) + + def tick(self): + """Update the hud""" + self.score_counter.set(self.game.score) + self.hp_counter.set( + self.game.player.hp if self.game.player.hp > 0 else 0) + + for x in self.items: + x.send_to_front() + + def destroy(self): + """Remove all the associated objects""" + for x in self.items: + x.destroy() + + def hide(self): + """Make this object invisible""" + for x in self.items: + x.hide() + + def show(self): + """Make this object visible""" + for x in self.items: + x.show() diff --git a/inputs.py b/inputs.py new file mode 100644 index 0000000..c42cf89 --- /dev/null +++ b/inputs.py @@ -0,0 +1,133 @@ +from dataclasses import dataclass +from os import path +from sys import stderr + +from config import Config + + +@dataclass +class InputSettings: + """Settings for keybinds""" + + down: str = "Down" + left: str = "Left" + up: str = "Up" + right: str = "Right" + action: str = "space" + pause: str = "Escape" + boss: str = "F9" + + def save_inputs(self): + """Save keybinds to a file""" + with open(Config.SETTINGS_FILE, "w", encoding="utf-8") as file: + for key, value in vars(self).items(): + file.write(f"{key}: {value}\n") + + def load_inputs(self): + """Load keybinds from a file""" + if path.exists(Config.SETTINGS_FILE): + with open(Config.SETTINGS_FILE, "r", encoding="utf-8") as file: + for line in file.readlines(): + split = line.strip().split(": ") + if len(split) == 2: + setattr(self, split[0], split[1]) + else: + print( + f"Settings file corrupted? Invalid line {line}", + file=stderr + ) + + +class InputController: + """Object which listens to key inputs""" + + def __init__(self, game) -> None: + """Initialise the input controller + + :param game: The game which this belongs to + :rtype: None + """ + game.win.bind('', self.on_key_press) + game.win.bind('', self.on_key_release) + + self.handlers = [] + + self.settings = InputSettings() + self.settings.load_inputs() + + self.k_down = False + self.k_left = False + self.k_up = False + self.k_right = False + self.k_action = False + self.k_pause = False + self.k_boss = False + + def on_key_press(self, e): + """Handle Key press events + + :param e: The key press event to handle + """ + if e.keysym == self.settings.left: + self.k_left = True + + if e.keysym == self.settings.right: + self.k_right = True + + if e.keysym == self.settings.up: + self.k_up = True + + if e.keysym == self.settings.down: + self.k_down = True + + if e.keysym == self.settings.action: + self.k_action = True + + if e.keysym == self.settings.pause: + self.k_pause = True + + for t, h in self.handlers: + if t == "press" and h(e): + break + + def on_key_release(self, e): + """Handle Key release events + + + :param e: The key press event to handle + """ + if e.keysym == self.settings.left: + self.k_left = False + + if e.keysym == self.settings.right: + self.k_right = False + + if e.keysym == self.settings.up: + self.k_up = False + + if e.keysym == self.settings.down: + self.k_down = False + + if e.keysym == self.settings.action: + self.k_action = False + + if e.keysym == self.settings.pause: + self.k_pause = False + + for t, h in self.handlers: + if t == "release" and h(e): + break + + def add_keypress_handler(self, callback): + """Register a key press listener + + :param callback: + """ + self.handlers.insert(0, ("press", callback)) + + def add_keyrelease_handler(self, callback): + """Register a key release listener + + :param callback: + """ + self.handlers.insert(0, ("release", callback)) diff --git a/leaderboard.py b/leaderboard.py new file mode 100644 index 0000000..7abcf91 --- /dev/null +++ b/leaderboard.py @@ -0,0 +1,443 @@ +from os import path +from random import randint +from typing import List + +from config import Config +from font import Font +from game import Game, GameSprite + + +class LeaderboardFile: + """Object to manage saving and loading the leaderboard""" + + def __init__(self): + """Initialise the leaderboard file""" + self.entries = [] + + def load_entries(self): + """Load leaderboard entries from a file""" + self.entries = [] + if path.exists(Config.LEADERBOARD_FILE): + with open(Config.LEADERBOARD_FILE, "rb") as file: + while (entry := file.read(8 + Config.NICK_LEN)): + name = entry[0:Config.NICK_LEN] + score = entry[Config.NICK_LEN:11] + score_entry = (name.decode("ascii"), + int.from_bytes(score, byteorder="little")) + self.entries.append(score_entry) + self.sort_entries() + + def sort_entries(self): + """Sort leaderboard entries""" + self.entries.sort(key=(lambda e: e[1])) + self.entries.reverse() + + def save_entries(self): + """Save leaderboard entries""" + with open(Config.LEADERBOARD_FILE, "wb") as file: + for name, score in self.entries: + file.write(bytes(name[0:Config.NICK_LEN], "ascii")) + file.write(int(score).to_bytes(8, "little")) + + def add_entry(self, name, score): + """Add a leaderboard entry + + :param name: Initials of the player + :param score: The sore that was achieved + """ + self.entries.append((name, score)) + self.sort_entries() + + +class NameEntryLetter(GameSprite): + """A single sprite used in a initial entry""" + + def __init__(self, game: Game, image, letter): + """Initialise the letter + + :param game: The game which this belongs to + :type game: Game + :param image: The image to use for this + :param letter: the letter to use + """ + self.letter = letter + super().__init__(game, image) + + +class NameEntry(): + """An initial entry element, allowing the user to enter their initials""" + + def __init__(self, game: Game, callback, num_letters=3, position=(0, 0)): + """Initialise the name entry + + :param game: The game which this belongs to + :type game: Game + :param callback: callback to call when the entry is complete + :param num_letters: Number of letters to use for the initials + :param position: Position of this element + """ + self.game = game + self.callback = callback + + self.alphabet = [ + Font.load_text(game.texture_factory, c) + for c in list(map(chr, range(97, 123))) + ] + + self.letters: List[NameEntryLetter] = [] + self.selection = 0 + + self.hidden = True + + self.populate_letters(num_letters) + self.game.inputs.add_keypress_handler(self.on_key) + self.set_pos(position) + + def populate_letters(self, num_letters): + """Create sprites for each of the letters + + :param num_letters: Number of letters to use for initials + """ + for _ in range(num_letters): + sprite = NameEntryLetter( + self.game, self.alphabet[0], 0 + ) + self.letters.append(sprite) + + enter_image = Font.load_text(self.game.texture_factory, "enter") + self.button = GameSprite(self.game, enter_image) + self.w = self.button.w + (self.letters[0].w+1)*len(self.letters) + self.h = Font.FONT_SIZE + + def on_key(self, _): + """Handle Key press events + + + :param _: The key press event to handle + """ + inp = self.game.inputs + if not self.hidden: + if inp.k_action and self.selection == len(self.letters): + self.callback(self.get_string()) + return True + + if inp.k_left: + self.selection -= 1 + self.get_selected_letter().show() + + if inp.k_right or inp.k_action: + self.get_selected_letter().show() + self.selection += 1 + self.get_selected_letter().hide() + + self.selection %= len(self.letters) + 1 + + if inp.k_up: + self.modify_letter(-1) + + if inp.k_down: + self.modify_letter(1) + + return True + + return False + + def get_string(self): + """Get the initials entered""" + return "".join(map(lambda l: chr(97 + l.letter), self.letters)) + + def modify_letter(self, amount): + """Increase or decrease a single character + + :param amount: number of letters to increment by + """ + letter = self.get_selected_letter() + if letter in self.letters: + letter.letter += amount + letter.letter %= len(self.alphabet) + self.update_letter(letter) + letter.show() + + def update_letter(self, letter): + """Upare the image of a single letter + + :param letter: letter to update + """ + letter.set_image(self.alphabet[letter.letter]) + + def get_selected_letter(self): + """Get the letter that has been selected""" + if self.selection < len(self.letters): + return self.letters[self.selection] + + return self.button + + def set_pos(self, pos): + """set the element's position + + :param pos: position to move to + """ + pos_x, pos_y = pos + offset_x = 0 + for letter in self.letters: + letter.set_pos((pos_x + offset_x, pos_y)) + offset_x += letter.w + 1 + offset_x += Font.FONT_SIZE + + self.button.set_pos((pos_x + offset_x, pos_y)) + + def update_position(self): + """Update the position of all the letters""" + for letter in self.letters: + letter.update_position() + + def show(self): + """Make this object visible""" + if self.hidden: + for letter in self.letters: + letter.show() + letter.send_to_front() + self.button.show() + self.button.send_to_front() + self.hidden = False + + def hide(self): + """Make this object invisible""" + if not self.hidden: + for letter in self.letters: + letter.hide() + self.button.hide() + self.hidden = True + + def tick(self): + """Update the state of this object""" + if not self.hidden: + selected = self.get_selected_letter() + for letter in self.letters + [self.button]: + if self.game.alpha//15 == self.game.alpha / 15: + if letter == selected: + if (self.game.alpha//15) % 2 == 0: + self.get_selected_letter().show() + else: + self.get_selected_letter().hide() + else: + letter.show() + else: + self.hide() + + +class Leaderboard: + """Leaderboard object to display previous scores""" + + ANIMATION_TIME = 5 + ANIMATION_DELAY = 5 + + def __init__(self, game: Game): + """Initialise the leaderboard + + :param game: The game which this belongs to + :type game: Game + """ + self.game = game + self.file = LeaderboardFile() + self.entries = [] + self.editing = True + self.padding = 5 + + self.callback = (lambda: None) + + self.hidden = True + + self.game.inputs.add_keypress_handler(self.on_key) + self.name_entry = NameEntry(self.game, self.submit_name) + + self.blinking_sprite = None + self.animation_start = -1 + + def populate_entries(self, blink_entry=("", 0)): + """Populate entries. + + :param blink_entry: + """ + self.clear_entries() + self.file.load_entries() + + editing_area = 0 + if self.editing: + editing_area = Font.FONT_SIZE + self.padding*2 + remaining_area = self.game.h - self.padding*2 - editing_area + + to_fit = remaining_area // (Font.FONT_SIZE+self.padding) - 1 + + to_draw = self.file.entries[0:to_fit] + + # create a row variable that is incremented for each entry + y = self.padding + + # create the title sprite and increment the row + image = Font.load_text(self.game.texture_factory, "leaderboard") + sprite = GameSprite(self.game, image) + sprite.set_pos((0, y)) + self.entries.append(sprite) + x = (self.game.w - sprite.w) // 2 + sprite.set_pos((x, y)) + + y += sprite.h + self.padding + + # calculate the number of zeros to pad the score by + zfill = ((self.game.w-self.padding*2) // + (Font.FONT_SIZE+1)) - Config.NICK_LEN - 5 + for name, score in to_draw: + text = f"{name} {str(score).zfill(zfill)}" + x = self.padding + image = Font.load_text(self.game.texture_factory, text) + sprite = GameSprite(self.game, image) + sprite.set_pos((x, y)) + + if (name, score) == blink_entry: + self.blinking_sprite = sprite + self.entries.append(sprite) + + y += sprite.h + self.padding + + if self.editing: + self.name_entry.set_pos((self.padding, y+self.padding)) + self.name_entry.show() + else: + self.name_entry.hide() + + def start_animation(self): + """Start the animation.""" + for e in self.entries: + e.set_pos((-self.game.w, e.y)) + self.name_entry.hide() + self.animation_start = self.game.alpha + + def on_key(self, _): + """Handle Key press events + + :param _: The key press event to handle + """ + inp = self.game.inputs + if not self.hidden and inp.k_action and not self.editing: + self.callback() + return True + + return False + + def submit_name(self, name): + """Submit a name to the leaderboard + + :param name: + """ + score = self.game.score + self.file.add_entry(name, score) + self.file.save_entries() + + self.editing = False + self.name_entry.hide() + + self.populate_entries(blink_entry=(name, score)) + self.start_animation() + for e in self.entries: + e.show() + + def animate_sprite(self, sprite, i): + """Animate a single sprite. + + :param sprite: + :param i: + """ + alpha = self.game.alpha \ + - self.animation_start \ + - i*Leaderboard.ANIMATION_DELAY + + if alpha <= Leaderboard.ANIMATION_TIME: + if i == 0: + # only title should be h aligned + cx = (self.game.w - sprite.w) // 2 + else: + cx = self.padding + + x = (alpha/Leaderboard.ANIMATION_TIME) * \ + (cx+self.game.w//2) - self.game.w//2 + sprite.set_pos((x, sprite.y)) + + return False + return True + + def tick(self): + """Update the leaderboard""" + animation_complete = True + for i, sprite in enumerate(self.entries): + sprite.send_to_front() + if not self.animate_sprite(sprite, i): + animation_complete = False + + if self.editing: + animation_time = self.game.alpha \ + - self.animation_start \ + - len(self.entries)*Leaderboard.ANIMATION_DELAY + + if animation_complete \ + and animation_time > Leaderboard.ANIMATION_TIME: + self.name_entry.show() + self.name_entry.tick() + else: + if self.blinking_sprite is not None: + if (self.game.alpha//15) % 2 == 0: + self.blinking_sprite.show() + else: + self.blinking_sprite.hide() + + def show(self): + """Make this object visible""" + if self.hidden: + for m in self.entries: + m.show() + m.send_to_front() + + self.hidden = False + + def hide(self): + """Make this object invisible""" + if not self.hidden: + for m in self.entries: + m.hide() + self.name_entry.hide() + self.hidden = True + + def start_editing(self): + """Allow the user to input a name""" + self.editing = True + self.blinking_sprite = None + + def clear_entries(self): + """Remove all the associated objects""" + for entry in self.entries: + entry.destroy() + self.entries = [] + + +# test to add entries to game leaderboard +if __name__ == "__main__": + lb = LeaderboardFile() + lb.load_entries() + + for input_name, input_score in lb.entries: + print(f"{input_name} {input_score}") + + while True: + input_name = input("Enter name or leave blank to exit: ") + if input_name: + input_score = input("enter score blank for random: ") + if not input_score: + input_score = randint(1, 999999) + lb.add_entry(input_name, int(input_score)) + else: + break + + for input_name, input_score in lb.entries: + print(f"{input_name} {input_score}") + + lb.save_entries() diff --git a/main.py b/main.py new file mode 100755 index 0000000..f8b9e35 --- /dev/null +++ b/main.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python3 + +from shooter_game import ShooterGame + + +def main(): + """The entry function to the game""" + game = ShooterGame() + game.start() + + +if __name__ == "__main__": + main() diff --git a/menu.py b/menu.py new file mode 100644 index 0000000..84e522c --- /dev/null +++ b/menu.py @@ -0,0 +1,282 @@ +import re + +from font import Font +from game import Game, GameSprite +from sprite import Sprite + + +class MenuItem(GameSprite): + """A selectable item in a menu""" + + def __init__(self, game: Game, text, callback): + """Initialise the item + + :param game: The game which this belongs to + :type game: Game + :param text: Text to display for this item + :param callback: function to call when this item is selected + """ + image = Font.load_text(game.texture_factory, text) + self.text = text + self.callback = callback + super().__init__(game, image) + + def set_text(self, text): + """Update the text of an entry + + :param text: + """ + self.text = text + image = Font.load_text(self.game.texture_factory, text) + self.set_image(image) + + +class Menu(): + """Menu object with selectable entries""" + + def __init__(self, game: Game, title) -> None: + """Initialise the menu object + + :param game: The game which this belongs to + :type game: Game + :param title: The title of this menu + """ + self.game = game + self.title = title + self.padding = 5 + + self.game.inputs.add_keypress_handler(self.on_key) + + self.menu_items = [] + + self.selection = 0 + + carret_image = Font.load_text(game.texture_factory, ">") + self.carret = GameSprite(self.game, carret_image) + + title_image = Font.load_text(game.texture_factory, title) + + position = ((self.game.w - len(title)*Font.FONT_WIDTH)//2, 5*2) + self.title = GameSprite(self.game, title_image) + self.title.set_pos(position) + + self.hidden = True + + self.alpha = 0 + + def on_key(self, _): + """Handle Key press events + + + :param event: The key press event to handle + """ + if not self.hidden: + inp = self.game.inputs + + if inp.k_down or inp.k_right: + self.selection += 1 + + if inp.k_up or inp.k_left: + self.selection -= 1 + + self.selection %= len(self.menu_items) + self.update_carret() + + if inp.k_action: + self.menu_items[self.selection].callback() + + return True + + return False + + def update_carret(self): + """Move the carret to the correct location""" + selected = self.menu_items[self.selection] + x = selected.x - Font.FONT_SIZE + y = selected.y + self.carret.set_pos((x, y)) + + def arrange_items(self): + """Move the menu items to their correct positions""" + cy = self.game.h // 2 + + max_height = sum(item.h for item in self.menu_items + ) + self.padding*(len(self.menu_items)-1) + top = cy - max_height//2 + + y = top + + for item in self.menu_items: + item_x = (self.game.w - item.w) // 2 + item_y = y + y += item.h + self.padding + item.set_pos((item_x, item_y)) + self.update_carret() + + def add_item(self, text: str, callback, index=-1): + """Add a menu item + + :param text: Label of this item + :type text: str + :param callback: Function to call when it is selected + :param index: Where to insert this item + """ + if index == -1: + index = len(self.menu_items) + self.menu_items.insert(index, MenuItem(self.game, text, callback)) + self.arrange_items() + + def show(self): + """Make this object visible""" + if self.hidden: + for m in self.menu_items: + m.show() + m.send_to_front() + self.carret.show() + self.carret.send_to_front() + + self.title.show() + self.title.send_to_front() + + self.hidden = False + + def hide(self): + """Make this object invisible""" + if not self.hidden: + for m in self.menu_items: + m.hide() + self.carret.hide() + + self.title.hide() + + self.hidden = True + + def tick(self): + """Update this object""" + self.alpha += 1 + if not self.hidden: + if (self.alpha//15) % 2 == 0: + self.carret.show() + else: + self.carret.hide() + else: + self.carret.hide() + + def has_item(self, text): + """Return true if matching item is found + + :param text: Label text to match + """ + for entry in self.menu_items: + if entry.text == text: + return True + + return False + + def get_item(self, regex) -> MenuItem: + """Return an item that is matched + + :param regex: regular expression to match item text to + :rtype: MenuItem + """ + for entry in self.menu_items: + if re.match(regex, entry.text): + return entry + + return self.menu_items[0] + + def edit_item(self, regex, new_text): + """Edit the text of a menu item + + :param regex: Regular expression to use to match the item's text to + :param new_text: Text to replace to + """ + for entry in self.menu_items: + if re.match(regex, entry.text): + entry.set_text(new_text) + + self.arrange_items() + + def del_item(self, text): + """Remove an item + + :param text: Label text to match + """ + for entry in self.menu_items: + if entry.text == text: + entry.destroy() + self.menu_items = Sprite.remove_destroyed(self.menu_items) + self.arrange_items() + + return False + + +class KeybindsMenu(Menu): + """A menu for selecting keybinds on""" + + def __init__(self, game: Game, title): + """Initialise the menu + + :param game: The game which this belongs to + :type game: Game + :param title: The title of this menu + """ + super().__init__(game, title) + self.key_selecting = "" + + image = Font.load_text(game.texture_factory, "press any key") + self.press_key_sprite = GameSprite(self.game, image) + self.press_key_sprite.set_pos( + ((self.game.w - self.press_key_sprite.w) // 2, self.game.h // 2)) + + def on_key(self, event): + """Handle Key press events + + + :param event: The key press event to handle + """ + if self.key_selecting: + key = event.keysym + self.set_keybind(self.key_selecting, key) + self.press_key_sprite.hide() + self.show() + return True + return super().on_key(event) + + def set_keybind(self, name, key): + """set_keybind. + + :param name: + :param key: + """ + setattr(self.game.inputs.settings, self.key_selecting, key) + self.game.inputs.settings.save_inputs() + self.key_selecting = "" + self.edit_item(f"{name}\\s*<.*>", self.get_label(name, key)) + + def select_keybind(self, keyname): + """Allow the user to press a key to decide their keybind + + :param keyname: + """ + self.hide() + self.press_key_sprite.show() + self.key_selecting = keyname + + def get_set_keybind(self, keyname): + """Return a function that sets the keybind of a particular keyname + + :param keyname: + """ + return lambda: self.select_keybind(keyname) + + def get_label(self, name, value): + """Get a label for a keybind item + + :param name: + :param value: + """ + available_width = (self.game.w - Font.FONT_SIZE*2) // Font.FONT_WIDTH + num_spaces = available_width - (len(name) + len(value) + 2) - 1 + spaces = " " * num_spaces + return f"{name} {spaces}<{value}>" diff --git a/shooter.py b/shooter.py new file mode 100644 index 0000000..94878e3 --- /dev/null +++ b/shooter.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import List + +from game import DamageableSprite, Game, GameSprite +from sprite import Sprite + + +class Lazer(GameSprite): + """Lazer object that is shot by a shooter""" + + def __init__(self, game: Game, velocity=-4, color="white"): + """Initialise the lazer + + :param game: The game which this belongs to + :type game: Game + :param velocity: Velocity to move the lazer at + :param color: name of the colour of the lazer + """ + self.velocity = velocity + self.game = game + super().__init__(game, game.texture_factory.get_image( + f"lazer:{color}")) + + def tick(self): + """Update this object""" + self.move(0, self.velocity) + if self.y + self.h > self.game.h or self.y < 0: + self.destroy() + + +@dataclass +class ShooterAttributes: + """Attributes for a shooter object""" + + lazer_color: str = "white" + cooldown: int = 40 + velocity: int = 1 + hp: int = 3 + + +class Shooter(DamageableSprite): + """A game object that is able to shoot lazers""" + + def __init__(self, game: Game, + image_name: str, attributes: ShooterAttributes): + """Initialise the shooter + + :param game: The game which this belongs to + :type game: Game + :param image_name: The name of the image to use for this sprite + :type image_name: str + :param attributes: The attributes to use for this object + :type attributes: ShooterAttributes + """ + super().__init__(game, image_name, hp=attributes.hp) + self.lazers: List[Lazer] = [] + self.attributes = attributes + self.last_shot = self.game.alpha + + def shoot(self): + """Soot a lazer if possible""" + next_shot = self.last_shot + self.attributes.cooldown + + if not self.destroyed \ + and self.game.alpha > next_shot: + self.last_shot = self.game.alpha + + lazer = Lazer(self.game, + velocity=self.attributes.velocity, + color=self.attributes.lazer_color) + lazer.set_pos((self.x + self.w//2 - 1, self.y + + self.h//2 - 1)) + lazer.show() + self.lazers.append(lazer) + + def tick(self): + """Update this object""" + super().tick() + for lazer in self.lazers: + lazer.tick() + + self.lazers = Sprite.remove_destroyed(self.lazers) + + def destroy(self): + """Remove all the associated objects""" + super().destroy() + for lazer in self.lazers: + self.game.sprites.append(lazer) diff --git a/shooter_game.py b/shooter_game.py new file mode 100644 index 0000000..d50a66c --- /dev/null +++ b/shooter_game.py @@ -0,0 +1,363 @@ +from enum import Enum, auto +from os import path, remove +from random import random + +from boss_key import BossKey +from cheat_engine import Cheat, CheatEngine, DevModeCheat, InvincibilityCheat +from config import Config +from formation_spawner import FormationSpawner +from game import Game +from hud import GameHud +from leaderboard import Leaderboard +from menu import KeybindsMenu, Menu +from shooter import Shooter, ShooterAttributes +from textures import Textures + + +class GameState(Enum): + """Enum of possible game states""" + + MAIN_MENU = auto() + GAME = auto() + PAUSED = auto() + END_LEADERBOARD = auto() + LEADERBOARD = auto() + SETTINGS = auto() + + +class GameSave: + """Static class for saving and loading game""" + + @staticmethod + def save_game(game): + """Save game state to a file + + :param game: Game to save + """ + + phase = int.to_bytes(game.formation_spawner.phase, 2, "big") + hp = int.to_bytes(game.player.hp, 1, "big") + score = int.to_bytes(game.score, 8, "big") + + with open(Config.SAVE_FILE, "wb") as file: + file.write(phase) + file.write(hp) + file.write(score) + + if not game.menu.has_item("Continue"): + game.menu.add_item("Continue", game.restore_game, index=0) + + @staticmethod + def load_game(game): + """load game state from file + + :param game: Game to load + """ + with open(Config.SAVE_FILE, "rb") as file: + game.formation_spawner.phase = int.from_bytes(file.read(2), "big") + game.player.hp = int.from_bytes(file.read(1), "big") + game.score = int.from_bytes(file.read(8), "big") + + @staticmethod + def remove_save(game): + """Remove the game save file + + :param game: + """ + if path.exists(Config.SAVE_FILE): + remove(Config.SAVE_FILE) + if game.menu.has_item("Continue"): + game.menu.del_item("Continue") + + +class Player(Shooter): + """Controllable player object""" + + def __init__(self, game: Game): + """Initialise the player + + :param game: The game which this belongs to + :type game: Game + """ + attributes = ShooterAttributes( + cooldown=12, + velocity=-2, + hp=10 + ) + + super().__init__(game, "ship", attributes) + self.set_pos( + ((self.game.w - self.w) // 2, (self.game.h - self.h))) + + def tick(self): + """Update this object""" + super().tick() + if self.game.inputs.k_left: + self.move(-1, 0) + if self.game.inputs.k_right: + self.move(1, 0) + if self.game.inputs.k_up: + self.move(0, -1) + if self.game.inputs.k_down: + self.move(0, 1) + + # clamp the player to the screen + if self.x < 0: + self.set_pos((0, self.y)) + if self.y < 0: + self.set_pos((self.x, 0)) + if self.x + self.w > self.game.w: + self.set_pos((self.game.w - self.w, self.y)) + if self.y + self.h > self.game.h: + self.set_pos((self.x, self.game.h - self.h)) + + if self.game.inputs.k_action: + self.shoot() + + +class ShooterGame(Game): + """Game with menus and enemies to be shot at """ + + def __init__(self): + """Initialise the game""" + super().__init__() + + self.state = GameState.MAIN_MENU + self.death_time = -1 + self.paused_frame = 0 + + # load textures + Textures.load_textures(self.texture_factory) + self.effect_player.load_textures() + self.effect_player.create_stars() + + self.state = GameState.MAIN_MENU + + self.formation_spawner = FormationSpawner(self) + + self.player = Player(self) + + # make the game hud last to make sure its ontop + self.game_hud = GameHud(self) + + # create the leaderboard sprites + self.leaderboard = Leaderboard(self) + self.leaderboard.callback = self.show_menu + + # make the settings menu + self.settings_menu = KeybindsMenu(self, "Keybinds") + for name, value in vars(self.inputs.settings).items(): + label = self.settings_menu.get_label(name, value) + self.settings_menu.add_item( + label, + self.settings_menu.get_set_keybind(name) + ) + self.settings_menu.add_item("Return", self.show_menu) + + # make the main menu + self.menu = Menu(self, "Main Menu") + if path.exists(Config.SAVE_FILE): + self.menu.add_item("Continue", self.restore_game) + self.menu.add_item("New Game", self.start_game) + self.menu.add_item("Leaderboard", self.show_leaderboard) + self.menu.add_item("Settings", self.show_settings) + self.menu.show() + + # make the pause menu + self.pause_menu = Menu(self, "Game Paused") + self.pause_menu.add_item("Resume", self.resume_game) + + self.pause_menu.add_item("Save", lambda: ( + self.save_game(), + self.effect_player.splash_text("Game saved"), + self.resume_game()) + ) + + self.pause_menu.add_item("Exit", self.show_menu) + + # initialise cheats + self.cheat_engine = CheatEngine(self) + self.cheat_engine.add_cheat( + Cheat(self, list("test"), + (lambda: self.effect_player.splash_text("test ok")) + )) + + self.cheat_engine.add_cheat( + Cheat(self, [ + "Up", + "Up", + "Down", + "Down", + "Left", + "Right", + "Left", + "Right", + "b", + "a", + ], + (lambda: [self.formation_spawner.spawn_rectangle() + for _ in range(20)]) + )) + + self.cheat_engine.add_cheat(DevModeCheat(self, [ + "Left", + "Right", + "Left", + "Right", + "Escape", + "d", + "Up", + "t", + "b", + "b", + "a", + "b", + "s" + ])) + + self.cheat_engine.add_cheat(InvincibilityCheat(self, list("xyzzy"))) + + self.boss_key = BossKey(self, self.pause_game) + + def tick(self): + """Update the game state""" + if self.state != GameState.PAUSED: + super().tick() + + if random() > 0.9: + self.effect_player.create_star() + + if self.state == GameState.MAIN_MENU: + self.menu.tick() + elif self.state == GameState.SETTINGS: + self.settings_menu.tick() + elif self.state == GameState.GAME: + self.tick_game() + elif self.state == GameState.PAUSED: + self.alpha = self.paused_frame + self.pause_menu.tick() + elif self.state == GameState.END_LEADERBOARD: + self.leaderboard.tick() + elif self.state == GameState.LEADERBOARD: + self.leaderboard.tick() + + def tick_game(self): + """Update the game during game play""" + self.game_hud.tick() + self.formation_spawner.tick() + self.player.tick() + + if self.player.destroyed: + if self.death_time == -1: + self.death_time = self.alpha + self.effect_player.splash_text("GAME OVER", 100) + elif self.alpha - self.death_time > 100: + self.show_score() + + if self.inputs.k_pause: + self.pause_game() + + def pause_game(self): + """Set the game to paused state""" + if self.state == GameState.GAME: + self.state = GameState.PAUSED + + self.paused_frame = self.alpha + self.pause_menu.show() + + def resume_game(self): + """Resume the game from paused state""" + self.state = GameState.GAME + + self.pause_menu.hide() + + def start_game(self): + """Start a new game""" + self.state = GameState.GAME + + GameSave.remove_save(self) + + self.menu.hide() + self.pause_menu.hide() + self.formation_spawner.phase = -1 + self.clear_all() + + self.score = 0 + self.player = Player(self) + + self.formation_spawner.next_phase() + + self.player.show() + self.game_hud.show() + + self.death_time = -1 + + def show_leaderboard(self): + """Show the game's leaderboard""" + self.state = GameState.LEADERBOARD + + self.menu.hide() + self.pause_menu.hide() + self.leaderboard.editing = False + self.leaderboard.populate_entries() + self.leaderboard.start_animation() + + self.leaderboard.show() + + def show_score(self): + """Allow the user to enter their name into the leaderboard""" + self.state = GameState.END_LEADERBOARD + + self.clear_all() + self.game_hud.hide() + self.leaderboard.editing = True + self.leaderboard.populate_entries() + self.leaderboard.start_animation() + self.leaderboard.show() + GameSave.remove_save(self) + + def show_menu(self): + """Show the main menu""" + self.state = GameState.MAIN_MENU + + self.clear_all() + self.leaderboard.hide() + self.game_hud.hide() + self.player.hide() + self.pause_menu.hide() + self.settings_menu.hide() + + self.menu.show() + + def clear_all(self): + """Remove all the associated game objects""" + self.formation_spawner.clear_all() + self.player.destroy() + + def restore_game(self): + """Restore the game's state from file""" + self.state = GameState.GAME + + self.menu.hide() + self.pause_menu.hide() + self.clear_all() + + self.player = Player(self) + self.death_time = -1 + + GameSave.load_game(self) + + self.formation_spawner.start_phase() + self.game_hud.show() + self.player.show() + + def save_game(self): + """Save the game's state to a file""" + GameSave.save_game(self) + + def show_settings(self): + """Show the keybind setting menu""" + self.state = GameState.SETTINGS + + self.menu.hide() + self.settings_menu.show() diff --git a/sprite.py b/sprite.py new file mode 100644 index 0000000..574c178 --- /dev/null +++ b/sprite.py @@ -0,0 +1,127 @@ +from tkinter import Canvas, NW, PhotoImage + +from config import Config + + +class Sprite: + """Sprite.""" + + @staticmethod + def remove_destroyed(sprite_list): + """Remove all destroyed sprites from a list + + :param sprite_list: + :type sprite_list: list[Sprite] + """ + return list(filter(lambda s: not s.destroyed, sprite_list)) + + def __init__(self, canvas: Canvas, image: PhotoImage, position=(0, 0)): + """Initialise the sprite class + + :param canvas: The canvas to draw the sprites to + :type canvas: Canvas + :param image: The image to be used for the sprite + :type image: PhotoImage + :param position: The default position to place the sprite + """ + # set positions + self.x, self.y = position + + self.canvas = canvas + self.canvas_image = canvas.create_image( + self.x * Config.SCALE, self.y * Config.SCALE, + anchor=NW, image=image, state="hidden") + + # get pixel width and heigh ignoring scale + self.w = image.width() // Config.SCALE + self.h = image.height() // Config.SCALE + + self.destroyed = False + self.hide() + + def update_position(self): + """Move the image to the sprites position""" + self.canvas.coords(self.canvas_image, self.x * + Config.SCALE, self.y*Config.SCALE) + + def set_pos(self, pos): + """Set the player position + + :param pos: Position to move to + """ + self.x, self.y = pos + self.update_position() + + def get_pos(self): + """Return the current position of the sprite""" + return (self.x, self.y) + + def move(self, x, y): + """Move the sprite by x and y + + :param x: the number of pixels right to move + :param y: the number of pixels down to move + """ + self.x += x + self.y += y + self.update_position() + + def collides(self, other): + """Check if the sprite collides with another sprite + + :param other: The other sprite + """ + return self.x < other.x + other.w \ + and self.x + self.w > other.x \ + and self.y < other.y + other.h \ + and self.h + self.y > other.y + + def collide_all(self, others): + """Check if the sprite collides with a list of sprites + + :param others: Array of other sprites to check if collides with + :returns: index of the sprite that it collided with first + or -1 if not colliding + """ + for i, other in enumerate(others): + if self.collides(other): + return i + return -1 + + def tick(self): + """Update the sprite""" + + def destroy(self): + """Remove the image from the canvas""" + self.canvas.delete(self.canvas_image) + self.destroyed = True + + def send_to_front(self): + """Move the sprite to the foreground""" + self.canvas.tag_raise(self.canvas_image) + + def send_to_back(self): + """Move the sprite to the background""" + self.canvas.tag_lower(self.canvas_image) + + def set_image(self, image: PhotoImage): + """Change the image used by the sprite + + :param image: the image to set the sprite to + :type image: PhotoImage + """ + self.canvas.itemconfig(self.canvas_image, image=image) + + def show(self): + """Set the sprite to be shown""" + self.canvas.itemconfig(self.canvas_image, state="normal") + return self + + def hide(self): + """Set the sprite to be hidden""" + self.canvas.itemconfig(self.canvas_image, state="hidden") + return self + + def is_hidden(self): + """Return True if the sprite is hidden""" + return self.canvas.itemcget(self.canvas_image, "state") == "hidden" diff --git a/textures.py b/textures.py new file mode 100644 index 0000000..ad0169d --- /dev/null +++ b/textures.py @@ -0,0 +1,406 @@ +from tkinter import PhotoImage + + +# tell pylint to ignore long lines in this file, since they make more sense +# to not be linewrapped +# +# ignore a particular guideline "when applying the guideline would make the code less readable" +# https://peps.python.org/pep-0008 +# +# pylint: disable=line-too-long +class Textures: + """Static class containing game textures""" + STAR = [ + ["#AAAAAA"] + ] + + ENEMY = [ + [ + [None, "#00E436", "#008751", "#008751", None, None, None, ], + ["#00E436", "#00E436", "#00E436", None, None, "#008751", None, ], + ["#008751", "#00E436", "#00E436", None, None, "#00E436", "#008751",], + [None, "#008751", "#008751", None, "#00E436", "#00E436", "#008751",], + [None, None, None, "#00E436", "#00E436", "#00E436", "#00E436",], + [None, None, "#00E436", "#00E436", "#1D2B53", "#00E436", "#008751",], + [None, None, "#008751", "#00E436", "#1D2B53", "#1D2B53", "#00E436",], + [None, None, "#008751", "#008751", "#00E436", "#FF004D", "#00E436",], + [None, "#1D2B53", None, "#008751", "#008751", "#00E436", "#00E436",], + [None, "#008751", "#00E436", None, "#008751", "#1D2B53", "#1D2B53",], + ["#008751", "#00E436", "#00E436", "#00E436", None, "#1D2B53", "#008751",], + ["#00E436", "#00E436", "#00E436", None, None, None, None, ], + ["#008751", "#00E436", "#00E436", "#00E436", "#008751", None, None, ], + ["#1D2B53", "#008751", "#00E436", "#008751", None, None, None, ], + ], + [ + [None, None, "#C2C3C7", "#1D2B53", None, None, None, ], + [None, None, "#83769C", "#C2C3C7", "#1D2B53", None, None, ], + [None, None, None, "#83769C", "#C2C3C7", "#83769C", "#C2C3C7",], + ["#83769C", "#1D2B53", None, None, "#83769C", "#C2C3C7", "#C2C3C7",], + ["#83769C", "#83769C", "#83769C", "#1D2B53", "#C2C3C7", "#83769C", "#C2C3C7",], + ["#1D2B53", "#C2C3C7", "#83769C", "#83769C", "#83769C", "#7E2553", "#1D2B53",], + ["#C2C3C7", "#C2C3C7", "#1D2B53", None, "#C2C3C7", "#7E2553", "#7E2553",], + ["#C2C3C7", "#C2C3C7", None, None, "#83769C", "#C2C3C7", "#C2C3C7",], + ["#C2C3C7", "#C2C3C7", "#C2C3C7", "#83769C", "#1D2B53", None, None, ], + ["#83769C", "#C2C3C7", "#C2C3C7", "#83769C", None, None, None, ], + ["#1D2B53", "#C2C3C7", "#83769C", "#1D2B53", None, None, None, ], + [None, "#C2C3C7", "#83769C", "#83769C", None, None, None, ], + [None, "#1D2B53", "#83769C", "#1D2B53", None, None, None, ], + ], + [ + [None, None, None, None, "#7E2553", "#FFA300", "#FFA300",], + [None, None, "#7E2553", "#FFA300", "#FFA300", "#FFA300", "#FFEC27",], + [None, "#7E2553", "#FFA300", "#FFA300", "#FFEC27", "#FFEC27", "#FFEC27",], + [None, "#FFA300", "#FFA300", "#FFEC27", "#FFA300", "#FFEC27", "#FFEC27",], + ["#7E2553", "#FFA300", "#FFEC27", "#FFEC27", "#FFEC27", "#FFA300", "#FFA300",], + ["#FFA300", "#FFA300", "#FFEC27", "#FFEC27", "#FFEC27", "#FFEC27", "#FFEC27",], + ["#AB5236", "#AB5236", "#000000", "#000000", "#FFA300", "#FFEC27", "#FFEC27",], + ["#AB5236", "#AB5236", "#1D2B53", "#FF004D", "#000000", "#FFA300", "#FFEC27",], + ["#AB5236", "#FFA300", "#AB5236", "#1D2B53", "#1D2B53", "#AB5236", "#AB5236",], + ["#7E2553", "#AB5236", "#FFA300", "#AB5236", "#AB5236", "#FFA300", "#FFA300",], + ["#7E2553", "#7E2553", "#AB5236", "#AB5236", "#AB5236", "#AB5236", "#AB5236",], + [None, "#7E2553", "#7E2553", "#7E2553", None, None, None, ], + ] + ] + + ROCK1 = [ + [None, None, "#FFA300", "#FFA300", "#FFA300", "#FFA300", "#5F574F", "#1D2B53", None, None, None, None, ], + [None, "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", None, None, None, ], + ["#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", None, None, ], + ["#FFA300", "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#5F574F", "#5F574F", "#1D2B53", None, ], + ["#5F574F", "#5F574F", "#5F574F", "#FFA300", "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#FFA300", "#5F574F", "#5F574F", None, ], + ["#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#FFA300", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53",], + [None, "#1D2B53", "#1D2B53", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53",], + [None, None, "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53",], + [None, None, None, "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, ], + [None, None, None, None, None, None, "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, None, ], + ] + + ROCK2 = [ + [None, None, None, None, "#FFA300", "#FFA300", "#5F574F", "#FFA300", "#5F574F", None, None, None, ], + [None, None, None, "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#1D2B53", "#1D2B53", None, ], + [None, None, "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53",], + [None, "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#FFA300", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#FFA300", "#5F574F", "#1D2B53", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53",], + [None, None, "#1D2B53", "#5F574F", "#5F574F", "#FFA300", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", None, ], + [None, None, None, "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, None, None, ], + ] + + ROCK3 = [ + [None, None, None, None, None, None, None, None, None, "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", None, None, ], + [None, None, None, None, "#FFA300", "#FFA300", "#FFA300", "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", None, ], + [None, "#FFA300", "#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#1D2B53", "#1D2B53",], + ["#FFA300", "#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#FFA300", "#1D2B53", "#1D2B53",], + ["#FFA300", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53",], + ["#5F574F", "#5F574F", "#1D2B53", "#5F574F", "#5F574F", "#FFA300", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, ], + ["#1D2B53", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, None, None, None, None, None, None, None, None, ], + [None, "#1D2B53", "#1D2B53", "#1D2B53", "#1D2B53", None, None, None, None, None, None, None, None, None, None, None, ], + ] + + ROCK4 = [ + [None, None, "#5F574F", "#FFA300", "#FFA300", "#5F574F", None, ], + [None, "#5F574F", "#FFA300", "#1D2B53", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#FFA300", "#1D2B53", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53",], + ["#FFA300", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53",], + ["#FFA300", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", "#1D2B53", None, ], + ["#5F574F", "#5F574F", "#1D2B53", "#1D2B53", "#1D2B53", None, None, ], + [None, "#1D2B53", "#1D2B53", "#1D2B53", None, None, None, ], + ] + + ROCK5 = [ + [None, "#FFA300", "#5F574F", "#5F574F", "#1D2B53", None, ], + ["#FFA300", "#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#1D2B53",], + ["#5F574F", "#5F574F", "#5F574F", "#5F574F", "#5F574F", "#1D2B53",], + ["#1D2B53", "#5F574F", "#5F574F", "#5F574F", "#1D2B53", None, ], + [None, "#1D2B53", "#1D2B53", "#1D2B53", None, None, ], + ] + + SHIP = [ + [None, None, None, None, None, None, None, None, ], + [None, None, None, "#83769C", "#83769C", None, None, None, ], + [None, "#83769C", "#1D2B53", "#FFF1E8", "#29ADFF", "#1D2B53", "#5F574F", None, ], + ["#83769C", "#83769C", "#7E2553", "#FFF1E8", "#29ADFF", "#1D2B53", "#5F574F", "#5F574F",], + ["#83769C", "#C2C3C7", "#83769C", "#29ADFF", "#29ADFF", "#1D2B53", "#83769C", "#5F574F",], + ["#83769C", "#C2C3C7", "#83769C", "#5F574F", "#1D2B53", "#1D2B53", "#83769C", "#5F574F",], + ["#1D2B53", "#83769C", "#5F574F", "#83769C", "#83769C", "#5F574F", "#5F574F", "#1D2B53",], + [None, "#1D2B53", "#1D2B53", None, None, "#1D2B53", "#1D2B53", None, ], + ] + + UFO = [ + [None, None, None, None, None, "#1D2B53", "#29ADFF", "#FFF1E8",], + [None, None, None, None, "#1D2B53", "#29ADFF", "#FFF1E8", "#FFF1E8",], + [None, None, None, "#1D2B53", "#29ADFF", "#29ADFF", "#29ADFF", "#29ADFF",], + [None, "#83769C", "#83769C", "#C2C3C7", "#FFF1E8", "#FFF1E8", "#C2C3C7", "#C2C3C7",], + ["#83769C", "#83769C", "#C2C3C7", "#FFF1E8", "#FFF1E8", "#C2C3C7", "#C2C3C7", "#C2C3C7",], + [None, "#7E2553", "#FF004D", "#7E2553", None, None, "#7E2553", "#FF004D",], + ] + + LAZER = [ + ["#8F8F8F"], + ["#F8F8F8"], + ["#F8F8F8"], + ["#F8F8F8"], + ["#F8F8F8"], + ["#8F8F8F"] + ] + + SMALLENEMY =[ + [ + [None, "#00E436", "#008751", None, ], + ["#00E436", "#008751", None, None, ], + ["#008751", "#008751", "#00E436", "#00E436",], + [None, "#00E436", "#1D2B53", "#00E436",], + [None, "#00E436", "#FF004D", "#00E436",], + ["#008751", "#00E436", "#008751", "#008751",], + ["#00E436", "#1D2B53", None, None, ], + ["#008751", "#00E436", "#00E436", None, ], + ], + [ + [None, "#7E2553", "#FFA300",], + ["#7E2553", "#FFA300", "#FFEC27",], + ["#AB5236", "#FFA300", "#FFA300",], + ["#AB5236", "#1D2B53", "#AB5236",], + ["#7E2553", "#AB5236", "#FFA300",], + [None, None, None, ], + ["#FFA300", "#AB5236", None, ], + ["#AB5236", "#7E2553", None, ], + ], + [ + ["#FF004D", None, "#7E2553", "#FF004D",], + ["#7E2553", "#7E2553", "#FF004D", "#FF77A8",], + [None, "#FF004D", "#FF004D", "#FF004D",], + [None, "#7E2553", None, "#7E2553",], + ["#7E2553", "#FF004D", "#1D2B53", "#7E2553",], + ["#FF004D", "#FF004D", None, "#FF004D",], + ["#FF004D", "#7E2553", None, None, ], + ["#7E2553", "#FF004D", "#7E2553", None, ], + ], + [ + [None, "#1D2B53", "#008751",], + [None, "#008751", "#00E436",], + ["#1D2B53", "#008751", "#00E436",], + ["#1D2B53", "#7E2553", "#008751",], + [None, "#1D2B53", "#008751",], + ["#008751", "#1D2B53", None, ], + ["#008751", None, None, ], + ["#1D2B53", "#008751", None, ], + ], + [ + [None, None, "#FFA300", "#FFEC27",], + [None, "#FFA300", "#FFEC27", "#FFEC27",], + ["#AB5236", "#FFA300", "#1D2B53", "#FFA300",], + ["#FFA300", "#FFEC27", "#FFA300", "#FFEC27",], + ["#FFEC27", "#FFA300", None, "#FFEC27",], + ["#FFA300", None, None, None, ], + ["#AB5236", "#FFA300", None, "#AB5236",], + [None, "#AB5236", None, None, ], + ], + [ + [None, None, "#7E2553", "#FF77A8",], + [None, "#7E2553", "#FF77A8", "#FFCCAA",], + [None, "#FF77A8", "#FFCCAA", "#FFCCAA",], + [None, "#FFCCAA", "#1D2B53", "#FF77A8",], + [None, "#7E2553", "#FF77A8", "#FFCCAA",], + [None, "#FF77A8", "#1D2B53", "#FFCCAA",], + [None, "#FF77A8", "#1D2B53", "#FF77A8",], + [None, "#7E2553", "#FF77A8", "#7E2553",], + ], + [ + ["#1D2B53", "#83769C", "#1D2B53", None, ], + [None, "#1D2B53", "#C2C3C7", None, ], + ["#83769C", "#1D2B53", "#83769C", "#C2C3C7",], + ["#C2C3C7", "#83769C", "#C2C3C7", "#C2C3C7",], + ["#C2C3C7", "#1D2B53", "#7E2553", "#83769C",], + ["#C2C3C7", "#1D2B53", "#C2C3C7", "#C2C3C7",], + ["#1D2B53", "#83769C", "#1D2B53", None, ], + [None, "#1D2B53", "#83769C", None, ], + ], + [ + ["#1D2B53", None, None, ], + ["#29ADFF", None, None, ], + ["#29ADFF", "#1D2B53", None, ], + ["#1D2B53", "#29ADFF", "#1D2B53",], + ["#29ADFF", None, "#29ADFF",], + ["#29ADFF", "#29ADFF", None, ], + ["#29ADFF", "#29ADFF", "#1D2B53",], + ["#29ADFF", "#1D2B53", None, ], + ] + ] + + EXPLOSION =[ + [ + ["#FFEC27", "#FFEC27"], + ["#FFF1E8", "#FFEC27"], + ["#7E2553", "#7E2553"] + ], + [ + ["#7E2553", "#FFEC27"], + ["#FFEC27", "#FFF1E8"], + ], + [ + [ None, "#7E2553", "#FFEC27", "#FFEC27"], + [ "#7E2553", "#FFEC27", "#FFEC27", "#FFF1E8"], + [ "#FFEC27", "#FFEC27", "#FFF1E8", "#FFF1E8"], + [ "#FFEC27", "#FFF1E8", "#FFF1E8", "#FFF1E8"], + ] + ] + + @staticmethod + def hmirror_texture(texture): + """Horizontally mirror a texture + + :param texture: texture to mirror + """ + return [(row + row[::-1]) for row in texture] + + @staticmethod + def vmirror_texture(texture): + """Vertically mirror a texture + + :param texture: texture to mirror + """ + return texture + texture[::-1] + + @staticmethod + def recolor(texture, color): + """recolor a texture + + :param texture: texture to recolor + :param color: Color to multiply the texture with + """ + return [[None if col is None else Textures.multiply_colors(col, color) for col in row] for row in texture] + + @staticmethod + def multiply_colors(hex1, hex2): + """Multiply two RGB colours + + :param hex1: first colour + :param hex2: second colour + """ + color1 = Textures.hex_to_rgb(hex1) + color2 = Textures.hex_to_rgb(hex2) + return Textures.rgb_to_hex([color1[i] * color2[i] for i in range(len(color1))]) + + @staticmethod + def hex_to_rgb(value): + """Convert a hexadecimal colour value to red green and blue + + :param value: hex value + """ + value = value.lstrip('#') + length = len(value) + return tuple(int(value[i:i + length // 3], 16)/256 for i in range(0, length, length // 3)) + + @staticmethod + def rgb_to_hex(value): + """Convert red green and blue value to a hexadecimal representation + + :param value: RGB value + """ + return "#" + "".join(f"{int(v*256):02X}" for v in value) + + @staticmethod + def white_texture(texture): + """Replace all coloured pixels with white + + :param texture: Texture to replace on + """ + return [[None if col is None else "#FFFFFF" for col in row] for row in texture] + + @staticmethod + def load_textures(texture_factory): + """Load all textures within this class + + :param texture_factory: + """ + texture_factory.load_texture( + "ufo", Textures.hmirror_texture(Textures.UFO)) + texture_factory.load_texture("star", Textures.STAR) + + texture_factory.load_texture("ship", Textures.SHIP) + texture_factory.load_texture( + "ship:white", Textures.white_texture(Textures.SHIP)) + + texture_factory.load_texture("rock1", Textures.ROCK1) + texture_factory.load_texture("rock2", Textures.ROCK2) + texture_factory.load_texture("rock3", Textures.ROCK3) + texture_factory.load_texture("rock4", Textures.ROCK4) + texture_factory.load_texture("rock5", Textures.ROCK5) + + texture_factory.load_texture( + "lazer:white", Textures.recolor(Textures.LAZER, "#ffffff")) + texture_factory.load_texture( + "lazer:red", Textures.recolor(Textures.LAZER, "#f2aaaa")) + texture_factory.load_texture( + "lazer:yellow", Textures.recolor(Textures.LAZER, "#f2ffaa")) + + for i, enemy in enumerate(Textures.SMALLENEMY): + name = f"smallenemy{i}" + texture = Textures.hmirror_texture(enemy) + texture_factory.load_texture(name, texture) + texture_factory.load_texture( + f"{name}:white", Textures.white_texture(texture)) + evil_texture = Textures.recolor(texture, "#FF5555") + texture_factory.load_texture(f"{name}_evil", evil_texture) + texture_factory.load_texture( + f"{name}_evil:white", Textures.white_texture(evil_texture)) + + for i, enemy in enumerate(Textures.ENEMY): + name = f"enemy{i}" + texture_factory.load_texture(name, Textures.hmirror_texture(enemy)) + texture_factory.load_texture( + f"{name}:white", Textures.white_texture(Textures.hmirror_texture(enemy))) + + texture_factory.load_texture("explosion3", Textures.EXPLOSION[0]) + texture_factory.load_texture("explosion2", Textures.hmirror_texture( + Textures.vmirror_texture(Textures.EXPLOSION[1]))) + texture_factory.load_texture("explosion1", Textures.hmirror_texture( + Textures.vmirror_texture(Textures.EXPLOSION[2]))) + + +class TextureFactory: + """Object that deals with loading and scaling textures""" + + def __init__(self, scale) -> None: + """Initialise the texture factory + + :param scale: the amount of pixels to upscale by + :rtype: None + """ + self.textures = {} + self.scale = scale + + def load_texture(self, namespace, texture_matrix): + """Load and upscale a texture + + :param namespace: namespace to save this texture to + :param texture_matrix: A matrix of hex colours that represents the texture + """ + if namespace not in self.textures: + height = len(texture_matrix) * self.scale + width = len(texture_matrix[0]) * self.scale + photo_image = PhotoImage(width=width, height=height) + + for matrix_y, row in enumerate(texture_matrix): + for matrix_x, color in enumerate(row): + if color is not None: + pixel_string = ( + "{" + f"{color} "*self.scale + "} ") * self.scale + photo_image.put( + pixel_string, (matrix_x*self.scale, matrix_y*self.scale)) + + self.textures[namespace] = photo_image + return photo_image + return self.get_image(namespace) + + def get_image(self, namespace): + """Get a loaded image + + :param namespace: to load the image from + """ + if namespace not in self.textures: + raise Exception( + f"Provided namespace \"{namespace}\" has not been loaded!") + return self.textures[namespace] -- cgit v1.2.1