basic ECS spawner

This commit is contained in:
2026-01-15 15:27:48 +01:00
parent 24a781f36a
commit eb737b469c
860 changed files with 58621 additions and 32 deletions

View File

@@ -0,0 +1,299 @@
## Archetype
##
## Represents a unique combination of component types in the ECS framework.
## Entities with the exact same set of components share an archetype.
##
## Archetypes enable high-performance queries by grouping entities with identical
## component structures together in flat arrays, providing excellent cache locality
## and eliminating the need for set intersections during queries.
##
## [b]Key Concepts:[/b]
## - [b]Signature:[/b] Hash of all component types (determines archetype identity)
## - [b]Entities:[/b] Flat array of entities with this exact component combination
## - [b]Edges:[/b] Fast lookup for when components are added/removed (future optimization)
##
## [b]Example:[/b]
## [codeblock]
## # Archetype for entities with Position + Velocity
## var archetype = Archetype.new(12345, ["Position", "Velocity"])
## archetype.add_entity(player)
## archetype.add_entity(enemy)
## # Now both entities are stored contiguously for fast iteration
## [/codeblock]
##
## [b]Performance:[/b]
## - Add entity: O(1) amortized (array append)
## - Remove entity: O(1) (swap-remove with index tracking)
## - Query match: O(1) (check if archetype signature matches query)
## - Iterate entities: O(n) with excellent cache locality
class_name Archetype
extends RefCounted
## Unique hash identifying this component combination
## Generated by QueryCacheKey.build() from sorted component types
var signature: int = 0
## Sorted array of component resource paths (e.g., ["res://c_position.gd", "res://c_velocity.gd"])
## Used for debugging and archetype matching logic
var component_types: Array = []
## Flat array of entities with this exact component combination
## Provides excellent cache locality when iterating in systems
var entities: Array[Entity] = []
## Fast lookup: Entity -> index in entities array
## Enables O(1) entity removal using swap-remove technique
var entity_to_index: Dictionary = {} # Entity -> int
## OPTIMIZATION: Bitset for enabled/disabled state instead of archetype splitting
## Uses PackedInt64Array where each bit represents whether entity at that index is enabled
## Reduces archetype count by 2x and enables O(1) enabled/disabled filtering
var enabled_bitset: PackedInt64Array = []
## OPTIMIZATION: Structure of Arrays (SoA) column storage for cache-friendly iteration
## Maps component_path -> Array of component instances
## Enables Flecs-style direct array iteration without dictionary lookups
## Example: columns["res://c_velocity.gd"] = [vel1, vel2, vel3, ...]
var columns: Dictionary = {} # String (component_path) -> Array of components
## Archetype edges for fast component add/remove (future optimization)
## Maps: component_path -> Archetype (the archetype you get by adding/removing that component)
var add_edges: Dictionary = {} # String -> Archetype
var remove_edges: Dictionary = {} # String -> Archetype
## Initialize archetype with signature and component types
func _init(p_signature: int, p_component_types: Array):
signature = p_signature
component_types = p_component_types.duplicate()
component_types.sort() # Ensure sorted for consistent matching
# Initialize column arrays for each component type
for comp_type in component_types:
columns[comp_type] = []
## Add an entity to this archetype
## Uses O(1) append and tracks index for fast removal
## OPTIMIZATION: Also populates column arrays for cache-friendly iteration
func add_entity(entity: Entity) -> void:
var index = entities.size()
entities.append(entity)
entity_to_index[entity] = index
# OPTIMIZATION: Update enabled bitset
_ensure_bitset_capacity(index + 1)
_set_enabled_bit(index, entity.enabled)
# OPTIMIZATION: Populate column arrays from entity.components
for comp_path in component_types:
if entity.components.has(comp_path):
(columns[comp_path]
.append(entity.components[comp_path]))
else:
# Entity doesn't have this component yet (might be mid-initialization)
# Push null placeholder, will be fixed when component is added
columns[comp_path].append(null)
## Remove an entity from this archetype using swap-remove
## O(1) operation: swaps with last entity and pops
## OPTIMIZATION: Also maintains column arrays in sync
func remove_entity(entity: Entity) -> bool:
if not entity_to_index.has(entity):
return false
var index = entity_to_index[entity]
var last_index = entities.size() - 1
# Swap with last element in entities array
if index != last_index:
var last_entity = entities[last_index]
entities[index] = last_entity
entity_to_index[last_entity] = index
# OPTIMIZATION: Swap in column arrays too (maintain same ordering)
for comp_path in component_types:
columns[comp_path][index] = columns[comp_path][last_index]
# OPTIMIZATION: Swap enabled bit
var last_enabled = _get_enabled_bit(last_index)
_set_enabled_bit(index, last_enabled)
# Remove last element from entities
entities.pop_back()
entity_to_index.erase(entity)
# OPTIMIZATION: Remove last element from all columns
for comp_path in component_types:
columns[comp_path].pop_back()
# OPTIMIZATION: Update bitset size (no need to clear the bit, just reduce logical size)
# The bit will be overwritten when a new entity is added
return true
## Check if this archetype has a specific entity
func has_entity(entity: Entity) -> bool:
return entity_to_index.has(entity)
## Get entity count in this archetype
func size() -> int:
return entities.size()
## Check if archetype is empty
func is_empty() -> bool:
return entities.is_empty()
## Clear all entities from this archetype
func clear() -> void:
entities.clear()
entity_to_index.clear()
# OPTIMIZATION: Clear column arrays
for comp_path in component_types:
columns[comp_path].clear()
# OPTIMIZATION: Clear bitset
enabled_bitset.clear()
## Check if this archetype matches a query with all/any/exclude components
## [param all_comp_types] Component paths that must all be present
## [param any_comp_types] Component paths where at least one must be present
## [param exclude_comp_types] Component paths that must not be present
func matches_query(all_comp_types: Array, any_comp_types: Array, exclude_comp_types: Array) -> bool:
# Check all_components: must have ALL of these
for comp_type in all_comp_types:
if not component_types.has(comp_type):
return false
# Check any_components: must have AT LEAST ONE of these
if not any_comp_types.is_empty():
var has_any = false
for comp_type in any_comp_types:
if component_types.has(comp_type):
has_any = true
break
if not has_any:
return false
# Check exclude_components: must have NONE of these
for comp_type in exclude_comp_types:
if component_types.has(comp_type):
return false
return true
## Get a debug-friendly string representation
func _to_string() -> String:
var comp_names = []
for comp_type in component_types:
# Extract just the class name from the path
var parts = comp_type.split("/")
var filename = parts[parts.size() - 1].replace(".gd", "")
comp_names.append(filename)
return "Archetype[sig=%d, comps=%s, entities=%d]" % [
signature,
str(comp_names),
entities.size()
]
## Set up an edge to another archetype when a component is added
## Enables O(1) archetype transitions when components change
func set_add_edge(component_path: String, target_archetype: Archetype) -> void:
add_edges[component_path] = target_archetype
## Set up an edge to another archetype when a component is removed
## Enables O(1) archetype transitions when components change
func set_remove_edge(component_path: String, target_archetype: Archetype) -> void:
remove_edges[component_path] = target_archetype
## Get the target archetype when adding a component (if edge exists)
func get_add_edge(component_path: String) -> Archetype:
return add_edges.get(component_path, null)
## Get the target archetype when removing a component (if edge exists)
func get_remove_edge(component_path: String) -> Archetype:
return remove_edges.get(component_path, null)
## OPTIMIZATION: Get component column array for cache-friendly iteration
## Enables Flecs-style direct array access instead of dictionary lookups per entity
## [param component_path] The resource path of the component type (e.g., C_Velocity.resource_path)
## [returns] Array of component instances in entity index order, or empty array if not found
##
## Example:
## [codeblock]
## var velocities = archetype.get_column(C_Velocity.resource_path)
## for i in range(velocities.size()):
## var velocity = velocities[i]
## var entity = archetype.entities[i]
## # Process with cache-friendly sequential access
## [/codeblock]
func get_column(component_path: String) -> Array:
return columns.get(component_path, [])
## OPTIMIZATION: Get entities filtered by enabled state using bitset
## [param enabled_only] If true, return only enabled entities; if false, only disabled
## [returns] Array of entities matching the enabled state
func get_entities_by_enabled_state(enabled_only: bool) -> Array[Entity]:
var result: Array[Entity] = []
for i in range(entities.size()):
if _get_enabled_bit(i) == enabled_only:
result.append(entities[i])
return result
## OPTIMIZATION: Update entity enabled state in bitset
## [param entity] The entity to update
## [param enabled] The new enabled state
func update_entity_enabled_state(entity: Entity, enabled: bool) -> void:
if entity_to_index.has(entity):
var index = entity_to_index[entity]
_set_enabled_bit(index, enabled)
## OPTIMIZATION: Ensure bitset has enough capacity for the given number of entities
func _ensure_bitset_capacity(required_size: int) -> void:
var required_int64s = (required_size + 63) / 64 # Round up to nearest 64-bit boundary
while enabled_bitset.size() < required_int64s:
enabled_bitset.append(0)
## OPTIMIZATION: Set enabled bit for entity at index
func _set_enabled_bit(index: int, enabled: bool) -> void:
var int64_index = index / 64
var bit_index = index % 64
_ensure_bitset_capacity(index + 1)
if enabled:
enabled_bitset[int64_index] |= (1 << bit_index)
else:
enabled_bitset[int64_index] &= ~(1 << bit_index)
## OPTIMIZATION: Get enabled bit for entity at index
func _get_enabled_bit(index: int) -> bool:
if index >= entities.size():
return false
var int64_index = index / 64
var bit_index = index % 64
if int64_index >= enabled_bitset.size():
return false
return (enabled_bitset[int64_index] & (1 << bit_index)) != 0

View File

@@ -0,0 +1 @@
uid://vrhpkju2aq7q

View File

