commit bf276e620c8300d0fe5c5e361b24bebd93558694
Author: MikoĊaj Lenczewski <mikolaj.lenczewski308@gmail.com>
Date: Wed, 9 Dec 2020 10:34:17 +0000
Imported repository
Diffstat:
18 files changed, 2311 insertions(+), 0 deletions(-)
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1,2 @@
+__pycache__/
+env/
diff --git a/LICENSE b/LICENSE
@@ -0,0 +1,121 @@
+Creative Commons Legal Code
+
+CC0 1.0 Universal
+
+ CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
+ LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
+ ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
+ INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
+ REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
+ PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
+ THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
+ HEREUNDER.
+
+Statement of Purpose
+
+The laws of most jurisdictions throughout the world automatically confer
+exclusive Copyright and Related Rights (defined below) upon the creator
+and subsequent owner(s) (each and all, an "owner") of an original work of
+authorship and/or a database (each, a "Work").
+
+Certain owners wish to permanently relinquish those rights to a Work for
+the purpose of contributing to a commons of creative, cultural and
+scientific works ("Commons") that the public can reliably and without fear
+of later claims of infringement build upon, modify, incorporate in other
+works, reuse and redistribute as freely as possible in any form whatsoever
+and for any purposes, including without limitation commercial purposes.
+These owners may contribute to the Commons to promote the ideal of a free
+culture and the further production of creative, cultural and scientific
+works, or to gain reputation or greater distribution for their Work in
+part through the use and efforts of others.
+
+For these and/or other purposes and motivations, and without any
+expectation of additional consideration or compensation, the person
+associating CC0 with a Work (the "Affirmer"), to the extent that he or she
+is an owner of Copyright and Related Rights in the Work, voluntarily
+elects to apply CC0 to the Work and publicly distribute the Work under its
+terms, with knowledge of his or her Copyright and Related Rights in the
+Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be
+protected by copyright and related or neighboring rights ("Copyright and
+Related Rights"). Copyright and Related Rights include, but are not
+limited to, the following:
+
+ i. the right to reproduce, adapt, distribute, perform, display,
+ communicate, and translate a Work;
+ ii. moral rights retained by the original author(s) and/or performer(s);
+iii. publicity and privacy rights pertaining to a person's image or
+ likeness depicted in a Work;
+ iv. rights protecting against unfair competition in regards to a Work,
+ subject to the limitations in paragraph 4(a), below;
+ v. rights protecting the extraction, dissemination, use and reuse of data
+ in a Work;
+ vi. database rights (such as those arising under Directive 96/9/EC of the
+ European Parliament and of the Council of 11 March 1996 on the legal
+ protection of databases, and under any national implementation
+ thereof, including any amended or successor version of such
+ directive); and
+vii. other similar, equivalent or corresponding rights throughout the
+ world based on applicable law or treaty, and any national
+ implementations thereof.
+
+2. Waiver. To the greatest extent permitted by, but not in contravention
+of, applicable law, Affirmer hereby overtly, fully, permanently,
+irrevocably and unconditionally waives, abandons, and surrenders all of
+Affirmer's Copyright and Related Rights and associated claims and causes
+of action, whether now known or unknown (including existing as well as
+future claims and causes of action), in the Work (i) in all territories
+worldwide, (ii) for the maximum duration provided by applicable law or
+treaty (including future time extensions), (iii) in any current or future
+medium and for any number of copies, and (iv) for any purpose whatsoever,
+including without limitation commercial, advertising or promotional
+purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
+member of the public at large and to the detriment of Affirmer's heirs and
+successors, fully intending that such Waiver shall not be subject to
+revocation, rescission, cancellation, termination, or any other legal or
+equitable action to disrupt the quiet enjoyment of the Work by the public
+as contemplated by Affirmer's express Statement of Purpose.
+
+3. Public License Fallback. Should any part of the Waiver for any reason
+be judged legally invalid or ineffective under applicable law, then the
+Waiver shall be preserved to the maximum extent permitted taking into
+account Affirmer's express Statement of Purpose. In addition, to the
+extent the Waiver is so judged Affirmer hereby grants to each affected
+person a royalty-free, non transferable, non sublicensable, non exclusive,
+irrevocable and unconditional license to exercise Affirmer's Copyright and
+Related Rights in the Work (i) in all territories worldwide, (ii) for the
+maximum duration provided by applicable law or treaty (including future
+time extensions), (iii) in any current or future medium and for any number
+of copies, and (iv) for any purpose whatsoever, including without
+limitation commercial, advertising or promotional purposes (the
+"License"). The License shall be deemed effective as of the date CC0 was
+applied by Affirmer to the Work. Should any part of the License for any
+reason be judged legally invalid or ineffective under applicable law, such
+partial invalidity or ineffectiveness shall not invalidate the remainder
+of the License, and in such case Affirmer hereby affirms that he or she
+will not (i) exercise any of his or her remaining Copyright and Related
+Rights in the Work or (ii) assert any associated claims and causes of
+action with respect to the Work, in either case contrary to Affirmer's
+express Statement of Purpose.
+
+4. Limitations and Disclaimers.
+
+ a. No trademark or patent rights held by Affirmer are waived, abandoned,
+ surrendered, licensed or otherwise affected by this document.
+ b. Affirmer offers the Work as-is and makes no representations or
+ warranties of any kind concerning the Work, express, implied,
+ statutory or otherwise, including without limitation warranties of
+ title, merchantability, fitness for a particular purpose, non
+ infringement, or the absence of latent or other defects, accuracy, or
+ the present or absence of errors, whether or not discoverable, all to
+ the greatest extent permissible under applicable law.
+ c. Affirmer disclaims responsibility for clearing rights of other persons
+ that may apply to the Work or any use thereof, including without
+ limitation any person's Copyright and Related Rights in the Work.
+ Further, Affirmer disclaims responsibility for obtaining any necessary
+ consents, permissions or other rights required for any use of the
+ Work.
+ d. Affirmer understands and acknowledges that Creative Commons is not a
+ party to this document and has no duty or obligation with respect to
+ this CC0 or use of the Work.
diff --git a/README.md b/README.md
@@ -0,0 +1,8 @@
+# pyecs
+An ECS game engine written in python, using Tkinter as the graphics library.
+Uses an archetypal ECS architecture.
+
+TODO:
+- Add spatial hashing to improve collisions
+- Consider adding sound
+- Improve performance generally
diff --git a/assets/boss_image.jpg b/assets/boss_image.jpg
Binary files differ.
diff --git a/assets/boss_image_v2.jpg b/assets/boss_image_v2.jpg
Binary files differ.
diff --git a/assets/out/boss_image.gif b/assets/out/boss_image.gif
Binary files differ.
diff --git a/assets/out/boss_image_v2.gif b/assets/out/boss_image_v2.gif
Binary files differ.
diff --git a/main.py b/main.py
@@ -0,0 +1,361 @@
+#!/usr/bin/env python3
+
+"""
+UoM COMP16321 Coursework 2
+
+This coursework is split into multiple parts, because i could not deal with a
+single python file that would be like 2000 lines long. The general structure
+is as follows:
+ - main.py : initialises the game, does serialisation / deserialisation,
+ and also starts the main menu.
+ - assets/ : stores game related assets such as the boss-key image
+ - src/ : stores the games source code
+ - common.py : Stores global variables, functions common to the
+ rest of the games modules, and a few miscellaneous
+ utility classes and functions.
+ - platform.py : Provides a wrapper around the tkinter canvas, called
+ "Screen". This wrapper implemented a bunch of GUI
+ related functionality, and is a singleton class.
+
+ - ecs/ : Stores the Entity-Component-System source code
+ - ecs.py : Stores the implementation of the ECS manager,
+ which allows for the registration of systems,
+ the creation of entities, and the registration
+ of components on said entities. It also provides
+ methods to setup, process, and cleanup an ECS.
+ - components.py : Stores all the components in use by the game.
+ Provides a "formal virtual interface" that should
+ be implemented by all components.
+ - systems.py : Stores all the systems in use by the game.
+ Provides a "formal virtual interface" that should
+ be implemented by all systems.
+"""
+
+import os
+import random
+import tkinter as tk
+
+import src
+import src.ecs.ecs as ecs
+import src.ecs.components as components
+import src.ecs.systems as systems
+import src.platform as platform
+
+from math import pi
+
+from src.common import *
+
+
+def run(root, starting_score=0, starting_lives=5):
+ screen = platform.Screen(root)
+
+ manager = ecs.EcsManager()
+
+ ## register all systems that will be used in the game
+ manager.register_system(systems.Render2DSystem())
+ manager.register_system(systems.Collider2DSystem())
+ manager.register_system(systems.EdgeHarmSystem())
+ manager.register_system(systems.Physics2DSystem())
+ manager.register_system(systems.UserInputSystem())
+ manager.register_system(systems.LifeSystem())
+ manager.register_system(systems.LifespanSystem())
+ manager.register_system(systems.BulletEmitterSystem())
+ manager.register_system(systems.SpawnerSystem())
+ manager.register_system(systems.EnemyEmitterSystem())
+
+ ## create player
+ player = manager.create_entity()
+ manager.register_component(player, components.PlayerTag())
+ screen.set_tracked_entity(player)
+
+ player_vertices = [-5, 10, 0, -10, 5, 10, 0, 5]
+ player_sprite = screen.draw_poly(player_vertices, fill=CYAN, tag='player')
+ manager.register_component(player, components.Transform2D(WIDTH / 2,
+ HEIGHT / 2, 0))
+ manager.register_component(player, components.Collider2D(10, 20))
+ manager.register_component(player, components.Velocity2D(0, 0))
+ manager.register_component(player, components.UserInput(220))
+ manager.register_component(player, components.ScreenElement(player_sprite,
+ player_vertices))
+
+ manager.register_component(player, components.Score(starting_score))
+ manager.register_component(player, components.Lives(starting_lives))
+ player_bullet_data = components.BulletData(
+ bullet_size=10,
+ bullet_speed=-270,
+ bullet_vertices=[-5, 0, 0, 5, 5, 0, 0, -5],
+ bullet_colours=[CYAN],
+ bullet_colour_idx=0)
+ manager.register_component(player, components.LinearBulletEmitter(
+ data=player_bullet_data, direction=-1))
+ manager.register_component(player, components.RadialBulletEmitter(
+ data=player_bullet_data, bullet_count=48, bullet_arc_offset=0))
+
+ ## create enemy spawner
+ enemy_spawner = manager.create_entity()
+
+ enemy_patterns = [
+ 'loner',
+ 'column',
+ 'row_ltr',
+ 'row_rtl',
+ ]
+
+ enemy_size = 20
+ enemy_padding = enemy_size / 2
+
+ def next_spawn():
+ while True:
+ next_pattern = random.choice(enemy_patterns)
+ pattern_cooldown = 1
+
+ min_enemies, max_enemies = 2, 3
+
+ player_score = screen.get_score()
+ if player_score > 100:
+ min_enemies, max_enemies = 4, 5
+ if player_score > 150:
+ min_enemies, max_enemies = 6, 7
+ if player_score > 200:
+ pattern_cooldown = 0.75
+
+ base_px = random.randint(enemy_size, WIDTH - enemy_size)
+ enemy_count = random.randint(min_enemies, max_enemies)
+
+ if next_pattern == 'loner':
+ yield (pattern_cooldown, (base_px, enemy_size))
+
+ elif next_pattern == 'column':
+ for i in range(enemy_count):
+ yield (0.25, (base_px, enemy_size))
+ yield (pattern_cooldown, (base_px, enemy_size))
+
+ elif next_pattern == 'row_ltr':
+ max_px = enemy_count * (enemy_size + enemy_padding) + enemy_size
+ base_px = random.randint(enemy_size, WIDTH - max_px)
+ curr_px = base_px
+ for i in range(enemy_count):
+ yield (0.05, (curr_px, enemy_size))
+ curr_px += enemy_size + enemy_size / 2
+ yield (pattern_cooldown, (curr_px, enemy_size))
+
+ elif next_pattern == 'row_rtl':
+ min_px = enemy_count * (enemy_size + enemy_padding) + enemy_size
+ base_px = random.randint(min_px, WIDTH - enemy_size)
+ curr_px = base_px
+ for i in range(enemy_count):
+ yield (0.05, (curr_px, enemy_size))
+ curr_px -= enemy_size + enemy_size / 2
+ yield (pattern_cooldown, (curr_px, enemy_size))
+
+
+ def create_enemy(spawn_location, manager, screen):
+ e = manager.create_entity()
+ manager.register_component(e, components.EnemyTag())
+
+ enemy_speed = 130
+ shooter_chance = 0.125
+ min_shooter_cooldown, max_shooter_cooldown = 2, 3
+ heavy_chance = 0
+
+ player_score = screen.get_score()
+ if player_score > 100:
+ shooter_chance = 0.25
+ min_shooter_cooldown = 1
+ heavy_chance = 0.05
+
+ if player_score > 150:
+ shooter_chance = 0.5
+ heavy_chance = 0.075
+
+ if player_score > 200:
+ max_shooter_cooldown = 2
+ heavy_chance = 0.1
+
+ enemy_l_wing = [-3, 0, -5, 0, -7, 7, -10, 0]
+ enemy_r_wing = [10, 0, 7, 7, 5, 0, 3, 0]
+ enemy_vertices = [*enemy_l_wing, -7, -3, 7, -3, *enemy_r_wing, 0, 15]
+
+ sx, sy = 16, 25
+ px, py = spawn_location
+ vx, vy = 0, enemy_speed
+
+ manager.register_component(e, components.Transform2D(px, py, 0))
+ manager.register_component(e, components.Collider2D(sx, sy))
+ manager.register_component(e, components.Velocity2D(vx, vy))
+ manager.register_component(e, components.EdgeHarm(player,
+ HEIGHT + 2 * sy))
+
+ ## have some enemies shoot bullets we have to dodge
+ if random.random() <= shooter_chance:
+ enemy_bullet_data = components.BulletData(
+ bullet_size=10,
+ bullet_speed=180,
+ bullet_vertices=[-5, 0, 0, 5, 5, 0, 0, -5],
+ bullet_colours=[RED],
+ bullet_colour_idx=0)
+
+ manager.register_component(e, components.EnemyEmitterCooldown(
+ min_shooter_cooldown, max_shooter_cooldown))
+ if random.randint(0, 1) == 1:
+ manager.register_component(e, components.LinearBulletEmitter(
+ data=enemy_bullet_data, direction=1))
+ else:
+ manager.register_component(e, components.RadialBulletEmitter(
+ data=enemy_bullet_data, bullet_count=4,
+ bullet_arc_offset=pi / 4))
+
+ ## have some enemies have 2 lives
+ if random.random() <= heavy_chance:
+ sprite = screen.draw_poly(enemy_vertices, fill=MAGENTA)
+ manager.register_component(e, components.ScreenElement(sprite,
+ enemy_vertices))
+ manager.register_component(e, components.Lives(2))
+ else:
+ sprite = screen.draw_poly(enemy_vertices, fill=YELLOW)
+ manager.register_component(e, components.ScreenElement(sprite,
+ enemy_vertices))
+ manager.register_component(e, components.Lives(1))
+
+
+ manager.register_component(enemy_spawner, components.Spawner(next_spawn(),
+ create_enemy))
+
+ ## make player visible
+ screen.raise_tag(player_sprite)
+
+ ## start processing the game
+ ecs.setup(manager, screen)
+ ecs.process(manager)
+ ecs.cleanup(manager, screen)
+
+ player_score = screen.get_score()
+ player_name = screen.get_name()
+
+ screen.destroy()
+ return (player_name, player_score)
+
+
+def load_scores(fpath):
+ scores = {}
+
+ if not os.path.isfile(fpath):
+ return {}
+
+ with open(fpath, 'r') as f:
+ for line in f:
+ segments = line.rsplit(',')
+ name, score = segments[0], int(segments[1].strip())
+ scores[name] = score
+
+ return scores
+
+
+def save_scores(fpath, scores):
+ with open(fpath, 'w') as f:
+ for name, score in scores.items():
+ f.write(f'{name},{score}\n')
+
+
+def main():
+ root = tk.Tk()
+ root.title('Bullet Purgatory')
+ root.protocol('WM_DELETE_WINDOW', lambda: root.destroy())
+
+ menu_elements = []
+
+ def disable_menu():
+ for element in menu_elements:
+ element.config(state='disabled')
+
+
+ def enable_menu():
+ for element in menu_elements:
+ element.config(state='normal')
+
+
+ def start_game(starting_score=0, starting_lives=5):
+ disable_menu()
+
+ game_window = tk.Toplevel(root)
+ game_window.title('Bullet Purgatory')
+
+ scores = load_scores(SCORE_FPATH)
+ name, score = run(game_window, starting_score, starting_lives)
+ scores[name] = max(score, scores.get(name, 0))
+
+ entries, highscores = len(scores), {}
+ for _ in range(entries if entries < 10 else 10):
+ keyfunc = lambda t: t[1]
+ highest_scoring = max(scores.items(), key=keyfunc)[0]
+
+ highscores[highest_scoring] = scores.pop(highest_scoring)
+
+ save_scores(SCORE_FPATH, highscores)
+
+ enable_menu()
+
+ root.protocol('WM_DELETE_WINDOW', lambda: root.destroy())
+
+
+ start_btn = tk.Button(root, text='Start Game', command=start_game)
+ start_btn.pack()
+ menu_elements.append(start_btn)
+
+ def show_leaderboard():
+ highscores = load_scores(SCORE_FPATH)
+
+ leaderboard_window = tk.Toplevel(root)
+ leaderboard_window.title('Bullet Purgatory Leaderboard')
+
+ header = tk.Label(leaderboard_window, text='FORMAT: <name> : <score>')
+ header.pack()
+ for name, score in highscores.items():
+ label = tk.Label(leaderboard_window, text=f'{name} : {score}')
+ label.pack()
+
+ close_btn = tk.Button(leaderboard_window, text='Close Leaderboard',
+ command=leaderboard_window.destroy)
+ close_btn.pack()
+
+
+ leaderboard_btn = tk.Button(root, text='Show Leaderboard',
+ command=show_leaderboard)
+ leaderboard_btn.pack()
+ menu_elements.append(leaderboard_btn)
+
+ def load_game():
+ state = load_gamestate(STATE_FPATH)
+
+ if gamestate_is_valid(state):
+ start_game(state['score'], state['lives'])
+ else:
+ start_game()
+
+
+ load_game_btn = tk.Button(root, text='Load Game', command=load_game)
+ load_game_btn.pack()
+ menu_elements.append(load_game_btn)
+
+ def show_help():
+ help_window = tk.Toplevel(root)
+ help_window.title('Bullet Purgatory Help (psst. Gradius called ;^))')
+
+ contents = tk.Label(help_window, text=HELP_CONTENTS)
+ contents.pack()
+
+ close_btn = tk.Button(help_window, text='Close Help',
+ command=help_window.destroy)
+ close_btn.pack()
+
+
+ show_help_btn = tk.Button(root, text='Show Help', command=show_help)
+ show_help_btn.pack()
+ menu_elements.append(show_help_btn)
+
+ root.mainloop()
+
+
+if __name__ == '__main__':
+ main()
+
diff --git a/pack.sh b/pack.sh
@@ -0,0 +1,12 @@
+#!/bin/sh
+
+OUT_FILE="game.zip"
+
+[ -f "${OUT_FILE}" ] && rm "${OUT_FILE}"
+
+[ -d ./__pycache__/ ] && rm -rf ./__pycache__/
+[ -d ./assets/__pycache__/ ] && rm -rf ./assets/__pycache__/
+[ -d ./src/__pycache__/ ] && rm -rf ./src/__pycache__/
+[ -d ./src/ecs/__pycache__/ ] && rm -rf ./src/ecs/__pycache__/
+
+zip "${OUT_FILE}" -r *
diff --git a/preprocess.py b/preprocess.py
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+
+from os import listdir, mkdir
+from os.path import isdir, isfile, join
+from PIL import Image
+from shutil import rmtree
+
+from src.common import HEIGHT, WIDTH
+
+
+BASE_DIR = './assets'
+OUT_DIR = join(BASE_DIR, 'out')
+
+
+def main():
+ if isdir(OUT_DIR):
+ rmtree(OUT_DIR)
+
+ mkdir(OUT_DIR)
+
+ for fpath in (join(BASE_DIR, f) for f in listdir(BASE_DIR) if isfile(join(BASE_DIR, f))):
+ fname = fpath.rsplit('/', 1)[-1].rsplit('.', 1)[0]
+
+ print(f'Fpath: {fpath}, Fname: {fname}')
+ print(f'Target size: {WIDTH}x{HEIGHT}')
+
+ image = Image.open(fpath)
+
+ resized = image.resize((WIDTH, HEIGHT))
+
+ resized.save(join(OUT_DIR, f'{fname}.gif'), format='GIF', save_all=True)
+
+
+if __name__ == '__main__':
+ main()
+
diff --git a/requirements.txt b/requirements.txt
@@ -0,0 +1,54 @@
+appdirs==1.4.4
+asgiref==3.2.10
+asn1crypto==1.4.0
+Beaker==1.11.0
+Brlapi==0.8.0
+CacheControl==0.12.6
+cffi==1.14.3
+chardet==3.0.4
+colorama==0.4.4
+contextlib2==0.6.0.post1
+cryptography==3.2.1
+distlib==0.3.1
+distro==1.5.0
+Django==3.1.1
+django-cors-headers==3.5.0
+djangorestframework==3.12.1
+filelock==3.0.12
+greenlet==0.4.17
+html5lib==1.1
+idna==2.10
+louis==3.15.0
+Mako==1.1.3
+Markdown==3.3
+MarkupSafe==1.1.1
+misaka==2.1.1
+msgpack==1.0.0
+numpy==1.19.2
+ordered-set==4.0.2
+packaging==20.4
+pep517==0.9.1
+Pillow==8.0.1
+ply==3.11
+profilehooks==1.12.0
+progress==1.5
+psutil==5.7.2
+pycparser==2.20
+PyGObject==3.38.0
+pynvim==0.4.2
+pyOpenSSL==19.1.0
+pyparsing==2.4.7
+pyserial==3.4
+python-libtorrent==1.2.10
+pytz==2020.1
+requests==2.24.0
+resolvelib==0.5.1
+retrying==1.3.3
+six==1.15.0
+sqlparse==0.4.1
+tk==0.1.0
+toml==0.10.2
+urllib3==1.25.10
+virtualenv==20.0.31
+webencodings==0.5.1
+Werkzeug==1.0.1
diff --git a/src/__init__.py b/src/__init__.py
diff --git a/src/common.py b/src/common.py
@@ -0,0 +1,128 @@
+import json
+import os
+
+from enum import Enum
+
+
+WIDTH, HEIGHT = 500, 800
+
+HELP_CONTENTS = (
+ 'Controls:\n'
+ 'w: move player ship up\n'
+ 'a: move player ship left\n'
+ 's: move player ship down\n'
+ 'd: move player ship right\n'
+ 'j: primary attack\n'
+ 'k: secondary attack\n'
+ 'Escape: pause menu\n'
+ 'Space: boss key'
+ )
+
+BOSS_KEY_IMAGE_FPATH = 'assets/out/boss_image_v2.gif'
+SCORE_FPATH = 'scores.scr'
+
+STATE_FPATH = 'state.stt'
+
+
+def gamestate_is_valid(state):
+ if state is None or \
+ state.get('score', None) is None or state.get('lives', None) is None:
+ return False
+
+ return True
+
+
+def pack_gamestate(score, lives):
+ if score is None or lives is None:
+ return None
+
+ return {'score': int(score), 'lives': int(lives)}
+
+
+def load_gamestate(fpath):
+ if not os.path.isfile(fpath):
+ return None
+
+ try:
+ with open(fpath, 'r') as f:
+ serialised = f.read()
+ d = json.loads(serialised)
+
+ score = d.get('score', None)
+ lives = d.get('lives', None)
+
+ return pack_gamestate(score, lives)
+
+ except Exception as err:
+ print(err)
+
+ return None
+
+
+def save_gamestate(fpath, score, lives):
+ with open(fpath, 'w') as f:
+ serialised = json.dumps(pack_gamestate(score, lives))
+ f.write(f'{serialised}\n')
+
+
+BLACK = '#000000'
+WHITE = '#ffffff'
+RED = '#ff0000'
+YELLOW = '#ffff00'
+GREEN = '#00ff00'
+CYAN = '#00ffff'
+BLUE = '#0000ff'
+MAGENTA = '#ff00ff'
+
+#SEVERITY = 2 ## turns off no messages
+#SEVERITY = 1 ## turns off debug messages
+#SEVERITY = 0 ## turns off debug and warning messages
+SEVERITY = -1 ## turns off all messages
+
+
+def debug(msg):
+ if SEVERITY >= 2:
+ print(f'DEBUG: {msg}')
+
+
+def warn(msg):
+ if SEVERITY >= 1:
+ print(f'WARNING: {msg}')
+
+
+def critical(msg):
+ if SEVERITY >= 0:
+ print(f'!!!CRITICAL!!!: {msg}')
+
+
+class AutoId:
+ """
+ Implements a stateful decorator to assign each decorated class a unique id.
+ """
+ _component_id = 0
+ _system_id = 0
+
+
+ @classmethod
+ def component(cls, wrapped_cls):
+ wrapped_cls.cid = cls._component_id
+ cls._component_id += 1
+
+ return wrapped_cls
+
+
+ @classmethod
+ def system(cls, wrapped_cls):
+ wrapped_cls.sid = cls._system_id
+ cls._system_id += 1
+
+ return wrapped_cls
+
+
+class EcsContinuation(Enum):
+ """
+ Whether the ECS system should continue to the next iteration or stop.
+ """
+ Stop = 0
+ Continue = 1
+
diff --git a/src/ecs/__init__.py b/src/ecs/__init__.py
diff --git a/src/ecs/components.py b/src/ecs/components.py
@@ -0,0 +1,237 @@
+import abc
+
+from dataclasses import dataclass
+from typing import Any, Callable, Generator, List, Tuple
+
+from ..common import *
+
+
+class Component(metaclass=abc.ABCMeta):
+ """
+ Defines the interface for a 'Component', with a unique id.
+ """
+ @classmethod
+ def __subclasshook__(cls, subclass):
+ return (
+ hasattr(subclass, 'cid') and type(subclass.cid) is type(int)
+ )
+
+ @abc.abstractmethod
+ def cid(self) -> int:
+ """
+ Returns a unique id for this component class.
+ """
+ raise NotImplementedError
+
+
+class ComponentBase:
+ """
+ Implements functionality common to each component.
+ """
+ def __hash__(self) -> int:
+ return self.cid
+
+
+@dataclass
+@AutoId.component
+class Transform2D(ComponentBase):
+ """
+ Holds the position (pixels), rotation (radians).
+ """
+ px: float
+ py: float
+ theta: float
+
+
+@dataclass
+@AutoId.component
+class Collider2D(ComponentBase):
+ """
+ Holds the size of the AABB in the x and y planes.
+ """
+ sx: float
+ sy: float
+
+
+@dataclass
+@AutoId.component
+class Collision(ComponentBase):
+ """
+ Stores a collision between 2 entities.
+ """
+ colliding_entity: int
+
+
+@dataclass
+@AutoId.component
+class Velocity2D(ComponentBase):
+ """
+ Holds the velocity (pixels per second) of an entity.
+ """
+ vx: float
+ vy: float
+
+
+@dataclass
+@AutoId.component
+class ScreenElement(ComponentBase):
+ """
+ Holds a handle to some screen element.
+ """
+ handle: int
+ vertices: List[int]
+
+
+@dataclass
+@AutoId.component
+class StaleTag(ComponentBase):
+ """
+ Marks an entity as being stale, and marks it to be be disposed of.
+ """
+ pass
+
+
+@dataclass
+@AutoId.component
+class EdgeHarm(ComponentBase):
+ """
+ Harms a given entity once it reaches the given y-coordinate.
+ """
+ target: int
+ py: int
+
+
+@dataclass
+@AutoId.component
+class UserInput(ComponentBase):
+ """
+ Receives input from peripherals.
+ """
+ speed: int
+
+
+@dataclass
+@AutoId.component
+class Score(ComponentBase):
+ """
+ Stores the current score that an entity has accrued.
+ """
+ count: int
+
+
+@dataclass
+@AutoId.component
+class Lives(ComponentBase):
+ """
+ Stores the number of lives that a given entitiy has.
+ """
+ count: int
+
+
+@dataclass
+@AutoId.component
+class Lifespan(ComponentBase):
+ """
+ Gives an entity a finite lifespan, after which it gets destroyed.
+ """
+ ttl: float
+
+
+@dataclass
+@AutoId.component
+class PlayerTag(ComponentBase):
+ """
+ Marks an entity as a player.
+ """
+ pass
+
+
+@dataclass
+@AutoId.component
+class EnemyTag(ComponentBase):
+ """
+ Marks an entity as an enemy.
+ """
+ pass
+
+
+@dataclass
+class BulletData:
+ """
+ Common bullet data.
+ """
+ bullet_size: int
+ bullet_speed: int
+ bullet_vertices: List[int]
+ bullet_colours: List[str]
+ bullet_colour_idx: int
+
+
+@dataclass
+@AutoId.component
+class BulletTag(ComponentBase):
+ """
+ Marks an entity as a bullet.
+ """
+ pass
+
+
+@dataclass
+@AutoId.component
+class LinearBulletEmitter(ComponentBase):
+ """
+ Emits bullets in a straight line.
+ """
+ data: BulletData
+ direction: int
+
+
+@dataclass
+@AutoId.component
+class FireLinearBulletEmitter(ComponentBase):
+ """
+ Used to emit a bullet from a linear emitter.
+ """
+ pass
+
+
+@dataclass
+@AutoId.component
+class RadialBulletEmitter(ComponentBase):
+ """
+ Emits bullets in an arc.
+ """
+ data: BulletData
+
+ bullet_count: int
+ bullet_arc_offset: float ## in radians
+
+
+@dataclass
+@AutoId.component
+class FireRadialBulletEmitter(ComponentBase):
+ """
+ Used to emit a bullet from a radial emitter.
+ """
+ pass
+
+
+@dataclass
+@AutoId.component
+class Spawner(ComponentBase):
+ """
+ Holds information for an entity spawner.
+ """
+ spawn_generator: Generator[Tuple[float, Tuple[int, int]], None, None]
+ instantiate: Callable[[Tuple[int, int], Any, Any], int]
+
+
+@dataclass
+@AutoId.component
+class EnemyEmitterCooldown(ComponentBase):
+ """
+ Gives an enemy bullet emitter a cooldown.
+ """
+ min_cooldown: float
+ max_cooldown: float
+
diff --git a/src/ecs/ecs.py b/src/ecs/ecs.py
@@ -0,0 +1,306 @@
+import time
+
+from dataclasses import dataclass
+from pprint import pprint
+from typing import Any, Dict, Generator, Iterable, List, Optional, Set, Tuple
+
+from ..common import *
+from .components import *
+from .systems import *
+
+
+def flatten_archetype(archetype):
+ return tuple(map(lambda c: c.cid, archetype))
+
+
+class EcsManager:
+ """
+ Manages an ECS game loop.
+ """
+ def __init__(self):
+ ## the current entity id value. Used to ensure each created entity
+ ## has a unique id. Increments whenever a new entity is added
+ self.__entity_id = 0
+
+ ## holds all currently registered entities, indexed by entity id
+ self.entities = set()
+
+ ## holds hashmaps of system action archetypes, indexed by system action
+ ## Is indexed by registered systems
+ self.systems = {}
+
+ ## holds hashmaps of component sets, indexed by the id of the entity
+ ## the components belong to. Is indexed by component archetype
+ self.archetypes = {}
+
+ ## holds hashmaps of components, indexed by the id of the entity the
+ ## component belongs to. Is indexed by the cid of the component held in
+ ## said hashmap
+ self.components = {}
+
+ ## the rate at which time flows
+ self._deltatime = 1
+
+
+ def register_system(self, system: System):
+ """
+ Registers a system for a component type. If the system is already
+ registered then this function is a no-op.
+ """
+ debug(f'Registering system type {system}')
+
+ if system not in self.systems:
+ self.systems[system] = {}
+
+ for action, action_archetype in system.actions().items():
+ archetype = flatten_archetype(action_archetype)
+ self.systems[system][action] = archetype
+
+ if archetype not in self.archetypes:
+ debug(f'New archetype encountered: {archetype}')
+ self.archetypes[archetype] = {}
+
+
+ def deregister_system(self, system: System):
+ """
+ Deregisters the given system.
+ """
+ debug(f'Deregistering system type {system}')
+
+ if (actionset := self.systems.pop(system, None)) is None:
+ return
+
+ for action, action_archetype in actionset.items():
+ for _, other_actionset in self.systems.items():
+ if action_archetype in other_actionset.values():
+ break
+ else:
+ ## if no other systems have the same archetype, we can remove
+ ## the archetype from the map
+ self.archetypes.pop(action_archetype, None)
+ debug(f'Deregistered stale archetype: {action_archetype}')
+
+
+ def create_entity(self) -> int:
+ """
+ Creates and starts tracking a new entity. Returns the created entity,
+ which can then have components registered on itself.
+ """
+ ## we create a new entity and assign it a unique postincremented id
+ entity = self.__entity_id
+ self.__entity_id += 1
+
+ ## we start tracking the entity
+ self.entities.add(entity)
+
+ return entity
+
+
+ def register_component(self, entity: int, component: Component) -> Component:
+ """
+ Registers the given component for the given entity. The entity will
+ have the component added to the list of processed components. If the
+ entity already had a component of the same type registered, the old
+ component will be returned. Otherwise, the newly set component will be
+ returned.
+ """
+ if entity not in self.entities:
+ critical(f'UNKNOWN ENTITY: {entity}')
+ raise ValueError('Attempted to register component for unknown entity')
+
+ component_type = component.cid
+ debug(f'Registering component type {component_type} for entity {entity}')
+
+ ## link the component and entity, so that we can later retrieve the
+ ## currently operated upon entity from a given component
+ component.eid = entity
+
+ ## if no component of the given type has yet been registered
+ if component_type not in self.components:
+ debug(f'New component type: {component_type}')
+ self.components[component_type] = {} ## create a hashmap for it
+
+ old_component = self.components[component_type].pop(entity, None)
+
+ ## no component of given type registered for the given entity
+ if old_component is None:
+ ## register the new component
+ self.components[component_type][entity] = component
+
+ ## we need to add the entity to any archetypes that need it
+ for archetype, bucket in self.archetypes.items():
+ if entity in bucket:
+ ## dont add entity to bucket twice to keep debug log clear
+ continue
+
+ values = [None] * len(archetype)
+ for idx, component_type in enumerate(archetype):
+ if (component_type not in self.components or
+ entity not in self.components[component_type]):
+ ## component type not registered, or entity doesnt
+ ## have the necessary component registered component
+ break
+ values[idx] = self.components[component_type][entity]
+ else:
+ ## by adding the components to the archetype bucket, we can
+ ## iterate through only the components we need when dealing
+ ## with the archetype later
+ bucket[entity] = tuple(values)
+ debug(f'Adding entity {entity} to archetype bucket: {archetype}')
+
+ ## return the registered component
+ return component
+
+ warn(f'Entity {entity} has existing component of type {component_type}!')
+
+ ## component already exists, so replace it and return old value
+ self.components[component_type][entity] = component
+ return old_component
+
+
+ def fetch_component(self, entity: int, component_type: int) -> Optional[Component]:
+ """
+ Returns the component of the given type, which was registered for the
+ given entity, if one exists. Otherwise, this method returns None.
+ """
+ if entity not in self.entities:
+ critical(f'UNKNOWN ENTITY: {entity}')
+ raise ValueError('Attempted to fetch component for unknown entity')
+
+ if component_type not in self.components:
+ return None
+
+ if entity not in self.components[component_type]:
+ return None
+
+ return self.components[component_type][entity]
+
+
+ def deregister_component(self, entity: int, component_type: int) -> Optional[Component]:
+ """
+ Deregisters the given component type for the given entity. The entity
+ will have its component removed from the list of processed components,
+ and the component will be returned. If no such component is registered,
+ this method will return None.
+ """
+ if entity not in self.entities:
+ critical(f'UNKNOWN ENTITY: {entity}')
+ raise ValueError('Attempted to deregister component for unknown entity')
+
+ if self.fetch_component(entity, component_type) is not None:
+ debug(f'Deregistering component type {component_type} for entity {entity}')
+ ## we need to deregister any stale component sets for this entity
+ ## from any archetypes where they were registered
+ for archetype, bucket in self.archetypes.items():
+ if component_type in archetype and entity in bucket:
+ value = bucket.pop(entity)
+
+ ## we return the old component that we just removed
+ return self.components[component_type].pop(entity)
+
+ return None
+
+
+ def destroy_entity(self, entity: int) -> List[Component]:
+ """
+ Removes an entity from the tracked entities list. It will no longer be
+ processed. Also removes the components associated with that entity.
+ This method returns a list of all components that were registered for
+ the given entity.
+ """
+ if entity not in self.entities:
+ critical(f'UNKNOWN ENTITY: {entity}')
+ raise ValueError('Attempted to destroy unknown entity')
+
+ registered_components = []
+ for component_type, components in self.components.items():
+ ## if the entity had a component of the given type registered
+ ## we add it to the list of components to return
+ if entity in components:
+ registered_components.append(components[entity])
+
+ ## we unregister all of our registered components
+ for component in registered_components:
+ self.deregister_component(entity, component.cid)
+
+ ## actually stop tracking the entity
+ self.entities.remove(entity)
+
+ return registered_components
+
+
+ def fetch_archetype(self, archetype: Tuple[type, ...]) -> Optional[Iterable[Tuple[type, ...]]]:
+ """
+ Fetch all component sets for the given archetype and return them.
+ """
+ if archetype not in self.archetypes:
+ warn(f'Tried to fetch unknown archetype {archetype}!')
+ return None
+
+ return self.archetypes[archetype].values()
+
+
+ def get_deltatime(self):
+ return self._deltatime
+
+
+ def pause(self):
+ self._deltatime = 0
+
+
+ def unpause(self):
+ self._deltatime = 1
+
+
+def setup(manager: EcsManager, screen: Screen):
+ """
+ Performs one-time initialisation for all registered systems.
+ """
+ for system, _ in manager.systems.items():
+ debug(f'Performing setup for system {system}')
+ system.setup(manager, screen)
+
+
+def cleanup(manager: EcsManager, screen: Screen):
+ """
+ Performs one-time cleanup for all registered systems.
+ """
+ for system, _ in manager.systems.items():
+ debug(f'Performing cleanup for system {system}')
+ system.cleanup(manager, screen)
+
+ pending_systems = [s for s in manager.systems.keys()]
+ for system in pending_systems:
+ manager.deregister_system(system)
+
+ pending_entities = [e for e in manager.entities]
+ for entity in pending_entities:
+ manager.destroy_entity(entity)
+
+
+def process(manager: EcsManager):
+ """
+ Processes systems until an EcsContinuation.Stop is returned.
+ """
+ if len(manager.systems) == 0:
+ warn(f'No systems have been registered!')
+ return
+
+ last_tick_time = time.time()
+ while True:
+ current_tick_time = time.time()
+ dt = (current_tick_time - last_tick_time) * manager.get_deltatime()
+
+ for archetype, component_sets in manager.archetypes.items():
+ for system, actionset in manager.systems.items():
+ for action, action_archetype in actionset.items():
+ if archetype != action_archetype:
+ continue
+
+ components = list(component_sets.values())
+ if action(dt, manager, components) == EcsContinuation.Stop:
+ debug(f'System action {action} stopped ECS')
+ return
+
+ last_tick_time = current_tick_time
+
diff --git a/src/ecs/systems.py b/src/ecs/systems.py
@@ -0,0 +1,793 @@
+import abc
+import random
+import time
+
+from math import cos, pi, sin
+#from profilehooks import profile
+from typing import Any, Callable, Dict, Iterator, Tuple, TypeVar
+
+from ..common import *
+from .components import *
+from ..platform import *
+
+
+## takes dt (seconds), manager (EcsManager instance), components
+SystemAction = Callable[[float, Any, Iterator[Tuple[Component, ...]]], EcsContinuation]
+
+
+class System(metaclass=abc.ABCMeta):
+ """
+ Defines the interface for a 'System', handling components of a single type.
+ """
+ @classmethod
+ def __subclasshook__(cls, subclass):
+ return (
+ hasattr(subclass, 'actions') and callable(subclass.actions) and
+ hasattr(subclass, 'setup') and callable(subclass.setup) and
+ hasattr(subclass, 'cleanup') and callable(subclass.cleanup)
+ )
+
+
+ @abc.abstractmethod
+ def actions(self) -> Dict[SystemAction, Tuple[type, ...]]:
+ """
+ Returns a tuple containing the component types this system operates on.
+ In other words, returns the component archetype of this system.
+ """
+ raise NotImplementedError
+
+
+ @abc.abstractmethod
+ def setup(self, manager: Any, screen: Screen):
+ """
+ Perform one-time initialisation of the system.
+ """
+ raise NotImplementedError
+
+
+ @abc.abstractmethod
+ def cleanup(self, manager: Any, screen: Screen):
+ """
+ Perform one-time cleanup of the system before shutdown.
+ """
+ raise NotImplementedError
+
+
+class SystemBase:
+ """
+ Implements functionality common to each system.
+ """
+ def __hash__(self) -> int:
+ return self.sid
+
+
+@AutoId.system
+class Render2DSystem(SystemBase):
+ """
+ System to handle rendering code.
+ """
+ def actions(self):
+ return {
+ self.process_active: (Transform2D, ScreenElement),
+ self.process_stale: (StaleTag,),
+ }
+
+
+ def setup(self, manager, screen):
+ self.screen = screen
+
+
+ def process_active(self, dt, manager, components) -> EcsContinuation:
+ for transform, element in components:
+ coords = [None] * len(element.vertices)
+
+ ## translate coords into screen space
+ for i in range(len(element.vertices)):
+ if i % 2 == 0: ## even-indexed elements are x coords
+ coords[i] = element.vertices[i] + transform.px
+ else: ## odd-indexed elements are y coords
+ coords[i] = element.vertices[i] + transform.py
+
+ self.screen.set_coords(element.handle, coords)
+
+ self.screen.tick(dt, manager)
+
+ return EcsContinuation.Continue
+
+
+ def process_stale(self, dt, manager, components) -> EcsContinuation:
+ for tag, in components:
+ entity = tag.eid
+ if (element := manager.fetch_component(entity, ScreenElement.cid)) is not None:
+ self.screen.remove(element.handle)
+ manager.destroy_entity(entity)
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class Collider2DSystem(SystemBase):
+ """
+ System to flag colliding objects (right now we only process bullet collisions).
+ """
+ def actions(self):
+ return {
+ self.process: (Transform2D, Collider2D),
+ }
+
+
+ def setup(self, manager, screen):
+ pass
+
+
+ def do_intersect(self, transform_a, collider_a, transform_b, collider_b):
+ if transform_a.px - collider_a.sx / 2 < transform_b.px + collider_b.sx / 2 and \
+ transform_a.px + collider_a.sx / 2 > transform_b.px - collider_b.sx / 2 and \
+ transform_a.py - collider_a.sy / 2 < transform_b.py + collider_b.sy / 2 and \
+ transform_a.py + collider_a.sy / 2 > transform_b.py - collider_b.sy / 2:
+ return True
+
+ return False
+
+ ## TODO: implement spatial hashing
+ #@profile
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for idx, (transform_a, collider_a) in enumerate(components):
+ entity_a = transform_a.eid
+
+ for transform_b, collider_b in components[idx:]:
+ entity_b = transform_b.eid
+
+ if entity_a == entity_b:
+ continue
+
+ ## only register collisions between a bullet and non-bullet
+ bullet_a = manager.fetch_component(entity_a, BulletTag.cid)
+ bullet_b = manager.fetch_component(entity_b, BulletTag.cid)
+ if (bullet_a is not None and bullet_b is not None) or \
+ (bullet_a is None and bullet_b is None):
+ continue
+
+ bullet = entity_a if bullet_a is not None else entity_b
+ other = entity_b if bullet_a is not None else entity_a
+
+ if self.do_intersect(transform_a, collider_a, transform_b, collider_b):
+ ## TODO: consider splitting collision resolution into separate system?
+ #manager.register_component(entity_a, Collision(entity_b))
+ #manager.register_component(entity_b, Collision(entity_a))
+
+ if (lives := manager.fetch_component(other, Lives.cid)) is not None:
+ bullet_player_tag = manager.fetch_component(bullet, PlayerTag.cid)
+ bullet_enemy_tag = manager.fetch_component(bullet, EnemyTag.cid)
+ other_player_tag = manager.fetch_component(other, PlayerTag.cid)
+ other_enemy_tag = manager.fetch_component(other, EnemyTag.cid)
+
+ if bullet_player_tag != other_player_tag or \
+ bullet_enemy_tag != other_enemy_tag:
+ lives.count -= 1
+ manager.register_component(bullet, StaleTag())
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class EdgeHarmSystem(SystemBase):
+ """
+ System to manage edge harm components.
+ """
+ def actions(self):
+ return {
+ self.process: (Transform2D, Collider2D, EdgeHarm),
+ }
+
+
+ def setup(self, manager, screen):
+ pass
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for transform, collider, edge_harm in components:
+ entity = transform.eid
+ dy = collider.sy // 2
+
+ if edge_harm.py - dy < transform.py:
+ target_lives = manager.fetch_component(edge_harm.target, Lives.cid)
+ if target_lives is not None:
+ target_lives.count -= 1
+
+ manager.register_component(entity, StaleTag())
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class Physics2DSystem(SystemBase):
+ """
+ System to handle 2D physics.
+ """
+ def actions(self):
+ return {
+ self.process: (Transform2D, Velocity2D),
+ }
+
+
+ def setup(self, manager, screen):
+ pass
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for transform, velocity in components:
+ transform.px += velocity.vx * dt
+ transform.py += velocity.vy * dt
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class UserInputSystem(SystemBase):
+ """
+ System to handle user input (keypresses).
+ """
+ def actions(self):
+ return {
+ self.process: (Transform2D, Collider2D, Velocity2D, UserInput),
+ }
+
+
+ def setup(self, manager, screen):
+ ## the bits which should be set when the control event is raised
+ self.input_masks = {
+ 'left': 0b00000001,
+ 'right': 0b00000010,
+ 'up': 0b00000100,
+ 'down': 0b00001000,
+ 'fire_primary': 0b00010000,
+ 'fire_secondary': 0b00100000,
+ 'menu': 0b01000000,
+ 'boss': 0b10000000,
+ }
+
+ ## the bits which should be unset when the control event is raised
+ self.input_reset_masks = {
+ 'left': 0b11111101,
+ 'right': 0b11111110,
+ 'up': 0b11110111,
+ 'down': 0b11111011,
+ 'fire_primary': 0b11111111,
+ 'fire_secondary': 0b11111111,
+ 'menu': 0b11111111,
+ 'boss': 0b11111111,
+ }
+
+ ## maps a key to a control event
+ self.controls = {
+ 'escape': 'menu',
+ 'space': 'boss',
+ 'a': 'left',
+ 'd': 'right',
+ 'w': 'up',
+ 's': 'down',
+ 'j': 'fire_primary',
+ 'k': 'fire_secondary',
+ }
+
+ ## Gradius is that you?!?
+ self.completed_easter_egg = False
+ self.easter_egg_idx = 0
+ self.easter_egg = [
+ 'up',
+ 'up',
+ 'down',
+ 'down',
+ 'left',
+ 'right',
+ 'left',
+ 'right',
+ 'b',
+ 'a']
+
+ ## the current input value
+ self.input_bitmask = 0
+
+ ## bitmask used to get the inverse input_mask
+ self.inverse_bitmask = 0b11111111
+
+ self.primary_cooldown = 0.15
+ self.can_shoot_primary = True
+
+ self.secondary_cooldown = 5
+ self.can_shoot_secondary = True
+
+ self.boss_key_cooldown = 0.2
+ self.can_toggle_boss_key = True
+
+ self.menu_key_cooldown = 0.2
+ self.can_toggle_menu_key = True
+
+ self.currently_paused = False
+ self.pause_initiator = ''
+
+ self.screen = screen
+ self.screen.set_event_handler(f'<KeyPress>',
+ lambda e: self.handle_key_pressed(e))
+ self.screen.set_event_handler(f'<KeyRelease>',
+ lambda e: self.handle_key_released(e))
+ self.screen.set_proto_handler('WM_DELETE_WINDOW',
+ lambda: self.handle_window_closed())
+ self.screen.set_quit_handler(lambda: self.handle_window_closed())
+
+ self.continuation = EcsContinuation.Continue
+
+
+ def reset_boss_key(self):
+ self.can_toggle_boss_key = True
+
+
+ def reset_menu_key(self):
+ self.can_toggle_menu_key = True
+
+
+ def reset_primary(self):
+ self.can_shoot_primary = True
+
+
+ def reset_secondary(self):
+ self.can_shoot_secondary = True
+
+
+ def check_mask(self, event):
+ return (self.input_bitmask & self.input_masks[event]) == self.input_masks[event]
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ e = self.input_bitmask
+
+ for transform, collider, velocity, user_input in components:
+ entity = transform.eid
+
+ if self.completed_easter_egg:
+ print('GAMER MOMENT')
+ lives = manager.fetch_component(entity, Lives.cid)
+ if lives is not None:
+ lives.count = 30
+ self.completed_easter_egg = False
+
+ if self.check_mask('menu'):
+ if self.can_toggle_menu_key:
+ if not self.currently_paused:
+ manager.pause()
+ self.currently_paused = True
+ self.pause_initiator = 'menu'
+ elif self.currently_paused and self.pause_initiator == 'menu':
+ manager.unpause()
+ self.currently_paused = False
+
+ ## allow 1 menu at one time
+ if self.pause_initiator == 'menu':
+ self.screen.toggle_menu()
+
+ self.can_toggle_menu_key = False
+ self.screen.do_after(self.menu_key_cooldown,
+ lambda: self.reset_menu_key())
+
+ if self.check_mask('boss'):
+ if self.can_toggle_boss_key:
+ if not self.currently_paused:
+ manager.pause()
+ self.currently_paused = True
+ self.pause_initiator = 'boss'
+ elif self.currently_paused and self.pause_initiator == 'boss':
+ manager.unpause()
+ self.currently_paused = False
+
+ ## allow 1 menu at one time
+ if self.pause_initiator == 'boss':
+ self.screen.toggle_boss_image()
+
+ self.can_toggle_boss_key = False
+ self.screen.do_after(self.boss_key_cooldown,
+ lambda: self.reset_boss_key())
+
+ vx, vy = 0, 0
+ if self.check_mask('left'):
+ vx = -1
+ if self.check_mask('right'):
+ vx = 1
+ if self.check_mask('up'):
+ vy = -1
+ if self.check_mask('down'):
+ vy = 1
+
+ ## we normalise the entities velocity vector to ensure that the
+ ## entity cannot use diagonal movement to break the speed barrier
+ magnitude = (vx * vx + vy * vy) ** 0.5
+ if magnitude:
+ vx = (vx / magnitude) * user_input.speed
+ vy = (vy / magnitude) * user_input.speed
+ else:
+ vx *= user_input.speed
+ vy *= user_input.speed
+
+ ## clamp the user to the playing field
+ dx, dy = collider.sx // 2, collider.sy // 2
+ if transform.px < dx:
+ transform.px += 1
+ vx = 0
+ elif WIDTH - dx < transform.px:
+ transform.px -= 1
+ vx = 0
+ if transform.py < dy:
+ transform.py += 1
+ vy = 0
+ elif HEIGHT - dy < transform.py:
+ transform.py -= 1
+ vy = 0
+
+ ## actually assign the velocity
+ velocity.vx = vx
+ velocity.vy = vy
+
+ if self.check_mask('fire_primary'):
+ if self.can_shoot_primary:
+ manager.register_component(entity, FireLinearBulletEmitter())
+
+ self.can_shoot_primary = False
+ self.screen.do_after(self.primary_cooldown,
+ lambda: self.reset_primary())
+
+ if self.check_mask('fire_secondary'):
+ if self.can_shoot_secondary:
+ manager.register_component(entity, FireRadialBulletEmitter())
+
+ self.can_shoot_secondary = False
+ self.screen.do_after(self.secondary_cooldown,
+ lambda: self.reset_secondary())
+
+ return self.continuation
+
+
+ def move_next_code_elem(self, key):
+ if self.easter_egg_idx == -1: ## disallow multiple code inputs
+ return
+
+ if key != self.easter_egg[self.easter_egg_idx]:
+ self.easter_egg_idx = 0
+ return
+
+ self.easter_egg_idx += 1
+
+ if self.easter_egg_idx == len(self.easter_egg):
+ self.easter_egg_idx = -1
+ self.completed_easter_egg = True
+
+
+ def handle_key_pressed(self, event):
+ self.move_next_code_elem(event.keysym.lower())
+
+ if event.keysym in self.controls:
+ binding = self.controls[event.keysym]
+ elif event.keysym.lower() in self.controls:
+ binding = self.controls[event.keysym.lower()]
+ else:
+ return
+
+ if binding in self.input_masks and binding in self.input_reset_masks:
+ self.input_bitmask &= self.input_reset_masks[binding]
+ self.input_bitmask |= self.input_masks[binding]
+
+
+ def handle_key_released(self, event):
+ if event.keysym in self.controls:
+ binding = self.controls[event.keysym]
+ elif event.keysym.lower() in self.controls:
+ binding = self.controls[event.keysym.lower()]
+ else:
+ return
+
+ if binding in self.input_masks and binding in self.input_reset_masks:
+ self.input_bitmask &= self.inverse_bitmask ^ self.input_masks[binding]
+
+
+ def handle_window_closed(self):
+ self.continuation = EcsContinuation.Stop
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class LifeSystem(SystemBase):
+ """
+ Clears dead entities.
+ """
+ def actions(self):
+ return {
+ self.process: (Lives,),
+ }
+
+
+ def setup(self, manager, screen):
+ self.screen = screen
+ self.continuation = EcsContinuation.Continue
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ player = -1
+ score_delta = 0
+
+ for lives, in components:
+ entity = lives.eid
+
+ player_tag = manager.fetch_component(entity, PlayerTag.cid)
+
+ if player_tag is not None:
+ player = entity
+
+ if lives.count <= 0:
+ if player_tag is not None:
+ manager.pause()
+ self.screen.set_tracked_entity(-1)
+
+ def callback():
+ self.continuation = EcsContinuation.Stop
+
+ self.screen.toggle_gameover(callback)
+
+ if manager.fetch_component(entity, EnemyTag.cid):
+ score_delta += 1
+
+ manager.register_component(entity, StaleTag())
+
+ if player != -1:
+ player_score = manager.fetch_component(player, Score.cid)
+ player_score.count += score_delta
+
+ return self.continuation
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class LifespanSystem(SystemBase):
+ """
+ System to kill components that exceed their lifespan.
+ """
+ def actions(self):
+ return {
+ self.process: (Lifespan,),
+ }
+
+
+ def setup(self, manager, screen):
+ pass
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for lifespan, in components:
+ entity = lifespan.eid
+
+ lifespan.ttl -= dt
+
+ if lifespan.ttl < 0:
+ manager.register_component(entity, StaleTag())
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class BulletEmitterSystem(SystemBase):
+ """
+ Controls entities with a bullet emitter.
+ """
+ def actions(self):
+ return {
+ self.process_linear: (Transform2D, Collider2D, LinearBulletEmitter),
+ self.process_radial: (Transform2D, Collider2D, RadialBulletEmitter),
+ }
+
+
+ def setup(self, manager, screen):
+ self.screen = screen
+
+
+ def create_bullet(self, manager, bullet_transform, bullet_velocity, bullet_data):
+ bullet = manager.create_entity()
+
+ bullet_collider = Collider2D(
+ bullet_data.bullet_size, bullet_data.bullet_size)
+
+ bullet_screen_element = self.screen.draw_poly(
+ bullet_data.bullet_vertices,
+ fill=bullet_data.bullet_colours[bullet_data.bullet_colour_idx])
+
+ bullet_data.bullet_colour_idx += 1
+ bullet_data.bullet_colour_idx %= len(bullet_data.bullet_colours)
+
+ bullet_sprite = ScreenElement(
+ bullet_screen_element, bullet_data.bullet_vertices)
+
+ ## ensure that bullets can travel the diagonal of the playfield at least
+ lifespan = (((HEIGHT ** 2) + (WIDTH ** 2)) ** 0.5) / abs(bullet_data.bullet_speed)
+ bullet_lifespan = Lifespan(lifespan)
+
+ manager.register_component(bullet, bullet_transform)
+ manager.register_component(bullet, bullet_collider)
+ manager.register_component(bullet, bullet_velocity)
+ manager.register_component(bullet, bullet_lifespan)
+ manager.register_component(bullet, bullet_sprite)
+ manager.register_component(bullet, BulletTag())
+
+ return bullet
+
+
+ def process_linear(self, dt, manager, components) -> EcsContinuation:
+ if dt == 0: ## dont shoot when paused
+ return EcsContinuation.Continue
+
+ for transform, collider, emitter in components:
+ entity = transform.eid
+
+ if (tag := manager.fetch_component(entity, FireLinearBulletEmitter.cid)) is None:
+ continue
+
+ manager.deregister_component(entity, tag.cid)
+
+ bullet_transform = Transform2D(transform.px, transform.py, 0)
+
+ vx, vy = 0, emitter.data.bullet_speed
+
+ bullet_velocity = Velocity2D(vx, vy)
+
+ bullet = self.create_bullet(
+ manager, bullet_transform, bullet_velocity, emitter.data)
+
+ if manager.fetch_component(entity, PlayerTag.cid) is not None:
+ manager.register_component(bullet, PlayerTag())
+ elif manager.fetch_component(entity, EnemyTag.cid) is not None:
+ manager.register_component(bullet, EnemyTag())
+
+ return EcsContinuation.Continue
+
+
+ def process_radial(self, dt, manager, components) -> EcsContinuation:
+ if dt == 0: ## dont shoot when paused
+ return EcsContinuation.Continue
+
+ for transform, collider, emitter in components:
+ entity = transform.eid
+
+ if (tag := manager.fetch_component(entity, FireRadialBulletEmitter.cid)) is None:
+ continue
+
+ manager.deregister_component(entity, tag.cid)
+
+ sweep_increment = 2 * pi / emitter.bullet_count
+
+ bullet_data = emitter.data
+
+ for n in range(emitter.bullet_count):
+ bullet_transform = Transform2D(transform.px, transform.py, 0)
+
+ theta = emitter.bullet_arc_offset + (n * sweep_increment)
+
+ ## Clockwise Rotation matrix:
+ ## [ cos0 sin0 ] [ x ] = [ x*cos0 + y*sin0 ]
+ ## [ -sin0 cos0 ] [ y ] [ -x*sin0 + y*cos0 ]
+ ## Since we only have a 1-dimensional 'speed', we assign it to
+ ## 'y' (completely arbitrarily) and then calculate the [vx vy]
+ ## vector for the rotated projectile (we assume x = 0)
+ vx, vy = sin(theta), cos(theta)
+
+ speed = emitter.data.bullet_speed
+ bullet_velocity = Velocity2D(vx * speed, vy * speed)
+
+ bullet = self.create_bullet(
+ manager, bullet_transform, bullet_velocity, bullet_data)
+
+ if manager.fetch_component(entity, PlayerTag.cid) is not None:
+ manager.register_component(bullet, PlayerTag())
+ elif manager.fetch_component(entity, EnemyTag.cid) is not None:
+ manager.register_component(bullet, EnemyTag())
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class SpawnerSystem(SystemBase):
+ """
+ Manages all of the spawners in the game.
+ """
+ def actions(self):
+ return {
+ self.process: (Spawner,),
+ }
+
+
+ def setup(self, manager, screen):
+ self.screen = screen
+ self.cooldowns = {}
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for spawner, in components:
+ entity = spawner.eid
+ cooldown_remaining = self.cooldowns.get(entity, 0)
+
+ cooldown_remaining -= dt
+ if cooldown_remaining <= 0:
+ cooldown_remaining, (spawn_px, spawn_py) = next(spawner.spawn_generator)
+ new_entity = spawner.instantiate((spawn_px, spawn_py), manager, self.screen)
+
+ self.cooldowns[entity] = cooldown_remaining
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
+
+@AutoId.system
+class EnemyEmitterSystem(SystemBase):
+ """
+ Manages enemies with bullet emitters.
+ """
+ def actions(self):
+ return {
+ self.process: (EnemyEmitterCooldown,),
+ }
+
+
+ def setup(self, manager, screen):
+ self.cooldowns = {}
+
+
+ def process(self, dt, manager, components) -> EcsContinuation:
+ for emitter_cooldown, in components:
+ entity = emitter_cooldown.eid
+
+ remaining_cooldown = self.cooldowns.get(entity, 0)
+
+ remaining_cooldown -= dt
+ if remaining_cooldown <= 0:
+ manager.register_component(entity, FireLinearBulletEmitter())
+ manager.register_component(entity, FireRadialBulletEmitter())
+
+ remaining_cooldown = random.randint(
+ emitter_cooldown.min_cooldown, emitter_cooldown.max_cooldown)
+
+ self.cooldowns[entity] = remaining_cooldown
+
+ return EcsContinuation.Continue
+
+
+ def cleanup(self, manager, screen):
+ pass
+
diff --git a/src/platform.py b/src/platform.py
@@ -0,0 +1,253 @@
+import tkinter as tk
+
+from base64 import b64encode
+from typing import List, Optional
+
+from .common import *
+from .ecs.components import Lives, Score
+
+
+__all__ = ['Screen']
+
+
+def load_image(fpath, **kwargs) -> Optional[tk.PhotoImage]:
+ try:
+ with open(fpath, 'rb') as f:
+ data = f.read()
+ return tk.PhotoImage(data=b64encode(data), **kwargs)
+ except Exception as err:
+ critical(err)
+ return None
+
+
+class Screen():
+ """
+ Abstraction over the raw tkinter canvas.
+ """
+ def __init__(self, root, **kwargs):
+ self.root = root
+ self.canvas = tk.Canvas(root,
+ width=WIDTH,
+ height=HEIGHT,
+ bg=BLACK,
+ **kwargs)
+ self.canvas.pack()
+
+ self.fps = self.canvas.create_text(
+ 5, 5, anchor='nw', fill=WHITE)
+ self.objects = self.canvas.create_text(
+ 5, 20, anchor='nw', fill=WHITE)
+ self.score = self.canvas.create_text(
+ WIDTH - 5, 5, anchor='ne', fill=WHITE)
+ self.lives = self.canvas.create_text(
+ WIDTH - 5, 20, anchor='ne', fill=WHITE)
+
+ self._tracked_entity = -1
+ self._tracked_score = 0
+ self._tracked_lives = 0
+ self.player_name = 'John Doe'
+
+ self.boss_image = self.canvas.create_image(1, 1, anchor='nw',
+ state='hidden', tags='boss-key-img')
+ self.boss_image_shown = False
+
+ self.boss_image_data = load_image(BOSS_KEY_IMAGE_FPATH)
+ if self.boss_image_data is not None:
+ debug('Loaded boss-key image')
+ self.update(self.boss_image, image=self.boss_image_data)
+
+ self.menu_shown = False
+
+ self.menu_bg = self.canvas.create_rectangle(
+ WIDTH * 0.25, HEIGHT * 0.25,
+ WIDTH * 0.75, HEIGHT * 0.75,
+ fill=BLACK, state='hidden', tags='menu')
+
+ menu_title_fontsize = 13
+ self.menu_title = self.canvas.create_text(
+ WIDTH * 0.5, HEIGHT * 0.25 + menu_title_fontsize,
+ text='Paused', font=menu_title_fontsize, anchor='center',
+ fill=WHITE, state='hidden', tags='menu')
+
+ self.menu_quit_btn_callback = lambda: None
+ self.menu_quit_btn = self.canvas.create_window(
+ WIDTH * 0.3, HEIGHT * 0.3,
+ width=WIDTH * 0.4, height=20,
+ window=tk.Button(self.canvas, text='Quit', bg=RED,
+ command=lambda: self.menu_quit_btn_callback()),
+ anchor='nw', state='hidden', tag='menu')
+
+ self.menu_save_btn_callback = lambda: self.save_state()
+ self.menu_save_btn = self.canvas.create_window(
+ WIDTH * 0.3, HEIGHT * 0.3 + 30,
+ width=WIDTH * 0.4, height=20,
+ window=tk.Button(self.canvas, text='Save', bg=RED,
+ command=lambda: self.menu_save_btn_callback()),
+ anchor='nw', state='hidden', tag='menu')
+
+ self.controls_text = self.canvas.create_text(
+ WIDTH * 0.3, HEIGHT * 0.4,
+ width=WIDTH * 0.4, text=HELP_CONTENTS, fill=WHITE,
+ anchor='nw', state='hidden', tag='menu')
+
+
+ def tick(self, dt: float, manager):
+ if self._tracked_entity != -1:
+ self._tracked_lives = manager.fetch_component(
+ self._tracked_entity, Lives.cid).count
+ self._tracked_score = manager.fetch_component(
+ self._tracked_entity, Score.cid).count
+
+ self.canvas.itemconfig(self.score,
+ text=f'SCORE: {self._tracked_score:9}')
+ self.canvas.itemconfig(self.lives,
+ text=f'LIVES: {self._tracked_lives:9}')
+
+ if dt != 0:
+ self.canvas.itemconfig(self.fps, text=f'FPS: {1/dt:9.3f}')
+
+ self.canvas.itemconfig(self.objects,
+ text=f'OBJ: {len(manager.entities):9}')
+
+ self.root.update()
+
+
+ def set_tracked_entity(self, entity):
+ self._tracked_entity = entity
+
+
+ def get_score(self):
+ return self._tracked_score
+
+
+ def get_lives(self):
+ return self._tracked_lives
+
+
+ def get_name(self):
+ return self.player_name
+
+
+ def draw_text(self, x, y, content, **kwargs):
+ return self.canvas.create_text(x, y, text=content, **kwargs)
+
+
+ def draw_image(self, x, y, **kwargs):
+ return self.canvas.create_image(x, y, **kwargs)
+
+
+ def draw_poly(self, vertices: List[int], **kwargs):
+ return self.canvas.create_polygon(*vertices, **kwargs)
+
+
+ def rect_vertices(self, sx, sy):
+ x0, y0 = -(sx // 2), -(sy // 2)
+ x1, y1 = sx // 2, sy // 2
+
+ return [
+ x0, y0,
+ x1, y0,
+ x1, y1,
+ x0, y1
+ ]
+
+
+ def raise_tag(self, tag):
+ self.canvas.tag_raise(tag)
+
+
+ def lower_tag(self, tag):
+ self.canvas.tag_lower(tag)
+
+
+ def update(self, handle, *args, **kwargs):
+ self.canvas.itemconfig(handle, *args, **kwargs)
+
+
+ def get_coords(self, handle, **kwargs):
+ return self.canvas.coords(handle)
+
+
+ def set_coords(self, handle, coords: List[int], **kwargs):
+ self.canvas.coords(handle, *coords, **kwargs)
+
+
+ def remove(self, handle):
+ self.canvas.delete(handle)
+
+
+ def remove_all(self):
+ self.canvas.delete(tk.ALL)
+
+
+ def set_event_handler(self, event, handler):
+ self.root.bind(event, handler)
+
+
+ def set_proto_handler(self, protocol, handler):
+ self.root.protocol(protocol, handler)
+
+
+ def set_quit_handler(self, callback):
+ self.menu_quit_btn_callback = callback
+
+
+ def do_after(self, seconds, callback):
+ self.canvas.after(int(seconds * 1000), callback)
+
+
+ def save_state(self):
+ save_gamestate(STATE_FPATH, self._tracked_score, self._tracked_lives)
+
+
+ def toggle_boss_image(self):
+ self.raise_tag('boss-key-img')
+ if self.boss_image_shown:
+ self.update(self.boss_image, state='hidden')
+ self.boss_image_shown = False
+ else:
+ self.update(self.boss_image, state='normal')
+ self.boss_image_shown = True
+
+
+ def toggle_menu(self):
+ self.raise_tag('menu')
+
+ menu_elements = self.canvas.find_withtag('menu')
+
+ if self.menu_shown:
+ for element in menu_elements:
+ self.update(element, state='hidden')
+
+ self.menu_shown = False
+ else:
+ for element in menu_elements:
+ self.update(element, state='normal')
+
+ self.menu_shown = True
+
+
+ def toggle_gameover(self, finished_callback):
+ self.draw_text(WIDTH / 2, HEIGHT / 2, 'GAME OVER', font='20', fill=RED,
+ anchor='center')
+
+ highscore_window = tk.Toplevel(self.root)
+ highscore_window.title('Enter your name')
+
+ name_variable = tk.StringVar()
+ name_variable.set(self.player_name)
+
+ name_entry = tk.Entry(highscore_window, textvariable=name_variable)
+ name_entry.pack()
+
+ def submit():
+ self.player_name = name_variable.get()
+ finished_callback()
+
+ finish_btn = tk.Button(highscore_window, text='Submit', command=submit)
+ finish_btn.pack()
+
+
+ def destroy(self):
+ self.root.destroy()
+