Files
modbison/scripts/world_list.gd
T
daddy dba865ae4b PC3 Phase 2.3: networked play path in the game
- Session autoload: carries {world_id, host, port} from the world browser
  to the game scene.
- world_list.gd "Enter World": POST /api/worlds/{id}/join -> Session.begin
  -> load game.tscn. On 503 (backend has no orchestration) falls back to
  local single-player; other errors surface on the card.
- api_client.gd: join_world().
- game.gd (new, on game.tscn root): local single-player by default; when
  Session.active, connects a NetworkClient to the world's dedicated server
  and runs networked -- forwards the local Player's input each physics
  frame, snaps to the server position past a generous drift threshold
  (the Player keeps predicting locally with real collision), renders other
  players from snapshots (labelled capsules, lerped -- avatar appearance
  sync is a follow-up, the server doesn't replicate loadouts yet), and
  returns to the world list on disconnect.
- player.gd: exposes net_input (the input frame it applied) and
  apply_server_correction().
- network_client.gd: send_input() + get_own_server_state() for callers that
  do their own collision-aware prediction.
- scripts/mp_game_flow_client.gd + run_game_multiplayer_test.sh: two real
  clients each register -> join via the API -> game.tscn -> networked;
  A sees B and moves. PASS.

Verified end to end against an orchestration-enabled backend. Non-networked
smokes green (enter_world 9/9, world_list 5/5, player 15/15,
avatar_in_world 10/10, main_menu_full_flow 12/12).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015jtxAiu5dAW4WxCNdGkrkJ
2026-09-05 22:07:06 +00:00

179 lines
6.1 KiB
GDScript

extends Control
## World list screen — browses public worlds via GET /api/worlds.
##
## Displays a scrollable list of world cards (name + description).
## Handles loading, empty list, errors, and refresh.
# UI references
@onready var scroll_container: ScrollContainer = $VBoxContainer/ScrollContainer
@onready var world_list: VBoxContainer = $VBoxContainer/ScrollContainer/WorldList
@onready var loading_label: Label = $VBoxContainer/LoadingLabel
@onready var error_label: Label = $VBoxContainer/ErrorLabel
@onready var refresh_button: Button = $VBoxContainer/HeaderHBox/RefreshButton
@onready var back_button: Button = $VBoxContainer/HeaderHBox/BackButton
@onready var empty_label: Label = $VBoxContainer/EmptyLabel
func _ready() -> void:
refresh_button.disabled = false
back_button.disabled = false
loading_label.visible = false
error_label.visible = false
empty_label.visible = false
world_list.visible = false
_refresh_worlds()
func _on_refresh_button_pressed() -> void:
_refresh_worlds()
func _on_back_button_pressed() -> void:
get_tree().change_scene_to_file("res://scenes/main_menu.tscn")
## Fetch the world list from the backend and update the UI.
func _refresh_worlds() -> void:
# Reset UI state
loading_label.visible = true
error_label.visible = false
empty_label.visible = false
world_list.visible = false
refresh_button.disabled = true
# Clear previous world cards
for child in world_list.get_children():
child.queue_free()
var result := await ApiClient.get_worlds()
loading_label.visible = false
refresh_button.disabled = false
if not result.ok:
# Show error message (401, network failure, etc.)
error_label.text = "Error: %s" % result.detail
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
var data = result.data
if not data is Array:
error_label.text = "Unexpected response format."
error_label.visible = true
empty_label.visible = false
world_list.visible = false
return
if data.is_empty():
empty_label.text = "No worlds yet. Check back later!"
empty_label.visible = true
error_label.visible = false
world_list.visible = false
return
# Render world cards
world_list.visible = true
empty_label.visible = false
error_label.visible = false
for world_dict in data:
if not world_dict is Dictionary:
continue
var card := _create_world_card(world_dict)
world_list.add_child(card)
## Create a visual card for a single world entry.
func _create_world_card(world_dict: Dictionary) -> Control:
# PanelContainer for the background, not a ColorRect moved to child index
# 0 of a VBoxContainer -- a VBoxContainer lays out ALL its children in a
# vertical sequence, it has no concept of "float this one child behind
# the others as an overlay". That version rendered as an empty dark bar
# sitting above the text, not a background behind it.
var panel := PanelContainer.new()
panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL
panel.custom_minimum_size = Vector2(0, 80)
var style := StyleBoxFlat.new()
style.bg_color = Color(0.15, 0.15, 0.15, 1.0)
style.content_margin_left = 12
style.content_margin_right = 12
style.content_margin_top = 8
style.content_margin_bottom = 8
panel.add_theme_stylebox_override("panel", style)
var card := VBoxContainer.new()
card.size_flags_horizontal = Control.SIZE_EXPAND_FILL
card.add_theme_constant_override("separation", 4)
panel.add_child(card)
var name_label := Label.new()
name_label.text = world_dict.get("name", "Unnamed World")
name_label.autowrap_mode = TextServer.AUTOWRAP_WORD
name_label.size_flags_horizontal = Control.SIZE_FILL
name_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
name_label.add_theme_font_size_override("font_size", 18)
var desc_label := Label.new()
var description: String = str(world_dict.get("description", ""))
desc_label.text = description if description != "" else "No description."
desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD
desc_label.size_flags_horizontal = Control.SIZE_FILL
desc_label.size_flags_vertical = Control.SIZE_EXPAND_FILL
desc_label.add_theme_font_size_override("font_size", 14)
desc_label.add_theme_color_override("font_color", Color(0.7, 0.7, 0.7, 1.0))
var meta_label := Label.new()
var visibility: String = str(world_dict.get("visibility", "public"))
var created: String = str(world_dict.get("created_at", ""))
meta_label.text = "Visibility: %s%s" % [visibility, " | Created: " + created if created != "" else ""]
meta_label.autowrap_mode = TextServer.AUTOWRAP_WORD
meta_label.size_flags_horizontal = Control.SIZE_FILL
meta_label.size_flags_vertical = Control.SIZE_SHRINK_BEGIN
meta_label.add_theme_font_size_override("font_size", 12)
meta_label.add_theme_color_override("font_color", Color(0.5, 0.5, 0.5, 1.0))
# "Enter World" asks the backend to host this world (POST /join) and then
# loads scenes/game.tscn networked. If the backend has no game-server
# orchestration (a plain dev instance), it falls back to local
# single-player. All worlds still load the same test level -- per-world
# content is a later milestone.
var world_id := int(world_dict.get("id", 0))
var enter_button := Button.new()
enter_button.text = "Enter World"
enter_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN
enter_button.pressed.connect(_on_enter_world_pressed.bind(world_id, enter_button))
card.add_child(name_label)
card.add_child(desc_label)
card.add_child(meta_label)
card.add_child(enter_button)
return panel
func _on_enter_world_pressed(world_id: int, button: Button) -> void:
button.disabled = true
button.text = "Joining…"
var result := await ApiClient.join_world(world_id)
if result.ok and result.data is Dictionary:
var d: Dictionary = result.data
Session.begin(world_id, str(d.get("host", "")), int(d.get("port", 0)))
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
if result.status == 503:
# This backend can't host game servers -- go in local single-player.
Session.clear()
get_tree().change_scene_to_file("res://scenes/game.tscn")
return
button.disabled = false
button.text = "Enter World"
error_label.text = "Couldn't join: %s" % result.detail
error_label.visible = true