Files
tactical-shooter/client/scripts/map_downloader.gd
T
shawn e7299b17e9 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)
2026-07-02 17:39:22 -04:00

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()