Files
2026-01-15 15:27:48 +01:00

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