Files
godot-shader-experiments/addons/gecs/ecs/query_builder.gd
2026-01-15 15:27:48 +01:00

572 lines
19 KiB
GDScript

## 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 )