Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions addons/gecs/ecs/accumulated_tick_source.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
## AccumulatedTickSource
##
## Time-based tick source that fires at intervals but returns the actual accumulated time.
##
## Similar to IntervalTickSource, but returns the actual accumulated delta instead of
## the fixed interval. Useful when you need to account for time drift or variable frame rates.
##
## [b]Example:[/b]
## [codeblock]
## # In world setup
## var physics_tick = AccumulatedTickSource.new()
## physics_tick.interval = 0.02 # ~50 FPS
## ECS.world.register_tick_source(physics_tick, "physics-tick")
##
## # In system
## class_name PhysicsSystem extends System
##
## func tick() -> TickSource:
## return ECS.world.get_tick_source("physics-tick")
##
## func process(entities: Array[Entity], components: Array, delta: float) -> void:
## # delta will be the actual accumulated time (e.g., 0.021 if slightly behind)
## apply_physics(entities, delta)
## [/codeblock]
class_name AccumulatedTickSource
extends TickSource

## The interval in seconds between ticks
@export var interval: float = 1.0

## Accumulated time since last tick
var accumulated_time: float = 0.0

## Total number of ticks that have occurred
var tick_count: int = 0


## Update the tick source with frame delta
## Returns the accumulated time when it's time to tick, 0.0 otherwise
## Carries forward extra time to prevent drift during lag spikes
func update(delta: float) -> float:
accumulated_time += delta

if accumulated_time >= interval:
tick_count += 1
last_delta = accumulated_time # Return full accumulated time
accumulated_time -= interval # Carry forward extra time
else:
last_delta = 0.0 # No tick this frame

return last_delta
Comment thread
coderabbitai[bot] marked this conversation as resolved.


## Reset tick source state
func reset() -> void:
super.reset()
accumulated_time = 0.0
tick_count = 0
59 changes: 59 additions & 0 deletions addons/gecs/ecs/interval_tick_source.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
## IntervalTickSource
##
## Time-based tick source that fires at fixed intervals.
##
## Returns a fixed interval delta when the accumulated time exceeds the interval.
## Useful for systems that need to run at specific time intervals (e.g., every 1 second).
##
## [b]Example:[/b]
## [codeblock]
## # In world setup
## var spawner_tick = IntervalTickSource.new()
## spawner_tick.interval = 1.0 # Tick every second
## ECS.world.register_tick_source(spawner_tick, "spawner-tick")
##
## # In system
## class_name SpawnerSystem extends System
##
## func tick() -> TickSource:
## return ECS.world.get_tick_source("spawner-tick")
##
## func process(entities: Array[Entity], components: Array, delta: float) -> void:
## # This runs every 1 second with delta = 1.0
## spawn_enemy()
## [/codeblock]
class_name IntervalTickSource
extends TickSource

## The interval in seconds between ticks
@export var interval: float = 1.0

## Accumulated time since last tick
var accumulated_time: float = 0.0

## Total number of ticks that have occurred
var tick_count: int = 0


## Update the tick source with frame delta
## Returns the fixed interval when it's time to tick, 0.0 otherwise
## Handles lag spikes by processing all pending intervals in a single frame
func update(delta: float) -> float:
accumulated_time += delta

# Process all pending intervals (handles lag spikes and pauses)
var ticked := false
while accumulated_time >= interval:
tick_count += 1
accumulated_time -= interval
ticked = true

last_delta = interval if ticked else 0.0
return last_delta


## Reset tick source state
func reset() -> void:
super.reset()
accumulated_time = 0.0
tick_count = 0
71 changes: 71 additions & 0 deletions addons/gecs/ecs/rate_filter_tick_source.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
## RateFilterTickSource
##
## Frame-based tick source that samples another tick source at a specific rate.
##
## Ticks every Nth time the source ticks, accumulating the delta values.
## Useful for creating hierarchical timing systems and deterministic frame-based execution.
##
## [b]Example:[/b]
## [codeblock]
## # In world setup
## ECS.world.create_interval_tick_source(1.0, "second")
## ECS.world.create_rate_filter(60, "second", "minute") # Every 60 seconds
##
## # In system
## class_name AutoSaveSystem extends System
##
## func tick() -> TickSource:
## return ECS.world.get_tick_source("minute")
##
## func process(entities: Array[Entity], components: Array, delta: float) -> void:
## # This runs every 60 seconds
## # delta will be the accumulated time from 60 ticks (~60 seconds)
## auto_save_game()
## [/codeblock]
##
## [b]Note:[/b] The source tick source is NOT updated by this class - World updates
## all tick sources in order, so we just read the source's last_delta from its update.
class_name RateFilterTickSource
extends TickSource

