commit b99dbb396c313f4b3130b566c0df42c10eec6084 Author: davidovski Date: Thu Jan 5 11:35:28 2023 +0000 Initial Commit 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]