microecs 0.2.0__tar.gz → 0.2.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
5
  Home-page: https://gitlab.com/meehai/microecs
6
6
  License: MIT
@@ -24,7 +24,7 @@ There are only 5 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`.
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
28
  - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
29
29
  - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions). They are merely a convention, not tied to `World` per se.
30
30
 
@@ -59,7 +59,7 @@ class MotionSystem(TickSystem)
59
59
  def on_tick(self, world: World): # must override
60
60
  qr = world.query_and((HasPosition, HasVelocity))
61
61
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
62
- # alternative, if you prefer per-pool update. May be faster in the extreme cases as it avoid the _Field creation
62
+ # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
63
63
  for pool in qr.pool_list:
64
64
  pool.position[:] = pool.position + pool.velocity * DT
65
65
 
@@ -12,7 +12,7 @@ There are only 5 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
12
12
 
13
13
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`.
14
14
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
15
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`.
15
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
16
16
  - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
17
17
  - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions). They are merely a convention, not tied to `World` per se.
18
18
 
@@ -47,7 +47,7 @@ class MotionSystem(TickSystem)
47
47
  def on_tick(self, world: World): # must override
48
48
  qr = world.query_and((HasPosition, HasVelocity))
49
49
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
50
- # alternative, if you prefer per-pool update. May be faster in the extreme cases as it avoid the _Field creation
50
+ # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
51
51
  for pool in qr.pool_list:
52
52
  pool.position[:] = pool.position + pool.velocity * DT
53
53
 
@@ -19,6 +19,20 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
19
19
  """Creates a numpy array from the underlying pool parts"""
20
20
  return np.concatenate(self.parts)
21
21
 
22
+ def _chunk(self, x: T, i: int) -> T:
23
+ if isinstance(x, _Field):
24
+ return x.parts[i]
25
+ if isinstance(x, np.ndarray) and x.ndim == len(self.shape) and x.shape[0] == self.len:
26
+ return x[self._bounds[i]:self._bounds[i + 1]]
27
+ return x
28
+
29
+ def _apply_fn_on_parts(self, fn: Callable, op_args: list, **kwargs):
30
+ results = []
31
+ for i in range(len(self.parts)):
32
+ pool_args = [self._chunk(x, i) for x in op_args]
33
+ results.append(fn(*pool_args, **kwargs))
34
+ return _Field(results)
35
+
22
36
  def __array_ufunc__(self, ufunc, method, *inputs, out=None, **kwargs):
23
37
  """wrapper forelementwise (python) primitives, e.g. qr.position[:] += 1"""
24
38
  if method != "__call__":
@@ -38,20 +52,6 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
38
52
  return NotImplemented # add them manually
39
53
  return self._apply_fn_on_parts(func, args, **kwargs)
40
54
 
41
- def _chunk(self, x: T, i: int) -> T:
42
- if isinstance(x, _Field):
43
- return x.parts[i]
44
- if isinstance(x, np.ndarray) and x.ndim >= 1 and x.shape[0] == self.len: # full-N raw -> slice per pool
45
- return x[self._bounds[i]:self._bounds[i + 1]]
46
- return x
47
-
48
- def _apply_fn_on_parts(self, fn: Callable, op_args: list, **kwargs):
49
- results = []
50
- for i in range(len(self.parts)):
51
- pool_args = [self._chunk(x, i) for x in op_args]
52
- results.append(fn(*pool_args, **kwargs))
53
- return _Field(results)
54
-
55
55
  # qr.position[:] = <field | scalar | per-entity broadcast> -> scatter through the views
56
56
  def __setitem__(self, key, value):
57
57
  if (not (isinstance(key, slice) and key == slice(None)) and
@@ -85,23 +85,32 @@ class _Field(np.lib.mixins.NDArrayOperatorsMixin):
85
85
 
86
86
  class QueryResult:
87
87
  """A list of pools seen as a contiguous view. Fields (qr.position) implement array interface to look like numpy"""
88
- def __init__(self, pool_list: list[Pool], field_shapes: dict[str, Shape], field_dtypes: dict[str, np.dtype]):
88
+ def __init__(self, pool_list: list[Pool], field_shapes: dict[str, Shape], field_dtypes: dict[str, np.dtype],
89
+ entity_ids: np.ndarray):
89
90
  self.pool_list = pool_list
90
- self.field_shapes = field_shapes
91
- self.field_dtypes = field_dtypes
92
- self.fields = list(field_shapes)
93
- self.data: dict[str, np.ndarray] = {f: [p.data[f][0:len(p)] for p in pool_list] for f in field_shapes.keys()}
94
- self.len = sum(len(pool) for pool in self.pool_list)
91
+ self.entity_ids = entity_ids
92
+ self._field_shapes = field_shapes
93
+ self._field_dtypes = field_dtypes
94
+ self._fields = list(field_shapes)
95
+ self._data: dict[str, np.ndarray] = {f: [p.data[f][0:len(p)] for p in pool_list] for f in field_shapes.keys()}
96
+ self._len = sum(len(pool) for pool in self.pool_list)
95
97
 
96
98
  def __getattr__(self, name):
97
- if (data := self.__dict__.get("data")) is not None and name in data:
99
+ if (data := self.__dict__.get("_data")) is not None and name in data:
98
100
  # the 'or' part is in case no pools match the query and we want qr.position[:] += 1 still to work (noop)
99
- return _Field(data[name] or [np.empty((0, *self.field_shapes[name]), self.field_dtypes[name])])
101
+ return _Field(data[name] or [np.empty((0, *self._field_shapes[name]), self._field_dtypes[name])])
100
102
  raise AttributeError(name)
101
103
 
104
+ def __setattr__(self, name, value):
105
+ if (data := self.__dict__.get("_data")) is not None and name in data:
106
+ getattr(self, name)[:] = value # recarray semantics: assigning a field scatters into it
107
+ return
108
+ super().__setattr__(name, value)
109
+
102
110
  def __len__(self):
103
- return self.len
111
+ return self._len
104
112
 
105
113
  def __repr__(self):
106
- return (f"[QueryResult]\n- Fields: {self.fields}\n- Pools: {len(self.pool_list)}\n- Len: {self.len}"
107
- f"\n- Shapes: {list(self.field_shapes.values())}\n- Dtypes: {list(self.field_dtypes.values())}")
114
+ return (f"[QueryResult]\n- Entities: {len(self.entity_ids)}\n- Fields: {self._fields}"
115
+ f"\n- Pools: {len(self.pool_list)}\n- Len: {self._len}\n- Shapes: {list(self._field_shapes.values())}"
116
+ f"\n- Dtypes: {list(self._field_dtypes.values())}")
@@ -25,11 +25,12 @@ class World:
25
25
  self.component_to_field_names: dict[type, list[str]] = {t: list(t.__dataclass_fields__) for t in components}
26
26
  # entity id management
27
27
  self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
28
- self._pool_ix_to_eid: dict[tuple[Pool, int], EntityId] = {}
28
+ self._pool_ids: dict[Pool, list[EntityId]] = {}
29
29
  self._last_id: EntityId = -1
30
30
  self._live_ids: set[EntityId] = set() # 'eager' mode ids so e.g. calling remove_entity twice before update fails
31
31
  # command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
32
32
  self._command_buffer: list[Callable] = []
33
+ self._cache: dict[PoolKey, QueryResult] = {}
33
34
  logger.debug(f"Created scene with components: {self.component_names}")
34
35
 
35
36
  # public api
@@ -38,7 +39,10 @@ class World:
38
39
  """commits the underlying pool changes from the systems between two updates. Should be called in main loop."""
39
40
  for fn in self._command_buffer:
40
41
  fn()
41
- self._command_buffer.clear()
42
+
43
+ if len(self._command_buffer) > 0:
44
+ self._command_buffer.clear()
45
+ self._cache.clear()
42
46
 
43
47
  def add_entity(self, components: list[ComponentType], **kwargs) -> EntityId:
44
48
  """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
@@ -77,16 +81,22 @@ class World:
77
81
 
78
82
  def query_and(self, component_types: list[ComponentType]) -> QueryResult:
79
83
  """returns A QueryResult object with the entities that have all the requested components (entity ids too)."""
80
- key = self._make_key(component_types)
84
+ # Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
85
+ if (key := self._make_key(component_types)) in self._cache:
86
+ return self._cache[key]
87
+
81
88
  res = []
82
89
  for archetype_key, archetype_pool in self.pools.items():
83
90
  if (archetype_key & key) == key: # key is subset of archetype_key
84
91
  res.append(archetype_pool)
85
92
 
86
93
  field_names = sum([self.component_to_field_names[c] for c in component_types], [])
87
- field_shapes = sum([self.component_to_shapes[c] for c in component_types], [])
88
- field_dtypes = sum([self.component_to_dtypes[c] for c in component_types], [])
89
- return QueryResult(res, dict(zip(field_names, field_shapes)), dict(zip(field_names, field_dtypes)))
94
+ field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in component_types], [])))
95
+ field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in component_types], [])))
96
+ entity_ids = np.array(sum((self._pool_ids[p] for p in res), []), dtype="int64")
97
+
98
+ self._cache[key] = QueryResult(res, field_shapes=field_shapes, field_dtypes=field_dtypes, entity_ids=entity_ids)
99
+ return self._cache[key]
90
100
 
91
101
  # private stuff
92
102
 
@@ -97,20 +107,22 @@ class World:
97
107
  pool = self._get_entity_pool(components)
98
108
  pool_index = pool.add_entity(**kwargs)
99
109
  self._eid_to_pool_ix[entity_id] = (pool, pool_index)
100
- self._pool_ix_to_eid[(pool, pool_index)] = entity_id
110
+ self._pool_ids.setdefault(pool, []).append(entity_id)
111
+ assert len(self._pool_ids[pool]) == len(pool), (pool, len(self._pool_ids[pool]), len(pool))
101
112
 
102
113
  def _pop_from_pool(self, entity_id: EntityId) -> tuple[EntityData, list[ComponentType]]:
103
114
  """common function that updates the entities inside a pool (after popswap) and removes them if they get empty"""
104
115
  old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
105
116
  entity = old_pool.pop_entity(pool_ix)
106
117
  components = self.pool_to_components[old_pool]
107
- id_which_was_last_in_pool = self._pool_ix_to_eid.pop((old_pool, len(old_pool)))
118
+ id_which_was_last_in_pool = self._pool_ids[old_pool].pop()
108
119
  if entity_id != id_which_was_last_in_pool:
109
120
  self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
110
- self._pool_ix_to_eid[(old_pool, pool_ix)] = id_which_was_last_in_pool
121
+ self._pool_ids[old_pool][pool_ix] = id_which_was_last_in_pool
111
122
  if len(old_pool) == 0:
112
123
  del self.pools[self._make_key(components)]
113
124
  del self.pool_to_components[old_pool]
125
+ del self._pool_ids[old_pool]
114
126
  return entity, components
115
127
 
116
128
  def _do_add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
@@ -158,7 +170,9 @@ class World:
158
170
  return key
159
171
 
160
172
  def _check_components(self, components: list[ComponentType]):
173
+ _query_result_reserved_names = _qres = sorted(vars(QueryResult([], {}, {}, [])))
161
174
  _dtypes = {"float32", "int32", "bool", "str", "object"}
175
+
162
176
  for component in components:
163
177
  assert hasattr(component, "__dataclass_fields__"), f"Component '{component}' is not a dataclass"
164
178
  hints = get_type_hints(component) # make it work with from __future__ import annotations
@@ -167,6 +181,7 @@ class World:
167
181
  assert _field.metadata.keys() == {"shape", "dtype"}, _field.metadata
168
182
  assert isinstance(_field.metadata["shape"], tuple), _field.metadata["shape"]
169
183
  assert isinstance(fmd := _field.metadata["dtype"], str) and fmd in _dtypes, f"{fmd} not in {_dtypes}"
184
+ assert field_name not in _query_result_reserved_names, f"Field '{field_name}' in {_qres}"
170
185
 
171
186
  def __len__(self):
172
187
  return len(self._live_ids)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: microecs
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: MicroECS: Minimal Entity Component System (ECS) in python and numpy
5
5
  Home-page: https://gitlab.com/meehai/microecs
6
6
  License: MIT
@@ -24,7 +24,7 @@ There are only 5 primitives (bottom up): `Component`, `Pool`, `QueryResult`, `Wo
24
24
 
