e7299b17e9
Stack installed: - netfox v1.35.3 (core + extras + noray + internals) - godot-jolt v0.16.0-stable Architecture: - Server: ENet transport (works headless, no netfox deps) - Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator) New/modified: - docs/migration-netfox-plan.md — migration architecture - scripts/network/network_manager.gd — netfox-aware ENet fallback - scripts/network/player.gd — clean base player - client/characters/player_netfox.gd — rollback player w/ WeaponManager - client/characters/input/player_net_input.gd — BaseNetInput subclass - client/characters/character/fps_character_controller.gd — netfox input feed - client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager - client/scripts/round_replicator.gd — client-side round state bridge - server/scripts/round_manager.gd — improved state machine - server/scripts/plugin_api/plugin_manager.gd — refined plugin system - config: enemy_tag, ally_tag for meatball targeting Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
514 lines
14 KiB
GDScript
514 lines
14 KiB
GDScript
## 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/<name>.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]]
|