@@ -0,0 +1,48 @@
## A Component serves as a data container within the [_ECS] ([Entity] [Component] [System]) framework.
##
## A [Component] holds specific data related to an [Entity] but does not contain any behavior or logic.[br]
## Components are designed to be lightweight and easily attachable to [Entity]s to define their properties.[br]
##[br]
## [b]Example:[/b]
##[codeblock]
## ## Velocity Component.
## ##
## ## Holds the velocity data for an entity.
## class_name VelocityComponent
## extends Node2D
##
## @export var velocity: Vector2 = Vector2.ZERO
##[/codeblock]
##[br]
## [b]Component Queries:[/b][br]
## Use component query dictionaries to match components by specific property criteria in queries and relationships:[br]
##[codeblock]
## # Query entities with health >= 50
## var entities = ECS.world.query.with_all([{C_Health: {'amount': {"_gte": 50}}}]).execute()
##
## # Query relationships with specific damage values
## var entities = ECS.world.query.with_relationship([
## Relationship.new({C_Damage: {'amount': {"_eq": 100}}}, target)
## ]).execute()
##[/codeblock]
@icon("res://addons/gecs/assets/component.svg")
class_name Component
extends Resource
## Emitted when a property of this component changes. This is slightly different from the property_changed signal
signal property_changed(component: Resource, property_name: String, old_value: Variant, new_value: Variant)
## Reference to the parent entity that owns this component
var parent: Entity
## Used to serialize the component to a dictionary with only the export variables
## This is used for the debugger to send the data to the editor
func serialize() -> Dictionary:
var data: Dictionary = {}
for prop_info in get_script().get_script_property_list():
# Only include properties that are exported (@export variables)
if prop_info.usage & PROPERTY_USAGE_EDITOR:
var prop_name: String = prop_info.name
var prop_val = get(prop_name)
data[prop_name] = prop_val
return data

View File

@@ -0,0 +1 @@
uid://b6k13gc2m4e5s

102
addons/gecs/ecs/ecs.gd Normal file
View File

@@ -0,0 +1,102 @@
## ECS ([Entity] [Component] [System]) Singleton[br]
## The ECS class acts as the central manager for the entire ECS framework
##
## The [_ECS] class maintains the current active [World] and provides access to [QueryBuilder] for fetching [Entity]s based on their [Component]s.
##[br]
## This singleton allows any part of the game to interact with the ECS system seamlessly.
## [codeblock]
## var entities = ECS.world.query.with_all([Transform, Velocity]).execute()
## for entity in entities:
## entity.get_component(Transform).position += entity.get_component(Velocity).direction * delta
## [/codeblock]
## This is also where you control the setup of the world and process loop of the ECS system.
##[codeblock]
##
## func _read(delta):
## ECS.world = world
##
## func _process(delta):
## ECS.process(delta)
##[/codeblock]
## or in the physics loop
##[codeblock]
## func _physics_process(delta):
## ECS.process(delta)
##[/codeblock]
class_name _ECS
extends Node
## Emitted when the world is changed with a ref to the new world
signal world_changed(world: World)
## Emitted when the world is exited
signal world_exited
## The Current active [World] Instance[br]
## Holds a reference to the currently active [World], allowing access to the [member World.query] instance and any [Entity]s and [System]s within it.
var world: World:
get:
return world
set(value):
# Add the new world to the scenes
world = value
if world:
if not world.is_inside_tree():
# Add the world to the tree if it is not already
get_tree().root.get_node("./Root").add_child(world)
if not world.is_connected("tree_exited", _on_world_exited):
world.connect("tree_exited", _on_world_exited)
world_changed.emit(world)
assert(GECSEditorDebuggerMessages.set_world(world) if debug else true, 'Debug Data')
## Are we in debug mode? Controlled by project setting gecs/debug_mode
var debug := ProjectSettings.get_setting(GecsSettings.SETTINGS_DEBUG_MODE, false)
## This is an array of functions that get called on the entities when they get added to the world (after they are ready)
var entity_preprocessors: Array[Callable] = []
## This is an array of functions that get called on the entities right before they get removed from the world
var entity_postprocessors: Array[Callable] = []
## A Wildcard for use in relatonship queries. Indicates can be any value for a relation
## or a target in a Relationship Pair ECS.wildcard
var wildcard = null
## This is called to process the current active [World] instance and the [System]s within it.
## You would call this in _process or _physics_process to update the [_ECS] system.[br]
## If you provide a group name it will run just that group otherwise it runs all groups[br]
## Example:
## [codeblock]ECS.world.process(world, 'my-system-group')[/codeblock]
func process(delta: float, group: String = "") -> void:
world.process(delta, group)
## Get all components of a specific type from a list of entities[br]
## If the component does not exist on the entity it will return the default_component if provided or assert
func get_components(entities, component_type, default_component = null) -> Array:
var components = []
for entity in entities:
var component = entity.components.get(component_type.resource_path, null)
if not component and not default_component:
assert(component, "Entity does not have component: " + str(component_type))
if not component and default_component:
component = default_component
components.append(component)
return components
## Called when the world is exited
func _on_world_exited() -> void:
world = null
world_exited.emit()
assert(GECSEditorDebuggerMessages.exit_world() if debug else true, 'Debug Data')
func serialize(query: QueryBuilder, config: GECSSerializeConfig = null) -> GecsData:
return GECSIO.serialize(query, config)
func save(gecs_data: GecsData, filepath: String, binary: bool = false) -> bool:
return GECSIO.save(gecs_data, filepath, binary)
func deserialize(gecs_filepath: String) -> Array[Entity]:
return GECSIO.deserialize(gecs_filepath)

View File

@@ -0,0 +1 @@
uid://dfqwl5njvdnmq

509
addons/gecs/ecs/entity.gd Normal file
View File

