extends RefCounted
## Drives THIS PEER'S OWN PLAYER — the peer-bot case from spec/bot-testbed.md (e1).
##
## BotRegistry.register("walker", ["map"], "none",
## {"kind": "script_file", "path": "res://bots/walker.gd"})
##
## body = none, because this bot does NOT create a body: it steers the player the
## game already spawned for this peer. Authority is the peer's own process, which
## is what makes a peer-bot a peer rather than an owned entity.
##
## ⚠ IT WRITES INPUT, NEVER A POSITION. The four fields here are the same ones a
## human's keyboard and mouse write, replicated by the same MultiplayerSynchronizer
## at the same interval. That is what makes this a TEST: if the avatar moves
## smoothly on another machine, the ephemeral path is working. A bot that set
## global_position would look identical locally and prove nothing.
var bot_id: String = ""
var radius: float = 6.0
var period: float = 14.0 ## seconds per lap
var _t: float = 0.0
var _input: Node = null ## the InputSync node we are driving
var _head: Node3D = null ## CameraHead: player.gd reads it for "forward"
## ⛔ ACQUISITION IS RETRIED, NOT DONE ONCE. This used to look the player up in
## _bot_init, warn "no local player to drive", and return -- after which nothing
## ever tried again, so a brain that started a moment too early was dead for its
## whole life with one warning to show for it. hyper-games #77 item 4 reports it
## failing "against the real tree every time", and a one-shot lookup produces
## exactly that symptom whatever the underlying timing is: the bot can be
## registered by an admin command that lands before on_game_started has spawned
## anything, and on a --dedicated peer the game does not start until /start.
##
## So _bot_init only tries, and _bot_tick keeps trying until it succeeds. Same
## mistake, same shape, as the one-shot reconnect in game.gd.
func _bot_init() -> void:
_acquire()
## Seconds between acquisition attempts while there is no player yet. A tick is
## every frame; retrying the tree walk at 60 Hz for a peer that has not started
## the game is pointless work and pointless log noise.
const RETRY_EVERY := 1.0
var _since_retry: float = 0.0
var _warned: bool = false
## Try to take control of this peer's player. Returns true once driving.
func _acquire() -> bool:
if _input != null and is_instance_valid(_input):
return true
var player := _find_local_player()
if player == null:
if not _warned:
_warned = true
print("[walker] no local player yet — will keep looking (is the game started?)")
return false
var input := player.get_node_or_null(^"InputSync")
if input == null:
if not _warned:
_warned = true
push_warning("[walker] player %s has no InputSync" % player.name)
return false
_input = input
_head = player.get_node_or_null(^"CameraHead") as Node3D
# Tell player_input.gd to stand down, or it rewrites these fields every
# physics frame from the (absent) keyboard and the bot appears to do nothing.
_input.set("driven", true)
_warned = false
print("[walker] driving %s" % player.name)
return true
func _bot_tick(delta: float) -> void:
if _input == null or not is_instance_valid(_input):
# The body can also GO AWAY -- a map change or a respawn frees it -- so
# this is a re-acquire, not only a first acquire.
_input = null
_since_retry += delta
if _since_retry < RETRY_EVERY:
return
_since_retry = 0.0
if not _acquire():
return
_t += delta
# Walk forward while turning: a circle, expressed the way a PLAYER expresses
# one -- hold forward, sweep the view -- rather than as a position.
var heading: float = TAU * (_t / period)
_input.set("input_direction", Vector2(0.0, -1.0))
_input.set("camera_rotation_y", heading)
_input.set("wants_jump", false)
# player.gd derives its forward vector from CameraHead's transform, so the head
# has to turn too. benchmark mode does the same thing for the same reason.
if _head != null and is_instance_valid(_head):
_head.rotation.y = heading
func _bot_exit() -> void:
# Hand the player back. Leaving `driven` set would make the peer unresponsive
# to its own keyboard with no visible cause -- the control equivalent of a leak.
if _input != null and is_instance_valid(_input):
_input.set("driven", false)
_input.set("input_direction", Vector2.ZERO)
_input.set("wants_jump", false)
print("[walker] '%s' released the player after %.1fs" % [bot_id, _t])
## The player this peer has authority over, or null before the game starts.
##
## PlayerPosition is asked FIRST. game.gd hands it the local player directly in
## _spawn_player_for (attach_local_player), so it is the maintained answer rather
## than one re-derived by walking the tree and testing is_multiplayer_authority()
## on every child -- which is also true of the orphan player the offline peer used
## to leave behind, named "1" and holding authority 1.
##
## The tree walk stays as a fallback: a brain is published content and may run on
## a build whose PlayerPosition predates get_local_player().
func _find_local_player() -> Node:
var tree := Engine.get_main_loop() as SceneTree
if tree == null:
return null
var pp := tree.root.get_node_or_null(^"PlayerPosition")
if pp != null and pp.has_method("get_local_player"):
var held: Variant = pp.get_local_player()
if held != null and is_instance_valid(held):
return held
var game := tree.root.get_node_or_null(^"Game")
if game == null:
return null
var players := game.get_node_or_null(^"Players")
if players == null:
return null
for c in players.get_children():
if c.has_method("is_local_player") and c.is_local_player():
return c
return null