microecs.world

world.py - The world container for ECS. It manages all the pools (one per archetype). Entities are id-based.

  1"""world.py - The world container for ECS. It manages all the pools (one per archetype). Entities are id-based."""
  2from typing import get_type_hints
  3from dataclasses import fields
  4import numpy as np
  5
  6from .utils import Shape, EntityId, PoolKey, logger
  7from .component import ComponentType
  8from .query_result import QueryResult, QUERY_RESULT_RESERVED_NAMES
  9from .entity import Entity, ENTITY_RESERVED_NAMES
 10from .pool import Pool, POOL_RESERVED_NAMES
 11from .command_buffer import CommandBuffer, Command, CommandType
 12
 13WORLD_RESERVED_NAMES = {"check_extra", "component", "components", "strict"}
 14
 15class World:
 16    """
 17    Generic container for pools of components. Newly added components are assigned a unique id and go in a pool
 18    Parameters
 19    - components The list of components that the world accepts
 20    - extra_metadata The list of required extra metadata for each field besides shape and dtype.
 21    """
 22    def __init__(self, components: list[ComponentType], extra_metadata: list[str] | None = None):
 23        self._default_metadata = {"shape", "dtype", "default"}
 24        self.extra_metadata = extra_metadata or []
 25        if not isinstance(self.extra_metadata, list):
 26            raise TypeError(type(self.extra_metadata))
 27        self._check_components(components)
 28
 29        # Pools management
 30        self.pools: dict[PoolKey, Pool] = {}
 31        self.pool_to_components: dict[Pool, list[ComponentType]] = {}
 32
 33        # Components management
 34        self.field_to_component: dict[str, ComponentType] = {}
 35        self.component_names = [x.__name__ for x in components]
 36        self.component_types = set(components)
 37        self.component_name_to_type = {x.__name__: x for x in components}
 38        self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
 39        self.component_to_field_names: dict[ComponentType, list[str]] = {c: [] for c in components}
 40        self.component_to_shapes: dict[ComponentType, list[Shape]] = {c: [] for c in components}
 41        self.component_to_dtypes: dict[ComponentType, list[str]] = {c: [] for c in components}
 42        self.component_to_defaults: dict[ComponentType, list[np.ndarray]] = {c: [] for c in components}
 43        # setup the obligatory metadata at each fields
 44        for c in components:
 45            for f in fields(c):
 46                self.component_to_field_names[c].append(f.name)
 47                self.component_to_shapes[c].append(field_shape := f.metadata["shape"])
 48                self.component_to_dtypes[c].append(field_dtype := f.metadata["dtype"])
 49
 50                # This allows defaults to be passed as lists/tuples if they are properly broadcastable
 51                if (field_default := f.metadata["default"]) is not None:
 52                    if not isinstance(field_default, np.ndarray):
 53                        field_default = np.broadcast_to(np.asarray(field_default, field_dtype), field_shape)
 54                    if (dt := field_default.dtype) != field_dtype:
 55                        raise TypeError(f"'{c.__name__}/{f.name}'. Expected dtype: {field_dtype}. Got: {dt}")
 56                    if (sh := field_default.shape) != field_shape:
 57                        raise ValueError(f"'{c.__name__}/{f.name}'. Expected shape: {field_shape}. Got: {sh}")
 58                self.component_to_defaults[c].append(field_default)
 59
 60                if f.name in self.field_to_component:
 61                    raise ValueError(f"Duplicate field '{c.__name__}/{f.name}': {self.field_to_component[f.name]}")
 62                self.field_to_component[f.name] = c
 63
 64        # Entities management
 65        self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
 66        self._pool_ids: dict[Pool, list[EntityId]] = {}
 67        self._last_id: EntityId = -1
 68        # A dictionary of all live entities in 'eager' mode (before update()). The actual entity is created at request
 69        # in get_entity, so we don't pay for the Entity object unless it's explicitly requested by the user.
 70        self.live_entities: dict[EntityId, Entity | None] = {}
 71
 72        # Command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
 73        self._command_buffer = CommandBuffer(self)
 74
 75        # QueryResult cache (key:tuple[include, exclude] - see query()). Useful so we re-use qrs between world.updates.
 76        self._qr_cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {}
 77        logger.debug(f"Created scene with components: {self.component_names}")
 78
 79    # public api
 80
 81    def add_entity(self, components: list[ComponentType], **kwargs) -> EntityId:
 82        """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
 83        kwargs = self._convert_components_data(components, **kwargs)
 84        self._validate_components(components, **kwargs)
 85        default_kwargs = self._defaults_for(components, **kwargs)
 86        self._last_id += 1
 87        self.live_entities[self._last_id] = None # add the id the live_entities, but the object is created in get_entity
 88        self._command_buffer.append(Command(CommandType.ADD_ENTITY, self._last_id,
 89                                            args={"components": components, **kwargs, **default_kwargs}))
 90        return self._last_id
 91
 92    def remove_entity(self, entity_id: EntityId):
 93        """Removes an entity based on its unique entity id. The same entity can be safely removed multiple times in the
 94        same tick (e.g. by different systems). Raises if the eid is not (anymore) in the world. Lazy; call update()"""
 95        if entity_id not in self.live_entities:
 96            # NOTE: the only reason this may happen is if we called remove_entity >=2 times before world.update()
 97            if entity_id not in self._command_buffer.removed_this_tick:
 98                raise ValueError(f"Entity: {entity_id} is not in the world (stale). Either wrong id or removed earlier")
 99            return
100        self._command_buffer.append(Command(CommandType.REMOVE_ENTITY, entity_id))
101        del self.live_entities[entity_id]
102
103    def get_entity(self, entity_id: EntityId) -> Entity:
104        """Gets the entity reference given an entity id. Used for 'object-like' ops. Structural updates
105        (add/remove_component) are lazy, so you need to call world.update(). Data updates (set_data) are immediate."""
106        try:
107            entity = self.live_entities[entity_id]
108        except KeyError:
109            raise ValueError(f"Entity id: {entity_id} not in the world")
110
111        if entity is None: # this can happen if it was just added by add_entity() but not materialized yet
112            self.live_entities[entity_id] = entity = Entity(
113                entity_id, eid_to_pool_ix=self._eid_to_pool_ix, pool_to_components=self.pool_to_components,
114                world_command_buffer=self._command_buffer)
115
116        return entity
117
118    def query(self, *include: ComponentType, exclude: list[ComponentType] | None = None) -> QueryResult:
119        """
120        Queries the world for entities that match the include set of components.
121        Syntax: `world.query(A, B, exclude=[C, D])` is, in logic form, a chain of 'ands': `A & B & ~C & ~D`.
122        Return: A `QueryResult` object with the entities that have all the requested components. Has `EntityIds` too.
123        """
124
125        # Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
126        include_key = self._make_key(include)
127        exclude_key = self._make_key(exclude or [])
128        if (key := (include_key, exclude_key)) in self._qr_cache:
129            return self._qr_cache[key]
130
131        # archetype_key = (1 0 0 1 1) &
132        #           key = (1 0 0 0 1)
133        #              -> (1 0 0 0 1) OK
134        # but
135        # archetype_key = (1 0 0 1 1) &
136        #           key = (1 0 1 0 0)
137        #              -> (1 0 0 0 0) NOT OK
138        res = []
139        for archetype_key, archetype_pool in self.pools.items():
140            if (archetype_key & include_key) == include_key and (archetype_key & exclude_key) == 0:
141                res.append(archetype_pool)
142
143        field_names = sum([self.component_to_field_names[c] for c in include], [])
144        field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in include], [])))
145        field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in include], [])))
146        self._qr_cache[key] = QueryResult(res, field_shapes, field_dtypes=field_dtypes, pool_ids=self._pool_ids)
147        return self._qr_cache[key]
148
149    def update(self):
150        """commits the underlying pool changes from the systems between two updates. Should be called in main loop."""
151        for command in self._command_buffer:
152            if command.command_type == CommandType.ADD_ENTITY:
153                components = command.args.pop("components")
154                self._add_to_pool(command.entity_id, components=components, entity_data=command.args)
155            elif command.command_type == CommandType.REMOVE_ENTITY:
156                self._remove_from_pool(command.entity_id)
157            elif command.command_type == CommandType.ADD_COMPONENT:
158                component = command.args.pop("component")
159                self._do_add_component(command.entity_id, component=component, **command.args)
160            elif command.command_type == CommandType.REMOVE_COMPONENT:
161                self._do_remove_component(command.entity_id, component=command.args)
162            else: # CommandType.REMOVE_COMPONENT
163                raise NotImplementedError(command)
164
165        # Check if there's any empty pool since the last movements and remove it, if so.
166        empty_keys = [pool_key for pool_key, pool in self.pools.items() if len(pool) == 0]
167        for pool_key in empty_keys:
168            pool = self.pools.pop(pool_key)
169            del self.pool_to_components[pool]
170            del self._pool_ids[pool]
171
172        if len(self._command_buffer) > 0:
173            self._qr_cache.clear()
174        self._command_buffer.clear() # .clear() here also clears the buffer.removed_this_tick set.
175
176    # private stuff
177
178    # eager mode methods equivalent to add/remove entities and add/remove_components
179
180    def _add_to_pool(self, entity_id: EntityId, components: list[ComponentType], entity_data: dict[str, np.ndarray]):
181        """adds the item to the pool"""
182        pool = self._get_entity_pool(components)
183        pool_index = pool.add_entity(entity_data=entity_data)
184        self._eid_to_pool_ix[entity_id] = (pool, pool_index)
185        self._pool_ids.setdefault(pool, []).append(entity_id)
186        if len(self._pool_ids[pool]) != len(pool):
187            raise ValueError(pool, len(self._pool_ids[pool]), len(pool))
188
189    def _remove_from_pool(self, entity_id: EntityId):
190        """removes the entity from the pool w/o any internal data copying, like _pop_from_pool"""
191        old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
192        old_pool.remove_entity(pool_ix)
193        # Move the last id from the pool in the place of the recently removed entity
194        id_which_was_last_in_pool = self._pool_ids[old_pool].pop()
195
196        if entity_id != id_which_was_last_in_pool:
197            self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
198            self._pool_ids[old_pool][pool_ix] = id_which_was_last_in_pool
199
200    def _pop_from_pool(self, entity_id: EntityId) -> tuple[dict[str, np.ndarray], list[ComponentType]]:
201        """common function that updates the entities inside a pool (after popswap) and removes them if they get empty"""
202        old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
203        entity_data = old_pool.pop_entity(pool_ix)
204        components = self.pool_to_components[old_pool]
205
206        # Move the last id from the pool in the place of the recently removed entity
207        id_which_was_last_in_pool = self._pool_ids[old_pool].pop()
208        if entity_id != id_which_was_last_in_pool:
209            self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
210            self._pool_ids[old_pool][pool_ix] = id_which_was_last_in_pool
211
212        return entity_data, components
213
214    def _do_add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
215        curr_data, curr_components = self._pop_from_pool(entity_id)
216        if not curr_data.keys().isdisjoint(kwargs):
217            raise ValueError(f"Duplicate keys: {curr_data.keys()} vs {kwargs.keys()}")
218
219        new_components = [*curr_components, component]
220        default_kwargs = self._defaults_for(new_components, **curr_data, **kwargs)
221        new_data = {**curr_data, **kwargs, **default_kwargs}
222        self._add_to_pool(entity_id, components=new_components, entity_data=new_data)
223
224    def _do_remove_component(self, entity_id: EntityId, component: ComponentType):
225        entity_data, components = self._pop_from_pool(entity_id)
226        for _field in self.component_to_field_names[component]:
227            if _field not in entity_data:
228                raise ValueError(f"Field {component}/{_field} not in components: {components} ({entity_id=})")
229            entity_data.pop(_field)
230        new_components = [c for c in components if c != component]
231        self._add_to_pool(entity_id, components=new_components, entity_data=entity_data)
232
233    # other low-level methods
234
235    def _validate_component(self, component: ComponentType, strict: bool, check_extra: bool, **kwargs):
236        """
237        Validates a single component.
238        Parameters:
239        - strict Two modes: True -> kwargs==component fields, False -> kwargs is subset
240        - check_extra If set, extra data in kwargs unrelated to this component also raises (_validate_components)
241        """
242        if check_extra and (extra := set(kwargs) - set(self.component_to_field_names[component])):
243            raise ValueError(f"Extra fields: {extra}; expected {self.component_to_field_names[component]}")
244
245        for name, shape, dtype, default in zip(
246                self.component_to_field_names[component], self.component_to_shapes[component],
247                self.component_to_dtypes[component], self.component_to_defaults[component]):
248            if name not in kwargs:
249                if default is None and strict is True:
250                    raise KeyError(f"'{component.__name__}/{name}' required (default=None) but not supplied")
251                continue                      # omitted but has a default -> fine (or non-strict mode for set_data)
252            if not isinstance(field := kwargs[name], np.ndarray):
253                raise TypeError(f"'{component.__name__}/{name}'. Expected np.ndarray, got {type(field)}")
254            if (dt := field.dtype) != dtype:
255                raise TypeError(f"'{component.__name__}/{name}'. Expected dtype {dtype}, got {dt}")
256            if (sh := field.shape) != shape:
257                raise ValueError(f"'{component.__name__}/{name}'. Expected shape {shape}, got {sh}")
258
259    def _validate_components(self, components: list[ComponentType], **kwargs):
260        """Pure check. Raises on: no components, unknown component, missing-required (default=None),
261            wrong dtype/shape, extra field. No mutation, no return. kwargs == fields data."""
262        if len(cs := set(components)) == 0:
263            raise ValueError(f"Entity has no components: {self.component_names}")
264        if diff := cs - self.component_types:
265            raise ValueError(f"Unknown components: {diff}")
266        if len(cs) != len(components):
267            raise ValueError(f"Duplicate components: {components}")
268
269        expected = set()
270        for component in components:
271            expected.update(self.component_to_field_names[component]) # updated here for error message.
272            self._validate_component(component, strict=True, check_extra=False, **kwargs)
273        if extra := set(kwargs) - expected:
274            raise ValueError(f"Extra fields: {extra}; expected {expected}")
275
276    def _convert_components_data(self, components: list[ComponentType], **kwargs) -> dict[str, np.ndarray]:
277        """This allows components data to be passed as convertible (e.g. tuple->ndarray or even numpy broadcasting).
278        Unknown kwargs are passed through untouched so the "Extra fields" validation can still fire on them."""
279        res = dict(kwargs)
280        for c in components:
281            for name, dtype, shape in zip(self.component_to_field_names[c], self.component_to_dtypes[c],
282                                          self.component_to_shapes[c]):
283                if name not in kwargs:
284                    continue
285
286                if (data := kwargs.get(name)) is None and dtype != "object":
287                    raise TypeError(f"'{c.__name__}/{name}': None is not a valid {dtype} value")
288                # If data is given as ndarray, then use it as-is (user provided). Otherwise, convert it to shape/dtype
289                res[name] = data
290                if not isinstance(data, np.ndarray):
291                    res[name] = np.broadcast_to(np.asarray(data, dtype=dtype), shape=shape)
292        return res
293
294    def _defaults_for(self, components: list[ComponentType], **kwargs) -> dict[str, np.ndarray]:
295        """Defaults for omitted fields. Assumes already validated. No mutation of `kwargs` (data) is done."""
296        res = {}
297        for c in components:
298            for name, default in zip(self.component_to_field_names[c], self.component_to_defaults[c]):
299                if name not in kwargs and default is not None:
300                    res[name] = default.copy()
301        return res
302
303    def _get_entity_pool(self, components: list[ComponentType]) -> Pool:
304        if (key := self._make_key(components)) not in self.pools:
305            _fields = sum([self.component_to_field_names[c] for c in components], []) # merge fields
306            shapes = sum([self.component_to_shapes[c] for c in components], []) # merge shapes
307            dtypes = sum([self.component_to_dtypes[c] for c in components], []) # merge dtypes
308            self.pools[key] = Pool(_fields, shapes, dtypes)
309            self.pool_to_components[self.pools[key]] = components
310        return self.pools[key]
311
312    def _make_key(self, components: list[ComponentType]) -> PoolKey:
313        key = 0
314        for c in components:
315            if c not in self.component_types:
316                raise ValueError(f"'{c.__name__}' not in {self.component_names}")
317            key |= self.component_to_bit[c]
318        return key
319
320    def _check_components(self, components: list[ComponentType]):
321        reserved = ENTITY_RESERVED_NAMES | POOL_RESERVED_NAMES | QUERY_RESULT_RESERVED_NAMES | WORLD_RESERVED_NAMES
322        dtypes = {"float32", "int32", "bool", "object"}
323        expected_meta = {*self._default_metadata, *self.extra_metadata}
324
325        for c in components:
326            if not hasattr(c, "__dataclass_fields__"):
327                raise TypeError(f"c '{c.__name__}' is not a dataclass")
328
329            hints = get_type_hints(c) # make it work with from __future__ import annotations
330            for f in fields(c):
331                if hints[f.name] is not np.ndarray:
332                    raise TypeError(f"Field '{c.__name__}/{f.name}' not an array: {f}")
333                if f.name in reserved:
334                    raise ValueError(f"Field '{c.__name__}/{f.name}' in {reserved}")
335                if f.metadata.keys() != expected_meta:
336                    raise ValueError(f"Field '{c.__name__}/{f.name}'\n{list(f.metadata.keys())}\nvs\n{expected_meta}\n"
337                                      "Perhaps missing World(extra_metadata=[...])?")
338                if not isinstance(f.metadata["shape"], tuple):
339                    raise TypeError(f"Expected tuple, got {type(f.metadata['shape'])}: {f.metadata['shape']}")
340                if not isinstance(fmd := f.metadata["dtype"], str) or fmd not in dtypes:
341                    raise TypeError(f"{fmd} not a string or not in {dtypes}")
342
343    def __len__(self):
344        return len(self.live_entities)
345
346    def __repr__(self):
347        return (f"[World]\n- Entities: {len(self)} (last id: {self._last_id})"
348                f"\n- Components ({len(self.component_names)}): {self.component_names}"
349                f"\n- Pools: {len(self.pools)}\n- Command buffer: {len(self._command_buffer)}")
WORLD_RESERVED_NAMES = {'strict', 'component', 'components', 'check_extra'}
class World:
 16class World:
 17    """
 18    Generic container for pools of components. Newly added components are assigned a unique id and go in a pool
 19    Parameters
 20    - components The list of components that the world accepts
 21    - extra_metadata The list of required extra metadata for each field besides shape and dtype.
 22    """
 23    def __init__(self, components: list[ComponentType], extra_metadata: list[str] | None = None):
 24        self._default_metadata = {"shape", "dtype", "default"}
 25        self.extra_metadata = extra_metadata or []
 26        if not isinstance(self.extra_metadata, list):
 27            raise TypeError(type(self.extra_metadata))
 28        self._check_components(components)
 29
 30        # Pools management
 31        self.pools: dict[PoolKey, Pool] = {}
 32        self.pool_to_components: dict[Pool, list[ComponentType]] = {}
 33
 34        # Components management
 35        self.field_to_component: dict[str, ComponentType] = {}
 36        self.component_names = [x.__name__ for x in components]
 37        self.component_types = set(components)
 38        self.component_name_to_type = {x.__name__: x for x in components}
 39        self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
 40        self.component_to_field_names: dict[ComponentType, list[str]] = {c: [] for c in components}
 41        self.component_to_shapes: dict[ComponentType, list[Shape]] = {c: [] for c in components}
 42        self.component_to_dtypes: dict[ComponentType, list[str]] = {c: [] for c in components}
 43        self.component_to_defaults: dict[ComponentType, list[np.ndarray]] = {c: [] for c in components}
 44        # setup the obligatory metadata at each fields
 45        for c in components:
 46            for f in fields(c):
 47                self.component_to_field_names[c].append(f.name)
 48                self.component_to_shapes[c].append(field_shape := f.metadata["shape"])
 49                self.component_to_dtypes[c].append(field_dtype := f.metadata["dtype"])
 50
 51                # This allows defaults to be passed as lists/tuples if they are properly broadcastable
 52                if (field_default := f.metadata["default"]) is not None:
 53                    if not isinstance(field_default, np.ndarray):
 54                        field_default = np.broadcast_to(np.asarray(field_default, field_dtype), field_shape)
 55                    if (dt := field_default.dtype) != field_dtype:
 56                        raise TypeError(f"'{c.__name__}/{f.name}'. Expected dtype: {field_dtype}. Got: {dt}")
 57                    if (sh := field_default.shape) != field_shape:
 58                        raise ValueError(f"'{c.__name__}/{f.name}'. Expected shape: {field_shape}. Got: {sh}")
 59                self.component_to_defaults[c].append(field_default)
 60
 61                if f.name in self.field_to_component:
 62                    raise ValueError(f"Duplicate field '{c.__name__}/{f.name}': {self.field_to_component[f.name]}")
 63                self.field_to_component[f.name] = c
 64
 65        # Entities management
 66        self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
 67        self._pool_ids: dict[Pool, list[EntityId]] = {}
 68        self._last_id: EntityId = -1
 69        # A dictionary of all live entities in 'eager' mode (before update()). The actual entity is created at request
 70        # in get_entity, so we don't pay for the Entity object unless it's explicitly requested by the user.
 71        self.live_entities: dict[EntityId, Entity | None] = {}
 72
 73        # Command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
 74        self._command_buffer = CommandBuffer(self)
 75
 76        # QueryResult cache (key:tuple[include, exclude] - see query()). Useful so we re-use qrs between world.updates.
 77        self._qr_cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {}
 78        logger.debug(f"Created scene with components: {self.component_names}")
 79
 80    # public api
 81
 82    def add_entity(self, components: list[ComponentType], **kwargs) -> EntityId:
 83        """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
 84        kwargs = self._convert_components_data(components, **kwargs)
 85        self._validate_components(components, **kwargs)
 86        default_kwargs = self._defaults_for(components, **kwargs)
 87        self._last_id += 1
 88        self.live_entities[self._last_id] = None # add the id the live_entities, but the object is created in get_entity
 89        self._command_buffer.append(Command(CommandType.ADD_ENTITY, self._last_id,
 90                                            args={"components": components, **kwargs, **default_kwargs}))
 91        return self._last_id
 92
 93    def remove_entity(self, entity_id: EntityId):
 94        """Removes an entity based on its unique entity id. The same entity can be safely removed multiple times in the
 95        same tick (e.g. by different systems). Raises if the eid is not (anymore) in the world. Lazy; call update()"""
 96        if entity_id not in self.live_entities:
 97            # NOTE: the only reason this may happen is if we called remove_entity >=2 times before world.update()
 98            if entity_id not in self._command_buffer.removed_this_tick:
 99                raise ValueError(f"Entity: {entity_id} is not in the world (stale). Either wrong id or removed earlier")
100            return
101        self._command_buffer.append(Command(CommandType.REMOVE_ENTITY, entity_id))
102        del self.live_entities[entity_id]
103
104    def get_entity(self, entity_id: EntityId) -> Entity:
105        """Gets the entity reference given an entity id. Used for 'object-like' ops. Structural updates
106        (add/remove_component) are lazy, so you need to call world.update(). Data updates (set_data) are immediate."""
107        try:
108            entity = self.live_entities[entity_id]
109        except KeyError:
110            raise ValueError(f"Entity id: {entity_id} not in the world")
111
112        if entity is None: # this can happen if it was just added by add_entity() but not materialized yet
113            self.live_entities[entity_id] = entity = Entity(
114                entity_id, eid_to_pool_ix=self._eid_to_pool_ix, pool_to_components=self.pool_to_components,
115                world_command_buffer=self._command_buffer)
116
117        return entity
118
119    def query(self, *include: ComponentType, exclude: list[ComponentType] | None = None) -> QueryResult:
120        """
121        Queries the world for entities that match the include set of components.
122        Syntax: `world.query(A, B, exclude=[C, D])` is, in logic form, a chain of 'ands': `A & B & ~C & ~D`.
123        Return: A `QueryResult` object with the entities that have all the requested components. Has `EntityIds` too.
124        """
125
126        # Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
127        include_key = self._make_key(include)
128        exclude_key = self._make_key(exclude or [])
129        if (key := (include_key, exclude_key)) in self._qr_cache:
130            return self._qr_cache[key]
131
132        # archetype_key = (1 0 0 1 1) &
133        #           key = (1 0 0 0 1)
134        #              -> (1 0 0 0 1) OK
135        # but
136        # archetype_key = (1 0 0 1 1) &
137        #           key = (1 0 1 0 0)
138        #              -> (1 0 0 0 0) NOT OK
139        res = []
140        for archetype_key, archetype_pool in self.pools.items():
141            if (archetype_key & include_key) == include_key and (archetype_key & exclude_key) == 0:
142                res.append(archetype_pool)
143
144        field_names = sum([self.component_to_field_names[c] for c in include], [])
145        field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in include], [])))
146        field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in include], [])))
147        self._qr_cache[key] = QueryResult(res, field_shapes, field_dtypes=field_dtypes, pool_ids=self._pool_ids)
148        return self._qr_cache[key]
149
150    def update(self):
151        """commits the underlying pool changes from the systems between two updates. Should be called in main loop."""
152        for command in self._command_buffer:
153            if command.command_type == CommandType.ADD_ENTITY:
154                components = command.args.pop("components")
155                self._add_to_pool(command.entity_id, components=components, entity_data=command.args)
156            elif command.command_type == CommandType.REMOVE_ENTITY:
157                self._remove_from_pool(command.entity_id)
158            elif command.command_type == CommandType.ADD_COMPONENT:
159                component = command.args.pop("component")
160                self._do_add_component(command.entity_id, component=component, **command.args)
161            elif command.command_type == CommandType.REMOVE_COMPONENT:
162                self._do_remove_component(command.entity_id, component=command.args)
163            else: # CommandType.REMOVE_COMPONENT
164                raise NotImplementedError(command)
165
166        # Check if there's any empty pool since the last movements and remove it, if so.
167        empty_keys = [pool_key for pool_key, pool in self.pools.items() if len(pool) == 0]
168        for pool_key in empty_keys:
169            pool = self.pools.pop(pool_key)
170            del self.pool_to_components[pool]
171            del self._pool_ids[pool]
172
173        if len(self._command_buffer) > 0:
174            self._qr_cache.clear()
175        self._command_buffer.clear() # .clear() here also clears the buffer.removed_this_tick set.
176
177    # private stuff
178
179    # eager mode methods equivalent to add/remove entities and add/remove_components
180
181    def _add_to_pool(self, entity_id: EntityId, components: list[ComponentType], entity_data: dict[str, np.ndarray]):
182        """adds the item to the pool"""
183        pool = self._get_entity_pool(components)
184        pool_index = pool.add_entity(entity_data=entity_data)
185        self._eid_to_pool_ix[entity_id] = (pool, pool_index)
186        self._pool_ids.setdefault(pool, []).append(entity_id)
187        if len(self._pool_ids[pool]) != len(pool):
188            raise ValueError(pool, len(self._pool_ids[pool]), len(pool))
189
190    def _remove_from_pool(self, entity_id: EntityId):
191        """removes the entity from the pool w/o any internal data copying, like _pop_from_pool"""
192        old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
193        old_pool.remove_entity(pool_ix)
194        # Move the last id from the pool in the place of the recently removed entity
195        id_which_was_last_in_pool = self._pool_ids[old_pool].pop()
196
197        if entity_id != id_which_was_last_in_pool:
198            self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
199            self._pool_ids[old_pool][pool_ix] = id_which_was_last_in_pool
200
201    def _pop_from_pool(self, entity_id: EntityId) -> tuple[dict[str, np.ndarray], list[ComponentType]]:
202        """common function that updates the entities inside a pool (after popswap) and removes them if they get empty"""
203        old_pool, pool_ix = self._eid_to_pool_ix.pop(entity_id)
204        entity_data = old_pool.pop_entity(pool_ix)
205        components = self.pool_to_components[old_pool]
206
207        # Move the last id from the pool in the place of the recently removed entity
208        id_which_was_last_in_pool = self._pool_ids[old_pool].pop()
209        if entity_id != id_which_was_last_in_pool:
210            self._eid_to_pool_ix[id_which_was_last_in_pool] = (old_pool, pool_ix) # we re-use the popped id (swapped)
211            self._pool_ids[old_pool][pool_ix] = id_which_was_last_in_pool
212
213        return entity_data, components
214
215    def _do_add_component(self, entity_id: EntityId, component: ComponentType, **kwargs):
216        curr_data, curr_components = self._pop_from_pool(entity_id)
217        if not curr_data.keys().isdisjoint(kwargs):
218            raise ValueError(f"Duplicate keys: {curr_data.keys()} vs {kwargs.keys()}")
219
220        new_components = [*curr_components, component]
221        default_kwargs = self._defaults_for(new_components, **curr_data, **kwargs)
222        new_data = {**curr_data, **kwargs, **default_kwargs}
223        self._add_to_pool(entity_id, components=new_components, entity_data=new_data)
224
225    def _do_remove_component(self, entity_id: EntityId, component: ComponentType):
226        entity_data, components = self._pop_from_pool(entity_id)
227        for _field in self.component_to_field_names[component]:
228            if _field not in entity_data:
229                raise ValueError(f"Field {component}/{_field} not in components: {components} ({entity_id=})")
230            entity_data.pop(_field)
231        new_components = [c for c in components if c != component]
232        self._add_to_pool(entity_id, components=new_components, entity_data=entity_data)
233
234    # other low-level methods
235
236    def _validate_component(self, component: ComponentType, strict: bool, check_extra: bool, **kwargs):
237        """
238        Validates a single component.
239        Parameters:
240        - strict Two modes: True -> kwargs==component fields, False -> kwargs is subset
241        - check_extra If set, extra data in kwargs unrelated to this component also raises (_validate_components)
242        """
243        if check_extra and (extra := set(kwargs) - set(self.component_to_field_names[component])):
244            raise ValueError(f"Extra fields: {extra}; expected {self.component_to_field_names[component]}")
245
246        for name, shape, dtype, default in zip(
247                self.component_to_field_names[component], self.component_to_shapes[component],
248                self.component_to_dtypes[component], self.component_to_defaults[component]):
249            if name not in kwargs:
250                if default is None and strict is True:
251                    raise KeyError(f"'{component.__name__}/{name}' required (default=None) but not supplied")
252                continue                      # omitted but has a default -> fine (or non-strict mode for set_data)
253            if not isinstance(field := kwargs[name], np.ndarray):
254                raise TypeError(f"'{component.__name__}/{name}'. Expected np.ndarray, got {type(field)}")
255            if (dt := field.dtype) != dtype:
256                raise TypeError(f"'{component.__name__}/{name}'. Expected dtype {dtype}, got {dt}")
257            if (sh := field.shape) != shape:
258                raise ValueError(f"'{component.__name__}/{name}'. Expected shape {shape}, got {sh}")
259
260    def _validate_components(self, components: list[ComponentType], **kwargs):
261        """Pure check. Raises on: no components, unknown component, missing-required (default=None),
262            wrong dtype/shape, extra field. No mutation, no return. kwargs == fields data."""
263        if len(cs := set(components)) == 0:
264            raise ValueError(f"Entity has no components: {self.component_names}")
265        if diff := cs - self.component_types:
266            raise ValueError(f"Unknown components: {diff}")
267        if len(cs) != len(components):
268            raise ValueError(f"Duplicate components: {components}")
269
270        expected = set()
271        for component in components:
272            expected.update(self.component_to_field_names[component]) # updated here for error message.
273            self._validate_component(component, strict=True, check_extra=False, **kwargs)
274        if extra := set(kwargs) - expected:
275            raise ValueError(f"Extra fields: {extra}; expected {expected}")
276
277    def _convert_components_data(self, components: list[ComponentType], **kwargs) -> dict[str, np.ndarray]:
278        """This allows components data to be passed as convertible (e.g. tuple->ndarray or even numpy broadcasting).
279        Unknown kwargs are passed through untouched so the "Extra fields" validation can still fire on them."""
280        res = dict(kwargs)
281        for c in components:
282            for name, dtype, shape in zip(self.component_to_field_names[c], self.component_to_dtypes[c],
283                                          self.component_to_shapes[c]):
284                if name not in kwargs:
285                    continue
286
287                if (data := kwargs.get(name)) is None and dtype != "object":
288                    raise TypeError(f"'{c.__name__}/{name}': None is not a valid {dtype} value")
289                # If data is given as ndarray, then use it as-is (user provided). Otherwise, convert it to shape/dtype
290                res[name] = data
291                if not isinstance(data, np.ndarray):
292                    res[name] = np.broadcast_to(np.asarray(data, dtype=dtype), shape=shape)
293        return res
294
295    def _defaults_for(self, components: list[ComponentType], **kwargs) -> dict[str, np.ndarray]:
296        """Defaults for omitted fields. Assumes already validated. No mutation of `kwargs` (data) is done."""
297        res = {}
298        for c in components:
299            for name, default in zip(self.component_to_field_names[c], self.component_to_defaults[c]):
300                if name not in kwargs and default is not None:
301                    res[name] = default.copy()
302        return res
303
304    def _get_entity_pool(self, components: list[ComponentType]) -> Pool:
305        if (key := self._make_key(components)) not in self.pools:
306            _fields = sum([self.component_to_field_names[c] for c in components], []) # merge fields
307            shapes = sum([self.component_to_shapes[c] for c in components], []) # merge shapes
308            dtypes = sum([self.component_to_dtypes[c] for c in components], []) # merge dtypes
309            self.pools[key] = Pool(_fields, shapes, dtypes)
310            self.pool_to_components[self.pools[key]] = components
311        return self.pools[key]
312
313    def _make_key(self, components: list[ComponentType]) -> PoolKey:
314        key = 0
315        for c in components:
316            if c not in self.component_types:
317                raise ValueError(f"'{c.__name__}' not in {self.component_names}")
318            key |= self.component_to_bit[c]
319        return key
320
321    def _check_components(self, components: list[ComponentType]):
322        reserved = ENTITY_RESERVED_NAMES | POOL_RESERVED_NAMES | QUERY_RESULT_RESERVED_NAMES | WORLD_RESERVED_NAMES
323        dtypes = {"float32", "int32", "bool", "object"}
324        expected_meta = {*self._default_metadata, *self.extra_metadata}
325
326        for c in components:
327            if not hasattr(c, "__dataclass_fields__"):
328                raise TypeError(f"c '{c.__name__}' is not a dataclass")
329
330            hints = get_type_hints(c) # make it work with from __future__ import annotations
331            for f in fields(c):
332                if hints[f.name] is not np.ndarray:
333                    raise TypeError(f"Field '{c.__name__}/{f.name}' not an array: {f}")
334                if f.name in reserved:
335                    raise ValueError(f"Field '{c.__name__}/{f.name}' in {reserved}")
336                if f.metadata.keys() != expected_meta:
337                    raise ValueError(f"Field '{c.__name__}/{f.name}'\n{list(f.metadata.keys())}\nvs\n{expected_meta}\n"
338                                      "Perhaps missing World(extra_metadata=[...])?")
339                if not isinstance(f.metadata["shape"], tuple):
340                    raise TypeError(f"Expected tuple, got {type(f.metadata['shape'])}: {f.metadata['shape']}")
341                if not isinstance(fmd := f.metadata["dtype"], str) or fmd not in dtypes:
342                    raise TypeError(f"{fmd} not a string or not in {dtypes}")
343
344    def __len__(self):
345        return len(self.live_entities)
346
347    def __repr__(self):
348        return (f"[World]\n- Entities: {len(self)} (last id: {self._last_id})"
349                f"\n- Components ({len(self.component_names)}): {self.component_names}"
350                f"\n- Pools: {len(self.pools)}\n- Command buffer: {len(self._command_buffer)}")

Generic container for pools of components. Newly added components are assigned a unique id and go in a pool Parameters

  • components The list of components that the world accepts
  • extra_metadata The list of required extra metadata for each field besides shape and dtype.
World( components: list[type[microecs.component.Component]], extra_metadata: list[str] | None = None)
23    def __init__(self, components: list[ComponentType], extra_metadata: list[str] | None = None):
24        self._default_metadata = {"shape", "dtype", "default"}
25        self.extra_metadata = extra_metadata or []
26        if not isinstance(self.extra_metadata, list):
27            raise TypeError(type(self.extra_metadata))
28        self._check_components(components)
29
30        # Pools management
31        self.pools: dict[PoolKey, Pool] = {}
32        self.pool_to_components: dict[Pool, list[ComponentType]] = {}
33
34        # Components management
35        self.field_to_component: dict[str, ComponentType] = {}
36        self.component_names = [x.__name__ for x in components]
37        self.component_types = set(components)
38        self.component_name_to_type = {x.__name__: x for x in components}
39        self.component_to_bit: dict[ComponentType, int] = {t: 2**i for i, t in enumerate(components)} # bit for querying
40        self.component_to_field_names: dict[ComponentType, list[str]] = {c: [] for c in components}
41        self.component_to_shapes: dict[ComponentType, list[Shape]] = {c: [] for c in components}
42        self.component_to_dtypes: dict[ComponentType, list[str]] = {c: [] for c in components}
43        self.component_to_defaults: dict[ComponentType, list[np.ndarray]] = {c: [] for c in components}
44        # setup the obligatory metadata at each fields
45        for c in components:
46            for f in fields(c):
47                self.component_to_field_names[c].append(f.name)
48                self.component_to_shapes[c].append(field_shape := f.metadata["shape"])
49                self.component_to_dtypes[c].append(field_dtype := f.metadata["dtype"])
50
51                # This allows defaults to be passed as lists/tuples if they are properly broadcastable
52                if (field_default := f.metadata["default"]) is not None:
53                    if not isinstance(field_default, np.ndarray):
54                        field_default = np.broadcast_to(np.asarray(field_default, field_dtype), field_shape)
55                    if (dt := field_default.dtype) != field_dtype:
56                        raise TypeError(f"'{c.__name__}/{f.name}'. Expected dtype: {field_dtype}. Got: {dt}")
57                    if (sh := field_default.shape) != field_shape:
58                        raise ValueError(f"'{c.__name__}/{f.name}'. Expected shape: {field_shape}. Got: {sh}")
59                self.component_to_defaults[c].append(field_default)
60
61                if f.name in self.field_to_component:
62                    raise ValueError(f"Duplicate field '{c.__name__}/{f.name}': {self.field_to_component[f.name]}")
63                self.field_to_component[f.name] = c
64
65        # Entities management
66        self._eid_to_pool_ix: dict[EntityId, tuple[Pool, int]] = {}
67        self._pool_ids: dict[Pool, list[EntityId]] = {}
68        self._last_id: EntityId = -1
69        # A dictionary of all live entities in 'eager' mode (before update()). The actual entity is created at request
70        # in get_entity, so we don't pay for the Entity object unless it's explicitly requested by the user.
71        self.live_entities: dict[EntityId, Entity | None] = {}
72
73        # Command buffer management. {add/remove}_{entity/component} are lazy. Taken into account after update().
74        self._command_buffer = CommandBuffer(self)
75
76        # QueryResult cache (key:tuple[include, exclude] - see query()). Useful so we re-use qrs between world.updates.
77        self._qr_cache: dict[tuple[PoolKey, PoolKey], QueryResult] = {}
78        logger.debug(f"Created scene with components: {self.component_names}")
extra_metadata
pools: dict[int, microecs.pool.Pool]
pool_to_components: dict[microecs.pool.Pool, list[type[microecs.component.Component]]]
field_to_component: dict[str, type[microecs.component.Component]]
component_names
component_types
component_name_to_type
component_to_bit: dict[type[microecs.component.Component], int]
component_to_field_names: dict[type[microecs.component.Component], list[str]]
component_to_shapes: dict[type[microecs.component.Component], list[tuple[int, ...]]]
component_to_dtypes: dict[type[microecs.component.Component], list[str]]
component_to_defaults: dict[type[microecs.component.Component], list[numpy.ndarray]]
live_entities: dict[int, microecs.entity.Entity | None]
def add_entity( self, components: list[type[microecs.component.Component]], **kwargs) -> int:
82    def add_entity(self, components: list[ComponentType], **kwargs) -> EntityId:
83        """Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()"""
84        kwargs = self._convert_components_data(components, **kwargs)
85        self._validate_components(components, **kwargs)
86        default_kwargs = self._defaults_for(components, **kwargs)
87        self._last_id += 1
88        self.live_entities[self._last_id] = None # add the id the live_entities, but the object is created in get_entity
89        self._command_buffer.append(Command(CommandType.ADD_ENTITY, self._last_id,
90                                            args={"components": components, **kwargs, **default_kwargs}))
91        return self._last_id