@@ -0,0 +1,509 @@
## Entity[br]
##
## Represents an entity within the [_ECS] framework.[br]
## An entity is a container that can hold multiple [Component]s.
##
## Entities serve as the fundamental building block for game objects, allowing for flexible and modular design.[br]
##[br]
## Entities can have [Component]s added or removed dynamically, enabling the behavior and properties of game objects to change at runtime.[br]
## Entities can have [Relationship]s added or removed dynamically, allowing for a deep hierarchical query system.[br]
##[br]
## Example:
##[codeblock]
## var entity = Entity.new()
## var transform = Transform.new()
## entity.add_component(transform)
## entity.component_added.connect(_on_component_added)
##
## func _on_component_added(entity: Entity, component_key: String) -> void:
## print("Component added:", component_key)
##[/codeblock]
@icon("res://addons/gecs/assets/entity.svg")
@tool
class_name Entity
extends CharacterBody3D
#region Signals
## Emitted when a [Component] is added to the entity.
signal component_added(entity: Entity, component: Resource)
## Emitted when a [Component] is removed from the entity.
signal component_removed(entity: Entity, component: Resource)
## Emitted when a [Component] property is changed.
signal component_property_changed(
entity: Entity,
component: Resource,
property_name: String,
old_value: Variant,
new_value: Variant
)
## Emit when a [Relationship] is added to the [Entity]
signal relationship_added(entity: Entity, relationship: Relationship)
## Emit when a [Relationship] is removed from the [Entity]
signal relationship_removed(entity: Entity, relationship: Relationship)
#endregion Signals
#region Exported Variables
## The id of the entity either UUID or custom string.
## This must be unique within a [World]. If left blank, a UUID will be generated when the entity is added to a world.
@export var id: String
## Is this entity active? (Will show up in queries)
@export var enabled: bool = true:
set(value):
if enabled != value:
var old_enabled = enabled
enabled = value
# Notify world to move entity between enabled/disabled archetypes
_on_enabled_changed(old_enabled, value)
## [Component]s to be attached to the entity set in the editor. These will be loaded for you and added to the [Entity]
@export var component_resources: Array[Component] = []
## Serialization config override for this specific entity (optional)
@export var serialize_config: GECSSerializeConfig
#endregion Exported Variables
#region Public Variables
## [Component]s attached to the [Entity] in the form of Dict[resource_path:String, Component]
var components: Dictionary = {}
## Relationships attached to the entity
var relationships: Array[Relationship] = []
## Cache for component resource paths to avoid repeated .get_script().resource_path calls
var _component_path_cache: Dictionary = {}
## Logger for entities to only log to a specific domain
var _entityLogger = GECSLogger.new().domain("Entity")
## We can store ephemeral state on the entity
var _state = {}
#endregion Public Variables
#region Built-in Virtual Methods
## Called to initialize the entity and its components.
## This is called automatically by [method World.add_entity][br]
func _initialize(_components: Array = []) -> void:
_entityLogger.trace("Entity Initializing Components: ", self.name)
# because components can be added before the entity is added to the world
# replay adding components here so signals pick them up and the index is updated
var temp_comps = components.values().duplicate_deep()
components.clear()
for comp in temp_comps:
add_component(comp)
# Add components defined in code to comp resources
component_resources.append_array(define_components())
# remove any component_resources that are already defined in components
# This is useful for when you instantiate an entity from a scene and want to overide components
component_resources = component_resources.filter(func(comp): return not has_component(comp.get_script()))
# Add components passed in directly to the _initialize method to override everything else
component_resources.append_array(_components)
# Initialize components
for res in component_resources:
add_component(res.duplicate(true))
# Call the lifecycle method on_ready
on_ready()
#endregion Built-in Virtual Methods
## Get the effective serialization config for this entity
## Returns entity-specific config if set, otherwise falls back to world default
func get_effective_serialize_config() -> GECSSerializeConfig:
if serialize_config != null:
return serialize_config
if ECS.world != null and ECS.world.default_serialize_config != null:
return ECS.world.default_serialize_config
# Fallback if no world or no default config
var fallback = GECSSerializeConfig.new()
return fallback
#region Components
## Adds a single component to the entity.[br]
## [param component] The subclass of [Component] to add.[br]
## [b]Example[/b]:
## [codeblock]entity.add_component(HealthComponent)[/codeblock]
func add_component(component: Resource) -> void:
# Cache the resource path to avoid repeated calls
var resource_path = component.get_script().resource_path
# If a component of this type already exists, remove it first
if components.has(resource_path):
var existing_component = components[resource_path]
remove_component(existing_component)
_component_path_cache[component] = resource_path
components[resource_path] = component
component.parent = self
if not component.property_changed.is_connected(_on_component_property_changed):
component.property_changed.connect(_on_component_property_changed)
## Adding components happens through a signal
component_added.emit(self , component)
_entityLogger.trace("Added Component: ", resource_path)
func _on_component_property_changed(
component: Resource, property_name: String, old_value: Variant, new_value: Variant
) -> void:
# Pass this signal on to the world
component_property_changed.emit(self , component, property_name, old_value, new_value)
## Adds multiple components to the entity.[br]
## [param _components] An [Array] of [Component]s to add.[br]
## [b]Example:[/b]
## [codeblock]entity.add_components([TransformComponent, VelocityComponent])[/codeblock]
func add_components(_components: Array):
# OPTIMIZATION: Batch component additions to avoid multiple archetype transitions
# Instead of moving archetype once per component, calculate the final archetype once
if _components.is_empty():
return
# Add all components to local storage first (no signals yet)
var added_components = []
for component in _components:
if component == null:
continue
var component_path = component.get_script().resource_path
if not components.has(component_path):
components[component_path] = component
added_components.append(component)
# If no new components were actually added, return early
if added_components.is_empty():
return
# OPTIMIZATION: Move to final archetype only once, after all components are added
if ECS.world and ECS.world.entity_to_archetype.has(self ):
var old_archetype = ECS.world.entity_to_archetype[ self ]
var new_signature = ECS.world._calculate_entity_signature(self )
var comp_types = components.keys()
var new_archetype = ECS.world._get_or_create_archetype(new_signature, comp_types)
# Only move if we actually need a different archetype
if old_archetype != new_archetype:
# Remove from old archetype
old_archetype.remove_entity(self )
# Add to new archetype
new_archetype.add_entity(self )
ECS.world.entity_to_archetype[ self ] = new_archetype
# Clean up empty old archetype
if old_archetype.is_empty():
old_archetype.add_edges.clear()
old_archetype.remove_edges.clear()
ECS.world.archetypes.erase(old_archetype.signature)
else:
# Same archetype - just update the column data for new components
for component in added_components:
var comp_path = component.get_script().resource_path
var entity_index = old_archetype.entity_to_index[ self ]
old_archetype.columns[comp_path][entity_index] = component
# Emit signals for all added components
for component in added_components:
component_added.emit(self , component)
## Removes a single component from the entity.[br]
## [param component] The [Component] subclass to remove.[br]
## [b]Example:[/b]
## [codeblock]entity.remove_component(HealthComponent)[/codeblock]
func remove_component(component: Resource) -> void:
# Use cached path if available, otherwise get it from the component class
var resource_path: String
if _component_path_cache.has(component):
resource_path = _component_path_cache[component]
_component_path_cache.erase(component)
else:
# Component parameter should be a class/script, consistent with has_component
resource_path = component.resource_path
if components.has(resource_path):
var component_instance = components[resource_path]
components.erase(resource_path)
# Clean up cache entry for the component instance
_component_path_cache.erase(component_instance)
component_removed.emit(self , component_instance)
# ARCHETYPE: Signal handler (_on_entity_component_removed) handles archetype update
_entityLogger.trace("Removed Component: ", resource_path)
func deferred_remove_component(component: Resource) -> void:
call_deferred_thread_group("remove_component", component)
## Removes multiple components from the entity.[br]
## [param _components] An array of components to remove.[br]
##
## [b]Example:[/b]
## [codeblock]entity.remove_components([transform_component, velocity_component])[/codeblock]
func remove_components(_components: Array):
# OPTIMIZATION: Batch component removals to avoid multiple archetype transitions
# Instead of moving archetype once per component, calculate the final archetype once
if _components.is_empty():
return
# Remove all components from local storage first (no signals yet)
var removed_components = []
for _component in _components:
if _component == null:
continue
var comp_to_remove: Resource = null
# Handle both Scripts and Resource instances
# NOTE: Check Script first since Script inherits from Resource
if _component is Script:
comp_to_remove = get_component(_component)
elif _component is Resource:
comp_to_remove = _component
if comp_to_remove:
var component_path = comp_to_remove.get_script().resource_path
if components.has(component_path):
components.erase(component_path)
removed_components.append(comp_to_remove)
# If no components were actually removed, return early
if removed_components.is_empty():
return
# OPTIMIZATION: Move to final archetype only once, after all components are removed
if ECS.world and ECS.world.entity_to_archetype.has(self ):
var old_archetype = ECS.world.entity_to_archetype[ self ]
var new_signature = ECS.world._calculate_entity_signature(self )
var comp_types = components.keys()
var new_archetype = ECS.world._get_or_create_archetype(new_signature, comp_types)
# Only move if we actually need a different archetype
if old_archetype != new_archetype:
# Remove from old archetype
old_archetype.remove_entity(self )
# Add to new archetype
new_archetype.add_entity(self )
ECS.world.entity_to_archetype[ self ] = new_archetype
# Clean up empty old archetype
if old_archetype.is_empty():
old_archetype.add_edges.clear()
old_archetype.remove_edges.clear()
ECS.world.archetypes.erase(old_archetype.signature)
# Emit signals for all removed components
for component in removed_components:
component_removed.emit(self , component)
## Removes all components from the entity.[br]
## [b]Example:[/b]
## [codeblock]entity.remove_all_components()[/codeblock]
func remove_all_components() -> void:
for component in components.values():
remove_component(component)
## Retrieves a specific [Component] from the entity.[br]
## [param component] The [Component] class to retrieve.[br]
## Returns the requested [Component] if it exists, otherwise `null`.[br]
## [b]Example:[/b]
## [codeblock]var transform = entity.get_component(Transform)[/codeblock]
func get_component(component: Resource) -> Component:
return components.get(component.resource_path, null)
## Check to see if an entity has a specific component on it.[br]
## This is useful when you're checking to see if it has a component and not going to use the component itself.[br]
## If you plan on getting and using the component, use [method get_component] instead.
func has_component(component: Resource) -> bool:
return components.has(component.resource_path)
#endregion Components
#region Relationships
## Adds a relationship to this entity.[br]
## [param relationship] The [Relationship] to add.
func add_relationship(relationship: Relationship) -> void:
assert(
not relationship._is_query_relationship,
"Cannot add query relationships to entities. Query relationships (created with dictionaries) are for matching only, not for storage."
)
relationship.source = self
relationships.append(relationship)
relationship_added.emit(self , relationship)
func add_relationships(_relationships: Array):
for relationship in _relationships:
add_relationship(relationship)
## Removes a relationship from the entity.[br]
## [param relationship] The [Relationship] to remove.[br]
## [param limit] Maximum number of relationships to remove. -1 = all (default), 0 = none, >0 = up to that many.[br]
## [br]
## [b]Examples:[/b]
## [codeblock]
## # Remove all matching relationships (default behavior)
## entity.remove_relationship(Relationship.new(C_Damage.new(), target))
##
## # Remove only one matching relationship
## entity.remove_relationship(Relationship.new(C_Damage.new(), target), 1)
##
## # Remove up to 3 matching relationships
## entity.remove_relationship(Relationship.new(C_Damage.new(), target), 3)
##
## # Remove no relationships (useful for testing/debugging)
## entity.remove_relationship(Relationship.new(C_Damage.new(), target), 0)
## [/codeblock]
func remove_relationship(relationship: Relationship, limit: int = -1) -> void:
if limit == 0:
return
var to_remove = []
var removed_count = 0
var pattern_remove = true
if relationships.has(relationship):
to_remove.append(relationship)
pattern_remove = false
if pattern_remove:
for rel in relationships:
if rel.matches(relationship):
to_remove.append(rel)
removed_count += 1
# If limit is positive and we've reached it, stop collecting
if limit > 0 and removed_count >= limit:
break
for rel in to_remove:
relationships.erase(rel)
relationship_removed.emit(self , rel)
## Removes multiple relationships from the entity.[br]
## [param _relationships] Array of [Relationship]s to remove.[br]
## [param limit] Maximum number of relationships to remove per relationship type. -1 = all (default), 0 = none, >0 = up to that many.
func remove_relationships(_relationships: Array, limit: int = -1):
for relationship in _relationships:
remove_relationship(relationship, limit)
## Removes all relationships from the entity.
func remove_all_relationships() -> void:
var to_remove = relationships.duplicate()
for rel in to_remove:
relationships.erase(rel)
relationship_removed.emit(self , rel)
## Retrieves a specific [Relationship] from the entity.
## [param relationship] The [Relationship] to retrieve.
## [return] The first matching [Relationship] if it exists, otherwise `null`
func get_relationship(relationship: Relationship) -> Relationship:
var to_remove = []
for rel in relationships:
# Check if the relationship is valid
if not rel.valid():
to_remove.append(rel)
continue
if rel.matches(relationship):
# Remove invalid relationships before returning
for invalid_rel in to_remove:
relationships.erase(invalid_rel)
relationship_removed.emit(self , invalid_rel)
return rel
# Remove invalid relationships
for rel in to_remove:
relationships.erase(rel)
relationship_removed.emit(self , rel)
return null
## Retrieves [Relationship]s from the entity.
## [param relationship] The [Relationship]s to retrieve.
## [return] Array of all matching [Relationship]s (empty array if none found).
func get_relationships(relationship: Relationship) -> Array[Relationship]:
var results: Array[Relationship] = []
var to_remove = []
for rel in relationships:
# Check if the relationship is valid
if not rel.valid():
to_remove.append(rel)
continue
if rel.matches(relationship):
results.append(rel)
# Remove invalid relationships
for rel in to_remove:
relationships.erase(rel)
relationship_removed.emit(self , rel)
return results
## Checks if the entity has a specific relationship.[br]
## [param relationship] The [Relationship] to check for.
func has_relationship(relationship: Relationship) -> bool:
return get_relationship(relationship) != null
#endregion Relationships
#region Lifecycle Methods
## Called after the entity is fully initialized and ready.[br]
## Override this method to perform additional setup after all components have been added.
func on_ready() -> void:
pass
## Called right before the entity is freed from memory.[br]
## Override this method to perform any necessary cleanup before the entity is destroyed.
func on_destroy() -> void:
pass
## Called when the entity is disabled.[br]
func on_disable() -> void:
pass
## Called when the entity is enabled.[br]
func on_enable() -> void:
pass
## Define the default components in code to use (Instead of in the editor)[br]
## This should return a list of components to add by default when the entity is created
func define_components() -> Array:
return []
## INTERNAL: Called when entity.enabled changes to move entity between archetypes
func _on_enabled_changed(old_value: bool, new_value: bool) -> void:
# Only handle if entity is already in a world
if not ECS.world or not ECS.world.entity_to_archetype.has(self ):
return
# OPTIMIZATION: Update bitset instead of moving between archetypes
# This eliminates the need for separate enabled/disabled archetypes
var archetype = ECS.world.entity_to_archetype[ self ]
archetype.update_entity_enabled_state(self , new_value)
# Invalidate query cache since archetypes changed
ECS.world.cache_invalidated.emit()
#endregion Lifecycle Methods

View File

@@ -0,0 +1 @@
uid://cl6glf45pcrns

View File

