## MapWorkshop — In-game Workshop-style Map Browser ## ## Full-screen UI for browsing, downloading, and managing community maps. ## Two tabs: "Installed" (local cache) and "Online" (registry server). ## Follows the visual style of the existing ServerBrowserUI. ## ## Signals: ## map_selected(map_name: String) — Player clicked "Play" on a map ## ## Usage: ## var workshop = preload("res://client/scripts/map_workshop.gd").new() ## add_child(workshop) ## workshop.show_tab("online") # or "installed" extends Control signal map_selected(map_name: String) # --------------------------------------------------------------------------- # Constants # --------------------------------------------------------------------------- const COL_WIDTHS := { "name": 0.32, "version": 0.08, "size": 0.10, "status": 0.20, "action": 0.30, } const SIZE_SUFFIXES := ["B", "KB", "MB", "GB"] # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _current_tab: String = "installed" # "installed" or "online" var _registry_maps: Array[Dictionary] = [] var _cached_maps: Array[String] = [] var _downloading: Dictionary = {} # map_name → float (progress 0.0-1.0) # UI nodes var _panel: Panel var _title_label: Label var _tab_installed: Button var _tab_online: Button var _filter_edit: LineEdit var _map_list: ItemList var _status_label: Label var _action_button: Button var _close_button: Button var _refresh_button: Button # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _build_ui() # Hook into MapDownloader signals if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") md.map_list_loaded.connect(_on_map_list_loaded) md.map_download_complete.connect(_on_download_complete) md.map_loaded.connect(_on_map_loaded) # Load initial data _refresh_installed() _refresh_online() func _build_ui() -> void: # Main panel _panel = Panel.new() _panel.name = "WorkshopPanel" _panel.anchor_right = 1.0 _panel.anchor_bottom = 1.0 add_child(_panel) var margin := 8 var style = StyleBoxFlat.new() style.bg_color = Color(0.12, 0.12, 0.14, 0.95) style.corner_radius_top_left = 4 style.corner_radius_top_right = 4 style.corner_radius_bottom_left = 4 style.corner_radius_bottom_right = 4 style.content_margin_left = margin style.content_margin_top = margin style.content_margin_right = margin style.content_margin_bottom = margin _panel.add_theme_stylebox_override("panel", style) # Title _title_label = Label.new() _title_label.name = "TitleLabel" _title_label.text = "MAP WORKSHOP" _title_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _title_label.add_theme_font_size_override("font_size", 18) _title_label.add_theme_color_override("font_color", Color(1.0, 0.84, 0.0)) # gold _title_label.position = Vector2(margin, margin) _panel.add_child(_title_label) # Tab buttons _tab_installed = Button.new() _tab_installed.name = "TabInstalled" _tab_installed.text = "Installed" _tab_installed.toggle_mode = true _tab_installed.button_pressed = true _tab_installed.pressed.connect(_on_tab_changed.bind("installed")) _panel.add_child(_tab_installed) _tab_online = Button.new() _tab_online.name = "TabOnline" _tab_online.text = "Online" _tab_online.toggle_mode = true _tab_online.pressed.connect(_on_tab_changed.bind("online")) _panel.add_child(_tab_online) # Filter _filter_edit = LineEdit.new() _filter_edit.name = "FilterEdit" _filter_edit.placeholder_text = "Filter maps by name..." _filter_edit.text_changed.connect(_on_filter_changed) _panel.add_child(_filter_edit) # Map list _map_list = ItemList.new() _map_list.name = "MapList" _map_list.allow_reselect = false _map_list.select_mode = ItemList.SELECT_SINGLE _map_list.item_selected.connect(_on_map_selected) _map_list.nothing_selected.connect(_clear_selection) _panel.add_child(_map_list) # Action button (changes context: "Play" / "Download" / "Remove") _action_button = Button.new() _action_button.name = "ActionButton" _action_button.text = "—" _action_button.disabled = true _action_button.pressed.connect(_on_action_pressed) _panel.add_child(_action_button) # Refresh button _refresh_button = Button.new() _refresh_button.name = "RefreshButton" _refresh_button.text = "↻ Refresh" _refresh_button.pressed.connect(_on_refresh_pressed) _panel.add_child(_refresh_button) # Status bar _status_label = Label.new() _status_label.name = "StatusLabel" _status_label.text = "Ready" _status_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _status_label.add_theme_color_override("font_color", Color(0.6, 0.6, 0.6)) _panel.add_child(_status_label) # Close button _close_button = Button.new() _close_button.name = "CloseButton" _close_button.text = "✕" _close_button.pressed.connect(_on_close_pressed) _panel.add_child(_close_button) func _notification(what: int) -> void: if what == NOTIFICATION_RESIZED or what == NOTIFICATION_SORT_CHILDREN: _resize_ui() func _resize_ui() -> void: var w = _panel.size.x var h = _panel.size.y var m := 8 var y := m # Title _title_label.position = Vector2(m, y) _title_label.size = Vector2(w - m * 2, 24) y += 30 # Tabs row var tab_w = (w - m * 3) / 2 _tab_installed.position = Vector2(m, y) _tab_installed.size = Vector2(tab_w, 26) _tab_online.position = Vector2(m + tab_w + m, y) _tab_online.size = Vector2(tab_w, 26) y += 34 # Filter _filter_edit.position = Vector2(m, y) _filter_edit.size = Vector2(w - m * 2, 24) y += 32 # Map list var list_height = h - y - 80 _map_list.position = Vector2(m, y) _map_list.size = Vector2(w - m * 2, max(list_height, 60)) y += _map_list.size.y + 8 # Buttons row var btn_w = (w - m * 3) / 3 _action_button.position = Vector2(m, y) _action_button.size = Vector2(btn_w, 28) _refresh_button.position = Vector2(m + btn_w + m / 2, y) _refresh_button.size = Vector2(btn_w, 28) y += 36 # Status _status_label.position = Vector2(m, y) _status_label.size = Vector2(w - m * 2, 20) # Close button (top-right corner) _close_button.position = Vector2(w - 28, 4) _close_button.size = Vector2(24, 24) # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- ## Open to a specific tab. func show_tab(tab: String) -> void: match tab: "installed": _tab_installed.button_pressed = true _tab_online.button_pressed = false _current_tab = "installed" _populate_list() "online": _tab_installed.button_pressed = false _tab_online.button_pressed = true _current_tab = "online" _refresh_online() _populate_list() # --------------------------------------------------------------------------- # Data # --------------------------------------------------------------------------- func _refresh_installed() -> void: if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") _cached_maps = md.get_cached_maps() else: _cached_maps = [] func _refresh_online() -> void: if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") md.fetch_map_list() _status_label.text = "Fetching map list..." else: _status_label.text = "MapDownloader not available" # --------------------------------------------------------------------------- # List population # --------------------------------------------------------------------------- func _populate_list() -> void: _map_list.clear() _action_button.disabled = true _action_button.text = "—" var filter_text = _filter_edit.text.strip_edges().to_lower() match _current_tab: "installed": _populate_installed(filter_text) "online": _populate_online(filter_text) func _populate_installed(filter_text: String) -> void: if _cached_maps.is_empty(): _map_list.add_item(" No maps installed") _status_label.text = "No maps cached. Switch to Online tab to download maps." return if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") var count := 0 for name in _cached_maps: if not filter_text.is_empty() and not name.to_lower().contains(filter_text): continue var info = md.get_cached_map_info(name) var size_bytes = info.get("size", 0) var version = info.get("version", 1) var size_str = _format_size(size_bytes) var status = "Ready" var icon = "✓" var label = "%s %s v%d %s %s" % [icon, name, version, size_str, status] _map_list.add_item(label) count += 1 _status_label.text = "%d installed map(s)" % count else: _map_list.add_item(" MapDownloader not loaded") _status_label.text = "MapDownloader singleton not found" func _populate_online(filter_text: String) -> void: if _registry_maps.is_empty(): _map_list.add_item(" Fetching map list... (press Refresh)") _status_label.text = "No maps from registry yet" return if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") var count := 0 for m in _registry_maps: if not (m is Dictionary): continue var name: String = m.get("name", "") if name.is_empty(): continue if not filter_text.is_empty() and not name.to_lower().contains(filter_text): continue var size_bytes = m.get("size", 0) var description = m.get("description", "") var reg_version = m.get("version", 1) var is_cached = md.is_map_cached(name) var cached_version = md.get_cached_version(name) if is_cached else 0 var needs_update = is_cached and reg_version > cached_version var status: String var icon: String if _downloading.has(name): status = "Downloading..." icon = "⏳" elif needs_update: status = "Update available" icon = "↑" elif is_cached: status = "Installed" icon = "✓" else: status = description if not description.is_empty() else "Not installed" icon = "+" var size_str = _format_size(size_bytes) var label = "%s %s v%d %s %s" % [icon, name, reg_version, size_str, status] _map_list.add_item(label) count += 1 _status_label.text = "%d map(s) on registry" % count else: _status_label.text = "MapDownloader not available" # --------------------------------------------------------------------------- # UI callbacks # --------------------------------------------------------------------------- func _on_tab_changed(tab: String) -> void: _current_tab = tab # Update toggle states _tab_installed.button_pressed = (tab == "installed") _tab_online.button_pressed = (tab == "online") if tab == "installed": _refresh_installed() elif tab == "online": _refresh_online() _populate_list() func _on_filter_changed(_new_text: String) -> void: _populate_list() func _on_map_selected(index: int) -> void: if index < 0: _clear_selection() return var label: String = _map_list.get_item_text(index) if label.begins_with(" "): _clear_selection() return # Parse the map name from the label (skipping icon prefix) var map_name = _extract_map_name(label) if map_name.is_empty(): _clear_selection() return match _current_tab: "installed": _action_button.disabled = false _action_button.text = "► Play" "online": if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") if md.is_map_cached(map_name): _action_button.disabled = false _action_button.text = "► Play" else: _action_button.disabled = false _action_button.text = "↓ Download" else: _action_button.disabled = true _action_button.text = "—" func _clear_selection() -> void: _action_button.disabled = true _action_button.text = "—" func _on_action_pressed() -> void: var selected = _map_list.get_selected_items() if selected.is_empty(): return var index = selected[0] var label = _map_list.get_item_text(index) var map_name = _extract_map_name(label) if map_name.is_empty(): return var action = _action_button.text if action.contains("Play"): map_selected.emit(map_name) elif action.contains("Download"): _start_download(map_name) func _on_refresh_pressed() -> void: match _current_tab: "installed": _refresh_installed() "online": _refresh_online() _populate_list() _status_label.text = "Refreshed" func _on_close_pressed() -> void: queue_free() # --------------------------------------------------------------------------- # Download handling # --------------------------------------------------------------------------- func _start_download(map_name: String) -> void: _downloading[map_name] = 0.0 if Engine.has_singleton("MapDownloader"): var md = Engine.get_singleton("MapDownloader") md.download_map(map_name) _status_label.text = "Downloading %s..." % map_name _populate_list() func _on_map_list_loaded(maps: Array) -> void: _registry_maps = maps if _current_tab == "online": _populate_list() func _on_download_complete(map_name: String, success: bool) -> void: _downloading.erase(map_name) if success: _status_label.text = "%s downloaded and loaded" % map_name _refresh_installed() else: _status_label.text = "Download failed: %s" % map_name if _current_tab == "online": _populate_list() func _on_map_loaded(map_name: String) -> void: print("[MapWorkshop] Map loaded: %s" % map_name) # The caller can now load res://scenes/maps/.tscn # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- ## Extract the map name from a formatted list item label. ## Labels are formatted: "✓ map_name v1 12.3 MB Ready" func _extract_map_name(label: String) -> String: # Skip the first character (icon) and space if label.length() < 3: return "" var rest = label.substr(2).strip_edges() # The map name runs until we hit two consecutive spaces or " v" var v_idx = rest.find(" v") if v_idx < 0: v_idx = rest.find(" v") if v_idx < 0: v_idx = rest.find(" v") if v_idx > 0: return rest.substr(0, v_idx).strip_edges() return rest.strip_edges() ## Format bytes to human-readable size. static func _format_size(bytes: int) -> String: if bytes <= 0: return "0 B" var unit_idx := 0 var size := float(bytes) while size >= 1024.0 and unit_idx < SIZE_SUFFIXES.size() - 1: size /= 1024.0 unit_idx += 1 if unit_idx == 0: return "%d %s" % [bytes, SIZE_SUFFIXES[unit_idx]] return "%.1f %s" % [size, SIZE_SUFFIXES[unit_idx]]