460 lines
18 KiB
GDScript
460 lines
18 KiB
GDScript
## 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
|