@@ -0,0 +1,74 @@
## An Observer is like a system that reacts when specific component events happen
## It has a query that filters which entities are monitored for these events
## Observers can respond to component add/remove/change events on specific sets of entities
##
## [b]Important:[/b] For property changes to trigger [method on_component_changed], you must
## manually emit the [signal Component.property_changed] signal from within your component.
## Simply setting properties does not automatically trigger observers.
##
## [b]Example of triggering property changes:[/b]
## [codeblock]
## # In your component class
## class_name MyComponent
## extends Component
##
## @export var health: int = 100 : set = set_health
##
## func set_health(new_value: int):
## var old_value = health
## health = new_value
## # This is required for observers to detect the change
## property_changed.emit(self, "health", old_value, new_value)
## [/codeblock]
@icon("res://addons/gecs/assets/observer.svg")
class_name Observer
extends Node
## The [QueryBuilder] object exposed for conveinence to use in the system and to create the query.
var q: QueryBuilder
## Override this method and return a [QueryBuilder] to define the required [Component]s the entity[br]
## must match for the observer to trigger. If empty this will match all [Entity]s
func match() -> QueryBuilder:
return q
## Override this method and provide a single component to watch for events.[br]
## This means that the observer will only react to events on this component (add/remove/change)[br]
## assuming the entity matches the query defined in the [method match] method
func watch() -> Resource:
assert(false, "You must override the watch() method in your system")
return
## Override this method to define the main processing function for the observer when a component is added to an [Entity].[br]
## [param entity] The [Entity] the component was added to.[br]
## [param component] The [Component] that was added. Guaranteed to be the component defined in [method watch].[br]
func on_component_added(entity: Entity, component: Resource) -> void:
pass
## Override this method to define the main processing function for the observer when a component is removed from an [Entity].[br]
## [param entity] The [Entity] the component was removed from.[br]
## [param component] The [Component] that was removed. Guaranteed to be the component defined in [method watch].[br]
func on_component_removed(entity: Entity, component: Resource) -> void:
pass
## Override this method to define the main processing function for property changes.[br]
## This method is called when a property changes on the watched component.[br]
## [br]
## [b]Note:[/b] This method only triggers when the component explicitly emits its
## [signal Component.property_changed] signal for performance reasons. Setting properties directly will
## [b]not[/b] automatically trigger this method.[br]
## [br]
## [param entity] The [Entity] the component that changed is attached to.
## [param component] The [Component] that changed. Guaranteed to be the component defined in [method watch].
## [param property] The name of the property that changed on the [Component].
## [param old_value] The old value of the property.
## [param new_value] The new value of the property.
func on_component_changed(
entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant
) -> void:
pass

View File

@@ -0,0 +1 @@
uid://dd3umv3f8qyx5

View File

