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 _leaving := false # a scene change is already in flight 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 ## Build-mode UI, gated behind toggle_build_mode (F2) -- only ever ## instantiated once the player actually enters build mode ## (docs/architecture/editor.md §7.2: "Instantiated only when the player ## enters 'build mode'... so the runtime footprint is negligible during ## normal play"). Editing is in-memory only here; the save path to the ## backend is a sibling task (world_persistence_impl.md). var _build_editor: InGameEditor = null func _ready() -> void: _show_build_stamp() if Session.editing: _open_editor_for_world() elif Session.active: _setup_networked() func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("toggle_build_mode"): # world_persistence_impl.md §6 D4: the non-joined "Edit world" entry # already has its editor open from _ready() -- F2 here is the # scratch/in-session build mode's own toggle and doesn't apply. if not Session.editing: get_viewport().set_input_as_handled() _toggle_build_mode() return # ESC leaves the world back to the browser (or, while build mode is # open, just closes build mode instead). Handled here (the scene root # that owns the connection) rather than in player.gd so the teardown is # a single ordered path -- doing it from two places raced _on_server_lost # against the scene change and looked like a crash. if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() if Session.editing: # Non-joined editing has no play session to fall back into -- # closing it means leaving, same as the Close button. _exit_editor_to_browser() elif _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() else: _leave_world() ## Entry point into the in-game editor (docs/architecture/editor.md §7.2). ## Freezes the player and opens a fresh, empty in-memory chunk to edit -- ## loading a *real* chunk from the backend for editing is the sibling ## save-path task's territory (it owns the HTTP round-trip); this task's ## contract stops at "a correctly-editable in-memory ChunkEditorModel". func _toggle_build_mode() -> void: if _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() else: _open_build_mode() func _open_build_mode() -> void: var model := ChunkEditorModel.create_empty(32, 32, str(Session.world_id), "main") var catalog := EditorObjectCatalog.new() var err := catalog.load_catalog() if err != OK: push_error("game.gd: failed to load editor object catalog (%d)" % err) var core := EditorCore.new(model, catalog) _build_editor = InGameEditor.new(core) _build_editor.closed.connect(_close_build_mode) add_child(_build_editor) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) ## Shared teardown for both close paths (F2 again, or the editor's own ## Close button/`closed` signal) -- safe to call twice: `queue_free()` on an ## already-freeing node and restoring already-restored player state are ## both no-ops. func _close_build_mode() -> void: if _build_editor != null and is_instance_valid(_build_editor): _build_editor.queue_free() _build_editor = null _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() ## "Edit world" entry point from the world browser ## (world_persistence_impl.md §6 D4 / §9 "Client/editor task"). Reached only ## via Session.editing (set by world_list.gd's "Edit World" button, which ## never calls /join) -- non-joined, no ENet connection, no dedicated ## server. Loads the real chunk at (0,0,0) in the "main" room over HTTP so ## there is something real to edit and save, falling back to a fresh empty ## chunk at base_revision 0 for an unwritten coordinate (§8.1: "an unwritten ## coordinate is not an error"). func _open_editor_for_world() -> void: Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) var world_id_str := str(Session.world_id) var room_slug := "main" var coord := Vector3i.ZERO var save_service := ChunkSaveService.new() save_service.world_id = world_id_str save_service.room_slug = room_slug add_child(save_service) var fetch_result: Dictionary = await save_service.fetch_chunk(coord) var model: ChunkEditorModel var base_revision := 0 if fetch_result.get("ok", false) and fetch_result.get("model") != null: model = fetch_result["model"] base_revision = int(fetch_result.get("revision", 0)) else: if not fetch_result.get("ok", false): push_error("game.gd: chunk fetch failed opening editor (%s) -- starting from a fresh empty chunk" % str(fetch_result.get("detail", ""))) model = ChunkEditorModel.create_empty(32, 32, world_id_str, room_slug, coord) base_revision = 0 var catalog := EditorObjectCatalog.new() var err := catalog.load_catalog() if err != OK: push_error("game.gd: failed to load editor object catalog (%d)" % err) var core := EditorCore.new(model, catalog) _build_editor = InGameEditor.new(core) _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision) _build_editor.closed.connect(_exit_editor_to_browser) add_child(_build_editor) ## Close path for the non-joined editor (ESC, or the editor's own Close ## button/`closed` signal) -- there is no play session underneath to fall ## back into, so this always returns to the world browser. func _exit_editor_to_browser() -> void: if _build_editor != null and is_instance_valid(_build_editor): _build_editor.queue_free() _build_editor = null Session.clear() get_tree().change_scene_to_file("res://scenes/world_list.tscn") func _leave_world() -> void: if _leaving: return _leaving = true Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _teardown_net() Session.clear() get_tree().change_scene_to_file("res://scenes/world_list.tscn") ## Close the ENet connection and stop it from calling us back mid-teardown. func _teardown_net() -> void: if _net == null or not is_instance_valid(_net): return if _net.server_disconnected.is_connected(_on_server_lost): _net.server_disconnected.disconnect(_on_server_lost) if _net.connection_failed.is_connected(_on_server_lost): _net.connection_failed.disconnect(_on_server_lost) _net.disconnect_from_server() 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.35)) lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1)) lbl.add_theme_constant_override("outline_size", 6) lbl.add_theme_font_size_override("font_size", 20) # Full-width strip pinned to the TOP-left. (A bare preset + .position on a # zero-size Label collapses the rect to negative width and nothing draws -- # which is why this never showed; explicit offsets fix it.) lbl.set_anchors_preset(Control.PRESET_TOP_WIDE) lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT lbl.offset_left = 12 lbl.offset_right = -12 lbl.offset_top = 8 lbl.offset_bottom = 40 lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(lbl) func _exit_tree() -> void: # Backstop for any exit path that didn't go through _leave_world (e.g. the # app quitting): close the ENet connection so the server fires # peer_disconnected and drops our player instead of leaving a ghost. _teardown_net() 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: if _leaving: return _leaving = true _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