1007 lines
35 KiB
GDScript
1007 lines
35 KiB
GDScript
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
|