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)