@@ -0,0 +1,571 @@
## QueryBuilder[br]
## A utility class for constructing and executing queries to retrieve entities based on their components.
##
## The QueryBuilder supports filtering entities that have all, any, or exclude specific components,
## as well as filtering by enabled/disabled status using high-performance group indexing.
## [codeblock]
## var enabled_entities = ECS.world.query
## .with_all([Transform, Velocity])
## .with_any([Health])
## .with_none([Inactive])
## .enabled(true)
## .execute()
##
## var disabled_entities = ECS.world.query.enabled(false).execute()
## var all_entities = ECS.world.query.enabled(null).execute()
##[/codeblock]
## This will efficiently query entities using indexed group lookups rather than
## filtering the entire entity list.
class_name QueryBuilder
extends RefCounted
# The world instance to query against.
var _world: World
# Components that an entity must have all of.
var _all_components: Array = []
# Components that an entity must have at least one of.
var _any_components: Array = []
# Components that an entity must not have.
var _exclude_components: Array = []
# Relationships that entities must have
var _relationships: Array = [] # (Retained for entity-level filtering only; NOT part of cache key)
var _exclude_relationships: Array = []
# Components queries that an entity must match
var _all_components_queries: Array = []
# Components queries that an entity must match for any components
var _any_components_queries: Array = []
# Groups that an entity must be in
var _groups: Array = []
# Groups that an entity must not be in
var _exclude_groups: Array = []
# Enabled/disabled filter: true = enabled only, false = disabled only, null = all
var _enabled_filter = null
# Components to iterate in archetype mode (ordered array of component types)
var _iterate_components: Array = []
# Add fields for query result caching
var _cache_valid: bool = false
var _cached_result: Array = []
# OPTIMIZATION: Cache the query hash key to avoid recalculating FNV-1a hash every frame
var _cache_key: int = -1
var _cache_key_valid: bool = false
## Initializes the QueryBuilder with the specified [param world]
func _init(world: World = null):
_world = world as World
## Allow setting the world after creation for editor time creation
func set_world(world: World):
_world = world
## Clears the query criteria, resetting all filters. Mostly used in testing
## [param returns] - The current instance of the QueryBuilder for chaining.
func clear():
_all_components = []
_any_components = []
_exclude_components = []
_relationships = []
_exclude_relationships = []
_all_components_queries = []
_any_components_queries = []
_groups = []
_exclude_groups = []
_enabled_filter = null
_iterate_components = []
_cache_valid = false
_cache_key_valid = false
return self
## Finds entities with all of the provided components.[br]
## [param components] An [Array] of [Component] classes.[br]
## [param returns]: [QueryBuilder] instance for chaining.
func with_all(components: Array = []) -> QueryBuilder:
var processed = ComponentQueryMatcher.process_component_list(components)
_all_components = processed.components
_all_components_queries = processed.queries
_cache_valid = false
_cache_key_valid = false
return self
## Entities must have at least one of the provided components.[br]
## [param components] An [Array] of [Component] classes.[br]
## [param reutrns] [QueryBuilder] instance for chaining.
func with_any(components: Array = []) -> QueryBuilder:
var processed = ComponentQueryMatcher.process_component_list(components)
_any_components = processed.components
_any_components_queries = processed.queries
_cache_valid = false
_cache_key_valid = false
return self
## Entities must not have any of the provided components.[br]
## Params: [param components] An [Array] of [Component] classes.[br]
## [param reutrns] [QueryBuilder] instance for chaining.
func with_none(components: Array = []) -> QueryBuilder:
# Don't process queries for with_none, just take the components directly
_exclude_components = components.map(
func(comp): return comp if not comp is Dictionary else comp.keys()[0]
)
_cache_valid = false
_cache_key_valid = false
return self
## Finds entities with specific relationships using weak matching by default (component type and queries).
## [br][b]Weak Matching (default):[/b] Components match by type and component queries are evaluated.
## [br]For strong matching (exact component data), use [method Entity.has_relationship] with [code]weak=false[/code].
func with_relationship(relationships: Array = []) -> QueryBuilder:
_relationships = relationships
_cache_valid = false
# Cache key unaffected by relationships (structural only)
return self
## Entities must not have any of the provided relationships using weak matching by default (component type and queries).
## [br][b]Weak Matching (default):[/b] Components match by type and component queries are evaluated.
## [br]For strong matching (exact component data), use [method Entity.has_relationship] with [code]weak=false[/code].
func without_relationship(relationships: Array = []) -> QueryBuilder:
_exclude_relationships = relationships
_cache_valid = false
return self
## Query for entities that are targets of specific relationships
func with_reverse_relationship(relationships: Array = []) -> QueryBuilder:
for rel in relationships:
if rel.relation != null:
var rev_key = "reverse_" + rel.relation.get_script().resource_path
if _world.reverse_relationship_index.has(rev_key):
return self.with_all(_world.reverse_relationship_index[rev_key])
_cache_valid = false
return self
## Finds entities with specific groups.
func with_group(groups: Array[String] = []) -> QueryBuilder:
_groups.append_array(groups)
_cache_valid = false
_cache_key_valid = false
return self
## Entities must not have any of the provided groups.
func without_group(groups: Array[String] = []) -> QueryBuilder:
_exclude_groups.append_array(groups)
_cache_valid = false
_cache_key_valid = false
return self
## Filter to only enabled entities using internal arrays for optimal performance.[br]
## [param returns] [QueryBuilder] instance for chaining.
func enabled() -> QueryBuilder:
_enabled_filter = true
_cache_valid = false
_cache_key_valid = false
return self
## Filter to only disabled entities using internal arrays for optimal performance.[br]
## [param returns] [QueryBuilder] instance for chaining.
func disabled() -> QueryBuilder:
_enabled_filter = false
_cache_valid = false
_cache_key_valid = false
return self
## Specifies the component order for batch processing iteration.[br]
## This determines the order of component arrays passed to System.process_batch()[br]
## [param components] An array of component types in the desired iteration order[br]
## [param returns] [QueryBuilder] instance for chaining.[br][br]
## [b]Example:[/b]
## [codeblock]
## func query() -> QueryBuilder:
## return q.with_all([C_Velocity, C_Timer]).enabled().iterate([C_Velocity, C_Timer])
##
## func process_batch(entities: Array[Entity], components: Array, delta: float) -> void:
## var velocities = components[0] # C_Velocity (first in iterate)
## var timers = components[1] # C_Timer (second in iterate)
## [/codeblock]
func iterate(components: Array) -> QueryBuilder:
_iterate_components = components
return self
func execute_one() -> Entity:
# Execute the query and return the first matching entity
var result = execute()
if result.size() > 0:
return result[0]
return null
## Executes the constructed query and retrieves matching entities.[br]
## [param returns] - An [Array] of [Entity] that match the query criteria.
func execute() -> Array:
# For relationship or group filters we need fresh filtering every call (no stale cached filtered result)
var uses_relationship_filters := (not _relationships.is_empty() or not _exclude_relationships.is_empty())
var uses_group_filters := (not _groups.is_empty() or not _exclude_groups.is_empty())
var structural_result: Array
if _cache_valid and not uses_relationship_filters and not uses_group_filters:
# Safe to reuse full cached result only for purely structural component queries
structural_result = _cached_result
else:
# Recompute base structural/group result (without relationship filtering caching)
structural_result = _internal_execute()
# Only cache if no dynamic relationship/group filters are present
if not uses_relationship_filters and not uses_group_filters:
_cached_result = structural_result
_cache_valid = true
else:
_cache_valid = false # force recompute next call
var result = structural_result
# Apply component property queries (post structural)
if not _all_components_queries.is_empty() and _has_actual_queries(_all_components_queries):
result = _filter_entities_by_queries(result, _all_components, _all_components_queries, true)
if not _any_components_queries.is_empty() and _has_actual_queries(_any_components_queries):
result = _filter_entities_by_queries(result, _any_components, _any_components_queries, false)
return result
func _internal_execute() -> Array:
# If we have groups or exclude groups, gather entities from those groups
if not _groups.is_empty() or not _exclude_groups.is_empty():
var entities_in_group = []
# Use Godot's optimized get_nodes_in_group() instead of filtering
if not _groups.is_empty():
# For multiple groups, use set operations for efficiency
var group_set: Set
for i in range(_groups.size()):
var group_name = _groups[i]
var nodes_in_group = _world.get_tree().get_nodes_in_group(group_name)
# Filter to only Entity nodes
var entities_in_this_group = nodes_in_group.filter(func(n): return n is Entity)
if i == 0:
# First group - start with these entities
group_set = Set.new(entities_in_this_group)
else:
# Subsequent groups - intersect (entity must be in ALL groups)
group_set = group_set.intersect(Set.new(entities_in_this_group))
entities_in_group = group_set.to_array() if group_set else []
else:
# If no required groups but we have exclude_groups, start with ALL entities from component query
# This handles the case of "without_group" queries
entities_in_group = (
_world._query(_all_components, _any_components, _exclude_components, _enabled_filter, get_cache_key()) as Array[Entity]
)
# Filter out entities in excluded groups
if not _exclude_groups.is_empty():
var exclude_set = Set.new()
for group_name in _exclude_groups:
var nodes_in_group = _world.get_tree().get_nodes_in_group(group_name)
var entities_in_excluded = nodes_in_group.filter(func(n): return n is Entity)
exclude_set = exclude_set.union(Set.new(entities_in_excluded))
# Remove excluded entities
var result_set = Set.new(entities_in_group)
entities_in_group = result_set.difference(exclude_set).to_array()
# match the entities in the group with the query
return matches(entities_in_group)
# Otherwise, query the world with enabled filter for optimal performance
# OPTIMIZATION: Pass pre-calculated cache key to avoid rehashing
var result = (
_world._query(_all_components, _any_components, _exclude_components, _enabled_filter, get_cache_key()) as Array[Entity]
)
# Handle relationship filtering
if not _relationships.is_empty() or not _exclude_relationships.is_empty():
var filtered_entities: Array = []
for entity in result:
var matches = true
# Required relationships
for relationship in _relationships:
if not entity.has_relationship(relationship):
matches = false
break
# Excluded relationships
if matches:
for ex_relationship in _exclude_relationships:
if entity.has_relationship(ex_relationship):
matches = false
break
if matches:
filtered_entities.append(entity)
result = filtered_entities
# Return the structural query result (caching handled in execute())
# Note: enabled/disabled filtering is now handled in World._query for optimal performance
return result
## Check if any query in the array has actual property filters (not just empty {})
func _has_actual_queries(queries: Array) -> bool:
for query in queries:
if not query.is_empty():
return true
return false
## Filter entities based on component queries
func _filter_entities_by_queries(
entities: Array, components: Array, queries: Array, require_all: bool
) -> Array:
var filtered = []
for entity in entities:
if entity == null:
continue
if require_all:
# Must match all queries
var matches = true
for i in range(components.size()):
var component = entity.get_component(components[i])
var query = queries[i]
if not ComponentQueryMatcher.matches_query(component, query):
matches = false
break
if matches:
filtered.append(entity)
else:
# Must match any query
for i in range(components.size()):
var component = entity.get_component(components[i])
var query = queries[i]
if component and ComponentQueryMatcher.matches_query(component, query):
filtered.append(entity)
break
return filtered
## Check if entity matches any of the queries
func _entity_matches_any_query(entity: Entity, components: Array, queries: Array) -> bool:
for i in range(components.size()):
var component = entity.get_component(components[i])
if component and ComponentQueryMatcher.matches_query(component, queries[i]):
return true
return false
## Filters a provided list of entities using the current query criteria.[br]
## Unlike execute(), this doesn't query the world but instead filters the provided entities.[br][br]
## [param entities] Array of entities to filter[br]
## [param returns] Array of entities that match the query criteria[br]
func matches(entities: Array) -> Array:
# if the query is empty all entities match
if is_empty():
return entities
var result = []
for entity in entities:
# If it's null skip it
if entity == null:
continue
assert(entity is Entity, "Must be an entity")
var matches = true
# Check all required components
for component in _all_components:
if not entity.has_component(component):
matches = false
break
# If still matching and we have any_components, check those
if matches and not _any_components.is_empty():
matches = false
for component in _any_components:
if entity.has_component(component):
matches = true
break
# Check excluded components
if matches:
for component in _exclude_components:
if entity.has_component(component):
matches = false
break
# Check required relationships
if matches and not _relationships.is_empty():
for relationship in _relationships:
if not entity.has_relationship(relationship):
matches = false
break
# Check excluded relationships
if matches and not _exclude_relationships.is_empty():
for relationship in _exclude_relationships:
if entity.has_relationship(relationship):
matches = false
break
if matches:
result.append(entity)
return result
func combine(other: QueryBuilder) -> QueryBuilder:
_all_components += other._all_components
_all_components_queries += other._all_components_queries
_any_components += other._any_components
_any_components_queries += other._any_components_queries
_exclude_components += other._exclude_components
_relationships += other._relationships
_exclude_relationships += other._exclude_relationships
_groups += other._groups
_exclude_groups += other._exclude_groups
_cache_valid = false
return self
func as_array() -> Array:
return [
_all_components,
_any_components,
_exclude_components,
_relationships,
_exclude_relationships
]
func is_empty() -> bool:
return (
_all_components.is_empty()
and _any_components.is_empty()
and _exclude_components.is_empty()
and _relationships.is_empty()
and _exclude_relationships.is_empty()
)
func _to_string() -> String:
var parts = []
if not _all_components.is_empty():
parts.append("with_all(" + _format_components(_all_components) + ")")
if not _any_components.is_empty():
parts.append("with_any(" + _format_components(_any_components) + ")")
if not _exclude_components.is_empty():
parts.append("with_none(" + _format_components(_exclude_components) + ")")
if not _relationships.is_empty():
parts.append("with_relationship(" + _format_relationships(_relationships) + ")")
if not _exclude_relationships.is_empty():
parts.append("without_relationship(" + _format_relationships(_exclude_relationships) + ")")
if not _groups.is_empty():
parts.append("with_group(" + str(_groups) + ")")
if not _exclude_groups.is_empty():
parts.append("without_group(" + str(_exclude_groups) + ")")
if _enabled_filter != null:
if _enabled_filter:
parts.append("enabled()")
else:
parts.append("disabled()")
if not _all_components_queries.is_empty():
parts.append("component_queries(" + _format_component_queries(_all_components_queries) + ")")
if not _any_components_queries.is_empty():
parts.append("any_component_queries(" + _format_component_queries(_any_components_queries) + ")")
if parts.is_empty():
return "ECS.world.query"
return "ECS.world.query." + ".".join(parts)
func _format_components(components: Array) -> String:
var names = []
for component in components:
if component is Script:
names.append(component.get_global_name())
else:
names.append(str(component))
return "[" + ", ".join(names) + "]"
func _format_relationships(relationships: Array) -> String:
var names = []
for relationship in relationships:
if relationship.has_method("to_string"):
names.append(relationship.to_string())
else:
names.append(str(relationship))
return "[" + ", ".join(names) + "]"
func _format_component_queries(queries: Array) -> String:
var formatted = []
for query in queries:
if query.has_method("to_string"):
formatted.append(query.to_string())
else:
formatted.append(str(query))
return "[" + ", ".join(formatted) + "]"
func compile(query: String) -> QueryBuilder:
return QueryBuilder.new(_world)
func invalidate_cache():
_cache_valid = false
_cache_key_valid = false
## Called when a relationship is added or removed (only for queries using relationships)
## Relationship changes do NOT affect structural cache key; queries only re-filter at execute time
func _on_relationship_changed(_entity: Entity, _relationship: Relationship):
_cache_valid = false # only result cache
## Get the cached query hash key, calculating it only once
## OPTIMIZATION: Avoids recalculating FNV-1a hash every frame in hot path queries
func get_cache_key() -> int:
# Structural cache key excludes relationships/groups (matches 6.0.0 behavior)
if not _cache_key_valid:
if _world:
_cache_key = QueryCacheKey.build(_all_components, _any_components, _exclude_components)
_cache_key_valid = true
else:
return -1
return _cache_key
## Get matching archetypes directly for column-based iteration
## OPTIMIZATION: Skip entity flattening, return archetypes directly for cache-friendly processing
## [br][br]
## [b]Example:[/b]
## [codeblock]
## func process_all(entities: Array, delta: float):
## for archetype in query().archetypes():
## var transforms = archetype.get_column(transform_path)
## for i in range(transforms.size()):
## # Process transform directly from packed array
## [/codeblock]
func archetypes() -> Array[Archetype]:
return _world.get_matching_archetypes(self )

View File

@@ -0,0 +1 @@
uid://dhyy752meflri

View File

