diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 737ea52..22f6152 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: Automated Tests on: push: @@ -17,18 +17,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.13' + python-version: '3.10.8' - name: Install dependencies run: | python -m pip install --upgrade pip - pip install black pytest + pip install pytest numpy line_profiler - name: Set up PYTHONPATH run: echo "PYTHONPATH=$PYTHONPATH:$(pwd)" >> $GITHUB_ENV - - name: FORMATTING - Run black - run: black . - - name: TESTS - Run tests run: pytest --disable-warnings \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d31a84..1eb2494 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Changed - Refactor `HemeraTermFx` to further optimize terminal printing performance. +- Update `NyxEngine` as the main enforcer of game state consistency across the project. It now holds all instances of the major subclasses. --- @@ -22,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Add an alient planet sprite to the current game demo in `main.py`. ### Changed -- Optimize terminal printing string generation for a 95% reduction in frame printing time. +- Optimize `HemeraTermFx` terminal printing string generation for a 95% reduction in frame printing time. --- diff --git a/main.py b/main.py index 86a0859..dab981d 100644 --- a/main.py +++ b/main.py @@ -97,6 +97,7 @@ def generate_planet(engine: NyxEngine): if __name__ == "__main__": + engine = NyxEngine() # Configs #Line profile string buffer printing: line_profiling = False @@ -109,7 +110,6 @@ def generate_planet(engine: NyxEngine): window_width = 480 # Start the engine - engine = NyxEngine() engine.hemera_term_fx.run_line_profile = line_profiling # Add required systems to loop engine.add_system(MovementSystem()) @@ -211,7 +211,7 @@ def generate_planet(engine: NyxEngine): engine.render_frame() # Cull off-screen entities engine.kill_entities() - sleep_time = NyxEngine.sec_per_game_loop - (datetime.now() - start_time).seconds + sleep_time = engine.sec_per_game_loop - (datetime.now() - start_time).seconds time.sleep(sleep_time) diff --git a/nyx/aether_renderer/aether_renderer.py b/nyx/aether_renderer/aether_renderer.py index 61281f2..e84d183 100644 --- a/nyx/aether_renderer/aether_renderer.py +++ b/nyx/aether_renderer/aether_renderer.py @@ -167,11 +167,10 @@ def _process_tilemap_component(self): component (TilemapComponent): The component holding a tilemap array. """ from nyx.nyx_engine.nyx_engine import NyxEngine - + engine = NyxEngine() frame_w = self.dimensions.effective_window_w frame_h = self.dimensions.effective_window_h - # NyxEngine.tilemap_manager.render_tilemap() - self.layered_frames[0] = NyxEngine.tilemap_manager.rendered_tilemap[ + self.layered_frames[0] = engine.tilemap_manager.rendered_tilemap[ :frame_h, :frame_w ] diff --git a/nyx/base_classes/base_manager.py b/nyx/base_classes/base_manager.py new file mode 100644 index 0000000..b297c14 --- /dev/null +++ b/nyx/base_classes/base_manager.py @@ -0,0 +1,14 @@ +from abc import ABC + +from nyx.aether_renderer.aether_dimensions import AetherDimensions +from nyx.nyx_engine.nyx_engine import NyxEngine + + +class BaseManager: + @property + def engine(self) -> NyxEngine: + return NyxEngine() + + @property + def dimensions(self) -> AetherDimensions: + return self.engine.aether_dimensions diff --git a/nyx/hemera_term_fx/term_utils.py b/nyx/hemera_term_fx/term_utils.py index 4915f44..acdd806 100644 --- a/nyx/hemera_term_fx/term_utils.py +++ b/nyx/hemera_term_fx/term_utils.py @@ -92,5 +92,11 @@ def cursor_abs_move(pixel_x: int, pixel_y: int) -> str: @staticmethod def get_terminal_dimensions() -> Tuple[int, int]: """Return the terminal size as a tuple of integers in (h, w) format.""" - terminal_size = os.get_terminal_size() - return (terminal_size.lines, terminal_size.columns) + try: + terminal_size = os.get_terminal_size() + h, w = terminal_size.lines, terminal_size.columns + except OSError: + # Triggers when running in a non-terminal env., such as during testing + # 10, 10 was used simply because it is not 0, 0; which may cause bound issues + h, w = 10, 10 + return h, w diff --git a/nyx/moirai_ecs/component/component_manager.py b/nyx/moirai_ecs/component/component_manager.py index c5a83a4..99e62ba 100644 --- a/nyx/moirai_ecs/component/component_manager.py +++ b/nyx/moirai_ecs/component/component_manager.py @@ -30,17 +30,18 @@ class ComponentManager: remove_entity(): Remove all components belonging to an entity. """ - # Component Registry - component_registry: Dict[str, Dict[int, NyxComponent]] = { - "background-color": {}, - "dimensions": {}, - "position": {}, - "scene": {}, - "texture": {}, - "tilemap": {}, - "velocity": {}, - "z-index": {}, - } + def __init__(self): + # Component Registry + self.component_registry: Dict[str, Dict[int, NyxComponent]] = { + "background-color": {}, + "dimensions": {}, + "position": {}, + "scene": {}, + "texture": {}, + "tilemap": {}, + "velocity": {}, + "z-index": {}, + } def add_component( self, entity_id: int, component_name: str, component: NyxComponent @@ -55,12 +56,12 @@ def add_component( Raises: ValueError: If the component type is already registered for that entity ID. """ - if entity_id in ComponentManager.component_registry[component_name]: + if entity_id in self.component_registry[component_name]: raise ValueError( f'Component="{component_name}" already exists for entity={entity_id}' ) - ComponentManager.component_registry[component_name][entity_id] = component + self.component_registry[component_name][entity_id] = component def get_component(self, entity_id: int, component_name: str) -> NyxComponent: """Get a component for an entity. @@ -76,8 +77,8 @@ def get_component(self, entity_id: int, component_name: str) -> NyxComponent: NyxComponent: The component retrieved. """ - if entity_id in ComponentManager.component_registry[component_name]: - return ComponentManager.component_registry[component_name][entity_id] + if entity_id in self.component_registry[component_name]: + return self.component_registry[component_name][entity_id] raise KeyError( f'Entity={entity_id} not found in "{component_name}" component registry.' ) @@ -95,13 +96,13 @@ def update_component( Raises: KeyError: If the entity_id is not found for that component type. """ - if entity_id not in ComponentManager.component_registry[component_name]: + if entity_id not in self.component_registry[component_name]: raise KeyError( f'Entity={entity_id} not found in "{component_name}" component registry.' ) - ComponentManager.component_registry[component_name][entity_id] = component + self.component_registry[component_name][entity_id] = component - def destroy_component(self, entity_id: int, component_name: str): + def remove_component(self, entity_id: int, component_name: str): """Remove the a component from from the registry. Args: @@ -111,19 +112,18 @@ def destroy_component(self, entity_id: int, component_name: str): Raises: KeyError: If the entity_id is not found for that component type. """ - if entity_id not in ComponentManager.component_registry[component_name]: + if entity_id not in self.component_registry[component_name]: raise KeyError( f'Entity={entity_id} not found in "{component_name}" component registry.' ) - del ComponentManager.component_registry[component_name][entity_id] + del self.component_registry[component_name][entity_id] - @staticmethod - def remove_entity(entity_id: int): + def remove_entity(self, entity_id: int): """Remove all components belonging to an entity. Args: entity_id (int): The entity ID of the entity to clear from the registry. """ - for sub_dict in ComponentManager.component_registry.values(): + for sub_dict in self.component_registry.values(): if entity_id in sub_dict.keys(): del sub_dict[entity_id] diff --git a/nyx/moirai_ecs/entity/moirai_entity_manager.py b/nyx/moirai_ecs/entity/moirai_entity_manager.py index ff9332b..87a9a42 100644 --- a/nyx/moirai_ecs/entity/moirai_entity_manager.py +++ b/nyx/moirai_ecs/entity/moirai_entity_manager.py @@ -14,10 +14,12 @@ the path that life will follow; and Atropos cuts the thread, ending that life's journey. """ -from typing import Dict -from nyx.moirai_ecs.component.component_manager import ComponentManager +from typing import Dict, TYPE_CHECKING from nyx.moirai_ecs.entity.nyx_entity import NyxEntity +if TYPE_CHECKING: + from nyx.nyx_engine.nyx_engine import NyxEngine + class MoiraiEntityManager: """Holds and performs CRUD operations on registries for entities and their entity ids. @@ -40,6 +42,12 @@ def reset_entity_registry(cls): """Clear the entity registry of all entities.""" cls.entity_registry.clear() + def __init__(self, engine: "NyxEngine"): + self.engine = engine + self.component_manager = self.engine.component_manager + self.component_registry = self.engine.component_registry + self.entity_registry: Dict[int, NyxEntity] = {} + def create_entity(self, friendly_name: str = "") -> NyxEntity: """Create a NyxEntity and add it to the entity registry. @@ -51,7 +59,7 @@ def create_entity(self, friendly_name: str = "") -> NyxEntity: """ new_entity = NyxEntity(friendly_name=friendly_name.strip()) - MoiraiEntityManager.entity_registry[new_entity.entity_id] = new_entity + self.entity_registry[new_entity.entity_id] = new_entity return new_entity def destroy_entity(self, entity_id: int): @@ -60,9 +68,9 @@ def destroy_entity(self, entity_id: int): Args: entity_id (int): The entity ID to remove. """ - if entity_id in MoiraiEntityManager.entity_registry: - del MoiraiEntityManager.entity_registry[entity_id] - ComponentManager.remove_entity(entity_id=entity_id) + if entity_id in self.entity_registry: + del self.entity_registry[entity_id] + self.component_manager.remove_entity(entity_id=entity_id) return self def is_alive(self, entity_id: int) -> bool: @@ -74,7 +82,7 @@ def is_alive(self, entity_id: int) -> bool: Returns: bool: If the entity is alive. """ - return entity_id in MoiraiEntityManager.entity_registry + return entity_id in self.entity_registry def get_entity(self, entity_id: int) -> NyxEntity: """Get an entity from the entity list @@ -85,8 +93,8 @@ def get_entity(self, entity_id: int) -> NyxEntity: Returns: NyxEntity: The entity with the specified entity ID. """ - if entity_id in MoiraiEntityManager.entity_registry: - return MoiraiEntityManager.entity_registry[entity_id] + if entity_id in self.entity_registry: + return self.entity_registry[entity_id] def get_all_entities(self) -> Dict[int, NyxEntity]: """Return a registry of all entities in this manager. @@ -94,4 +102,4 @@ def get_all_entities(self) -> Dict[int, NyxEntity]: Returns: Dict[int, NyxEntity]: The registry of NyxEntity objects. """ - return MoiraiEntityManager.entity_registry + return self.entity_registry diff --git a/nyx/moirai_ecs/system/aether_bridge_system.py b/nyx/moirai_ecs/system/aether_bridge_system.py index 0aa9f46..3c720f8 100644 --- a/nyx/moirai_ecs/system/aether_bridge_system.py +++ b/nyx/moirai_ecs/system/aether_bridge_system.py @@ -12,7 +12,6 @@ from typing import Dict, List, Tuple import numpy as np -from nyx.moirai_ecs.component.component_manager import ComponentManager from nyx.moirai_ecs.system.base_systems import BaseSystem @@ -32,7 +31,7 @@ def __init__(self): def update(self): """Gather all renderable entities and components, then pass them to AetherRenderer.""" - component_registry = ComponentManager.component_registry + component_registry = self.engine.component_registry renderable_entities = {} # scene_entities = {} diff --git a/nyx/moirai_ecs/system/base_systems.py b/nyx/moirai_ecs/system/base_systems.py index 35c3f18..7f93dd9 100644 --- a/nyx/moirai_ecs/system/base_systems.py +++ b/nyx/moirai_ecs/system/base_systems.py @@ -6,9 +6,17 @@ """ from abc import ABC +from typing import TYPE_CHECKING -from nyx.moirai_ecs.entity.moirai_entity_manager import MoiraiEntityManager +if TYPE_CHECKING: + from nyx.nyx_engine.nyx_engine import NyxEngine class BaseSystem(ABC): - """The base system class, which all systems in the ECS architechture inherit from.""" + """The base system class, which all systems in the ECS architecture inherit from.""" + + @property + def engine(self) -> "NyxEngine": + """Return the engine instance.""" + from nyx.nyx_engine.nyx_engine import NyxEngine + return NyxEngine() diff --git a/nyx/moirai_ecs/system/movement_system.py b/nyx/moirai_ecs/system/movement_system.py index 74f829d..c032b21 100644 --- a/nyx/moirai_ecs/system/movement_system.py +++ b/nyx/moirai_ecs/system/movement_system.py @@ -8,12 +8,10 @@ """ from typing import Dict -from nyx.moirai_ecs.component.component_manager import ComponentManager from nyx.moirai_ecs.component.transform_components import ( PositionComponent, VelocityComponent, ) -from nyx.moirai_ecs.entity.moirai_entity_manager import MoiraiEntityManager from nyx.moirai_ecs.system.base_systems import BaseSystem from nyx.nyx_engine.nyx_engine import NyxEngine @@ -28,14 +26,17 @@ def update(self): Note: Calculates both the actual position and the position to render on the screen. """ - entity_reg = MoiraiEntityManager.entity_registry + engine = self.engine + component_registry = engine.component_registry + entity_reg = engine.entity_manager.entity_registry position_reg: Dict[int, PositionComponent] = ( - ComponentManager.component_registry["position"] + component_registry["position"] ) velocity_reg: Dict[int, VelocityComponent] = ( - ComponentManager.component_registry["velocity"] + component_registry["velocity"] ) - dt = NyxEngine.sec_per_game_loop + engine = NyxEngine() + dt = engine.sec_per_game_loop # Update the position of each entity for entity_id, velocity_component in velocity_reg.items(): diff --git a/nyx/nyx_engine/nyx_engine.py b/nyx/nyx_engine/nyx_engine.py index 2dd3f31..f2bcc00 100644 --- a/nyx/nyx_engine/nyx_engine.py +++ b/nyx/nyx_engine/nyx_engine.py @@ -8,14 +8,20 @@ """ import time +from typing import TYPE_CHECKING, Dict, List +from nyx.aether_renderer.aether_dimensions import AetherDimensions from nyx.aether_renderer.aether_renderer import AetherRenderer from nyx.aether_renderer.tilemap_manager import TilemapManager from nyx.hemera_term_fx.hemera_term_fx import HemeraTermFx +from nyx.moirai_ecs.component.base_components import NyxComponent from nyx.moirai_ecs.component.component_manager import ComponentManager from nyx.moirai_ecs.entity.moirai_entity_manager import MoiraiEntityManager from nyx.moirai_ecs.system.aether_bridge_system import AetherBridgeSystem -from nyx.moirai_ecs.system.base_systems import BaseSystem + + +if TYPE_CHECKING: + from nyx.moirai_ecs.system.base_systems import BaseSystem class NyxEngine: @@ -40,35 +46,52 @@ class NyxEngine: render_frame(): Renders the current frame. """ - # fps_target = 5 - game_update_per_sec = 60 - sec_per_game_loop = 1 / game_update_per_sec - running_systems = [] - entity_manager = MoiraiEntityManager() - component_manager = ComponentManager() - aether_bridge = AetherBridgeSystem() - aether_renderer = AetherRenderer() - hemera_term_fx = HemeraTermFx() - tilemap_manager = TilemapManager(dimensions=aether_renderer.dimensions) + # Singleton instance + _instance = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if not hasattr(self, "initialized"): + self.initialized = True + self.is_running = False + self.fps_target = 30 + self.sec_per_frame = 1 / self.fps_target + self.game_update_per_sec = 60 + self.sec_per_game_loop = 1 / self.game_update_per_sec + self.running_systems: List[BaseSystem] = [] + self.component_manager: ComponentManager = ComponentManager() + self.component_registry: Dict[str, Dict[int, NyxComponent]] = self.component_manager.component_registry + self.entity_manager: MoiraiEntityManager = MoiraiEntityManager(self) + self.aether_bridge: AetherBridgeSystem = AetherBridgeSystem() + self.aether_renderer: AetherRenderer = AetherRenderer() + self.aether_dimensions: AetherDimensions = self.aether_renderer.dimensions + self.hemera_term_fx: HemeraTermFx = HemeraTermFx() + self.tilemap_manager: TilemapManager = TilemapManager( + dimensions=self.aether_dimensions + ) def run_game(self): """The main game loop.""" while True: self.trigger_systems() self.render_frame() - time.sleep(NyxEngine.sec_per_game_loop) + time.sleep(self.sec_per_game_loop) - def add_system(self, system: BaseSystem): + def add_system(self, system: "BaseSystem"): """Adds a system to the running systems list. Args: - system (BaseSystem): The system to add. + system ("BaseSystem"): The system to add. """ - NyxEngine.running_systems.append(system) + self.running_systems.append(system) def trigger_systems(self): """Triggers an update call on all running systems.""" - for system in NyxEngine.running_systems: + for system in self.running_systems: system.update() def kill_entities(self, bounds: int = 10): @@ -79,21 +102,21 @@ def kill_entities(self, bounds: int = 10): """ cull_id_list = [] h, w = ( - NyxEngine.aether_renderer.dimensions.effective_window_h, - NyxEngine.aether_renderer.dimensions.effective_window_w, + self.aether_renderer.dimensions.effective_window_h, + self.aether_renderer.dimensions.effective_window_w, ) - for entity_id, comp in NyxEngine.component_manager.component_registry[ + for entity_id, comp in self.component_manager.component_registry[ "position" ].items(): if comp.render_x_pos >= w + bounds or comp.render_y_pos >= h + bounds: cull_id_list.append(entity_id) for entity_id in cull_id_list: - NyxEngine.entity_manager.destroy_entity(entity_id) + self.entity_manager.destroy_entity(entity_id) def render_frame(self): """Renders the current frame.""" - NyxEngine.aether_bridge.update() - renderable_entities = NyxEngine.aether_bridge.renderable_entities - NyxEngine.aether_renderer.accept_entities(renderable_entities) - new_frame = NyxEngine.aether_renderer.render() - NyxEngine.hemera_term_fx.print(new_frame) + self.aether_bridge.update() + renderable_entities = self.aether_bridge.renderable_entities + self.aether_renderer.accept_entities(renderable_entities) + new_frame = self.aether_renderer.render() + self.hemera_term_fx.print(new_frame) diff --git a/tests/test_moirai_ecs/test_component/test_component_manager.py b/tests/test_moirai_ecs/test_component/test_component_manager.py index 29b6e7a..35caca1 100644 --- a/tests/test_moirai_ecs/test_component/test_component_manager.py +++ b/tests/test_moirai_ecs/test_component/test_component_manager.py @@ -20,7 +20,7 @@ def test_add_component(): component_manager.add_component( entity_id=entity_id, component_name="dimensions", component=component ) - assert component in ComponentManager.component_registry["dimensions"].values() + assert component in component_manager.component_registry["dimensions"].values() def test_get_component(): @@ -55,7 +55,7 @@ def test_update_component(): component_manager.update_component( entity_id=entity_id, component_name=component_name, component=component_2 ) - assert component_2 in ComponentManager.component_registry[component_name].values() + assert component_2 in component_manager.component_registry[component_name].values() def test_destroy_component(): @@ -68,11 +68,13 @@ def test_destroy_component(): component_manager.add_component( entity_id=entity_id, component_name=component_name, component=component ) - component_manager.destroy_component( + component_manager.remove_component( entity_id=entity_id, component_name=component_name ) - assert component not in ComponentManager.component_registry[component_name].values() + assert ( + component not in component_manager.component_registry[component_name].values() + ) def test_remove_entity(): @@ -98,5 +100,5 @@ def test_remove_entity(): component_manager.remove_entity(entity_id=entity_id) - for sub_dict in ComponentManager.component_registry.values(): + for sub_dict in component_manager.component_registry.values(): assert entity_id not in sub_dict.keys() diff --git a/tests/test_moirai_ecs/test_entity/test_entity_manager.py b/tests/test_moirai_ecs/test_entity/test_entity_manager.py index 6a9a20d..5a14261 100644 --- a/tests/test_moirai_ecs/test_entity/test_entity_manager.py +++ b/tests/test_moirai_ecs/test_entity/test_entity_manager.py @@ -1,15 +1,16 @@ + from nyx.moirai_ecs.entity.moirai_entity_manager import MoiraiEntityManager from nyx.moirai_ecs.entity.nyx_entity import NyxEntity - +from nyx.nyx_engine.nyx_engine import NyxEngine def test_entity_manager_construction(): """Test that the entity manager can be initialized.""" - assert isinstance(MoiraiEntityManager(), MoiraiEntityManager) + assert isinstance(MoiraiEntityManager(NyxEngine()), MoiraiEntityManager) def test_create_entities(): """Test the creation of entities (create, add to reg, return).""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_list = [] for i in range(10): @@ -21,7 +22,7 @@ def test_create_entities(): def test_reset_entity_registry(): """Test clearing the entire entity registry""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_list = [] # Create the entitiies @@ -41,7 +42,7 @@ def test_reset_entity_registry(): def test_destroy_entity(): """Test the destruction of entities.""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_fate_manager.reset_entity_registry() entity_list = [] @@ -60,7 +61,7 @@ def test_destroy_entity(): def test_is_alive(): """Test if an entity is present in the entity list.""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_fate_manager.reset_entity_registry() entity_fate_manager.create_entity() @@ -70,7 +71,7 @@ def test_is_alive(): def test_get_entity(): """Test getting a single, existing entity""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_fate_manager.reset_entity_registry() assert isinstance( @@ -81,7 +82,7 @@ def test_get_entity(): def test_get_all_entities(): """Test getting all entities from the manager""" - entity_fate_manager = MoiraiEntityManager() + entity_fate_manager = MoiraiEntityManager(NyxEngine()) entity_fate_manager.reset_entity_registry() entity_list = []