OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / bots / walker.gd

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