basic ECS spawner
This commit is contained in:
299
addons/gecs/ecs/archetype.gd
Normal file
299
addons/gecs/ecs/archetype.gd
Normal 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
|
||||
1
addons/gecs/ecs/archetype.gd.uid
Normal file
1
addons/gecs/ecs/archetype.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://vrhpkju2aq7q
|
||||
48
addons/gecs/ecs/component.gd
Normal file
48
addons/gecs/ecs/component.gd
Normal 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
|
||||
1
addons/gecs/ecs/component.gd.uid
Normal file
1
addons/gecs/ecs/component.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://b6k13gc2m4e5s
|
||||
102
addons/gecs/ecs/ecs.gd
Normal file
102
addons/gecs/ecs/ecs.gd
Normal 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)
|
||||
1
addons/gecs/ecs/ecs.gd.uid
Normal file
1
addons/gecs/ecs/ecs.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dfqwl5njvdnmq
|
||||
509
addons/gecs/ecs/entity.gd
Normal file
509
addons/gecs/ecs/entity.gd
Normal 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
|
||||
1
addons/gecs/ecs/entity.gd.uid
Normal file
1
addons/gecs/ecs/entity.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cl6glf45pcrns
|
||||
74
addons/gecs/ecs/observer.gd
Normal file
74
addons/gecs/ecs/observer.gd
Normal 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
|
||||
1
addons/gecs/ecs/observer.gd.uid
Normal file
1
addons/gecs/ecs/observer.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dd3umv3f8qyx5
|
||||
571
addons/gecs/ecs/query_builder.gd
Normal file
571
addons/gecs/ecs/query_builder.gd
Normal 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 )
|
||||
1
addons/gecs/ecs/query_builder.gd.uid
Normal file
1
addons/gecs/ecs/query_builder.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dhyy752meflri
|
||||
184
addons/gecs/ecs/query_cache_key.gd
Normal file
184
addons/gecs/ecs/query_cache_key.gd
Normal 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()
|
||||
1
addons/gecs/ecs/query_cache_key.gd.uid
Normal file
1
addons/gecs/ecs/query_cache_key.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://rjjelegj3npr
|
||||
294
addons/gecs/ecs/relationship.gd
Normal file
294
addons/gecs/ecs/relationship.gd
Normal 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] + ")"
|
||||
1
addons/gecs/ecs/relationship.gd.uid
Normal file
1
addons/gecs/ecs/relationship.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bsyujqr14xkrv
|
||||
459
addons/gecs/ecs/system.gd
Normal file
459
addons/gecs/ecs/system.gd
Normal 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
|
||||
1
addons/gecs/ecs/system.gd.uid
Normal file
1
addons/gecs/ecs/system.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dyrahdwwpjpri
|
||||
1341
addons/gecs/ecs/world.gd
Normal file
1341
addons/gecs/ecs/world.gd
Normal file
File diff suppressed because it is too large
Load Diff
1
addons/gecs/ecs/world.gd.uid
Normal file
1
addons/gecs/ecs/world.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cdu5tlyk72uu4
|
||||
Reference in New Issue
Block a user