OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / player.gd

603 lines · gd ref:
extends CharacterBody3D

## Physics constants — carried over from hyper-tps_demo player_core.gd
const MOVEMENT_SPEED: float = 14.0
const FALL_ACCELERATION: float = 27.0
const MOVEMENT_ACCELERATION: float = 4.0
const JUMP_HEIGHT: float = 15.0
const TURN_VELOCITY: float = 15.0
const MOUSE_SENSITIVITY: float = 0.3

## Grappling hook constants
const GRAPPLE_SPEED: float = 0.5
const REST_LENGTH: float = 1.0
const MAX_GRAPPLE_SPEED: float = 2.75

## Quake liquid swim (WinQuake/sv_user.c SV_WaterMove) — view-directed, friction-damped.
const WATER_SPEED_SCALE: float = 0.7    # wishspeed *= 0.7 (water is 70% of ground speed)
const WATER_FRICTION: float = 4.0       # sv_friction
const WATER_ACCEL: float = 10.0         # sv_accelerate
const WATER_IDLE_SINK: float = 0.18     # idle: drift toward the bottom (Quake wishvel[2]-=60)
## Quake content color-shifts (WinQuake/view.c cshift_*): {destcolor RGB 0-255, percent/255}.
const LIQUID_BLEND := {
	"water": Color(130.0 / 255.0, 80.0 / 255.0, 50.0 / 255.0, 128.0 / 255.0),
	"lava": Color(255.0 / 255.0, 80.0 / 255.0, 0.0 / 255.0, 150.0 / 255.0),
	"slime": Color(0.0 / 255.0, 25.0 / 255.0, 5.0 / 255.0, 150.0 / 255.0),
}

## Landing particles
const LandingParticles := preload("res://scenes/player/landing_particles.tscn")

## Quantized sync state (18 bytes packed via PlayerSync, ~60% bandwidth reduction)
## Format: pos.xyz(6) rot_y(2) mesh_rot_y(2) vel.xyz(6) flags(1) reserved(1)
var sync_state: PackedByteArray = PackedByteArray()

## Decoded sync values (unpacked from sync_state by remotes)
var _decoded_position: Vector3 = Vector3.ZERO
var _decoded_rotation_y: float = 0.0
var _decoded_mesh_rotation_y: float = 0.0
var _decoded_velocity: Vector3 = Vector3.ZERO
var _decoded_on_floor: bool = true

## Grapple state
var grapple_hooked: bool = false
var grapple_target: Vector3 = Vector3.ZERO

## Liquid swim state (local player only)
var _in_liquid: bool = false
var _water_level: int = 0            # Quake SV_CheckWater: 0 dry, 1 feet, 2 mid (swim), 3 eye (blend)
var _liquid_overlay: ColorRect = null

## Dead reckoning state (remote players only)
var _last_sync_time: float = 0.0
var _last_synced_position: Vector3 = Vector3.ZERO
var _sync_velocity: Vector3 = Vector3.ZERO
const SYNC_INTERVAL: float = 0.05  # matches replication_interval
const SNAP_DISTANCE: float = 5.0   # teleport instead of lerp if too far

## Animation state
var _was_on_floor: bool = true
var _footstep_timer: float = 0.0
var _y_before_slide: float = 0.0
const FOOTSTEP_INTERVAL: float = 0.35

## Node references
@onready var model: Node3D = $Model
@onready var tumble_model: Node3D = $Model/TumbleModel
@onready var camera_head: Node3D = $CameraHead
@onready var camera: Camera3D = $CameraHead/CameraPivot/SpringArm3D/Camera3D
@onready var camera_pivot: Node3D = $CameraHead/CameraPivot
@onready var grapple_cast: RayCast3D = $CameraHead/CameraPivot/GrappleHookCast
@onready var grapple_line: MeshInstance3D = $Model/GrappleLine
@onready var flashlight: OmniLight3D = $OmniLight3D
@onready var input_sync: Node = $InputSync
@onready var anim_tree: AnimationTree = $AnimationTree
@onready var grapple_point: MeshInstance3D = $GrapplePoint
@onready var name_label: Label3D = $NameLabel
var anim_player: AnimationPlayer = null
var grapple_ik: SkeletonIK3D = null
var grapple_ik_target: Marker3D = null

