Files
tactical-shooter/scripts/map_packaging/map_downloader.gd
T
shawn e9dc05983c Save workspace artifacts: character-controller, gdextension scaffold, network scripts, map-pipeline, server config
Includes code from task workspaces that was never pushed:
- client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd
- gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files)
- scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd
- scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README
- scripts/config/ — server_config.gd (480 lines)
- scenes/ — client_main, server_main, player, test_range
- project.godot, export_presets.cfg
2026-07-01 18:30:44 -04:00

400 lines
14 KiB
GDScript

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