25
25
  - `Component` is a simple python dataclass holding only data. All entries must be numpy arrays with metadata fields: shape and dtype. We support 5 dtypes only: `int32`, `float32`, `bool`, `str` and `object`.
26
26
  - `Pool` is a simple 'archetype' dynamic array, holding entities of the same type (same set of components). Usses `Components` metadata to construct contiguous arrays for all entities of the same type.
27
- - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`.
27
+ - `QueryResult` is a list of pools that match some query on all the entities of the `World`. It acts as a contiguous numpy-like container that implements numpy's `__array_function__` and `__array_ufunc__`. For all intents and purposes it should feel like a `(N, ...)` view over all the entities. If you need a numpy array (not all ops are supported, for e.g. indexing on the first axis), use `QueryResult.numpy()`. It also exposes `entity_ids`: a flat `(N,)` array of the matched entities' ids, in the same pool-by-pool order as the fields, so you can `zip(qr.entity_ids, qr.position)` or feed an id back to `world.get_entity` / `world.remove_entity`.
28
28
  - `World` is a manager of `Pools` and has an overview of all the entities in the scene. It also manages the migration of entities from one pool to the other.
29
29
  - `System` is an abstract class that queries the `World` for a subset of `Pools` matching some components. It updates the entities in these pools given some logic (e.g. collisions, motion physics or simply calls the drawing functions). They are merely a convention, not tied to `World` per se.
30
30
 
@@ -59,7 +59,7 @@ class MotionSystem(TickSystem)
59
59
  def on_tick(self, world: World): # must override
60
60
  qr = world.query_and((HasPosition, HasVelocity))
61
61
  qr.position[:] = qr.position + qr.velocity * DT # writes back to all the underlying pools using numpy's rules
62
- # alternative, if you prefer per-pool update. May be faster in the extreme cases as it avoid the _Field creation
62
+ # Alternative for per-pool update. Less ergonomic, but maybe faster in extreme cases as it avoids the _Field obj
63
63
  for pool in qr.pool_list:
64
64
  pool.position[:] = pool.position + pool.velocity * DT
65
65
 
@@ -3,7 +3,7 @@ from pathlib import Path
3
3
  from setuptools import setup, find_packages
4
4
 
5
5
  NAME = "microecs"
6
- VERSION = "0.2.0"
6
+ VERSION = "0.2.2"
7
7
  DESCRIPTION = "MicroECS: Minimal Entity Component System (ECS) in python and numpy"
8
8
  URL = "https://gitlab.com/meehai/microecs"
9
9
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes