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