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.
- {microecs-0.2.0 → microecs-0.2.2}/PKG-INFO +3 -3
- {microecs-0.2.0 → microecs-0.2.2}/README.md +2 -2
- {microecs-0.2.0 → microecs-0.2.2}/microecs/query_result.py +34 -25
- {microecs-0.2.0 → microecs-0.2.2}/microecs/world.py +24 -9
- {microecs-0.2.0 → microecs-0.2.2}/microecs.egg-info/PKG-INFO +3 -3
- {microecs-0.2.0 → microecs-0.2.2}/setup.py +1 -1
- {microecs-0.2.0 → microecs-0.2.2}/LICENSE.TXT +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs/__init__.py +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs/component.py +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs/pool.py +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs/system.py +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs/utils.py +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs.egg-info/SOURCES.txt +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs.egg-info/dependency_links.txt +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs.egg-info/requires.txt +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/microecs.egg-info/top_level.txt +0 -0
- {microecs-0.2.0 → microecs-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: microecs
|
|
3
|
-
Version: 0.2.
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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.
|
|
91
|
-
self.
|
|
92
|
-
self.
|
|
93
|
-
self.
|
|
94
|
-
self.
|
|
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("
|
|
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.
|
|
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.
|
|
111
|
+
return self._len
|
|
104
112
|
|
|
105
113
|
def __repr__(self):
|
|
106
|
-
return (f"[QueryResult]\n-
|
|
107
|
-
f"\n-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
88
|
-
field_dtypes = sum([self.component_to_dtypes[c]
|
|
89
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
#
|
|
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
|
|
|
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
|