extends Node3D ## Root controller for scenes/game.tscn. ## ## Local single-player by default (walk around the test level). When ## Session.active (set by world_list.gd after POST /api/worlds/{id}/join), ## connects a NetworkClient to the world's dedicated server and runs ## networked: the local Player keeps predicting with its own collision-aware ## movement, its input is forwarded to the server each physics frame, the ## server's authoritative position is a correction backstop, and other ## players are rendered from snapshots. ## ## Appearance sync is a follow-up -- remote players show as labelled capsules, ## not their own avatars (the server doesn't replicate loadouts yet). const REMOTE_LERP := 12.0 ## Snap the local player to the server position past this drift (metres). ## Tight enough that the client always ends up where the server actually ## spawned it (which is spread out, not the fixed marker player.gd uses). const CORRECTION_THRESHOLD := 0.6 @onready var _player: Player = $Player var _net: NetworkClient = null var _networked := false var _aligned := false # snapped to the server spawn yet? var _remotes: Dictionary = {} # peer_id -> Node3D var _remote_prev: Dictionary = {} # peer_id -> Vector3 (last snapshot position) var _status_label: Label = null func _ready() -> void: _show_build_stamp() if Session.active: _setup_networked() static func _build_label() -> String: var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ) if f == null: return "dev" var d = JSON.parse_string(f.get_as_text()) if d is Dictionary: return "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")] return "dev" func _show_build_stamp() -> void: var layer := CanvasLayer.new() layer.layer = 128 add_child(layer) var lbl := Label.new() lbl.text = "build %s" % _build_label() lbl.add_theme_color_override("font_color", Color(1, 1, 0.45)) lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.85)) lbl.add_theme_constant_override("outline_size", 4) lbl.add_theme_font_size_override("font_size", 14) # Anchor a full-width strip to the bottom of the screen and left-align the # text in it. (A bare preset + .position on a zero-size Label collapses the # rect to negative width and nothing draws -- which is why this never showed.) lbl.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT lbl.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM lbl.offset_left = 8 lbl.offset_right = -8 lbl.offset_top = -30 lbl.offset_bottom = -6 lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(lbl) func _exit_tree() -> void: # Leaving the world -- close the ENet connection cleanly so the server # fires peer_disconnected and removes our player (otherwise everyone else # keeps seeing a frozen "ghost" of us). if _net != null and is_instance_valid(_net): _net.disconnect_from_server() func _setup_networked() -> void: _show_status("Connecting to %s…" % Session.host) _net = NetworkClient.new() _net.name = "NetworkClient" add_child(_net) _net.server_disconnected.connect(_on_server_lost) _net.connection_failed.connect(_on_server_lost) var ok: bool = await _net.connect_to_server(Session.host, Session.port) if not ok: _on_server_lost() return _networked = true _hide_status() # Physics-process AFTER the player so net_input reflects this frame. process_physics_priority = 10 func _physics_process(_delta: float) -> void: if not _networked or _net == null or not _net.is_connected_to_server(): return # 1. Forward the local player's input to the server. var inp: Dictionary = _player.net_input _net.send_input(inp["move_x"], inp["move_z"], inp["jump"], inp["look_yaw"]) # 2. Server-authoritative position. var own: Dictionary = _net.get_own_server_state() if own.has("position"): var server_pos: Vector3 = own["position"] if not _aligned: # First snapshot: hard-snap to where the server actually spawned # us (player.gd starts everyone on the fixed marker, but the # server spreads spawns out). _player.apply_server_correction(server_pos) _aligned = true elif _player.global_position.distance_to(server_pos) > CORRECTION_THRESHOLD: _player.apply_server_correction(server_pos) # 3. Remote players. _sync_remotes(_net.get_remote_states(), _delta) func _sync_remotes(states: Dictionary, delta: float) -> void: for pid in states: var st: Dictionary = states[pid] var node: Node3D = _remotes.get(pid) if node == null: node = _make_remote(pid) _remotes[pid] = node add_child(node) node.global_position = st.get("position", Vector3.ZERO) var target: Vector3 = st.get("position", node.global_position) node.global_position = node.global_position.lerp(target, clampf(REMOTE_LERP * delta, 0.0, 1.0)) node.rotation.y = st.get("rotation_y", node.rotation.y) # Drive the remote's walk cycle from how far its authoritative # position moved this snapshot. var prev: Vector3 = _remote_prev.get(pid, target) var anim := node.get_node_or_null("LimbAnim") if anim != null and delta > 0.0: anim.set("speed", (target - prev).length() / delta) _remote_prev[pid] = target for pid in _remotes.keys(): if not states.has(pid): _remotes[pid].queue_free() _remotes.erase(pid) _remote_prev.erase(pid) func _make_remote(pid: int) -> Node3D: var root := Node3D.new() root.name = "Remote_%d" % pid # Other players get the assembled avatar too (everyone shares the default # look for now -- per-player loadout replication is the follow-up), tinted # by peer id so they're still tellable apart, plus a name tag. var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95) var avatars := get_node_or_null("/root/AvatarSystem") var visual: Node3D = null if avatars and avatars.has_method("build_current_avatar"): visual = avatars.build_current_avatar() if visual != null: visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server) for mi in _all_mesh_instances(visual): var m := StandardMaterial3D.new() m.albedo_color = tint mi.material_override = m root.add_child(visual) var skel := visual.find_child("Skeleton3D", true, false) if skel is Skeleton3D: var anim := preload("res://scripts/avatar/limb_animator.gd").new() anim.name = "LimbAnim" root.add_child(anim) anim.call("setup", skel) else: var body := MeshInstance3D.new() var capsule := CapsuleMesh.new() capsule.radius = 0.35 capsule.height = 1.7 body.mesh = capsule body.position = Vector3(0, 0.85, 0) var mat := StandardMaterial3D.new() mat.albedo_color = tint body.material_override = mat root.add_child(body) var tag := Label3D.new() tag.text = "Player %d" % pid tag.position = Vector3(0, 2.1, 0) tag.billboard = BaseMaterial3D.BILLBOARD_ENABLED tag.no_depth_test = true tag.pixel_size = 0.004 root.add_child(tag) return root func _all_mesh_instances(node: Node, out: Array = []) -> Array: if node is MeshInstance3D: out.append(node) for c in node.get_children(): _all_mesh_instances(c, out) return out func _on_server_lost() -> void: _networked = false _show_status("Disconnected from the server. Returning to worlds…") await get_tree().create_timer(1.5).timeout Session.clear() get_tree().change_scene_to_file("res://scenes/world_list.tscn") # ─── tiny on-screen status ──────────────────────────────────────────────────── func _show_status(text: String) -> void: if _status_label == null: var layer := CanvasLayer.new() layer.name = "StatusLayer" add_child(layer) _status_label = Label.new() _status_label.position = Vector2(16, 16) _status_label.add_theme_font_size_override("font_size", 18) layer.add_child(_status_label) _status_label.text = text _status_label.visible = true func _hide_status() -> void: if _status_label: _status_label.visible = false