e9dc05983c
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
400 lines
14 KiB
GDScript
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()
|