## Sound references
@onready var snd_jump: AudioStreamPlayer3D = $Sounds/Jump
@onready var snd_land: AudioStreamPlayer3D = $Sounds/Land
@onready var snd_footstep_1: AudioStreamPlayer3D = $Sounds/Footstep1
@onready var snd_footstep_2: AudioStreamPlayer3D = $Sounds/Footstep2
@onready var snd_footstep_3: AudioStreamPlayer3D = $Sounds/Footstep3
@onready var snd_grapple_fire: AudioStreamPlayer3D = $Sounds/GrappleFire
@onready var snd_grapple_release: AudioStreamPlayer3D = $Sounds/GrappleRelease
@onready var snd_light: AudioStreamPlayer3D = $Sounds/ToggleLight
@onready var _footstep_sounds: Array[AudioStreamPlayer3D] = []


func _ready() -> void:
	# Discoverable by other systems (BotRegistry tiers, spawner objects, etc.) via group lookup.
	add_to_group("player")

	_footstep_sounds = [snd_footstep_1, snd_footstep_2, snd_footstep_3]

	# Find AnimationPlayer inside the imported GLB scene
	if tumble_model:
		anim_player = _find_child_of_type(tumble_model, "AnimationPlayer") as AnimationPlayer

	# Connect AnimationTree to the GLB's AnimationPlayer
	if anim_player and anim_tree:
		# Debug: show what animations the GLB has
		print("[Player] AnimationPlayer found: ", anim_player.get_path())
		print("[Player] Libraries: ", anim_player.get_animation_library_list())
		for lib_name in anim_player.get_animation_library_list():
			var lib = anim_player.get_animation_library(lib_name)
			print("[Player] Library '", lib_name, "' anims: ", lib.get_animation_list())
			# Remove existing library if present, then add
			if anim_tree.has_animation_library(lib_name):
				anim_tree.remove_animation_library(lib_name)
			anim_tree.add_animation_library(lib_name, lib)

		# Ensure idle pose loops (GLB exports it as single-frame, no loop)
		for lib_name in anim_player.get_animation_library_list():
			var lib = anim_player.get_animation_library(lib_name)
			if lib.has_animation("default"):
				lib.get_animation("default").loop_mode = Animation.LOOP_LINEAR

		# Set root to TumbleModel so animation tracks (Armature/Skeleton3D) resolve
		anim_tree.root_node = anim_tree.get_path_to(tumble_model)
		anim_player.active = false
		anim_tree.active = true
		print("[Player] AnimationTree active, root=", anim_tree.root_node)
		print("[Player] AnimationTree libraries: ", anim_tree.get_animation_library_list())
	else:
		push_warning("[Player] Setup failed - anim_player:", anim_player != null, " anim_tree:", anim_tree != null)
		if tumble_model:
			print("[Player] TumbleModel children:")
			for child in tumble_model.get_children():
				print("  ", child.name, " (", child.get_class(), ")")

	# Set up grapple hand IK (right arm reaches toward target)
	if tumble_model:
		var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
		if skeleton:
			var ik := SkeletonIK3D.new()
			ik.name = "GrappleIK"
			ik.root_bone = "DEF-upper_arm.R"
			ik.tip_bone = "DEF-hand.R"
			skeleton.add_child(ik)
			grapple_ik = ik
			var ik_target := Marker3D.new()
			ik_target.name = "IKTarget"
			add_child(ik_target)
			grapple_ik_target = ik_target
			ik.target_node = ik.get_path_to(ik_target)

	# Exclude player's own body from SpringArm3D collision
	# (otherwise it detects our CylinderShape3D and zooms into the head on jump)
	var spring_arm: SpringArm3D = $CameraHead/CameraPivot/SpringArm3D
	spring_arm.add_excluded_object(get_rid())

	if is_local_player():
		camera.current = true
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
		# Hide own name label (only visible on remote players).
		name_label.visible = false
		_setup_liquid_overlay()
	else:
		camera.current = false
	_init_nameplate()


func is_local_player() -> bool:
	return is_multiplayer_authority()


