## 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/.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/.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()