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.
Files changed (33) hide show
  1. {esper-3.4 → esper-3.6}/.github/workflows/unit-tests.yml +3 -3
  2. {esper-3.4 → esper-3.6}/PKG-INFO +4 -3
  3. {esper-3.4 → esper-3.6}/README.md +1 -1
  4. {esper-3.4 → esper-3.6}/RELEASE_NOTES +32 -0
  5. {esper-3.4 → esper-3.6}/docs/index.rst +1 -0
  6. {esper-3.4 → esper-3.6}/esper/__init__.py +256 -171
  7. {esper-3.4 → esper-3.6}/examples/benchmark.py +109 -0
  8. {esper-3.4 → esper-3.6}/tests/test_world.py +186 -3
  9. {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  10. {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  11. {esper-3.4 → esper-3.6}/.github/ISSUE_TEMPLATE/question-or-comment.md +0 -0
  12. {esper-3.4 → esper-3.6}/.github/workflows/type-checking.yml +0 -0
  13. {esper-3.4 → esper-3.6}/.gitignore +0 -0
  14. {esper-3.4 → esper-3.6}/.mypy.ini +0 -0
  15. {esper-3.4 → esper-3.6}/.readthedocs.yaml +0 -0
  16. {esper-3.4 → esper-3.6}/.ruff.toml +0 -0
  17. {esper-3.4 → esper-3.6}/LICENSE +0 -0
  18. {esper-3.4 → esper-3.6}/MANIFEST.in +0 -0
  19. {esper-3.4 → esper-3.6}/docs/Makefile +0 -0
  20. {esper-3.4 → esper-3.6}/docs/conf.py +0 -0
  21. {esper-3.4 → esper-3.6}/docs/make.bat +0 -0
  22. {esper-3.4 → esper-3.6}/esper/py.typed +0 -0
  23. {esper-3.4 → esper-3.6}/examples/benchmark_cache.py +0 -0
  24. {esper-3.4 → esper-3.6}/examples/bluesquare.png +0 -0
  25. {esper-3.4 → esper-3.6}/examples/headless_example.py +0 -0
  26. {esper-3.4 → esper-3.6}/examples/pygame_example.py +0 -0
  27. {esper-3.4 → esper-3.6}/examples/pyglet_example.py +0 -0
  28. {esper-3.4 → esper-3.6}/examples/pysdl2_example.py +0 -0
  29. {esper-3.4 → esper-3.6}/examples/pythonista_ios_example.py +0 -0
  30. {esper-3.4 → esper-3.6}/examples/redsquare.png +0 -0
  31. {esper-3.4 → esper-3.6}/make.py +0 -0
  32. {esper-3.4 → esper-3.6}/pyproject.toml +0 -0
  33. {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.8', '3.9', '3.10', '3.11', '3.12-dev', 'pypy-3.10' ]
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@v3
17
+ uses: actions/checkout@v4
18
18
  - name: Set up Python ${{ matrix.python-version }}
19
- uses: actions/setup-python@v4
19
+ uses: actions/setup-python@v5
20
20
  with:
21
21
  python-version: ${{ matrix.python-version }}
22
22
  - name: Install test dependencies
@@ -1,11 +1,12 @@
1
- Metadata-Version: 2.3
1
+ Metadata-Version: 2.4
2
2
  Name: esper
3
- Version: 3.4
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
  [![pypi](https://badge.fury.io/py/esper.svg)](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 s specific period of time, then disappear. For example, the code below
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 s specific period of time, then disappear. For example, the code below
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.4'
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 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::
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
- 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::
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
- # ECS functions
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: "_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(), {}, {}, [], {}, {})}
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
- clear_cache()
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: _Type[Processor]) -> None:
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
- for processor in _processors:
214
- if type(processor) is processor_type:
215
- _processors.remove(processor)
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: _Type[Processor]) -> _Optional[Processor]:
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
- for processor in _processors:
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
- _entities[entity][component_type] = component_instance
255
- clear_cache()
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
- for component_type in _entities[entity]:
274
- _components[component_type].discard(entity)
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 _components[component_type]:
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: _Type[_C]) -> _C:
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) -> _Tuple[_C, ...]:
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: _Type[_C]) -> bool:
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: _Type[_C]) -> bool:
399
+ def has_components(entity: int, *component_types: type[_C]) -> bool:
327
400
  """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)
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: _Optional[_Type[_C]] = None) -> None:
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: _Type[_C]) -> _C:
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].discard(entity)
435
+ comp_set = _components[component_type]
436
+ comp_set.discard(entity)
364
437
 
365
- if not _components[component_type]:
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 _get_component(component_type: _Type[_C]) -> _Iterable[_Tuple[int, _C]]:
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 _components.get(component_type, []):
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: _Type[_C]) -> _Iterable[_Tuple[int, _List[_C]]]:
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
- 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
-
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
- def get_component(component_type: _Type[_C]) -> _List[_Tuple[int, _C]]:
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
- try:
393
- return _get_component_cache[component_type]
394
- except KeyError:
395
- return _get_component_cache.setdefault(component_type, list(_get_component(component_type)))
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: _Type[_C], __c2: _Type[_C2]) -> _List[_Tuple[int, _Tuple[_C, _C2]]]:
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: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _List[_Tuple[int, _Tuple[_C, _C2, _C3]]]:
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: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _List[
410
- _Tuple[int, _Tuple[_C, _C2, _C3, _C4]]]:
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: _Type[_Any]) -> _Iterable[_Tuple[int, _Tuple[_Any, ...]]]:
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
- try:
417
- return _get_components_cache[component_types]
418
- except KeyError:
419
- return _get_components_cache.setdefault(component_types, list(_get_components(*component_types)))
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
- def try_component(entity: int, component_type: _Type[_C]) -> _Optional[_C]:
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
- if component_type in _entities[entity]:
431
- return _entities[entity][component_type] # type: ignore[no-any-return]
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: _Type[_C], __c2: _Type[_C2]) -> _Tuple[_C, _C2]:
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: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3]) -> _Tuple[_C, _C2, _C3]:
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: _Type[_C], __c2: _Type[_C2], __c3: _Type[_C3], __c4: _Type[_C4]) -> _Tuple[_C, _C2, _C3, _C4]:
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: _Type[_C]) -> _Optional[_Tuple[_C, ...]]:
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
- 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]
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 _entities[entity]:
477
- _components[component_type].discard(entity)
621
+ for component_type in entity_comps:
622
+ comp_set = _components[component_type]
623
+ comp_set.discard(entity)
478
624
 
479
- if not _components[component_type]:
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, list)
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, list)
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, list)
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
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