Adds an entity to the world based on components (data->kwargs). Returns an entity id. Lazy; call update()

def remove_entity(self, entity_id: int):
 93    def remove_entity(self, entity_id: EntityId):
 94        """Removes an entity based on its unique entity id. The same entity can be safely removed multiple times in the
 95        same tick (e.g. by different systems). Raises if the eid is not (anymore) in the world. Lazy; call update()"""
 96        if entity_id not in self.live_entities:
 97            # NOTE: the only reason this may happen is if we called remove_entity >=2 times before world.update()
 98            if entity_id not in self._command_buffer.removed_this_tick:
 99                raise ValueError(f"Entity: {entity_id} is not in the world (stale). Either wrong id or removed earlier")
100            return
101        self._command_buffer.append(Command(CommandType.REMOVE_ENTITY, entity_id))
102        del self.live_entities[entity_id]

Removes an entity based on its unique entity id. The same entity can be safely removed multiple times in the same tick (e.g. by different systems). Raises if the eid is not (anymore) in the world. Lazy; call update()

def get_entity(self, entity_id: int) -> microecs.entity.Entity:
104    def get_entity(self, entity_id: EntityId) -> Entity:
105        """Gets the entity reference given an entity id. Used for 'object-like' ops. Structural updates
106        (add/remove_component) are lazy, so you need to call world.update(). Data updates (set_data) are immediate."""
107        try:
108            entity = self.live_entities[entity_id]
109        except KeyError:
110            raise ValueError(f"Entity id: {entity_id} not in the world")
111
112        if entity is None: # this can happen if it was just added by add_entity() but not materialized yet
113            self.live_entities[entity_id] = entity = Entity(
114                entity_id, eid_to_pool_ix=self._eid_to_pool_ix, pool_to_components=self.pool_to_components,
115                world_command_buffer=self._command_buffer)
116
117        return entity

