## MapDownloader — Client-side map download and cache management ## ## Autoload singleton that downloads .pck map packs from the master server ## registry and loads them into the running game via ProjectSettings.load_resource_pack(). ## ## ## Architecture ## ## MapRegistry Server (Python) MapDownloader (Godot) ## ┌──────────────────────┐ ┌──────────────────┐ ## │ GET /maps │ ◄── list ──│ get_map_list() │ ## │ GET /maps/:name.pck │ ◄── dl ──│ download_map() │ ## │ GET /maps/:name.json│ ◄── meta ─│ get_map_info() │ ## └──────────────────────┘ │ │ ## │ Cache: │ ## Disk cache │ user://maps/ │ ## user://maps/.pck ─────│ .pck files │ ## user://maps/manifest.json │ manifest.json │ ## └──────────────────┘ ## ## ## Map Lifecycle ## ## 1. Server sends map list (e.g. ["de_dust2", "de_inferno"]) ## 2. MapDownloader checks local cache via manifest.json ## 3. Missing maps are downloaded from the registry server ## 4. Downloaded .pck is loaded via load_resource_pack() ## 5. Map scene becomes available at res://maps/.tscn ## ## ## Configuration ## ## Set the registry URL before first use: ## MapDownloader.registry_url = "https://maps.example.com" ## # or via env: MAP_REGISTRY_URL ## ## ## Signals ## ## map_download_progress(map_name, bytes_received, bytes_total) ## map_download_complete(map_name, success) ## map_loaded(map_name) extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted during download for progress bar updates. signal map_download_progress(map_name: String, bytes_received: int, bytes_total: int) ## Emitted when a map download finishes. success=true means the .pck is on disk. signal map_download_complete(map_name: String, success: bool) ## Emitted after a .pck is loaded into the resource system. 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 (without trailing slash). ## Can be overridden at runtime. var registry_url: String = DEFAULT_REGISTRY_URL ## Timeout for HTTP requests in seconds. var http_timeout: float = 30.0 ## Max concurrent downloads. var max_concurrent: int = 2 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var _http: HTTPRequest = null var _active_downloads: Dictionary = {} # map_name → Dictionary (progress tracking) var _download_queue: Array[Dictionary] = [] var _manifest: Dictionary = {} # {map_name: {version, size, downloaded_at}} var _loaded_pcks: Array[String] = [] # tracks which .pcks are already loaded # --------------------------------------------------------------------------- # 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. ## Returns a signal-based result via map_download_progress / etc. ## Call this first to discover what maps exist. func fetch_map_list() -> void: var url: String = "%s/maps" % [registry_url] _http_get(url, _on_map_list_received) ## 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) ## 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: # Skip if already cached if is_map_cached(map_name): print("[MapDownloader] %s already cached — loading" % map_name) _load_map_pck(map_name) return # Check if already downloading if map_name in _active_downloads: print("[MapDownloader] %s is already downloading" % map_name) return # Queue or start download 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. Pass an array of map names. 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() _loaded_pcks.erase(map_name) 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") # --------------------------------------------------------------------------- # Internal: HTTP helpers # --------------------------------------------------------------------------- func _http_get(url: String, callback: Callable) -> void: var http := HTTPRequest.new() add_child(http) http.connect("request_completed", callback) http.timeout = http_timeout http.request(url) func _http_download(url: String, save_path: String, callback: Callable) -> void: var http := HTTPRequest.new() add_child(http) http.connect("request_completed", callback) http.download_file = save_path http.timeout = http_timeout # Connect download progress if http.has_signal("download_progress"): # Godot 4's HTTPRequest has request_completed but not always download_progress # We track via the file size after completion pass http.request(url) # --------------------------------------------------------------------------- # 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 → %s" % [url, save_path]) _http_download(url, save_path, _on_map_downloaded.bind(map_name)) 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) -> void: if response_code != 200: push_error("[MapDownloader] Failed to fetch map list: HTTP %d" % response_code) 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: %s" % error_string(parse_err)) return var data: Dictionary = json.data var maps: Array = data.get("maps", data.get("available", [])) if maps.is_empty(): print("[MapDownloader] No maps available on registry") return print("[MapDownloader] Registry offers %d maps: %s" % [maps.size(), maps]) # Auto-download any maps we don't have cached var to_download: Array[String] = [] for m in maps: var name: String = str(m) if typeof(m) == TYPE_STRING else "" if name.is_empty(): # Support both string lists and object lists if typeof(m) == TYPE_DICTIONARY: name = m.get("name", "") if not name.is_empty() and not is_map_cached(name): to_download.append(name) if not to_download.is_empty(): print("[MapDownloader] Downloading %d new maps: %s" % [to_download.size(), to_download]) download_maps(to_download) else: print("[MapDownloader] All registry maps are already cached") func _on_map_downloaded(result: int, response_code: int, headers: PackedStringArray, body: PackedByteArray, map_name: String) -> void: _active_downloads.erase(map_name) if response_code != 200: push_error("[MapDownloader] Download failed for %s: HTTP %d" % [map_name, 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 # --------------------------------------------------------------------------- ## Load a .pck into Godot's resource system. ## After this call, res://maps/.tscn becomes available. func _load_map_pck(map_name: String) -> bool: # Skip if already loaded 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 at %s" % [map_name, global_path]) return false var err: Error = ProjectSettings.load_resource_pack(global_path) if err != OK: push_error("[MapDownloader] Failed to load map pack %s: %s" % [map_name, error_string(err)]) return false _loaded_pcks.append(map_name) print("[MapDownloader] Loaded map pack: %s (%s)" % [map_name, pck_path]) 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() ## Validate the manifest: remove entries whose .pck files no longer exist on disk. 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()