@@ -0,0 +1,184 @@
## QueryCacheKey
## ------------------------------------------------------------------------------
## PURPOSE
## Build a structural query signature (cache key) that is:
## * Order-insensitive inside each domain (with_all / with_any / with_none)
## * Order-sensitive ACROSS domains (the same component in different domains => different key)
## * Extremely fast (single allocation + contiguous integer writes)
## * Stable for the lifetime of loaded component scripts (uses script.instance_id)
##
## WHY NOT JUST MERGE & SORT?
## A naive approach merges all component IDs + domain markers then sorts. That destroys
## domain boundaries and lets these collide:
## with_all([A,B]) vs with_any([A,B])
## After a full sort both become the same multiset {1,2,3,A,B}. We prevent that by
## emitting DOMAIN MARKER then COUNT then the sorted IDs for that domain preserving
## domain structure while still being permutation-insensitive within the domain.
##
## LAYOUT (integers in final array):
## [ 1, |count_all|, sorted(all_ids)...,
## 2, |count_any|, sorted(any_ids)...,
## 3, |count_none|, sorted(ex_ids)... ]
## 1/2/3 : domain sentinels (ALL / ANY / NONE)
## count_* : disambiguates empty vs non-empty ( [] vs [X] ) and prevents boundary ambiguity
## sorted(ids) : order-insensitivity; identical sets different order => same run of ints
##
## COMPLEXITY
## Sorting dominates: O(a log a + y log y + n log n). Typical domain sizes are tiny.
## Allocation: exactly one integer array sized to final layout.
## Hash: Godot's native Array.hash() (64-bit) very fast.
##
## COLLISION PROFILE
## 64-bit space (~1.84e19). Even 1,000,000 distinct structural queries => ~2.7e-8 collision probability.
## Practically zero for real ECS usage. See PERFORMANCE_CACHE_KEY_NOTE.md for math.
##
## EXTENSION POINTS
## * Add a leading VERSION marker if the format evolves.
## * Add extra domains (e.g. relationship structure) by appending new marker + count + IDs.
## * Add enabled-state separation by injecting a synthetic domain marker (kept separate currently).
##
## INLINE COMMENT LEGEND
## all_ids / any_ids / ex_ids : per-domain sorted component script instance IDs
## total : exact integer count used for one-shot allocation (prevents incremental reallocation)
## layout[i] = marker/count/id : sequential write building final signature array
##
class_name QueryCacheKey
extends RefCounted
static func build(
all_components: Array,
any_components: Array,
exclude_components: Array,
relationships: Array = [],
exclude_relationships: Array = [],
groups: Array = [],
exclude_groups: Array = []
) -> int:
# Collect & sort per-domain IDs (order-insensitive inside each domain)
var all_ids: Array[int] = []
for c in all_components: all_ids.append(c.get_instance_id())
all_ids.sort()
var any_ids: Array[int] = []
for c in any_components: any_ids.append(c.get_instance_id())
any_ids.sort()
var ex_ids: Array[int] = []
for c in exclude_components: ex_ids.append(c.get_instance_id())
ex_ids.sort()
# Collect & sort relationship IDs
var rel_ids: Array[int] = []
for rel in relationships:
# Use Script instance ID for type matching (consistent with component queries)
# Relationship.new(C_TestB.new()) creates component instance, we want the Script's ID
if rel.relation:
rel_ids.append(rel.relation.get_script().get_instance_id())
else:
rel_ids.append(0)
# Handle target - use Script instance ID for Components (type matching)
if rel.target is Component:
# Component target: use Script instance ID for type matching
rel_ids.append(rel.target.get_script().get_instance_id())
elif rel.target is Entity:
# Entity target: use entity instance ID (entities are specific instances)
rel_ids.append(rel.target.get_instance_id())
elif rel.target is Script:
# Archetype target: use Script instance ID
rel_ids.append(rel.target.get_instance_id())
elif rel.target != null:
# Other types: use generic hash
rel_ids.append(rel.target.hash())
else:
rel_ids.append(0) # null target
rel_ids.sort()
var ex_rel_ids: Array[int] = []
for rel in exclude_relationships:
# Use Script instance ID for type matching (consistent with component queries)
if rel.relation:
ex_rel_ids.append(rel.relation.get_script().get_instance_id())
else:
ex_rel_ids.append(0)
# Handle target - use Script instance ID for Components (type matching)
if rel.target is Component:
ex_rel_ids.append(rel.target.get_script().get_instance_id())
elif rel.target is Entity:
ex_rel_ids.append(rel.target.get_instance_id())
elif rel.target is Script:
ex_rel_ids.append(rel.target.get_instance_id())
elif rel.target != null:
ex_rel_ids.append(rel.target.hash())
else:
ex_rel_ids.append(0)
ex_rel_ids.sort()
# Collect & sort group name hashes
var group_ids: Array[int] = []
for group_name in groups:
group_ids.append(group_name.hash())
group_ids.sort()
var ex_group_ids: Array[int] = []
for group_name in exclude_groups:
ex_group_ids.append(group_name.hash())
ex_group_ids.sort()
# Compute exact total length: (marker + count) per domain + IDs
var total = 1 + 1 + all_ids.size() # ALL marker + count + ids
total += 1 + 1 + any_ids.size() # ANY marker + count + ids
total += 1 + 1 + ex_ids.size() # NONE marker + count + ids
total += 1 + 1 + rel_ids.size() # RELATIONSHIPS marker + count + ids
total += 1 + 1 + ex_rel_ids.size() # EXCLUDE_RELATIONSHIPS marker + count + ids
total += 1 + 1 + group_ids.size() # GROUPS marker + count + ids
total += 1 + 1 + ex_group_ids.size() # EXCLUDE_GROUPS marker + count + ids
# Single allocation for final signature layout
var layout: Array[int] = []
layout.resize(total)
var i := 0
# --- Domain: ALL ---
layout[i] = 1; i += 1 # Marker for ALL domain
layout[i] = all_ids.size(); i += 1 # Count (disambiguates empty vs non-empty)
for id in all_ids:
layout[i] = id; i += 1 # Sorted ALL component IDs
# --- Domain: ANY ---
layout[i] = 2; i += 1 # Marker for ANY domain
layout[i] = any_ids.size(); i += 1 # Count
for id in any_ids:
layout[i] = id; i += 1 # Sorted ANY component IDs
# --- Domain: NONE (exclude) ---
layout[i] = 3; i += 1 # Marker for NONE domain
layout[i] = ex_ids.size(); i += 1 # Count
for id in ex_ids:
layout[i] = id; i += 1 # Sorted EXCLUDE component IDs
# --- Domain: RELATIONSHIPS ---
layout[i] = 4; i += 1 # Marker for RELATIONSHIPS domain
layout[i] = rel_ids.size(); i += 1 # Count
for id in rel_ids:
layout[i] = id; i += 1 # Sorted relationship IDs
# --- Domain: EXCLUDE_RELATIONSHIPS ---
layout[i] = 5; i += 1 # Marker for EXCLUDE_RELATIONSHIPS domain
layout[i] = ex_rel_ids.size(); i += 1 # Count
for id in ex_rel_ids:
layout[i] = id; i += 1 # Sorted exclude relationship IDs
# --- Domain: GROUPS ---
layout[i] = 6; i += 1 # Marker for GROUPS domain
layout[i] = group_ids.size(); i += 1 # Count
for id in group_ids:
layout[i] = id; i += 1 # Sorted group name hashes
# --- Domain: EXCLUDE_GROUPS ---
layout[i] = 7; i += 1 # Marker for EXCLUDE_GROUPS domain
layout[i] = ex_group_ids.size(); i += 1 # Count
for id in ex_group_ids:
layout[i] = id; i += 1 # Sorted exclude group name hashes
# Hash the structural layout -> 64-bit key
return layout.hash()

View File

@@ -0,0 +1 @@
uid://rjjelegj3npr

View File