func _unhandled_input(event: InputEvent) -> void:
	if not is_local_player():
		return

	# Mouse look
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		# Degrees per pixel from Settings (Controls tab; was the fixed MOUSE_SENSITIVITY 0.3).
		var look: Vector2 = Settings.mouse_look_scale()
		camera_head.rotate_y(deg_to_rad(-event.relative.x * look.x))
		camera_pivot.rotate_x(deg_to_rad(-event.relative.y * look.y))
		camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-80), deg_to_rad(80))
		input_sync.camera_rotation_y = camera_head.rotation.y
		input_sync.camera_pitch = camera_pivot.rotation.x

	# Grapple shoot/release
	# Through the keymap so build mode suppresses it — see player_procedural.gd.
	if Keymap.tapped(event, "grapple"):
		grapple_cast.force_raycast_update()
		if grapple_cast.is_colliding():
			var hit_pos: Vector3 = grapple_cast.get_collision_point()
			_shoot_grapple.rpc(hit_pos)
	elif Keymap.released(event, "grapple"):
		_release_grapple.rpc()

	# Toggle light
	if Keymap.tapped(event, "toggle_light"):
		_toggle_light.rpc()

	# Respawn
	if Keymap.tapped(event, "respawn"):
		_respawn.rpc()

	# Escape to release mouse, click to recapture (unless a UI overlay is open)
	if event.is_action_pressed("ui_cancel"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	elif event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
		# ASK THE MODE, don't name the panel. This used to check one specific
		# overlay by node name (`game._sky_debug_panel.visible`), so every new
		# editor panel would have had to be added to a condition inside two player
		# scripts — and the one that got missed would silently eat its own clicks
		# by recapturing the mouse on the first press. GameMode owns the pointer.
		if not GameMode.wants_pointer():
			Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


## Encode position/rotation/velocity into 18-byte PackedByteArray.
## Position: int16 per axis (cm resolution, +-327m range)
## Rotation: uint16 (0-65535 maps to 0-2PI)
## Velocity: int16 per axis (cm/s, +-327m/s)
## Flags: bit 0 = on_floor
static func _encode_sync_state(pos: Vector3, rot_y: float, mesh_rot_y: float,
		vel: Vector3, on_floor: bool) -> PackedByteArray:
	var buf := PackedByteArray()
	buf.resize(18)
	# Position (cm resolution)
	buf.encode_s16(0, clampi(roundi(pos.x * 100.0), -32768, 32767))
	buf.encode_s16(2, clampi(roundi(pos.y * 100.0), -32768, 32767))
	buf.encode_s16(4, clampi(roundi(pos.z * 100.0), -32768, 32767))
	# Rotation (0-2PI -> 0-65535)
	buf.encode_u16(6, roundi(fmod(rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
	buf.encode_u16(8, roundi(fmod(mesh_rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
	# Velocity (cm/s)
	buf.encode_s16(10, clampi(roundi(vel.x * 100.0), -32768, 32767))
	buf.encode_s16(12, clampi(roundi(vel.y * 100.0), -32768, 32767))
	buf.encode_s16(14, clampi(roundi(vel.z * 100.0), -32768, 32767))
	# Flags
	buf[16] = 1 if on_floor else 0
	buf[17] = 0  # reserved
	return buf


static func _decode_sync_state(buf: PackedByteArray) -> Dictionary:
	if buf.size() < 18:
		return {}
	return {
		"position": Vector3(
			buf.decode_s16(0) / 100.0,
			buf.decode_s16(2) / 100.0,
			buf.decode_s16(4) / 100.0),
		"rotation_y": buf.decode_u16(6) / 65535.0 * TAU,
		"mesh_rotation_y": buf.decode_u16(8) / 65535.0 * TAU,
		"velocity": Vector3(
			buf.decode_s16(10) / 100.0,
			buf.decode_s16(12) / 100.0,
			buf.decode_s16(14) / 100.0),
		"on_floor": (buf[16] & 1) != 0,
	}


func _physics_process(delta: float) -> void:
	if is_local_player():
		_process_local(delta)
	else:
		_process_remote(delta)


func _process_local(delta: float) -> void:
	var input_dir: Vector2 = input_sync.input_direction
	var forward := -camera_head.global_transform.basis.z
	forward.y = 0
	forward = forward.normalized()
	var right := camera_head.global_transform.basis.x
	right.y = 0
	right = right.normalized()

	var direction := (forward * -input_dir.y + right * input_dir.x).normalized()

	# Quake waterlevel (SV_CheckWater): sample feet / mid / eye. global_position is at the
	# capsule feet; capsule centre 0.79, head/eye 1.5. Swim engages at level >= 2 (mid-body
	# submerged); the screen blend shows only at level 3 (head under) — NOT off the 3rd-person
	# SpringArm camera, which sits behind/above the player and is usually out of the water.
	var tree := get_tree()
	var mid_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 0.79, 0))
	var eye_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 1.5, 0))
	_water_level = 0
	if not LiquidVolume.content_at(tree, global_position + Vector3(0, 0.1, 0)).is_empty():
		_water_level = 1
	if not mid_liq.is_empty():
		_water_level = 2
	if not eye_liq.is_empty():
		_water_level = 3
	_in_liquid = _water_level >= 2
	_update_liquid_blend(eye_liq)   # non-empty only when the head (eye) point is submerged

	if _in_liquid and not grapple_hooked:
		# Swim (SV_WaterMove): view-directed water physics replaces ground accel + gravity + jump.
		_apply_water_move(delta)
	else:
		# Horizontal movement with acceleration
		var target_vel := direction * MOVEMENT_SPEED
		var y_cache := velocity.y
		velocity = velocity.lerp(Vector3(target_vel.x, velocity.y, target_vel.z), MOVEMENT_ACCELERATION * delta)
		velocity.y = y_cache

		# Gravity and jump
		if not grapple_hooked:
			velocity.y -= FALL_ACCELERATION * delta
			if input_sync.wants_jump and is_on_floor():
				velocity.y = JUMP_HEIGHT
				snd_jump.play()

	# Grappling hook physics
	if grapple_hooked:
		_apply_grapple_physics()

	_y_before_slide = velocity.y
	move_and_slide()

	# Landing detection
	if is_on_floor() and not _was_on_floor:
		snd_land.play()
		# Landing particles on hard fall
		if _y_before_slide < -17.0:
			var particles := LandingParticles.instantiate()
			get_tree().current_scene.add_child(particles)
			particles.global_position = global_position
	_was_on_floor = is_on_floor()

	# Footstep sounds
	if is_on_floor() and direction.length() > 0.1:
		_footstep_timer += delta
		if _footstep_timer >= FOOTSTEP_INTERVAL:
			_footstep_timer = 0.0
			# Footsteps muted — current sounds are placeholder. Re-enable when assets are good.
			# _footstep_sounds[randi() % _footstep_sounds.size()].play()
	else:
		_footstep_timer = 0.0

	# Rotate model toward movement direction (negate to match old player_core.gd)
	if direction.length() > 0.1:
		var target_angle := atan2(-direction.x, -direction.z)
		model.rotation.y = lerp_angle(model.rotation.y, target_angle, TURN_VELOCITY * delta)

	# Update animations
	_update_animation(delta)

	# Update grapple line and visual point
	_update_grapple_line()
	_update_grapple_point()

	# Write quantized sync state (18 bytes)
	sync_state = _encode_sync_state(global_position, camera_head.rotation.y,
			model.rotation.y, velocity, is_on_floor())


func _process_remote(delta: float) -> void:
	# Decode quantized sync state.
	var state: Dictionary = _decode_sync_state(sync_state)
	if state.is_empty():
		return

	_decoded_position = state["position"]
	_decoded_rotation_y = state["rotation_y"]
	_decoded_mesh_rotation_y = state["mesh_rotation_y"]
	_decoded_velocity = state["velocity"]
	_decoded_on_floor = state["on_floor"]

	var now: float = Time.get_ticks_msec() / 1000.0
	var time_since_sync: float = now - _last_sync_time

	# Detect new sync data arriving.
	if _decoded_position != _last_synced_position:
		_sync_velocity = _decoded_velocity
		_last_synced_position = _decoded_position
		_last_sync_time = now
		time_since_sync = 0.0

	# Dead reckoning: extrapolate when sync is late.
	var target_pos: Vector3 = _decoded_position
	if time_since_sync > SYNC_INTERVAL:
		target_pos += _sync_velocity * (time_since_sync - SYNC_INTERVAL)

	# Snap if too far (teleport/respawn), otherwise smooth lerp.
	if global_position.distance_to(target_pos) > SNAP_DISTANCE:
		global_position = target_pos
	else:
		global_position = global_position.lerp(target_pos, 10.0 * delta)

	velocity = _decoded_velocity  # Feed velocity so animations work.
	model.rotation.y = lerp_angle(model.rotation.y, _decoded_mesh_rotation_y, 10.0 * delta)
	camera_head.rotation.y = lerp_angle(camera_head.rotation.y, input_sync.camera_rotation_y, 10.0 * delta)
	camera_pivot.rotation.x = lerp_angle(camera_pivot.rotation.x, input_sync.camera_pitch, 10.0 * delta)
	_update_grapple_line()
	_update_animation(delta)


func _update_animation(_delta: float) -> void:
	if not anim_tree or not anim_tree.active:
		return

	# IWR blend (matches old player_core.gd line 260)
	# Blend3: -1=idle, 0=walk, 1=run
	# Old formula: currentSpeed = ((abs(vx) + abs(vz) - 7) / 7)
	var speed_sum: float = absf(velocity.x) + absf(velocity.z)
	var iwr := clampf((speed_sum - 7.0) / 7.0, -1.0, 1.0)
	anim_tree.set("parameters/iwr_blend/blend_amount", iwr)

	# Air state — use _decoded_on_floor for remote players (is_on_floor() only
	# works after move_and_slide() which remotes don't call).
	var on_floor: bool = is_on_floor() if is_local_player() else _decoded_on_floor
	if not on_floor:
		anim_tree.set("parameters/air_transition/transition_request", "air")
		if velocity.y > 0.1:
			anim_tree.set("parameters/air_blend/blend_amount", -1.0)  # jumping up
		elif velocity.y < -0.1:
			anim_tree.set("parameters/air_blend/blend_amount", 0.0)   # falling
	else:
		if not _was_on_floor:
			# Just landed — briefly show landing animation
			anim_tree.set("parameters/air_blend/blend_amount", 1.0)
		anim_tree.set("parameters/air_transition/transition_request", "ground")
	_was_on_floor = on_floor


func _apply_grapple_physics() -> void:
	var player_to_hook := grapple_target - global_position
	var length := player_to_hook.length()

	# Distance-adaptive damping
	if length > 4.0:
		velocity *= 0.999
	else:
		velocity *= 0.9

	# Hooke's law: F = k * (x - rest_length)
	var force := GRAPPLE_SPEED * (length - REST_LENGTH)
	force = clampf(force, -MAX_GRAPPLE_SPEED, MAX_GRAPPLE_SPEED)
	velocity += player_to_hook.normalized() * force


func _update_grapple_line() -> void:
	if not grapple_hooked:
		grapple_line.visible = false
		return

	grapple_line.visible = true
	var start := global_position + Vector3(0, 0.8, 0)
	var end_pos := grapple_target
	var mid := (start + end_pos) * 0.5
	var dist := start.distance_to(end_pos)

	grapple_line.global_position = mid
	grapple_line.global_rotation = Vector3.ZERO
	if dist > 0.01:
		grapple_line.look_at(end_pos, Vector3.UP)
		# Rotate 90° on X so CylinderMesh Y-axis aligns with look_at Z-axis
		grapple_line.rotate_object_local(Vector3.RIGHT, deg_to_rad(90))
	grapple_line.scale = Vector3(1, dist, 1)


func _update_grapple_point() -> void:
	if grapple_cast.is_colliding():
		grapple_point.visible = true
		grapple_point.global_position = grapple_cast.get_collision_point()
	else:
		grapple_point.visible = false


# ---- RPCs for discrete events ----

@rpc("any_peer", "call_local", "reliable")
func _shoot_grapple(target_pos: Vector3) -> void:
	grapple_hooked = true
	grapple_target = target_pos
	snd_grapple_fire.play()
	if grapple_ik and is_local_player():
		grapple_ik_target.global_position = target_pos
		grapple_ik.start()


@rpc("any_peer", "call_local", "reliable")
func _release_grapple() -> void:
	grapple_hooked = false
	snd_grapple_release.play()
	if grapple_ik:
		grapple_ik.stop()


@rpc("any_peer", "call_local", "reliable")
func _toggle_light() -> void:
	flashlight.visible = not flashlight.visible
	snd_light.play()


@rpc("any_peer", "call_local", "reliable")
func _respawn() -> void:
	var spawns := get_tree().get_nodes_in_group("spawn_points")
	if spawns.is_empty():
		global_position = Vector3(0, 2, 0)
	else:
		global_position = spawns[randi() % spawns.size()].global_position
	velocity = Vector3.ZERO
	grapple_hooked = false


# ---- Helpers ----

func _find_child_of_type(node: Node, type_name: String) -> Node:
	for child in node.get_children():
		if child.get_class() == type_name:
			return child
		var found := _find_child_of_type(child, type_name)
		if found:
			return found
	return null


# ---- Quake liquids (water / lava / slime) ----

func _setup_liquid_overlay() -> void:
	# Full-screen tint for the Quake content color-shift (V_CalcBlend) when the eye is
	# submerged. A ColorRect on its own CanvasLayer above the 3D view; the color's alpha
	# carries the blend percentage. mouse_filter IGNORE so it never eats input.
	var layer := CanvasLayer.new()
	layer.name = "LiquidBlend"
	layer.layer = 1
	add_child(layer)
	var rect := ColorRect.new()
	rect.set_anchors_preset(Control.PRESET_FULL_RECT)
	rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
	rect.visible = false
	layer.add_child(rect)
	_liquid_overlay = rect


func _update_liquid_blend(view_liquid: Dictionary) -> void:
	if _liquid_overlay == null:
		return
	var cls := String(view_liquid.get("class", ""))
	if cls != "" and LIQUID_BLEND.has(cls):
		_liquid_overlay.color = LIQUID_BLEND[cls]    # rgba: alpha == Quake percent/255
		_liquid_overlay.visible = true
	else:
		_liquid_overlay.visible = false


func _apply_water_move(delta: float) -> void:
	# Faithful SV_WaterMove (WinQuake/sv_user.c), adapted to our units (MOVEMENT_SPEED == the
	# ground analogue of sv_maxspeed). You swim where you LOOK — the wish vector is built from
	# the camera basis (full 3D, includes pitch), like Quake's v_angle forward/right vectors.
	var input_dir: Vector2 = input_sync.input_direction
	var fwd := -camera.global_transform.basis.z
	var right := camera.global_transform.basis.x
	var wishvel := fwd * -input_dir.y + right * input_dir.x
	if input_sync.wants_jump:
		wishvel += Vector3.UP                          # jump == swim up (Quake upmove)
	if input_dir == Vector2.ZERO and not input_sync.wants_jump:
		wishvel = Vector3.DOWN * WATER_IDLE_SINK       # idle → drift toward the bottom
	var wishspeed := minf(wishvel.length(), 1.0) * MOVEMENT_SPEED * WATER_SPEED_SCALE
	# Water friction: newspeed = speed - dt*speed*friction (scale velocity to it).
	var speed := velocity.length()
	var newspeed := 0.0
	if speed > 0.0:
		newspeed = maxf(speed - delta * speed * WATER_FRICTION, 0.0)
		velocity *= newspeed / speed
	# Water acceleration toward the wish direction, capped at (wishspeed - newspeed).
	if wishspeed > 0.0:
		var addspeed := wishspeed - newspeed
		if addspeed > 0.0:
			var accelspeed := minf(WATER_ACCEL * wishspeed * delta, addspeed)
			velocity += wishvel.normalized() * accelspeed


# ---- nameplate ---------------------------------------------------------------

## THE LABEL IS THE VERIFIED KEET MEMBER'S ROSTER NAME (hyper-games #89 §3.3).
##
## It used to be self-asserted: an any_peer `_set_player_name` RPC (any peer could
## relabel any other) plus a realtime player-name state copy that went out unreliable
## and was never replayed, so a late joiner saw "Player". KeetService.peer_identity
## binds each mesh peer to a keet member by a proof checked against the key the
## swarm handshake authenticated, and names it from the room roster; this only
## displays that, and follows it by signal (claims, roster, peers leaving).
func _init_nameplate() -> void:
	if KeetService == null or KeetService.peer_identity == null:
		return
	_apply_nameplate()
	if not KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
		KeetService.peer_identity.names_changed.connect(_apply_nameplate)


func _apply_nameplate() -> void:
	if not is_instance_valid(name_label) or KeetService == null or KeetService.peer_identity == null:
		return
	name_label.text = KeetService.peer_identity.name_for_peer(get_multiplayer_authority())


func _exit_tree() -> void:
	if KeetService == null or KeetService.peer_identity == null:
		return
	if KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
		KeetService.peer_identity.names_changed.disconnect(_apply_nameplate)