1342 lines
52 KiB
GDScript
1342 lines
52 KiB
GDScript
## World
|
|
##
|
|
## Represents the game world in the [_ECS] framework, managing all [Entity]s and [System]s.
|
|
##
|
|
## The World class handles the addition and removal of [Entity]s and [System]s, and orchestrates the processing of [Entity]s through [System]s each frame.
|
|
## The World class also maintains an index mapping of components to entities for efficient querying.
|
|
@icon("res://addons/gecs/assets/world.svg")
|
|
class_name World
|
|
extends Node
|
|
|
|
#region Signals
|
|
## Emitted when an entity is added
|
|
signal entity_added(entity: Entity)
|
|
signal entity_enabled(entity: Entity)
|
|
## Emitted when an entity is removed
|
|
signal entity_removed(entity: Entity)
|
|
signal entity_disabled(entity: Entity)
|
|
## Emitted when a system is added
|
|
signal system_added(system: System)
|
|
## Emitted when a system is removed
|
|
signal system_removed(system: System)
|
|
## Emitted when a component is added to an entity
|
|
signal component_added(entity: Entity, component: Variant)
|
|
## Emitted when a component is removed from an entity
|
|
signal component_removed(entity: Entity, component: Variant)
|
|
## Emitted when a component property changes on an entity
|
|
signal component_changed(
|
|
entity: Entity, component: Variant, property: String, new_value: Variant, old_value: Variant
|
|
)
|
|
## Emitted when a relationship is added to an entity
|
|
signal relationship_added(entity: Entity, relationship: Relationship)
|
|
## Emitted when a relationship is removed from an entity
|
|
signal relationship_removed(entity: Entity, relationship: Relationship)
|
|
## Emitted when the queries are invalidated because of a component change
|
|
signal cache_invalidated
|
|
|
|
#endregion Signals
|
|
|
|
#region Exported Variables
|
|
## Where are all the [Entity] nodes placed in the scene tree?
|
|
@export var entity_nodes_root: NodePath
|
|
## Where are all the [System] nodes placed in the scene tree?
|
|
@export var system_nodes_root: NodePath
|
|
## Default serialization config for all entities in this world
|
|
@export var default_serialize_config: GECSSerializeConfig
|
|
|
|
#endregion Exported Variables
|
|
|
|
#region Public Variables
|
|
## All the [Entity]s in the world.
|
|
var entities: Array[Entity] = []
|
|
## All the [Observer]s in the world.
|
|
var observers: Array[Observer] = []
|
|
## All the [System]s by group Dictionary[String, Array[System]]
|
|
var systems_by_group: Dictionary[String, Array] = {}
|
|
## All the [System]s in the world flattened into a single array
|
|
var systems: Array[System]:
|
|
get:
|
|
var all_systems: Array[System] = []
|
|
for group in systems_by_group.keys():
|
|
all_systems.append_array(systems_by_group[group])
|
|
return all_systems
|
|
## ID to [Entity] registry - Prevents duplicate IDs and enables fast ID lookups and singleton behavior
|
|
var entity_id_registry: Dictionary = {} # String (id) -> Entity
|
|
## ARCHETYPE STORAGE - Entity storage by component signature for O(1) queries
|
|
## Maps archetype signature (FNV-1a hash) -> Archetype instance
|
|
var archetypes: Dictionary = {} # int -> Archetype
|
|
## Fast lookup: Entity -> its current Archetype
|
|
var entity_to_archetype: Dictionary = {} # Entity -> Archetype
|
|
## The [QueryBuilder] instance for this world used to build and execute queries.
|
|
## Anytime we request a query we want to connect the cache invalidated signal to the query
|
|
## so that all queries are invalidated anytime we emit cache_invalidated.
|
|
var query: QueryBuilder:
|
|
get:
|
|
var q: QueryBuilder = QueryBuilder.new(self)
|
|
if not cache_invalidated.is_connected(q.invalidate_cache):
|
|
cache_invalidated.connect(q.invalidate_cache)
|
|
return q
|
|
## Index for relationships to entities (Optional for optimization)
|
|
var relationship_entity_index: Dictionary = {}
|
|
## Index for reverse relationships (target to source entities)
|
|
var reverse_relationship_index: Dictionary = {}
|
|
## Logger for the world to only log to a specific domain
|
|
var _worldLogger = GECSLogger.new().domain("World")
|
|
## Cache for commonly used query results - stores matching archetypes, not entities
|
|
## This dramatically reduces cache invalidation since archetypes are stable
|
|
var _query_archetype_cache: Dictionary = {} # query_sig -> Array[Archetype]
|
|
## Track cache hits for performance monitoring
|
|
var _cache_hits: int = 0
|
|
var _cache_misses: int = 0
|
|
## Track cache invalidations for debugging
|
|
var _cache_invalidation_count: int = 0
|
|
var _cache_invalidation_reasons: Dictionary = {} # reason -> count
|
|
## Global cache: resource_path -> Script (loaded once, reused forever)
|
|
var _component_script_cache: Dictionary = {} # String -> Script
|
|
## OPTIMIZATION: Flag to control cache invalidation during batch operations
|
|
var _should_invalidate_cache: bool = true
|
|
## Frame + accumulated performance metrics (debug-only)
|
|
var _perf_metrics := {
|
|
"frame": {}, # Per-frame aggregated timings
|
|
"accum": {} # Long-lived totals (cleared manually)
|
|
}
|
|
|
|
|
|
## Internal perf helper (debug only)
|
|
func perf_mark(key: String, duration_usec: int, extra: Dictionary = {}) -> void:
|
|
if not ECS.debug:
|
|
return
|
|
# Aggregate per frame
|
|
var entry = _perf_metrics.frame.get(key, {"count": 0, "time_usec": 0})
|
|
entry.count += 1
|
|
entry.time_usec += duration_usec
|
|
for k in extra.keys():
|
|
# Attach/overwrite ancillary data (last value wins)
|
|
entry[k] = extra[k]
|
|
_perf_metrics.frame[key] = entry
|
|
# Accumulate lifetime totals
|
|
var accum_entry = _perf_metrics.accum.get(key, {"count": 0, "time_usec": 0})
|
|
accum_entry.count += 1
|
|
accum_entry.time_usec += duration_usec
|
|
_perf_metrics.accum[key] = accum_entry
|
|
|
|
|
|
## Reset per-frame metrics (called at world.process start)
|
|
func perf_reset_frame() -> void:
|
|
if ECS.debug:
|
|
_perf_metrics.frame.clear()
|
|
|
|
|
|
## Get a copy of current frame metrics
|
|
func perf_get_frame_metrics() -> Dictionary:
|
|
return _perf_metrics.frame.duplicate(true)
|
|
|
|
|
|
## Get a copy of accumulated metrics
|
|
func perf_get_accum_metrics() -> Dictionary:
|
|
return _perf_metrics.accum.duplicate(true)
|
|
|
|
|
|
## Reset accumulated metrics
|
|
func perf_reset_accum() -> void:
|
|
if ECS.debug:
|
|
_perf_metrics.accum.clear()
|
|
|
|
#endregion Public Variables
|
|
|
|
|
|
#region Built-in Virtual Methods
|
|
## Called when the World node is ready.
|
|
func _ready() -> void:
|
|
#_worldLogger.disabled = true
|
|
initialize()
|
|
|
|
|
|
func _make_nodes_root(name: String) -> Node:
|
|
var node = Node.new()
|
|
node.name = name
|
|
add_child(node)
|
|
return node
|
|
|
|
|
|
## Adds [Entity]s and [System]s from the scene tree to the [World].
|
|
## Called when the World node is ready or when we should re-initialize the world from the tree.
|
|
func initialize():
|
|
# Initialize default serialize config if not set
|
|
if default_serialize_config == null:
|
|
default_serialize_config = GECSSerializeConfig.new()
|
|
|
|
# if no entities/systems root node is set create them and use them. This keeps things tidy for debugging
|
|
entity_nodes_root = (
|
|
_make_nodes_root("Entities").get_path() if not entity_nodes_root else entity_nodes_root
|
|
)
|
|
system_nodes_root = (
|
|
_make_nodes_root("Systems").get_path() if not system_nodes_root else system_nodes_root
|
|
)
|
|
|
|
# Add systems from scene tree
|
|
var _systems = get_node(system_nodes_root).find_children("*", "System") as Array[System]
|
|
add_systems(_systems, true) # and sort them after they're added
|
|
_worldLogger.debug("_initialize Added Systems from Scene Tree and dep sorted: ", _systems)
|
|
|
|
# Add observers from scene tree
|
|
var _observers = get_node(system_nodes_root).find_children("*", "Observer") as Array[Observer]
|
|
add_observers(_observers)
|
|
_worldLogger.debug("_initialize Added Observers from Scene Tree: ", _observers)
|
|
|
|
# Add entities from the scene tree
|
|
var _entities = get_node(entity_nodes_root).find_children("*", "Entity") as Array[Entity]
|
|
add_entities(_entities)
|
|
_worldLogger.debug("_initialize Added Entities from Scene Tree: ", _entities)
|
|
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.world_init(self ), '')
|
|
# Register debugger message handler for entity polling
|
|
if not Engine.is_editor_hint() and OS.has_feature("editor"):
|
|
EngineDebugger.register_message_capture("gecs", _handle_debugger_message)
|
|
|
|
#endregion Built-in Virtual Methods
|
|
|
|
|
|
#region Public Methods
|
|
## Called every frame by the [method _ECS.process] to process [System]s.
|
|
## [param delta] The time elapsed since the last frame.
|
|
## [param group] The string for the group we should run. If empty runs all systems in default "" group.
|
|
func process(delta: float, group: String = "") -> void:
|
|
# PERF: Reset frame metrics at start of processing step
|
|
perf_reset_frame()
|
|
if systems_by_group.has(group):
|
|
var system_index = 0
|
|
for system in systems_by_group[group]:
|
|
if system.active:
|
|
system._handle(delta)
|
|
if ECS.debug:
|
|
# Add execution order to last run data
|
|
system.lastRunData["execution_order"] = system_index
|
|
assert(GECSEditorDebuggerMessages.system_last_run_data(system, system.lastRunData), '')
|
|
system_index += 1
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.process_world(delta, group), '')
|
|
|
|
|
|
## Updates the pause behavior for all systems based on the provided paused state.
|
|
## If paused, only systems with PROCESS_MODE_ALWAYS remain active; all others become inactive.
|
|
## If unpaused, systems with PROCESS_MODE_DISABLED stay inactive; all others become active.
|
|
func update_pause_state(paused: bool) -> void:
|
|
for group_key in systems_by_group.keys():
|
|
for system in systems_by_group[group_key]:
|
|
# Check to see if the system is can process based on the process mode and paused state
|
|
system.paused = not system.can_process()
|
|
|
|
|
|
## Adds a single [Entity] to the world.[br]
|
|
## [param entity] The [Entity] to add.[br]
|
|
## [param components] The optional list of [Component] to add to the entity.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]
|
|
## # add just an entity
|
|
## world.add_entity(player_entity)
|
|
## # add an entity with some components
|
|
## world.add_entity(other_entity, [component_a, component_b])
|
|
## [/codeblock]
|
|
func add_entity(entity: Entity, components = null, add_to_tree = true) -> void:
|
|
# Check for ID collision - if entity with same ID exists, replace it
|
|
var entity_id = GECSIO.uuid() if not entity.id else entity.id
|
|
entity.id = entity_id # update entity with it's new id
|
|
|
|
if entity_id in entity_id_registry:
|
|
var existing_entity = entity_id_registry[entity_id]
|
|
_worldLogger.debug("ID collision detected, replacing entity: ", existing_entity.name, " with: ", entity.name)
|
|
remove_entity(existing_entity)
|
|
|
|
# Register this entity's ID
|
|
entity_id_registry[entity_id] = entity
|
|
|
|
# ID will auto-generate in _enter_tree if empty, or via property getter on first access
|
|
|
|
# Update index
|
|
_worldLogger.debug("add_entity Adding Entity to World: ", entity)
|
|
|
|
# Connect to entity signals for components so we can track global component state
|
|
if not entity.component_added.is_connected(_on_entity_component_added):
|
|
entity.component_added.connect(_on_entity_component_added)
|
|
if not entity.component_removed.is_connected(_on_entity_component_removed):
|
|
entity.component_removed.connect(_on_entity_component_removed)
|
|
if not entity.relationship_added.is_connected(_on_entity_relationship_added):
|
|
entity.relationship_added.connect(_on_entity_relationship_added)
|
|
if not entity.relationship_removed.is_connected(_on_entity_relationship_removed):
|
|
entity.relationship_removed.connect(_on_entity_relationship_removed)
|
|
|
|
# Add the entity to the tree if it's not already there after hooking up the signals
|
|
# This ensures that any _ready methods on the entity or its components are called after setup
|
|
if add_to_tree and not entity.is_inside_tree():
|
|
get_node(entity_nodes_root).add_child(entity)
|
|
|
|
# add entity to our list
|
|
entities.append(entity)
|
|
|
|
# ARCHETYPE: Add entity to archetype system BEFORE initialization
|
|
# Start with empty archetype, then move as components are added
|
|
_add_entity_to_archetype(entity)
|
|
|
|
# initialize the entity and its components in game only
|
|
# This will trigger component_added signals which move the entity to the right archetype
|
|
if not Engine.is_editor_hint():
|
|
entity._initialize(components if components else [])
|
|
|
|
entity_added.emit(entity)
|
|
|
|
# All the entities are ready so we should run the pre-processors now
|
|
for processor in ECS.entity_preprocessors:
|
|
processor.call(entity)
|
|
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_added(entity), '')
|
|
|
|
|
|
## Adds multiple entities to the world.[br]
|
|
## [param entities] An array of entities to add.
|
|
## [param components] The optional list of [Component] to add to the entity.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.add_entities([player_entity, enemy_entity], [component_a])[/codeblock]
|
|
func add_entities(_entities: Array, components = null):
|
|
# OPTIMIZATION: Batch processing to reduce cache invalidations
|
|
# Temporarily disable cache invalidation during batch, then invalidate once at the end
|
|
var original_invalidate = _should_invalidate_cache
|
|
_should_invalidate_cache = false
|
|
|
|
var new_archetypes_created = false
|
|
var initial_archetype_count = archetypes.size()
|
|
|
|
# Process all entities
|
|
for _entity in _entities:
|
|
add_entity(_entity, components)
|
|
|
|
# Check if any new archetypes were created
|
|
if archetypes.size() > initial_archetype_count:
|
|
new_archetypes_created = true
|
|
|
|
# Re-enable cache invalidation and invalidate once if needed
|
|
_should_invalidate_cache = original_invalidate
|
|
if new_archetypes_created:
|
|
_invalidate_cache("batch_add_entities")
|
|
|
|
|
|
## Removes an [Entity] from the world.[br]
|
|
## [param entity] The [Entity] to remove.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_entity(player_entity)[/codeblock]
|
|
func remove_entity(entity) -> void:
|
|
entity = entity as Entity
|
|
|
|
for processor in ECS.entity_postprocessors:
|
|
processor.call(entity)
|
|
entity_removed.emit(entity)
|
|
_worldLogger.debug("remove_entity Removing Entity: ", entity)
|
|
entities.erase(entity) # FIXME: This doesn't always work for some reason?
|
|
|
|
# Only disconnect signals if they're actually connected
|
|
if entity.component_added.is_connected(_on_entity_component_added):
|
|
entity.component_added.disconnect(_on_entity_component_added)
|
|
if entity.component_removed.is_connected(_on_entity_component_removed):
|
|
entity.component_removed.disconnect(_on_entity_component_removed)
|
|
if entity.relationship_added.is_connected(_on_entity_relationship_added):
|
|
entity.relationship_added.disconnect(_on_entity_relationship_added)
|
|
if entity.relationship_removed.is_connected(_on_entity_relationship_removed):
|
|
entity.relationship_removed.disconnect(_on_entity_relationship_removed)
|
|
|
|
# Remove from ID registry
|
|
var entity_id = entity.id
|
|
if entity_id != "" and entity_id in entity_id_registry and entity_id_registry[entity_id] == entity:
|
|
entity_id_registry.erase(entity_id)
|
|
|
|
# ARCHETYPE: Remove entity from archetype system (parallel)
|
|
_remove_entity_from_archetype(entity)
|
|
|
|
# Destroy entity normally
|
|
entity.on_destroy()
|
|
entity.queue_free()
|
|
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_removed(entity), '')
|
|
|
|
|
|
## Removes an Array of [Entity] from the world.[br]
|
|
## [param entity] The Array of [Entity] to remove.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_entities([player_entity, other_entity])[/codeblock]
|
|
func remove_entities(_entities: Array) -> void:
|
|
# OPTIMIZATION: Batch processing to reduce cache invalidations
|
|
# Temporarily disable cache invalidation during batch, then invalidate once at the end
|
|
var original_invalidate = _should_invalidate_cache
|
|
_should_invalidate_cache = false
|
|
|
|
# Process all entities
|
|
for _entity in _entities:
|
|
remove_entity(_entity)
|
|
|
|
# Re-enable cache invalidation and always invalidate when entities are removed
|
|
# QueryBuilder caches execute() results, so any entity removal requires cache invalidation
|
|
_should_invalidate_cache = original_invalidate
|
|
_invalidate_cache("batch_remove_entities")
|
|
|
|
|
|
## Disable an [Entity] from the world. Disabled entities don't run process or physics,[br]
|
|
## are hidden and removed the entities list and the[br]
|
|
## [param entity] The [Entity] to disable.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.disable_entity(player_entity)[/codeblock]
|
|
func disable_entity(entity) -> Entity:
|
|
entity = entity as Entity
|
|
entity.enabled = false # This will trigger _on_entity_enabled_changed via setter
|
|
entity_disabled.emit(entity)
|
|
_worldLogger.debug("disable_entity Disabling Entity: ", entity)
|
|
|
|
entity.component_added.disconnect(_on_entity_component_added)
|
|
entity.component_removed.disconnect(_on_entity_component_removed)
|
|
entity.relationship_added.disconnect(_on_entity_relationship_added)
|
|
entity.relationship_removed.disconnect(_on_entity_relationship_removed)
|
|
entity.on_disable()
|
|
entity.set_process(false)
|
|
entity.set_physics_process(false)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_disabled(entity), '')
|
|
return entity
|
|
|
|
|
|
## Disable an Array of [Entity] from the world. Disabled entities don't run process or physics,[br]
|
|
## are hidden and removed the entities list[br]
|
|
## [param entity] The [Entity] to disable.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.disable_entities([player_entity, other_entity])[/codeblock]
|
|
func disable_entities(_entities: Array) -> void:
|
|
for _entity in _entities:
|
|
disable_entity(_entity)
|
|
|
|
|
|
## Enables a single [Entity] to the world.[br]
|
|
## [param entity] The [Entity] to enable.[br]
|
|
## [param components] The optional list of [Component] to add to the entity.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]
|
|
## # enable just an entity
|
|
## world.enable_entity(player_entity)
|
|
## # enable an entity with some components
|
|
## world.enable_entity(other_entity, [component_a, component_b])
|
|
## [/codeblock]
|
|
func enable_entity(entity: Entity, components = null) -> void:
|
|
# Update index
|
|
_worldLogger.debug("enable_entity Enabling Entity to World: ", entity)
|
|
entity.enabled = true # This will trigger _on_entity_enabled_changed via setter
|
|
entity_enabled.emit(entity)
|
|
|
|
# Connect to entity signals for components so we can track global component state
|
|
if not entity.component_added.is_connected(_on_entity_component_added):
|
|
entity.component_added.connect(_on_entity_component_added)
|
|
if not entity.component_removed.is_connected(_on_entity_component_removed):
|
|
entity.component_removed.connect(_on_entity_component_removed)
|
|
if not entity.relationship_added.is_connected(_on_entity_relationship_added):
|
|
entity.relationship_added.connect(_on_entity_relationship_added)
|
|
if not entity.relationship_removed.is_connected(_on_entity_relationship_removed):
|
|
entity.relationship_removed.connect(_on_entity_relationship_removed)
|
|
|
|
if components:
|
|
entity.add_components(components)
|
|
|
|
entity.set_process(true)
|
|
entity.set_physics_process(true)
|
|
entity.on_enable()
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_enabled(entity), '')
|
|
|
|
|
|
## Find an entity by its persistent ID
|
|
## [param id] The id to search for
|
|
## [return] The Entity with matching ID, or null if not found
|
|
func get_entity_by_id(id: String) -> Entity:
|
|
return entity_id_registry.get(id, null)
|
|
|
|
|
|
## Check if an entity with the given ID exists in the world
|
|
## [param id] The id to check
|
|
## [return] true if an entity with this ID exists, false otherwise
|
|
func has_entity_with_id(id: String) -> bool:
|
|
return id in entity_id_registry
|
|
|
|
#region Systems
|
|
|
|
|
|
## Adds a single system to the world.
|
|
##
|
|
## [param system] The system to add.
|
|
##
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.add_system(movement_system)[/codeblock]
|
|
func add_system(system: System, topo_sort: bool = false) -> void:
|
|
if not system.is_inside_tree():
|
|
get_node(system_nodes_root).add_child(system)
|
|
_worldLogger.trace("add_system Adding System: ", system)
|
|
|
|
# Give the system a reference to this world
|
|
system._world = self
|
|
|
|
if not systems_by_group.has(system.group):
|
|
systems_by_group[system.group] = []
|
|
systems_by_group[system.group].push_back(system)
|
|
system_added.emit(system)
|
|
system._internal_setup() # Determines execution method and calls user setup()
|
|
if topo_sort:
|
|
ArrayExtensions.topological_sort(systems_by_group)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.system_added(system), '')
|
|
|
|
|
|
## Adds multiple systems to the world.
|
|
##
|
|
## [param systems] An array of systems to add.
|
|
##
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.add_systems([movement_system, render_system])[/codeblock]
|
|
func add_systems(_systems: Array, topo_sort: bool = false):
|
|
for _system in _systems:
|
|
add_system(_system)
|
|
# After we add them all sort them
|
|
if topo_sort:
|
|
ArrayExtensions.topological_sort(systems_by_group)
|
|
|
|
|
|
## Removes a [System] from the world.[br]
|
|
## [param system] The [System] to remove.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_system(movement_system)[/codeblock]
|
|
func remove_system(system, topo_sort: bool = false) -> void:
|
|
_worldLogger.debug("remove_system Removing System: ", system)
|
|
systems_by_group[system.group].erase(system)
|
|
if systems_by_group[system.group].size() == 0:
|
|
systems_by_group.erase(system.group)
|
|
system_removed.emit(system)
|
|
# Update index
|
|
system.queue_free()
|
|
if topo_sort:
|
|
ArrayExtensions.topological_sort(systems_by_group)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.system_removed(system), '')
|
|
|
|
|
|
## Removes an Array of [System] from the world.[br]
|
|
## [param system] The Array of [System] to remove.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_systems([movement_system, other_system])[/codeblock]
|
|
func remove_systems(_systems: Array, topo_sort: bool = false) -> void:
|
|
for _system in _systems:
|
|
remove_system(_system)
|
|
if topo_sort:
|
|
ArrayExtensions.topological_sort(systems_by_group)
|
|
|
|
|
|
## Removes all systems in a group from the world.[br]
|
|
## [param group] The group name of the systems to remove.[br]
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_system_group("Gameplay")[/codeblock]
|
|
func remove_system_group(group: String, topo_sort: bool = false) -> void:
|
|
if systems_by_group.has(group):
|
|
for system in systems_by_group[group]:
|
|
remove_system(system)
|
|
if topo_sort:
|
|
ArrayExtensions.topological_sort(systems_by_group)
|
|
|
|
|
|
## Removes all [Entity]s and [System]s from the world.[br]
|
|
## [param should_free] Optionally frees the world node by default
|
|
## [param keep] A list of entities that should be kept in the world
|
|
func purge(should_free = true, keep := []) -> void:
|
|
# Get rid of all entities
|
|
_worldLogger.debug("Purging Entities", entities)
|
|
for entity in entities.duplicate().filter(func(x): return not keep.has(x)):
|
|
remove_entity(entity)
|
|
|
|
# Clear relationship indexes after purging entities
|
|
relationship_entity_index.clear()
|
|
reverse_relationship_index.clear()
|
|
_worldLogger.debug("Cleared relationship indexes after purge")
|
|
|
|
# ARCHETYPE: Clear archetype system
|
|
# First, break circular references by clearing edges
|
|
for archetype in archetypes.values():
|
|
archetype.add_edges.clear()
|
|
archetype.remove_edges.clear()
|
|
archetypes.clear()
|
|
entity_to_archetype.clear()
|
|
_worldLogger.debug("Cleared archetype storage after purge")
|
|
|
|
# Purge all systems
|
|
_worldLogger.debug("Purging All Systems")
|
|
for group_key in systems_by_group.keys():
|
|
for system in systems_by_group[group_key].duplicate():
|
|
remove_system(system)
|
|
|
|
# Purge all observers
|
|
_worldLogger.debug("Purging Observers", observers)
|
|
for observer in observers.duplicate():
|
|
remove_observer(observer)
|
|
|
|
_invalidate_cache("purge")
|
|
|
|
# remove itself
|
|
if should_free:
|
|
queue_free()
|
|
|
|
## Executes a query to retrieve entities based on component criteria.[br]
|
|
## [param all_components] [Component]s that [Entity]s must have all of.[br]
|
|
## [param any_components] [Component]s that [Entity]s must have at least one of.[br]
|
|
## [param exclude_components] [Component]s that [Entity]s must not have.[br]
|
|
## [param returns] An [Array] of [Entity]s that match the query.[br]
|
|
## [br]
|
|
## Performance Optimization:[br]
|
|
## When checking for all_components, the system first identifies the component with the smallest[br]
|
|
## set of entities and starts with that set. This significantly reduces the number of comparisons needed,[br]
|
|
## as we only need to check the smallest possible set of entities against other components.
|
|
|
|
#endregion Systems
|
|
|
|
#region Signal Callbacks
|
|
|
|
|
|
## [signal Entity.component_added] Callback when a component is added to an entity.[br]
|
|
## [param entity] The entity that had a component added.[br]
|
|
## [param component] The resource path of the added component.
|
|
func _on_entity_component_added(entity: Entity, component: Resource) -> void:
|
|
# ARCHETYPE: Move entity to new archetype
|
|
if entity_to_archetype.has(entity):
|
|
var old_archetype = entity_to_archetype[entity]
|
|
var comp_path = component.get_script().resource_path
|
|
var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, true)
|
|
# Must invalidate: QueryBuilder caches execute() results, not just archetype matches
|
|
_invalidate_cache("entity_component_added")
|
|
|
|
# Emit Signal
|
|
component_added.emit(entity, component)
|
|
_handle_observer_component_added(entity, component)
|
|
if not entity.component_property_changed.is_connected(_on_entity_component_property_change):
|
|
entity.component_property_changed.connect(_on_entity_component_property_change)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_component_added(entity, component), '')
|
|
|
|
|
|
## Called when a component property changes through signals called on the components and connected to.[br]
|
|
## in the _ready method.[br]
|
|
## [param entity] The [Entity] with the component change.[br]
|
|
## [param component] The [Component] that changed.[br]
|
|
## [param property_name] The name of the property that changed.[br]
|
|
## [param old_value] The old value of the property.[br]
|
|
## [param new_value] The new value of the property.[br]
|
|
func _on_entity_component_property_change(
|
|
entity: Entity,
|
|
component: Resource,
|
|
property_name: String,
|
|
old_value: Variant,
|
|
new_value: Variant
|
|
) -> void:
|
|
# Notify the World to trigger observers
|
|
_handle_observer_component_changed(entity, component, property_name, new_value, old_value)
|
|
# ARCHETYPE: No cache invalidation - property changes don't affect archetype membership
|
|
# Send the message to the debugger if we're in debug
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_component_property_changed(
|
|
entity, component, property_name, old_value, new_value
|
|
), '')
|
|
|
|
|
|
## [signal Entity.component_removed] Callback when a component is removed from an entity.[br]
|
|
## [param entity] The entity that had a component removed.[br]
|
|
## [param component] The resource path of the removed component.
|
|
func _on_entity_component_removed(entity, component: Resource) -> void:
|
|
if entity_to_archetype.has(entity):
|
|
var old_archetype = entity_to_archetype[entity]
|
|
var comp_path = component.resource_path
|
|
var new_archetype = _move_entity_to_new_archetype_fast(entity, old_archetype, comp_path, false)
|
|
# Must invalidate: QueryBuilder caches execute() results, not just archetype matches
|
|
_invalidate_cache("entity_component_removed")
|
|
|
|
component_removed.emit(entity, component)
|
|
_handle_observer_component_removed(entity, component)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_component_removed(entity, component), '')
|
|
|
|
|
|
## (Optional) Update index when a relationship is added.
|
|
func _on_entity_relationship_added(entity: Entity, relationship: Relationship) -> void:
|
|
var key = relationship.relation.resource_path
|
|
if not relationship_entity_index.has(key):
|
|
relationship_entity_index[key] = []
|
|
relationship_entity_index[key].append(entity)
|
|
|
|
# Index the reverse relationship
|
|
if is_instance_valid(relationship.target) and relationship.target is Entity:
|
|
var rev_key = "reverse_" + key
|
|
if not reverse_relationship_index.has(rev_key):
|
|
reverse_relationship_index[rev_key] = []
|
|
reverse_relationship_index[rev_key].append(relationship.target)
|
|
|
|
# PERFORMANCE: Do NOT invalidate archetype cache on relationship changes
|
|
# Relationships do not alter archetype membership (structural component sets)
|
|
# QueryBuilder.execute() performs relationship filtering on entity results.
|
|
# Systems use archetypes() + per-entity filtering, so invalidation here only
|
|
# increases cache churn without improving correctness.
|
|
|
|
# Emit Signal
|
|
relationship_added.emit(entity, relationship)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_relationship_added(entity, relationship), '')
|
|
|
|
|
|
## (Optional) Update index when a relationship is removed.
|
|
func _on_entity_relationship_removed(entity: Entity, relationship: Relationship) -> void:
|
|
var key = relationship.relation.resource_path
|
|
if relationship_entity_index.has(key):
|
|
relationship_entity_index[key].erase(entity)
|
|
|
|
if is_instance_valid(relationship.target) and relationship.target is Entity:
|
|
var rev_key = "reverse_" + key
|
|
if reverse_relationship_index.has(rev_key):
|
|
reverse_relationship_index[rev_key].erase(relationship.target)
|
|
|
|
# PERFORMANCE: No cache invalidation (see comment in _on_entity_relationship_added)
|
|
|
|
# Emit Signal
|
|
relationship_removed.emit(entity, relationship)
|
|
if ECS.debug:
|
|
assert(GECSEditorDebuggerMessages.entity_relationship_removed(entity, relationship), '')
|
|
|
|
|
|
## Adds a single [Observer] to the [World].
|
|
## [param observer] The [Observer] to add.
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.add_observer(health_change_system)[/codeblock]
|
|
func add_observer(_observer: Observer) -> void:
|
|
# Verify the system has a valid watch component
|
|
_observer.watch() # Just call to validate it returns a component
|
|
if not _observer.is_inside_tree():
|
|
get_node(system_nodes_root).add_child(_observer)
|
|
_worldLogger.trace("add_observer Adding Observer: ", _observer)
|
|
observers.append(_observer)
|
|
|
|
# Initialize the query builder for the observer
|
|
_observer.q = QueryBuilder.new(self )
|
|
|
|
# Verify the system has a valid watch component
|
|
_observer.watch() # Just call to validate it returns a component
|
|
|
|
|
|
## Adds multiple [Observer]s to the [World].
|
|
## [param observers] An array of [Observer]s to add.
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.add_observers([health_system, damage_system])[/codeblock]
|
|
func add_observers(_observers: Array):
|
|
for _observer in _observers:
|
|
add_observer(_observer)
|
|
|
|
|
|
## Removes an [Observer] from the [World].
|
|
## [param observer] The [Observer] to remove.
|
|
## [b]Example:[/b]
|
|
## [codeblock]world.remove_observer(health_system)[/codeblock]
|
|
func remove_observer(observer: Observer) -> void:
|
|
_worldLogger.debug("remove_observer Removing Observer: ", observer)
|
|
observers.erase(observer)
|
|
# if ECS.debug:
|
|
# # Don't use system_removed as it expects a System not ReactiveSystem
|
|
# GECSEditorDebuggerMessages.exit_world() # Just send a general update
|
|
observer.queue_free()
|
|
|
|
|
|
## Handle component property changes and notify observers
|
|
## [param entity] The entity with the component change
|
|
## [param component] The component that changed
|
|
## [param property] The property name that changed
|
|
## [param new_value] The new value of the property
|
|
## [param old_value] The previous value of the property
|
|
func handle_component_changed(
|
|
entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant
|
|
) -> void:
|
|
# Emit the general signal
|
|
component_changed.emit(entity, component, property, new_value, old_value)
|
|
|
|
# Find observers watching for this component and notify them
|
|
_handle_observer_component_changed(entity, component, property, new_value, old_value)
|
|
|
|
|
|
## Notify observers when a component is added
|
|
func _handle_observer_component_added(entity: Entity, component: Resource) -> void:
|
|
for reactive_system in observers:
|
|
# Get the component that this system is watching
|
|
var watch_component = reactive_system.watch()
|
|
if (
|
|
watch_component
|
|
and component and component.get_script()
|
|
and watch_component.resource_path == component.get_script().resource_path
|
|
):
|
|
# Check if the entity matches the system's query
|
|
var query_builder = reactive_system.match()
|
|
var matches = true
|
|
|
|
if query_builder:
|
|
# Use the _query method instead of trying to use query as a function
|
|
var entities_matching = _query(
|
|
query_builder._all_components,
|
|
query_builder._any_components,
|
|
query_builder._exclude_components
|
|
)
|
|
# Check if our entity is in the result set
|
|
matches = entities_matching.has(entity)
|
|
|
|
if matches:
|
|
reactive_system.on_component_added(entity, component)
|
|
|
|
|
|
## Notify observers when a component is removed
|
|
func _handle_observer_component_removed(entity: Entity, component: Resource) -> void:
|
|
for reactive_system in observers:
|
|
# Get the component that this system is watching
|
|
var watch_component = reactive_system.watch()
|
|
if (
|
|
watch_component
|
|
and component and component.get_script()
|
|
and watch_component.resource_path == component.get_script().resource_path
|
|
):
|
|
# For removal, we don't check the query since the component is already removed
|
|
# Just notify the system
|
|
reactive_system.on_component_removed(entity, component)
|
|
|
|
|
|
## Notify observers when a component property changes
|
|
func _handle_observer_component_changed(
|
|
entity: Entity, component: Resource, property: String, new_value: Variant, old_value: Variant
|
|
) -> void:
|
|
for reactive_system in observers:
|
|
# Get the component that this system is watching
|
|
var watch_component = reactive_system.watch()
|
|
if (
|
|
watch_component
|
|
and component and component.get_script()
|
|
and watch_component.resource_path == component.get_script().resource_path
|
|
):
|
|
# Check if the entity matches the system's query
|
|
var query_builder = reactive_system.match()
|
|
var matches = true
|
|
|
|
if query_builder:
|
|
# Use the _query method instead of trying to use query as a function
|
|
var entities_matching = _query(
|
|
query_builder._all_components,
|
|
query_builder._any_components,
|
|
query_builder._exclude_components
|
|
)
|
|
# Check if our entity is in the result set
|
|
matches = entities_matching.has(entity)
|
|
|
|
if matches:
|
|
reactive_system.on_component_changed(
|
|
entity, component, property, new_value, old_value
|
|
)
|
|
|
|
#endregion Signal Callbacks
|
|
|
|
#endregion Public Methods
|
|
|
|
#region Utility Methods
|
|
|
|
|
|
func _query(all_components = [], any_components = [], exclude_components = [], enabled_filter = null, precalculated_cache_key: int = -1) -> Array:
|
|
var _perf_start_total := 0
|
|
if ECS.debug:
|
|
_perf_start_total = Time.get_ticks_usec()
|
|
# Early return if no components specified - return all entities
|
|
if all_components.is_empty() and any_components.is_empty() and exclude_components.is_empty():
|
|
if enabled_filter == null:
|
|
if ECS.debug:
|
|
perf_mark("query_all_entities", Time.get_ticks_usec() - _perf_start_total, {"returned": entities.size()})
|
|
return entities
|
|
else:
|
|
# OPTIMIZATION: Use bitset filtering from all archetypes instead of entity.enabled check
|
|
var filtered: Array[Entity] = []
|
|
for archetype in archetypes.values():
|
|
filtered.append_array(archetype.get_entities_by_enabled_state(enabled_filter))
|
|
if ECS.debug:
|
|
perf_mark("query_all_entities_filtered", Time.get_ticks_usec() - _perf_start_total, {"returned": filtered.size(), "enabled_filter": enabled_filter})
|
|
return filtered
|
|
|
|
# OPTIMIZATION: Use pre-calculated cache key if provided (avoids hash recalculation)
|
|
var _perf_start_cache_key := 0
|
|
if ECS.debug:
|
|
_perf_start_cache_key = Time.get_ticks_usec()
|
|
var cache_key = precalculated_cache_key if precalculated_cache_key != -1 else QueryCacheKey.build(all_components, any_components, exclude_components)
|
|
if ECS.debug:
|
|
perf_mark("query_cache_key", Time.get_ticks_usec() - _perf_start_cache_key)
|
|
|
|
# Check if we have cached matching archetypes for this query
|
|
var matching_archetypes: Array[Archetype] = []
|
|
if _query_archetype_cache.has(cache_key):
|
|
_cache_hits += 1
|
|
matching_archetypes = _query_archetype_cache[cache_key]
|
|
if ECS.debug:
|
|
perf_mark("query_cache_hit", 0, {"archetypes": matching_archetypes.size()})
|
|
else:
|
|
_cache_misses += 1
|
|
var _perf_start_scan := 0
|
|
if ECS.debug:
|
|
_perf_start_scan = Time.get_ticks_usec()
|
|
# Find all archetypes that match this query
|
|
var map_resource_path = func(x): return x.resource_path
|
|
var _all := all_components.map(map_resource_path)
|
|
var _any := any_components.map(map_resource_path)
|
|
var _exclude := exclude_components.map(map_resource_path)
|
|
|
|
for archetype in archetypes.values():
|
|
if archetype.matches_query(_all, _any, _exclude):
|
|
matching_archetypes.append(archetype)
|
|
# Cache the matching archetypes (not the entity arrays!)
|
|
_query_archetype_cache[cache_key] = matching_archetypes
|
|
if ECS.debug:
|
|
perf_mark("query_archetype_scan", Time.get_ticks_usec() - _perf_start_scan, {"archetypes": matching_archetypes.size()})
|
|
|
|
# OPTIMIZATION: If there's only ONE matching archetype with no filtering, return it directly
|
|
# This avoids array allocation and copying for the common case
|
|
if matching_archetypes.size() == 1 and enabled_filter == null:
|
|
if ECS.debug:
|
|
perf_mark("query_single_archetype", Time.get_ticks_usec() - _perf_start_total, {"entities": matching_archetypes[0].entities.size()})
|
|
return matching_archetypes[0].entities
|
|
|
|
# Collect entities from all matching archetypes with enabled filtering if needed
|
|
var _perf_start_flatten := 0
|
|
if ECS.debug:
|
|
_perf_start_flatten = Time.get_ticks_usec()
|
|
var result: Array[Entity] = []
|
|
for archetype in matching_archetypes:
|
|
if enabled_filter == null:
|
|
# No filtering - add all entities
|
|
result.append_array(archetype.entities)
|
|
else:
|
|
# OPTIMIZATION: Use bitset filtering instead of per-entity enabled check
|
|
result.append_array(archetype.get_entities_by_enabled_state(enabled_filter))
|
|
if ECS.debug:
|
|
perf_mark("query_flatten", Time.get_ticks_usec() - _perf_start_flatten, {"returned": result.size(), "archetypes": matching_archetypes.size()})
|
|
perf_mark("query_total", Time.get_ticks_usec() - _perf_start_total, {"returned": result.size()})
|
|
|
|
return result
|
|
|
|
|
|
## OPTIMIZATION: Group entities by their archetype for column-based iteration
|
|
## Enables systems to use get_column() for cache-friendly array access
|
|
## [param entities] Array of entities to group
|
|
## [returns] Dictionary mapping Archetype -> Array[Entity]
|
|
##
|
|
## Example usage in a System:
|
|
## [codeblock]
|
|
## func process_all(entities: Array, delta: float):
|
|
## var grouped = ECS.world.group_entities_by_archetype(entities)
|
|
## for archetype in grouped.keys():
|
|
## process_columns(archetype, delta)
|
|
## [/codeblock]
|
|
func group_entities_by_archetype(entities: Array) -> Dictionary:
|
|
var grouped = {}
|
|
for entity in entities:
|
|
if entity_to_archetype.has(entity):
|
|
var archetype = entity_to_archetype[entity]
|
|
if not grouped.has(archetype):
|
|
grouped[archetype] = []
|
|
grouped[archetype].append(entity)
|
|
return grouped
|
|
|
|
|
|
## OPTIMIZATION: Get matching archetypes directly from query (no entity array flattening)
|
|
## This is MUCH faster than query().execute() + group_entities_by_archetype()
|
|
## [param query_builder] The query to execute
|
|
## [returns] Array of matching archetypes
|
|
##
|
|
## Example usage in a System:
|
|
## [codeblock]
|
|
## func process_all(entities: Array, delta: float):
|
|
## # OLD WAY (slow):
|
|
## # var grouped = ECS.world.group_entities_by_archetype(entities)
|
|
##
|
|
## # NEW WAY (fast):
|
|
## var archetypes = ECS.world.get_matching_archetypes(q.with_all([C_Velocity]))
|
|
## for archetype in archetypes:
|
|
## var velocities = archetype.get_column(C_Velocity.resource_path)
|
|
## for i in range(velocities.size()):
|
|
## # Process with cache-friendly column access
|
|
## [/codeblock]
|
|
func get_matching_archetypes(query_builder: QueryBuilder) -> Array[Archetype]:
|
|
var _perf_start := 0
|
|
if ECS.debug:
|
|
_perf_start = Time.get_ticks_usec()
|
|
# PERFORMANCE: Archetype matching is based ONLY on structural components.
|
|
# Relationship/group filters are evaluated per-entity in System execution.
|
|
# This avoids double-scanning entities (World + System) and reduces cache churn.
|
|
var all_components = query_builder._all_components
|
|
var any_components = query_builder._any_components
|
|
var exclude_components = query_builder._exclude_components
|
|
|
|
# Use a COMPONENT-ONLY cache key (ignore relationships/groups)
|
|
var cache_key = QueryCacheKey.build(all_components, any_components, exclude_components)
|
|
|
|
if _query_archetype_cache.has(cache_key):
|
|
if ECS.debug:
|
|
perf_mark("archetypes_cache_hit", Time.get_ticks_usec() - _perf_start)
|
|
return _query_archetype_cache[cache_key]
|
|
|
|
var map_resource_path = func(x): return x.resource_path
|
|
var _all := all_components.map(map_resource_path)
|
|
var _any := any_components.map(map_resource_path)
|
|
var _exclude := exclude_components.map(map_resource_path)
|
|
|
|
var matching: Array[Archetype] = []
|
|
var _perf_scan_start := 0
|
|
if ECS.debug:
|
|
_perf_scan_start = Time.get_ticks_usec()
|
|
for archetype in archetypes.values():
|
|
if archetype.matches_query(_all, _any, _exclude):
|
|
matching.append(archetype)
|
|
if ECS.debug:
|
|
perf_mark("archetypes_scan", Time.get_ticks_usec() - _perf_scan_start, {"archetypes": matching.size()})
|
|
|
|
_query_archetype_cache[cache_key] = matching
|
|
if ECS.debug:
|
|
perf_mark("archetypes_total", Time.get_ticks_usec() - _perf_start, {"archetypes": matching.size()})
|
|
return matching
|
|
|
|
|
|
## Get performance statistics for cache usage
|
|
func get_cache_stats() -> Dictionary:
|
|
var total_requests = _cache_hits + _cache_misses
|
|
var hit_rate = 0.0 if total_requests == 0 else float(_cache_hits) / float(total_requests)
|
|
return {
|
|
"cache_hits": _cache_hits,
|
|
"cache_misses": _cache_misses,
|
|
"hit_rate": hit_rate,
|
|
"cached_queries": _query_archetype_cache.size(),
|
|
"total_archetypes": archetypes.size(),
|
|
"invalidation_count": _cache_invalidation_count,
|
|
"invalidation_reasons": _cache_invalidation_reasons.duplicate()
|
|
}
|
|
|
|
|
|
## Reset cache statistics
|
|
func reset_cache_stats() -> void:
|
|
_cache_hits = 0
|
|
_cache_misses = 0
|
|
_cache_invalidation_count = 0
|
|
_cache_invalidation_reasons.clear()
|
|
|
|
|
|
## Internal helper to track cache invalidations (debug mode only)
|
|
func _invalidate_cache(reason: String) -> void:
|
|
# OPTIMIZATION: Skip invalidation during batch operations
|
|
if not _should_invalidate_cache:
|
|
return
|
|
|
|
_query_archetype_cache.clear()
|
|
cache_invalidated.emit()
|
|
|
|
# Track invalidation stats (debug mode only)
|
|
if ECS.debug:
|
|
_cache_invalidation_count += 1
|
|
_cache_invalidation_reasons[reason] = _cache_invalidation_reasons.get(reason, 0) + 1
|
|
|
|
|
|
## Calculate archetype signature for an entity based on its components
|
|
## Uses the same hash function as queries for consistency
|
|
## An entity signature is just a query with all its components (no any/exclude)
|
|
func _calculate_entity_signature(entity: Entity) -> int:
|
|
# Get component resource paths
|
|
var comp_paths = entity.components.keys()
|
|
comp_paths.sort() # Sort paths for consistent ordering
|
|
|
|
# Convert paths to Script objects using cached scripts (load once, reuse forever)
|
|
var comp_scripts = []
|
|
for comp_path in comp_paths:
|
|
# Check cache first
|
|
if not _component_script_cache.has(comp_path):
|
|
# Load once and cache
|
|
var component = entity.components[comp_path]
|
|
_component_script_cache[comp_path] = component.get_script()
|
|
comp_scripts.append(_component_script_cache[comp_path])
|
|
|
|
# Use the SAME hash function as queries - entity is just "all components, no any/exclude"
|
|
# OPTIMIZATION: Removed enabled_marker from signature - now handled by bitset in archetype
|
|
var signature = QueryCacheKey.build(comp_scripts, [], [])
|
|
|
|
return signature
|
|
|
|
|
|
## Get or create an archetype for the given signature and component types
|
|
func _get_or_create_archetype(signature: int, component_types: Array) -> Archetype:
|
|
var is_new = not archetypes.has(signature)
|
|
if is_new:
|
|
var archetype = Archetype.new(signature, component_types)
|
|
archetypes[signature] = archetype
|
|
_worldLogger.trace("Created new archetype: ", archetype)
|
|
|
|
# ARCHETYPE OPTIMIZATION: Only invalidate cache when NEW archetype is created
|
|
# This is rare compared to entities moving between existing archetypes
|
|
_invalidate_cache("new_archetype_created")
|
|
|
|
return archetypes[signature]
|
|
|
|
|
|
## Add entity to appropriate archetype (parallel system)
|
|
func _add_entity_to_archetype(entity: Entity) -> void:
|
|
# Calculate signature based on entity's components (enabled state now handled by bitset)
|
|
var signature = _calculate_entity_signature(entity)
|
|
|
|
# Get component type paths for this entity
|
|
var comp_types = entity.components.keys()
|
|
|
|
# Get or create archetype (no longer needs enabled filter value)
|
|
var archetype = _get_or_create_archetype(signature, comp_types)
|
|
|
|
# Add entity to archetype
|
|
archetype.add_entity(entity)
|
|
entity_to_archetype[entity] = archetype
|
|
|
|
_worldLogger.trace("Added entity ", entity.name, " to archetype: ", archetype)
|
|
|
|
|
|
## Remove entity from its current archetype
|
|
func _remove_entity_from_archetype(entity: Entity) -> bool:
|
|
if not entity_to_archetype.has(entity):
|
|
return false
|
|
|
|
var archetype = entity_to_archetype[entity]
|
|
var removed = archetype.remove_entity(entity)
|
|
entity_to_archetype.erase(entity)
|
|
|
|
# Clean up empty archetypes (optional - can keep them for reuse)
|
|
if archetype.is_empty():
|
|
# Break circular references before removing
|
|
archetype.add_edges.clear()
|
|
archetype.remove_edges.clear()
|
|
archetypes.erase(archetype.signature)
|
|
_worldLogger.trace("Removed empty archetype: ", archetype)
|
|
# OPTIMIZATION: Only invalidate when archetype is actually removed from world
|
|
_invalidate_cache("empty_archetype_removed")
|
|
|
|
return removed
|
|
|
|
|
|
## Fast path: Move entity when we already know which component was added/removed
|
|
## This avoids expensive set comparisons to find the difference
|
|
## Returns the new archetype the entity was moved to
|
|
func _move_entity_to_new_archetype_fast(entity: Entity, old_archetype: Archetype, comp_path: String, is_add: bool) -> Archetype:
|
|
# Try to use archetype edge for O(1) transition
|
|
var new_archetype: Archetype = null
|
|
|
|
if is_add:
|
|
# Check if we have a cached edge for this component addition
|
|
new_archetype = old_archetype.get_add_edge(comp_path)
|
|
else:
|
|
# Check if we have a cached edge for this component removal
|
|
new_archetype = old_archetype.get_remove_edge(comp_path)
|
|
|
|
# BUG FIX: If archetype retrieved from edge cache was removed from world.archetypes
|
|
# when it became empty, re-add it so queries can find it
|
|
if new_archetype != null and not archetypes.has(new_archetype.signature):
|
|
archetypes[new_archetype.signature] = new_archetype
|
|
_worldLogger.trace("Re-added archetype from edge cache: ", new_archetype)
|
|
|
|
# If no cached edge, calculate signature and find/create archetype
|
|
if new_archetype == null:
|
|
var new_signature = _calculate_entity_signature(entity)
|
|
var comp_types = entity.components.keys()
|
|
new_archetype = _get_or_create_archetype(new_signature, comp_types)
|
|
|
|
# Cache the edge for next time (archetype graph optimization)
|
|
if is_add:
|
|
old_archetype.set_add_edge(comp_path, new_archetype)
|
|
new_archetype.set_remove_edge(comp_path, old_archetype)
|
|
else:
|
|
old_archetype.set_remove_edge(comp_path, new_archetype)
|
|
new_archetype.set_add_edge(comp_path, old_archetype)
|
|
|
|
# Remove from old archetype
|
|
old_archetype.remove_entity(entity)
|
|
|
|
# Add to new archetype
|
|
new_archetype.add_entity(entity)
|
|
entity_to_archetype[entity] = new_archetype
|
|
|
|
_worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype)
|
|
|
|
# Clean up empty old archetype
|
|
if old_archetype.is_empty():
|
|
# Break circular references before removing
|
|
old_archetype.add_edges.clear()
|
|
old_archetype.remove_edges.clear()
|
|
archetypes.erase(old_archetype.signature)
|
|
|
|
return new_archetype
|
|
|
|
|
|
## Move entity from one archetype to another (when components change)
|
|
## Uses archetype edges for O(1) transitions when possible
|
|
## NOTE: This slow path compares sets - only used when we don't know which component changed
|
|
func _move_entity_to_new_archetype(entity: Entity, old_archetype: Archetype) -> void:
|
|
# Determine which component was added/removed by comparing old archetype with current entity
|
|
var old_comp_set = {}
|
|
for comp_path in old_archetype.component_types:
|
|
old_comp_set[comp_path] = true
|
|
|
|
var new_comp_set = {}
|
|
for comp_path in entity.components.keys():
|
|
new_comp_set[comp_path] = true
|
|
|
|
# Find the difference (added or removed component)
|
|
var added_comp: String = ""
|
|
var removed_comp: String = ""
|
|
|
|
for comp_path in new_comp_set.keys():
|
|
if not old_comp_set.has(comp_path):
|
|
added_comp = comp_path
|
|
break
|
|
|
|
for comp_path in old_comp_set.keys():
|
|
if not new_comp_set.has(comp_path):
|
|
removed_comp = comp_path
|
|
break
|
|
|
|
# Try to use archetype edge for O(1) transition
|
|
var new_archetype: Archetype = null
|
|
|
|
if added_comp != "":
|
|
# Check if we have a cached edge for this component addition
|
|
new_archetype = old_archetype.get_add_edge(added_comp)
|
|
elif removed_comp != "":
|
|
# Check if we have a cached edge for this component removal
|
|
new_archetype = old_archetype.get_remove_edge(removed_comp)
|
|
|
|
# If no cached edge, calculate signature and find/create archetype
|
|
if new_archetype == null:
|
|
var new_signature = _calculate_entity_signature(entity)
|
|
var comp_types = entity.components.keys()
|
|
new_archetype = _get_or_create_archetype(new_signature, comp_types)
|
|
|
|
# Cache the edge for next time (archetype graph optimization)
|
|
if added_comp != "":
|
|
old_archetype.set_add_edge(added_comp, new_archetype)
|
|
new_archetype.set_remove_edge(added_comp, old_archetype)
|
|
elif removed_comp != "":
|
|
old_archetype.set_remove_edge(removed_comp, new_archetype)
|
|
new_archetype.set_add_edge(removed_comp, old_archetype)
|
|
|
|
# Remove from old archetype
|
|
old_archetype.remove_entity(entity)
|
|
|
|
# Add to new archetype
|
|
new_archetype.add_entity(entity)
|
|
entity_to_archetype[entity] = new_archetype
|
|
|
|
_worldLogger.trace("Moved entity ", entity.name, " from ", old_archetype, " to ", new_archetype)
|
|
|
|
# Clean up empty old archetype
|
|
if old_archetype.is_empty():
|
|
# Break circular references before removing
|
|
old_archetype.add_edges.clear()
|
|
old_archetype.remove_edges.clear()
|
|
archetypes.erase(old_archetype.signature)
|
|
|
|
#endregion Utility Methods
|
|
|
|
#region Debugger Support
|
|
|
|
|
|
## Handle messages from the editor debugger
|
|
func _handle_debugger_message(message: String, data: Array) -> bool:
|
|
if message == "set_system_active":
|
|
# Editor requested to toggle a system's active state
|
|
var system_id = data[0]
|
|
var new_active = data[1]
|
|
|
|
# Find the system by instance ID
|
|
for sys in systems:
|
|
if sys.get_instance_id() == system_id:
|
|
sys.active = new_active
|
|
|
|
# Send confirmation back to editor
|
|
GECSEditorDebuggerMessages.system_added(sys)
|
|
return true
|
|
|
|
return false
|
|
elif message == "poll_entity":
|
|
# Editor requested a component poll for a specific entity
|
|
var entity_id = data[0]
|
|
_poll_entity_for_debugger(entity_id)
|
|
return true
|
|
elif message == "select_entity":
|
|
# Editor requested to select an entity in the scene tree
|
|
var entity_path = data[0]
|
|
print("GECS World: Received select_entity request for path: ", entity_path)
|
|
# Get the actual node to get its ObjectID
|
|
var node = get_node_or_null(entity_path)
|
|
if node:
|
|
var obj_id = node.get_instance_id()
|
|
var _class_name = node.get_class()
|
|
# The path needs to be an array of node names from root to target
|
|
var path_array = str(entity_path).split("/", false)
|
|
print(" Found node, sending inspect message")
|
|
print(" ObjectID: ", obj_id)
|
|
print(" Class: ", _class_name)
|
|
|
|
if GECSEditorDebuggerMessages.can_send_message():
|
|
# The scene:inspect_object format per Godot source code:
|
|
# [object_id (uint64), class_name (STRING), properties_array (ARRAY)]
|
|
# NO path_array! Just 3 elements total
|
|
# properties_array contains arrays of 6 elements each:
|
|
# [name (STRING), type (INT), hint (INT), hint_string (STRING), usage (INT), value (VARIANT)]
|
|
# Get actual properties from the node
|
|
var properties: Array = []
|
|
var prop_list = node.get_property_list()
|
|
# Add properties (limit to avoid huge payload)
|
|
for i in range(min(20, prop_list.size())):
|
|
var prop = prop_list[i]
|
|
var prop_name: String = prop.name
|
|
var prop_type: int = prop.type
|
|
var prop_hint: int = prop.get("hint", 0)
|
|
var prop_hint_string: String = prop.get("hint_string", "")
|
|
var prop_usage: int = prop.usage
|
|
var prop_value = node.get(prop_name)
|
|
|
|
var prop_info: Array = [prop_name, prop_type, prop_hint, prop_hint_string, prop_usage, prop_value]
|
|
properties.append(prop_info)
|
|
|
|
# Message format: [object_id, class_name, properties] - only 3 elements!
|
|
var msg_data: Array = [obj_id, _class_name, properties]
|
|
print(" Sending scene:inspect_object: [", obj_id, ", ", _class_name, ", ", properties.size(), " props]")
|
|
EngineDebugger.send_message("scene:inspect_object", msg_data)
|
|
else:
|
|
print(" ERROR: Could not find node at path: ", entity_path)
|
|
return true
|
|
return false
|
|
|
|
|
|
## Poll a specific entity's components and send updates to the debugger
|
|
func _poll_entity_for_debugger(entity_id: int) -> void:
|
|
# Find the entity by instance ID
|
|
var entity: Entity = null
|
|
for ent in entities:
|
|
if ent.get_instance_id() == entity_id:
|
|
entity = ent
|
|
break
|
|
|
|
if entity == null:
|
|
return
|
|
|
|
# Re-send all component data with fresh serialize() calls
|
|
for comp_path in entity.components.keys():
|
|
var comp = entity.components[comp_path]
|
|
if comp and comp is Resource:
|
|
# Send updated component data
|
|
GECSEditorDebuggerMessages.entity_component_added(entity, comp)
|
|
|
|
#endregion Debugger Support
|