OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / prefabs / spawner_object.gd

200 lines · gd ref:
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