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)
361 lines
12 KiB
GDScript
361 lines
12 KiB
GDScript
## 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()
|