├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── feature_request.md │ └── question-or-comment.md └── workflows │ ├── type-checking.yml │ └── unit-tests.yml ├── .gitignore ├── .mypy.ini ├── .readthedocs.yaml ├── .ruff.toml ├── LICENSE ├── MANIFEST.in ├── README.md ├── RELEASE_NOTES ├── docs ├── Makefile ├── conf.py ├── index.rst └── make.bat ├── esper ├── __init__.py └── py.typed ├── examples ├── benchmark.py ├── benchmark_cache.py ├── bluesquare.png ├── headless_example.py ├── pygame_example.py ├── pyglet_example.py ├── pysdl2_example.py ├── pythonista_ios_example.py └── redsquare.png ├── make.py ├── pyproject.toml └── tests ├── __init__.py └── test_world.py /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior, or sample code that shows the bug. 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Development environment:** 20 | - Python version 21 | - Esper version 22 | 23 | **Additional context** 24 | Add any other context about the problem here. 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: proposal 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Please note that new features must not:** 11 | 1. Negatively affect performance. 12 | 2. Depend on any external dependencies. 13 | 14 | **Describe the addition or change you'd like to see.** 15 | A description of what you would like Esper to do (or do differently). 16 | 17 | **An example of how the API might look.** 18 | If applicable, pseudo code that shows what the change would look like. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question-or-comment.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question or comment 3 | about: Ask a general question about Esper. 4 | title: '' 5 | labels: question 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/type-checking.yml: -------------------------------------------------------------------------------- 1 | name: type checking 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | 8 | jobs: 9 | typechecking: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ] 14 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12-dev' ] 15 | steps: 16 | - name: Python ${{ matrix.python-version }} ${{ matrix.os }} 17 | uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install mypy 23 | run: pip install mypy 24 | - name: Run mypy 25 | run: mypy -v esper 26 | -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: unit tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | 8 | jobs: 9 | tests: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ] 14 | python-version: [ '3.8', '3.9', '3.10', '3.11', '3.12-dev', 'pypy-3.10' ] 15 | steps: 16 | - name: Python ${{ matrix.python-version }} ${{ matrix.os }} 17 | uses: actions/checkout@v3 18 | - name: Set up Python ${{ matrix.python-version }} 19 | uses: actions/setup-python@v4 20 | with: 21 | python-version: ${{ matrix.python-version }} 22 | - name: Install test dependencies 23 | run: pip install pytest 24 | - name: Run tests 25 | run: pytest -v tests 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Benchmark results 2 | *.pickle 3 | 4 | # Python byte code 5 | *.py[co] 6 | 7 | # C extensions 8 | *.so 9 | *.pyd 10 | 11 | # Packages 12 | *.egg 13 | *.egg-info 14 | dist 15 | build 16 | eggs 17 | parts 18 | bin 19 | var 20 | sdist 21 | develop-eggs 22 | .installed.cfg 23 | lib 24 | lib64 25 | 26 | # Installer logs 27 | pip-log.txt 28 | 29 | # Unit test / coverage reports 30 | .coverage 31 | .tox 32 | nosetests.xml 33 | 34 | # Translations 35 | *.mo 36 | 37 | # Complexity 38 | output/*.html 39 | output/*/index.html 40 | 41 | # Sphinx 42 | docs/_build 43 | 44 | # IDE files 45 | .idea 46 | .vscode 47 | 48 | # Virtual environment 49 | .env 50 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | warn_unused_configs = True 3 | disallow_any_generics = True 4 | disallow_subclassing_any = True 5 | disallow_incomplete_defs = True 6 | check_untyped_defs = True 7 | disallow_untyped_decorators = True 8 | warn_redundant_casts = True 9 | warn_unused_ignores = True 10 | warn_return_any = True 11 | no_implicit_reexport = True 12 | strict_equality = True 13 | extra_checks = True 14 | 15 | [mypy-esper] 16 | disallow_untyped_defs = True 17 | disallow_untyped_calls = True 18 | 19 | [mypy-tests] 20 | check_untyped_defs = True 21 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/conf.py 16 | 17 | -------------------------------------------------------------------------------- /.ruff.toml: -------------------------------------------------------------------------------- 1 | lint.select = ["F", "E", "W", "C90", "B", "A", "C4", "G", "T20", "PT", "SLF", "TCH", "RET", "N", "ARG"] 2 | lint.ignore = ["E501"] 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Benjamin Moran 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE.rst 2 | graft examples 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![pypi](https://badge.fury.io/py/esper.svg)](https://pypi.python.org/pypi/esper) 2 | [![rtd](https://readthedocs.org/projects/esper/badge/?version=latest)](https://esper.readthedocs.io) 3 | [![PyTest](https://github.com/benmoran56/esper/actions/workflows/unit-tests.yml/badge.svg)](https://github.com/benmoran56/esper/actions/workflows/unit-tests.yml) 4 | 5 | esper is a lightweight Entity System module for Python, with a focus on performance 6 | =================================================================================== 7 | 8 | **esper** is an MIT licensed Entity System, or, Entity Component System (ECS). 9 | The design is based on the Entity System concepts originally popularized by 10 | Adam Martin and others. The primary focus for **esper** is to maximize perfomance, 11 | while handling most common use cases. 12 | 13 | For more information on the ECS pattern, you might find the following 14 | resources interesting: 15 | https://github.com/SanderMertens/ecs-faq/blob/master/README.md 16 | https://github.com/jslee02/awesome-entity-component-system/blob/master/README.md 17 | https://en.wikipedia.org/wiki/Entity_component_system 18 | 19 | API documentation is hosted at ReadTheDocs: https://esper.readthedocs.io 20 | Due to the small size of the project, this README currently serves as general usage 21 | documentation. 22 | 23 | > :warning: **esper 3.0 introduces breaking changes**. Version 3.0 removes the 24 | > World object, and migrates its methods to module level functions. Multiple 25 | > contexts can be created and switched between. The v2.x README can be found 26 | > here: https://github.com/benmoran56/esper/blob/v2_maintenance/README.md 27 | 28 | - [Compatibility](#compatibility) 29 | - [Installation](#installation) 30 | - [Design](#design) 31 | - [Quick Start](#quick-start) 32 | - [General Usage](#general-usage) 33 | * [Adding and Removing Processors](#adding-and-removing-processors) 34 | * [Adding and Removing Components](#adding-and-removing-components) 35 | * [Querying Specific Components](#querying-specific-components) 36 | * [Boolean and Conditional Checks](#boolean-and-conditional-checks) 37 | * [More Examples](#more-examples) 38 | - [Event Dispatching](#event-dispatching) 39 | - [Contributing](#contributing) 40 | 41 | 42 | Compatibility 43 | ============= 44 | **esper** attempts to target all currently supported Python releases (any Python version that is 45 | not EOL). **esper** is written in 100% pure Python, so *any* compliant interpreter should work. 46 | Automated testing is currently done for both CPython and PyPy3. 47 | 48 | 49 | Installation 50 | ============ 51 | **esper** is a pure Python package with no dependencies, so installation is flexible. 52 | You can simply copy the `esper` folder right into your project, and `import esper`. 53 | You can also install into your site-packages from PyPi via `pip`:: 54 | 55 | pip install --user --upgrade esper 56 | 57 | Or from the source directory:: 58 | 59 | pip install . --user 60 | 61 | 62 | Design 63 | ====== 64 | 65 | * World Context 66 | 67 | **esper** uses the concept of "World" contexts. When you first `import esper`, a default context is 68 | active. You create Entities, assign Components, register Processors, etc., by calling functions 69 | on the `esper` module. Entities, Components and Processors can be created, assigned, or deleted 70 | while your game is running. A simple call to `esper.process()` is all that's needed for each 71 | iteration of your game loop. Advanced users can switch contexts, which can be useful for 72 | isolating different game scenes that have different Processor requirements. 73 | 74 | 75 | * Entities 76 | 77 | Entities are defined internally as plain integer IDs (1, 2, 3, 4, etc.). Generally speaking 78 | you should not need to care about the individual entity IDs, since entities are queried based 79 | on their specific combination of Components - not by their ID. An Entity can be thought of as 80 | a specific combination of Components. Creating an Entity is done with the `esper.create_entity()` 81 | function. You can pass Component instances on creation or add/remove them later. 82 | 83 | * Components 84 | 85 | Components are defined as simple Python classes. In keeping with a pure Entity System design 86 | philosophy, Components should not contain any processing logic. They may contain initialization 87 | logic, and you can take advantage of Python language features, like properties, to simplify data 88 | lookup. The key point is that game logic does not belong in these classes, and Components should 89 | have no knowledge of other Components or Entities. A simple Component can be defined as:: 90 | 91 | class Position: 92 | def __init__(self, x=0.0, y=0.0): 93 | self.x = x 94 | self.y = y 95 | 96 | To save on typing, the standard library dataclass decorator is quite useful. 97 | https://docs.python.org/3/library/dataclasses.html#module-dataclasses 98 | This decorator simplifies defining your Component classes. The attribute names don't need to 99 | be repeated, and you can still instantiate the Component with positional or keyword arguments:: 100 | 101 | from dataclasses import dataclass as component 102 | 103 | @component 104 | class Position: 105 | x: float = 0.0 106 | y: float = 0.0 107 | 108 | Python language features, like properties, can be useful to simplify data access. For example, 109 | a Body component that is often repositioned may benefit from a local AABB (axis aligned bounding 110 | box) property:: 111 | 112 | @dataclass 113 | class Body: 114 | width: int 115 | height: int 116 | pos_x: float = 0 117 | pos_y: float = 0 118 | 119 | @property 120 | def aabb(self) -> tuple[float, float, float, float]: 121 | return self.pos_x, self.pos_y, self.pos_x + self.width, self.pos_y + self.height 122 | 123 | 124 | * Processors 125 | 126 | Processors, also commonly known as "Systems", are where all processing logic is defined and executed. 127 | All Processors must inherit from the `esper.Processor` class, and have a method called `process`. 128 | Other than that, there are no restrictions. You can define any additional methods you might need. 129 | A simple Processor might look like:: 130 | 131 | class MovementProcessor(esper.Processor): 132 | 133 | def process(self): 134 | for ent, (vel, pos) in esper.get_components(Velocity, Position): 135 | pos.x += vel.x 136 | pos.y += vel.y 137 | 138 | In the above code, you can see the standard usage of the `esper.get_components()` function. This 139 | function allows efficient iteration over all Entities that contain the specified Component types. 140 | This function can be used for querying two or more components at once. Note that tuple unpacking 141 | is necessary for the return component pairs: `(vel, pos)`. In addition to Components, you also 142 | get a reference to the Entity ID for the current pair of Velocity/Position Components. This entity 143 | ID can be useful in a variety of cases. For example, if your Processor will need to delete certain 144 | Entites, you can call the `esper.delete_entity()` function on this Entity ID. Another common use 145 | is if you wish to add or remove a Component on this Entity as a result of some condition being met. 146 | For example, an Entity that should be deleted once it's `Lifecycle` Component reaches 0:: 147 | 148 | class LifecycleProcessor(esper.Processor): 149 | def __init__(self, ...): 150 | ... 151 | 152 | def process(self, dt): 153 | for ent, (life, rend) in esper.get_components(Lifecycle, Renderable): 154 | life.lifespan -= dt 155 | if life.lifespan <= 0: 156 | esper.delete_entity(ent) 157 | 158 | 159 | Quick Start 160 | =========== 161 | 162 | To get started, simply import **esper**:: 163 | 164 | import esper 165 | 166 | From there, define some Components, and create Entities that use them:: 167 | 168 | player = esper.create_entity() 169 | esper.add_component(player, Velocity(x=0.9, y=1.2)) 170 | esper.add_component(player, Position(x=5, y=5)) 171 | 172 | Optionally, Component instances can be assigned directly to the Entity on creation:: 173 | 174 | player = esper.create_entity(Velocity(x=0.9, y=1.2), Position(x=5, y=5)) 175 | 176 | 177 | Design some Processors that operate on these Component types, and then register them with 178 | **esper** for processing. You can specify an optional priority (higher numbers are processed first). 179 | All Processors are priority "0" by default:: 180 | 181 | movement_processor = MovementProcessor() 182 | collision_processor = CollisionProcessor() 183 | rendering_processor = RenderingProcessor() 184 | esper.add_processor(collision_processor, priority=2) 185 | esper.add_processor(movement_processor, priority=3) 186 | esper.add_processor(rendering_processor) 187 | # or just add them in one line: 188 | esper.add_processor(SomeProcessor()) 189 | 190 | 191 | Executing all Processors is done with a single call to `esper.process()`. This will call the 192 | `process` method on all assigned Processors, in order of their priority. This is usually called 193 | once per frame update of your game (every tick of the clock).:: 194 | 195 | esper.process() 196 | 197 | 198 | **Note:** You can pass any arguments (or keyword arguments) you need to `esper.process()`, but you 199 | must also make sure to receive them properly in the `process()` methods of your Processors. For 200 | example, if you pass a delta time argument as `esper.process(dt)`, your Processor's `process()` 201 | methods should all receive it as: `def process(self, dt):` 202 | This is appropriate for libraries such as **pyglet**, which automatically pass a delta time value 203 | into scheduled functions. 204 | 205 | 206 | General Usage 207 | ============= 208 | 209 | World Contexts 210 | -------------- 211 | **esper** has the capability of supporting multiple "World" contexts. On import, a "default" World is 212 | active. All creation of Entities, assignment of Processors, and all other operations occur within 213 | the confines of the active World. In other words, the World contexts are completely isolated from 214 | each other. For basic games and designs, you may not need to bother with this functionality. A 215 | single default World context can often be enough. For advanced use cases, such as when different 216 | scenes in your game have different Entities and Processor requirements, this functionality can be 217 | quite useful. World context operations are done with the following functions:: 218 | * esper.list_worlds() 219 | * esper.switch_world(name) 220 | * esper.delete_world(name) 221 | 222 | When switching Worlds, be mindful of the `name`. If a World doesn't exist, it will be created when 223 | you first switch to it. You can delete old Worlds if they are no longer needed, but you can not 224 | delete the currently active World. 225 | 226 | Adding and Removing Processors 227 | ------------------------------ 228 | You have already seen examples of adding Processors in an earlier section. There is also a 229 | `remove_processor` function available: 230 | 231 | * esper.add_processor(processor_instance) 232 | * esper.remove_processor(ProcessorClass) 233 | 234 | Depending on the structure of your game, you may want to add or remove certain Processors when changing 235 | scenes, etc. 236 | 237 | Adding and Removing Components 238 | ------------------------------ 239 | In addition to adding Components to Entities when you're creating them, it's a common pattern to add or 240 | remove Components inside your Processors. The following functions are available for this purpose: 241 | 242 | * esper.add_component(entity_id, component_instance) 243 | * esper.remove_component(entity_id, ComponentClass) 244 | 245 | As an example of this, you could have a "Blink" component with a `duration` attribute. This can be used 246 | to make certain things blink for a specific period of time, then disappear. For example, the code below 247 | shows a simplified case of adding this Component to an Entity when it takes damage in one processor. A 248 | dedicated `BlinkProcessor` handles the effect, and then removes the Component after the duration expires:: 249 | 250 | class BlinkComponent: 251 | def __init__(self, duration): 252 | self.duration = duration 253 | 254 | 255 | ..... 256 | 257 | 258 | class CollisionProcessor(esper.Processor): 259 | 260 | def process(self, dt): 261 | for ent, enemy in esper.get_component(Enemy): 262 | ... 263 | is_damaged = self._some_method() 264 | if is_damaged: 265 | esper.add_component(ent, BlinkComponent(duration=1)) 266 | ... 267 | 268 | 269 | class BlinkProcessor(esper.Processor): 270 | 271 | def process(self, dt): 272 | for ent, (rend, blink) in esper.get_components(Renderable, BlinkComponent): 273 | if blink.duration < 0: 274 | # Times up. Remove the Component: 275 | rend.sprite.visible = True 276 | esper.remove_component(ent, BlinkComponent) 277 | else: 278 | blink.duration -= dt 279 | # Toggle between visible and not visible each frame: 280 | rend.sprite.visible = not rend.sprite.visible 281 | 282 | 283 | Querying Specific Components 284 | ---------------------------- 285 | If you have an Entity ID and wish to query one specific, or ALL Components that are assigned 286 | to it, the following functions are available: 287 | 288 | * esper.component_for_entity 289 | * esper.components_for_entity 290 | 291 | The `component_for_entity` function is useful in a limited number of cases where you know a specific 292 | Entity ID, and wish to get a specific Component for it. An error is raised if the Component does not 293 | exist for the Entity ID, so it may be more useful when combined with the `has_component` 294 | function that is explained in the next section. For example:: 295 | 296 | if esper.has_component(ent, SFX): 297 | sfx = esper.component_for_entity(ent, SFX) 298 | sfx.play() 299 | 300 | The `components_for_entity` function is a special function that returns ALL the Components that are 301 | assigned to a specific Entity, as a tuple. This is a heavy operation, and not something you would 302 | want to do each frame or inside your `Processor.process` method. It can be useful, however, if 303 | you wanted to transfer all of a specific Entity's Components between two separate contexts 304 | (such as when changing Scenes, or levels). For example:: 305 | 306 | player_components = esper.components_for_entity(player_entity_id) 307 | esper.switch_world('context_name') 308 | player_entity_id = esper.create_entity(player_components) 309 | 310 | Boolean and Conditional Checks 311 | ------------------------------ 312 | In some cases you may wish to check if an Entity has a specific Component before performing 313 | some action. The following functions are available for this task: 314 | 315 | * esper.has_component(entity, ComponentType) 316 | * esper.has_components(entity, ComponentTypeA, ComponentTypeB) 317 | * esper.try_component(entity, ComponentType) 318 | * esper.try_components(entity, ComponentTypeA, ComponentTypeB) 319 | 320 | 321 | For example, you may want projectiles (and only projectiles) to disappear when hitting a wall in 322 | your game. We can do this by checking if the Entity has a `Projectile` Component. We don't want 323 | to do anything to this Component, simply check if it's there. Consider this example:: 324 | 325 | class CollisionProcessor(esper.Processor): 326 | 327 | def process(self, dt): 328 | for ent, body in esper.get_component(PhysicsBody): 329 | ... 330 | colliding_with_wall = self._some_method(body): 331 | if colliding_with_wall and esper.has_component(ent, Projectile): 332 | esper.delete_entity(ent) 333 | ... 334 | 335 | 336 | In a different scenario, we may want to perform some action on an Entity's Component, *if* it has 337 | one. For example, a MovementProcessor that skips over Entities that have a `Stun` Component:: 338 | 339 | class MovementProcessor(esper.Processor): 340 | 341 | def process(self, dt): 342 | for ent, (body, vel) in esper.get_components(PhysicsBody, Velocity): 343 | 344 | if esper.has_component(ent, Stun): 345 | stun = esper.component_for_entity(ent, Stun) 346 | stun.duration -= dt 347 | if stun.duration <= 0: 348 | esper.remove_component(ent, Stun) 349 | continue # Continue to the next Entity 350 | 351 | movement_code_here() 352 | ... 353 | 354 | 355 | Let's look at the core part of the code:: 356 | 357 | if esper.has_component(ent, Stun): 358 | stun = esper.component_for_entity(ent, Stun) 359 | stun.duration -= dt 360 | 361 | This code works fine, but the `try_component` function can accomplish the same thing with one 362 | less function call. The following example will get a specific Component if it exists, or 363 | return None if it does not:: 364 | 365 | stun = esper.try_component(ent, Stun) 366 | if stun: 367 | stun.duration -= dt 368 | 369 | With Python 3.8+, the new "walrus" operator (`:=`) can also be used, making the `try_component` 370 | functions even more concise :: 371 | 372 | if stun := esper.try_component(ent, Stun): 373 | stun.duration -= dt 374 | 375 | 376 | More Examples 377 | ------------- 378 | 379 | See the `/examples` folder to get an idea of how the basic structure of a game might look. 380 | 381 | Event Dispatching 382 | ================= 383 | 384 | **esper** includes basic support for event dispatching and handling. This functionality is 385 | provided by three functions to set (register), remove, and dispatch events. Minimal error 386 | checking is done, so it's left up to the user to ensure correct naming and number of 387 | arguments are used when dispatching and receiving events. 388 | 389 | Events are dispatched by name:: 390 | 391 | esper.dispatch_event('event_name', arg1, arg2) 392 | 393 | In order to receive the above event, you must register handlers. An event handler can be a 394 | function or class method. Registering a handler is also done by name:: 395 | 396 | esper.set_handler('event_name', my_func) 397 | # or 398 | esper.set_handler('event_name', self.my_method) 399 | 400 | **Note:** Only weak-references are kept to the registered handlers. If a handler is garbage 401 | collected, it will be automatically un-registered by an internal callback. 402 | 403 | Handlers can also be removed at any time, if you no longer want them to receive events:: 404 | 405 | esper.remove_handler('event_name', my_func) 406 | # or 407 | esper.remove_handler('event_name', self.my_method) 408 | 409 | Registered events and handlers are part of the current `World` context. 410 | 411 | Contributing 412 | ============ 413 | 414 | Contributions to **esper** are always welcome, but there are some specific project goals to keep in mind: 415 | 416 | - Pure Python code only: no binary extensions, Cython, etc. 417 | - Try to target all non-EOL Python versions. Exceptions can be made if there is a compelling reason. 418 | - Avoid bloat as much as possible. New features will be considered if they are commonly useful. Generally speaking, we don't want to add functionality that is better served by another module or library. 419 | - Performance is preferrable to readability. The public API should remain clean, but ugly internal code is acceptable if it provides a performance benefit. Every cycle counts! 420 | 421 | If you have any questions before contributing, feel free to [open an issue]. 422 | 423 | [open an issue]: https://github.com/benmoran56/esper/issues 424 | -------------------------------------------------------------------------------- /RELEASE_NOTES: -------------------------------------------------------------------------------- 1 | esper 3.4 2 | ========= 3 | Maintenance release 4 | 5 | Changes 6 | ------- 7 | - `esper.remove_handler` should use a reference instead of direct object comparison. (#107) 8 | 9 | 10 | esper 3.3 11 | ========= 12 | Maintenance release 13 | 14 | Changes 15 | ------- 16 | - Fix unreadable `esper.current_world` property. (#100) 17 | - Minor typing configuration updates. 18 | - Remove outdated pyglet example. (Another one already exists). 19 | 20 | 21 | esper 3.2 22 | ========= 23 | Maintenance release 24 | 25 | Changes 26 | ------- 27 | - Add `esper.current_world` property to easily check the current World context. 28 | - Made some minor docstring corrections, and added some programmer notes. 29 | 30 | 31 | esper 3.1 32 | ========= 33 | Maintenance release 34 | 35 | Changes 36 | ------- 37 | - modernization of build process 38 | 39 | 40 | esper 3.0 41 | ========= 42 | Major release 43 | 44 | Changes 45 | ------- 46 | - esper has been refactored to use a more functional API for performance and flexibility. 47 | 48 | 49 | esper 2.5 50 | ========= 51 | Maintenance release 52 | 53 | Changes 54 | ------- 55 | - Removing all Components from an Entity will no longer automatically delete the Entity. 56 | - Typing fixes and additions. 57 | - Entity DB check and creation is done once when Entity is created, not when adding Components. 58 | 59 | 60 | esper 2.4 61 | ========= 62 | Maintenance release 63 | 64 | Changes 65 | ------- 66 | - Minor typing changes, and docstring improvement. 67 | 68 | 69 | esper 2.3 70 | ========= 71 | Maintenance release 72 | 73 | Changes 74 | ------- 75 | - Equality comparison changes, and other minor cleanup. 76 | 77 | 78 | esper 2.2 79 | ========= 80 | Maintenance release 81 | 82 | Changes 83 | ------- 84 | - Fix incomplete docstrings for event functions. 85 | - Typing and docstring cleanups and rewording. 86 | 87 | 88 | esper 2.1 89 | ========= 90 | Maintenance release 91 | 92 | Changes 93 | ------- 94 | - Fix bug when adding a function as an event handler. 95 | - Add some event handler unit tests. 96 | 97 | 98 | esper 2.0 99 | ========= 100 | Feature release 101 | 102 | Changes 103 | ------- 104 | - Add a simple event system, for registering and dispatching events. 105 | - Replace usage of the lru_cache module with internal cache. 106 | - To help with type checking, esper has been converted to a package. 107 | 108 | 109 | esper 1.5 110 | ========= 111 | Maintenance release 112 | 113 | Changes 114 | ------- 115 | - Update documentation with notes about dataclass decorator usage. 116 | - Add Python 3.9 to Continuous Integration testing. 117 | - The behavior of the `try_component` & `try_components` methods has changed slightly. 118 | 119 | 120 | esper 1.4 121 | ========= 122 | Maintenance release 123 | 124 | Changes 125 | ------- 126 | - Add missing docstrings. 127 | - Add additional typing definitions. 128 | 129 | 130 | esper 1.3 131 | ========= 132 | Feature release 133 | 134 | Changes 135 | ------- 136 | - Add new `World.has_components` method which allows multiple Component queries. Returns a boolean. 137 | - Add new `World.try_components` method which allows multiple Component queries. 138 | - Add Python 3.8 to Continuous Integration testing. 139 | 140 | 141 | esper 1.2 142 | ========= 143 | Feature release 144 | 145 | Changes 146 | ------- 147 | - Calls to `super()` are no longer necessary in your Processor subclasses. 148 | - Update README with more usage examples. All methods should now have at least one example. 149 | - Include wheels for PyPi to help with packaging systems that only support wheels. (#38) 150 | 151 | 152 | esper 1.0.0 153 | =========== 154 | Feature release 155 | 156 | Changes 157 | ------- 158 | - Use lru_caching internally by default. The cache is currently 159 | - Allow passing kwargs to Processors. 160 | - Include Python 3.7 in Continuous Integration testing. 161 | 162 | 163 | esper 0.9.9 164 | =========== 165 | Feature release 166 | 167 | Changes 168 | ------- 169 | - Condense esper into a single file -> esper.py. 170 | 171 | 172 | esper 0.9.8 173 | =========== 174 | Feature release 175 | 176 | Changes 177 | ------- 178 | - New timer argument for World to assist in profiling Processor execution times. 179 | - Consolidate and clean up the benchmarks. 180 | 181 | 182 | esper 0.9.7 183 | =========== 184 | Feature release 185 | 186 | Changes 187 | ------- 188 | - Lazily delete entities by default, preventing errors while iterating. 189 | 190 | 191 | esper 0.9.6 192 | =========== 193 | Feature release 194 | 195 | Changes 196 | ------- 197 | - Add new `World.get_processor` convenience method which returns a Processor instance by type. 198 | 199 | 200 | esper 0.9.5 201 | =========== 202 | Feature release 203 | 204 | Changes 205 | ------- 206 | - Add `World.components_for_entity` method which returns a tuple of an Entity's Components. 207 | - The `World.component_for_entity` method will raise a KeyError if the Entity ID does not exist. 208 | 209 | 210 | esper 0.9.4 211 | =========== 212 | Feature release 213 | 214 | Changes 215 | ------- 216 | - Add new method `World.has_component` which returns a Boolean (True/False). 217 | 218 | 219 | esper 0.9.3 220 | =========== 221 | Feature release 222 | 223 | Changes 224 | ------- 225 | - Rename `World.delete_component` to `World.remove_component` for API consistency. 226 | - `World.delete_entity` and `World.remove_component` will raise a KeyError if the Entity or 227 | Component do not exist. 228 | 229 | 230 | esper 0.9.2 231 | =========== 232 | Feature release 233 | 234 | Changes 235 | ------- 236 | - Switch to different internal database structure. (No API changes) 237 | - Add examples for pyglet. 238 | - Multiple Component queries are faster. 239 | 240 | 241 | esper 0.9.0 242 | =========== 243 | Feature release 244 | 245 | Changes 246 | ------- 247 | - First usable release. 248 | - Included examples for Pygame and PySDL2. 249 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | import os 14 | import sys 15 | import time 16 | import datetime 17 | sys.path.insert(0, os.path.abspath('..')) 18 | 19 | YEAR = datetime.datetime.fromtimestamp(time.time()).year 20 | 21 | # -- Project information ----------------------------------------------------- 22 | 23 | project = 'esper' 24 | copyright = f'{YEAR}, Benjamin Moran' 25 | author = 'Benjamin Moran' 26 | 27 | # -- General configuration --------------------------------------------------- 28 | 29 | # Add any Sphinx extension module names here, as strings. They can be 30 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 31 | # ones. 32 | extensions = ['sphinx.ext.autodoc'] 33 | 34 | # Add any paths that contain templates here, relative to this directory. 35 | templates_path = ['_templates'] 36 | 37 | # List of patterns, relative to source directory, that match files and 38 | # directories to ignore when looking for source files. 39 | # This pattern also affects html_static_path and html_extra_path. 40 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 41 | 42 | master_doc = 'index' 43 | 44 | # -- Options for HTML output ------------------------------------------------- 45 | 46 | # The theme to use for HTML and HTML Help pages. See the documentation for 47 | # a list of builtin themes. 48 | # 49 | html_theme = 'nature' 50 | 51 | # Add any paths that contain custom static files (such as style sheets) here, 52 | # relative to this directory. They are copied after the builtin static files, 53 | # so a file named "default.css" will overwrite the builtin "default.css". 54 | # html_static_path = ['_static'] 55 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | esper - API documentation 2 | ========================= 3 | 4 | Processors 5 | ---------- 6 | .. autoclass:: esper.Processor 7 | 8 | 9 | Components 10 | ---------- 11 | **esper** does not define any specific Component base class 12 | to inherit from. Any valid Python class can be used. For more 13 | compact definitions, the ``@dataclass`` decorator from the 14 | ``dataclasses`` module is quite useful. You can also use a 15 | ``namedtuple`` instead of a class, but this is limited to 16 | cases where the Component's data does not need to be modified. 17 | Three examples of valid Components:: 18 | 19 | class Velocity: 20 | def __init__(self, x=0.0, y=0.0, accel=0.1, decel=0.75, maximum=3): 21 | self.vector = Vec2(x, y) 22 | self.accel = accel 23 | self.decel = decel 24 | self.maximum = maximum 25 | 26 | 27 | @dataclass 28 | class Camera: 29 | current_x_offset: float = 0 30 | current_y_offset: float = 0 31 | 32 | 33 | Interaction = namedtuple('Interaction', 'interaction_type target_name') 34 | 35 | 36 | The World context 37 | ----------------- 38 | 39 | .. autofunction:: esper.switch_world 40 | .. autofunction:: esper.delete_world 41 | .. autofunction:: esper.list_worlds 42 | .. autofunction:: esper.create_entity 43 | .. autofunction:: esper.delete_entity 44 | .. autofunction:: esper.entity_exists 45 | .. autofunction:: esper.add_processor 46 | .. autofunction:: esper.remove_processor 47 | .. autofunction:: esper.get_processor 48 | .. autofunction:: esper.component_for_entity 49 | .. autofunction:: esper.components_for_entity 50 | .. autofunction:: esper.add_component 51 | .. autofunction:: esper.remove_component 52 | .. autofunction:: esper.get_component 53 | .. autofunction:: esper.get_components 54 | .. autofunction:: esper.has_component 55 | .. autofunction:: esper.has_components 56 | .. autofunction:: esper.try_component 57 | .. autofunction:: esper.try_components 58 | .. autofunction:: esper.process 59 | .. autofunction:: esper.timed_process 60 | .. autofunction:: esper.clear_database 61 | .. autofunction:: esper.clear_cache 62 | .. autofunction:: esper.clear_dead_entities 63 | 64 | Events 65 | ------ 66 | For convenience, **esper** includes functionality for 67 | dispatching and handling events. This is limited in scope, 68 | but should be robust enough for most general use cases. 69 | 70 | .. autofunction:: esper.dispatch_event 71 | .. autofunction:: esper.set_handler 72 | .. autofunction:: esper.remove_handler 73 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /esper/__init__.py: -------------------------------------------------------------------------------- 1 | """esper is a lightweight Entity System (ECS) for Python, with a focus on performance 2 | 3 | More information is available at https://github.com/benmoran56/esper 4 | """ 5 | import time as _time 6 | 7 | from types import MethodType as _MethodType 8 | 9 | from typing import Any as _Any 10 | from typing import Callable as _Callable 11 | from typing import Dict as _Dict 12 | from typing import List as _List 13 | from typing import Set as _Set 14 | from typing import Type as _Type 15 | from typing import Tuple as _Tuple 16 | from typing import TypeVar as _TypeVar 17 | from typing import Iterable as _Iterable 18 | from typing import Optional as _Optional 19 | from typing import overload as _overload 20 | 21 | from weakref import ref as _ref 22 | from weakref import WeakMethod as _WeakMethod 23 | 24 | from itertools import count as _count 25 | 26 | __version__ = version = '3.4' 27 | 28 | 29 | ################### 30 | # Event system 31 | ################### 32 | 33 | 34 | def dispatch_event(name: str, *args: _Any) -> None: 35 | """Dispatch an event by name, with optional arguments. 36 | 37 | Any handlers set with the :py:func:`esper.set_handler` function 38 | will recieve the event. If no handlers have been set, this 39 | function call will pass silently. 40 | 41 | :note:: If optional arguments are provided, but set handlers 42 | do not account for them, it will likely result in a 43 | TypeError or other undefined crash. 44 | """ 45 | for func in event_registry.get(name, []): 46 | func()(*args) 47 | 48 | 49 | def _make_callback(name: str) -> _Callable[[_Any], None]: 50 | """Create an internal callback to remove dead handlers.""" 51 | 52 | def callback(weak_method: _Any) -> None: 53 | event_registry[name].remove(weak_method) 54 | if not event_registry[name]: 55 | del event_registry[name] 56 | 57 | return callback 58 | 59 | 60 | def set_handler(name: str, func: _Callable[..., None]) -> None: 61 | """Register a function to handle the named event type. 62 | 63 | After registering a function (or method), it will receive all 64 | events that are dispatched by the specified name. 65 | 66 | .. note:: A weak reference is kept to the passed function, 67 | """ 68 | if name not in event_registry: 69 | event_registry[name] = set() 70 | 71 | if isinstance(func, _MethodType): 72 | event_registry[name].add(_WeakMethod(func, _make_callback(name))) 73 | else: 74 | event_registry[name].add(_ref(func, _make_callback(name))) 75 | 76 | 77 | def remove_handler(name: str, func: _Callable[..., None]) -> None: 78 | """Unregister a handler from receiving events of this name. 79 | 80 | If the passed function/method is not registered to 81 | receive the named event, or if the named event does 82 | not exist, this function call will pass silently. 83 | """ 84 | func_ref = _ref(func) 85 | if func_ref not in event_registry.get(name, []): 86 | return 87 | 88 | event_registry[name].remove(func_ref) 89 | if not event_registry[name]: 90 | del event_registry[name] 91 | 92 | 93 | ################### 94 | # ECS Classes 95 | ################### 96 | 97 | 98 | _C = _TypeVar('_C') 99 | _C2 = _TypeVar('_C2') 100 | _C3 = _TypeVar('_C3') 101 | _C4 = _TypeVar('_C4') 102 | 103 | 104 | class Processor: 105 | """Base class for all Processors to inherit from. 106 | 107 | Processor instances must contain a `process` method, but you are otherwise 108 | free to define the class any way you wish. Processors should be instantiated, 109 | and then added to the current World context by calling :py:func:`esper.add_processor`. 110 | For example:: 111 | 112 | my_processor_instance = MyProcessor() 113 | esper.add_processor(my_processor_instance) 114 | 115 | All the Processors that have been added to the World context will have their 116 | :py:meth:`esper.Processor.process` methods called by a single call to 117 | :py:func:`esper.process`. Inside the `process` method is generally where you 118 | should iterate over Entities with one (or more) calls to the appropriate methods:: 119 | 120 | def process(self): 121 | for ent, (rend, vel) in esper.get_components(Renderable, Velocity): 122 | your_code_here() 123 | """ 124 | 125 | priority = 0 126 | 127 | def process(self, *args: _Any, **kwargs: _Any) -> None: 128 | raise NotImplementedError 129 | 130 | 131 | ################### 132 | # ECS functions 133 | ################### 134 | 135 | _current_world: str = "default" 136 | _entity_count: "_count[int]" = _count(start=1) 137 | _components: _Dict[_Type[_Any], _Set[_Any]] = {} 138 | _entities: _Dict[int, _Dict[_Type[_Any], _Any]] = {} 139 | _dead_entities: _Set[int] = set() 140 | _get_component_cache: _Dict[_Type[_Any], _List[_Any]] = {} 141 | _get_components_cache: _Dict[_Tuple[_Type[_Any], ...], _List[_Any]] = {} 142 | _processors: _List[Processor] = [] 143 | event_registry: _Dict[str, _Any] = {} 144 | process_times: _Dict[str, int] = {} 145 | current_world: str = "default" 146 | 147 | 148 | # {context_name: (entity_count, components, entities, dead_entities, 149 | # comp_cache, comps_cache, processors, process_times, event_registry)} 150 | _context_map: _Dict[str, _Tuple[ 151 | "_count[int]", 152 | _Dict[_Type[_Any], _Set[_Any]], 153 | _Dict[int, _Dict[_Type[_Any], _Any]], 154 | _Set[int], 155 | _Dict[_Type[_Any], _List[_Any]], 156 | _Dict[_Tuple[_Type[_Any], ...], _List[_Any]], 157 | _List[Processor], 158 | _Dict[str, int], 159 | _Dict[str, _Any] 160 | ]] = {"default": (_entity_count, {}, {}, set(), {}, {}, [], {}, {})} 161 | 162 | 163 | def clear_cache() -> None: 164 | """Manually clear the Component lookup cache. 165 | 166 | Clearing the cache is not necessary to do manually, 167 | but may be useful for benchmarking or debugging. 168 | """ 169 | _get_component_cache.clear() 170 | _get_components_cache.clear() 171 | 172 | 173 | def clear_database() -> None: 174 | """Clear the Entity Component database. 175 | 176 | Removes all Entities and Components from the current World. 177 | Processors are not removed. 178 | """ 179 | global _entity_count 180 | _entity_count = _count(start=1) 181 | _components.clear() 182 | _entities.clear() 183 | _dead_entities.clear() 184 | clear_cache() 185 | 186 | 187 | def add_processor(processor_instance: Processor, priority: int = 0) -> None: 188 | """Add a Processor instance to the current World. 189 | 190 | Add a Processor instance to the world (subclass of 191 | :py:class:`esper.Processor`), with optional priority. 192 | 193 | When the :py:func:`esper.World.process` function is called, 194 | Processors with higher priority will be called first. 195 | """ 196 | processor_instance.priority = priority 197 | _processors.append(processor_instance) 198 | _processors.sort(key=lambda proc: proc.priority, reverse=True) 199 | 200 | 201 | def remove_processor(processor_type: _Type[Processor]) -> None: 202 | """Remove a Processor from the World, by type. 203 | 204 | Make sure to provide the class itself, **not** an instance. For example:: 205 | 206 | # OK: 207 | self.world.remove_processor(MyProcessor) 208 | 209 | # NG: 210 | self.world.remove_processor(my_processor_instance) 211 | 212 | """ 213 | for processor in _processors: 214 | if type(processor) is processor_type: 215 | _processors.remove(processor) 216 | 217 | 218 | def get_processor(processor_type: _Type[Processor]) -> _Optional[Processor]: 219 | """Get a Processor instance, by type. 220 | 221 | This function returns a Processor instance by type. This could be 222 | useful in certain situations, such as wanting to call a method on a 223 | Processor, from within another Processor. 224 | """ 225 | for processor in _processors: 226 | if type(processor) is processor_type: 227 | return processor 228 | else: 229 | return None 230 | 231 | 232 | def create_entity(*components: _C) -> int: 233 | """Create a new Entity, with optional initial Components. 234 | 235 | This function returns an Entity ID, which is a plain integer. 236 | You can optionally pass one or more Component instances to be 237 | assigned to the Entity on creation. Components can be also be 238 | added later with the :py:func:`esper.add_component` function. 239 | """ 240 | entity = next(_entity_count) 241 | 242 | if entity not in _entities: 243 | _entities[entity] = {} 244 | 245 | for component_instance in components: 246 | 247 | component_type = type(component_instance) 248 | 249 | if component_type not in _components: 250 | _components[component_type] = set() 251 | 252 | _components[component_type].add(entity) 253 | 254 | _entities[entity][component_type] = component_instance 255 | clear_cache() 256 | 257 | return entity 258 | 259 | 260 | def delete_entity(entity: int, immediate: bool = False) -> None: 261 | """Delete an Entity from the current World. 262 | 263 | Delete an Entity and all of it's assigned Component instances from 264 | the world. By default, Entity deletion is delayed until the next call 265 | to :py:func:`esper.process`. You can, however, request immediate 266 | deletion by passing the `immediate=True` parameter. Note that immediate 267 | deletion may cause issues, such as when done during Entity iteration 268 | (calls to esper.get_component/s). 269 | 270 | Raises a KeyError if the given entity does not exist in the database. 271 | """ 272 | if immediate: 273 | for component_type in _entities[entity]: 274 | _components[component_type].discard(entity) 275 | 276 | if not _components[component_type]: 277 | del _components[component_type] 278 | 279 | del _entities[entity] 280 | clear_cache() 281 | 282 | else: 283 | _dead_entities.add(entity) 284 | 285 | 286 | def entity_exists(entity: int) -> bool: 287 | """Check if a specific Entity exists. 288 | 289 | Empty Entities (with no components) and dead Entities (destroyed 290 | by delete_entity) will not count as existent ones. 291 | """ 292 | return entity in _entities and entity not in _dead_entities 293 | 294 | 295 | def component_for_entity(entity: int, component_type: _Type[_C]) -> _C: 296 | """Retrieve a Component instance for a specific Entity. 297 | 298 | Retrieve a Component instance for a specific Entity. In some cases, 299 | it may be necessary to access a specific Component instance. 300 | For example: directly modifying a Component to handle user input. 301 | 302 | Raises a KeyError if the given Entity and Component do not exist. 303 | """ 304 | return _entities[entity][component_type] # type: ignore[no-any-return] 305 | 306 | 307 | def components_for_entity(entity: int) -> _Tuple[_C, ...]: 308 | """Retrieve all Components for a specific Entity, as a Tuple. 309 | 310 | Retrieve all Components for a specific Entity. This function is probably 311 | not appropriate to use in your Processors, but might be useful for 312 | saving state, or passing specific Components between World contexts. 313 | Unlike most other functions, this returns all the Components as a 314 | Tuple in one batch, instead of returning a Generator for iteration. 315 | 316 | Raises a KeyError if the given entity does not exist in the database. 317 | """ 318 | return tuple(_entities[entity].values()) 319 | 320 | 321 | def has_component(entity: int, component_type: _Type[_C]) -> bool: 322 | """Check if an Entity has a specific Component type.""" 323 | return component_type in _entities[entity] 324 | 325 | 326 | def has_components(entity: int, *component_types: _Type[_C]) -> bool: 327 | """Check if an Entity has all the specified Component types.""" 328 | components_dict = _entities[entity] 329 | return all(comp_type in components_dict for comp_type in component_types) 330 | 331 | 332 | def add_component(entity: int, component_instance: _C, type_alias: _Optional[_Type[_C]] = None) -> None: 333 | """Add a new Component instance to an Entity. 334 | 335 | Add a Component instance to an Entiy. If a Component of the same type 336 | is already assigned to the Entity, it will be replaced. 337 | 338 | A `type_alias` can also be provided. This can be useful if you're using 339 | subclasses to organize your Components, but would like to query them 340 | later by some common parent type. 341 | """ 342 | component_type = type_alias or type(component_instance) 343 | 344 | if component_type not in _components: 345 | _components[component_type] = set() 346 | 347 | _components[component_type].add(entity) 348 | 349 | _entities[entity][component_type] = component_instance 350 | clear_cache() 351 | 352 | 353 | def remove_component(entity: int, component_type: _Type[_C]) -> _C: 354 | """Remove a Component instance from an Entity, by type. 355 | 356 | A Component instance can only be removed by providing its type. 357 | For example: esper.delete_component(enemy_a, Velocity) will remove 358 | the Velocity instance from the Entity enemy_a. 359 | 360 | Raises a KeyError if either the given entity or Component type does 361 | not exist in the database. 362 | """ 363 | _components[component_type].discard(entity) 364 | 365 | if not _components[component_type]: 366 | del _components[component_type] 367 | 368 | clear_cache() 369 | return _entities[entity].pop(component_type) # type: ignore[no-any-return] 370 | 371 | 372 | def _get_component(component_type: _Type[_C]) -> _Iterable[_Tuple[int, _C]]: 373 | entity_db = _entities 374 | 375 | for entity in _components.get(component_type, []): 376 | yield entity, entity_db[entity][component_type] 377 | 378 | 379 | def _get_components(*component_types: _Type[_C]) -> _Iterable[_Tuple[int, _List[_C]]]: 380 | entity_db = _entities 381 | comp_db = _components 382 | 383 | try: 384 | for entity in set.intersection(*[comp_db[ct] for ct in component_types]): 385 | yield entity, [entity_db[entity][ct] for ct in component_types] 386 | except KeyError: 387 | pass 388 | 389 | 390 | def get_component(component_type: _Type[_C]) -> _List[_Tuple[int, _C]]: 391 | """Get an iterator for Entity, Component pairs.""" 392 | try: 393 | return _get_component_cache[component_type] 394 | except KeyError: 395 | return _get_component_cache.setdefault(component_type, list(_get_component(component_type))) 396 | 397 | 398 | @_overload 399 | def get_components(__c1: _Type[_C], __c2: _Type[_C2]) -> _List[_Tuple[int, _Tuple[_C, _C2]]]: 400 | ... 401 | 402 | 403 | @_overload 404 | def get_components(__c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _List[_Tuple[int, _Tuple[_C, _C2, _C3]]]: 405 | ... 406 | 407 | 408 | @_overload 409 | def get_components(__c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _List[ 410 | _Tuple[int, _Tuple[_C, _C2, _C3, _C4]]]: 411 | ... 412 | 413 | 414 | def get_components(*component_types: _Type[_Any]) -> _Iterable[_Tuple[int, _Tuple[_Any, ...]]]: 415 | """Get an iterator for Entity and multiple Component sets.""" 416 | try: 417 | return _get_components_cache[component_types] 418 | except KeyError: 419 | return _get_components_cache.setdefault(component_types, list(_get_components(*component_types))) 420 | 421 | 422 | def try_component(entity: int, component_type: _Type[_C]) -> _Optional[_C]: 423 | """Try to get a single component type for an Entity. 424 | 425 | This function will return the requested Component if it exists, 426 | or None if it does not. This allows a way to access optional Components 427 | that may or may not exist, without having to first query if the Entity 428 | has the Component type. 429 | """ 430 | if component_type in _entities[entity]: 431 | return _entities[entity][component_type] # type: ignore[no-any-return] 432 | return None 433 | 434 | 435 | @_overload 436 | def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2]) -> _Tuple[_C, _C2]: 437 | ... 438 | 439 | 440 | @_overload 441 | def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _Tuple[_C, _C2, _C3]: 442 | ... 443 | 444 | 445 | @_overload 446 | def try_components(entity: int, __c1: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _Tuple[_C, _C2, _C3, _C4]: 447 | ... 448 | 449 | 450 | def try_components(entity: int, *component_types: _Type[_C]) -> _Optional[_Tuple[_C, ...]]: 451 | """Try to get multiple component types for an Entity. 452 | 453 | This function will return the requested Components if they exist, 454 | or None if they do not. This allows a way to access optional Components 455 | that may or may not exist, without first having to query if the Entity 456 | has the Component types. 457 | """ 458 | if all(comp_type in _entities[entity] for comp_type in component_types): 459 | return [_entities[entity][comp_type] for comp_type in component_types] # type: ignore[return-value] 460 | return None 461 | 462 | 463 | def clear_dead_entities() -> None: 464 | """Finalize deletion of any Entities that are marked as dead. 465 | 466 | This function is provided for those who are not making use of 467 | :py:func:`esper.add_processor` and :py:func:`esper.process`. If you are 468 | calling your processors manually, this function should be called in 469 | your main loop after calling all processors. 470 | """ 471 | # In the interest of performance, this function duplicates code from the 472 | # `delete_entity` function. If that function is changed, those changes should 473 | # be duplicated here as well. 474 | for entity in _dead_entities: 475 | 476 | for component_type in _entities[entity]: 477 | _components[component_type].discard(entity) 478 | 479 | if not _components[component_type]: 480 | del _components[component_type] 481 | 482 | del _entities[entity] 483 | 484 | _dead_entities.clear() 485 | clear_cache() 486 | 487 | 488 | def process(*args: _Any, **kwargs: _Any) -> None: 489 | """Call the process method on all Processors, in order of their priority. 490 | 491 | Call the :py:meth:`esper.Processor.process` method on all assigned 492 | Processors, respective of their priority. In addition, any Entities 493 | that were marked for deletion since the last call will be deleted 494 | at the start of this call. 495 | """ 496 | clear_dead_entities() 497 | for processor in _processors: 498 | processor.process(*args, **kwargs) 499 | 500 | 501 | def timed_process(*args: _Any, **kwargs: _Any) -> None: 502 | """Track Processor execution time for benchmarking. 503 | 504 | This function is identical to :py:func:`esper.process`, but 505 | it additionally records the elapsed time of each processor 506 | (in milliseconds) in the :py:attr:`~process_times` dictionary 507 | after each call. 508 | """ 509 | clear_dead_entities() 510 | for processor in _processors: 511 | start_time = _time.process_time() 512 | processor.process(*args, **kwargs) 513 | process_times[processor.__class__.__name__] = int((_time.process_time() - start_time) * 1000) 514 | 515 | 516 | def list_worlds() -> _List[str]: 517 | """A list all World context names.""" 518 | return list(_context_map) 519 | 520 | 521 | def delete_world(name: str) -> None: 522 | """Delete a World context. 523 | 524 | This will completely delete the World, including all entities 525 | that are contained within it. 526 | 527 | Raises `PermissionError` if you attempt to delete the currently 528 | active World context. 529 | """ 530 | if _current_world == name: 531 | raise PermissionError("The active World context cannot be deleted.") 532 | 533 | del _context_map[name] 534 | 535 | 536 | def switch_world(name: str) -> None: 537 | """Switch to a new World context by name. 538 | 539 | Esper can have one or more "Worlds". Each World is a dedicated 540 | context, and does not share Entities, Components, events, etc. 541 | Some game designs can benefit from using a dedicated World 542 | for each scene. For other designs, a single World may be sufficient. 543 | 544 | This function will allow you to create and switch between as 545 | many World contexts as required. If the requested name does not 546 | exist, a new context is created automatically with that name. 547 | 548 | The name of the currently active World context can be checked 549 | at any time by examining the :py:attr:`esper.current_world`. 550 | This attribute gets updated whenever you switch Worlds, and 551 | modifying it has no effect. 552 | 553 | .. note:: At startup, a "default" World context is active. 554 | """ 555 | if name not in _context_map: 556 | # Create a new context if the name does not already exist: 557 | _context_map[name] = (_count(start=1), {}, {}, set(), {}, {}, [], {}, {}) 558 | 559 | global _current_world 560 | global _entity_count 561 | global _components 562 | global _entities 563 | global _dead_entities 564 | global _get_component_cache 565 | global _get_components_cache 566 | global _processors 567 | global process_times 568 | global event_registry 569 | global current_world 570 | 571 | # switch the references to the objects in the named context_map: 572 | (_entity_count, _components, _entities, _dead_entities, _get_component_cache, 573 | _get_components_cache, _processors, process_times, event_registry) = _context_map[name] 574 | _current_world = current_world = name 575 | -------------------------------------------------------------------------------- /esper/py.typed: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmoran56/esper/4b2eb377ad2525aa7816ea65ba205c05433bfe9c/esper/py.typed -------------------------------------------------------------------------------- /examples/benchmark.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import gc 5 | import sys 6 | import time 7 | import optparse 8 | 9 | from dataclasses import dataclass as component 10 | 11 | import esper 12 | 13 | ###################### 14 | # Commandline options: 15 | ###################### 16 | parser = optparse.OptionParser() 17 | parser.add_option("-p", "--plot", dest="plot", action="store_true", default=False, 18 | help="Display benchmark. Requires matplotlib module.") 19 | parser.add_option("-w", "--walltime", dest="walltime", action="store_true", default=False, 20 | help="Benchmark the 'wall clock' time, instead of process time.") 21 | parser.add_option("-e", "--entities", dest="entities", action="store", default=5000, type="int", 22 | help="Change the maximum number of Entities to benchmark. Default is 5000.") 23 | 24 | (options, arguments) = parser.parse_args() 25 | 26 | MAX_ENTITIES = options.entities 27 | if MAX_ENTITIES <= 500: 28 | print("The number of entities must be greater than 500.") 29 | sys.exit(1) 30 | 31 | if options.walltime: 32 | print("Benchmarking wall clock time...\n") 33 | time_query = time.time 34 | else: 35 | time_query = time.process_time 36 | 37 | 38 | ########################## 39 | # Simple timing decorator: 40 | ########################## 41 | def timing(f): 42 | def wrap(*args): 43 | time1 = time_query() 44 | ret = f(*args) 45 | time2 = time_query() 46 | result_times.append((time2 - time1)*1000.0) 47 | return ret 48 | return wrap 49 | 50 | 51 | ################################# 52 | # Define some generic components: 53 | ################################# 54 | @component 55 | class Velocity: 56 | x: int = 0 57 | y: int = 0 58 | 59 | 60 | @component 61 | class Position: 62 | x: int = 0 63 | y: int = 0 64 | 65 | 66 | @component 67 | class Health: 68 | hp: int = 100 69 | 70 | 71 | @component 72 | class Command: 73 | attack: bool = False 74 | defend: bool = True 75 | 76 | 77 | @component 78 | class Projectile: 79 | size: int = 10 80 | lifespan: int = 100 81 | 82 | 83 | @component 84 | class Damageable: 85 | defense: int = 45 86 | 87 | 88 | @component 89 | class Brain: 90 | smarts: int = 9000 91 | 92 | 93 | ############################# 94 | # Set up some dummy entities: 95 | ############################# 96 | def create_entities(number): 97 | for _ in range(number // 2): 98 | esper.create_entity(Position(), Velocity(), Health(), Command()) 99 | esper.create_entity(Position(), Health(), Damageable()) 100 | 101 | 102 | ############################# 103 | # Some timed query functions: 104 | ############################# 105 | @timing 106 | def single_comp_query(): 107 | for _, _ in esper.get_component(Position): 108 | pass 109 | 110 | 111 | @timing 112 | def two_comp_query(): 113 | for _, (_, _) in esper.get_components(Position, Velocity): 114 | pass 115 | 116 | 117 | @timing 118 | def three_comp_query(): 119 | for _, (_, _, _) in esper.get_components(Position, Damageable, Health): 120 | pass 121 | 122 | 123 | ################################################# 124 | # Perform several queries, and print the results: 125 | ################################################# 126 | results = {1: {}, 2: {}, 3: {}} 127 | result_times = [] 128 | 129 | for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50): 130 | create_entities(amount) 131 | for _ in range(50): 132 | single_comp_query() 133 | 134 | result_min = min(result_times) 135 | print("Query one component, {} Entities: {:f} ms".format(amount, result_min)) 136 | results[1][amount] = result_min 137 | result_times = [] 138 | esper.clear_database() 139 | gc.collect() 140 | 141 | for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50): 142 | create_entities(amount) 143 | for _ in range(50): 144 | two_comp_query() 145 | 146 | result_min = min(result_times) 147 | print("Query two components, {} Entities: {:f} ms".format(amount, result_min)) 148 | results[2][amount] = result_min 149 | result_times = [] 150 | esper.clear_database() 151 | gc.collect() 152 | 153 | for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50): 154 | create_entities(amount) 155 | for _ in range(50): 156 | three_comp_query() 157 | 158 | result_min = min(result_times) 159 | print("Query three components, {} Entities: {:f} ms".format(amount, result_min)) 160 | results[3][amount] = result_min 161 | result_times = [] 162 | esper.clear_database() 163 | gc.collect() 164 | 165 | 166 | ############################################# 167 | # Save the results to disk, or plot directly: 168 | ############################################# 169 | 170 | if not options.plot: 171 | print("\nRun 'benchmark.py --help' for details on plotting this benchmark.") 172 | 173 | if options.plot: 174 | try: 175 | from matplotlib import pyplot as plt 176 | except ImportError: 177 | print("\nThe matplotlib module is required for plotting results.") 178 | sys.exit(1) 179 | 180 | lines = [] 181 | for num, result in results.items(): 182 | x, y = zip(*sorted(result.items())) 183 | label = '%i Component%s' % (num, '' if num == 1 else 's') 184 | lines.extend(plt.plot(x, y, label=label)) 185 | 186 | plt.ylabel("Query Time (ms)") 187 | plt.xlabel("Number of Entities") 188 | plt.legend(handles=lines, bbox_to_anchor=(0.5, 1)) 189 | plt.show() 190 | -------------------------------------------------------------------------------- /examples/benchmark_cache.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import time 6 | import optparse 7 | 8 | from dataclasses import dataclass as component 9 | 10 | import esper 11 | 12 | try: 13 | from matplotlib import pyplot 14 | except ImportError: 15 | print("The matplotlib module is required for this benchmark.") 16 | raise Exception 17 | 18 | ###################### 19 | # Commandline options: 20 | ###################### 21 | parser = optparse.OptionParser() 22 | parser.add_option("-e", "--entities", dest="entities", action="store", default=5000, type="int", 23 | help="Change the maximum number of Entities to benchmark. Default is 5000.") 24 | 25 | (options, arguments) = parser.parse_args() 26 | 27 | MAX_ENTITIES = options.entities 28 | if MAX_ENTITIES <= 50: 29 | print("The number of entities must be greater than 500.") 30 | sys.exit(1) 31 | 32 | 33 | ########################## 34 | # Simple timing decorator: 35 | ########################## 36 | def timing(f): 37 | def wrap(*args): 38 | time1 = time.process_time() 39 | ret = f(*args) 40 | time2 = time.process_time() 41 | current_run.append((time2 - time1) * 1000.0) 42 | return ret 43 | return wrap 44 | 45 | 46 | ################################# 47 | # Define some generic components: 48 | ################################# 49 | @component 50 | class Velocity: 51 | x: float = 0.0 52 | y: float = 0.0 53 | 54 | 55 | @component 56 | class Position: 57 | x: float = 0.0 58 | y: float = 0.0 59 | 60 | 61 | @component 62 | class Health: 63 | hp: int = 100 64 | 65 | 66 | @component 67 | class Command: 68 | attack: bool = False 69 | defend: bool = True 70 | 71 | 72 | @component 73 | class Projectile: 74 | size: int = 10 75 | lifespan: int = 100 76 | 77 | 78 | @component 79 | class Damageable: 80 | defense: int = 45 81 | 82 | 83 | @component 84 | class Brain: 85 | smarts: int = 9000 86 | 87 | 88 | ########################## 89 | # Define some Processors: 90 | ########################## 91 | class MovementProcessor: 92 | def process(self): 93 | for ent, (vel, pos) in esper.get_components(Velocity, Position): 94 | pos.x += vel.x 95 | pos.y += vel.y 96 | print("Current Position: {}".format((int(pos.x), int(pos.y)))) 97 | 98 | 99 | ############################# 100 | # Set up some dummy entities: 101 | ############################# 102 | def create_entities(number): 103 | for _ in range(number // 2): 104 | esper.create_entity(Position(), Velocity(), Health(), Command()) 105 | esper.create_entity(Position(), Health(), Damageable()) 106 | 107 | 108 | ################################################# 109 | # Perform several queries, and print the results: 110 | ################################################# 111 | current_run = [] 112 | results = [] 113 | print("\nFor the first half of each pass, Entities are static.") 114 | print("For the second half, Entities are created/deleted each frame.\n") 115 | 116 | 117 | @timing 118 | def query_entities(): 119 | for _, (_, _) in esper.get_components(Position, Velocity): 120 | pass 121 | for _, (_, _, _) in esper.get_components(Health, Damageable, Position): 122 | pass 123 | 124 | 125 | for current_pass in range(10): 126 | esper.clear_database() 127 | create_entities(MAX_ENTITIES) 128 | 129 | print(f"Pass {current_pass + 1}...") 130 | 131 | for amount in range(1, 500): 132 | query_entities() 133 | 134 | if amount > 250: 135 | esper.delete_entity(amount, immediate=True) 136 | create_entities(1) 137 | 138 | results.append(current_run) 139 | current_run = [] 140 | 141 | averaged_results = [sorted(e)[0] for e in zip(*results)] 142 | 143 | pyplot.ylabel("Query time (ms)") 144 | pyplot.xlabel("Query of {} entities".format(MAX_ENTITIES)) 145 | pyplot.plot(averaged_results, label="Average query time") 146 | pyplot.legend(bbox_to_anchor=(0.5, 1)) 147 | pyplot.show() 148 | -------------------------------------------------------------------------------- /examples/bluesquare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmoran56/esper/4b2eb377ad2525aa7816ea65ba205c05433bfe9c/examples/bluesquare.png -------------------------------------------------------------------------------- /examples/headless_example.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from dataclasses import dataclass as component 4 | 5 | import esper 6 | 7 | 8 | ################################## 9 | # Define some Components: 10 | ################################## 11 | @component 12 | class Velocity: 13 | x: float = 0.0 14 | y: float = 0.0 15 | 16 | 17 | @component 18 | class Position: 19 | x: int = 0 20 | y: int = 0 21 | 22 | 23 | ################################ 24 | # Define some Processors: 25 | ################################ 26 | class MovementProcessor: 27 | def process(self): 28 | for ent, (vel, pos) in esper.get_components(Velocity, Position): 29 | pos.x += vel.x 30 | pos.y += vel.y 31 | print("Current Position: {}".format((int(pos.x), int(pos.y)))) 32 | 33 | 34 | ########################################################## 35 | # Instantiate everything, and create your main logic loop: 36 | ########################################################## 37 | def main(): 38 | # Instantiate a Processor (or more), and add them to the world: 39 | movement_processor = MovementProcessor() 40 | 41 | # Create entities, and assign Component instances to them: 42 | player = esper.create_entity() 43 | esper.add_component(player, Velocity(x=0.9, y=1.2)) 44 | esper.add_component(player, Position(x=5, y=5)) 45 | 46 | # A dummy main loop: 47 | try: 48 | while True: 49 | # Call esper.process() to run all Processors. 50 | movement_processor.process() 51 | time.sleep(1) 52 | 53 | except KeyboardInterrupt: 54 | return 55 | 56 | 57 | if __name__ == '__main__': 58 | print("\nHeadless Example. Press Ctrl+C to quit!\n") 59 | main() 60 | -------------------------------------------------------------------------------- /examples/pygame_example.py: -------------------------------------------------------------------------------- 1 | import pygame 2 | 3 | import esper 4 | 5 | 6 | FPS = 60 7 | RESOLUTION = 720, 480 8 | 9 | 10 | ################################## 11 | # Define some Components: 12 | ################################## 13 | class Velocity: 14 | def __init__(self, x=0.0, y=0.0): 15 | self.x = x 16 | self.y = y 17 | 18 | 19 | class Renderable: 20 | def __init__(self, image, posx, posy, depth=0): 21 | self.image = image 22 | self.depth = depth 23 | self.x = posx 24 | self.y = posy 25 | self.w = image.get_width() 26 | self.h = image.get_height() 27 | 28 | 29 | ################################ 30 | # Define some Processors: 31 | ################################ 32 | class MovementProcessor: 33 | def __init__(self, minx, maxx, miny, maxy): 34 | super().__init__() 35 | self.minx = minx 36 | self.maxx = maxx 37 | self.miny = miny 38 | self.maxy = maxy 39 | 40 | def process(self): 41 | # This will iterate over every Entity that has BOTH of these components: 42 | for ent, (vel, rend) in esper.get_components(Velocity, Renderable): 43 | # Update the Renderable Component's position by it's Velocity: 44 | rend.x += vel.x 45 | rend.y += vel.y 46 | # An example of keeping the sprite inside screen boundaries. Basically, 47 | # adjust the position back inside screen boundaries if it tries to go outside: 48 | rend.x = max(self.minx, rend.x) 49 | rend.y = max(self.miny, rend.y) 50 | rend.x = min(self.maxx - rend.w, rend.x) 51 | rend.y = min(self.maxy - rend.h, rend.y) 52 | 53 | 54 | class RenderProcessor: 55 | def __init__(self, window, clear_color=(0, 0, 0)): 56 | super().__init__() 57 | self.window = window 58 | self.clear_color = clear_color 59 | 60 | def process(self): 61 | # Clear the window: 62 | self.window.fill(self.clear_color) 63 | # This will iterate over every Entity that has this Component, and blit it: 64 | for ent, rend in esper.get_component(Renderable): 65 | self.window.blit(rend.image, (rend.x, rend.y)) 66 | # Flip the framebuffers 67 | pygame.display.flip() 68 | 69 | 70 | ################################ 71 | # The main core of the program: 72 | ################################ 73 | def run(): 74 | # Initialize Pygame stuff 75 | pygame.init() 76 | window = pygame.display.set_mode(RESOLUTION) 77 | pygame.display.set_caption("Esper Pygame example") 78 | clock = pygame.time.Clock() 79 | pygame.key.set_repeat(1, 1) 80 | 81 | # Initialize Esper world, and create a "player" Entity with a few Components. 82 | player = esper.create_entity() 83 | esper.add_component(player, Velocity(x=0, y=0)) 84 | esper.add_component(player, Renderable(image=pygame.image.load("redsquare.png"), posx=100, posy=100)) 85 | # Another motionless Entity: 86 | enemy = esper.create_entity() 87 | esper.add_component(enemy, Renderable(image=pygame.image.load("bluesquare.png"), posx=400, posy=250)) 88 | 89 | # Create some Processor instances, and asign them to be processed. 90 | render_processor = RenderProcessor(window=window) 91 | movement_processor = MovementProcessor(minx=0, maxx=RESOLUTION[0], miny=0, maxy=RESOLUTION[1]) 92 | 93 | running = True 94 | while running: 95 | for event in pygame.event.get(): 96 | if event.type == pygame.QUIT: 97 | running = False 98 | elif event.type == pygame.KEYDOWN: 99 | if event.key == pygame.K_LEFT: 100 | # Here is a way to directly access a specific Entity's 101 | # Velocity Component's attribute (y) without making a 102 | # temporary variable. 103 | esper.component_for_entity(player, Velocity).x = -3 104 | elif event.key == pygame.K_RIGHT: 105 | # For clarity, here is an alternate way in which a 106 | # temporary variable is created and modified. The previous 107 | # way above is recommended instead. 108 | player_velocity_component = esper.component_for_entity(player, Velocity) 109 | player_velocity_component.x = 3 110 | elif event.key == pygame.K_UP: 111 | esper.component_for_entity(player, Velocity).y = -3 112 | elif event.key == pygame.K_DOWN: 113 | esper.component_for_entity(player, Velocity).y = 3 114 | elif event.key == pygame.K_ESCAPE: 115 | running = False 116 | elif event.type == pygame.KEYUP: 117 | if event.key in (pygame.K_LEFT, pygame.K_RIGHT): 118 | esper.component_for_entity(player, Velocity).x = 0 119 | if event.key in (pygame.K_UP, pygame.K_DOWN): 120 | esper.component_for_entity(player, Velocity).y = 0 121 | 122 | # A single call to e.process() will update all Processors: 123 | render_processor.process() 124 | movement_processor.process() 125 | 126 | clock.tick(FPS) 127 | 128 | 129 | if __name__ == "__main__": 130 | run() 131 | pygame.quit() 132 | -------------------------------------------------------------------------------- /examples/pyglet_example.py: -------------------------------------------------------------------------------- 1 | import pyglet 2 | import esper 3 | 4 | 5 | FPS = 60 6 | RESOLUTION = 720, 480 7 | 8 | 9 | ################################## 10 | # Define some Components: 11 | ################################## 12 | class Velocity: 13 | def __init__(self, x=0.0, y=0.0): 14 | self.x = x 15 | self.y = y 16 | 17 | 18 | class Renderable: 19 | def __init__(self, sprite): 20 | self.sprite = sprite 21 | self.w = sprite.width 22 | self.h = sprite.height 23 | 24 | 25 | ################################ 26 | # Define some Processors: 27 | ################################ 28 | class MovementProcessor: 29 | def __init__(self, minx, maxx, miny, maxy): 30 | super().__init__() 31 | self.minx = minx 32 | self.miny = miny 33 | self.maxx = maxx 34 | self.maxy = maxy 35 | 36 | def process(self, dt): 37 | # This will iterate over every Entity that has BOTH of these components: 38 | for ent, (vel, rend) in esper.get_components(Velocity, Renderable): 39 | # Update the Renderable Component's position by its Velocity: 40 | # An example of keeping the sprite inside screen boundaries. Basically, 41 | # adjust the position back inside screen boundaries if it is outside: 42 | new_x = max(self.minx, rend.sprite.x + vel.x) 43 | new_y = max(self.miny, rend.sprite.y + vel.y) 44 | new_x = min(self.maxx - rend.w, new_x) 45 | new_y = min(self.maxy - rend.h, new_y) 46 | rend.sprite.position = new_x, new_y, rend.sprite.z 47 | 48 | 49 | ############################################### 50 | # Initialize pyglet window and graphics batch: 51 | ############################################### 52 | window = pyglet.window.Window(width=RESOLUTION[0], 53 | height=RESOLUTION[1], 54 | caption="Esper pyglet example") 55 | batch = pyglet.graphics.Batch() 56 | 57 | # Initialize Esper world, and create a "player" Entity with a few Components: 58 | player = esper.create_entity() 59 | esper.add_component(player, Velocity(x=0, y=0)) 60 | player_image = pyglet.resource.image("redsquare.png") 61 | esper.add_component(player, Renderable(sprite=pyglet.sprite.Sprite(img=player_image, 62 | x=100, 63 | y=100, 64 | batch=batch))) 65 | # Another motionless Entity: 66 | enemy = esper.create_entity() 67 | enemy_image = pyglet.resource.image("bluesquare.png") 68 | esper.add_component(enemy, Renderable(sprite=pyglet.sprite.Sprite(img=enemy_image, 69 | x=400, 70 | y=250, 71 | batch=batch))) 72 | 73 | # Create some Processor instances, and asign them to the World to be processed: 74 | movement_processor = MovementProcessor(minx=0, miny=0, maxx=RESOLUTION[0], maxy=RESOLUTION[1]) 75 | 76 | 77 | ################################################ 78 | # Set up pyglet events for input and rendering: 79 | ################################################ 80 | @window.event 81 | def on_key_press(key, mod): 82 | if key == pyglet.window.key.RIGHT: 83 | esper.component_for_entity(player, Velocity).x = 3 84 | if key == pyglet.window.key.LEFT: 85 | esper.component_for_entity(player, Velocity).x = -3 86 | if key == pyglet.window.key.UP: 87 | esper.component_for_entity(player, Velocity).y = 3 88 | if key == pyglet.window.key.DOWN: 89 | esper.component_for_entity(player, Velocity).y = -3 90 | 91 | 92 | @window.event 93 | def on_key_release(key, mod): 94 | if key in (pyglet.window.key.RIGHT, pyglet.window.key.LEFT): 95 | esper.component_for_entity(player, Velocity).x = 0 96 | if key in (pyglet.window.key.UP, pyglet.window.key.DOWN): 97 | esper.component_for_entity(player, Velocity).y = 0 98 | 99 | 100 | @window.event 101 | def on_draw(): 102 | # Clear the window: 103 | window.clear() 104 | # Draw the batch of Renderables: 105 | batch.draw() 106 | 107 | 108 | #################################################### 109 | # Schedule a World update and start the pyglet app: 110 | #################################################### 111 | if __name__ == "__main__": 112 | # NOTE! schedule_interval will automatically pass a "delta time" argument 113 | # to esper.process, so you must make sure that your Processor classes 114 | # account for this. See the example Processors above. 115 | pyglet.clock.schedule_interval(movement_processor.process, interval=1.0/FPS) 116 | pyglet.app.run() 117 | -------------------------------------------------------------------------------- /examples/pysdl2_example.py: -------------------------------------------------------------------------------- 1 | from sdl2 import * 2 | import sdl2.ext as ext 3 | import esper 4 | 5 | 6 | RESOLUTION = 720, 480 7 | 8 | 9 | ################################## 10 | # Define some Components: 11 | ################################## 12 | class Velocity: 13 | def __init__(self, x=0.0, y=0.0): 14 | self.x = x 15 | self.y = y 16 | 17 | 18 | class Renderable: 19 | def __init__(self, texture, width, height, posx, posy): 20 | self.texture = texture 21 | self.x = posx 22 | self.y = posy 23 | self.w = width 24 | self.h = height 25 | 26 | 27 | ################################ 28 | # Define some Processors: 29 | ################################ 30 | class MovementProcessor: 31 | def __init__(self, minx, maxx, miny, maxy): 32 | super().__init__() 33 | self.minx = minx 34 | self.maxx = maxx 35 | self.miny = miny 36 | self.maxy = maxy 37 | 38 | def process(self): 39 | # This will iterate over every Entity that has BOTH of these components: 40 | for ent, (vel, rend) in esper.get_components(Velocity, Renderable): 41 | # Update the Renderable Component's position by it's Velocity: 42 | rend.x += vel.x 43 | rend.y += vel.y 44 | # An example of keeping the sprite inside screen boundaries. Basically, 45 | # adjust the position back inside screen boundaries if it tries to go outside: 46 | rend.x = max(self.minx, rend.x) 47 | rend.y = max(self.miny, rend.y) 48 | rend.x = min(self.maxx - rend.w, rend.x) 49 | rend.y = min(self.maxy - rend.h, rend.y) 50 | 51 | 52 | class RenderProcessor: 53 | def __init__(self, renderer, clear_color=(0, 0, 0)): 54 | super().__init__() 55 | self.renderer = renderer 56 | self.clear_color = clear_color 57 | 58 | def process(self): 59 | # Clear the window: 60 | self.renderer.clear(self.clear_color) 61 | # Create a destination Rect for the texture: 62 | destination = SDL_Rect(0, 0, 0, 0) 63 | # This will iterate over every Entity that has this Component, and blit it: 64 | for ent, rend in esper.get_component(Renderable): 65 | destination.x = int(rend.x) 66 | destination.y = int(rend.y) 67 | destination.w = rend.w 68 | destination.h = rend.h 69 | SDL_RenderCopy(self.renderer.renderer, rend.texture, None, destination) 70 | self.renderer.present() 71 | 72 | 73 | ################################ 74 | # Some SDL2 Functions: 75 | ################################ 76 | def texture_from_image(renderer, image_name): 77 | """Create an SDL2 Texture from an image file""" 78 | soft_surface = ext.load_image(image_name) 79 | texture = SDL_CreateTextureFromSurface(renderer.renderer, soft_surface) 80 | SDL_FreeSurface(soft_surface) 81 | return texture 82 | 83 | 84 | ################################ 85 | # The main core of the program: 86 | ################################ 87 | def run(): 88 | # Initialize PySDL2 stuff 89 | ext.init() 90 | window = ext.Window(title="Esper PySDL2 example", size=RESOLUTION) 91 | renderer = ext.Renderer(target=window) 92 | window.show() 93 | 94 | # Initialize Esper world, and create a "player" Entity with a few Components. 95 | player = esper.create_entity() 96 | esper.add_component(player, Velocity(x=0, y=0)) 97 | esper.add_component(player, Renderable(texture=texture_from_image(renderer, "redsquare.png"), 98 | width=64, height=64, posx=100, posy=100)) 99 | # Another motionless Entity: 100 | enemy = esper.create_entity() 101 | esper.add_component(enemy, Renderable(texture=texture_from_image(renderer, "bluesquare.png"), 102 | width=64, height=64, posx=400, posy=250)) 103 | 104 | # Create some Processor instances, and asign them to be processed. 105 | render_processor = RenderProcessor(renderer=renderer) 106 | movement_processor = MovementProcessor(minx=0, maxx=RESOLUTION[0], miny=0, maxy=RESOLUTION[1]) 107 | 108 | # A simple main loop 109 | running = True 110 | while running: 111 | start_time = SDL_GetTicks() 112 | 113 | for event in ext.get_events(): 114 | if event.type == SDL_QUIT: 115 | running = False 116 | break 117 | if event.type == SDL_KEYDOWN: 118 | if event.key.keysym.sym == SDLK_UP: 119 | # Here is a way to directly access a specific Entity's Velocity 120 | # Component's attribute (y) without making a temporary variable. 121 | esper.component_for_entity(player, Velocity).y = -3 122 | elif event.key.keysym.sym == SDLK_DOWN: 123 | # For clarity, here is an alternate way in which a temporary variable 124 | # is created and modified. The previous way above is recommended instead. 125 | player_velocity_component = esper.component_for_entity(player, Velocity) 126 | player_velocity_component.y = 3 127 | elif event.key.keysym.sym == SDLK_LEFT: 128 | esper.component_for_entity(player, Velocity).x = -3 129 | elif event.key.keysym.sym == SDLK_RIGHT: 130 | esper.component_for_entity(player, Velocity).x = 3 131 | elif event.key.keysym.sym == SDLK_ESCAPE: 132 | running = False 133 | break 134 | elif event.type == SDL_KEYUP: 135 | if event.key.keysym.sym in (SDLK_UP, SDLK_DOWN): 136 | esper.component_for_entity(player, Velocity).y = 0 137 | if event.key.keysym.sym in (SDLK_LEFT, SDLK_RIGHT): 138 | esper.component_for_entity(player, Velocity).x = 0 139 | 140 | # A single call to esper.process() will update all Processors: 141 | render_processor.process() 142 | movement_processor.process() 143 | 144 | # A crude FPS limiter for about 60fps 145 | current_time = SDL_GetTicks() 146 | sleep_time = int(start_time + 16.667 - current_time) 147 | if sleep_time > 0: 148 | SDL_Delay(sleep_time) 149 | 150 | if __name__ == "__main__": 151 | run() 152 | ext.quit() 153 | -------------------------------------------------------------------------------- /examples/pythonista_ios_example.py: -------------------------------------------------------------------------------- 1 | from scene import * 2 | import esper 3 | 4 | ################################## 5 | ## Here are a couple of Components 6 | ################################## 7 | class Renderable(SpriteNode): 8 | def __init__(self, **kwargs): 9 | SpriteNode.__init__(self, **kwargs) 10 | 11 | 12 | class Velocity: 13 | def __init__(self, x=0.0, y=0.0): 14 | self.x = x 15 | self.y = y 16 | 17 | 18 | ############## 19 | ## A processor 20 | ############## 21 | class MovementProcessor(esper.Processor): 22 | def __init__(self): 23 | super().__init__() 24 | 25 | def process(self): 26 | for ent, (rend, vel) in self.world.get_components(Renderable, Velocity): 27 | rend.position += (vel.x, vel.y) 28 | move_action = Action.move_to(rend.position[0], rend.position[1], 0.7) 29 | rend.run_action(move_action) 30 | 31 | 32 | class MyScene (Scene): 33 | def setup(self): 34 | #Create a World Object 35 | self.newworld = esper.World() 36 | 37 | #Add the processor 38 | self.movement_processor = MovementProcessor() 39 | self.newworld.add_processor(self.movement_processor) 40 | 41 | #Create a couple of entities 42 | self.player = self.newworld.create_entity() 43 | self.newworld.add_component(self.player, Renderable(parent=self, 44 | texture='plc:Character_Boy', position=(100, 100))) 45 | self.newworld.add_component(self.player, Velocity(x=1, y=.5)) 46 | 47 | self.enemy = self.newworld.create_entity() 48 | self.newworld.add_component(self.enemy, Renderable(parent=self, 49 | texture='plc:Character_Pink_Girl', position=(200, 200))) 50 | self.newworld.add_component(self.enemy, Velocity(x=.5, y=0)) 51 | 52 | def did_change_size(self): 53 | pass 54 | 55 | def update(self): 56 | # Process the world at each update! 57 | self.newworld.process() 58 | 59 | def touch_began(self, touch): 60 | pass 61 | 62 | def touch_moved(self, touch): 63 | pass 64 | 65 | def touch_ended(self, touch): 66 | pass 67 | 68 | if __name__ == '__main__': 69 | run(MyScene(), show_fps=True) -------------------------------------------------------------------------------- /examples/redsquare.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmoran56/esper/4b2eb377ad2525aa7816ea65ba205c05433bfe9c/examples/redsquare.png -------------------------------------------------------------------------------- /make.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import sys 5 | import shlex 6 | import shutil 7 | 8 | from subprocess import call 9 | 10 | 11 | HERE = os.path.dirname(os.path.abspath(__file__)) 12 | 13 | 14 | def clean(): 15 | """Clean up all build & test artifacts.""" 16 | dirs = [os.path.join(HERE, 'dist'), 17 | os.path.join(HERE, 'build'), 18 | os.path.join(HERE, '_build'), 19 | os.path.join(HERE, 'esper.egg-info'), 20 | os.path.join(HERE, '.pytest_cache'), 21 | os.path.join(HERE, '.mypy_cache')] 22 | 23 | for d in dirs: 24 | print(f' --> Deleting: {d}') 25 | shutil.rmtree(d, ignore_errors=True) 26 | 27 | 28 | def dist(): 29 | """Create sdist and wheels, then upload to PyPi.""" 30 | call(shlex.split("flit publish")) 31 | 32 | 33 | if __name__ == '__main__': 34 | 35 | def _print_usage(): 36 | print('Usage: make.py ') 37 | print(' where commands are:', ', '.join(avail_cmds)) 38 | print() 39 | for name, cmd in avail_cmds.items(): 40 | print(name, '\t', cmd.__doc__) 41 | 42 | avail_cmds = dict(clean=clean, dist=dist) 43 | 44 | try: 45 | command = avail_cmds[sys.argv[1]] 46 | except IndexError: 47 | _print_usage() 48 | 49 | except KeyError: 50 | print('Unknown command:', sys.argv[1]) 51 | print() 52 | _print_usage() 53 | else: 54 | command() 55 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core >=3.2,<4"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "esper" 7 | authors = [{name = "Benjamin Moran", email = "benmoran@protonmail.com"}] 8 | readme = "README.md" 9 | license = {file = "LICENSE"} 10 | classifiers = ["License :: OSI Approved :: MIT License"] 11 | dynamic = ["version", "description"] 12 | requires-python = ">=3.8" 13 | 14 | [project.urls] 15 | Home = "https://github.com/benmoran56/esper" 16 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benmoran56/esper/4b2eb377ad2525aa7816ea65ba205c05433bfe9c/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_world.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import esper 4 | 5 | 6 | # ECS test 7 | @pytest.fixture(autouse=True) 8 | def _reset_to_zero(): 9 | # Wipe out all world contexts 10 | # and re-create the default. 11 | esper._context_map.clear() 12 | esper.switch_world("default") 13 | 14 | 15 | def test_create_entity(): 16 | entity1 = esper.create_entity() 17 | entity2 = esper.create_entity() 18 | assert isinstance(entity1, int) 19 | assert isinstance(entity2, int) 20 | assert entity1 < entity2 21 | 22 | 23 | def test_create_entity_with_components(): 24 | entity1 = esper.create_entity(ComponentA()) 25 | entity2 = esper.create_entity(ComponentB(), ComponentC()) 26 | assert esper.has_component(entity1, ComponentA) is True 27 | assert esper.has_component(entity1, ComponentB) is False 28 | assert esper.has_component(entity1, ComponentC) is False 29 | assert esper.has_component(entity2, ComponentA) is False 30 | assert esper.has_component(entity2, ComponentB) is True 31 | assert esper.has_component(entity2, ComponentC) is True 32 | 33 | 34 | def test_adding_component_to_not_existing_entity_raises_error(): 35 | with pytest.raises(KeyError): 36 | esper.add_component(123, ComponentA()) 37 | 38 | 39 | def test_create_entity_and_add_components(): 40 | entity1 = esper.create_entity() 41 | esper.add_component(entity1, ComponentA()) 42 | esper.add_component(entity1, ComponentB()) 43 | assert esper.has_component(entity1, ComponentA) is True 44 | assert esper.has_component(entity1, ComponentC) is False 45 | 46 | 47 | def test_create_entity_and_add_components_with_alias(): 48 | entity = esper.create_entity() 49 | esper.add_component(entity, ComponentA(), type_alias=ComponentF) 50 | assert esper.has_component(entity, ComponentF) is True 51 | assert esper.component_for_entity(entity, ComponentF).a == -66 # type: ignore[attr-defined] 52 | 53 | 54 | def test_delete_entity(): 55 | esper.create_entity(ComponentA()) 56 | entity_b = esper.create_entity(ComponentB()) 57 | entity_c = esper.create_entity(ComponentC()) 58 | empty_entity = esper.create_entity() 59 | assert entity_c == 3 60 | esper.delete_entity(entity_b, immediate=True) 61 | with pytest.raises(KeyError): 62 | esper.components_for_entity(entity_b) 63 | with pytest.raises(KeyError): 64 | esper.delete_entity(999, immediate=True) 65 | esper.delete_entity(empty_entity, immediate=True) 66 | 67 | 68 | def test_component_for_entity(): 69 | entity = esper.create_entity(ComponentC()) 70 | assert isinstance(esper.component_for_entity(entity, ComponentC), ComponentC) 71 | with pytest.raises(KeyError): 72 | esper.component_for_entity(entity, ComponentD) 73 | 74 | 75 | def test_components_for_entity(): 76 | entity = esper.create_entity(ComponentA(), ComponentD(), ComponentE()) 77 | all_components: tuple[..., ...] = esper.components_for_entity(entity) 78 | assert isinstance(all_components, tuple) 79 | assert len(all_components) == 3 80 | with pytest.raises(KeyError): 81 | esper.components_for_entity(999) 82 | 83 | 84 | def test_has_component(): 85 | entity1 = esper.create_entity(ComponentA()) 86 | entity2 = esper.create_entity(ComponentB()) 87 | assert esper.has_component(entity1, ComponentA) is True 88 | assert esper.has_component(entity1, ComponentB) is False 89 | assert esper.has_component(entity2, ComponentA) is False 90 | assert esper.has_component(entity2, ComponentB) is True 91 | 92 | 93 | def test_has_components(): 94 | entity = esper.create_entity(ComponentA(), ComponentB(), ComponentC()) 95 | assert esper.has_components(entity, ComponentA, ComponentB) is True 96 | assert esper.has_components(entity, ComponentB, ComponentC) is True 97 | assert esper.has_components(entity, ComponentA, ComponentC) is True 98 | assert esper.has_components(entity, ComponentA, ComponentD) is False 99 | assert esper.has_components(entity, ComponentD) is False 100 | 101 | 102 | def test_get_component(): 103 | create_entities(2000) 104 | assert isinstance(esper.get_component(ComponentA), list) 105 | # Confirm that the actually contains something: 106 | assert len(esper.get_component(ComponentA)) > 0, "No Components Returned" 107 | 108 | for ent, comp in esper.get_component(ComponentA): 109 | assert isinstance(ent, int) 110 | assert isinstance(comp, ComponentA) 111 | 112 | 113 | def test_get_two_components(): 114 | create_entities(2000) 115 | assert isinstance(esper.get_components(ComponentD, ComponentE), list) 116 | # Confirm that the actually contains something: 117 | assert len(esper.get_components(ComponentD, ComponentE)) > 0, "No Components Returned" 118 | 119 | for ent, comps in esper.get_components(ComponentD, ComponentE): 120 | assert isinstance(ent, int) 121 | assert isinstance(comps, list) 122 | assert len(comps) == 2 123 | 124 | for ent, (d, e) in esper.get_components(ComponentD, ComponentE): 125 | assert isinstance(ent, int) 126 | assert isinstance(d, ComponentD) 127 | assert isinstance(e, ComponentE) 128 | 129 | 130 | def test_get_three_components(): 131 | create_entities(2000) 132 | assert isinstance(esper.get_components(ComponentC, ComponentD, ComponentE), list) 133 | 134 | for ent, comps in esper.get_components(ComponentC, ComponentD, ComponentE): 135 | assert isinstance(ent, int) 136 | assert isinstance(comps, list) 137 | assert len(comps) == 3 138 | 139 | for ent, (c, d, e) in esper.get_components(ComponentC, ComponentD, ComponentE): 140 | assert isinstance(ent, int) 141 | assert isinstance(c, ComponentC) 142 | assert isinstance(d, ComponentD) 143 | assert isinstance(e, ComponentE) 144 | 145 | 146 | def test_try_component(): 147 | entity1 = esper.create_entity(ComponentA(), ComponentB()) 148 | 149 | one_item = esper.try_component(entity=entity1, component_type=ComponentA) 150 | assert isinstance(one_item, ComponentA) 151 | 152 | zero_item = esper.try_component(entity=entity1, component_type=ComponentC) 153 | assert zero_item is None 154 | 155 | 156 | def test_try_components(): 157 | entity1 = esper.create_entity(ComponentA(), ComponentB()) 158 | 159 | one_item = esper.try_components(entity1, ComponentA, ComponentB) 160 | assert isinstance(one_item, list) 161 | assert len(one_item) == 2 162 | assert isinstance(one_item[0], ComponentA) 163 | assert isinstance(one_item[1], ComponentB) 164 | 165 | zero_item = esper.try_components(entity1, ComponentA, ComponentC) 166 | assert zero_item is None 167 | 168 | 169 | def test_clear_database(): 170 | create_entities(2000) 171 | assert len(esper.get_component(ComponentA)) == 1000 172 | esper.clear_database() 173 | assert len(esper.get_component(ComponentA)) == 0 174 | 175 | 176 | def test_clear_cache(): 177 | create_entities(2000) 178 | assert len(esper.get_component(ComponentA)) == 1000 179 | esper.clear_cache() 180 | assert len(esper.get_component(ComponentA)) == 1000 181 | 182 | 183 | def test_cache_results(): 184 | _______ = esper.create_entity(ComponentA(), ComponentB(), ComponentC()) 185 | entity2 = esper.create_entity(ComponentB(), ComponentC(), ComponentD()) 186 | assert len(esper.get_components(ComponentB, ComponentC)) == 2 187 | esper.delete_entity(entity2, immediate=True) 188 | assert len(esper.get_components(ComponentB, ComponentC)) == 1 189 | 190 | 191 | class TestEntityExists: 192 | def test_dead_entity(self): 193 | dead_entity = esper.create_entity(ComponentB()) 194 | esper.delete_entity(dead_entity) 195 | assert not esper.entity_exists(dead_entity) 196 | 197 | def test_not_created_entity(self): 198 | assert not esper.entity_exists(123) 199 | 200 | def test_empty_entity(self): 201 | empty_entity = esper.create_entity() 202 | assert esper.entity_exists(empty_entity) 203 | 204 | def test_entity_with_component(self): 205 | entity_with_component = esper.create_entity(ComponentA()) 206 | assert esper.entity_exists(entity_with_component) 207 | 208 | 209 | class TestRemoveComponent: 210 | def test_remove_from_not_existing_entity_raises_key_error(self): 211 | with pytest.raises(KeyError): 212 | esper.remove_component(123, ComponentA) 213 | 214 | def test_remove_not_existing_component_raises_key_error(self): 215 | entity = esper.create_entity(ComponentB()) 216 | 217 | with pytest.raises(KeyError): 218 | esper.remove_component(entity, ComponentA) 219 | 220 | def test_remove_component_with_object_raises_key_error(self): 221 | create_entities(2000) 222 | entity = 2 223 | component = ComponentD() 224 | 225 | assert esper.has_component(entity, type(component)) 226 | with pytest.raises(KeyError): 227 | esper.remove_component(entity, component) # type: ignore[arg-type] 228 | 229 | def test_remove_component_returns_removed_instance(self): 230 | component = ComponentA() 231 | entity = esper.create_entity(component) 232 | 233 | result = esper.remove_component(entity, type(component)) 234 | 235 | assert result is component 236 | 237 | def test_remove_last_component_leaves_empty_entity(self): 238 | entity = esper.create_entity() 239 | esper.add_component(entity, ComponentA()) 240 | 241 | esper.remove_component(entity, ComponentA) 242 | 243 | assert not esper.has_component(entity, ComponentA) 244 | assert esper.entity_exists(entity) 245 | 246 | def test_removing_one_component_leaves_other_intact(self): 247 | component_a = ComponentA() 248 | component_b = ComponentB() 249 | component_c = ComponentC() 250 | entity = esper.create_entity(component_a, component_b, component_c) 251 | 252 | esper.remove_component(entity, ComponentB) 253 | 254 | assert esper.component_for_entity(entity, ComponentA) is component_a 255 | assert not esper.has_component(entity, ComponentB) 256 | assert esper.component_for_entity(entity, ComponentC) is component_c 257 | 258 | 259 | def test_clear_dead_entities(): 260 | component = ComponentA() 261 | entity1 = esper.create_entity(component) 262 | entity2 = esper.create_entity() 263 | assert esper.entity_exists(entity1) 264 | assert esper.entity_exists(entity2) 265 | assert esper.has_component(entity1, ComponentA) 266 | esper.delete_entity(entity1, immediate=False) 267 | assert not esper.entity_exists(entity1) 268 | assert esper.entity_exists(entity2) 269 | assert esper.has_component(entity1, ComponentA) 270 | esper.clear_dead_entities() 271 | assert not esper.entity_exists(entity1) 272 | assert esper.entity_exists(entity2) 273 | with pytest.raises(KeyError): 274 | assert esper.has_component(entity1, ComponentA) 275 | 276 | 277 | def test_switch_world(): 278 | # The `create_entities` helper will add /2 of 279 | # 'ComponentA' to the World context. Make a new 280 | # "left" context, and confirm this is True: 281 | esper.switch_world("left") 282 | assert len(esper.get_component(ComponentA)) == 0 283 | create_entities(200) 284 | assert len(esper.get_component(ComponentA)) == 100 285 | 286 | # Switching to a new "right" World context, no 287 | # 'ComponentA' Components should yet exist. 288 | esper.switch_world("right") 289 | assert len(esper.get_component(ComponentA)) == 0 290 | create_entities(300) 291 | assert len(esper.get_component(ComponentA)) == 150 292 | 293 | # Switching back to the original "left" context, 294 | # the original 100 Components should still exist. 295 | # From there, 200 more should be added: 296 | esper.switch_world("left") 297 | assert len(esper.get_component(ComponentA)) == 100 298 | create_entities(400) 299 | assert len(esper.get_component(ComponentA)) == 300 300 | 301 | 302 | ################################################## 303 | # Some helper functions and Component templates: 304 | ################################################## 305 | def create_entities(number): 306 | """This function will create X number of entities. 307 | 308 | The entities are created with a mix of Components, 309 | so the World context will see an addition of 310 | ComponentA * number * 1 311 | ComponentB * number * 1 312 | ComponentC * number * 2 313 | ComponentD * number * 1 314 | ComponentE * number * 1 315 | """ 316 | for _ in range(number // 2): 317 | esper.create_entity(ComponentA(), ComponentB(), ComponentC()) 318 | esper.create_entity(ComponentC(), ComponentD(), ComponentE()) 319 | 320 | 321 | class ComponentA: 322 | def __init__(self): 323 | self.a = -66 324 | self.b = 9999.99 325 | 326 | 327 | class ComponentB: 328 | def __init__(self): 329 | self.attrib_a = True 330 | self.attrib_b = False 331 | self.attrib_c = False 332 | self.attrib_d = True 333 | 334 | 335 | class ComponentC: 336 | def __init__(self): 337 | self.x = 0 338 | self.y = 0 339 | self.z = None 340 | 341 | 342 | class ComponentD: 343 | def __init__(self): 344 | self.direction = "left" 345 | self.previous = "right" 346 | 347 | 348 | class ComponentE: 349 | def __init__(self): 350 | self.items = {"itema": None, "itemb": 1000} 351 | self.points = [a + 2 for a in list(range(44))] 352 | 353 | 354 | class ComponentF: 355 | pass 356 | 357 | 358 | # Processor test 359 | def test_add_processor(): 360 | create_entities(2000) 361 | assert len(esper._processors) == 0 362 | correct_processor_a = CorrectProcessorA() 363 | assert isinstance(correct_processor_a, esper.Processor) 364 | esper.add_processor(correct_processor_a) 365 | assert len(esper._processors) == 1 366 | assert isinstance(esper._processors[0], esper.Processor) 367 | 368 | 369 | def test_remove_processor(): 370 | create_entities(2000) 371 | assert len(esper._processors) == 0 372 | correct_processor_a = CorrectProcessorA() 373 | esper.add_processor(correct_processor_a) 374 | assert len(esper._processors) == 1 375 | esper.remove_processor(CorrectProcessorB) 376 | assert len(esper._processors) == 1 377 | esper.remove_processor(CorrectProcessorA) 378 | assert len(esper._processors) == 0 379 | 380 | 381 | def test_get_processor(): 382 | processor_a = CorrectProcessorA() 383 | processor_b = CorrectProcessorB() 384 | processor_c = CorrectProcessorC() 385 | 386 | esper.add_processor(processor_a) 387 | esper.add_processor(processor_b) 388 | esper.add_processor(processor_c) 389 | 390 | retrieved_proc_c = esper.get_processor(CorrectProcessorC) 391 | retrieved_proc_b = esper.get_processor(CorrectProcessorB) 392 | retrieved_proc_a = esper.get_processor(CorrectProcessorA) 393 | assert type(retrieved_proc_a) == CorrectProcessorA 394 | assert type(retrieved_proc_b) == CorrectProcessorB 395 | assert type(retrieved_proc_c) == CorrectProcessorC 396 | 397 | 398 | def test_processor_args(): 399 | esper.add_processor(ArgsProcessor()) 400 | with pytest.raises(TypeError): 401 | esper.process() # Missing argument 402 | esper.process("arg") 403 | 404 | 405 | def test_processor_kwargs(): 406 | esper.add_processor(KwargsProcessor()) 407 | with pytest.raises(TypeError): 408 | esper.process() # Missing argument 409 | esper.process("spam", "eggs") 410 | esper.process("spam", eggs="eggs") 411 | esper.process(spam="spam", eggs="eggs") 412 | esper.process(eggs="eggs", spam="spam") 413 | 414 | 415 | # Event dispatch test 416 | def test_event_dispatch_no_handlers(): 417 | esper.dispatch_event("foo") 418 | esper.dispatch_event("foo", 1) 419 | esper.dispatch_event("foo", 1, 2) 420 | esper.event_registry.clear() 421 | 422 | 423 | def test_event_dispatch_one_arg(): 424 | esper.set_handler("foo", myhandler_onearg) 425 | esper.dispatch_event("foo", 1) 426 | esper.event_registry.clear() 427 | 428 | 429 | def test_event_dispatch_two_args(): 430 | esper.set_handler("foo", myhandler_twoargs) 431 | esper.dispatch_event("foo", 1, 2) 432 | esper.event_registry.clear() 433 | 434 | 435 | def test_event_dispatch_incorrect_args(): 436 | esper.set_handler("foo", myhandler_noargs) 437 | with pytest.raises(TypeError): 438 | esper.dispatch_event("foo", "arg1", "arg2") 439 | esper.event_registry.clear() 440 | 441 | 442 | def test_set_methoad_as_handler_in_init(): 443 | 444 | class MyClass(esper.Processor): 445 | 446 | def __init__(self): 447 | esper.set_handler("foo", self._my_handler) 448 | 449 | @staticmethod 450 | def _my_handler(): 451 | print("OK") 452 | 453 | def process(self, dt): 454 | pass 455 | 456 | _myclass = MyClass() 457 | esper.dispatch_event("foo") 458 | esper.event_registry.clear() 459 | 460 | 461 | def test_set_instance_methoad_as_handler(): 462 | class MyClass(esper.Processor): 463 | 464 | @staticmethod 465 | def my_handler(): 466 | print("OK") 467 | 468 | def process(self, dt): 469 | pass 470 | 471 | myclass = MyClass() 472 | esper.set_handler("foo", myclass.my_handler) 473 | esper.dispatch_event("foo") 474 | esper.event_registry.clear() 475 | 476 | 477 | def test_event_handler_switch_world(): 478 | called = 0 479 | def handler(): 480 | nonlocal called 481 | called += 1 482 | 483 | # Switch to a new "left" World context, and register 484 | # an event handler. Confirm that it is being called 485 | # by checking that the 'called' variable is incremented. 486 | esper.switch_world("left") 487 | esper.set_handler("foo", handler) 488 | assert called == 0 489 | esper.dispatch_event("foo") 490 | assert called == 1 491 | 492 | # Here we switch to a new "right" World context. 493 | # The handler is registered to the "left" context only, 494 | # so dispatching the event should have no effect. The 495 | # handler is not attached, and so the 'called' value 496 | # should not be incremented further. 497 | esper.switch_world("right") 498 | esper.dispatch_event("foo") 499 | assert called == 1 500 | 501 | # Switching back to the "left" context and dispatching 502 | # the event, the handler should still be registered and 503 | # the 'called' variable should be incremented by 1. 504 | esper.switch_world("left") 505 | esper.dispatch_event("foo") 506 | assert called == 2 507 | 508 | def test_remove_handler(): 509 | def handler(): 510 | pass 511 | 512 | assert esper.event_registry == {} 513 | esper.set_handler("foo", handler) 514 | assert "foo" in esper.event_registry 515 | esper.remove_handler("foo", handler) 516 | assert esper.event_registry == {} 517 | 518 | 519 | ################################################## 520 | # Some helper functions and Component templates: 521 | ################################################## 522 | class CorrectProcessorA(esper.Processor): 523 | 524 | def process(self): 525 | pass 526 | 527 | 528 | class CorrectProcessorB(esper.Processor): 529 | def __init__(self, x=0, y=0): 530 | self.x = x 531 | self.y = y 532 | 533 | def process(self): 534 | pass 535 | 536 | 537 | class CorrectProcessorC(esper.Processor): 538 | 539 | def process(self): 540 | pass 541 | 542 | 543 | class ArgsProcessor(esper.Processor): 544 | 545 | def process(self, spam): 546 | pass 547 | 548 | 549 | class KwargsProcessor(esper.Processor): 550 | def process(self, spam, eggs): 551 | pass 552 | 553 | 554 | class IncorrectProcessor: 555 | 556 | def process(self): 557 | pass 558 | 559 | 560 | # Event handler templates: 561 | 562 | def myhandler_noargs(): 563 | print("OK") 564 | 565 | 566 | def myhandler_onearg(arg): 567 | print("Arg:", arg) 568 | 569 | 570 | def myhandler_twoargs(arg1, arg2): 571 | print("Args:", arg1, arg2) 572 | --------------------------------------------------------------------------------