## World ## ## Represents the game world in the [_ECS] framework, managing all [Entity]s and [System]s. ## ## The World class handles the addition and removal of [Entity]s and [System]s, and orchestrates the processing of [Entity]s through [System]s each frame. ## The World class also maintains an index mapping of components to entities for efficient querying. @icon("res://addons/gecs/assets/world.svg") class_name World extends Node #region Signals ## Emitted when an entity is added signal entity_added(entity: Entity) signal entity_enabled(entity: Entity) ## Emitted when an entity is removed signal entity_removed(entity: Entity) signal entity_disabled(entity: Entity) ## Emitted when a system is added signal system_added(system: System) ## Emitted when a system is removed signal system_removed(system: System) ## Emitted when a component is added to an entity signal component_added(entity: Entity, component: Variant) ## Emitted when a component is removed from an entity signal component_removed(entity: Entity, component: Variant) ## Emitted when a component property changes on an entity signal component_changed( entity: Entity, component: Variant, property: String, new_value: Variant, old_value: Variant ) ## Emitted when a relationship is added to an entity signal relationship_added(entity: Entity, relationship: Relationship) ## Emitted when a relationship is removed from an entity signal relationship_removed(entity: Entity, relationship: Relationship) ## Emitted when the queries are invalidated because of a component change signal cache_invalidated #endregion Signals #region Exported Variables ## Where are all the [Entity] nodes placed in the scene tree? @export var entity_nodes_root: NodePath ## Where are all the [System] nodes placed in the scene tree? @export var system_nodes_root: NodePath ## Default serialization config for all entities in this world @export var default_serialize_config: GECSSerializeConfig #endregion Exported Variables #region Public Variables ## All the [Entity]s in the world. var entities: Array[Entity] = [] ## All the [Observer]s in the world. var observers: Array[Observer] = [] ## All the [System]s by group Dictionary[String, Array[System]] var systems_by_group: Dictionary[String, Array] = {} ## All the [System]s in the world flattened into a single array var systems: Array[System]: get: var all_systems: Array[System] = [] for group in systems_by_group.keys(): all_systems.append_array(systems_by_group[group]) return all_systems ## ID to [Entity] registry - Prevents duplicate IDs and enables fast ID lookups and singleton behavior var entity_id_registry: Dictionary = {} # String (id) -> Entity ## ARCHETYPE STORAGE - Entity storage by component signature for O(1) queries ## Maps archetype signature (FNV-1a hash) -> Archetype instance var archetypes: Dictionary = {} # int -> Archetype ## Fast lookup: Entity -> its current Archetype var entity_to_archetype: Dictionary = {} # Entity -> Archetype ## The [QueryBuilder] instance for this world used to build and execute queries. ## Anytime we request a query we want to connect the cache invalidated signal to the query ## so that all queries are invalidated anytime we emit cache_invalidated. var query: QueryBuilder: get: var q: QueryBuilder = QueryBuilder.new(self) if not cache_invalidated.is_connected(q.invalidate_cache): cache_invalidated.connect(q.invalidate_cache) return q ## Index for relationships to entities (Optional for optimization) var relationship_entity_index: Dictionary = {} ## Index for reverse relationships (target to source entities) var reverse_relationship_index: Dictionary = {} ## Logger for the world to only log to a specific domain var _worldLogger = GECSLogger.new().domain("World") ## Cache for commonly used query results - stores matching archetypes, not entities ## This dramatically reduces cache invalidation since archetypes are stable var _query_archetype_cache: Dictionary = {} # query_sig -> Array[Archetype] ## Track cache hits for performance monitoring var _cache_hits: int = 0 var _cache_misses: int = 0 ## Track cache invalidations for debugging var _cache_invalidation_count: int = 0 var _cache_invalidation_reasons: Dictionary = {} # reason -> count ## Global cache: resource_path -> Script (loaded once, reused forever) var _component_script_cache: Dictionary = {} # String -> Script ## OPTIMIZATION: Flag to control cache invalidation during batch operations var _should_invalidate_cache: bool = true ## Frame + accumulated performance metrics (debug-only) var _perf_metrics := { "frame": {}, # Per-frame aggregated timings "accum": {} # Long-lived totals (cleared manually) } ## Internal perf helper (debug only) func perf_mark(key: String, duration_usec: int, extra: Dictionary = {}) -> void: if not ECS.debug: return # Aggregate per frame var entry = _perf_metrics.frame.get(key, {"count": 0, "time_usec": 0}) entry.count += 1 entry.time_usec += duration_usec for k in extra.keys(): # Attach/overwrite ancillary data (last value wins) entry[k] = extra[k] _perf_metrics.frame[key] = entry # Accumulate lifetime totals var accum_entry = _perf_metrics.accum.get(key, {"count": 0, "time_usec": 0}) accum_entry.count += 1 accum_entry.time_usec += duration_usec _perf_metrics.accum[key] = accum_entry ## Reset per-frame metrics (called at world.process start) func perf_reset_frame() -> void: if ECS.debug: _perf_metrics.frame.clear() ## Get a copy of current frame metrics func perf_get_frame_metrics() -> Dictionary: return _perf_metrics.frame.duplicate(true) ## Get a copy of accumulated metrics func perf_get_accum_metrics() -> Dictionary: return _perf_metrics.accum.duplicate(true) ## Reset accumulated metrics func perf_reset_accum() -> void: if ECS.debug: _perf_metrics.accum.clear() #endregion Public Variables #region Built-in Virtual Methods ## Called when the World node is ready. func _ready() -> void: #_worldLogger.disabled = true initialize() func _make_nodes_root(name: String) -> Node: var node = Node.new() node.name = name add_child(node) return node ## Adds [Entity]s and [System]s from the scene tree to the [World]. ## Called when the World node is ready or when we should re-initialize the world from the tree. func initialize(): # Initialize default serialize config if not set if default_serialize_config == null: default_serialize_config = GECSSerializeConfig.new() # if no entities/systems root node is set create them and use them. This keeps things tidy for debugging entity_nodes_root = ( _make_nodes_root("Entities").get_path() if not entity_nodes_root else entity_nodes_root ) system_nodes_root = ( _make_nodes_root("Systems").get_path() if not system_nodes_root else system_nodes_root ) # Add systems from scene tree var _systems = get_node(system_nodes_root).find_children("*", "System") as Array[System] add_systems(_systems, true) # and sort them after they're added _worldLogger.debug("_initialize Added Systems from Scene Tree and dep sorted: ", _systems) # Add observers from scene tree var _observers = get_node(system_nodes_root).find_children("*", "Observer") as Array[Observer] add_observers(_observers) _worldLogger.debug("_initialize Added Observers from Scene Tree: ", _observers) # Add entities from the scene tree var _entities = get_node(entity_nodes_root).find_children("*", "Entity") as Array[Entity] add_entities(_entities) _worldLogger.debug("_initialize Added Entities from Scene Tree: ", _entities) if ECS.debug: assert(GECSEditorDebuggerMessages.world_init(self ), '') # Register debugger message handler for entity polling if not Engine.is_editor_hint() and OS.has_feature("editor"): EngineDebugger.register_message_capture("gecs", _handle_debugger_message) #endregion Built-in Virtual Methods #region Public Methods ## Called every frame by the [method _ECS.process] to process [System]s. ## [param delta] The time elapsed since the last frame. ## [param group] The string for the group we should run. If empty runs all systems in default "" group. func process(delta: float, group: String = "") -> void: # PERF: Reset frame metrics at start of processing step perf_reset_frame() if systems_by_group.has(group): var system_index = 0 for system in systems_by_group[group]: if system.active: system._handle(delta) if ECS.debug: # Add execution order to last run data system.lastRunData["execution_order"] = system_index assert(GECSEditorDebuggerMessages.system_last_run_data(system, system.lastRunData), '') system_index += 1 if ECS.debug: assert(GECSEditorDebuggerMessages.process_world(delta, group), '') ## Updates the pause behavior for all systems based on the provided paused state. ## If paused, only systems with PROCESS_MODE_ALWAYS remain active; all others become inactive. ## If unpaused, systems with PROCESS_MODE_DISABLED stay inactive; all others become active. func update_pause_state(paused: bool) -> void: for group_key in systems_by_group.keys(): for system in systems_by_group[group_key]: # Check to see if the system is can process based on the process mode and paused state system.paused = not system.can_process() ## Adds a single [Entity] to the world.[br] ## [param entity] The [Entity] to add.[br] ## [param components] The optional list of [Component] to add to the entity.[br] ## [b]Example:[/b] ## [codeblock] ## # add just an entity ## world.add_entity(player_entity) ## # add an entity with some components ## world.add_entity(other_entity, [component_a, component_b]) ## [/codeblock] func add_entity(entity: Entity, components = null, add_to_tree = true) -> void: # Check for ID collision - if entity with same ID exists, replace it var entity_id = GECSIO.uuid() if not entity.id else entity.id entity.id = entity_id # update entity with it's new id if entity_id in entity_id_registry: var existing_entity = entity_id_registry[entity_id] _worldLogger.debug("ID collision detected, replacing entity: ", existing_entity.name, " with: ", entity.name) remove_entity(existing_entity) # Register this entity's ID entity_id_registry[entity_id] = entity # ID will auto-generate in _enter_tree if empty, or via property getter on first access # Update index _worldLogger.debug("add_entity Adding Entity to World: ", entity) # Connect to entity signals for components so we can track global component state if not entity.component_added.is_connected(_on_entity_component_added): entity.component_added.connect(_on_entity_component_added) if not entity.component_removed.is_connected(_on_entity_component_removed): entity.component_removed.connect(_on_entity_component_removed) if not entity.relationship_added.is_connected(_on_entity_relationship_added): entity.relationship_added.connect(_on_entity_relationship_added) if not entity.relationship_removed.is_connected(_on_entity_relationship_removed): entity.relationship_removed.connect(_on_entity_relationship_removed) # Add the entity to the tree if it's not already there after hooking up the signals # This ensures that any _ready methods on the entity or its components are called after setup if add_to_tree and not entity.is_inside_tree(): get_node(entity_nodes_root).add_child(entity) # add entity to our list entities.append(entity) # ARCHETYPE: Add entity to archetype system BEFORE initialization # Start with empty archetype, then move as components are added _add_entity_to_archetype(entity) # initialize the entity and its components in game only # This will trigger component_added signals which move the entity to the right archetype if not Engine.is_editor_hint(): entity._initialize(components if components else []) entity_added.emit(entity) # All the entities are ready so we should run the pre-processors now for processor in ECS.entity_preprocessors: processor.call(entity) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_added(entity), '') ## Adds multiple entities to the world.[br] ## [param entities] An array of entities to add. ## [param components] The optional list of [Component] to add to the entity.[br] ## [b]Example:[/b] ## [codeblock]world.add_entities([player_entity, enemy_entity], [component_a])[/codeblock] func add_entities(_entities: Array, components = null): # OPTIMIZATION: Batch processing to reduce cache invalidations # Temporarily disable cache invalidation during batch, then invalidate once at the end var original_invalidate = _should_invalidate_cache _should_invalidate_cache = false var new_archetypes_created = false var initial_archetype_count = archetypes.size() # Process all entities for _entity in _entities: add_entity(_entity, components) # Check if any new archetypes were created if archetypes.size() > initial_archetype_count: new_archetypes_created = true # Re-enable cache invalidation and invalidate once if needed _should_invalidate_cache = original_invalidate if new_archetypes_created: _invalidate_cache("batch_add_entities") ## Removes an [Entity] from the world.[br] ## [param entity] The [Entity] to remove.[br] ## [b]Example:[/b] ## [codeblock]world.remove_entity(player_entity)[/codeblock] func remove_entity(entity) -> void: entity = entity as Entity for processor in ECS.entity_postprocessors: processor.call(entity) entity_removed.emit(entity) _worldLogger.debug("remove_entity Removing Entity: ", entity) entities.erase(entity) # FIXME: This doesn't always work for some reason? # Only disconnect signals if they're actually connected if entity.component_added.is_connected(_on_entity_component_added): entity.component_added.disconnect(_on_entity_component_added) if entity.component_removed.is_connected(_on_entity_component_removed): entity.component_removed.disconnect(_on_entity_component_removed) if entity.relationship_added.is_connected(_on_entity_relationship_added): entity.relationship_added.disconnect(_on_entity_relationship_added) if entity.relationship_removed.is_connected(_on_entity_relationship_removed): entity.relationship_removed.disconnect(_on_entity_relationship_removed) # Remove from ID registry var entity_id = entity.id if entity_id != "" and entity_id in entity_id_registry and entity_id_registry[entity_id] == entity: entity_id_registry.erase(entity_id) # ARCHETYPE: Remove entity from archetype system (parallel) _remove_entity_from_archetype(entity) # Destroy entity normally entity.on_destroy() entity.queue_free() if ECS.debug: assert(GECSEditorDebuggerMessages.entity_removed(entity), '') ## Removes an Array of [Entity] from the world.[br] ## [param entity] The Array of [Entity] to remove.[br] ## [b]Example:[/b] ## [codeblock]world.remove_entities([player_entity, other_entity])[/codeblock] func remove_entities(_entities: Array) -> void: # OPTIMIZATION: Batch processing to reduce cache invalidations # Temporarily disable cache invalidation during batch, then invalidate once at the end var original_invalidate = _should_invalidate_cache _should_invalidate_cache = false # Process all entities for _entity in _entities: remove_entity(_entity) # Re-enable cache invalidation and always invalidate when entities are removed # QueryBuilder caches execute() results, so any entity removal requires cache invalidation _should_invalidate_cache = original_invalidate _invalidate_cache("batch_remove_entities") ## Disable an [Entity] from the world. Disabled entities don't run process or physics,[br] ## are hidden and removed the entities list and the[br] ## [param entity] The [Entity] to disable.[br] ## [b]Example:[/b] ## [codeblock]world.disable_entity(player_entity)[/codeblock] func disable_entity(entity) -> Entity: entity = entity as Entity entity.enabled = false # This will trigger _on_entity_enabled_changed via setter entity_disabled.emit(entity) _worldLogger.debug("disable_entity Disabling Entity: ", entity) entity.component_added.disconnect(_on_entity_component_added) entity.component_removed.disconnect(_on_entity_component_removed) entity.relationship_added.disconnect(_on_entity_relationship_added) entity.relationship_removed.disconnect(_on_entity_relationship_removed) entity.on_disable() entity.set_process(false) entity.set_physics_process(false) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_disabled(entity), '') return entity ## Disable an Array of [Entity] from the world. Disabled entities don't run process or physics,[br] ## are hidden and removed the entities list[br] ## [param entity] The [Entity] to disable.[br] ## [b]Example:[/b] ## [codeblock]world.disable_entities([player_entity, other_entity])[/codeblock] func disable_entities(_entities: Array) -> void: for _entity in _entities: disable_entity(_entity) ## Enables a single [Entity] to the world.[br] ## [param entity] The [Entity] to enable.[br] ## [param components] The optional list of [Component] to add to the entity.[br] ## [b]Example:[/b] ## [codeblock] ## # enable just an entity ## world.enable_entity(player_entity) ## # enable an entity with some components ## world.enable_entity(other_entity, [component_a, component_b]) ## [/codeblock] func enable_entity(entity: Entity, components = null) -> void: # Update index _worldLogger.debug("enable_entity Enabling Entity to World: ", entity) entity.enabled = true # This will trigger _on_entity_enabled_changed via setter entity_enabled.emit(entity) # Connect to entity signals for components so we can track global component state if not entity.component_added.is_connected(_on_entity_component_added): entity.component_added.connect(_on_entity_component_added) if not entity.component_removed.is_connected(_on_entity_component_removed): entity.component_removed.connect(_on_entity_component_removed) if not entity.relationship_added.is_connected(_on_entity_relationship_added): entity.relationship_added.connect(_on_entity_relationship_added) if not entity.relationship_removed.is_connected(_on_entity_relationship_removed): entity.relationship_removed.connect(_on_entity_relationship_removed) if components: entity.add_components(components) entity.set_process(true) entity.set_physics_process(true) entity.on_enable() if ECS.debug: assert(GECSEditorDebuggerMessages.entity_enabled(entity), '') ## Find an entity by its persistent ID ## [param id] The id to search for ## [return] The Entity with matching ID, or null if not found func get_entity_by_id(id: String) -> Entity: return entity_id_registry.get(id, null) ## Check if an entity with the given ID exists in the world ## [param id] The id to check ## [return] true if an entity with this ID exists, false otherwise func has_entity_with_id(id: String) -> bool: return id in entity_id_registry #region Systems ## Adds a single system to the world. ## ## [param system] The system to add. ## ## [b]Example:[/b] ## [codeblock]world.add_system(movement_system)[/codeblock] func add_system(system: System, topo_sort: bool = false) -> void: if not system.is_inside_tree(): get_node(system_nodes_root).add_child(system) _worldLogger.trace("add_system Adding System: ", system) # Give the system a reference to this world system._world = self if not systems_by_group.has(system.group): systems_by_group[system.group] = [] systems_by_group[system.group].push_back(system) system_added.emit(system) system._internal_setup() # Determines execution method and calls user setup() if topo_sort: ArrayExtensions.topological_sort(systems_by_group) if ECS.debug: assert(GECSEditorDebuggerMessages.system_added(system), '') ## Adds multiple systems to the world. ## ## [param systems] An array of systems to add. ## ## [b]Example:[/b] ## [codeblock]world.add_systems([movement_system, render_system])[/codeblock] func add_systems(_systems: Array, topo_sort: bool = false): for _system in _systems: add_system(_system) # After we add them all sort them if topo_sort: ArrayExtensions.topological_sort(systems_by_group) ## Removes a [System] from the world.[br] ## [param system] The [System] to remove.[br] ## [b]Example:[/b] ## [codeblock]world.remove_system(movement_system)[/codeblock] func remove_system(system, topo_sort: bool = false) -> void: _worldLogger.debug("remove_system Removing System: ", system) systems_by_group[system.group].erase(system) if systems_by_group[system.group].size() == 0: systems_by_group.erase(system.group) system_removed.emit(system) # Update index system.queue_free() if topo_sort: ArrayExtensions.topological_sort(systems_by_group) if ECS.debug: assert(GECSEditorDebuggerMessages.system_removed(system), '') ## Removes an Array of [System] from the world.[br] ## [param system] The Array of [System] to remove.[br] ## [b]Example:[/b] ## [codeblock]world.remove_systems([movement_system, other_system])[/codeblock] func remove_systems(_systems: Array, topo_sort: bool = false) -> void: for _system in _systems: remove_system(_system) if topo_sort: ArrayExtensions.topological_sort(systems_by_group) ## Removes all systems in a group from the world.[br] ## [param group] The group name of the systems to remove.[br] ## [b]Example:[/b] ## [codeblock]world.remove_system_group("Gameplay")[/codeblock] func remove_system_group(group: String, topo_sort: bool = false) -> void: if systems_by_group.has(group): for system in systems_by_group[group]: remove_system(system) if topo_sort: ArrayExtensions.topological_sort(systems_by_group) ## Removes all [Entity]s and [System]s from the world.[br] ## [param should_free] Optionally frees the world node by default ## [param keep] A list of entities that should be kept in the world func purge(should_free = true, keep := []) -> void: # Get rid of all entities _worldLogger.debug("Purging Entities", entities) for entity in entities.duplicate().filter(func(x): return not keep.has(x)): remove_entity(entity) # Clear relationship indexes after purging entities relationship_entity_index.clear() reverse_relationship_index.clear() _worldLogger.debug("Cleared relationship indexes after purge") # ARCHETYPE: Clear archetype system # First, break circular references by clearing edges for archetype in archetypes.values(): archetype.add_edges.clear() archetype.remove_edges.clear() archetypes.clear() entity_to_archetype.clear() _worldLogger.debug("Cleared archetype storage after purge") # Purge all systems _worldLogger.debug("Purging All Systems") for group_key in systems_by_group.keys(): for system in systems_by_group[group_key].duplicate(): remove_system(system) # Purge all observers _worldLogger.debug("Purging Observers", observers) for observer in observers.duplicate(): remove_observer(observer) _invalidate_cache("purge") # remove itself if should_free: queue_free() ## Executes a query to retrieve entities based on component criteria.[br] ## [param all_components] [Component]s that [Entity]s must have all of.[br] ## [param any_components] [Component]s that [Entity]s must have at least one of.[br] ## [param exclude_components] [Component]s that [Entity]s must not have.[br] ## [param returns] An [Array] of [Entity]s that match the query.[br] ## [br] ## Performance Optimization:[br] ## When checking for all_components, the system first identifies the component with the smallest[br] ## set of entities and starts with that set. This significantly reduces the number of comparisons needed,[br] ## as we only need to check the smallest possible set of entities against other components. #endregion Systems #region Signal Callbacks ## [signal Entity.component_added] Callback when a component is added to an entity.[br] ## [param entity] The entity that had a component added.[br] ## [param component] The resource path of the added component. func _on_entity_component_added(entity: Entity, component: Resource) -> void: # ARCHETYPE: Move entity to new archetype if entity_to_archetype.has(entity): var old_archetype = entity_to_archetype[entity] var comp_path = component.get_script().resource_path var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, true) # Must invalidate: QueryBuilder caches execute() results, not just archetype matches _invalidate_cache("entity_component_added") # Emit Signal component_added.emit(entity, component) _handle_observer_component_added(entity, component) if not entity.component_property_changed.is_connected(_on_entity_component_property_change): entity.component_property_changed.connect(_on_entity_component_property_change) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_component_added(entity, component), '') ## Called when a component property changes through signals called on the components and connected to.[br] ## in the _ready method.[br] ## [param entity] The [Entity] with the component change.[br] ## [param component] The [Component] that changed.[br] ## [param property_name] The name of the property that changed.[br] ## [param old_value] The old value of the property.[br] ## [param new_value] The new value of the property.[br] func _on_entity_component_property_change( entity: Entity, component: Resource, property_name: String, old_value: Variant, new_value: Variant ) -> void: # Notify the World to trigger observers _handle_observer_component_changed(entity, component, property_name, new_value, old_value) # ARCHETYPE: No cache invalidation - property changes don't affect archetype membership # Send the message to the debugger if we're in debug if ECS.debug: assert(GECSEditorDebuggerMessages.entity_component_property_changed( entity, component, property_name, old_value, new_value ), '') ## [signal Entity.component_removed] Callback when a component is removed from an entity.[br] ## [param entity] The entity that had a component removed.[br] ## [param component] The resource path of the removed component. func _on_entity_component_removed(entity, component: Resource) -> void: if entity_to_archetype.has(entity): var old_archetype = entity_to_archetype[entity] var comp_path = component.resource_path var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, false) # Must invalidate: QueryBuilder caches execute() results, not just archetype matches _invalidate_cache("entity_component_removed") component_removed.emit(entity, component) _handle_observer_component_removed(entity, component) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_component_removed(entity, component), '') ## (Optional) Update index when a relationship is added. func _on_entity_relationship_added(entity: Entity, relationship: Relationship) -> void: var key = relationship.relation.resource_path if not relationship_entity_index.has(key): relationship_entity_index[key] = [] relationship_entity_index[key].append(entity) # Index the reverse relationship if is_instance_valid(relationship.target) and relationship.target is Entity: var rev_key = "reverse_" + key if not reverse_relationship_index.has(rev_key): reverse_relationship_index[rev_key] = [] reverse_relationship_index[rev_key].append(relationship.target) # PERFORMANCE: Do NOT invalidate archetype cache on relationship changes # Relationships do not alter archetype membership (structural component sets) # QueryBuilder.execute() performs relationship filtering on entity results. # Systems use archetypes() + per-entity filtering, so invalidation here only # increases cache churn without improving correctness. # Emit Signal relationship_added.emit(entity, relationship) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_relationship_added(entity, relationship), '') ## (Optional) Update index when a relationship is removed. func _on_entity_relationship_removed(entity: Entity, relationship: Relationship) -> void: var key = relationship.relation.resource_path if relationship_entity_index.has(key): relationship_entity_index[key].erase(entity) if is_instance_valid(relationship.target) and relationship.target is Entity: var rev_key = "reverse_" + key if reverse_relationship_index.has(rev_key): reverse_relationship_index[rev_key].erase(relationship.target) # PERFORMANCE: No cache invalidation (see comment in _on_entity_relationship_added) # Emit Signal relationship_removed.emit(entity, relationship) if ECS.debug: assert(GECSEditorDebuggerMessages.entity_relationship_removed(entity, relationship), '') ## Adds a single [Observer] to the [World]. ## [param observer] The [Observer] to add. ## [b]Example:[/b] ## [codeblock]world.add_observer(health_change_system)[/codeblock] func add_observer(_observer: Observer) -> void: # Verify the system has a valid watch component _observer.watch() # Just call to validate it returns a component if not _observer.is_inside_tree(): get_node(system_nodes_root).add_child(_observer) _worldLogger.trace("add_observer Adding Observer: ", _observer) observers.append(_observer) # Initialize the query builder for the observer _observer.q = QueryBuilder.new(self ) # Verify the system has a valid watch component _observer.watch() # Just call to validate it returns a component ## Adds multiple [Observer]s to the [World]. ## [param observers] An array of [Observer]s to add. ## [b]Example:[/b] ## [codeblock]world.add_observers([health_system, damage_system])[/codeblock] func add_observers(_observers: Array): for _observer in _observers: add_observer(_observer) ## Removes an [Observer] from the [World]. ## [param observer] The [Observer] to remove. ## [b]Example:[/b] ## [codeblock]world.remove_observer(health_system)[/codeblock] func remove_observer(observer: Observer) -> void: _worldLogger.debug("remove_observer Removing Observer: ", observer) observers.erase(observer) # if ECS.debug: # # Don't use system_removed as it expects a System not ReactiveSystem # GECSEditorDebuggerMessages.exit_world() # Just send a general update observer.queue_free() ## Handle component property changes and notify observers ## [param entity] The entity with the component change ## [param component] The component that changed ## [param property] The property name that changed ## [param new_value] The new value of the property ## [param old_value] The previous value of the property func handle_component_changed( entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant ) -> void: # Emit the general signal component_changed.emit(entity, component, property, new_value, old_value) # Find observers watching for this component and notify them _handle_observer_component_changed(entity, component, property, new_value, old_value) ## Notify observers when a component is added func _handle_observer_component_added(entity: Entity, component: Resource) -> void: for reactive_system in observers: # Get the component that this system is watching var watch_component = reactive_system.watch() if ( watch_component and component and component.get_script() and watch_component.resource_path == component.get_script().resource_path ): # Check if the entity matches the system's query var query_builder = reactive_system.match() var matches = true if query_builder: # Use the _query method instead of trying to use query as a function var entities_matching = _query( query_builder._all_components, query_builder._any_components, query_builder._exclude_components ) # Check if our entity is in the result set matches = entities_matching.has(entity) if matches: reactive_system.on_component_added(entity, component) ## Notify observers when a component is removed func _handle_observer_component_removed(entity: Entity, component: Resource) -> void: for reactive_system in observers: # Get the component that this system is watching var watch_component = reactive_system.watch() if ( watch_component and component and component.get_script() and watch_component.resource_path == component.get_script().resource_path ): # For removal, we don't check the query since the component is already removed # Just notify the system reactive_system.on_component_removed(entity, component) ## Notify observers when a component property changes func _handle_observer_component_changed( entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant ) -> void: for reactive_system in observers: # Get the component that this system is watching var watch_component = reactive_system.watch() if ( watch_component and component and component.get_script() and watch_component.resource_path == component.get_script().resource_path ): # Check if the entity matches the system's query var query_builder = reactive_system.match() var matches = true if query_builder: # Use the _query method instead of trying to use query as a function var entities_matching = _query( query_builder._all_components, query_builder._any_components, query_builder._exclude_components ) # Check if our entity is in the result set matches = entities_matching.has(entity) if matches: reactive_system.on_component_changed( entity, component, property, new_value, old_value ) #endregion Signal Callbacks #endregion Public Methods #region Utility Methods func _query(all_components = [], any_components = [], exclude_components = [], enabled_filter = null, precalculated_cache_key: int = -1) -> Array: var _perf_start_total := 0 if ECS.debug: _perf_start_total = Time.get_ticks_usec() # Early return if no components specified - return all entities if all_components.is_empty() and any_components.is_empty() and exclude_components.is_empty(): if enabled_filter == null: if ECS.debug: perf_mark("query_all_entities", Time.get_ticks_usec() - _perf_start_total, {"returned": entities.size()}) return entities else: # OPTIMIZATION: Use bitset filtering from all archetypes instead of entity.enabled check var filtered: Array[Entity] = [] for archetype in archetypes.values(): filtered.append_array(archetype.get_entities_by_enabled_state(enabled_filter)) if ECS.debug: perf_mark("query_all_entities_filtered", Time.get_ticks_usec() - _perf_start_total, {"returned": filtered.size(), "enabled_filter": enabled_filter}) return filtered # OPTIMIZATION: Use pre-calculated cache key if provided (avoids hash recalculation) var _perf_start_cache_key := 0 if ECS.debug: _perf_start_cache_key = Time.get_ticks_usec() var cache_key = precalculated_cache_key if precalculated_cache_key != -1 else QueryCacheKey.build(all_components, any_components, exclude_components) if ECS.debug: perf_mark("query_cache_key", Time.get_ticks_usec() - _perf_start_cache_key) # Check if we have cached matching archetypes for this query var matching_archetypes: Array[Archetype] = [] if _query_archetype_cache.has(cache_key): _cache_hits += 1 matching_archetypes = _query_archetype_cache[cache_key] if ECS.debug: perf_mark("query_cache_hit", 0, {"archetypes": matching_archetypes.size()}) else: _cache_misses += 1 var _perf_start_scan := 0 if ECS.debug: _perf_start_scan = Time.get_ticks_usec() # Find all archetypes that match this query var map_resource_path = func(x): return x.resource_path var _all := all_components.map(map_resource_path) var _any := any_components.map(map_resource_path) var _exclude := exclude_components.map(map_resource_path) for archetype in archetypes.values(): if archetype.matches_query(_all, _any, _exclude): matching_archetypes.append(archetype) # Cache the matching archetypes (not the entity arrays!) _query_archetype_cache[cache_key] = matching_archetypes if ECS.debug: perf_mark("query_archetype_scan", Time.get_ticks_usec() - _perf_start_scan, {"archetypes": matching_archetypes.size()}) # OPTIMIZATION: If there's only ONE matching archetype with no filtering, return it directly # This avoids array allocation and copying for the common case if matching_archetypes.size() == 1 and enabled_filter == null: if ECS.debug: perf_mark("query_single_archetype", Time.get_ticks_usec() - _perf_start_total, {"entities": matching_archetypes[0].entities.size()}) return matching_archetypes[0].entities # Collect entities from all matching archetypes with enabled filtering if needed var _perf_start_flatten := 0 if ECS.debug: _perf_start_flatten = Time.get_ticks_usec() var result: Array[Entity] = [] for archetype in matching_archetypes: if enabled_filter == null: # No filtering - add all entities result.append_array(archetype.entities) else: # OPTIMIZATION: Use bitset filtering instead of per-entity enabled check result.append_array(archetype.get_entities_by_enabled_state(enabled_filter)) if ECS.debug: perf_mark("query_flatten", Time.get_ticks_usec() - _perf_start_flatten, {"returned": result.size(), "archetypes": matching_archetypes.size()}) perf_mark("query_total", Time.get_ticks_usec() - _perf_start_total, {"returned": result.size()}) return result ## OPTIMIZATION: Group entities by their archetype for column-based iteration ## Enables systems to use get_column() for cache-friendly array access ## [param entities] Array of entities to group ## [returns] Dictionary mapping Archetype -> Array[Entity] ## ## Example usage in a System: ## [codeblock] ## func process_all(entities: Array, delta: float): ## var grouped = ECS.world.group_entities_by_archetype(entities) ## for archetype in grouped.keys(): ## process_columns(archetype, delta) ## [/codeblock] func group_entities_by_archetype(entities: Array) -> Dictionary: var grouped = {} for entity in entities: if entity_to_archetype.has(entity): var archetype = entity_to_archetype[entity] if not grouped.has(archetype): grouped[archetype] = [] grouped[archetype].append(entity) return grouped ## OPTIMIZATION: Get matching archetypes directly from query (no entity array flattening) ## This is MUCH faster than query().execute() + group_entities_by_archetype() ## [param query_builder] The query to execute ## [returns] Array of matching archetypes ## ## Example usage in a System: ## [codeblock] ## func process_all(entities: Array, delta: float): ## # OLD WAY (slow): ## # var grouped = ECS.world.group_entities_by_archetype(entities) ## ## # NEW WAY (fast): ## var archetypes = ECS.world.get_matching_archetypes(q.with_all([C_Velocity])) ## for archetype in archetypes: ## var velocities = archetype.get_column(C_Velocity.resource_path) ## for i in range(velocities.size()): ## # Process with cache-friendly column access ## [/codeblock] func get_matching_archetypes(query_builder: QueryBuilder) -> Array[Archetype]: var _perf_start := 0 if ECS.debug: _perf_start = Time.get_ticks_usec() # PERFORMANCE: Archetype matching is based ONLY on structural components. # Relationship/group filters are evaluated per-entity in System execution. # This avoids double-scanning entities (World + System) and reduces cache churn. var all_components = query_builder._all_components var any_components = query_builder._any_components var exclude_components = query_builder._exclude_components # Use a COMPONENT-ONLY cache key (ignore relationships/groups) var cache_key = QueryCacheKey.build(all_components, any_components, exclude_components) if _query_archetype_cache.has(cache_key): if ECS.debug: perf_mark("archetypes_cache_hit", Time.get_ticks_usec() - _perf_start) return _query_archetype_cache[cache_key] var map_resource_path = func(x): return x.resource_path var _all := all_components.map(map_resource_path) var _any := any_components.map(map_resource_path) var _exclude := exclude_components.map(map_resource_path) var matching: Array[Archetype] = [] var _perf_scan_start := 0 if ECS.debug: _perf_scan_start = Time.get_ticks_usec() for archetype in archetypes.values(): if archetype.matches_query(_all, _any, _exclude): matching.append(archetype) if ECS.debug: perf_mark("archetypes_scan", Time.get_ticks_usec() - _perf_scan_start, {"archetypes": matching.size()}) _query_archetype_cache[cache_key] = matching if ECS.debug: perf_mark("archetypes_total", Time.get_ticks_usec() - _perf_start, {"archetypes": matching.size()}) return matching ## Get performance statistics for cache usage func get_cache_stats() -> Dictionary: var total_requests = _cache_hits + _cache_misses var hit_rate = 0.0 if total_requests == 0 else float(_cache_hits) / float(total_requests) return { "cache_hits": _cache_hits, "cache_misses": _cache_misses, "hit_rate": hit_rate, "cached_queries": _query_archetype_cache.size(), "total_archetypes": archetypes.size(), "invalidation_count": _cache_invalidation_count, "invalidation_reasons": _cache_invalidation_reasons.duplicate() } ## Reset cache statistics func reset_cache_stats() -> void: _cache_hits = 0 _cache_misses = 0 _cache_invalidation_count = 0 _cache_invalidation_reasons.clear() ## Internal helper to track cache invalidations (debug mode only) func _invalidate_cache(reason: String) -> void: # OPTIMIZATION: Skip invalidation during batch operations if not _should_invalidate_cache: return _query_archetype_cache.clear() cache_invalidated.emit() # Track invalidation stats (debug mode only) if ECS.debug: _cache_invalidation_count += 1 _cache_invalidation_reasons[reason] = _cache_invalidation_reasons.get(reason, 0) + 1 ## Calculate archetype signature for an entity based on its components ## Uses the same hash function as queries for consistency ## An entity signature is just a query with all its components (no any/exclude) func _calculate_entity_signature(entity: Entity) -> int: # Get component resource paths var comp_paths = entity.components.keys() comp_paths.sort() # Sort paths for consistent ordering # Convert paths to Script objects using cached scripts (load once, reuse forever) var comp_scripts = [] for comp_path in comp_paths: # Check cache first if not _component_script_cache.has(comp_path): # Load once and cache var component = entity.components[comp_path] _component_script_cache[comp_path] = component.get_script() comp_scripts.append(_component_script_cache[comp_path]) # Use the SAME hash function as queries - entity is just "all components, no any/exclude" # OPTIMIZATION: Removed enabled_marker from signature - now handled by bitset in archetype var signature = QueryCacheKey.build(comp_scripts, [], []) return signature ## Get or create an archetype for the given signature and component types func _get_or_create_archetype(signature: int, component_types: Array) -> Archetype: var is_new = not archetypes.has(signature) if is_new: var archetype = Archetype.new(signature, component_types) archetypes[signature] = archetype _worldLogger.trace("Created new archetype: ", archetype) # ARCHETYPE OPTIMIZATION: Only invalidate cache when NEW archetype is created # This is rare compared to entities moving between existing archetypes _invalidate_cache("new_archetype_created") return archetypes[signature] ## Add entity to appropriate archetype (parallel system) func _add_entity_to_archetype(entity: Entity) -> void: # Calculate signature based on entity's components (enabled state now handled by bitset) var signature = _calculate_entity_signature(entity) # Get component type paths for this entity var comp_types = entity.components.keys() # Get or create archetype (no longer needs enabled filter value) var archetype = _get_or_create_archetype(signature, comp_types) # Add entity to archetype archetype.add_entity(entity) entity_to_archetype[entity] = archetype _worldLogger.trace("Added entity ", entity.name, " to archetype: ", archetype) ## Remove entity from its current archetype func _remove_entity_from_archetype(entity: Entity) -> bool: if not entity_to_archetype.has(entity): return false var archetype = entity_to_archetype[entity] var removed = archetype.remove_entity(entity) entity_to_archetype.erase(entity) # Clean up empty archetypes (optional - can keep them for reuse) if archetype.is_empty(): # Break circular references before removing archetype.add_edges.clear() archetype.remove_edges.clear() archetypes.erase(archetype.signature) _worldLogger.trace("Removed empty archetype: ", archetype) # OPTIMIZATION: Only invalidate when archetype is actually removed from world _invalidate_cache("empty_archetype_removed") return removed ## Fast path: Move entity when we already know which component was added/removed ## This avoids expensive set comparisons to find the difference ## Returns the new archetype the entity was moved to func _move_entity_to_new_archetype_fast(entity: Entity, old_archetype: Archetype, comp_path: String, is_add: bool) -> Archetype: # Try to use archetype edge for O(1) transition var new_archetype: Archetype = null if is_add: # Check if we have a cached edge for this component addition new_archetype = old_archetype.get_add_edge(comp_path) else: # Check if we have a cached edge for this component removal new_archetype = old_archetype.get_remove_edge(comp_path) # BUG FIX: If archetype retrieved from edge cache was removed from world.archetypes # when it became empty, re-add it so queries can find it if new_archetype != null and not archetypes.has(new_archetype.signature): archetypes[new_archetype.signature] = new_archetype _worldLogger.trace("Re-added archetype from edge cache: ", new_archetype) # If no cached edge, calculate signature and find/create archetype if new_archetype == null: var new_signature = _calculate_entity_signature(entity) var comp_types = entity.components.keys() new_archetype = _get_or_create_archetype(new_signature, comp_types) # Cache the edge for next time (archetype graph optimization) if is_add: old_archetype.set_add_edge(comp_path, new_archetype) new_archetype.set_remove_edge(comp_path, old_archetype) else: old_archetype.set_remove_edge(comp_path, new_archetype) new_archetype.set_add_edge(comp_path, old_archetype) # Remove from old archetype old_archetype.remove_entity(entity) # Add to new archetype new_archetype.add_entity(entity) entity_to_archetype[entity] = new_archetype _worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype) # Clean up empty old archetype if old_archetype.is_empty(): # Break circular references before removing old_archetype.add_edges.clear() old_archetype.remove_edges.clear() archetypes.erase(old_archetype.signature) return new_archetype ## Move entity from one archetype to another (when components change) ## Uses archetype edges for O(1) transitions when possible ## NOTE: This slow path compares sets - only used when we don't know which component changed func _move_entity_to_new_archetype(entity: Entity, old_archetype: Archetype) -> void: # Determine which component was added/removed by comparing old archetype with current entity var old_comp_set = {} for comp_path in old_archetype.component_types: old_comp_set[comp_path] = true var new_comp_set = {} for comp_path in entity.components.keys(): new_comp_set[comp_path] = true # Find the difference (added or removed component) var added_comp: String = "" var removed_comp: String = "" for comp_path in new_comp_set.keys(): if not old_comp_set.has(comp_path): added_comp = comp_path break for comp_path in old_comp_set.keys(): if not new_comp_set.has(comp_path): removed_comp = comp_path break # Try to use archetype edge for O(1) transition var new_archetype: Archetype = null if added_comp != "": # Check if we have a cached edge for this component addition new_archetype = old_archetype.get_add_edge(added_comp) elif removed_comp != "": # Check if we have a cached edge for this component removal new_archetype = old_archetype.get_remove_edge(removed_comp) # If no cached edge, calculate signature and find/create archetype if new_archetype == null: var new_signature = _calculate_entity_signature(entity) var comp_types = entity.components.keys() new_archetype = _get_or_create_archetype(new_signature, comp_types) # Cache the edge for next time (archetype graph optimization) if added_comp != "": old_archetype.set_add_edge(added_comp, new_archetype) new_archetype.set_remove_edge(added_comp, old_archetype) elif removed_comp != "": old_archetype.set_remove_edge(removed_comp, new_archetype) new_archetype.set_add_edge(removed_comp, old_archetype) # Remove from old archetype old_archetype.remove_entity(entity) # Add to new archetype new_archetype.add_entity(entity) entity_to_archetype[entity] = new_archetype _worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype) # Clean up empty old archetype if old_archetype.is_empty(): # Break circular references before removing old_archetype.add_edges.clear() old_archetype.remove_edges.clear() archetypes.erase(old_archetype.signature) #endregion Utility Methods #region Debugger Support ## Handle messages from the editor debugger func _handle_debugger_message(message: String, data: Array) -> bool: if message == "set_system_active": # Editor requested to toggle a system's active state var system_id = data[0] var new_active = data[1] # Find the system by instance ID for sys in systems: if sys.get_instance_id() == system_id: sys.active = new_active # Send confirmation back to editor GECSEditorDebuggerMessages.system_added(sys) return true return false elif message == "poll_entity": # Editor requested a component poll for a specific entity var entity_id = data[0] _poll_entity_for_debugger(entity_id) return true elif message == "select_entity": # Editor requested to select an entity in the scene tree var entity_path = data[0] print("GECS World: Received select_entity request for path: ", entity_path) # Get the actual node to get its ObjectID var node = get_node_or_null(entity_path) if node: var obj_id = node.get_instance_id() var _class_name = node.get_class() # The path needs to be an array of node names from root to target var path_array = str(entity_path).split("/", false) print(" Found node, sending inspect message") print(" ObjectID: ", obj_id) print(" Class: ", _class_name) if GECSEditorDebuggerMessages.can_send_message(): # The scene:inspect_object format per Godot source code: # [object_id (uint64), class_name (STRING), properties_array (ARRAY)] # NO path_array! Just 3 elements total # properties_array contains arrays of 6 elements each: # [name (STRING), type (INT), hint (INT), hint_string (STRING), usage (INT), value (VARIANT)] # Get actual properties from the node var properties: Array = [] var prop_list = node.get_property_list() # Add properties (limit to avoid huge payload) for i in range(min(20, prop_list.size())): var prop = prop_list[i] var prop_name: String = prop.name var prop_type: int = prop.type var prop_hint: int = prop.get("hint", 0) var prop_hint_string: String = prop.get("hint_string", "") var prop_usage: int = prop.usage var prop_value = node.get(prop_name) var prop_info: Array = [prop_name, prop_type, prop_hint, prop_hint_string, prop_usage, prop_value] properties.append(prop_info) # Message format: [object_id, class_name, properties] - only 3 elements! var msg_data: Array = [obj_id, _class_name, properties] print(" Sending scene:inspect_object: [", obj_id, ", ", _class_name, ", ", properties.size(), " props]") EngineDebugger.send_message("scene:inspect_object", msg_data) else: print(" ERROR: Could not find node at path: ", entity_path) return true return false ## Poll a specific entity's components and send updates to the debugger func _poll_entity_for_debugger(entity_id: int) -> void: # Find the entity by instance ID var entity: Entity = null for ent in entities: if ent.get_instance_id() == entity_id: entity = ent break if entity == null: return # Re-send all component data with fresh serialize() calls for comp_path in entity.components.keys(): var comp = entity.components[comp_path] if comp and comp is Resource: # Send updated component data GECSEditorDebuggerMessages.entity_component_added(entity, comp) #endregion Debugger Support