extends Node3D
## A SPAWNER you place (creatures → bots, #114 phase 4; user 2026-10-13: "remove the
## default creatures from our maps … if people want to add them into their specific
## rooms that is up to them"). The old CreatureSpawner as a room's own object.
##
## On the room AUTHORITY it keeps up to `crawlers` / `hoppers` / `serpents` bots near
## the players — registered bots (BotRegistry.register) with that creature body and the
## ROAM brain — adding one every `interval` seconds `spawn_radius` away, and despawning
## any that every observer has left further than `despawn_radius`. They are ordinary
## bots: one scheduler (tiers + sleep), one replication path, every peer draws them.
##
## THE SPAWNER IS THE DECLARATION; its bots are not journalled one by one. Their ids
## start with this object's dot, so taking authority clears an earlier session's
## (or authority's) left-overs — ambient bots are re-spawned, not inherited — and
## deleting the spawner despawns its bots. Every other peer does nothing here.
## Settable (PrefabCatalogue.PROPS "spawner"); these defaults ARE the prop defaults.
var crawlers: int = 6
var hoppers: int = 4
var serpents: int = 2
var spawn_radius: float = 40.0
var despawn_radius: float = 80.0
var interval: float = 2.0
const ROAM := "res://bots/roam.gd"
const ROAM_RADIUS := 12.0
var _mine: Dictionary = {} # bot id -> body kind, the bots this spawner runs
var _next := 0
var _tag := ""
var _left := 0.0
var _was_auth := false
var _rng := RandomNumberGenerator.new()
var _mark: MeshInstance3D
func _ready() -> void:
_rng.randomize()
_tag = "%06x" % (_rng.randi() & 0xffffff)
_mark = MeshInstance3D.new()
_mark.name = "Mark"
var s := SphereMesh.new()
s.radius = 0.4
s.height = 0.8
_mark.mesh = s
var m := StandardMaterial3D.new()
m.albedo_color = Color(0.3, 0.9, 0.8)
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
_mark.material_override = m
add_child(_mark)
# Seen (and picked) in build mode only; in play the spawner is just its bots.
var gm: Node = get_node_or_null(^"/root/GameMode")
if gm != null:
gm.mode_changed.connect(_on_mode)
_on_mode("")
# THE HAND-OFF IS AN EVENT (BotRegistry.authority_changed, from the election).
var reg: Node = get_node_or_null(^"/root/BotRegistry")
if reg != null:
reg.authority_changed.connect(_on_authority)
if reg.is_authority():
_on_authority.call_deferred(true) # once the object's dot is set
## Taking authority: clear left-overs, then run. Losing it: forget ours — the new
## authority's spawner retires them (its own reconcile).
func _on_authority(is_auth: bool) -> void:
_was_auth = is_auth
if is_auth:
var reg: Node = get_node_or_null(^"/root/BotRegistry")
if reg != null and String(get_meta("object_dot", "")) != "":
_reconcile(reg)
else:
_mine.clear()
func _on_mode(_m: String) -> void:
var gm: Node = get_node_or_null(^"/root/GameMode")
_mark.visible = gm != null and gm.is_building()
## Every bot id this spawner mints starts with this.
func id_prefix() -> String:
return "spawn-" + String(get_meta("object_dot", "")).replace(":", "-") + "-"
func _physics_process(delta: float) -> void:
var reg: Node = get_node_or_null(^"/root/BotRegistry")
if reg == null or not _was_auth or String(get_meta("object_dot", "")) == "":
return
_left -= delta
if _left > 0.0:
return
_left = interval
var obs: PackedVector3Array = reg._observers()
if obs.is_empty():
return
_despawn_far(reg, obs)
_spawn_one(reg, obs)
## TAKING AUTHORITY: every bot with this spawner's prefix that it does not run is a
## left-over. Retire it (explicit despawn erases its row; a row never revived here is
## erased directly).
func _reconcile(reg: Node) -> void:
var gs: Node = get_node_or_null(^"/root/GameState")
if gs == null:
return
var pre: String = reg.KEY_MAP_REGISTRY + id_prefix()
var n := 0
for key: String in gs.keys_with_prefix(pre):
var id := key.trim_prefix(reg.KEY_MAP_REGISTRY)
if _mine.has(id):
continue
if reg.is_registered(id):
reg.despawn(id)
else:
gs.erase_state(key)
gs.erase_state(reg.KEY_MAP_POS + id)
n += 1
if n > 0:
print("[Spawner] took authority: retired %d left-over bot(s)" % n)
func _despawn_far(reg: Node, obs: PackedVector3Array) -> void:
var r2 := despawn_radius * despawn_radius
for id: String in _mine.keys():
var b: Node3D = reg.body_of(id)
var near := false
if b != null:
for o: Vector3 in obs:
if b.global_position.distance_squared_to(o) <= r2:
near = true
break
if not near:
reg.despawn(id)
_mine.erase(id)
func _count(kind: String) -> int:
var n := 0
for k: Variant in _mine.values():
if String(k) == kind:
n += 1
return n
## One bot, if there is room: the kind by remaining room (as the old spawner rolled),
## `spawn_radius` from a random observer, on the ground (a serpent above it).
func _spawn_one(reg: Node, obs: PackedVector3Array) -> void:
var room := {"crawler": crawlers - _count("crawler"), "hopper": hoppers - _count("hopper"),
"serpent": serpents - _count("serpent")}
var total := 0
for k: String in room:
room[k] = maxi(0, int(room[k]))
total += int(room[k])
if total <= 0:
return
var roll := _rng.randi_range(1, total)
var kind := ""
for k: String in room:
roll -= int(room[k])
if roll <= 0:
kind = k
break
var host: Node3D = reg._container_for([reg.BINDING_MAP]) as Node3D
var space := get_world_3d().direct_space_state if is_inside_tree() else null
if host == null or space == null:
return
var o: Vector3 = obs[_rng.randi_range(0, obs.size() - 1)]
for _attempt in 3:
var a := _rng.randf() * TAU
var d := spawn_radius * _rng.randf_range(0.7, 1.0)
var at := o + Vector3(cos(a) * d, 0.0, sin(a) * d)
var hit: Dictionary = SphereSweep.sweep(space, at + Vector3.UP * 30.0, at - Vector3.UP * 60.0, 0.3)
if hit.is_empty():
continue
at.y = (hit["point"] as Vector3).y + (_rng.randf_range(15.0, 40.0) if kind == "serpent" else 0.1)
var id := "%s%s-%d" % [id_prefix(), _tag, _next]
_next += 1
var r: Dictionary = reg.register(id, [reg.BINDING_MAP], kind,
{"kind": reg.BRAIN_SCRIPT_FILE, "path": ROAM, "params": {"radius": ROAM_RADIUS}}, host.to_local(at))
if bool(r.get("ok", false)):
_mine[id] = kind
return
## Deleted (or its map ending): its bots go with it, on the authority.
func _exit_tree() -> void:
var reg: Node = get_node_or_null(^"/root/BotRegistry")
if reg == null or not _was_auth:
return
for id: String in _mine.keys():
reg.despawn(id)
_mine.clear()
## For gates: the bots this spawner runs (id -> body kind).
func bots() -> Dictionary:
return _mine