## The number of source ticks to wait before ticking
@export var rate: int = 60

## The source tick source to sample (set by World.create_rate_filter)
@export var source: TickSource

## Internal counter for source ticks
var tick_count: int = 0

## Accumulated delta from source ticks
var accumulated_delta: float = 0.0


## Update the tick source
## Samples the source's last_delta and ticks every Nth source tick
## Returns the accumulated delta when it's time to tick, 0.0 otherwise
func update(delta: float) -> float:
# NOTE: Source is NOT updated here - World updates all tick sources
# We just read the source's last_delta from its previous update

if source.last_delta > 0.0: # Source ticked this frame
tick_count += 1
accumulated_delta += source.last_delta

if tick_count >= rate:
tick_count = 0
last_delta = accumulated_delta # Return accumulated delta
accumulated_delta = 0.0
else:
last_delta = 0.0 # Not time to tick yet
else:
last_delta = 0.0 # Source didn't tick

return last_delta


## Reset tick source state
func reset() -> void:
super.reset()
tick_count = 0
accumulated_delta = 0.0
24 changes: 24 additions & 0 deletions addons/gecs/ecs/system.gd
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ var _query_cache: QueryBuilder = null
var _component_paths: Array[String] = []
## Cached subsystems array (6.0.0 style)
var _subsystems_cache: Array = []
## Cached tick source (null = use frame delta)
var _tick_source_cached: TickSource = null

#endregion Public Variables

Expand All @@ -113,6 +115,25 @@ func query() -> QueryBuilder:
return _world.query if _world else ECS.world.query


## Override this method to specify a custom tick source for this system.[br]
## Called once during system setup (cached like query()).[br]
## If not overridden, the system uses frame delta (default behavior).[br][br]
## [b]Example:[/b]
## [codeblock]
## # Register tick source in world setup
## func _ready():
## ECS.world.create_interval_tick_source(1.0, 'spawner-tick')
##
## # Get tick source in system
## class_name SpawnerSystem extends System
##
## func tick() -> TickSource:
## return ECS.world.get_tick_source('spawner-tick')
## [/codeblock]
func tick() -> TickSource:
return null # Base implementation - use frame delta


## 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]
Expand Down Expand Up @@ -162,6 +183,9 @@ func process(entities: Array[Entity], components: Array, delta: float) -> void:
## INTERNAL: Called by World.add_system() to initialize the system
## DO NOT CALL OR OVERRIDE - this is framework code
func _internal_setup():
# Cache tick source - base returns null (fast path), overrides provide TickSource
_tick_source_cached = tick()

# Call user setup
setup()

Expand Down
41 changes: 41 additions & 0 deletions addons/gecs/ecs/tick_source.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
## TickSource
##
## Base class for custom tick sources that control system execution timing.
##
## Tick sources determine when systems run and what delta value they receive.
## The base implementation passes through the frame delta (default behavior).
## Override [method update] to create custom timing behaviors.
##
## [b]Example (Custom tick source):[/b]
## [codeblock]
## class_name RandomTickSource extends TickSource
##
## var probability: float = 0.5
##
## func update(delta: float) -> float:
## if randf() < probability:
## last_delta = delta
## else:
## last_delta = 0.0 # Skip this frame
## return last_delta
## [/codeblock]
class_name TickSource
extends Resource

## The delta value from the last update (0.0 = didn't tick this frame)
var last_delta: float = 0.0


## Called every frame by World.process()
## Must set last_delta and return it
## [param delta] The frame delta time
## [return] The delta value to pass to systems (0.0 to skip this frame)
func update(delta: float) -> float:
last_delta = delta # Pass through - override in subclasses
return last_delta


