extends Control class_name DiscoveryUI const WorldBriefingModal = preload("res://scripts/world_briefing_modal.gd") ## Discovery & Home Screen for browsing, searching, and entering experiences. ## ## docs/planning/discovery.md (task b77bddf31ba5). signal back_requested signal world_selected(world_id: int) signal worlds_loaded(count: int) @onready var back_button: Button = $VBox/Header/BackButton @onready var refresh_button: Button = $VBox/Header/RefreshButton @onready var world_list_button: Button = $VBox/Header.get_node_or_null("WorldListButton") @onready var status_label: Label = $VBox/StatusLabel # Filter Controls @onready var search_edit: LineEdit = $VBox/Filters/SearchEdit @onready var category_option: OptionButton = $VBox/Filters/CategoryOption @onready var sort_option: OptionButton = $VBox/Filters/SortOption @onready var filter_button: Button = $VBox/Filters/FilterButton # Content @onready var loading_label: Label = $VBox/Content/LoadingLabel @onready var error_label: Label = $VBox/Content/ErrorLabel @onready var empty_label: Label = $VBox/Content/EmptyLabel @onready var worlds_container: VBoxContainer = $VBox/Content/Scroll/WorldsVBox # Pagination @onready var prev_button: Button = $VBox/Pagination/PrevButton @onready var next_button: Button = $VBox/Pagination/NextButton @onready var page_label: Label = $VBox/Pagination/PageLabel var _api_client: Node = null var _current_page: int = 0 var _page_size: int = 10 var _total_count: int = 0 var _loaded_worlds: Array[DiscoveryApiClient.WorldSummary] = [] func _ready() -> void: _api_client = _get_api() _wire_signals() _setup_sort_options() _setup_initial_ui_state() # Populate categories then load initial worlds feed await load_categories() await load_worlds(0) func _get_api() -> Node: if has_node("/root/ApiClient"): return get_node("/root/ApiClient") var api_script = load("res://scripts/api_client.gd") if api_script: var api = api_script.new() add_child(api) return api return null func _setup_initial_ui_state() -> void: if loading_label: loading_label.visible = false if error_label: error_label.visible = false if empty_label: empty_label.visible = false if prev_button: prev_button.disabled = true if next_button: next_button.disabled = true if page_label: page_label.text = "Page 1" func _setup_sort_options() -> void: if not sort_option: return sort_option.clear() sort_option.add_item("Newest", 0) sort_option.add_item("Most Played", 1) sort_option.add_item("Trending", 2) sort_option.selected = 0 func _wire_signals() -> void: if back_button and not back_button.pressed.is_connected(_on_back_pressed): back_button.pressed.connect(_on_back_pressed) if refresh_button and not refresh_button.pressed.is_connected(_on_refresh_pressed): refresh_button.pressed.connect(_on_refresh_pressed) if filter_button and not filter_button.pressed.is_connected(_on_filter_pressed): filter_button.pressed.connect(_on_filter_pressed) if search_edit and not search_edit.text_submitted.is_connected(_on_search_submitted): search_edit.text_submitted.connect(_on_search_submitted) if category_option and not category_option.item_selected.is_connected(_on_category_selected): category_option.item_selected.connect(_on_category_selected) if sort_option and not sort_option.item_selected.is_connected(_on_sort_selected): sort_option.item_selected.connect(_on_sort_selected) if prev_button and not prev_button.pressed.is_connected(_on_prev_page_pressed): prev_button.pressed.connect(_on_prev_page_pressed) if next_button and not next_button.pressed.is_connected(_on_next_page_pressed): next_button.pressed.connect(_on_next_page_pressed) if world_list_button and not world_list_button.pressed.is_connected(_on_world_list_pressed): world_list_button.pressed.connect(_on_world_list_pressed) func load_categories() -> void: if not category_option: return category_option.clear() category_option.add_item("All Categories", 0) if _api_client == null: return var res: Dictionary = await DiscoveryApiClient.get_categories(_api_client) if res.ok and res.data is Array: var idx := 1 for cat in res.data: var cat_str := str(cat).strip_edges() if cat_str != "": category_option.add_item(cat_str, idx) idx += 1 func load_worlds(page: int = 0) -> void: _current_page = page if loading_label: loading_label.visible = true if error_label: error_label.visible = false if empty_label: empty_label.visible = false var q := search_edit.text.strip_edges() if search_edit else "" var category := "" if category_option and category_option.selected > 0: category = category_option.get_item_text(category_option.selected) var sort := "newest" if sort_option: match sort_option.selected: 0: sort = "newest" 1: sort = "most_played" 2: sort = "trending" var skip := _current_page * _page_size var res: Dictionary = await DiscoveryApiClient.get_worlds( _api_client, q, [], category, sort, skip, _page_size ) if loading_label: loading_label.visible = false if not res.ok: if error_label: error_label.text = "Error: %s" % res.detail error_label.visible = true _clear_container() return var parsed: DiscoveryApiClient.DiscoveryResult = res.get("parsed") if parsed == null and res.data is Dictionary: parsed = DiscoveryApiClient.DiscoveryResult.from_dict(res.data) if parsed == null: if error_label: error_label.text = "Invalid discovery response" error_label.visible = true _clear_container() return _total_count = parsed.total _loaded_worlds = parsed.items _render_worlds(_loaded_worlds) _update_pagination() worlds_loaded.emit(_loaded_worlds.size()) func _clear_container() -> void: if not worlds_container: return for c in worlds_container.get_children(): worlds_container.remove_child(c) c.queue_free() func _render_worlds(worlds: Array[DiscoveryApiClient.WorldSummary]) -> void: _clear_container() if worlds.is_empty(): if empty_label: empty_label.text = "No experiences found matching your criteria." empty_label.visible = true return if empty_label: empty_label.visible = false for world in worlds: var card := _create_world_card(world) worlds_container.add_child(card) func _create_world_card(world: DiscoveryApiClient.WorldSummary) -> Control: var panel := PanelContainer.new() panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL panel.custom_minimum_size = Vector2(0, 90) var style := StyleBoxFlat.new() style.bg_color = Color(0.14, 0.16, 0.20, 1.0) style.corner_radius_top_left = 6 style.corner_radius_top_right = 6 style.corner_radius_bottom_left = 6 style.corner_radius_bottom_right = 6 style.content_margin_left = 16 style.content_margin_right = 16 style.content_margin_top = 10 style.content_margin_bottom = 10 panel.add_theme_stylebox_override("panel", style) var vbox := VBoxContainer.new() vbox.size_flags_horizontal = Control.SIZE_EXPAND_FILL vbox.add_theme_constant_override("separation", 4) panel.add_child(vbox) # Title & Category Row var title_row := HBoxContainer.new() var name_label := Label.new() name_label.text = world.name name_label.add_theme_font_size_override("font_size", 18) name_label.add_theme_color_override("font_color", Color(1.0, 1.0, 1.0, 1.0)) title_row.add_child(name_label) if world.category != "": var cat_label := Label.new() cat_label.text = " [%s]" % world.category cat_label.add_theme_font_size_override("font_size", 14) cat_label.add_theme_color_override("font_color", Color(0.4, 0.7, 1.0, 1.0)) title_row.add_child(cat_label) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL title_row.add_child(spacer) # Enter World Button var enter_button := Button.new() enter_button.text = "Enter World" enter_button.custom_minimum_size = Vector2(110, 30) enter_button.pressed.connect(_show_world_briefing.bind(world, enter_button)) title_row.add_child(enter_button) vbox.add_child(title_row) # Description var desc_label := Label.new() desc_label.text = world.description if world.description != "" else "No description provided." desc_label.autowrap_mode = TextServer.AUTOWRAP_WORD desc_label.add_theme_font_size_override("font_size", 13) desc_label.add_theme_color_override("font_color", Color(0.75, 0.75, 0.75, 1.0)) vbox.add_child(desc_label) # Metadata Row: Tags, Plays, Owner var meta_row := HBoxContainer.new() meta_row.add_theme_constant_override("separation", 16) var play_label := Label.new() play_label.text = "%d plays" % world.play_count play_label.add_theme_font_size_override("font_size", 12) play_label.add_theme_color_override("font_color", Color(0.6, 0.9, 0.6, 1.0)) meta_row.add_child(play_label) if world.tags.size() > 0: var tags_str: String = "" for t in world.tags: tags_str += "#" + str(t) + " " var tags_label := Label.new() tags_label.text = tags_str.strip_edges() tags_label.add_theme_font_size_override("font_size", 12) tags_label.add_theme_color_override("font_color", Color(0.8, 0.7, 0.4, 1.0)) meta_row.add_child(tags_label) if world.owner_username != "": var owner_label := Label.new() owner_label.text = "Creator: %s" % world.owner_username owner_label.add_theme_font_size_override("font_size", 12) owner_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6, 1.0)) meta_row.add_child(owner_label) vbox.add_child(meta_row) return panel func _update_pagination() -> void: var total_pages := int(ceil(float(_total_count) / float(_page_size))) if total_pages <= 0: total_pages = 1 if page_label: page_label.text = "Page %d of %d (%d experiences)" % [_current_page + 1, total_pages, _total_count] if prev_button: prev_button.disabled = (_current_page <= 0) if next_button: next_button.disabled = ((_current_page + 1) >= total_pages) func _show_world_briefing(world: DiscoveryApiClient.WorldSummary, button: Button) -> void: var modal := WorldBriefingModal.new() modal.setup_world_data( world.id, world.name, world.owner_username, world.content_guidelines, world.tags, world.play_count ) 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…" world_selected.emit(world_id) # 1. Record visit metric if _api_client != null: await DiscoveryApiClient.record_visit(_api_client, world_id) # 2. Join flow (existing game server orchestration) if _api_client != null and _api_client.has_method("join_world"): var result: Dictionary = await _api_client.join_world(world_id) if result.ok and result.data is Dictionary: var d: Dictionary = result.data if has_node("/root/Session"): get_node("/root/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: # Dev fallback: local single-player if has_node("/root/Session"): get_node("/root/Session").clear() get_tree().change_scene_to_file("res://scenes/game.tscn") return if status_label: status_label.text = "Failed to join: " + str(result.detail) status_label.visible = true else: # Fallback if standalone test or not wired if has_node("/root/Session"): get_node("/root/Session").clear() get_tree().change_scene_to_file("res://scenes/game.tscn") return button.disabled = false button.text = "Enter World" func _on_filter_pressed() -> void: load_worlds(0) func _on_search_submitted(_new_text: String) -> void: load_worlds(0) func _on_category_selected(_index: int) -> void: load_worlds(0) func _on_sort_selected(_index: int) -> void: load_worlds(0) func _on_refresh_pressed() -> void: load_worlds(_current_page) func _on_prev_page_pressed() -> void: if _current_page > 0: load_worlds(_current_page - 1) func _on_next_page_pressed() -> void: var total_pages := int(ceil(float(_total_count) / float(_page_size))) if (_current_page + 1) < total_pages: load_worlds(_current_page + 1) func _on_back_pressed() -> void: back_requested.emit() if get_tree() and get_tree().current_scene == self: get_tree().change_scene_to_file("res://scenes/main_menu.tscn") func _on_world_list_pressed() -> void: if get_tree(): get_tree().change_scene_to_file("res://scenes/world_list.tscn") func _unhandled_input(event: InputEvent) -> void: if event.is_action_pressed("ui_cancel"): for child in get_children(): if child is WorldBriefingModal: child.queue_free() get_viewport().set_input_as_handled() return get_viewport().set_input_as_handled() _on_back_pressed()