Phase 7: netfox + godot-jolt stack upgrade

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)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+513
View File
@@ -0,0 +1,513 @@
## 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]]