esper 3.4__tar.gz → 3.6__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {esper-3.4 → esper-3.6}/.github/workflows/unit-tests.yml +3 -3
- {esper-3.4 → esper-3.6}/PKG-INFO +4 -3
- {esper-3.4 → esper-3.6}/README.md +1 -1
- {esper-3.4 → esper-3.6}/RELEASE_NOTES +32 -0
- {esper-3.4 → esper-3.6}/docs/index.rst +1 -0
- {esper-3.4 → esper-3.6}/esper/__init__.py +256 -171
- {esper-3.4 → esper-3.6}/examples/benchmark.py +109 -0
- {esper-3.4 → esper-3.6}/tests/test_world.py +186 -3
- {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/question-or-comment.md +0 -0
- {esper-3.4 → esper-3.6}/.github/workflows/type-checking.yml +0 -0
- {esper-3.4 → esper-3.6}/.gitignore +0 -0
- {esper-3.4 → esper-3.6}/.mypy.ini +0 -0
- {esper-3.4 → esper-3.6}/.readthedocs.yaml +0 -0
- {esper-3.4 → esper-3.6}/.ruff.toml +0 -0
- {esper-3.4 → esper-3.6}/LICENSE +0 -0
- {esper-3.4 → esper-3.6}/MANIFEST.in +0 -0
- {esper-3.4 → esper-3.6}/docs/Makefile +0 -0
- {esper-3.4 → esper-3.6}/docs/conf.py +0 -0
- {esper-3.4 → esper-3.6}/docs/make.bat +0 -0
- {esper-3.4 → esper-3.6}/esper/py.typed +0 -0
- {esper-3.4 → esper-3.6}/examples/benchmark_cache.py +0 -0
- {esper-3.4 → esper-3.6}/examples/bluesquare.png +0 -0
- {esper-3.4 → esper-3.6}/examples/headless_example.py +0 -0
- {esper-3.4 → esper-3.6}/examples/pygame_example.py +0 -0
- {esper-3.4 → esper-3.6}/examples/pyglet_example.py +0 -0
- {esper-3.4 → esper-3.6}/examples/pysdl2_example.py +0 -0
- {esper-3.4 → esper-3.6}/examples/pythonista_ios_example.py +0 -0
- {esper-3.4 → esper-3.6}/examples/redsquare.png +0 -0
- {esper-3.4 → esper-3.6}/make.py +0 -0
- {esper-3.4 → esper-3.6}/pyproject.toml +0 -0
- {esper-3.4 → esper-3.6}/tests/__init__.py +0 -0
|
@@ -11,12 +11,12 @@ jobs:
|
|
|
11
11
|
strategy:
|
|
12
12
|
matrix:
|
|
13
13
|
os: [ 'ubuntu-latest', 'macos-latest', 'windows-latest' ]
|
|
14
|
-
python-version: [ '3.
|
|
14
|
+
python-version: [ '3.9', '3.10', '3.11', '3.12', '3.13', '3.14', 'pypy-3.10' ]
|
|
15
15
|
steps:
|
|
16
16
|
- name: Python ${{ matrix.python-version }} ${{ matrix.os }}
|
|
17
|
-
uses: actions/checkout@
|
|
17
|
+
uses: actions/checkout@v4
|
|
18
18
|
- name: Set up Python ${{ matrix.python-version }}
|
|
19
|
-
uses: actions/setup-python@
|
|
19
|
+
uses: actions/setup-python@v5
|
|
20
20
|
with:
|
|
21
21
|
python-version: ${{ matrix.python-version }}
|
|
22
22
|
- name: Install test dependencies
|
{esper-3.4 → esper-3.6}/PKG-INFO
RENAMED
|
@@ -1,11 +1,12 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: esper
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.6
|
|
4
4
|
Summary: esper is a lightweight Entity System (ECS) for Python, with a focus on performance
|
|
5
5
|
Author-email: Benjamin Moran <benmoran@protonmail.com>
|
|
6
6
|
Requires-Python: >=3.8
|
|
7
7
|
Description-Content-Type: text/markdown
|
|
8
8
|
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
License-File: LICENSE
|
|
9
10
|
Project-URL: Home, https://github.com/benmoran56/esper
|
|
10
11
|
|
|
11
12
|
[](https://pypi.python.org/pypi/esper)
|
|
@@ -253,7 +254,7 @@ remove Components inside your Processors. The following functions are available
|
|
|
253
254
|
* esper.remove_component(entity_id, ComponentClass)
|
|
254
255
|
|
|
255
256
|
As an example of this, you could have a "Blink" component with a `duration` attribute. This can be used
|
|
256
|
-
to make certain things blink for
|
|
257
|
+
to make certain things blink for a specific period of time, then disappear. For example, the code below
|
|
257
258
|
shows a simplified case of adding this Component to an Entity when it takes damage in one processor. A
|
|
258
259
|
dedicated `BlinkProcessor` handles the effect, and then removes the Component after the duration expires::
|
|
259
260
|
|
|
@@ -243,7 +243,7 @@ remove Components inside your Processors. The following functions are available
|
|
|
243
243
|
* esper.remove_component(entity_id, ComponentClass)
|
|
244
244
|
|
|
245
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
|
|
246
|
+
to make certain things blink for a specific period of time, then disappear. For example, the code below
|
|
247
247
|
shows a simplified case of adding this Component to an Entity when it takes damage in one processor. A
|
|
248
248
|
dedicated `BlinkProcessor` handles the effect, and then removes the Component after the duration expires::
|
|
249
249
|
|
|
@@ -1,3 +1,35 @@
|
|
|
1
|
+
esper 3.6
|
|
2
|
+
=========
|
|
3
|
+
Maintenance release
|
|
4
|
+
|
|
5
|
+
Changes
|
|
6
|
+
-------
|
|
7
|
+
- Add a new `try_remove_component` function as a convenience instead of (has_component + remove_component).
|
|
8
|
+
- Start migrating type hints to the modern syntax.
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
esper 3.5
|
|
12
|
+
=========
|
|
13
|
+
Performance-focused release with major internal optimizations.
|
|
14
|
+
|
|
15
|
+
Changes
|
|
16
|
+
-------
|
|
17
|
+
- Optimized `get_components` to iterate over the smallest set of entities, drastically speeding up queries with rare components.
|
|
18
|
+
- Implemented lazy cache invalidation to reduce overhead from frequent entity modifications.
|
|
19
|
+
- Sped up `get_processor` and `remove_processor` to be constant time (O(1)) operations.
|
|
20
|
+
- Minor performance improvements to entity creation and deletion routines.
|
|
21
|
+
- Expanded Benchmarks.
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
esper 3.4
|
|
25
|
+
=========
|
|
26
|
+
Maintenance release
|
|
27
|
+
|
|
28
|
+
Changes
|
|
29
|
+
-------
|
|
30
|
+
- `esper.remove_handler` should use a reference instead of direct object comparison. (#107)
|
|
31
|
+
|
|
32
|
+
|
|
1
33
|
esper 3.3
|
|
2
34
|
=========
|
|
3
35
|
Maintenance release
|
|
@@ -49,6 +49,7 @@ The World context
|
|
|
49
49
|
.. autofunction:: esper.components_for_entity
|
|
50
50
|
.. autofunction:: esper.add_component
|
|
51
51
|
.. autofunction:: esper.remove_component
|
|
52
|
+
.. autofunction:: esper.try_remove_component
|
|
52
53
|
.. autofunction:: esper.get_component
|
|
53
54
|
.. autofunction:: esper.get_components
|
|
54
55
|
.. autofunction:: esper.has_component
|
|
@@ -2,28 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
More information is available at https://github.com/benmoran56/esper
|
|
4
4
|
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
5
7
|
import time as _time
|
|
6
8
|
|
|
7
9
|
from types import MethodType as _MethodType
|
|
8
10
|
|
|
9
11
|
from typing import Any as _Any
|
|
10
12
|
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
13
|
from typing import TypeVar as _TypeVar
|
|
17
14
|
from typing import Iterable as _Iterable
|
|
18
|
-
from typing import Optional as _Optional
|
|
19
15
|
from typing import overload as _overload
|
|
20
16
|
|
|
21
17
|
from weakref import ref as _ref
|
|
22
18
|
from weakref import WeakMethod as _WeakMethod
|
|
23
19
|
|
|
20
|
+
from math import inf
|
|
21
|
+
|
|
24
22
|
from itertools import count as _count
|
|
25
23
|
|
|
26
|
-
__version__ = version = '3.
|
|
24
|
+
__version__ = version = '3.6'
|
|
27
25
|
|
|
28
26
|
|
|
29
27
|
###################
|
|
@@ -104,18 +102,19 @@ _C4 = _TypeVar('_C4')
|
|
|
104
102
|
class Processor:
|
|
105
103
|
"""Base class for all Processors to inherit from.
|
|
106
104
|
|
|
107
|
-
Processor instances must
|
|
108
|
-
free to define the class any way you wish. Processors should be
|
|
109
|
-
and then added to the current World context by calling
|
|
110
|
-
For example::
|
|
105
|
+
Processor instances must define a :py:meth:`process` method, but you are
|
|
106
|
+
otherwise free to define the class any way you wish. Processors should be
|
|
107
|
+
instantiated, and then added to the current World context by calling
|
|
108
|
+
:py:func:`esper.add_processor`. For example::
|
|
111
109
|
|
|
112
110
|
my_processor_instance = MyProcessor()
|
|
113
111
|
esper.add_processor(my_processor_instance)
|
|
114
112
|
|
|
115
|
-
|
|
116
|
-
:py:meth:`esper.Processor.process` methods called by a single call
|
|
117
|
-
:py:func:`esper.process`. Inside the
|
|
118
|
-
should iterate over Entities with one (or more) calls to the
|
|
113
|
+
All the Processors that have been added to the World context will have
|
|
114
|
+
their :py:meth:`esper.Processor.process` methods called by a single call
|
|
115
|
+
to :py:func:`esper.process`. Inside the :py:meth:`process` method is
|
|
116
|
+
where you should iterate over Entities with one (or more) calls to the
|
|
117
|
+
appropriate esper functions::
|
|
119
118
|
|
|
120
119
|
def process(self):
|
|
121
120
|
for ent, (rend, vel) in esper.get_components(Renderable, Velocity):
|
|
@@ -128,37 +127,107 @@ class Processor:
|
|
|
128
127
|
raise NotImplementedError
|
|
129
128
|
|
|
130
129
|
|
|
131
|
-
|
|
132
|
-
#
|
|
133
|
-
|
|
130
|
+
###########################################
|
|
131
|
+
# World Context template and functions
|
|
132
|
+
###########################################
|
|
134
133
|
|
|
134
|
+
# Global variables that contain the state of the current world context:
|
|
135
135
|
_current_world: str = "default"
|
|
136
|
-
_entity_count:
|
|
137
|
-
_components:
|
|
138
|
-
_entities:
|
|
139
|
-
_dead_entities:
|
|
140
|
-
_get_component_cache:
|
|
141
|
-
_get_components_cache:
|
|
142
|
-
_processors:
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
_context_map
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
]
|
|
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
|
+
_processors_dict: dict[type[Processor], Processor] = {}
|
|
144
|
+
_cache_dirty: bool = False
|
|
145
|
+
event_registry: dict[str, _Any] = {}
|
|
146
|
+
process_times: dict[str, int] = {}
|
|
147
|
+
# 'public' alias that gets set when switching worlds:
|
|
148
|
+
current_world: str = _current_world
|
|
149
|
+
|
|
150
|
+
# The _context_map holds all variables for the active & inactive contexts.
|
|
151
|
+
# We initialize it with default values::
|
|
152
|
+
_context_map: dict[str, tuple[
|
|
153
|
+
_count[int],
|
|
154
|
+
dict[type[_Any], set[_Any]],
|
|
155
|
+
dict[int, dict[type[_Any], _Any]],
|
|
156
|
+
set[int],
|
|
157
|
+
dict[type[_Any], list[_Any]],
|
|
158
|
+
dict[tuple[type[_Any], ...], list[_Any]],
|
|
159
|
+
list[Processor],
|
|
160
|
+
dict[type[Processor], Processor],
|
|
161
|
+
bool,
|
|
162
|
+
dict[str, int],
|
|
163
|
+
dict[str, _Any]
|
|
164
|
+
]] = {_current_world: (_entity_count, {}, {}, set(), {}, {}, [], {}, False, {}, {})}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def list_worlds() -> list[str]:
|
|
168
|
+
"""A list all World context names."""
|
|
169
|
+
return list(_context_map)
|
|
170
|
+
|
|
161
171
|
|
|
172
|
+
def delete_world(name: str) -> None:
|
|
173
|
+
"""Delete a World context.
|
|
174
|
+
|
|
175
|
+
This will completely delete the World, including all entities
|
|
176
|
+
that are contained within it.
|
|
177
|
+
|
|
178
|
+
Raises `PermissionError` if you attempt to delete the currently
|
|
179
|
+
active World context.
|
|
180
|
+
"""
|
|
181
|
+
if _current_world == name:
|
|
182
|
+
raise PermissionError("The active World context cannot be deleted.")
|
|
183
|
+
|
|
184
|
+
del _context_map[name]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def switch_world(name: str) -> None:
|
|
188
|
+
"""Switch to a new World context by name.
|
|
189
|
+
|
|
190
|
+
Esper can have one or more "Worlds". Each World is a dedicated
|
|
191
|
+
context, and does not share Entities, Components, events, etc.
|
|
192
|
+
Some game designs can benefit from using a dedicated World
|
|
193
|
+
for each scene. For other designs, a single World may be sufficient.
|
|
194
|
+
|
|
195
|
+
This function will allow you to create and switch between as
|
|
196
|
+
many World contexts as required. If the requested name does not
|
|
197
|
+
exist, a new context is created automatically with that name.
|
|
198
|
+
|
|
199
|
+
The name of the currently active World context can be checked
|
|
200
|
+
by examining :py:attr:`esper.current_world` attribute. This
|
|
201
|
+
attribute gets updated whenever you switch Worlds.
|
|
202
|
+
|
|
203
|
+
.. note:: At startup, a "default" World context is active.
|
|
204
|
+
"""
|
|
205
|
+
if name not in _context_map:
|
|
206
|
+
_context_map[name] = (_count(start=1), {}, {}, set(), {}, {}, [], {}, False, {}, {})
|
|
207
|
+
|
|
208
|
+
global _current_world
|
|
209
|
+
global _entity_count
|
|
210
|
+
global _components
|
|
211
|
+
global _entities
|
|
212
|
+
global _dead_entities
|
|
213
|
+
global _get_component_cache
|
|
214
|
+
global _get_components_cache
|
|
215
|
+
global _processors
|
|
216
|
+
global _processors_dict
|
|
217
|
+
global _cache_dirty
|
|
218
|
+
global process_times
|
|
219
|
+
global event_registry
|
|
220
|
+
global current_world
|
|
221
|
+
|
|
222
|
+
(_entity_count, _components, _entities, _dead_entities, _get_component_cache,
|
|
223
|
+
_get_components_cache, _processors, _processors_dict, _cache_dirty,
|
|
224
|
+
process_times, event_registry) = _context_map[name]
|
|
225
|
+
_current_world = current_world = name
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
#####################
|
|
229
|
+
# ECS functions
|
|
230
|
+
#####################
|
|
162
231
|
|
|
163
232
|
def clear_cache() -> None:
|
|
164
233
|
"""Manually clear the Component lookup cache.
|
|
@@ -166,8 +235,16 @@ def clear_cache() -> None:
|
|
|
166
235
|
Clearing the cache is not necessary to do manually,
|
|
167
236
|
but may be useful for benchmarking or debugging.
|
|
168
237
|
"""
|
|
238
|
+
global _cache_dirty
|
|
239
|
+
_cache_dirty = True
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _clear_cache_now() -> None:
|
|
243
|
+
"""Actually clear the cache (internal use)."""
|
|
244
|
+
global _cache_dirty
|
|
169
245
|
_get_component_cache.clear()
|
|
170
246
|
_get_components_cache.clear()
|
|
247
|
+
_cache_dirty = False
|
|
171
248
|
|
|
172
249
|
|
|
173
250
|
def clear_database() -> None:
|
|
@@ -181,7 +258,7 @@ def clear_database() -> None:
|
|
|
181
258
|
_components.clear()
|
|
182
259
|
_entities.clear()
|
|
183
260
|
_dead_entities.clear()
|
|
184
|
-
|
|
261
|
+
_clear_cache_now()
|
|
185
262
|
|
|
186
263
|
|
|
187
264
|
def add_processor(processor_instance: Processor, priority: int = 0) -> None:
|
|
@@ -196,9 +273,10 @@ def add_processor(processor_instance: Processor, priority: int = 0) -> None:
|
|
|
196
273
|
processor_instance.priority = priority
|
|
197
274
|
_processors.append(processor_instance)
|
|
198
275
|
_processors.sort(key=lambda proc: proc.priority, reverse=True)
|
|
276
|
+
_processors_dict[type(processor_instance)] = processor_instance
|
|
199
277
|
|
|
200
278
|
|
|
201
|
-
def remove_processor(processor_type:
|
|
279
|
+
def remove_processor(processor_type: type[Processor]) -> None:
|
|
202
280
|
"""Remove a Processor from the World, by type.
|
|
203
281
|
|
|
204
282
|
Make sure to provide the class itself, **not** an instance. For example::
|
|
@@ -210,23 +288,19 @@ def remove_processor(processor_type: _Type[Processor]) -> None:
|
|
|
210
288
|
self.world.remove_processor(my_processor_instance)
|
|
211
289
|
|
|
212
290
|
"""
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
291
|
+
processor = _processors_dict.pop(processor_type, None)
|
|
292
|
+
if processor:
|
|
293
|
+
_processors.remove(processor)
|
|
216
294
|
|
|
217
295
|
|
|
218
|
-
def get_processor(processor_type:
|
|
296
|
+
def get_processor(processor_type: type[Processor]) -> Processor | None:
|
|
219
297
|
"""Get a Processor instance, by type.
|
|
220
298
|
|
|
221
299
|
This function returns a Processor instance by type. This could be
|
|
222
300
|
useful in certain situations, such as wanting to call a method on a
|
|
223
301
|
Processor, from within another Processor.
|
|
224
302
|
"""
|
|
225
|
-
|
|
226
|
-
if type(processor) is processor_type:
|
|
227
|
-
return processor
|
|
228
|
-
else:
|
|
229
|
-
return None
|
|
303
|
+
return _processors_dict.get(processor_type)
|
|
230
304
|
|
|
231
305
|
|
|
232
306
|
def create_entity(*components: _C) -> int:
|
|
@@ -238,21 +312,19 @@ def create_entity(*components: _C) -> int:
|
|
|
238
312
|
added later with the :py:func:`esper.add_component` function.
|
|
239
313
|
"""
|
|
240
314
|
entity = next(_entity_count)
|
|
241
|
-
|
|
242
|
-
if entity not in _entities:
|
|
243
|
-
_entities[entity] = {}
|
|
315
|
+
entity_dict = {}
|
|
244
316
|
|
|
245
317
|
for component_instance in components:
|
|
246
|
-
|
|
247
318
|
component_type = type(component_instance)
|
|
248
319
|
|
|
249
320
|
if component_type not in _components:
|
|
250
321
|
_components[component_type] = set()
|
|
251
322
|
|
|
252
323
|
_components[component_type].add(entity)
|
|
324
|
+
entity_dict[component_type] = component_instance
|
|
253
325
|
|
|
254
|
-
|
|
255
|
-
|
|
326
|
+
_entities[entity] = entity_dict
|
|
327
|
+
clear_cache()
|
|
256
328
|
|
|
257
329
|
return entity
|
|
258
330
|
|
|
@@ -270,15 +342,16 @@ def delete_entity(entity: int, immediate: bool = False) -> None:
|
|
|
270
342
|
Raises a KeyError if the given entity does not exist in the database.
|
|
271
343
|
"""
|
|
272
344
|
if immediate:
|
|
273
|
-
|
|
274
|
-
|
|
345
|
+
entity_comps = _entities[entity]
|
|
346
|
+
for component_type in entity_comps:
|
|
347
|
+
comp_set = _components[component_type]
|
|
348
|
+
comp_set.discard(entity)
|
|
275
349
|
|
|
276
|
-
if not
|
|
350
|
+
if not comp_set:
|
|
277
351
|
del _components[component_type]
|
|
278
352
|
|
|
279
353
|
del _entities[entity]
|
|
280
354
|
clear_cache()
|
|
281
|
-
|
|
282
355
|
else:
|
|
283
356
|
_dead_entities.add(entity)
|
|
284
357
|
|
|
@@ -292,7 +365,7 @@ def entity_exists(entity: int) -> bool:
|
|
|
292
365
|
return entity in _entities and entity not in _dead_entities
|
|
293
366
|
|
|
294
367
|
|
|
295
|
-
def component_for_entity(entity: int, component_type:
|
|
368
|
+
def component_for_entity(entity: int, component_type: type[_C]) -> _C:
|
|
296
369
|
"""Retrieve a Component instance for a specific Entity.
|
|
297
370
|
|
|
298
371
|
Retrieve a Component instance for a specific Entity. In some cases,
|
|
@@ -304,7 +377,7 @@ def component_for_entity(entity: int, component_type: _Type[_C]) -> _C:
|
|
|
304
377
|
return _entities[entity][component_type] # type: ignore[no-any-return]
|
|
305
378
|
|
|
306
379
|
|
|
307
|
-
def components_for_entity(entity: int) ->
|
|
380
|
+
def components_for_entity(entity: int) -> tuple[_C, ...]:
|
|
308
381
|
"""Retrieve all Components for a specific Entity, as a Tuple.
|
|
309
382
|
|
|
310
383
|
Retrieve all Components for a specific Entity. This function is probably
|
|
@@ -318,18 +391,18 @@ def components_for_entity(entity: int) -> _Tuple[_C, ...]:
|
|
|
318
391
|
return tuple(_entities[entity].values())
|
|
319
392
|
|
|
320
393
|
|
|
321
|
-
def has_component(entity: int, component_type:
|
|
394
|
+
def has_component(entity: int, component_type: type[_C]) -> bool:
|
|
322
395
|
"""Check if an Entity has a specific Component type."""
|
|
323
396
|
return component_type in _entities[entity]
|
|
324
397
|
|
|
325
398
|
|
|
326
|
-
def has_components(entity: int, *component_types:
|
|
399
|
+
def has_components(entity: int, *component_types: type[_C]) -> bool:
|
|
327
400
|
"""Check if an Entity has all the specified Component types."""
|
|
328
|
-
|
|
329
|
-
return all(comp_type in
|
|
401
|
+
entity_comps = _entities[entity]
|
|
402
|
+
return all(comp_type in entity_comps for comp_type in component_types)
|
|
330
403
|
|
|
331
404
|
|
|
332
|
-
def add_component(entity: int, component_instance: _C, type_alias:
|
|
405
|
+
def add_component(entity: int, component_instance: _C, type_alias: type[_C] | None = None) -> None:
|
|
333
406
|
"""Add a new Component instance to an Entity.
|
|
334
407
|
|
|
335
408
|
Add a Component instance to an Entiy. If a Component of the same type
|
|
@@ -345,12 +418,11 @@ def add_component(entity: int, component_instance: _C, type_alias: _Optional[_Ty
|
|
|
345
418
|
_components[component_type] = set()
|
|
346
419
|
|
|
347
420
|
_components[component_type].add(entity)
|
|
348
|
-
|
|
349
421
|
_entities[entity][component_type] = component_instance
|
|
350
422
|
clear_cache()
|
|
351
423
|
|
|
352
424
|
|
|
353
|
-
def remove_component(entity: int, component_type:
|
|
425
|
+
def remove_component(entity: int, component_type: type[_C]) -> _C:
|
|
354
426
|
"""Remove a Component instance from an Entity, by type.
|
|
355
427
|
|
|
356
428
|
A Component instance can only be removed by providing its type.
|
|
@@ -360,66 +432,133 @@ def remove_component(entity: int, component_type: _Type[_C]) -> _C:
|
|
|
360
432
|
Raises a KeyError if either the given entity or Component type does
|
|
361
433
|
not exist in the database.
|
|
362
434
|
"""
|
|
363
|
-
_components[component_type]
|
|
435
|
+
comp_set = _components[component_type]
|
|
436
|
+
comp_set.discard(entity)
|
|
364
437
|
|
|
365
|
-
if not
|
|
438
|
+
if not comp_set:
|
|
366
439
|
del _components[component_type]
|
|
367
440
|
|
|
368
441
|
clear_cache()
|
|
369
442
|
return _entities[entity].pop(component_type) # type: ignore[no-any-return]
|
|
370
443
|
|
|
371
444
|
|
|
372
|
-
def
|
|
445
|
+
def try_remove_component(entity: int, component_type: type[_C]) -> _C | None:
|
|
446
|
+
"""Try to remove a Component instance from an Entity, by type.
|
|
447
|
+
|
|
448
|
+
This operation is similar to :py:func:`esper.remove_component`, but
|
|
449
|
+
will NOT raise an exception if the Component does not exist.
|
|
450
|
+
"""
|
|
451
|
+
if comp_set := _components.get(component_type):
|
|
452
|
+
comp_set.discard(entity)
|
|
453
|
+
|
|
454
|
+
if not comp_set:
|
|
455
|
+
del _components[component_type]
|
|
456
|
+
|
|
457
|
+
clear_cache()
|
|
458
|
+
return _entities[entity].pop(component_type) # type: ignore[no-any-return]
|
|
459
|
+
|
|
460
|
+
return None
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _get_component(component_type: type[_C]) -> _Iterable[tuple[int, _C]]:
|
|
373
464
|
entity_db = _entities
|
|
465
|
+
comp_set = _components.get(component_type)
|
|
466
|
+
|
|
467
|
+
if comp_set is None:
|
|
468
|
+
return
|
|
374
469
|
|
|
375
|
-
for entity in
|
|
470
|
+
for entity in comp_set:
|
|
376
471
|
yield entity, entity_db[entity][component_type]
|
|
377
472
|
|
|
378
473
|
|
|
379
|
-
def _get_components(*component_types:
|
|
474
|
+
def _get_components(*component_types: type[_C]) -> _Iterable[tuple[int, tuple[_C, ...]]]:
|
|
475
|
+
if not component_types:
|
|
476
|
+
return
|
|
477
|
+
|
|
380
478
|
entity_db = _entities
|
|
381
479
|
comp_db = _components
|
|
382
480
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
481
|
+
min_set = None
|
|
482
|
+
min_size = inf
|
|
483
|
+
other_types = []
|
|
484
|
+
|
|
485
|
+
for ct in component_types:
|
|
486
|
+
comp_set = comp_db.get(ct)
|
|
487
|
+
if comp_set is None:
|
|
488
|
+
return
|
|
489
|
+
set_size = len(comp_set)
|
|
490
|
+
if set_size < min_size:
|
|
491
|
+
if min_set is not None:
|
|
492
|
+
other_types.append(component_types[len(other_types)])
|
|
493
|
+
min_size = set_size
|
|
494
|
+
min_set = comp_set
|
|
495
|
+
else:
|
|
496
|
+
other_types.append(ct)
|
|
497
|
+
|
|
498
|
+
if min_set is None:
|
|
499
|
+
return
|
|
389
500
|
|
|
390
|
-
|
|
501
|
+
if not other_types:
|
|
502
|
+
for entity in min_set:
|
|
503
|
+
entity_comps = entity_db[entity]
|
|
504
|
+
yield entity, tuple(entity_comps[ct] for ct in component_types)
|
|
505
|
+
else:
|
|
506
|
+
for entity in min_set:
|
|
507
|
+
entity_comps = entity_db[entity]
|
|
508
|
+
has_all = True
|
|
509
|
+
for ct in other_types:
|
|
510
|
+
if ct not in entity_comps:
|
|
511
|
+
has_all = False
|
|
512
|
+
break
|
|
513
|
+
if has_all:
|
|
514
|
+
yield entity, tuple(entity_comps[ct] for ct in component_types)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def get_component(component_type: type[_C]) -> list[tuple[int, _C]]:
|
|
391
518
|
"""Get an iterator for Entity, Component pairs."""
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
519
|
+
if _cache_dirty:
|
|
520
|
+
_clear_cache_now()
|
|
521
|
+
|
|
522
|
+
cached = _get_component_cache.get(component_type)
|
|
523
|
+
if cached is not None:
|
|
524
|
+
return cached
|
|
525
|
+
|
|
526
|
+
result = list(_get_component(component_type))
|
|
527
|
+
_get_component_cache[component_type] = result
|
|
528
|
+
return result
|
|
396
529
|
|
|
397
530
|
|
|
398
531
|
@_overload
|
|
399
|
-
def get_components(__c1:
|
|
532
|
+
def get_components(__c1: type[_C], __c2: type[_C2]) -> list[tuple[int, tuple[_C, _C2]]]:
|
|
400
533
|
...
|
|
401
534
|
|
|
402
535
|
|
|
403
536
|
@_overload
|
|
404
|
-
def get_components(__c1:
|
|
537
|
+
def get_components(__c1: type[_C], __c2: type[_C2], __c3: type[_C3]) -> list[tuple[int, tuple[_C, _C2, _C3]]]:
|
|
405
538
|
...
|
|
406
539
|
|
|
407
540
|
|
|
408
541
|
@_overload
|
|
409
|
-
def get_components(__c1:
|
|
410
|
-
|
|
542
|
+
def get_components(__c1: type[_C], __c2: type[_C2], __c3: type[_C3], __c4: type[_C4]) -> list[
|
|
543
|
+
tuple[int, tuple[_C, _C2, _C3, _C4]]]:
|
|
411
544
|
...
|
|
412
545
|
|
|
413
546
|
|
|
414
|
-
def get_components(*component_types:
|
|
547
|
+
def get_components(*component_types: type[_Any]) -> list[tuple[int, tuple[_Any, ...]]]:
|
|
415
548
|
"""Get an iterator for Entity and multiple Component sets."""
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
549
|
+
if _cache_dirty:
|
|
550
|
+
_clear_cache_now()
|
|
551
|
+
|
|
552
|
+
cached = _get_components_cache.get(component_types)
|
|
553
|
+
if cached is not None:
|
|
554
|
+
return cached
|
|
420
555
|
|
|
556
|
+
result = list(_get_components(*component_types))
|
|
557
|
+
_get_components_cache[component_types] = result
|
|
558
|
+
return result
|
|
421
559
|
|
|
422
|
-
|
|
560
|
+
|
|
561
|
+
def try_component(entity: int, component_type: type[_C]) -> _C | None:
|
|
423
562
|
"""Try to get a single component type for an Entity.
|
|
424
563
|
|
|
425
564
|
This function will return the requested Component if it exists,
|
|
@@ -427,27 +566,28 @@ def try_component(entity: int, component_type: _Type[_C]) -> _Optional[_C]:
|
|
|
427
566
|
that may or may not exist, without having to first query if the Entity
|
|
428
567
|
has the Component type.
|
|
429
568
|
"""
|
|
430
|
-
|
|
431
|
-
|
|
569
|
+
entity_comps = _entities.get(entity)
|
|
570
|
+
if entity_comps and component_type in entity_comps:
|
|
571
|
+
return entity_comps[component_type] # type: ignore[no-any-return]
|
|
432
572
|
return None
|
|
433
573
|
|
|
434
574
|
|
|
435
575
|
@_overload
|
|
436
|
-
def try_components(entity: int, __c1:
|
|
576
|
+
def try_components(entity: int, __c1: type[_C], __c2: type[_C2]) -> tuple[_C, _C2]:
|
|
437
577
|
...
|
|
438
578
|
|
|
439
579
|
|
|
440
580
|
@_overload
|
|
441
|
-
def try_components(entity: int, __c1:
|
|
581
|
+
def try_components(entity: int, __c1: type[_C], __c2: type[_C2], __c3: type[_C3]) -> tuple[_C, _C2, _C3]:
|
|
442
582
|
...
|
|
443
583
|
|
|
444
584
|
|
|
445
585
|
@_overload
|
|
446
|
-
def try_components(entity: int, __c1:
|
|
586
|
+
def try_components(entity: int, __c1: type[_C], __c2: type[_C2], __c3: type[_C3], __c4: type[_C4]) -> tuple[_C, _C2, _C3, _C4]:
|
|
447
587
|
...
|
|
448
588
|
|
|
449
589
|
|
|
450
|
-
def try_components(entity: int, *component_types:
|
|
590
|
+
def try_components(entity: int, *component_types: type[_C]) -> tuple[_C, ...] | None:
|
|
451
591
|
"""Try to get multiple component types for an Entity.
|
|
452
592
|
|
|
453
593
|
This function will return the requested Components if they exist,
|
|
@@ -455,8 +595,9 @@ def try_components(entity: int, *component_types: _Type[_C]) -> _Optional[_Tuple
|
|
|
455
595
|
that may or may not exist, without first having to query if the Entity
|
|
456
596
|
has the Component types.
|
|
457
597
|
"""
|
|
458
|
-
|
|
459
|
-
|
|
598
|
+
entity_comps = _entities.get(entity)
|
|
599
|
+
if entity_comps and all(comp_type in entity_comps for comp_type in component_types):
|
|
600
|
+
return tuple(entity_comps[comp_type] for comp_type in component_types)
|
|
460
601
|
return None
|
|
461
602
|
|
|
462
603
|
|
|
@@ -471,12 +612,17 @@ def clear_dead_entities() -> None:
|
|
|
471
612
|
# In the interest of performance, this function duplicates code from the
|
|
472
613
|
# `delete_entity` function. If that function is changed, those changes should
|
|
473
614
|
# be duplicated here as well.
|
|
615
|
+
if not _dead_entities:
|
|
616
|
+
return
|
|
617
|
+
|
|
474
618
|
for entity in _dead_entities:
|
|
619
|
+
entity_comps = _entities[entity]
|
|
475
620
|
|
|
476
|
-
for component_type in
|
|
477
|
-
_components[component_type]
|
|
621
|
+
for component_type in entity_comps:
|
|
622
|
+
comp_set = _components[component_type]
|
|
623
|
+
comp_set.discard(entity)
|
|
478
624
|
|
|
479
|
-
if not
|
|
625
|
+
if not comp_set:
|
|
480
626
|
del _components[component_type]
|
|
481
627
|
|
|
482
628
|
del _entities[entity]
|
|
@@ -511,64 +657,3 @@ def timed_process(*args: _Any, **kwargs: _Any) -> None:
|
|
|
511
657
|
start_time = _time.process_time()
|
|
512
658
|
processor.process(*args, **kwargs)
|
|
513
659
|
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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# -*- coding: utf-8 -*-
|
|
3
3
|
|
|
4
4
|
import gc
|
|
5
|
+
import random
|
|
5
6
|
import sys
|
|
6
7
|
import time
|
|
7
8
|
import optparse
|
|
@@ -90,6 +91,11 @@ class Brain:
|
|
|
90
91
|
smarts: int = 9000
|
|
91
92
|
|
|
92
93
|
|
|
94
|
+
@component
|
|
95
|
+
class IsPlayer:
|
|
96
|
+
pass
|
|
97
|
+
|
|
98
|
+
|
|
93
99
|
#############################
|
|
94
100
|
# Set up some dummy entities:
|
|
95
101
|
#############################
|
|
@@ -99,6 +105,14 @@ def create_entities(number):
|
|
|
99
105
|
esper.create_entity(Position(), Health(), Damageable())
|
|
100
106
|
|
|
101
107
|
|
|
108
|
+
def create_mixed_entities(number):
|
|
109
|
+
for _ in range(number - 5):
|
|
110
|
+
esper.create_entity(Position(), Velocity())
|
|
111
|
+
|
|
112
|
+
for _ in range(5):
|
|
113
|
+
esper.create_entity(Position(), Velocity(), IsPlayer())
|
|
114
|
+
|
|
115
|
+
|
|
102
116
|
#############################
|
|
103
117
|
# Some timed query functions:
|
|
104
118
|
#############################
|
|
@@ -120,10 +134,56 @@ def three_comp_query():
|
|
|
120
134
|
pass
|
|
121
135
|
|
|
122
136
|
|
|
137
|
+
@timing
|
|
138
|
+
def rare_comp_query():
|
|
139
|
+
"""
|
|
140
|
+
Benchmark a query involving a common and a rare component.
|
|
141
|
+
|
|
142
|
+
This scenario is designed to highlight the performance gain from the
|
|
143
|
+
"iterate over the smallest set" optimization. The query for
|
|
144
|
+
(Position, Velocity, IsPlayer) should be extremely fast, as it only
|
|
145
|
+
needs to iterate over the few entities that have the rare `IsPlayer`
|
|
146
|
+
component, instead of all entities with `Position`.
|
|
147
|
+
"""
|
|
148
|
+
for _, (_, _, _) in esper.get_components(Position, Velocity, IsPlayer):
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@timing
|
|
153
|
+
def dynamic_world_frame(entities_to_kill, new_entities_to_create):
|
|
154
|
+
"""
|
|
155
|
+
Benchmark a single frame in a dynamic world with entity churn.
|
|
156
|
+
|
|
157
|
+
This function simulates a typical game loop frame to measure performance
|
|
158
|
+
under dynamic conditions. It tests the combined cost of:
|
|
159
|
+
1. Running common queries.
|
|
160
|
+
2. Deleting a batch of existing entities (testing `delete_entity`).
|
|
161
|
+
3. Creating a batch of new entities (testing `create_entity`).
|
|
162
|
+
4. Cleaning up dead entities (testing `clear_dead_entities`).
|
|
163
|
+
|
|
164
|
+
This is a good test for the lazy cache invalidation and optimized
|
|
165
|
+
entity cleanup mechanisms.
|
|
166
|
+
"""
|
|
167
|
+
for _, (_, _) in esper.get_components(Position, Velocity):
|
|
168
|
+
pass
|
|
169
|
+
for _, (_, _, _) in esper.get_components(Position, Damageable, Health):
|
|
170
|
+
pass
|
|
171
|
+
|
|
172
|
+
for ent_id in entities_to_kill:
|
|
173
|
+
if esper.entity_exists(ent_id):
|
|
174
|
+
esper.delete_entity(ent_id, immediate=False)
|
|
175
|
+
|
|
176
|
+
create_entities(new_entities_to_create)
|
|
177
|
+
|
|
178
|
+
esper.clear_dead_entities()
|
|
179
|
+
|
|
180
|
+
|
|
123
181
|
#################################################
|
|
124
182
|
# Perform several queries, and print the results:
|
|
125
183
|
#################################################
|
|
126
184
|
results = {1: {}, 2: {}, 3: {}}
|
|
185
|
+
# result_times = []
|
|
186
|
+
new_results = {"Rare Comp": {}, "Dynamic World": {}}
|
|
127
187
|
result_times = []
|
|
128
188
|
|
|
129
189
|
for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
|
|
@@ -163,6 +223,40 @@ for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
|
|
|
163
223
|
gc.collect()
|
|
164
224
|
|
|
165
225
|
|
|
226
|
+
print("\n--- Benchmarking: Optimized Scenarios ---")
|
|
227
|
+
|
|
228
|
+
for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
|
|
229
|
+
create_mixed_entities(amount)
|
|
230
|
+
for _ in range(50):
|
|
231
|
+
rare_comp_query()
|
|
232
|
+
result_min = min(result_times)
|
|
233
|
+
print("Query rare component, {} Entities: {:f} ms".format(amount, result_min))
|
|
234
|
+
new_results["Rare Comp"][amount] = result_min
|
|
235
|
+
result_times = []
|
|
236
|
+
esper.clear_database()
|
|
237
|
+
gc.collect()
|
|
238
|
+
|
|
239
|
+
for amount in range(500, MAX_ENTITIES, MAX_ENTITIES//50):
|
|
240
|
+
create_entities(amount)
|
|
241
|
+
all_entities = list(esper._entities.keys())
|
|
242
|
+
k = min(10, len(all_entities))
|
|
243
|
+
entities_to_kill_per_frame = random.sample(all_entities, k=k)
|
|
244
|
+
|
|
245
|
+
for _ in range(50):
|
|
246
|
+
dynamic_world_frame(entities_to_kill_per_frame, 10)
|
|
247
|
+
all_entities = list(esper._entities.keys())
|
|
248
|
+
k = min(10, len(all_entities))
|
|
249
|
+
if k > 0:
|
|
250
|
+
entities_to_kill_per_frame = random.sample(all_entities, k=k)
|
|
251
|
+
|
|
252
|
+
result_min = min(result_times)
|
|
253
|
+
print("Dynamic world frame, {} Entities: {:f} ms".format(amount, result_min))
|
|
254
|
+
new_results["Dynamic World"][amount] = result_min
|
|
255
|
+
result_times = []
|
|
256
|
+
esper.clear_database()
|
|
257
|
+
gc.collect()
|
|
258
|
+
|
|
259
|
+
|
|
166
260
|
#############################################
|
|
167
261
|
# Save the results to disk, or plot directly:
|
|
168
262
|
#############################################
|
|
@@ -177,6 +271,7 @@ if options.plot:
|
|
|
177
271
|
print("\nThe matplotlib module is required for plotting results.")
|
|
178
272
|
sys.exit(1)
|
|
179
273
|
|
|
274
|
+
plt.figure(1)
|
|
180
275
|
lines = []
|
|
181
276
|
for num, result in results.items():
|
|
182
277
|
x, y = zip(*sorted(result.items()))
|
|
@@ -185,5 +280,19 @@ if options.plot:
|
|
|
185
280
|
|
|
186
281
|
plt.ylabel("Query Time (ms)")
|
|
187
282
|
plt.xlabel("Number of Entities")
|
|
283
|
+
plt.title("Basic Component Queries")
|
|
188
284
|
plt.legend(handles=lines, bbox_to_anchor=(0.5, 1))
|
|
285
|
+
|
|
286
|
+
plt.figure(2)
|
|
287
|
+
lines = []
|
|
288
|
+
for name, result in new_results.items():
|
|
289
|
+
if result:
|
|
290
|
+
x, y = zip(*sorted(result.items()))
|
|
291
|
+
lines.extend(plt.plot(x, y, label=name, marker='o'))
|
|
292
|
+
|
|
293
|
+
plt.ylabel("Query Time (ms)")
|
|
294
|
+
plt.xlabel("Number of Entities")
|
|
295
|
+
plt.title("Optimized Scenarios")
|
|
296
|
+
plt.legend(handles=lines, bbox_to_anchor=(0.5, 1))
|
|
297
|
+
|
|
189
298
|
plt.show()
|
|
@@ -118,7 +118,7 @@ def test_get_two_components():
|
|
|
118
118
|
|
|
119
119
|
for ent, comps in esper.get_components(ComponentD, ComponentE):
|
|
120
120
|
assert isinstance(ent, int)
|
|
121
|
-
assert isinstance(comps,
|
|
121
|
+
assert isinstance(comps, tuple)
|
|
122
122
|
assert len(comps) == 2
|
|
123
123
|
|
|
124
124
|
for ent, (d, e) in esper.get_components(ComponentD, ComponentE):
|
|
@@ -133,7 +133,7 @@ def test_get_three_components():
|
|
|
133
133
|
|
|
134
134
|
for ent, comps in esper.get_components(ComponentC, ComponentD, ComponentE):
|
|
135
135
|
assert isinstance(ent, int)
|
|
136
|
-
assert isinstance(comps,
|
|
136
|
+
assert isinstance(comps, tuple)
|
|
137
137
|
assert len(comps) == 3
|
|
138
138
|
|
|
139
139
|
for ent, (c, d, e) in esper.get_components(ComponentC, ComponentD, ComponentE):
|
|
@@ -157,7 +157,7 @@ def test_try_components():
|
|
|
157
157
|
entity1 = esper.create_entity(ComponentA(), ComponentB())
|
|
158
158
|
|
|
159
159
|
one_item = esper.try_components(entity1, ComponentA, ComponentB)
|
|
160
|
-
assert isinstance(one_item,
|
|
160
|
+
assert isinstance(one_item, tuple)
|
|
161
161
|
assert len(one_item) == 2
|
|
162
162
|
assert isinstance(one_item[0], ComponentA)
|
|
163
163
|
assert isinstance(one_item[1], ComponentB)
|
|
@@ -255,6 +255,21 @@ class TestRemoveComponent:
|
|
|
255
255
|
assert not esper.has_component(entity, ComponentB)
|
|
256
256
|
assert esper.component_for_entity(entity, ComponentC) is component_c
|
|
257
257
|
|
|
258
|
+
def test_try_remove_component(self):
|
|
259
|
+
component_a = ComponentA()
|
|
260
|
+
component_b = ComponentB()
|
|
261
|
+
component_c = ComponentC()
|
|
262
|
+
entity = esper.create_entity(component_a, component_b, component_c)
|
|
263
|
+
|
|
264
|
+
# The component should be deleted the first time:
|
|
265
|
+
_deleted_component = esper.try_remove_component(entity, ComponentB)
|
|
266
|
+
assert _deleted_component is component_b
|
|
267
|
+
assert not esper.has_component(entity, ComponentB)
|
|
268
|
+
|
|
269
|
+
# Future calls should return None, and not raise any Exception:
|
|
270
|
+
_deleted_component = esper.try_remove_component(entity, ComponentB)
|
|
271
|
+
assert _deleted_component is None
|
|
272
|
+
|
|
258
273
|
|
|
259
274
|
def test_clear_dead_entities():
|
|
260
275
|
component = ComponentA()
|
|
@@ -516,6 +531,174 @@ def test_remove_handler():
|
|
|
516
531
|
assert esper.event_registry == {}
|
|
517
532
|
|
|
518
533
|
|
|
534
|
+
##################################################
|
|
535
|
+
# Advanced Feature Tests #
|
|
536
|
+
##################################################
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def test_delayed_delete_entity():
|
|
540
|
+
"""
|
|
541
|
+
Verify that delayed entity deletion works as expected.
|
|
542
|
+
|
|
543
|
+
This test checks the default deletion behavior (`immediate=False`).
|
|
544
|
+
It ensures that:
|
|
545
|
+
1. An entity marked for deletion is immediately considered non-existent
|
|
546
|
+
by the public `entity_exists()` function.
|
|
547
|
+
2. The entity's data, however, remains in the internal database
|
|
548
|
+
structures until the cleanup process is run.
|
|
549
|
+
3. After `clear_dead_entities()` is called, the entity is completely
|
|
550
|
+
purged from the database.
|
|
551
|
+
"""
|
|
552
|
+
entity = esper.create_entity(ComponentA())
|
|
553
|
+
esper.delete_entity(entity, immediate=False)
|
|
554
|
+
|
|
555
|
+
assert esper.entity_exists(entity) is False
|
|
556
|
+
assert entity in esper._entities
|
|
557
|
+
|
|
558
|
+
esper.clear_dead_entities()
|
|
559
|
+
|
|
560
|
+
assert entity not in esper._entities
|
|
561
|
+
with pytest.raises(KeyError):
|
|
562
|
+
esper.components_for_entity(entity)
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def test_cache_invalidation_on_add_component():
|
|
566
|
+
"""
|
|
567
|
+
Verify that adding a component correctly invalidates the query cache.
|
|
568
|
+
|
|
569
|
+
This test ensures that:
|
|
570
|
+
1. A query is run to populate the cache (making it "hot").
|
|
571
|
+
2. A new component is added to an existing entity, which should trigger
|
|
572
|
+
the lazy cache invalidation mechanism by setting the dirty flag.
|
|
573
|
+
3. A subsequent query correctly rebuilds the cache and returns the
|
|
574
|
+
expected results.
|
|
575
|
+
"""
|
|
576
|
+
entity = esper.create_entity(ComponentA())
|
|
577
|
+
|
|
578
|
+
result1 = esper.get_component(ComponentA)
|
|
579
|
+
assert len(result1) == 1
|
|
580
|
+
|
|
581
|
+
esper.add_component(entity, ComponentB())
|
|
582
|
+
|
|
583
|
+
esper.clear_cache()
|
|
584
|
+
result2 = esper.get_component(ComponentA)
|
|
585
|
+
assert len(result2) == 1
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def test_cache_invalidation_on_remove_component():
|
|
589
|
+
"""
|
|
590
|
+
Verify that removing a component correctly invalidates the query cache.
|
|
591
|
+
|
|
592
|
+
This test ensures that:
|
|
593
|
+
1. A query for a specific component combination is run to populate the cache.
|
|
594
|
+
2. One of the components is removed from the entity, which should
|
|
595
|
+
trigger the lazy cache invalidation by setting the dirty flag.
|
|
596
|
+
3. A subsequent query for the same component combination correctly
|
|
597
|
+
returns an empty result and clears the dirty flag.
|
|
598
|
+
"""
|
|
599
|
+
entity = esper.create_entity(ComponentA(), ComponentB())
|
|
600
|
+
|
|
601
|
+
result1 = esper.get_components(ComponentA, ComponentB)
|
|
602
|
+
assert len(result1) == 1
|
|
603
|
+
|
|
604
|
+
esper.remove_component(entity, ComponentB)
|
|
605
|
+
|
|
606
|
+
result2 = esper.get_components(ComponentA, ComponentB)
|
|
607
|
+
assert len(result2) == 0
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def test_processor_priority():
|
|
611
|
+
"""
|
|
612
|
+
Verify that processors are executed in the correct order based on priority.
|
|
613
|
+
|
|
614
|
+
This test adds two processors, A and B, with different priority values
|
|
615
|
+
(A has a higher priority of 10, B has a lower priority of 5). It then
|
|
616
|
+
confirms that when `esper.process()` is called, the processor with the
|
|
617
|
+
higher numerical priority (Processor A) is executed before the one with
|
|
618
|
+
the lower priority.
|
|
619
|
+
"""
|
|
620
|
+
|
|
621
|
+
class PriorityProcessorA(esper.Processor):
|
|
622
|
+
priority = 10
|
|
623
|
+
|
|
624
|
+
def process(self, order_list):
|
|
625
|
+
order_list.append('A')
|
|
626
|
+
|
|
627
|
+
class PriorityProcessorB(esper.Processor):
|
|
628
|
+
priority = 5
|
|
629
|
+
|
|
630
|
+
def process(self, order_list):
|
|
631
|
+
order_list.append('B')
|
|
632
|
+
|
|
633
|
+
proc_b = PriorityProcessorB()
|
|
634
|
+
proc_a = PriorityProcessorA()
|
|
635
|
+
|
|
636
|
+
esper.add_processor(proc_b, priority=proc_b.priority)
|
|
637
|
+
esper.add_processor(proc_a, priority=proc_a.priority)
|
|
638
|
+
|
|
639
|
+
order = []
|
|
640
|
+
esper.process(order)
|
|
641
|
+
|
|
642
|
+
assert order == ['A', 'B']
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_weak_reference_handler_removal():
|
|
646
|
+
"""
|
|
647
|
+
Verify that event handlers are automatically unregistered when garbage collected.
|
|
648
|
+
|
|
649
|
+
The event system uses weak references to handlers to prevent memory leaks.
|
|
650
|
+
This test confirms that:
|
|
651
|
+
1. A method from a temporary object instance is registered as an event handler.
|
|
652
|
+
2. After the only strong reference to the instance is deleted, the garbage
|
|
653
|
+
collector reclaims the object.
|
|
654
|
+
3. The weak reference in the event registry becomes dead, and the handler
|
|
655
|
+
is automatically removed via its callback.
|
|
656
|
+
4. Dispatching the event no longer calls the handler, and the event name
|
|
657
|
+
is removed from the registry.
|
|
658
|
+
"""
|
|
659
|
+
called = 0
|
|
660
|
+
|
|
661
|
+
class TempHandler:
|
|
662
|
+
def handle(self):
|
|
663
|
+
nonlocal called
|
|
664
|
+
called += 1
|
|
665
|
+
|
|
666
|
+
temp_instance = TempHandler()
|
|
667
|
+
esper.set_handler("temp_event", temp_instance.handle)
|
|
668
|
+
|
|
669
|
+
assert "temp_event" in esper.event_registry
|
|
670
|
+
|
|
671
|
+
del temp_instance
|
|
672
|
+
|
|
673
|
+
import gc
|
|
674
|
+
gc.collect()
|
|
675
|
+
|
|
676
|
+
esper.dispatch_event("temp_event")
|
|
677
|
+
assert called == 0
|
|
678
|
+
|
|
679
|
+
assert "temp_event" not in esper.event_registry
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def test_delete_world():
|
|
683
|
+
"""
|
|
684
|
+
Verify the functionality of creating and deleting world contexts.
|
|
685
|
+
|
|
686
|
+
This test checks that:
|
|
687
|
+
1. A new world context can be created implicitly via `switch_world`.
|
|
688
|
+
2. The `list_worlds` function correctly reports the existence of the new world.
|
|
689
|
+
3. The `delete_world` function successfully removes the specified world.
|
|
690
|
+
4. Attempting to delete a non-existent world correctly raises a KeyError.
|
|
691
|
+
"""
|
|
692
|
+
esper.switch_world("temp_world")
|
|
693
|
+
esper.switch_world("default")
|
|
694
|
+
|
|
695
|
+
assert "temp_world" in esper.list_worlds()
|
|
696
|
+
esper.delete_world("temp_world")
|
|
697
|
+
assert "temp_world" not in esper.list_worlds()
|
|
698
|
+
|
|
699
|
+
with pytest.raises(KeyError):
|
|
700
|
+
esper.delete_world("non_existent")
|
|
701
|
+
|
|
519
702
|
##################################################
|
|
520
703
|
# Some helper functions and Component templates:
|
|
521
704
|
##################################################
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{esper-3.4 → esper-3.6}/LICENSE
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{esper-3.4 → esper-3.6}/make.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|