From a9144a1160aae2e05296011053324c76494b3855 Mon Sep 17 00:00:00 2001 From: daddy Date: Tue, 15 Sep 2026 21:02:58 +0000 Subject: [PATCH] feat(godot): discovery & home UI with search, categories, sorting, pagination, and world entry --- scenes/discovery_ui.tscn | 150 ++++++++++++ scripts/discovery_ui.gd | 376 +++++++++++++++++++++++++++++ scripts/smoke_test_discovery_ui.gd | 89 +++++++ 3 files changed, 615 insertions(+) create mode 100644 scenes/discovery_ui.tscn create mode 100644 scripts/discovery_ui.gd create mode 100644 scripts/smoke_test_discovery_ui.gd diff --git a/scenes/discovery_ui.tscn b/scenes/discovery_ui.tscn new file mode 100644 index 0000000..bd28346 --- /dev/null +++ b/scenes/discovery_ui.tscn @@ -0,0 +1,150 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/discovery_ui.gd" id="1_disc"] + +[node name="DiscoveryUI" type="Control"] +layout_mode = 3 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 +script = ExtResource("1_disc") + +[node name="VBox" type="VBoxContainer" parent="."] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32.0 +offset_top = 24.0 +offset_right = -32.0 +offset_bottom = -24.0 +grow_horizontal = 2 +grow_vertical = 2 +theme_override_constants/separation = 12 + +[node name="Header" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 + +[node name="BackButton" type="Button" parent="VBox/Header"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "< Back" + +[node name="TitleLabel" type="Label" parent="VBox/Header"] +layout_mode = 2 +size_flags_horizontal = 3 +theme_override_font_sizes/font_size = 26 +text = "Discover Experiences" +horizontal_alignment = 1 + +[node name="RefreshButton" type="Button" parent="VBox/Header"] +custom_minimum_size = Vector2(100, 36) +layout_mode = 2 +text = "Refresh" + +[node name="StatusLabel" type="Label" parent="VBox"] +visible = false +layout_mode = 2 +theme_override_colors/font_color = Color(1, 0.3, 0.3, 1) +text = "" + +[node name="Filters" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 10 + +[node name="SearchEdit" type="LineEdit" parent="VBox/Filters"] +custom_minimum_size = Vector2(240, 36) +layout_mode = 2 +placeholder_text = "Search by name or tag..." + +[node name="CategoryOption" type="OptionButton" parent="VBox/Filters"] +custom_minimum_size = Vector2(160, 36) +layout_mode = 2 + +[node name="SortOption" type="OptionButton" parent="VBox/Filters"] +custom_minimum_size = Vector2(140, 36) +layout_mode = 2 + +[node name="FilterButton" type="Button" parent="VBox/Filters"] +custom_minimum_size = Vector2(90, 36) +layout_mode = 2 +text = "Filter" + +[node name="Content" type="Control" parent="VBox"] +layout_mode = 2 +size_flags_vertical = 3 + +[node name="LoadingLabel" type="Label" parent="VBox/Content"] +visible = false +layout_mode = 1 +anchors_preset = 14 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_top = -12.0 +offset_bottom = 12.0 +text = "Loading experiences..." +horizontal_alignment = 1 + +[node name="ErrorLabel" type="Label" parent="VBox/Content"] +visible = false +layout_mode = 1 +anchors_preset = 14 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_top = -12.0 +offset_bottom = 12.0 +theme_override_colors/font_color = Color(1, 0.4, 0.4, 1) +text = "" +horizontal_alignment = 1 + +[node name="EmptyLabel" type="Label" parent="VBox/Content"] +visible = false +layout_mode = 1 +anchors_preset = 14 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_top = -12.0 +offset_bottom = 12.0 +theme_override_colors/font_color = Color(0.7, 0.7, 0.7, 1) +text = "No experiences found." +horizontal_alignment = 1 + +[node name="Scroll" type="ScrollContainer" parent="VBox/Content"] +layout_mode = 1 +anchors_preset = 15 +anchor_right = 1.0 +anchor_bottom = 1.0 +grow_horizontal = 2 +grow_vertical = 2 + +[node name="WorldsVBox" type="VBoxContainer" parent="VBox/Content/Scroll"] +layout_mode = 2 +size_flags_horizontal = 3 +size_flags_vertical = 3 +theme_override_constants/separation = 10 + +[node name="Pagination" type="HBoxContainer" parent="VBox"] +layout_mode = 2 +theme_override_constants/separation = 16 +alignment = 1 + +[node name="PrevButton" type="Button" parent="VBox/Pagination"] +custom_minimum_size = Vector2(90, 32) +layout_mode = 2 +text = "< Prev" + +[node name="PageLabel" type="Label" parent="VBox/Pagination"] +layout_mode = 2 +text = "Page 1 of 1" +horizontal_alignment = 1 + +[node name="NextButton" type="Button" parent="VBox/Pagination"] +custom_minimum_size = Vector2(90, 32) +layout_mode = 2 +text = "Next >" diff --git a/scripts/discovery_ui.gd b/scripts/discovery_ui.gd new file mode 100644 index 0000000..7810fb9 --- /dev/null +++ b/scripts/discovery_ui.gd @@ -0,0 +1,376 @@ +extends Control +class_name DiscoveryUI +## 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 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) + + +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(_on_enter_world_pressed.bind(world.id, 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 _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(): + get_tree().change_scene_to_file("res://scenes/main_menu.tscn") diff --git a/scripts/smoke_test_discovery_ui.gd b/scripts/smoke_test_discovery_ui.gd new file mode 100644 index 0000000..f7d3674 --- /dev/null +++ b/scripts/smoke_test_discovery_ui.gd @@ -0,0 +1,89 @@ +extends SceneTree +## Headless smoke test for DiscoveryUI. + +func _initialize() -> void: + _run() + +func _run() -> void: + print("[SMOKE] Starting DiscoveryUI smoke test...") + await process_frame + + var scene = load("res://scenes/discovery_ui.tscn") + if not scene: + printerr("[FAIL] Could not load res://scenes/discovery_ui.tscn") + quit(1) + return + + var ui: DiscoveryUI = scene.instantiate() + root.add_child(ui) + await process_frame + + # 1. Verify node references + assert(ui.search_edit != null, "search_edit is null") + assert(ui.category_option != null, "category_option is null") + assert(ui.sort_option != null, "sort_option is null") + assert(ui.filter_button != null, "filter_button is null") + assert(ui.worlds_container != null, "worlds_container is null") + assert(ui.prev_button != null, "prev_button is null") + assert(ui.next_button != null, "next_button is null") + assert(ui.page_label != null, "page_label is null") + print("[PASS] Node references verified") + + # 2. Verify sort options + assert(ui.sort_option.item_count == 3, "sort_option should have 3 items") + assert(ui.sort_option.get_item_text(0) == "Newest", "sort 0 should be Newest") + assert(ui.sort_option.get_item_text(1) == "Most Played", "sort 1 should be Most Played") + assert(ui.sort_option.get_item_text(2) == "Trending", "sort 2 should be Trending") + print("[PASS] Sort options verified") + + # 3. Verify category dropdown initial state + assert(ui.category_option.item_count >= 1, "category_option should have at least All Categories") + assert(ui.category_option.get_item_text(0) == "All Categories", "category 0 should be All Categories") + print("[PASS] Category dropdown verified") + + # 4. Test rendering of WorldSummary cards + var mock_w := DiscoveryApiClient.WorldSummary.new() + mock_w.id = 101 + mock_w.name = "Cyber Nexus" + mock_w.category = "Action" + mock_w.tags = ["cyberpunk", "pvp"] + mock_w.play_count = 256 + mock_w.description = "A futuristic arena" + mock_w.owner_username = "neon_builder" + + var mock_worlds: Array[DiscoveryApiClient.WorldSummary] = [mock_w] + ui._render_worlds(mock_worlds) + assert(ui.worlds_container.get_child_count() == 1, "Should render 1 card") + assert(ui.empty_label.visible == false, "empty_label should be hidden when items present") + print("[PASS] World card rendering verified") + + # 5. Test empty state + var empty_list: Array[DiscoveryApiClient.WorldSummary] = [] + ui._render_worlds(empty_list) + assert(ui.worlds_container.get_child_count() == 0, "Container should have 0 children") + assert(ui.empty_label.visible == true, "empty_label should be visible when empty") + print("[PASS] Empty state verified") + + # 6. Test pagination logic + ui._total_count = 25 + ui._page_size = 10 + ui._current_page = 0 + ui._update_pagination() + assert(ui.prev_button.disabled == true, "Page 0 prev_button should be disabled") + assert(ui.next_button.disabled == false, "Page 0 next_button should be enabled") + assert(ui.page_label.text.contains("Page 1 of 3"), "Page 1 of 3 mismatch: %s" % ui.page_label.text) + + ui._current_page = 1 + ui._update_pagination() + assert(ui.prev_button.disabled == false, "Page 1 prev_button should be enabled") + assert(ui.next_button.disabled == false, "Page 1 next_button should be enabled") + + ui._current_page = 2 + ui._update_pagination() + assert(ui.prev_button.disabled == false, "Page 2 prev_button should be enabled") + assert(ui.next_button.disabled == true, "Page 2 next_button should be disabled") + print("[PASS] Pagination logic verified") + + ui.queue_free() + print("[SMOKE] DiscoveryUI smoke test PASSED") + quit(0)