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 _remote_loadouts: Dictionary = {} # peer_id -> Dictionary (last known loadout) 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 var _stream_manager: ChunkStreamManager = null var _loaded_chunk_coords: Array[Vector3i] = [] func _ready() -> void: _show_build_stamp() if Session.editing: _open_editor_for_world() else: if Session.active: _setup_networked() _setup_chunk_streaming() func _setup_chunk_streaming() -> void: if Session.world_id == null or int(Session.world_id) <= 0: return if _stream_manager != null and is_instance_valid(_stream_manager): return _stream_manager = ChunkStreamManager.new() _stream_manager.name = "ChunkStreamManager" _stream_manager.player = _player _stream_manager.streaming_radius = 2 add_child(_stream_manager) var chunk_source := HttpChunkSource.new() chunk_source.name = "HttpChunkSource" chunk_source.world_id = str(Session.world_id) chunk_source.room_slug = "main" chunk_source.stream_manager = _stream_manager _stream_manager.add_child(chunk_source) _stream_manager.set_server_rpc(chunk_source) _stream_manager.is_active = true _stream_manager.chunk_loaded.connect(_on_chunk_loaded) _stream_manager._load_chunk(Vector3i.ZERO) func _on_chunk_loaded(coord: Vector3i, chunk_scene: Node) -> void: if chunk_scene is ChunkScene: var cs := chunk_scene as ChunkScene _hide_default_test_props() if not (coord in _loaded_chunk_coords): _loaded_chunk_coords.append(coord) _adjust_ground_and_spawn_for_chunks(cs) func _adjust_ground_and_spawn_for_chunks(cs: ChunkScene) -> void: var min_cx := 0 var max_cx := 0 var min_cy := 0 var max_cy := 0 for c in _loaded_chunk_coords: min_cx = mini(min_cx, c.x) max_cx = maxi(max_cx, c.x) min_cy = mini(min_cy, c.y) max_cy = maxi(max_cy, c.y) var min_x := float(min_cx * Chunk.CHUNK_SIZE_X) var max_x := float((max_cx + 1) * Chunk.CHUNK_SIZE_X) var min_z := float(min_cy * Chunk.CHUNK_SIZE_Y) var max_z := float((max_cy + 1) * Chunk.CHUNK_SIZE_Y) var center_x := (min_x + max_x) * 0.5 var center_z := (min_z + max_z) * 0.5 var size_x := maxf(40.0, (max_x - min_x) + 8.0) var size_z := maxf(40.0, (max_z - min_z) + 8.0) var ground := get_node_or_null("Level/Ground") as StaticBody3D if ground != null: ground.position = Vector3(center_x, -0.5, center_z) var shape_node := ground.find_child("CollisionShape3D", true, false) as CollisionShape3D if shape_node != null: var box: BoxShape3D if shape_node.shape is BoxShape3D: box = (shape_node.shape as BoxShape3D).duplicate() as BoxShape3D else: box = BoxShape3D.new() box.size = Vector3(size_x, 1.0, size_z) shape_node.shape = box var mesh_node := ground.find_child("MeshInstance3D", true, false) as MeshInstance3D if mesh_node != null: var box_mesh: BoxMesh if mesh_node.mesh is BoxMesh: box_mesh = (mesh_node.mesh as BoxMesh).duplicate() as BoxMesh else: box_mesh = BoxMesh.new() box_mesh.size = Vector3(size_x, 1.0, size_z) mesh_node.mesh = box_mesh var spawn_marker := get_node_or_null("Level/PlayerSpawn") as Node3D var found_spawn: Variant = null for obj in cs.world_objects: if obj.type == "spawn_point" or (obj.properties is Dictionary and obj.properties.get("catalog_id") == "spawn_point"): found_spawn = cs.global_position + Vector3(obj.position.x, 1.5, obj.position.y) break if found_spawn != null: if spawn_marker != null: spawn_marker.global_position = found_spawn if not _aligned: _player.global_position = found_spawn elif spawn_marker != null and spawn_marker.global_position.is_equal_approx(Vector3(0, 1.5, 0)): var default_center := Vector3(16.0, 1.5, 16.0) spawn_marker.global_position = default_center if not _aligned: _player.global_position = default_center func _hide_default_test_props() -> void: var props := get_node_or_null("Level/Props") if props != null and props is Node3D: (props as Node3D).visible = false for child in props.get_children(): if child is CollisionObject3D: (child as CollisionObject3D).set_collision_layer_value(1, false) (child as CollisionObject3D).set_collision_mask_value(1, false) var chest := get_node_or_null("Level/ScriptedChest") if chest != null and chest is Node3D: (chest as Node3D).visible = false if chest is CollisionObject3D: (chest as CollisionObject3D).set_collision_layer_value(1, false) (chest as CollisionObject3D).set_collision_mask_value(1, false) 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 world_id_str := str(Session.world_id) if Session.world_id != null else "0" var room_slug := "main" var coord := Vector3i.ZERO Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) var save_service: ChunkSaveService = null var model: ChunkEditorModel = null var base_revision := 0 if int(world_id_str) > 0: 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) if fetch_result.get("ok", false) and fetch_result.get("model") != null: model = fetch_result["model"] base_revision = int(fetch_result.get("revision", 0)) if model == null: 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) if save_service != null: _build_editor.setup_persistence(save_service, world_id_str, room_slug, coord, base_revision) _build_editor.closed.connect(_close_build_mode) add_child(_build_editor) ## 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 for child in get_children(): if child is ChunkSaveService: child.queue_free() _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() _loaded_chunk_coords.clear() if _stream_manager == null: _setup_chunk_streaming() elif is_instance_valid(_stream_manager): var source := _stream_manager.get_node_or_null("HttpChunkSource") as HttpChunkSource if source != null and Session.world_id != null and int(Session.world_id) > 0: source.world_id = str(Session.world_id) _stream_manager.force_reload_chunk(Vector3i.ZERO) ## "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: _loaded_chunk_coords.clear() if _stream_manager != null and is_instance_valid(_stream_manager): _stream_manager.clear_all_chunks() _stream_manager.queue_free() _stream_manager = null 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, 1.0)) lbl.add_theme_color_override("font_outline_color", Color(0, 0, 0, 1.0)) lbl.add_theme_constant_override("outline_size", 6) lbl.add_theme_font_size_override("font_size", 18) lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE layer.add_child(lbl) var reposition := func() -> void: if is_instance_valid(lbl) and is_inside_tree(): var vp := get_viewport().get_visible_rect().size lbl.position = Vector2(20, vp.y - 40) lbl.size = Vector2(vp.x - 40, 32) reposition.call() get_viewport().size_changed.connect(reposition) 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() # Replicate local avatar to server and remote peers var avatars := get_node_or_null("/root/AvatarSystem") if avatars != null: if avatars.has_method("ensure_catalog"): avatars.ensure_catalog() if avatars.has_method("get_current_loadout_dict"): _net.send_loadout(avatars.get_current_loadout_dict()) if avatars.has_signal("loadout_changed") and not avatars.loadout_changed.is_connected(_on_local_loadout_changed): avatars.loadout_changed.connect(_on_local_loadout_changed) # Physics-process AFTER the player so net_input reflects this frame. process_physics_priority = 10 func _on_local_loadout_changed(lo: Variant) -> void: if _networked and _net != null and is_instance_valid(_net): if lo != null and lo.has_method("to_dict"): _net.send_loadout(lo.to_dict()) elif lo is Dictionary: _net.send_loadout(lo) 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) var loadout: Variant = st.get("loadout", null) if node == null: node = _make_remote(pid, loadout) _remotes[pid] = node add_child(node) node.global_position = st.get("position", Vector3.ZERO) elif loadout is Dictionary and not (loadout as Dictionary).is_empty() and _remote_loadouts.get(pid) != loadout: _setup_remote_visual(node, pid, loadout) 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) _remote_loadouts.erase(pid) func _make_remote(pid: int, loadout: Variant = null) -> Node3D: var root := Node3D.new() root.name = "Remote_%d" % pid _setup_remote_visual(root, pid, loadout) var tag := Label3D.new() tag.name = "NameTag" 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 _setup_remote_visual(root: Node3D, pid: int, loadout: Variant) -> void: var old_vis := root.get_node_or_null("Visual") if old_vis != null and is_instance_valid(old_vis): root.remove_child(old_vis) old_vis.queue_free() var old_anim := root.get_node_or_null("LimbAnim") if old_anim != null and is_instance_valid(old_anim): root.remove_child(old_anim) old_anim.queue_free() var avatars := get_node_or_null("/root/AvatarSystem") var visual: Node3D = null if avatars != null: if avatars.has_method("ensure_catalog"): avatars.ensure_catalog() if loadout is Dictionary and not (loadout as Dictionary).is_empty(): if avatars.has_method("assemble_avatar"): visual = avatars.assemble_avatar(loadout) if visual == null and avatars.has_method("get_assets_for_slot") and avatars.has_method("assemble_avatar"): var bases: Array = avatars.get_assets_for_slot("base_body") if not bases.is_empty(): visual = avatars.assemble_avatar({"base_body": bases[0], "slots": {}}) if visual != null: visual.name = "Visual" visual.position = Vector3(0, -0.85, 0) # feet -> ground (root is at hip height from server) 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: # Fallback capsule if assets/catalog are unavailable (e.g. headless) var body := MeshInstance3D.new() body.name = "Visual" var capsule := CapsuleMesh.new() capsule.radius = 0.35 capsule.height = 1.7 body.mesh = capsule body.position = Vector3(0, 0.85, 0) var tint := Color.from_hsv(fmod(float(pid) * 0.61803, 1.0), 0.55, 0.95) var mat := StandardMaterial3D.new() mat.albedo_color = tint body.material_override = mat root.add_child(body) _remote_loadouts[pid] = (loadout as Dictionary).duplicate(true) if (loadout is Dictionary) else loadout 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