extends Control const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd") ## 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 create_button: Button = $VBoxContainer/HeaderHBox/CreateButton @onready var empty_label: Label = $VBoxContainer/EmptyLabel ## Cached so each world card can decide whether to show "Edit World" ## (docs/architecture/world_persistence_impl.md §5.3: chunk writes require ## `world.owner_id == caller`, same as `update_world`) without an extra ## round trip per card. -1 until resolved / on failure, in which case no ## card shows the edit entry point. var _current_user_id: int = -1 var _create_modal: Control = null func _ready() -> void: refresh_button.disabled = false back_button.disabled = false create_button.disabled = false loading_label.visible = false error_label.visible = false empty_label.visible = false world_list.visible = false _show_build_stamp() var me_result: Dictionary = await ApiClient.get_me() if me_result.ok and me_result.data is Dictionary: _current_user_id = int((me_result.data as Dictionary).get("id", -1)) _refresh_worlds() func _show_build_stamp() -> void: var lbl := Label.new() var f := FileAccess.open("res://assets/build_info.json", FileAccess.READ) var stamp := "dev" if f != null: var d = JSON.parse_string(f.get_as_text()) if d is Dictionary: stamp = "%s %s" % [d.get("checkpoint", "?"), d.get("commit", "?")] lbl.text = "build %s" % stamp 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 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 _on_create_button_pressed() -> void: if _create_modal != null and is_instance_valid(_create_modal): _create_modal.queue_free() _create_modal = _build_create_world_modal() add_child(_create_modal) func _build_create_world_modal() -> Control: var overlay := ColorRect.new() overlay.name = "CreateWorldOverlay" overlay.color = Color(0, 0, 0, 0.75) overlay.set_anchors_preset(Control.PRESET_FULL_RECT) overlay.mouse_filter = Control.MOUSE_FILTER_STOP var center := CenterContainer.new() center.set_anchors_preset(Control.PRESET_FULL_RECT) overlay.add_child(center) var panel := PanelContainer.new() panel.custom_minimum_size = Vector2(480, 0) var style := StyleBoxFlat.new() style.bg_color = Color(0.18, 0.18, 0.22, 1.0) style.border_color = Color(0.4, 0.55, 0.8, 1.0) style.set_border_width_all(2) style.set_corner_radius_all(8) style.content_margin_left = 24 style.content_margin_right = 24 style.content_margin_top = 20 style.content_margin_bottom = 20 panel.add_theme_stylebox_override("panel", style) center.add_child(panel) var form := VBoxContainer.new() form.add_theme_constant_override("separation", 12) panel.add_child(form) var title := Label.new() title.text = "Create New World" title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER title.add_theme_font_size_override("font_size", 22) title.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0)) form.add_child(title) var name_lbl := Label.new() name_lbl.text = "World Name:" name_lbl.add_theme_font_size_override("font_size", 14) form.add_child(name_lbl) var name_input := LineEdit.new() name_input.placeholder_text = "Enter world name..." name_input.text = "My New World" form.add_child(name_input) var desc_lbl := Label.new() desc_lbl.text = "Description:" desc_lbl.add_theme_font_size_override("font_size", 14) form.add_child(desc_lbl) var desc_input := LineEdit.new() desc_input.placeholder_text = "Describe your world..." desc_input.text = "A creative build world" form.add_child(desc_input) var tags_lbl := Label.new() tags_lbl.text = "Content Tags (comma separated):" tags_lbl.add_theme_font_size_override("font_size", 14) form.add_child(tags_lbl) var tags_input := LineEdit.new() tags_input.placeholder_text = "e.g. Building, Roleplay, Chill, Parkour" tags_input.text = "Building, Creative" form.add_child(tags_input) var guide_lbl := Label.new() guide_lbl.text = "World Guidelines & Rules / Disclaimer:" guide_lbl.add_theme_font_size_override("font_size", 14) form.add_child(guide_lbl) var guide_input := TextEdit.new() guide_input.custom_minimum_size = Vector2(0, 60) guide_input.placeholder_text = "Specify rules, content notices, or disclaimers for players..." guide_input.text = "Welcome! Be respectful to fellow builders and enjoy creating." form.add_child(guide_input) var err_label := Label.new() err_label.add_theme_font_size_override("font_size", 13) err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) err_label.visible = false form.add_child(err_label) var btn_row := HBoxContainer.new() btn_row.alignment = BoxContainer.ALIGNMENT_END btn_row.add_theme_constant_override("separation", 10) form.add_child(btn_row) var cancel_btn := Button.new() cancel_btn.text = "Cancel" cancel_btn.pressed.connect(func(): overlay.queue_free()) btn_row.add_child(cancel_btn) var create_btn := Button.new() create_btn.text = "Create World" btn_row.add_child(create_btn) var create_edit_btn := Button.new() create_edit_btn.text = "Create & Edit Now" btn_row.add_child(create_edit_btn) var handle_create := func(open_editor: bool) -> void: var wname := name_input.text.strip_edges() if wname.is_empty(): err_label.text = "World name cannot be empty." err_label.visible = true return create_btn.disabled = true create_edit_btn.disabled = true cancel_btn.disabled = true err_label.text = "Creating world..." err_label.add_theme_color_override("font_color", Color(0.8, 0.8, 0.8)) err_label.visible = true var raw_tags := tags_input.text.split(",") var clean_tags: Array = [] for t in raw_tags: var st := t.strip_edges() if not st.is_empty(): clean_tags.append(st) var res: Dictionary = await ApiClient.create_world( wname, desc_input.text.strip_edges(), "public", "Building", clean_tags, guide_input.text.strip_edges() ) if not res.get("ok", false): create_btn.disabled = false create_edit_btn.disabled = false cancel_btn.disabled = false err_label.text = "Failed: %s" % str(res.get("detail", "unknown error")) err_label.add_theme_color_override("font_color", Color(1.0, 0.4, 0.4)) return var new_world: Dictionary = res.get("data", {}) var new_world_id: int = int(new_world.get("id", 0)) overlay.queue_free() if open_editor and new_world_id > 0: Session.begin_edit(new_world_id) get_tree().change_scene_to_file("res://scenes/game.tscn") else: _refresh_worlds() create_btn.pressed.connect(handle_create.bind(false)) create_edit_btn.pressed.connect(handle_create.bind(true)) return overlay 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") func _on_discover_button_pressed() -> void: get_tree().change_scene_to_file("res://scenes/discovery_ui.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 owner_id := int(world_dict.get("owner_id", -1)) var is_owner := (_current_user_id != -1 and owner_id == _current_user_id) var name_label := Label.new() var raw_name: String = world_dict.get("name", "Unnamed World") if is_owner: name_label.text = "%s ★ [Your World]" % raw_name name_label.add_theme_color_override("font_color", Color(1.0, 0.9, 0.4, 1.0)) style.border_color = Color(0.7, 0.6, 0.2, 0.8) style.set_border_width_all(2) else: name_label.text = raw_name 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(func(): _show_world_briefing(world_dict, enter_button)) var buttons_row := HBoxContainer.new() buttons_row.add_child(enter_button) # "Edit World" (world_persistence_impl.md §6 D4): a non-joined entry # point into the in-game editor -- it does NOT call POST /join. Only # shown for the world's owner, matching the write-side authorization the # save path itself enforces (§5.3), so a non-owner never sees a button # that would just 403 on the first save. if is_owner: var edit_button := Button.new() edit_button.text = "Edit World" edit_button.size_flags_horizontal = Control.SIZE_SHRINK_BEGIN edit_button.pressed.connect(_on_edit_world_pressed.bind(world_id)) buttons_row.add_child(edit_button) card.add_child(name_label) card.add_child(desc_label) card.add_child(meta_label) card.add_child(buttons_row) return panel func _show_world_briefing(world_dict: Dictionary, button: Button) -> void: var modal := WorldBriefingModal.new() var wid: int = int(world_dict.get("id", 0)) modal.setup_world_data( wid, str(world_dict.get("name", "World")), str(world_dict.get("owner_username", "")), str(world_dict.get("content_guidelines", "")), world_dict.get("tags", []), int(world_dict.get("play_count", 0)) ) modal.entered_confirmed.connect(func(target_wid: int): _on_enter_world_pressed(target_wid, button)) add_child(modal) 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 ## Enter build mode for this world without joining it ## (world_persistence_impl.md §6 D4). No /join call, no ENet connection, no ## dedicated server -- game.gd loads/saves the world's chunks over HTTP. func _on_edit_world_pressed(world_id: int) -> void: Session.begin_edit(world_id) get_tree().change_scene_to_file("res://scenes/game.tscn")