Gets the entity reference given an entity id. Used for 'object-like' ops. Structural updates (add/remove_component) are lazy, so you need to call world.update(). Data updates (set_data) are immediate.

def query( self, *include: type[microecs.component.Component], exclude: list[type[microecs.component.Component]] | None = None) -> microecs.query_result.QueryResult:
119    def query(self, *include: ComponentType, exclude: list[ComponentType] | None = None) -> QueryResult:
120        """
121        Queries the world for entities that match the include set of components.
122        Syntax: `world.query(A, B, exclude=[C, D])` is, in logic form, a chain of 'ands': `A & B & ~C & ~D`.
123        Return: A `QueryResult` object with the entities that have all the requested components. Has `EntityIds` too.
124        """
125
126        # Note: we can cache the queries. The only time it can get invalidated (via public API) is at update().
127        include_key = self._make_key(include)
128        exclude_key = self._make_key(exclude or [])
129        if (key := (include_key, exclude_key)) in self._qr_cache:
130            return self._qr_cache[key]
131
132        # archetype_key = (1 0 0 1 1) &
133        #           key = (1 0 0 0 1)
134        #              -> (1 0 0 0 1) OK
135        # but
136        # archetype_key = (1 0 0 1 1) &
137        #           key = (1 0 1 0 0)
138        #              -> (1 0 0 0 0) NOT OK
139        res = []
140        for archetype_key, archetype_pool in self.pools.items():
141            if (archetype_key & include_key) == include_key and (archetype_key & exclude_key) == 0:
142                res.append(archetype_pool)
143
144        field_names = sum([self.component_to_field_names[c] for c in include], [])
145        field_shapes = dict(zip(field_names, sum([self.component_to_shapes[c] for c in include], [])))
146        field_dtypes = dict(zip(field_names, sum([self.component_to_dtypes[c] for c in include], [])))
147        self._qr_cache[key] = QueryResult(res, field_shapes, field_dtypes=field_dtypes, pool_ids=self._pool_ids)
148        return self._qr_cache[key]