@@ -0,0 +1,294 @@
## Relationship
## Represents a relationship between entities in the ECS framework.
## A relationship consists of a [Component] relation and a target, which can be an [Entity], a [Component], or an archetype.
##
## Relationships are used to link entities together, allowing for complex queries and interactions.
## They enable entities to have dynamic associations that can be queried and manipulated at runtime.
## The powerful relationship system supports component-based targets for hierarchical type systems.
##
## [b]Relationship Types:[/b]
## [br]• [b]Entity Relationships:[/b] Link entities to other entities
## [br]• [b]Component Relationships:[/b] Link entities to component instances for type hierarchies
## [br]• [b]Archetype Relationships:[/b] Link entities to component/entity classes
##
## [b]Query Features:[/b]
## [br]• [b]Type Matching:[/b] Find entities by relationship component type (default)
## [br]• [b]Query Matching:[/b] Use dictionaries to match by specific property criteria
## [br]• [b]Wildcard Queries:[/b] Use [code]null[/code] targets to find any relationship of a type
##
## [b]Basic Entity Relationship Example:[/b]
## [codeblock]
## # Create a 'likes' relationship where e_bob likes e_alice
## var likes_relationship = Relationship.new(C_Likes.new(), e_alice)
## e_bob.add_relationship(likes_relationship)
##
## # Check if e_bob has a 'likes' relationship with e_alice
## if e_bob.has_relationship(Relationship.new(C_Likes.new(), e_alice)):
## print("Bob likes Alice!")
## [/codeblock]
##
## [b]Component-Based Relationship Example:[/b]
## [codeblock]
## # Create a damage type hierarchy using components as targets
## var fire_damage = C_FireDamage.new(50)
## var poison_damage = C_PoisonDamage.new(25)
##
## # Entity has different types of damage
## entity.add_relationship(Relationship.new(C_Damaged.new(), fire_damage))
## entity.add_relationship(Relationship.new(C_Damaged.new(), poison_damage))
##
## # Query for entities with any damage type (wildcard)
## var damaged_entities = ECS.world.query.with_relationship([
## Relationship.new(C_Damaged.new(), null)
## ]).execute()
##
## # Query for entities with fire damage amount >= 50 using component query
## var fire_damaged = ECS.world.query.with_relationship([
## Relationship.new(C_Damaged.new(), {C_FireDamage: {'amount': {"_gte": 50}}})
## ]).execute()
##
## # Check if entity has any fire damage (type matching)
## var has_fire_damage = entity.has_relationship(
## Relationship.new(C_Damaged.new(), C_FireDamage.new())
## )
## [/codeblock]
##
## [b]Component Query Examples:[/b]
## [codeblock]
## # Query relation by property value
## var entities = ECS.world.query.with_relationship([
## Relationship.new({C_Eats: {'value': {"_eq": 8}}}, e_apple)
## ]).execute()
##
## # Query target by property value
## var entities = ECS.world.query.with_relationship([
## Relationship.new(C_Damage.new(), {C_Health: {'amount': {"_gte": 50}}})
## ]).execute()
##
## # Query both relation AND target
## var entities = ECS.world.query.with_relationship([
## Relationship.new(
## {C_Buff: {'duration': {"_gt": 10}}},
## {C_Player: {'level': {"_gte": 5}}}
## )
## ]).execute()
## [/codeblock]
class_name Relationship
extends Resource
## The relation component of the relationship.
## This defines the type of relationship and can contain additional data.
var relation
## The target of the relationship.
## This can be an [Entity], a [Component], an archetype, or null.
var target
## The source of the relationship.
var source
## Component query for relation matching (if relation was created from dictionary)
var relation_query: Dictionary = {}
## Component query for target matching (if target was created from dictionary)
var target_query: Dictionary = {}
## Flag to track if this relationship was created from a component query dictionary (private - used for validation)
var _is_query_relationship: bool = false
func _init(_relation = null, _target = null):
# Handle component queries (dictionaries) for relation
if _relation is Dictionary:
_is_query_relationship = true
# Extract component type and query from dictionary
for component_type in _relation:
var query = _relation[component_type]
# Store the query and create component instance
relation_query = query
_relation = component_type.new()
break
# Handle component queries (dictionaries) for target
if _target is Dictionary:
_is_query_relationship = true
# Extract component type and query from dictionary
for component_type in _target:
var query = _target[component_type]
# Store the query and create component instance
target_query = query
_target = component_type.new()
break
# Assert for class reference vs instance for relation (skip for dictionaries)
if not _relation is Dictionary:
assert(
not (_relation != null and (_relation is GDScript or _relation is Script)),
"Relation must be an instance of Component (did you forget to call .new()?)"
)
# Assert for relation type
assert(
_relation == null or _relation is Component, "Relation must be null or a Component instance"
)
# Assert for class reference vs instance for target (skip for dictionaries)
if not _target is Dictionary:
assert(
not (_target != null and _target is GDScript and _target is Component),
"Target must be an instance of Component (did you forget to call .new()?)"
)
# Assert for target type
assert(
_target == null or _target is Entity or _target is Script or _target is Component,
"Target must be null, an Entity instance, a Script archetype, or a Component instance"
)
relation = _relation
target = _target
## Checks if this relationship matches another relationship.
## [param other]: The [Relationship] to compare with.
## [return]: `true` if both the relation and target match, `false` otherwise.
##
## [b]Matching Modes:[/b]
## [br]• [b]Type Matching:[/b] Components match by type (default behavior)
## [br]• [b]Query Matching:[/b] If component query dictionary used, evaluates property criteria
## [br]• [b]Wildcard Matching:[/b] [code]null[/code] relations or targets act as wildcards and match anything
func matches(other: Relationship) -> bool:
var rel_match = false
var target_match = false
# Compare relations
if other.relation == null or relation == null:
# If either relation is null, consider it a match (wildcard)
rel_match = true
else:
# Check if other relation has component query (query relationships)
if not other.relation_query.is_empty():
# Other has component query, check if this relation matches that query
if relation.get_script() == other.relation.get_script():
rel_match = ComponentQueryMatcher.matches_query(relation, other.relation_query)
else:
rel_match = false
# Check if this relation has component query (this is query relationship)
elif not relation_query.is_empty():
# This has component query, check if other relation matches this query
if relation.get_script() == other.relation.get_script():
rel_match = ComponentQueryMatcher.matches_query(other.relation, relation_query)
else:
rel_match = false
else:
# Standard type matching by script type
rel_match = relation.get_script() == other.relation.get_script()
# Compare targets
if other.target == null or target == null:
# If either target is null, consider it a match (wildcard)
target_match = true
else:
if target == other.target:
target_match = true
elif target is Entity and other.target is Script:
# target is an entity instance, other.target is an archetype
target_match = target.get_script() == other.target
elif target is Script and other.target is Entity:
# target is an archetype, other.target is an entity instance
target_match = other.target.get_script() == target
elif target is Entity and other.target is Entity:
# Both targets are entities; compare references directly
target_match = target == other.target
elif target is Script and other.target is Script:
# Both targets are archetypes; compare directly
target_match = target == other.target
elif target is Component and other.target is Component:
# Both targets are components; check for query or type matching
# Check if other target has component query
if not other.target_query.is_empty():
# Other has component query, check if this target matches that query
if target.get_script() == other.target.get_script():
target_match = ComponentQueryMatcher.matches_query(target, other.target_query)
else:
target_match = false
# Check if this target has component query
elif not target_query.is_empty():
# This has component query, check if other target matches this query
if target.get_script() == other.target.get_script():
target_match = ComponentQueryMatcher.matches_query(other.target, target_query)
else:
target_match = false
else:
# Standard type matching by script type
target_match = target.get_script() == other.target.get_script()
elif target is Component and other.target is Script:
# target is component instance, other.target is component archetype
target_match = target.get_script() == other.target
elif target is Script and other.target is Component:
# target is component archetype, other.target is component instance
target_match = other.target.get_script() == target
else:
# Unable to compare targets
target_match = false
return rel_match and target_match
func valid() -> bool:
# make sure the target is valid or null
var target_valid = false
if target == null:
target_valid = true
elif target is Entity:
target_valid = is_instance_valid(target)
elif target is Component:
# Components are Resources, so they're always valid once created
target_valid = true
elif target is Script:
# Script archetypes are always valid
target_valid = true
else:
target_valid = false
# Ensure the source is a valid Entity instance; it cannot be null
var source_valid = is_instance_valid(source)
return target_valid and source_valid
## Provides a consistent string representation for cache keys and debugging.
## Two relationships with the same relation type and target should produce identical strings.
func _to_string() -> String:
var parts = []
# Format relation component
if relation == null:
parts.append("null")
elif not relation_query.is_empty():
# This is a query relationship - include the query criteria
parts.append(relation.get_script().resource_path + str(relation_query))
else:
# Standard relation - just the type
parts.append(relation.get_script().resource_path)
# Format target
if target == null:
parts.append("null")
elif target is Entity:
# Use instance_id for stability - entity ID may not be set yet
parts.append("Entity#" + str(target.get_instance_id()))
elif target is Component:
if not target_query.is_empty():
# Component with query
parts.append(target.get_script().resource_path + str(target_query))
else:
# Type matching - use Script instance ID (consistent with query caching)
parts.append(target.get_script().resource_path + "#" + str(target.get_script().get_instance_id()))
elif target is Script:
# Archetype target
parts.append("Archetype:" + target.resource_path)
else:
parts.append(str(target))
return "Relationship(" + parts[0] + " -> " + parts[1] + ")"

View File

@@ -0,0 +1 @@
uid://bsyujqr14xkrv

459
addons/gecs/ecs/system.gd Normal file
View File

