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:
@@ -0,0 +1,153 @@
|
||||
@tool
|
||||
extends EditorScript
|
||||
|
||||
# Apply distance-based visibility ranges to all modular CSG pieces.
|
||||
#
|
||||
# This is the equivalent of LOD for CSG-based geometry — it hides
|
||||
# entire pieces when they're beyond a useful visual distance.
|
||||
#
|
||||
# Ranges (defined per asset type):
|
||||
# Walls: hide past 50 m
|
||||
# Floors: hide past 60 m
|
||||
# Pillars: hide past 30 m
|
||||
# Beams: hide past 30 m
|
||||
# Accent panels: hide past 20 m
|
||||
# Door/Window: hide past 40 m
|
||||
#
|
||||
# Usage:
|
||||
# Select a scene in the Godot editor, then:
|
||||
# Scene > Run Script (select this file)
|
||||
#
|
||||
# Or from CLI on a specific scene:
|
||||
# godot --script scripts/apply_visibility_ranges.gd \
|
||||
# --scene res://assets/scenes/modular/kit_demo.tscn
|
||||
|
||||
const DEBUG := false # Enable for verbose logging
|
||||
|
||||
# Default ranges per type (begin = 0.0 means always visible up to end)
|
||||
const RANGES := {
|
||||
"wall": {"begin": 0.0, "end": 50.0, "margin": 5.0}, # fade out over 5m
|
||||
"floor": {"begin": 0.0, "end": 60.0, "margin": 10.0},
|
||||
"pillar": {"begin": 0.0, "end": 30.0, "margin": 3.0},
|
||||
"beam": {"begin": 0.0, "end": 30.0, "margin": 3.0},
|
||||
"accent": {"begin": 0.0, "end": 20.0, "margin": 2.0},
|
||||
"doorway": {"begin": 0.0, "end": 40.0, "margin": 5.0},
|
||||
"window": {"begin": 0.0, "end": 40.0, "margin": 5.0},
|
||||
"endcap": {"begin": 0.0, "end": 50.0, "margin": 5.0},
|
||||
"default": {"begin": 0.0, "end": 40.0, "margin": 5.0},
|
||||
}
|
||||
|
||||
func _run():
|
||||
var scene_root: Node
|
||||
|
||||
if Engine.is_editor_hint():
|
||||
scene_root = get_scene() as Node
|
||||
if not scene_root:
|
||||
printerr("No open scene in editor. Open a scene first.")
|
||||
return
|
||||
else:
|
||||
var args = OS.get_cmdline_args()
|
||||
var scene_path := ""
|
||||
for i in range(args.size()):
|
||||
if args[i] == "--scene" and i + 1 < args.size():
|
||||
scene_path = args[i + 1]
|
||||
if scene_path.is_empty():
|
||||
printerr("Usage: godot --script apply_visibility_ranges.gd --scene res://path/to/scene.tscn" +
|
||||
"\nOr run from the Godot editor with a scene open.")
|
||||
return
|
||||
|
||||
var packed = ResourceLoader.load(scene_path)
|
||||
if not packed:
|
||||
printerr("Failed to load scene: ", scene_path)
|
||||
return
|
||||
scene_root = packed.instantiate()
|
||||
|
||||
print("=== Visibility Range Applicator ===")
|
||||
print("Scene: ", scene_root.name)
|
||||
|
||||
var counts := {} # type → number of nodes modified
|
||||
var total := 0
|
||||
|
||||
_apply_to_node(scene_root, counts, total)
|
||||
|
||||
print("")
|
||||
print("Modified nodes: ", total)
|
||||
for type_name in counts:
|
||||
print(" %s: %d" % [type_name, counts[type_name]])
|
||||
print("")
|
||||
print("=== Ranges applied ===")
|
||||
|
||||
func _apply_to_node(node: Node, counts: Dictionary, total: int) -> void:
|
||||
# Only process CSG nodes (the modular pieces)
|
||||
if node is CSGShape3D:
|
||||
var range_config := _get_range_for_node(node)
|
||||
if range_config != null:
|
||||
node.visibility_range_begin = range_config.begin
|
||||
node.visibility_range_end = range_config.end
|
||||
node.visibility_range_begin_margin = range_config.margin
|
||||
node.visibility_range_end_margin = range_config.margin
|
||||
|
||||
var type_name := _classify_node(node)
|
||||
counts[type_name] = counts.get(type_name, 0) + 1
|
||||
total += 1
|
||||
|
||||
if DEBUG:
|
||||
print(" %s → %.0f–%.0f m (type: %s)" % [node.name, range_config.begin, range_config.end, type_name])
|
||||
|
||||
# Also apply to CSGCombiner3D parents (hold doorway/window subtractions)
|
||||
if node is CSGCombiner3D:
|
||||
var range_config := _get_range_for_combiner(node)
|
||||
if range_config != null:
|
||||
node.visibility_range_begin = range_config.begin
|
||||
node.visibility_range_end = range_config.end
|
||||
node.visibility_range_begin_margin = range_config.margin
|
||||
node.visibility_range_end_margin = range_config.margin
|
||||
|
||||
var type_name := _classify_node(node)
|
||||
counts[type_name] = counts.get(type_name, 0) + 1
|
||||
total += 1
|
||||
|
||||
if DEBUG:
|
||||
print(" %s → %.0f–%.0f m (type: %s)" % [node.name, range_config.begin, range_config.end, type_name])
|
||||
|
||||
# Recurse
|
||||
for child in node.get_children():
|
||||
_apply_to_node(child, counts, total)
|
||||
|
||||
func _classify_node(node: Node) -> String:
|
||||
var name_lower := node.name.to_lower()
|
||||
|
||||
if name_lower.contains("wall"):
|
||||
if name_lower.contains("door"): return "doorway"
|
||||
if name_lower.contains("window"): return "window"
|
||||
if name_lower.contains("corner"): return "wall"
|
||||
if name_lower.contains("endcap"): return "endcap"
|
||||
return "wall"
|
||||
if name_lower.contains("floor"): return "floor"
|
||||
if name_lower.contains("pillar"): return "pillar"
|
||||
if name_lower.contains("beam"): return "beam"
|
||||
if name_lower.contains("accent") or name_lower.contains("blue") or name_lower.contains("red"):
|
||||
return "accent"
|
||||
if name_lower.contains("panel"):
|
||||
return "accent"
|
||||
|
||||
return "default"
|
||||
|
||||
func _get_range_for_node(node: CSGShape3D) -> Dictionary:
|
||||
# Detect CSGBox3D size to infer type
|
||||
if node is CSGBox3D:
|
||||
var box := node as CSGBox3D
|
||||
var size := box.size
|
||||
|
||||
# Floor slabs: thin, wide (Z > X on rotated ones, check min dimension)
|
||||
if size.y <= 0.1 and (size.x >= 2.0 or size.z >= 2.0):
|
||||
return RANGES.floor.duplicate()
|
||||
|
||||
return _get_range_by_name(node)
|
||||
|
||||
func _get_range_for_combiner(node: CSGCombiner3D) -> Dictionary:
|
||||
return _get_range_by_name(node)
|
||||
|
||||
func _get_range_by_name(node: Node) -> Dictionary:
|
||||
var type_name := _classify_node(node)
|
||||
return RANGES.get(type_name, RANGES.default).duplicate()
|
||||
@@ -1 +1 @@
|
||||
uid://q8nutj781fyf
|
||||
uid://tvxatyq01ubm
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
## MapDownloader — Client-side map download and cache management
|
||||
##
|
||||
## Autoload singleton that downloads .pck map packs from the map registry
|
||||
## server and loads them into the running game. Provides a cache manifest
|
||||
## at user://maps/manifest.json for persistence across restarts.
|
||||
##
|
||||
## ## Architecture
|
||||
##
|
||||
## Registry Server MapDownloader (Godot autoload)
|
||||
## ┌──────────────────────┐ ┌─────────────────────────┐
|
||||
## │ GET /maps │ ◄──── │ fetch_map_list() │
|
||||
## │ GET /maps/:name.pck │ ◄──── │ download_map(name) │
|
||||
## │ GET /maps/:name.json│ ◄──── │ get_map_info(name) │
|
||||
## └──────────────────────┘ │ │
|
||||
## │ Cache: │
|
||||
## user://maps/<name>.pck ══╡ .pck files │
|
||||
## user://maps/manifest.json ═╡ manifest │
|
||||
## └─────────────────────────┘
|
||||
##
|
||||
## ## Signals
|
||||
## map_list_loaded(maps: Array[Dictionary]) — registry map list
|
||||
## map_download_progress(map_name, received, total)
|
||||
## map_download_complete(map_name, success)
|
||||
## map_loaded(map_name)
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
signal map_list_loaded(maps: Array) # maps from registry server
|
||||
signal map_download_progress(map_name: String, bytes_received: int, bytes_total: int)
|
||||
signal map_download_complete(map_name: String, success: bool)
|
||||
signal map_loaded(map_name: String)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
const MAP_CACHE_DIR: String = "user://maps/"
|
||||
const MANIFEST_FILE: String = "user://maps/manifest.json"
|
||||
const DEFAULT_REGISTRY_URL: String = "http://127.0.0.1:8090"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## URL of the map registry server (no trailing slash).
|
||||
var registry_url: String = DEFAULT_REGISTRY_URL:
|
||||
set(val):
|
||||
registry_url = val.trim_suffix("/")
|
||||
|
||||
## Timeout for HTTP requests in seconds.
|
||||
var http_timeout: float = 30.0
|
||||
|
||||
## Max concurrent downloads.
|
||||
var max_concurrent: int = 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var _http_busy: bool = false
|
||||
var _active_downloads: Dictionary = {} # map_name → Dictionary
|
||||
var _download_queue: Array[Dictionary] = []
|
||||
var _manifest: Dictionary = {} # {map_name: {version, size, downloaded_at}}
|
||||
var _loaded_pcks: Array[String] = []
|
||||
var _http_nodes: Array[HTTPRequest] = []
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Create cache directory
|
||||
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(MAP_CACHE_DIR))
|
||||
|
||||
# Load local manifest
|
||||
_load_manifest()
|
||||
|
||||
# Override registry URL from environment
|
||||
if OS.has_environment("MAP_REGISTRY_URL"):
|
||||
registry_url = OS.get_environment("MAP_REGISTRY_URL")
|
||||
|
||||
print("[MapDownloader] Registry URL: %s" % registry_url)
|
||||
print("[MapDownloader] Cache dir: %s" % MAP_CACHE_DIR)
|
||||
print("[MapDownloader] Cached maps: %d" % _manifest.size())
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Fetch the list of available maps from the registry server.
|
||||
## Emits map_list_loaded when done.
|
||||
func fetch_map_list() -> void:
|
||||
if _http_busy:
|
||||
return
|
||||
_http_busy = true
|
||||
|
||||
var url: String = "%s/maps" % [registry_url]
|
||||
var http := HTTPRequest.new()
|
||||
http.name = "MapListHTTP"
|
||||
http.timeout = http_timeout
|
||||
add_child(http)
|
||||
_http_nodes.append(http)
|
||||
http.request_completed.connect(_on_map_list_received.bind(http))
|
||||
http.request(url)
|
||||
|
||||
## Check if a map is in the local cache.
|
||||
func is_map_cached(map_name: String) -> bool:
|
||||
if not _manifest.has(map_name):
|
||||
return false
|
||||
|
||||
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
|
||||
var global_path: String = ProjectSettings.globalize_path(pck_path)
|
||||
return FileAccess.file_exists(global_path)
|
||||
|
||||
## Get the version of a cached map. Returns 0 if not cached.
|
||||
func get_cached_version(map_name: String) -> int:
|
||||
var info = _manifest.get(map_name, {})
|
||||
return info.get("version", 1)
|
||||
|
||||
## Download a single map .pck from the registry server.
|
||||
## The map is saved to user://maps/<name>.pck and loaded automatically.
|
||||
func download_map(map_name: String) -> void:
|
||||
if is_map_cached(map_name):
|
||||
print("[MapDownloader] %s already cached — loading" % map_name)
|
||||
_load_map_pck(map_name)
|
||||
return
|
||||
|
||||
if map_name in _active_downloads:
|
||||
print("[MapDownloader] %s is already downloading" % map_name)
|
||||
return
|
||||
|
||||
var entry := {
|
||||
map_name = map_name,
|
||||
url = "%s/maps/%s.pck" % [registry_url, map_name],
|
||||
}
|
||||
if _active_downloads.size() < max_concurrent:
|
||||
_start_download(entry)
|
||||
else:
|
||||
_download_queue.append(entry)
|
||||
print("[MapDownloader] %s queued (%d waiting)" % [map_name, _download_queue.size()])
|
||||
|
||||
## Download multiple maps.
|
||||
func download_maps(map_names: Array[String]) -> void:
|
||||
for name in map_names:
|
||||
download_map(name)
|
||||
|
||||
## Load a cached .pck into the resource system.
|
||||
## Returns true if the pack was loaded successfully.
|
||||
func load_map(map_name: String) -> bool:
|
||||
return _load_map_pck(map_name)
|
||||
|
||||
## Remove a cached map from disk and manifest.
|
||||
func remove_map(map_name: String) -> void:
|
||||
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
|
||||
var global_path: String = ProjectSettings.globalize_path(pck_path)
|
||||
if FileAccess.file_exists(global_path):
|
||||
DirAccess.remove_absolute(global_path)
|
||||
_manifest.erase(map_name)
|
||||
_save_manifest()
|
||||
var idx = _loaded_pcks.find(map_name)
|
||||
if idx >= 0:
|
||||
_loaded_pcks.remove_at(idx)
|
||||
print("[MapDownloader] Removed cached map: %s" % map_name)
|
||||
|
||||
## Get list of locally cached map names.
|
||||
func get_cached_maps() -> Array[String]:
|
||||
return _manifest.keys()
|
||||
|
||||
## Get info about a cached map from the manifest.
|
||||
func get_cached_map_info(map_name: String) -> Dictionary:
|
||||
return _manifest.get(map_name, {})
|
||||
|
||||
## Clear all cached maps.
|
||||
func clear_cache() -> void:
|
||||
for name in _manifest.keys():
|
||||
remove_map(name)
|
||||
print("[MapDownloader] Cache cleared")
|
||||
|
||||
## Get total cache size in bytes.
|
||||
func get_cache_size_bytes() -> int:
|
||||
var total := 0
|
||||
for name in _manifest.keys():
|
||||
total += _manifest[name].get("size", 0)
|
||||
return total
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: Download handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _start_download(entry: Dictionary) -> void:
|
||||
var map_name: String = entry.map_name
|
||||
var url: String = entry.url
|
||||
var save_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, map_name])
|
||||
|
||||
_active_downloads[map_name] = entry
|
||||
print("[MapDownloader] Downloading: %s" % map_name)
|
||||
|
||||
var http := HTTPRequest.new()
|
||||
http.name = "MapDl_%s" % map_name
|
||||
http.timeout = http_timeout
|
||||
http.download_file = save_path
|
||||
add_child(http)
|
||||
_http_nodes.append(http)
|
||||
http.request_completed.connect(_on_map_downloaded.bind(map_name, http))
|
||||
http.request(url)
|
||||
|
||||
func _process_download_queue() -> void:
|
||||
if _download_queue.is_empty():
|
||||
return
|
||||
var next: Dictionary = _download_queue.pop_front()
|
||||
_start_download(next)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: Callbacks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_map_list_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray, http_node: HTTPRequest) -> void:
|
||||
_http_busy = false
|
||||
|
||||
# Cleanup the HTTP node
|
||||
http_node.request_completed.disconnect(_on_map_list_received)
|
||||
_http_nodes.erase(http_node)
|
||||
http_node.queue_free()
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
push_error("[MapDownloader] Network error fetching map list: %d" % result)
|
||||
map_list_loaded.emit([])
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
push_error("[MapDownloader] Failed to fetch map list: HTTP %d" % response_code)
|
||||
map_list_loaded.emit([])
|
||||
return
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_err: Error = json.parse(body.get_string_from_utf8())
|
||||
if parse_err != OK:
|
||||
push_error("[MapDownloader] Failed to parse map list JSON")
|
||||
map_list_loaded.emit([])
|
||||
return
|
||||
|
||||
var data: Dictionary = json.data
|
||||
var maps: Array = data.get("maps", [])
|
||||
|
||||
print("[MapDownloader] Registry offers %d maps" % maps.size())
|
||||
map_list_loaded.emit(maps)
|
||||
|
||||
func _on_map_downloaded(result: int, response_code: int, _headers: PackedStringArray, _body: PackedByteArray, map_name: String, http_node: HTTPRequest) -> void:
|
||||
_active_downloads.erase(map_name)
|
||||
|
||||
# Cleanup the HTTP node
|
||||
http_node.request_completed.disconnect(_on_map_downloaded)
|
||||
_http_nodes.erase(http_node)
|
||||
http_node.queue_free()
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
|
||||
push_error("[MapDownloader] Download failed for %s: result=%d HTTP=%d" % [map_name, result, response_code])
|
||||
map_download_complete.emit(map_name, false)
|
||||
_process_download_queue()
|
||||
return
|
||||
|
||||
print("[MapDownloader] Downloaded %s successfully" % map_name)
|
||||
map_download_complete.emit(map_name, true)
|
||||
|
||||
# Update manifest
|
||||
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
|
||||
var global_path: String = ProjectSettings.globalize_path(pck_path)
|
||||
var file_size: int = 0
|
||||
if FileAccess.file_exists(global_path):
|
||||
file_size = FileAccess.get_size(global_path)
|
||||
|
||||
_manifest[map_name] = {
|
||||
version = _manifest.get(map_name, {}).get("version", 1),
|
||||
size = file_size,
|
||||
downloaded_at = Time.get_datetime_string_from_system(),
|
||||
}
|
||||
_save_manifest()
|
||||
|
||||
# Load the map into the resource system
|
||||
_load_map_pck(map_name)
|
||||
_process_download_queue()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: PCK loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _load_map_pck(map_name: String) -> bool:
|
||||
if map_name in _loaded_pcks:
|
||||
return true
|
||||
|
||||
var pck_path: String = "%s%s.pck" % [MAP_CACHE_DIR, map_name]
|
||||
var global_path: String = ProjectSettings.globalize_path(pck_path)
|
||||
|
||||
if not FileAccess.file_exists(global_path):
|
||||
push_error("[MapDownloader] Cannot load %s: .pck not found" % map_name)
|
||||
return false
|
||||
|
||||
var err: Error = ProjectSettings.load_resource_pack(global_path)
|
||||
if err != OK:
|
||||
push_error("[MapDownloader] Failed to load %s: %s" % [map_name, error_string(err)])
|
||||
return false
|
||||
|
||||
_loaded_pcks.append(map_name)
|
||||
print("[MapDownloader] Loaded map: %s" % map_name)
|
||||
map_loaded.emit(map_name)
|
||||
return true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: Manifest persistence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _load_manifest() -> void:
|
||||
var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
|
||||
if not FileAccess.file_exists(global_path):
|
||||
_manifest = {}
|
||||
return
|
||||
|
||||
var f := FileAccess.open(global_path, FileAccess.READ)
|
||||
if f == null:
|
||||
_manifest = {}
|
||||
return
|
||||
|
||||
var json := JSON.new()
|
||||
var parse_err: Error = json.parse(f.get_as_text())
|
||||
f.close()
|
||||
|
||||
if parse_err != OK:
|
||||
push_warning("[MapDownloader] Failed to parse manifest — starting fresh")
|
||||
_manifest = {}
|
||||
else:
|
||||
_manifest = json.data
|
||||
_validate_manifest()
|
||||
|
||||
func _save_manifest() -> void:
|
||||
var global_path: String = ProjectSettings.globalize_path(MANIFEST_FILE)
|
||||
var f := FileAccess.open(global_path, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("[MapDownloader] Cannot save manifest to %s" % global_path)
|
||||
return
|
||||
|
||||
f.store_string(JSON.stringify(_manifest, "\t", false))
|
||||
f.close()
|
||||
|
||||
func _validate_manifest() -> void:
|
||||
var stale: Array[String] = []
|
||||
for name in _manifest.keys():
|
||||
var pck_path: String = ProjectSettings.globalize_path("%s%s.pck" % [MAP_CACHE_DIR, name])
|
||||
if not FileAccess.file_exists(pck_path):
|
||||
stale.append(name)
|
||||
|
||||
for name in stale:
|
||||
print("[MapDownloader] Manifest cleanup: %s (file missing)" % name)
|
||||
_manifest.erase(name)
|
||||
|
||||
if not stale.is_empty():
|
||||
_save_manifest()
|
||||
@@ -0,0 +1 @@
|
||||
uid://5fcqul7vigrv
|
||||
@@ -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]]
|
||||
@@ -0,0 +1 @@
|
||||
uid://cokemo2x02v8k
|
||||
@@ -0,0 +1,155 @@
|
||||
## RoundReplicator — Client-side round state synced from RoundManager Self-RPCs.
|
||||
##
|
||||
## The server's round_manager signals arrive on clients via netfox Self-RPC
|
||||
## (@rpc with call_local). This replicator listens to those signals and
|
||||
## maintains a local round state snapshot for rollback-accessible use.
|
||||
##
|
||||
## This script is ONLY loaded from client scenes.
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals (mirrors server RoundManager for client-side consumers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round state (mirrors server RoundManager)
|
||||
# ---------------------------------------------------------------------------
|
||||
enum RoundState {
|
||||
INACTIVE = 0,
|
||||
WAITING_FOR_PLAYERS = 1,
|
||||
WARMUP = 2,
|
||||
LIVE = 3,
|
||||
POST_ROUND = 4,
|
||||
MATCH_END = 5,
|
||||
}
|
||||
|
||||
# Current round state (populated from server broadcasts)
|
||||
var round_state: int = RoundState.INACTIVE
|
||||
var round_number: int = 0
|
||||
var team_a_score: int = 0
|
||||
var team_b_score: int = 0
|
||||
var time_remaining: float = 0.0
|
||||
var round_time_seconds: float = 600.0
|
||||
var win_limit: int = 3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback-friendly state (used by rollback_tick in player scripts)
|
||||
# ---------------------------------------------------------------------------
|
||||
## Whether the round is in freeze time (before round start).
|
||||
var is_frozen: bool = false
|
||||
## The network tick when freeze ends (for rollback checks).
|
||||
var freeze_end_tick: int = -1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Connect to RoundManager signals (which now arrive via Self-RPC)
|
||||
if Engine.has_singleton(&"RoundManager"):
|
||||
var rm = Engine.get_singleton(&"RoundManager")
|
||||
if rm.round_started.is_connected(_on_round_start):
|
||||
return # Already connected
|
||||
rm.round_started.connect(_on_round_start)
|
||||
rm.round_ended.connect(_on_round_end)
|
||||
rm.match_ended.connect(_on_match_end)
|
||||
rm.score_changed.connect(_on_score_change)
|
||||
else:
|
||||
push_warning("[RoundReplicator] RoundManager not available")
|
||||
|
||||
func _on_connected() -> void:
|
||||
print("[RoundReplicator] Connected to server — awaiting round state")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signal handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _on_round_start(round_num: int) -> void:
|
||||
round_number = round_num
|
||||
# Read round_time_seconds from RoundManager (replicated via _rpc_round_started)
|
||||
if Engine.has_singleton(&"RoundManager"):
|
||||
var rm = Engine.get_singleton(&"RoundManager")
|
||||
round_time_seconds = rm.round_time_seconds if rm.round_time_seconds > 0 else 600
|
||||
time_remaining = round_time_seconds
|
||||
round_state = RoundState.LIVE
|
||||
|
||||
# Freeze time handling (10 seconds for first round, 5 for subsequent)
|
||||
var freeze_time: float = 10.0 if round_num <= 1 else 5.0
|
||||
is_frozen = true
|
||||
var nt = Engine.get_singleton(&"NetworkTime")
|
||||
if nt:
|
||||
freeze_end_tick = nt.tick + int(freeze_time * 64.0) # 64 tick-rate assumption
|
||||
else:
|
||||
freeze_end_tick = -1
|
||||
|
||||
print("[RoundReplicator] Round %d started (%.0fs, frozen for %.0fs)" % [round_num, round_time_seconds, freeze_time])
|
||||
|
||||
func _on_round_end(winner_team: int, reason: String) -> void:
|
||||
# Read scores from RoundManager (replicated by Self-RPC)
|
||||
if Engine.has_singleton(&"RoundManager"):
|
||||
var rm = Engine.get_singleton(&"RoundManager")
|
||||
team_a_score = rm.team_a_score
|
||||
team_b_score = rm.team_b_score
|
||||
is_frozen = false
|
||||
round_state = RoundState.POST_ROUND
|
||||
print("[RoundReplicator] Round ended: %s wins (%s). Score: %d-%d" % [_team_name(winner_team), reason, team_a_score, team_b_score])
|
||||
|
||||
func _on_match_end(winner_team: int) -> void:
|
||||
# Read scores from RoundManager
|
||||
if Engine.has_singleton(&"RoundManager"):
|
||||
var rm = Engine.get_singleton(&"RoundManager")
|
||||
team_a_score = rm.team_a_score
|
||||
team_b_score = rm.team_b_score
|
||||
round_state = RoundState.MATCH_END
|
||||
print("[RoundReplicator] MATCH OVER: %s wins %d-%d" % [_team_name(winner_team), team_a_score, team_b_score])
|
||||
|
||||
func _on_score_change(a_score: int, b_score: int) -> void:
|
||||
team_a_score = a_score
|
||||
team_b_score = b_score
|
||||
print("[RoundReplicator] Score updated: %d-%d" % [a_score, b_score])
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
# Update time remaining (for HUD display)
|
||||
if round_state == RoundState.LIVE:
|
||||
time_remaining -= delta
|
||||
if time_remaining < 0.0:
|
||||
time_remaining = 0.0
|
||||
|
||||
# Update freeze state
|
||||
if is_frozen and freeze_end_tick >= 0:
|
||||
var nt = Engine.get_singleton(&"NetworkTime")
|
||||
if nt:
|
||||
if nt.tick >= freeze_end_tick:
|
||||
is_frozen = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Get a snapshot of the current round state (for HUD/UI).
|
||||
func get_snapshot() -> Dictionary:
|
||||
return {
|
||||
"state": round_state,
|
||||
"round_number": round_number,
|
||||
"team_a_score": team_a_score,
|
||||
"team_b_score": team_b_score,
|
||||
"time_remaining": time_remaining,
|
||||
"is_frozen": is_frozen,
|
||||
}
|
||||
|
||||
## Check if the player can move (not frozen, not dead, round is LIVE).
|
||||
func can_player_move(player_is_alive: bool) -> bool:
|
||||
return player_is_alive and round_state == RoundState.LIVE and not is_frozen
|
||||
|
||||
## Check if the round is currently active (LIVE or WARMUP).
|
||||
func is_round_active() -> bool:
|
||||
return round_state == RoundState.LIVE or round_state == RoundState.WARMUP
|
||||
|
||||
## Get the human-readable team name.
|
||||
static func _team_name(team: int) -> String:
|
||||
match team:
|
||||
0: return "Draw"
|
||||
1: return "Team A"
|
||||
2: return "Team B"
|
||||
_: return "Unknown"
|
||||
@@ -0,0 +1,411 @@
|
||||
extends Control
|
||||
|
||||
# =============================================================================
|
||||
# Server Browser UI
|
||||
# =============================================================================
|
||||
# In-game server list that fetches from the master server API and lets the
|
||||
# player pick a server to connect to.
|
||||
#
|
||||
# Usage:
|
||||
# var browser = preload("res://client/scripts/server_browser_ui.gd").new()
|
||||
# add_child(browser)
|
||||
# browser.master_url = "http://master.example.com:28961"
|
||||
# browser.refresh()
|
||||
#
|
||||
# Signals:
|
||||
# server_selected(server_info: Dictionary)
|
||||
# Emitted when the player clicks "Connect" on a server.
|
||||
# The server_info dict has ip, port, name, password, etc.
|
||||
# =============================================================================
|
||||
|
||||
signal server_selected(server_info: Dictionary)
|
||||
|
||||
const DEFAULT_COLUMN_WIDTHS := {
|
||||
"name": 0.30,
|
||||
"map": 0.15,
|
||||
"players": 0.10,
|
||||
"game_mode": 0.12,
|
||||
"ping": 0.08,
|
||||
"version": 0.10,
|
||||
"tags": 0.15,
|
||||
}
|
||||
|
||||
# Configuration — set these before calling refresh()
|
||||
var master_url: String = "http://127.0.0.1:28961"
|
||||
var refresh_interval: float = 30.0
|
||||
var auto_refresh: bool = false
|
||||
|
||||
# Internal state
|
||||
var _http: HTTPRequest
|
||||
var _refresh_timer: Timer
|
||||
var _servers: Array = []
|
||||
var _selected_index: int = -1
|
||||
var _loading: bool = false
|
||||
|
||||
# UI nodes (assigned in _ready or injected)
|
||||
var _panel: Panel
|
||||
var _title_label: Label
|
||||
var _server_list: ItemList
|
||||
var _refresh_button: Button
|
||||
var _connect_button: Button
|
||||
var _status_label: Label
|
||||
var _auto_refresh_check: CheckBox
|
||||
var _filter_edit: LineEdit
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
_build_ui()
|
||||
|
||||
# HTTP request node
|
||||
_http = HTTPRequest.new()
|
||||
_http.name = "BrowserHTTP"
|
||||
_http.timeout = 10
|
||||
_http.use_threads = true
|
||||
add_child(_http)
|
||||
_http.request_completed.connect(_on_servers_received)
|
||||
|
||||
# Auto-refresh timer
|
||||
_refresh_timer = Timer.new()
|
||||
_refresh_timer.name = "RefreshTimer"
|
||||
_refresh_timer.one_shot = false
|
||||
_refresh_timer.wait_time = refresh_interval
|
||||
add_child(_refresh_timer)
|
||||
_refresh_timer.timeout.connect(_do_refresh)
|
||||
|
||||
|
||||
func _build_ui() -> void:
|
||||
# Main panel
|
||||
_panel = Panel.new()
|
||||
_panel.name = "BrowserPanel"
|
||||
_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 = "SERVER BROWSER"
|
||||
_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)
|
||||
|
||||
# Filter
|
||||
_filter_edit = LineEdit.new()
|
||||
_filter_edit.name = "FilterEdit"
|
||||
_filter_edit.placeholder_text = "Filter by name, map, or tag..."
|
||||
_filter_edit.text_changed.connect(_on_filter_changed)
|
||||
_panel.add_child(_filter_edit)
|
||||
|
||||
# Server list
|
||||
_server_list = ItemList.new()
|
||||
_server_list.name = "ServerList"
|
||||
_server_list.allow_reselect = false
|
||||
_server_list.select_mode = ItemList.SELECT_SINGLE
|
||||
_server_list.item_selected.connect(_on_server_selected)
|
||||
_server_list.nothing_selected.connect(_clear_selection)
|
||||
_panel.add_child(_server_list)
|
||||
|
||||
# Buttons row
|
||||
_refresh_button = Button.new()
|
||||
_refresh_button.name = "RefreshButton"
|
||||
_refresh_button.text = "↻ Refresh"
|
||||
_refresh_button.pressed.connect(_do_refresh)
|
||||
_panel.add_child(_refresh_button)
|
||||
|
||||
_connect_button = Button.new()
|
||||
_connect_button.name = "ConnectButton"
|
||||
_connect_button.text = "Connect"
|
||||
_connect_button.disabled = true
|
||||
_connect_button.pressed.connect(_on_connect_pressed)
|
||||
_panel.add_child(_connect_button)
|
||||
|
||||
_auto_refresh_check = CheckBox.new()
|
||||
_auto_refresh_check.name = "AutoRefreshCheck"
|
||||
_auto_refresh_check.text = "Auto-refresh"
|
||||
_auto_refresh_check.button_pressed = auto_refresh
|
||||
_auto_refresh_check.toggled.connect(_on_auto_refresh_toggled)
|
||||
_panel.add_child(_auto_refresh_check)
|
||||
|
||||
# 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/resize buttons
|
||||
var close_btn = Button.new()
|
||||
close_btn.name = "CloseButton"
|
||||
close_btn.text = "✕"
|
||||
close_btn.pressed.connect(_on_close_pressed)
|
||||
_panel.add_child(close_btn)
|
||||
|
||||
|
||||
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
|
||||
|
||||
# Filter
|
||||
_filter_edit.position = Vector2(m, y)
|
||||
_filter_edit.size = Vector2(w - m * 2, 24)
|
||||
y += 32
|
||||
|
||||
# Server list
|
||||
var list_height = h - y - 80
|
||||
_server_list.position = Vector2(m, y)
|
||||
_server_list.size = Vector2(w - m * 2, max(list_height, 60))
|
||||
y += _server_list.size.y + 8
|
||||
|
||||
# Buttons row
|
||||
var btn_y = y
|
||||
var btn_w = (w - m * 3) / 3
|
||||
|
||||
_refresh_button.position = Vector2(m, btn_y)
|
||||
_refresh_button.size = Vector2(btn_w, 28)
|
||||
|
||||
_connect_button.position = Vector2(m + btn_w + m / 2, btn_y)
|
||||
_connect_button.size = Vector2(btn_w, 28)
|
||||
|
||||
_auto_refresh_check.position = Vector2(m + btn_w * 2 + m, btn_y)
|
||||
_auto_refresh_check.size = Vector2(btn_w, 28)
|
||||
|
||||
y += 36
|
||||
|
||||
# Status
|
||||
_status_label.position = Vector2(m, y)
|
||||
_status_label.size = Vector2(w - m * 2, 20)
|
||||
|
||||
# Close button
|
||||
close_btn = _panel.get_node("CloseButton")
|
||||
close_btn.position = Vector2(w - 28, 4)
|
||||
close_btn.size = Vector2(24, 24)
|
||||
|
||||
|
||||
func _get_close_button() -> Button:
|
||||
var node = _panel.get_node("CloseButton")
|
||||
return node as Button
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Public API
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
"""Manually trigger a server list refresh."""
|
||||
_do_refresh()
|
||||
|
||||
|
||||
func set_master_url(url: String) -> void:
|
||||
master_url = url.trim_suffix("/")
|
||||
if not master_url.begins_with("http"):
|
||||
master_url = "http://" + master_url
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Internal
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
|
||||
func _do_refresh() -> void:
|
||||
if _loading:
|
||||
return
|
||||
_loading = true
|
||||
_status_label.text = "Fetching servers..."
|
||||
_refresh_button.disabled = true
|
||||
|
||||
var endpoint = master_url + "/api/v1/servers"
|
||||
var err = _http.request(endpoint, [], HTTPClient.METHOD_GET)
|
||||
if err != OK:
|
||||
_status_label.text = "Failed to send request (error " + str(err) + ")"
|
||||
_loading = false
|
||||
_refresh_button.disabled = false
|
||||
|
||||
|
||||
func _on_servers_received(result: int, response_code: int, _headers: PackedStringArray, body: PackedByteArray) -> void:
|
||||
_loading = false
|
||||
_refresh_button.disabled = false
|
||||
|
||||
if result != HTTPRequest.RESULT_SUCCESS:
|
||||
_status_label.text = "Network error " + str(result)
|
||||
return
|
||||
|
||||
if response_code != 200:
|
||||
_status_label.text = "Server error (HTTP " + str(response_code) + ")"
|
||||
return
|
||||
|
||||
var body_text = body.get_string_from_utf8()
|
||||
var json = JSON.new()
|
||||
var parse_err = json.parse(body_text)
|
||||
if parse_err != OK or not (json.data is Dictionary):
|
||||
_status_label.text = "Invalid response from master server"
|
||||
return
|
||||
|
||||
var data = json.data as Dictionary
|
||||
_servers = data.get("servers", []) as Array
|
||||
_populate_list()
|
||||
_status_label.text = str(_servers.size()) + " server(s) found"
|
||||
|
||||
# Start auto-refresh timer if supposed to
|
||||
if auto_refresh and not _refresh_timer.is_stopped():
|
||||
_refresh_timer.start()
|
||||
|
||||
|
||||
func _populate_list() -> void:
|
||||
_server_list.clear()
|
||||
|
||||
var filter_text = _filter_edit.text.strip_edges().to_lower()
|
||||
|
||||
for srv in _servers:
|
||||
if not (srv is Dictionary):
|
||||
continue
|
||||
|
||||
# Apply filter
|
||||
if not filter_text.is_empty():
|
||||
var match_found = false
|
||||
for field in ["name", "map", "tags", "game_mode"]:
|
||||
var val = _get_str(srv, field, "")
|
||||
if val.to_lower().contains(filter_text):
|
||||
match_found = true
|
||||
break
|
||||
if not match_found:
|
||||
continue
|
||||
|
||||
var name = _get_str(srv, "name", "Unnamed")
|
||||
var map_name = _get_str(srv, "map", "?")
|
||||
var players = _get_int(srv, "players", 0)
|
||||
var max_players = _get_int(srv, "max_players", 16)
|
||||
var mode = _get_str(srv, "game_mode", "?")
|
||||
var version = _get_str(srv, "version", "?")
|
||||
var tags = _get_str(srv, "tags", "")
|
||||
var has_pass = _get_bool(srv, "password", false)
|
||||
|
||||
# Format: "Server Name dust2 8/16 DM v1.0 eu,ranked"
|
||||
var pass_mark = "🔒" if has_pass else " "
|
||||
var tag_str = ""
|
||||
if tags is Array:
|
||||
tag_str = ",".join(tags)
|
||||
elif tags is String:
|
||||
tag_str = tags
|
||||
|
||||
var label = pass_mark + " " + name
|
||||
# Pad fields with spaces
|
||||
label += " " + _pad_right(map_name, 14)
|
||||
label += str(players) + "/" + str(max_players) + " "
|
||||
label += _pad_right(mode, 10)
|
||||
label += version + " "
|
||||
label += tag_str
|
||||
|
||||
_server_list.add_item(label)
|
||||
|
||||
_connect_button.disabled = true
|
||||
_selected_index = -1
|
||||
|
||||
|
||||
func _on_server_selected(index: int) -> void:
|
||||
_selected_index = index
|
||||
_connect_button.disabled = index < 0
|
||||
|
||||
|
||||
func _clear_selection() -> void:
|
||||
_selected_index = -1
|
||||
_connect_button.disabled = true
|
||||
|
||||
|
||||
func _on_connect_pressed() -> void:
|
||||
if _selected_index < 0 or _selected_index >= _servers.size():
|
||||
return
|
||||
|
||||
var srv = _servers[_selected_index] as Dictionary
|
||||
var host = _get_str(srv, "host", "")
|
||||
var port = _get_int(srv, "port", 0)
|
||||
|
||||
if host.is_empty() or port == 0:
|
||||
_status_label.text = "ERROR: Server has no address info"
|
||||
return
|
||||
|
||||
emit_signal("server_selected", srv)
|
||||
|
||||
|
||||
func _on_filter_changed(_new_text: String) -> void:
|
||||
_populate_list()
|
||||
|
||||
|
||||
func _on_auto_refresh_toggled(enabled: bool) -> void:
|
||||
auto_refresh = enabled
|
||||
if enabled:
|
||||
_refresh_timer.wait_time = refresh_interval
|
||||
_refresh_timer.start()
|
||||
else:
|
||||
_refresh_timer.stop()
|
||||
|
||||
|
||||
func _on_close_pressed() -> void:
|
||||
_refresh_timer.stop()
|
||||
queue_free()
|
||||
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
static func _get_str(d: Dictionary, key: String, fallback: String) -> String:
|
||||
var val = d.get(key, fallback)
|
||||
if val is String:
|
||||
return val
|
||||
return str(val)
|
||||
|
||||
|
||||
static func _get_int(d: Dictionary, key: String, fallback: int) -> int:
|
||||
var val = d.get(key, fallback)
|
||||
if val is int:
|
||||
return val
|
||||
if val is float:
|
||||
return int(val)
|
||||
if val is String:
|
||||
return val.to_int()
|
||||
return fallback
|
||||
|
||||
|
||||
static func _get_bool(d: Dictionary, key: String, fallback: bool) -> bool:
|
||||
var val = d.get(key, fallback)
|
||||
if val is bool:
|
||||
return val
|
||||
if val is int:
|
||||
return val != 0
|
||||
return fallback
|
||||
|
||||
|
||||
static func _pad_right(text: String, min_width: int) -> String:
|
||||
var result = text
|
||||
while result.length() < min_width:
|
||||
result += " "
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://piem67enrl6r
|
||||
@@ -0,0 +1,42 @@
|
||||
extends SceneTree
|
||||
|
||||
func _init():
|
||||
print("Current working dir: ", OS.get_executable_path())
|
||||
|
||||
# List resources in assets/scenes/modular/
|
||||
var dir = DirAccess.open("res://assets/scenes/modular/")
|
||||
if dir:
|
||||
print("Files in res://assets/scenes/modular/:")
|
||||
dir.list_dir_begin()
|
||||
var f = dir.get_next()
|
||||
while f != "":
|
||||
print(" ", f)
|
||||
f = dir.get_next()
|
||||
else:
|
||||
print("Cannot open res://assets/scenes/modular/")
|
||||
print("Error code: ", DirAccess.get_open_error())
|
||||
|
||||
# Try loading kit_demo
|
||||
var path = "res://assets/scenes/modular/kit_demo.tscn"
|
||||
print("Loading: ", path)
|
||||
var scene = ResourceLoader.load(path)
|
||||
if scene:
|
||||
print("Loaded! Class: ", scene.get_class())
|
||||
var instance = scene.instantiate()
|
||||
print("Instantiate result: ", instance != null)
|
||||
if instance:
|
||||
print("Instance type: ", instance.get_class())
|
||||
print("Instance name: ", instance.name)
|
||||
else:
|
||||
print("Failed to load: ", path)
|
||||
|
||||
# Try wall_straight_01
|
||||
path = "res://assets/scenes/modular/wall_straight_01.tscn"
|
||||
print("Loading: ", path)
|
||||
scene = ResourceLoader.load(path)
|
||||
if scene:
|
||||
print("Loaded! Class: ", scene.get_class())
|
||||
else:
|
||||
print("Failed to load: ", path)
|
||||
|
||||
quit(0)
|
||||
@@ -1 +1 @@
|
||||
uid://d0go24t1lsx2j
|
||||
uid://d34jlb2dhy72t
|
||||
|
||||
Reference in New Issue
Block a user