class_name CreatureScheduler extends RefCounted
## SIMULATION TIERS + SLEEP for the creatures one spawner simulates (#109 / hyper-games
## #114, user 2026-10-13: "start on simulation tiers and sleep for creatures").
##
## Measured first (#114 benchmark): a crawler costs 90–270 µs a tick, mostly its own
## GDScript rather than its ground sweeps, so the old LOD — which only skipped the
## sweeps — saved little. The fix every shipped crowd system uses (Assassin's Creed
## Unity's LOD tiers, Project Highrise, Minecraft simulation distance, Valheim's active
## zone): tick near things at full rate, far things rarely, and unobserved things NOT AT
## ALL, so cost follows what someone can see rather than how many creatures exist.
##
## Tier by the NEAREST OBSERVER (every player in the mesh, not just this peer's own):
## NEAR < near_radius every physics tick, ground sweeps on (the old FULL LOD)
## MID < mid_radius every MID_EVERY-th tick (10 Hz), no sweeps
## FAR < sleep_radius every FAR_EVERY-th tick (2 Hz), no sweeps, last ground held
## SLEEP beyond no tick at all: frozen; its time does not pass
## A ticking creature gets the time it missed (its accumulated dt, capped at MAX_DT —
## the coarsest step its steering lerp stays stable at), so a 2 Hz creature walks as far
## per second as a 60 Hz one. Each tier is staggered by a per-creature SLOT, so no frame
## takes every far creature at once.
##
## Creatures stop ticking themselves while scheduled (`scheduled = true`); a creature
## outside any spawner (a bot's body) still ticks on its own.
enum Tier { NEAR, MID, FAR, SLEEP }
const MID_EVERY := 6 # 60 Hz / 6 = 10 Hz
const FAR_EVERY := 30 # 60 Hz / 30 = 2 Hz
const MAX_DT := 0.5 # the crawler's head lerp (2·dt) saturates at 0.5 s
var near_radius := 20.0
var mid_radius := 50.0
var sleep_radius := 100.0
## NEAR creatures that can INTERPOLATE (an `interpolating` property: the crawler) tick
## every near_every physics frames — 1 = 60 Hz, 2 = 30 Hz, 3 = 20 Hz (user 2026-10-13:
## "20 Hz near tier and interpolation") — and blend their last two poses every render
## frame. The rest stay at 60 Hz near until they can.
var near_every := 3
## creature -> {tier, acc, slot, dirty}
var _e: Dictionary = {}
var _next_slot := 0
## Ticks GIVEN per tier since reset_stats() — what gate_creature_tiers counts.
var ticks := PackedInt64Array([0, 0, 0, 0])
var simulated := {} # creature -> seconds of dt handed to it (gate)
var tick_count := {} # creature -> ticks handed to it (gate)
## Tiered AT ONCE from `observers` — not left at NEAR until the next retier, which ran a
## creature spawned far away at full rate for up to RETIER_INTERVAL (gate_creature_tiers).
func add(c: Node3D, observers := PackedVector3Array()) -> void:
var e := {"tier": Tier.NEAR, "acc": 0.0, "slot": _next_slot, "dirty": true}
_e[c] = e
_next_slot += 1
c.set("scheduled", true)
if not observers.is_empty():
_apply(c, e, _tier_for(c.global_position, observers))
func remove(c: Node3D) -> void:
_e.erase(c)
simulated.erase(c)
tick_count.erase(c)
func clear() -> void:
_e.clear()
simulated.clear()
tick_count.clear()
func size() -> int:
return _e.size()
func tier_of(c: Node3D) -> int:
return int((_e.get(c, {"tier": Tier.SLEEP}) as Dictionary)["tier"])
func reset_stats() -> void:
ticks = PackedInt64Array([0, 0, 0, 0])
simulated.clear()
tick_count.clear()
## Re-tier everything by its distance to the NEAREST observer. Cheap enough at 2 Hz:
## one distance per creature per observer.
func retier(observers: PackedVector3Array) -> void:
for c: Node3D in _e.keys():
if not is_instance_valid(c):
_e.erase(c)
continue
_apply(c, _e[c], _tier_for(c.global_position, observers))
func _tier_for(p: Vector3, observers: PackedVector3Array) -> int:
var best := INF
for o: Vector3 in observers:
best = minf(best, p.distance_squared_to(o))
if best < near_radius * near_radius:
return Tier.NEAR
if best < mid_radius * mid_radius:
return Tier.MID
if best < sleep_radius * sleep_radius:
return Tier.FAR
return Tier.SLEEP
func _apply(c: Node3D, e: Dictionary, t: int) -> void:
if t == Tier.SLEEP and int(e["tier"]) != Tier.SLEEP:
e["acc"] = 0.0 # falling asleep: the time it would have had is dropped
e["tier"] = t
# Sweeps only up close (the creatures' own FULL/LOW switch).
if "_lod" in c:
c.set("_lod", 0 if t == Tier.NEAR else 2)
# Near at a reduced rate only where the creature blends its poses in between.
var interp: bool = t == Tier.NEAR and near_every > 1 and "interpolating" in c
e["every"] = near_every if interp else 1
if "interpolating" in c:
c.set("interpolating", interp)
c.set("_tick_period", near_every if interp else 1)
## One physics frame: tick whoever's turn it is, with the time each has missed.
func step(delta: float, frame: int) -> void:
for c: Node3D in _e.keys():
if not is_instance_valid(c):
_e.erase(c)
continue
var e: Dictionary = _e[c]
var t: int = e["tier"]
if t == Tier.SLEEP:
continue
e["acc"] = float(e["acc"]) + delta
var every: int = int(e.get("every", 1)) # NEAR: 1, or near_every if interpolating
if t == Tier.MID:
every = MID_EVERY
elif t == Tier.FAR:
every = FAR_EVERY
if (frame + int(e["slot"])) % every != 0:
continue
var dt := minf(float(e["acc"]), MAX_DT)
e["acc"] = 0.0
e["dirty"] = true
c.call("sim_tick", dt)
ticks[t] += 1
simulated[c] = float(simulated.get(c, 0.0)) + dt
tick_count[c] = int(tick_count.get(c, 0)) + 1
## The creatures that moved since the last call — the only ones worth a position row.
func take_dirty() -> Array[Node3D]:
var out: Array[Node3D] = []
for c: Node3D in _e.keys():
var e: Dictionary = _e[c]
if bool(e["dirty"]) and is_instance_valid(c):
out.append(c)
e["dirty"] = false
return out