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). const ReportModal = preload("res://scripts/report_modal.gd") const PauseMenu = preload("res://scripts/pause_menu.gd") const ControlsModal = preload("res://scripts/controls_modal.gd") var _build_editor: InGameEditor = null var _chat_overlay: Control = null var _friends_overlay: Control = null var _report_modal: Control = null var _pause_menu: Control = null var _controls_modal: Control = null var _hud_layer: CanvasLayer = null var _overlay_layer: CanvasLayer = null var _stream_manager: ChunkStreamManager = null var _loaded_chunk_coords: Array[Vector3i] = [] func _ready() -> void: _show_build_stamp() _setup_in_game_hud() _setup_chunk_streaming() if Session.editing: _open_editor_for_world() else: if Session.active: _setup_networked() 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 InputEventKey and event.pressed and not event.echo: if event.keycode == KEY_T or (event.keycode == KEY_ENTER and not (_chat_overlay != null and is_instance_valid(_chat_overlay))): if not (_build_editor != null and is_instance_valid(_build_editor)) and not (_pause_menu != null and is_instance_valid(_pause_menu)): get_viewport().set_input_as_handled() _toggle_chat_overlay() return elif event.keycode == KEY_TAB or event.keycode == KEY_F1: if not (_build_editor != null and is_instance_valid(_build_editor)) and not (_pause_menu != null and is_instance_valid(_pause_menu)): get_viewport().set_input_as_handled() _toggle_friends_overlay() return if event.is_action_pressed("toggle_build_mode"): get_viewport().set_input_as_handled() _toggle_build_mode() return # ESC opens Pause Menu (or closes open modal/overlay/editor). if event.is_action_pressed("ui_cancel"): get_viewport().set_input_as_handled() if _report_modal != null and is_instance_valid(_report_modal): _close_report_modal() elif _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() elif _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() elif _controls_modal != null and is_instance_valid(_controls_modal): _close_controls_modal() elif _pause_menu != null and is_instance_valid(_pause_menu): _close_pause_menu() elif _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() else: _open_pause_menu() # ─── In-Game HUD & Social Overlays ─────────────────────────────────────────── func _setup_in_game_hud() -> void: if _hud_layer != null and is_instance_valid(_hud_layer): return _hud_layer = CanvasLayer.new() _hud_layer.name = "InGameHUD" _hud_layer.layer = 10 add_child(_hud_layer) var panel := PanelContainer.new() panel.name = "HUDPanel" panel.position = Vector2(20, 20) var style := StyleBoxFlat.new() style.bg_color = Color(0.1, 0.12, 0.16, 0.8) style.border_color = Color(0.3, 0.35, 0.45, 0.8) style.set_border_width_all(1) style.set_corner_radius_all(6) style.content_margin_left = 8 style.content_margin_right = 8 style.content_margin_top = 6 style.content_margin_bottom = 6 panel.add_theme_stylebox_override("panel", style) _hud_layer.add_child(panel) var bar := HBoxContainer.new() bar.name = "QuickActionBar" bar.add_theme_constant_override("separation", 8) panel.add_child(bar) var chat_btn := Button.new() chat_btn.text = "💬 Chat [T]" chat_btn.pressed.connect(_toggle_chat_overlay) bar.add_child(chat_btn) var friends_btn := Button.new() friends_btn.text = "👥 Friends [Tab]" friends_btn.pressed.connect(_toggle_friends_overlay) bar.add_child(friends_btn) var build_btn := Button.new() build_btn.text = "🔨 Build [F2]" build_btn.pressed.connect(_toggle_build_mode) bar.add_child(build_btn) var report_btn := Button.new() report_btn.text = "🚩 Report" report_btn.pressed.connect(_toggle_report_modal) bar.add_child(report_btn) var exit_btn := Button.new() exit_btn.text = "🚪 Menu [ESC]" exit_btn.pressed.connect(func(): if _report_modal != null and is_instance_valid(_report_modal): _close_report_modal() elif _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() elif _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() elif _controls_modal != null and is_instance_valid(_controls_modal): _close_controls_modal() elif _pause_menu != null and is_instance_valid(_pause_menu): _close_pause_menu() elif _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() else: _open_pause_menu() ) bar.add_child(exit_btn) # Bottom controls pill hint var controls_pill := PanelContainer.new() controls_pill.name = "ControlsPill" var pill_style := StyleBoxFlat.new() pill_style.bg_color = Color(0.08, 0.10, 0.14, 0.75) pill_style.set_corner_radius_all(14) pill_style.content_margin_left = 14 pill_style.content_margin_right = 14 pill_style.content_margin_top = 4 pill_style.content_margin_bottom = 4 controls_pill.add_theme_stylebox_override("panel", pill_style) controls_pill.set_anchors_preset(Control.PRESET_BOTTOM_WIDE) controls_pill.offset_top = -34 controls_pill.offset_bottom = -8 controls_pill.offset_left = 220 controls_pill.offset_right = -220 var hint_lbl := Label.new() hint_lbl.text = "[W][A][S][D] Move [Space] Jump [E] Interact [F1] Friends [T] Chat [F2] Build [ESC] Menu" hint_lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER hint_lbl.add_theme_font_size_override("font_size", 12) hint_lbl.add_theme_color_override("font_color", Color(0.85, 0.9, 0.95, 0.85)) controls_pill.add_child(hint_lbl) _hud_layer.add_child(controls_pill) # If editing in creator test mode, show status badge if Session.editing and Session.world_id != null and int(Session.world_id) > 0: var creator_pill := PanelContainer.new() creator_pill.name = "CreatorTestPill" var c_style := StyleBoxFlat.new() c_style.bg_color = Color(0.18, 0.14, 0.04, 0.85) c_style.border_color = Color(0.9, 0.75, 0.2, 0.9) c_style.set_border_width_all(1) c_style.set_corner_radius_all(6) c_style.content_margin_left = 10 c_style.content_margin_right = 10 c_style.content_margin_top = 4 c_style.content_margin_bottom = 4 creator_pill.add_theme_stylebox_override("panel", c_style) creator_pill.set_anchors_preset(Control.PRESET_TOP_RIGHT) creator_pill.offset_left = -280 creator_pill.offset_top = 16 creator_pill.offset_right = -20 creator_pill.offset_bottom = 46 var creator_lbl := Label.new() creator_lbl.text = "🛠️ Creator Test Mode (World #%s)" % str(Session.world_id) creator_lbl.add_theme_font_size_override("font_size", 12) creator_lbl.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4)) creator_pill.add_child(creator_lbl) _hud_layer.add_child(creator_pill) func _toggle_report_modal() -> void: if _report_modal != null and is_instance_valid(_report_modal): _close_report_modal() else: _open_report_modal() func _open_report_modal() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() if _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() if _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) _report_modal = ReportModal.new() var wid: int = int(Session.world_id) if Session.world_id != null else 0 _report_modal.setup_report("world", wid, "Current World" if wid > 0 else "Play Session") _report_modal.closed.connect(_close_report_modal) _report_modal.report_submitted.connect(func(_type, _id): pass ) _report_modal.tree_exited.connect(func(): if _report_modal != null: _close_report_modal() ) add_child(_report_modal) func _close_report_modal() -> void: if _report_modal != null and is_instance_valid(_report_modal): var m := _report_modal _report_modal = null m.queue_free() _player.set_physics_process(true) _player.set_process_unhandled_input(true) Input.mouse_mode = Input.MOUSE_MODE_CAPTURED func _toggle_chat_overlay() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() else: _open_chat_overlay() func _open_chat_overlay() -> void: if _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() if _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) if _overlay_layer == null or not is_instance_valid(_overlay_layer): _overlay_layer = CanvasLayer.new() _overlay_layer.name = "OverlayLayer" _overlay_layer.layer = 20 add_child(_overlay_layer) var bg := ColorRect.new() bg.name = "OverlayBG" bg.color = Color(0.06, 0.08, 0.12, 0.9) bg.set_anchors_preset(Control.PRESET_FULL_RECT) _overlay_layer.add_child(bg) var chat_scene := load("res://scenes/chat_ui.tscn") _chat_overlay = chat_scene.instantiate() as Control _chat_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(_chat_overlay) # If active in a world, configure world chat automatically if Session.world_id != null and int(Session.world_id) > 0: var wid_edit = _chat_overlay.find_child("WorldIdEdit", true, false) as LineEdit if wid_edit != null: wid_edit.text = str(Session.world_id) if _chat_overlay.has_method("switch_world_chat"): _chat_overlay.switch_world_chat(int(Session.world_id)) if _chat_overlay.has_signal("back_requested"): _chat_overlay.back_requested.connect(_close_chat_overlay) var back_btn = _chat_overlay.find_child("BackButton", true, false) as Button if back_btn != null: back_btn.text = "✕ Close Chat" if not back_btn.pressed.is_connected(_close_chat_overlay): back_btn.pressed.connect(_close_chat_overlay) var msg_input = _chat_overlay.find_child("MessageInput", true, false) as LineEdit if msg_input != null: msg_input.grab_focus.call_deferred() func _close_chat_overlay() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _chat_overlay.queue_free() _chat_overlay = null if _overlay_layer != null and is_instance_valid(_overlay_layer): _overlay_layer.queue_free() _overlay_layer = null _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() func _toggle_friends_overlay() -> void: if _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() else: _open_friends_overlay() func _open_friends_overlay() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() if _build_editor != null and is_instance_valid(_build_editor): _close_build_mode() Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) if _overlay_layer == null or not is_instance_valid(_overlay_layer): _overlay_layer = CanvasLayer.new() _overlay_layer.name = "OverlayLayer" _overlay_layer.layer = 20 add_child(_overlay_layer) var bg := ColorRect.new() bg.name = "OverlayBG" bg.color = Color(0.06, 0.08, 0.12, 0.9) bg.set_anchors_preset(Control.PRESET_FULL_RECT) _overlay_layer.add_child(bg) var friends_scene := load("res://scenes/friends_presence_ui.tscn") _friends_overlay = friends_scene.instantiate() as Control _friends_overlay.set_anchors_preset(Control.PRESET_FULL_RECT) bg.add_child(_friends_overlay) if _friends_overlay.has_signal("back_requested"): _friends_overlay.back_requested.connect(_close_friends_overlay) var back_btn = _friends_overlay.find_child("BackButton", true, false) as Button if back_btn != null: back_btn.text = "✕ Close" if not back_btn.pressed.is_connected(_close_friends_overlay): back_btn.pressed.connect(_close_friends_overlay) func _close_friends_overlay() -> void: if _friends_overlay != null and is_instance_valid(_friends_overlay): _friends_overlay.queue_free() _friends_overlay = null if _overlay_layer != null and is_instance_valid(_overlay_layer): _overlay_layer.queue_free() _overlay_layer = null _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() func _toggle_pause_menu() -> void: if _pause_menu != null and is_instance_valid(_pause_menu): _close_pause_menu() else: _open_pause_menu() func _open_pause_menu() -> void: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() if _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() if _controls_modal != null and is_instance_valid(_controls_modal): _close_controls_modal() Input.mouse_mode = Input.MOUSE_MODE_VISIBLE _player.set_physics_process(false) _player.set_process_unhandled_input(false) if _overlay_layer == null or not is_instance_valid(_overlay_layer): _overlay_layer = CanvasLayer.new() _overlay_layer.name = "OverlayLayer" _overlay_layer.layer = 20 add_child(_overlay_layer) var can_edit := Session.editing or (Session.world_id != null and int(Session.world_id) > 0) var title_str := "World #%s" % str(Session.world_id) if Session.world_id != null and int(Session.world_id) > 0 else "Sandbox World" _pause_menu = PauseMenu.new(can_edit, title_str) _pause_menu.resume_requested.connect(_close_pause_menu) _pause_menu.edit_world_requested.connect(func(): _close_pause_menu() _open_build_mode() ) _pause_menu.friends_requested.connect(func(): _close_pause_menu() _open_friends_overlay() ) _pause_menu.chat_requested.connect(func(): _close_pause_menu() _open_chat_overlay() ) _pause_menu.controls_requested.connect(func(): _open_controls_modal() ) _pause_menu.leave_world_confirmed.connect(func(): _close_pause_menu() _leave_world() ) _overlay_layer.add_child(_pause_menu) func _close_pause_menu() -> void: if _pause_menu != null and is_instance_valid(_pause_menu): _pause_menu.queue_free() _pause_menu = null if _controls_modal != null and is_instance_valid(_controls_modal): _controls_modal.queue_free() _controls_modal = null if _overlay_layer != null and is_instance_valid(_overlay_layer): _overlay_layer.queue_free() _overlay_layer = null _player.set_physics_process(true) _player.set_process_unhandled_input(true) _player._refresh_mouse_mode() func _open_controls_modal() -> void: if _controls_modal != null and is_instance_valid(_controls_modal): return _controls_modal = ControlsModal.new() _controls_modal.closed.connect(_close_controls_modal) if _overlay_layer != null and is_instance_valid(_overlay_layer): _overlay_layer.add_child(_controls_modal) else: add_child(_controls_modal) func _close_controls_modal() -> void: if _controls_modal != null and is_instance_valid(_controls_modal): _controls_modal.queue_free() _controls_modal = null ## 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: if _chat_overlay != null and is_instance_valid(_chat_overlay): _close_chat_overlay() if _friends_overlay != null and is_instance_valid(_friends_overlay): _close_friends_overlay() 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) _build_editor.exit_browser_requested.connect(_exit_editor_to_browser) 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(_close_build_mode) _build_editor.exit_browser_requested.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) var uname: String = str(st.get("username", "")) if uname.is_empty() and loadout is Dictionary: uname = str(loadout.get("username", "")) if uname.is_empty(): uname = "Player %d" % pid if node == null: node = _make_remote(pid, loadout, uname) _remotes[pid] = node add_child(node) node.global_position = st.get("position", Vector3.ZERO) else: if loadout is Dictionary and not (loadout as Dictionary).is_empty() and _remote_loadouts.get(pid) != loadout: _setup_remote_visual(node, pid, loadout) var tag := node.get_node_or_null("NameTag") as Label3D if tag != null and tag.text != uname and not uname.is_empty(): tag.text = uname 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, uname: String = "") -> 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 = uname if not uname.is_empty() else ("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