@@ -0,0 +1,459 @@
## System[br]
##
## The base class for all systems within the ECS framework.[br]
##
## Systems contain the core logic and behavior, processing [Entity]s that have specific [Component]s.[br]
## Each system overrides the [method System.query] and returns a query using [code]q[/code] or [code]ECS.world.query[/code][br]
## to define the required [Component]s for it to process [Entity]s and implements the [method System.process] method.[br][br]
## [b]Example (Simple):[/b]
##[codeblock]
## class_name MovementSystem
## extends System
##
## func query():
## return q.with_all([Transform, Velocity])
##
## func process(entities: Array[Entity], components: Array, delta: float) -> void:
## # Per-entity processing (simple but slower)
## for entity in entities:
## var transform = entity.get_component(Transform)
## var velocity = entity.get_component(Velocity)
## transform.position += velocity.direction * velocity.speed * delta
##[/codeblock]
## [b]Example (Optimized with iterate()):[/b]
##[codeblock]
## func query():
## return q.with_all([Transform, Velocity]).iterate([Transform, Velocity])
##
## func process(entities: Array[Entity], components: Array, delta: float) -> void:
## # Batch processing with component arrays (faster)
## var transforms = components[0]
## var velocities = components[1]
## for i in entities.size():
## transforms[i].position += velocities[i].velocity * delta
##[/codeblock]
@icon("res://addons/gecs/assets/system.svg")
class_name System
extends Node
#region Enums
## These control when the system should run in relation to other systems.
enum Runs {
## This system should run before all the systems defined in the array ex: [TransformSystem] means it will run before the [TransformSystem] system runs
Before,
## This system should run after all the systems defined in the array ex: [TransformSystem] means it will run after the [TransformSystem] system runs
After,
}
#endregion Enums
#region Exported Variables
## What group this system belongs to. Systems can be organized and run by group
@export var group: String = ""
## Determines whether the system should run even when there are no [Entity]s to process.
@export var process_empty := false
## Is this system active. (Will be skipped if false)
@export var active := true
@export_group("Parallel Processing")
## Enable parallel processing for this system's entities (No access to scene tree in process method)
@export var parallel_processing := false
## Minimum entities required to use parallel processing (performance threshold)
@export var parallel_threshold := 50
#endregion Exported Variables
#region Public Variables
## Is this system paused. (Will be skipped if true)
var paused := false
## Logger for system debugging and tracing
var systemLogger = GECSLogger.new().domain("System")
## Data for debugger and profiling - you can add ANY arbitrary data here when ECS.debug is enabled
## All keys and values will automatically appear in the GECS debugger tab
## Example:
## if ECS.debug:
## lastRunData["my_counter"] = 123
## lastRunData["player_stats"] = {"health": 100, "mana": 50}
## lastRunData["events"] = ["event1", "event2"]
var lastRunData := {}
## Reference to the world this system belongs to (set by World.add_system)
var _world: World = null
## Convenience property for accessing query builder (returns _world.query or ECS.world.query)
var q: QueryBuilder:
get:
return _world.query if _world else ECS.world.query
## Cached query to avoid recreating it every frame (lazily initialized)
var _query_cache: QueryBuilder = null
## Cached component paths for iterate() fast path (6.0.0 style)
var _component_paths: Array[String] = []
## Cached subsystems array (6.0.0 style)
var _subsystems_cache: Array = []
#endregion Public Variables
#region Public Methods
## Override this method to define the [System]s that this system depends on.[br]
## If not overridden the system will run based on the order of the systems in the [World][br]
## and the order of the systems in the [World] will be based on the order they were added to the [World].[br]
func deps() -> Dictionary[int, Array]:
return {
Runs.After: [],
Runs.Before: [],
}
## Override this method and return a [QueryBuilder] to define the required [Component]s for the system.[br]
## If not overridden, the system will run on every update with no entities.[br][br]
## You can use [code]q[/code] or [code]ECS.world.query[/code] - both are equivalent.
func query() -> QueryBuilder:
process_empty = true
return _world.query if _world else ECS.world.query
## Override this method to define any sub-systems that should be processed by this system.[br]
## Each subsystem is defined as [QueryBuilder, Callable][br]
## Return empty array if not using subsystems (base implementation)[br][br]
## You can use [code]q[/code] or [code]ECS.world.query[/code] in subsystems - both work.[br][br]
## [b]Example:[/b]
## [codeblock]
## func sub_systems() -> Array[Array]:
## return [
## [q.with_all([C_Velocity]).iterate([C_Velocity]), process_velocity],
## [q.with_all([C_Health]), process_health]
## ]
##
## func process_velocity(entities: Array[Entity], components: Array, delta: float):
## var velocities = components[0]
## for i in entities.size():
## entities[i].position += velocities[i].velocity * delta
##
## func process_health(entities: Array[Entity], components: Array, delta: float):
## for entity in entities:
## var health = entity.get_component(C_Health)
## health.regenerate(delta)
## [/codeblock]
func sub_systems() -> Array[Array]:
return [] # Base returns empty - overridden systems return populated Array[Array]
## Runs once after the system has been added to the [World] to setup anything on the system one time[br]
func setup():
pass # Override in subclasses if needed
## The main processing function for the system.[br]
## Override this method to define your system's behavior.[br]
## [param entities] Array of entities matching the system's query[br]
## [param components] Array of component arrays (in order from iterate()), or empty if no iterate() call[br]
## [param delta] The time elapsed since the last frame[br][br]
## [b]Simple approach:[/b] Loop through entities and use get_component()[br]
## [b]Fast approach:[/b] Use iterate() in query and access component arrays directly
func process(entities: Array[Entity], components: Array, delta: float) -> void:
pass # Override in subclasses - base implementation does nothing
#endregion Public Methods
#region Private Methods
## INTERNAL: Called by World.add_system() to initialize the system
## DO NOT CALL OR OVERRIDE - this is framework code
func _internal_setup():
# Call user setup
setup()
## Process entities in parallel using WorkerThreadPool
## Splits entities into batches and processes them concurrently
func _process_parallel(entities: Array[Entity], components: Array, delta: float) -> void:
if entities.is_empty():
return
# Use OS thread count as fallback since WorkerThreadPool.get_thread_count() doesn't exist
var worker_count = OS.get_processor_count()
var batch_size = max(1, entities.size() / worker_count)
var tasks = []
# Submit tasks for each batch
for batch_start in range(0, entities.size(), batch_size):
var batch_end = min(batch_start + batch_size, entities.size())
# Slice entities and components for this batch
var batch_entities = entities.slice(batch_start, batch_end)
var batch_components = []
for comp_array in components:
batch_components.append(comp_array.slice(batch_start, batch_end))
var task_id = WorkerThreadPool.add_task(_process_batch_callable.bind(batch_entities, batch_components, delta))
tasks.append(task_id)
# Wait for all tasks to complete
for task_id in tasks:
WorkerThreadPool.wait_for_task_completion(task_id)
## Process a batch of entities - called by worker threads
func _process_batch_callable(entities: Array[Entity], components: Array, delta: float) -> void:
process(entities, components, delta)
## Called by World.process() each frame - main entry point for system execution
## [param delta] The time elapsed since the last frame
func _handle(delta: float) -> void:
if not active or paused:
return
var start_time_usec := 0
if ECS.debug:
start_time_usec = Time.get_ticks_usec()
lastRunData = {
"system_name": get_script().resource_path.get_file().get_basename(),
"frame_delta": delta,
}
var subs = sub_systems()
if not subs.is_empty():
_run_subsystems(delta)
else:
_run_process(delta)
if ECS.debug:
var end_time_usec = Time.get_ticks_usec()
lastRunData["execution_time_ms"] = (end_time_usec - start_time_usec) / 1000.0
## UNIFIED execution function for both main systems and subsystems
## This ensures consistent behavior and entity processing logic
## Subsystems and main systems execute IDENTICALLY - no special behavior
## [param query_builder] The query to execute
## [param callable] The function to call with matched entities
## [param delta] Time delta
## [param subsystem_index] Index for debug tracking (-1 for main system)
func _run_subsystems(delta: float) -> void:
if _subsystems_cache.is_empty():
_subsystems_cache = sub_systems()
var subsystem_index := 0
for subsystem_tuple in _subsystems_cache:
var subsystem_query := subsystem_tuple[0] as QueryBuilder
var subsystem_callable := subsystem_tuple[1] as Callable
var uses_non_structural := _query_has_non_structural_filters(subsystem_query)
var iterate_comps = subsystem_query._iterate_components
if uses_non_structural:
# Gather ALL structural entities first then filter once (avoid per-archetype filtering churn)
var all_entities: Array[Entity] = []
for arch in subsystem_query.archetypes():
if not arch.entities.is_empty():
all_entities.append_array(arch.entities) # no snapshot to allow mid-frame changes visible to later subsystems
var filtered = _filter_entities_global(subsystem_query, all_entities)
if filtered.is_empty():
if ECS.debug:
lastRunData[subsystem_index] = {"subsystem_index": subsystem_index, "entity_count": 0, "fallback_execute": true}
subsystem_index += 1
continue
var components := []
if not iterate_comps.is_empty():
for comp_type in iterate_comps:
components.append(_build_component_column_from_entities(filtered, comp_type))
subsystem_callable.call(filtered, components, delta)
if ECS.debug:
lastRunData[subsystem_index] = {"subsystem_index": subsystem_index, "entity_count": filtered.size(), "fallback_execute": true}
else:
# Structural fast path archetype iteration
var total_entity_count := 0
for archetype in subsystem_query.archetypes():
if archetype.entities.is_empty():
continue
# Snapshot to avoid losing entities during add/remove component archetype moves mid-iteration
var arch_entities = archetype.entities.duplicate()
total_entity_count += arch_entities.size()
var components = []
if not iterate_comps.is_empty():
for comp_type in iterate_comps:
var comp_path = comp_type.resource_path if comp_type is Script else comp_type.get_script().resource_path
components.append(archetype.get_column(comp_path))
subsystem_callable.call(arch_entities, components, delta)
if ECS.debug:
lastRunData[subsystem_index] = {"subsystem_index": subsystem_index, "entity_count": total_entity_count, "fallback_execute": false}
subsystem_index += 1
func _run_process(delta: float) -> void:
if not _query_cache:
_query_cache = query()
if _component_paths.is_empty():
var iterate_comps = _query_cache._iterate_components
for comp_type in iterate_comps:
var comp_path = comp_type.resource_path if comp_type is Script else comp_type.get_script().resource_path
_component_paths.append(comp_path)
var uses_non_structural := _query_has_non_structural_filters(_query_cache)
var iterate_comps = _query_cache._iterate_components
if uses_non_structural:
# Gather all entities across structural archetypes and then filter once
var all_entities: Array[Entity] = []
for arch in _query_cache.archetypes():
if not arch.entities.is_empty():
all_entities.append_array(arch.entities)
if all_entities.is_empty():
if process_empty:
process([], [], delta)
return
var filtered = _filter_entities_global(_query_cache, all_entities)
if filtered.is_empty():
if process_empty:
process([], [], delta)
return
var components := []
if not iterate_comps.is_empty():
for comp_type in iterate_comps:
components.append(_build_component_column_from_entities(filtered, comp_type))
if parallel_processing and filtered.size() >= parallel_threshold:
_process_parallel(filtered, components, delta)
else:
process(filtered, components, delta)
if ECS.debug:
lastRunData["entity_count"] = filtered.size()
lastRunData["archetype_count"
] = _query_cache.archetypes().size()
lastRunData["fallback_execute"] = true
lastRunData["parallel"] = parallel_processing and filtered.size() >= parallel_threshold
return
# Structural fast path
var matching_archetypes = _query_cache.archetypes()
var has_entities = false
var total_entity_count := 0
for arch in matching_archetypes:
if not arch.entities.is_empty():
has_entities = true
total_entity_count += arch.entities.size()
if ECS.debug:
lastRunData["entity_count"] = total_entity_count
lastRunData["archetype_count"] = matching_archetypes.size()
lastRunData["fallback_execute"] = false
if not has_entities and not process_empty:
return
if not has_entities and process_empty:
process([], [], delta)
return
for arch in matching_archetypes:
var arch_entities = arch.entities
if arch_entities.is_empty():
continue
# Snapshot structural entities to avoid mutation skipping during component add/remove
var snapshot_entities = arch_entities.duplicate()
var components = []
if not iterate_comps.is_empty():
for comp_path in _component_paths:
components.append(arch.get_column(comp_path))
if parallel_processing and snapshot_entities.size() >= parallel_threshold:
if ECS.debug:
lastRunData["parallel"] = true
lastRunData["threshold"] = parallel_threshold
_process_parallel(snapshot_entities, components, delta)
else:
if ECS.debug:
lastRunData["parallel"] = false
process(snapshot_entities, components, delta)
## Determine if a query includes non-structural filters requiring execute() fallback
func _query_has_non_structural_filters(qb: QueryBuilder) -> bool:
if not qb._relationships.is_empty():
return true
if not qb._exclude_relationships.is_empty():
return true
if not qb._groups.is_empty():
return true
if not qb._exclude_groups.is_empty():
return true
# Component property queries (ensure actual queries, not placeholders)
if not qb._all_components_queries.is_empty():
for query in qb._all_components_queries:
if not query.is_empty():
return true
if not qb._any_components_queries.is_empty():
for query in qb._any_components_queries:
if not query.is_empty():
return true
return false
## Build component arrays for iterate() when falling back to execute() result (no archetype columns)
func _build_component_column_from_entities(entities: Array[Entity], comp_type) -> Array:
var out := []
for e in entities:
if e == null:
out.append(null)
continue
var comp = e.get_component(comp_type)
out.append(comp)
return out
## Filter entities in an archetype for non-structural query criteria (relationships/groups/property queries)
## Filter a flat entity array for non-structural criteria
func _filter_entities_global(qb: QueryBuilder, entities: Array[Entity]) -> Array[Entity]:
var result: Array[Entity] = []
for e in entities:
if e == null:
continue
var include := true
for rel in qb._relationships:
if not e.has_relationship(rel):
include = false; break
if include:
for ex_rel in qb._exclude_relationships:
if e.has_relationship(ex_rel):
include = false; break
if include and not qb._groups.is_empty():
for g in qb._groups:
if not e.is_in_group(g):
include = false; break
if include and not qb._exclude_groups.is_empty():
for g in qb._exclude_groups:
if e.is_in_group(g):
include = false; break
if include and not qb._all_components_queries.is_empty():
for i in range(qb._all_components.size()):
if i >= qb._all_components_queries.size():
break
var comp_type = qb._all_components[i]
var query = qb._all_components_queries[i]
if not query.is_empty():
var comp = e.get_component(comp_type)
if comp == null or not ComponentQueryMatcher.matches_query(comp, query):
include = false; break
if include and not qb._any_components_queries.is_empty():
var any_match := qb._any_components_queries.is_empty()
for i in range(qb._any_components.size()):
if i >= qb._any_components_queries.size():
break
var comp_type = qb._any_components[i]
var query = qb._any_components_queries[i]
if not query.is_empty():
var comp = e.get_component(comp_type)
if comp and ComponentQueryMatcher.matches_query(comp, query):
any_match = true; break
if not any_match and not qb._any_components.is_empty():
include = false
if include:
result.append(e)
return result
## Debug helper - updates lastRunData (compiled out in production)
func _update_debug_data(callable: Callable = func(): return {}) -> bool:
if ECS.debug:
var data = callable.call()
if data:
lastRunData.assign(data)
return true
## Debug helper - sets lastRunData (compiled out in production)
func _debug_data(_lrd: Dictionary, callable: Callable = func(): return {}) -> bool:
if ECS.debug:
lastRunData = _lrd
lastRunData.assign(callable.call())
return true
#endregion Private Methods

View File

@@ -0,0 +1 @@
uid://dyrahdwwpjpri

1341
addons/gecs/ecs/world.gd Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
uid://cdu5tlyk72uu4