summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--boss.py203
-rw-r--r--boss_key.py127
-rw-r--r--cheat_engine.py162
-rw-r--r--config.py20
-rw-r--r--enemy.py63
-rw-r--r--font.py342
-rw-r--r--formation.py296
-rw-r--r--formation_spawner.py341
-rw-r--r--frame_counter.py41
-rw-r--r--game.py310
-rw-r--r--hud.py152
-rw-r--r--inputs.py133
-rw-r--r--leaderboard.py443
-rwxr-xr-xmain.py13
-rw-r--r--menu.py282
-rw-r--r--shooter.py88
-rw-r--r--shooter_game.py363
-rw-r--r--sprite.py127
-rw-r--r--textures.py406
19 files changed, 3912 insertions, 0 deletions
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('<KeyPress>', self.on_key_press)
+ game.win.bind('<KeyRelease>', 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]