pyecs

pyecs.git
git clone git://git.lenczewski.org/pyecs.git
Log | Files | Refs | README | LICENSE

commit bf276e620c8300d0fe5c5e361b24bebd93558694
Author: MikoĊ‚aj Lenczewski <mikolaj.lenczewski308@gmail.com>
Date:   Wed,  9 Dec 2020 10:34:17 +0000

Imported repository

Diffstat:
A.gitignore | 2++
ALICENSE | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
AREADME.md | 8++++++++
Aassets/boss_image.jpg | 0
Aassets/boss_image_v2.jpg | 0
Aassets/out/boss_image.gif | 0
Aassets/out/boss_image_v2.gif | 0
Amain.py | 361+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apack.sh | 12++++++++++++
Apreprocess.py | 36++++++++++++++++++++++++++++++++++++
Arequirements.txt | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/__init__.py | 0
Asrc/common.py | 128+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ecs/__init__.py | 0
Asrc/ecs/components.py | 237+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ecs/ecs.py | 306+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/ecs/systems.py | 793+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/platform.py | 253+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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() +