Queries the world for entities that match the include set of components. Syntax: world.query(A, B, exclude=[C, D]) is, in logic form, a chain of 'ands': A & B & ~C & ~D. Return: A QueryResult object with the entities that have all the requested components. Has EntityIds too.

def update(self):
150    def update(self):
151        """commits the underlying pool changes from the systems between two updates. Should be called in main loop."""
152        for command in self._command_buffer:
153            if command.command_type == CommandType.ADD_ENTITY:
154                components = command.args.pop("components")
155                self._add_to_pool(command.entity_id, components=components, entity_data=command.args)
156            elif command.command_type == CommandType.REMOVE_ENTITY:
157                self._remove_from_pool(command.entity_id)
158            elif command.command_type == CommandType.ADD_COMPONENT:
159                component = command.args.pop("component")
160                self._do_add_component(command.entity_id, component=component, **command.args)
161            elif command.command_type == CommandType.REMOVE_COMPONENT:
162                self._do_remove_component(command.entity_id, component=command.args)
163            else: # CommandType.REMOVE_COMPONENT
164                raise NotImplementedError(command)
165
166        # Check if there's any empty pool since the last movements and remove it, if so.
167        empty_keys = [pool_key for pool_key, pool in self.pools.items() if len(pool) == 0]
168        for pool_key in empty_keys:
169            pool = self.pools.pop(pool_key)
170            del self.pool_to_components[pool]
171            del self._pool_ids[pool]
172
173        if len(self._command_buffer) > 0:
174            self._qr_cache.clear()
175        self._command_buffer.clear() # .clear() here also clears the buffer.removed_this_tick set.

commits the underlying pool changes from the systems between two updates. Should be called in main loop.