## Reset tick source state
## Override this to reset custom state variables
func reset() -> void:
last_delta = 0.0
96 changes: 95 additions & 1 deletion addons/gecs/ecs/world.gd
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ var _perf_metrics := {
"frame": {}, # Per-frame aggregated timings
"accum": {} # Long-lived totals (cleared manually)
}
## Tick source registry - maps name -> TickSource
var _tick_sources: Dictionary = {}
## Frame counter to ensure tick sources are only updated once per frame
var _tick_sources_last_frame: int = -1


## Internal perf helper (debug only)
Expand Down Expand Up @@ -205,11 +209,29 @@ func initialize():
func process(delta: float, group: String = "") -> void:
# PERF: Reset frame metrics at start of processing step
perf_reset_frame()

# Update all tick sources ONCE per frame (only if any exist to avoid overhead)
# This prevents double-updating when processing multiple groups in the same frame
var current_frame = Engine.get_process_frames()
if not _tick_sources.is_empty() and _tick_sources_last_frame != current_frame:
_tick_sources_last_frame = current_frame
for tick_source in _tick_sources.values():
tick_source.update(delta)

# Process systems
if systems_by_group.has(group):
var system_index = 0
for system in systems_by_group[group]:
if system.active:
system._handle(delta)
# Fast path: No tick source = pass through frame delta (zero overhead)
if system._tick_source_cached == null:
system._handle(delta)
else:
# Tick source path: check if we should run this frame
var system_delta = system._tick_source_cached.last_delta
if system_delta > 0.0: # Only run if ticked
system._handle(system_delta)

if ECS.debug:
# Add execution order to last run data
system.lastRunData["execution_order"] = system_index
Expand All @@ -229,6 +251,78 @@ func update_pause_state(paused: bool) -> void:
system.paused = not system.can_process()


#region Tick Sources

## Register a tick source with a unique name.[br]
## Asserts if name already exists to prevent accidental bugs.[br]
## [param tick_source] The [TickSource] to register.[br]
## [param name] The unique name for this tick source.[br]
## [return] The registered tick source (for chaining).[br]
## [b]Example:[/b]
## [codeblock]
## var my_tick = IntervalTickSource.new()
## my_tick.interval = 1.0
## ECS.world.register_tick_source(my_tick, "spawner-tick")
## [/codeblock]
func register_tick_source(tick_source: TickSource, name: String) -> TickSource:
assert(not _tick_sources.has(name), "TickSource '%s' already registered!" % name)
_tick_sources[name] = tick_source
return tick_source


## Get existing tick source by name.[br]
## Returns null if not found.[br]
## [param name] The name of the tick source to get.[br]
## [return] The [TickSource] or null if not found.[br]
## [b]Example:[/b]
## [codeblock]
## class_name MySystem extends System
##
## func tick() -> TickSource:
## return ECS.world.get_tick_source('spawner-tick')
## [/codeblock]
func get_tick_source(name: String) -> TickSource:
return _tick_sources.get(name)


## Convenience: Create and register an interval tick source.[br]
## [param interval] The interval in seconds between ticks.[br]
## [param name] The unique name for this tick source.[br]
## [return] The created tick source.[br]
## [b]Example:[/b]
## [codeblock]
## # In world setup (_ready, autoload, etc.)
## ECS.world.create_interval_tick_source(1.0, 'spawner-tick')
## [/codeblock]
func create_interval_tick_source(interval: float, name: String) -> TickSource:
var ts = IntervalTickSource.new()
ts.interval = interval
return register_tick_source(ts, name)


## Convenience: Create and register a rate filter tick source.[br]
## [param rate] The number of source ticks to wait before ticking.[br]
## [param source_name] The name of the source tick source.[br]
## [param name] The unique name for this tick source.[br]
## [return] The created tick source.[br]
## [b]Example:[/b]
## [codeblock]
## # In world setup
## ECS.world.create_interval_tick_source(1.0, 'second')
## ECS.world.create_rate_filter(60, 'second', 'minute') # Every 60 seconds
## [/codeblock]
func create_rate_filter(rate: int, source_name: String, name: String) -> TickSource:
var source = get_tick_source(source_name)
assert(source != null, "Source tick source '%s' not found!" % source_name)

var rf = RateFilterTickSource.new()
rf.rate = rate
rf.source = source
return register_tick_source(rf, name)

#endregion Tick Sources


## 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]
Expand Down
Loading