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
This commit is contained in:
2026-07-01 18:30:44 -04:00
parent 2cf57a989f
commit e9dc05983c
42 changed files with 6249 additions and 0 deletions
+363
View File
@@ -0,0 +1,363 @@
# Map PCK Packaging Pipeline
## Overview
The PCK packaging pipeline converts map scenes (`.tscn`) into standalone
resource packs (`.pck`) that can be downloaded and loaded at runtime.
This is Godot's built-in DLC/addon pattern — no engine modifications
required.
```
Map Creator Registry Server Game Client
────────────────── ────────────────── ──────────────────
│ │ │
├── Build map in template project │ │
├── Run pack_map.gd ──────────────►│ (upload .pck) │
│ │ │
│ ├── GET /maps ───────────────►│
│ │◄── [map list JSON] ────────┤
│ │ ├── Check user://maps/
│ │ │
│ │◄── GET /maps/<name>.pck ───┤ (if not cached)
│ ├── .pck file ───────────────►│
│ │ ├── Save to user://maps/
│ │ ├── load_resource_pack()
│ │ ├── Load res://maps/<name>.tscn
│ │ │
```
## Components
| File | Role |
|------|------|
| `pack_map.gd` | Godot editor tool — packages a `.tscn``.pck` |
| `map_downloader.gd` | Godot autoload — client-side download + cache + load |
| `map_registry_server.py` | Python HTTP server — serves `.pck` files + metadata |
| `config-ext-map-registry.cfg` | Server config extension for registry integration |
| `README.md` | This file |
---
## 1. Packaging Maps (`pack_map.gd`)
### Prerequisites
- Godot 4.2+
- The map scene and all its dependencies must be local (`res://` paths)
### In the Editor
1. Open the map template project (`client/map_template/`)
2. Build your map in `scenes/maps/` (e.g., `scenes/maps/my_map.tscn`)
3. Open the map scene in the editor
4. Run **Project > Tools > Pack Current Map**
Output goes to `user://packed_maps/my_map.pck`.
### Headless / CLI
```bash
godot --headless --script scripts/map_packaging/pack_map.gd \
--map=res://scenes/maps/my_map.tscn
```
### Output
- `user://packed_maps/<map_name>.pck` — the resource pack
- `user://packed_maps/<map_name>.json` — metadata sidecar
### What's in the .pck?
The pack contains only the map scene and its direct resource dependencies
(meshes, textures, materials). No game logic, no scripts, no config files.
This keeps packs small and safe — a map can't inject code.
---
## 2. Map Registry Server (`map_registry_server.py`)
A lightweight Python HTTP server that serves `.pck` files and map metadata.
### Quick Start
```bash
# Install — no dependencies, pure stdlib
python3 scripts/map_packaging/map_registry_server.py
# With custom port and maps directory
python3 scripts/map_packaging/map_registry_server.py \
--port 8090 \
--maps-dir /data/maps
# Via environment variables
export MAP_REGISTRY_PORT=8080
export MAP_REGISTRY_MAPS=/data/maps
python3 scripts/map_packaging/map_registry_server.py
```
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/maps` | GET | JSON list of all available maps with metadata |
| `/maps/<name>.pck` | GET | Download a map pack (binary) |
| `/maps/<name>.json` | GET | Metadata for a single map |
| `/` | GET | Server info and endpoint documentation |
### Deployment
#### Systemd Service
```ini
[Unit]
Description=Tactical Shooter Map Registry
After=network.target
[Service]
Type=simple
User=tactical-shooter
WorkingDirectory=/opt/tactical-shooter
ExecStart=/usr/bin/python3 /opt/tactical-shooter/scripts/map_packaging/map_registry_server.py \
--port 8090 \
--maps-dir /var/lib/tactical-shooter/maps
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
#### NPMplus Reverse Proxy
```nginx
# NPMplus custom location for map registry
location /maps/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
}
```
---
## 3. Client Map Downloader (`map_downloader.gd`)
### Installation
Add `map_downloader.gd` as an autoload singleton in `project.godot`:
```ini
[autoload]
MapDownloader="*res://scripts/map_packaging/map_downloader.gd"
```
### Usage
```gdscript
# Set the registry URL (default: http://127.0.0.1:8090)
MapDownloader.registry_url = "http://maps.example.com:8090"
# Fetch the map list from the registry (auto-downloads missing maps)
MapDownloader.fetch_map_list()
# Or download specific maps
MapDownloader.download_map("de_dust2")
MapDownloader.download_maps(["de_dust2", "de_inferno"])
# Check cache
if MapDownloader.is_map_cached("de_dust2"):
print("Map is ready")
MapDownloader.load_map("de_dust2")
# Now res://maps/de_dust2.tscn is available
# Get cached maps
var cached = MapDownloader.get_cached_maps()
# Remove a map
MapDownloader.remove_map("de_dust2")
# Clear all caches
MapDownloader.clear_cache()
```
### Signals
```gdscript
# Progress during download
MapDownloader.map_download_progress.connect(
func(map_name, received, total):
var pct = float(received) / total * 100
print("%s: %.1f%%" % [map_name, pct])
)
# Download completed
MapDownloader.map_download_complete.connect(
func(map_name, success):
if success:
print("%s ready to play!" % map_name)
)
# Map loaded into resource system
MapDownloader.map_loaded.connect(
func(map_name):
print("%s is now available at res://maps/%s.tscn" % [map_name, map_name])
)
```
### Environment Overrides
| Variable | Description |
|----------|-------------|
| `MAP_REGISTRY_URL` | Override the registry server URL |
---
## 4. Integration with Server Main
To wire map downloads into the existing server flow (`server_main.gd`):
```gdscript
# In server_main.gd or a dedicated map manager:
func _on_server_started() -> void:
# Start map registry if configured
if ServerConfig.has_property("map_registry_enabled") and ServerConfig.map_registry_enabled:
var registry_url: String = ServerConfig.get("map_registry_url", "")
if not registry_url.is_empty():
MapDownloader.registry_url = registry_url
# Fetch map list — MapDownloader auto-downloads missing maps
MapDownloader.fetch_map_list()
func _on_map_needed(map_name: String) -> void:
if not MapDownloader.is_map_cached(map_name):
# Wait for download
var waiter := func(name, success):
if success:
_start_map(name)
MapDownloader.map_download_complete.connect(waiter.bind(map_name), CONNECT_ONE_SHOT)
MapDownloader.download_map(map_name)
else:
_start_map(map_name)
func _start_map(map_name: String) -> void:
if MapDownloader.load_map(map_name):
# Now load the scene
var map_scene: PackedScene = load("res://scenes/maps/%s.tscn" % map_name)
var instance := map_scene.instantiate()
add_child(instance)
```
### Server Config Extension
Add this section to your `server_config.cfg` to enable map registry:
```ini
[map_registry]
; Enable automatic map download on server start
enabled=false
; URL of the map registry server
url="http://127.0.0.1:8090"
; Auto-download all maps from the registry, not just advertised ones
auto_download_all=false
```
---
## 5. Cache and Storage
### Client Cache
- Location: `user://maps/`
- Files: `<map_name>.pck` + `manifest.json`
- Manifest is validated on startup — stale entries (missing .pck files) are pruned
- The cache is persistent across restarts
### Cache Size Management
Maps can be large (10100 MB depending on texture resolution).
Consider:
```gdscript
# Check cache size
var total_size := 0
for name in MapDownloader.get_cached_maps():
var info = MapDownloader.get_cached_map_info(name)
total_size += info.get("size", 0)
print("Cache size: %.1f MB" % (total_size / 1048576.0))
# Remove least-recently-downloaded maps if over budget
const MAX_CACHE_MB := 500
if total_size > MAX_CACHE_MB * 1048576:
var cached = MapDownloader.get_cached_maps()
for name in cached:
MapDownloader.remove_map(name)
break # remove one at a time
```
---
## 6. Security Considerations
- **.pck files cannot execute scripts** that weren't already in the game binary.
Resource packs only add data (scenes, textures, meshes). Game logic lives
in the binary and cannot be injected via a .pck.
- **Checksum verification**: The server provides SHA-256 checksums. The
client can verify integrity before loading:
```gdscript
func verify_map(map_name: String) -> bool:
var pck_path = "user://maps/%s.pck" % map_name
var global_path = ProjectSettings.globalize_path(pck_path)
var f = FileAccess.open(global_path, FileAccess.READ)
if not f:
return false
var ctx = HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
while f.get_position() < f.get_length():
ctx.update(f.get_buffer(65536))
var checksum = ctx.finish().hex_encode()
f.close()
# Compare with server checksum
var meta = MapDownloader.get_cached_map_info(map_name)
var expected = meta.get("checksum_sha256", "")
return expected.is_empty() or checksum == expected
```
- **HTTPS**: Deploy the registry server behind an NPMplus/nginx proxy with
SSL termination. The client supports HTTPS natively via HTTPRequest.
---
## 7. Workflow Summary
### For Map Creators
```
1. Open map_template/ in Godot 4
2. Build map using CSG prefabs (scenes/maps/<name>.tscn)
3. Run: godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/<name>.tscn
4. Upload output/user://packed_maps/<name>.pck to registry server's maps directory
```
### For Server Operators
```
1. Deploy map_registry_server.py on your backend (or alongside the game server)
2. Copy .pck files into the maps directory
3. SIGHUP the server to rescan (or it auto-scans every 5 seconds)
4. Configure [map_registry] in server_config.cfg
5. Game clients auto-download on connect
```
### For Developers
```
1. Add MapDownloader as project autoload
2. On server start, call MapDownloader.fetch_map_list()
3. Before loading a map scene, call MapDownloader.load_map() if it's a community map
4. Handle download signals for progress UI
```
+399
View File
@@ -0,0 +1,399 @@
## 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()
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""
map_registry_server.py — Map Registry HTTP Server
Serves packaged .pck map files and a JSON map listing for the Godot
client's MapDownloader to consume.
Endpoints
---------
GET /maps
Returns a JSON list of available maps with metadata.
Response:
{
"maps": [
{
"name": "de_dust2",
"size": 4194304,
"version": 1,
"description": "Classic bomb-defusal map",
"scene": "res://scenes/maps/de_dust2.tscn",
"checksum_sha256": "a1b2c3..."
}
],
"server_name": "Tactical Shooter Map Registry",
"map_count": 1
}
GET /maps/<name>.pck
Download a packaged map file. Content-Type: application/octet-stream.
GET /maps/<name>.json
Download metadata for a specific map.
Response:
{
"name": "de_dust2",
"size": 4194304,
"version": 1,
"description": "Classic bomb-defusal map",
"scene": "res://scenes/maps/de_dust2.tscn",
"checksum_sha256": "a1b2c3...",
"packed_at": "2026-06-30 12:00:00"
}
Usage
-----
# Start the server on the default port (8090):
python3 map_registry_server.py
# Custom port and map directory:
python3 map_registry_server.py --port 8080 --maps-dir /data/maps
# Docker / container friendly:
MAP_REGISTRY_PORT=8080 MAP_REGISTRY_MAPS=/data/maps python3 map_registry_server.py
The server scans `maps_dir` (default: ./packed_maps/) for *.pck files and
their matching *.json metadata files on startup and on SIGHUP.
Integration
-----------
In your server's ServerConfig or config, add a registry section:
[map_registry]
enabled=true
url="http://maps.example.com:8090"
Or set env: MAP_REGISTRY_URL on the game server.
The game client's MapDownloader singleton will connect to this server
to fetch the map list and download .pck files.
"""
import argparse
import hashlib
import json
import logging
import os
import signal
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger("MapRegistry")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_PORT = 8090
DEFAULT_MAPS_DIR = "packed_maps"
SERVER_NAME = "Tactical Shooter Map Registry"
VERSION = "1.0.0"
# ---------------------------------------------------------------------------
# Map Registry
# ---------------------------------------------------------------------------
class MapRegistry:
"""Scans a directory for .pck files and their metadata."""
def __init__(self, maps_dir: str):
self.maps_dir = Path(maps_dir)
self.maps_dir.mkdir(parents=True, exist_ok=True)
self._cache: Dict[str, dict] = {}
self._last_scan: float = 0
self._scan_interval: float = 5.0 # seconds between rescans
self._scan()
def _scan(self) -> None:
"""Scan the maps directory for .pck and .json files."""
now = time.time()
if now - self._last_scan < self._scan_interval:
return
self._last_scan = now
self._cache.clear()
if not self.maps_dir.exists():
logger.warning("Maps directory does not exist: %s", self.maps_dir)
return
for pck_file in sorted(self.maps_dir.glob("*.pck")):
map_name = pck_file.stem
json_file = pck_file.with_suffix(".json")
entry = {
"name": map_name,
"size": pck_file.stat().st_size,
"path": str(pck_file.relative_to(self.maps_dir)),
"version": 1,
"description": "",
"scene": f"res://scenes/maps/{map_name}.tscn",
"checksum_sha256": "",
"packed_at": "",
}
# Load metadata from sidecar JSON if it exists
if json_file.exists():
try:
with open(json_file, "r") as f:
meta = json.load(f)
entry["version"] = meta.get("version", 1)
entry["description"] = meta.get("description", "")
entry["packed_at"] = meta.get("packed_at", "")
entry["source_scene"] = meta.get("source_scene", "")
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to parse metadata for %s: %s", map_name, e)
# Compute SHA-256 checksum (on first scan, cached in memory)
ck = self._compute_checksum(pck_file)
if ck:
entry["checksum_sha256"] = ck
self._cache[map_name] = entry
logger.debug("Registered map: %s (%d bytes)", map_name, entry["size"])
logger.info("Scanned %s: %d maps registered", self.maps_dir, len(self._cache))
def _compute_checksum(self, path: Path) -> str:
"""Compute SHA-256 checksum of a file."""
try:
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except OSError as e:
logger.warning("Checksum failed for %s: %s", path.name, e)
return ""
def get_map_list(self) -> dict:
"""Return the full map listing as a dict."""
self._scan()
return {
"maps": list(self._cache.values()),
"server_name": SERVER_NAME,
"server_version": VERSION,
"map_count": len(self._cache),
}
def get_map_info(self, name: str) -> Optional[dict]:
"""Return metadata for a single map."""
self._scan()
return self._cache.get(name)
def get_map_file(self, name: str) -> Optional[Path]:
"""Return the filesystem path to a .pck file, or None."""
self._scan()
entry = self._cache.get(name)
if entry is None:
return None
pck_path = self.maps_dir / entry["path"]
return pck_path if pck_path.exists() else None
# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class MapRegistryHandler(BaseHTTPRequestHandler):
"""HTTP request handler for the map registry API."""
# Shared across all instances (set by the server)
registry: MapRegistry = None # type: ignore
def log_message(self, format: str, *args) -> None:
logger.info("%s - %s", self.client_address[0], format % args)
def _send_json(self, data: dict, status: int = 200) -> None:
body = json.dumps(data, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _send_error(self, status: int, message: str) -> None:
self._send_json({"error": message}, status)
def _send_file(self, path: Path, content_type: str = "application/octet-stream") -> None:
try:
file_size = path.stat().st_size
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(file_size))
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except OSError as e:
logger.error("File send failed for %s: %s", path.name, e)
self._send_error(500, "Internal server error")
def do_GET(self) -> None:
path = self.path.rstrip("/")
# GET /maps — list all available maps
if path == "/maps":
self._send_json(self.registry.get_map_list())
return
# GET /maps/<name>.pck — download a map file
if path.startswith("/maps/") and path.endswith(".pck"):
map_name = path[len("/maps/"):-len(".pck")]
map_file = self.registry.get_map_file(map_name)
if map_file:
self._send_file(map_file)
else:
self._send_error(404, f"Map '{map_name}' not found")
return
# GET /maps/<name>.json — get metadata for a single map
if path.startswith("/maps/") and path.endswith(".json"):
map_name = path[len("/maps/"):-len(".json")]
info = self.registry.get_map_info(map_name)
if info:
self._send_json(info)
else:
self._send_error(404, f"Map '{map_name}' not found")
return
# GET / — server info
if path == "/" or path == "":
self._send_json({
"service": SERVER_NAME,
"version": VERSION,
"endpoints": {
"list_maps": "/maps",
"download_map": "/maps/<name>.pck",
"map_metadata": "/maps/<name>.json",
},
})
return
self._send_error(404, f"Not found: {path}")
def do_OPTIONS(self) -> None:
"""CORS preflight."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
def create_server(port: int, maps_dir: str) -> HTTPServer:
"""Create and configure the HTTP server."""
registry = MapRegistry(maps_dir)
MapRegistryHandler.registry = registry
server = HTTPServer(("0.0.0.0", port), MapRegistryHandler)
server.timeout = 0.5 # allow signal handling
logger.info("Map Registry Server v%s", VERSION)
logger.info(" Listen: http://0.0.0.0:%d", port)
logger.info(" Maps dir: %s", registry.maps_dir.resolve())
logger.info(" Maps found: %d", len(registry._cache))
logger.info(" Endpoints:")
logger.info(" List: GET /maps")
logger.info(" Download: GET /maps/<name>.pck")
logger.info(" Metadata: GET /maps/<name>.json")
return server
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Tactical Shooter Map Registry Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--port", "-p",
type=int,
default=int(os.environ.get("MAP_REGISTRY_PORT", DEFAULT_PORT)),
help="HTTP port (default: %d)" % DEFAULT_PORT,
)
parser.add_argument(
"--maps-dir", "-d",
type=str,
default=os.environ.get("MAP_REGISTRY_MAPS", DEFAULT_MAPS_DIR),
help="Directory containing .pck files (default: %s)" % DEFAULT_MAPS_DIR,
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args()
def main():
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
server = create_server(args.port, args.maps_dir)
# Handle SIGTERM/SIGINT for graceful shutdown
shutdown_requested = False
def handle_signal(sig, frame):
nonlocal shutdown_requested
if not shutdown_requested:
logger.info("Shutdown requested (signal %d)", sig)
shutdown_requested = True
server.shutdown()
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
# SIGHUP rescans the map directory
if hasattr(signal, "SIGHUP"):
def handle_hup(sig, frame):
logger.info("SIGHUP received — rescanning maps")
server.RequestHandlerClass.registry._scan()
signal.signal(signal.SIGHUP, handle_hup)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
logger.info("Server stopped")
if __name__ == "__main__":
main()
+190
View File
@@ -0,0 +1,190 @@
## pack_map.gd — Map PCK Exporter
##
## Editor tool script that exports a map scene (.tscn) as a standalone
## .pck resource pack. The .pck contains only the map scene and its
## direct dependencies (meshes, textures, materials) — no game logic.
##
## Usage:
## 1. Open the map template project (client/map_template/) in Godot.
## 2. Build your map in the scenes/maps/ directory.
## 3. Run this script from the Editor > Tools menu, or via the CLI:
## godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/my_map.tscn
##
## Output:
## user://packed_maps/my_map.pck (ready to upload to the registry server)
##
## The exported .pck can be loaded in-game via:
## ProjectSettings.load_resource_pack("user://maps/my_map.pck")
##
## Requirements:
## - Godot 4.2+
## - The map scene must use only local resources (relative paths within the project)
## - External dependencies (e.g. shared game assets) should be excluded — the
## .pck is meant to be additive content, not a full replacement.
tool
extends EditorScript
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Output directory relative to user://
const OUTPUT_DIR: String = "packed_maps"
## Godot export mode — packs all dependencies recursively.
const EXPORT_MODE: int = PackedScene.GEN_FLAG_SAVE_NONE
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
func _run() -> void:
# Determine the map scene path
var scene_path: String = _resolve_scene_path()
if scene_path.is_empty():
print("Usage: godot --headless --script pack_map.gd --map=res://scenes/maps/<map_name>.tscn")
print(" Or run from Editor > Tools > Pack Current Map")
return
# Validate the scene exists
if not ResourceLoader.exists(scene_path):
push_error("[PackMap] Scene not found: %s" % scene_path)
return
# Derive map name from file name
var map_name: String = scene_path.get_file().trim_suffix(".tscn")
var output_path: String = "user://%s/%s.pck" % [OUTPUT_DIR, map_name]
# Ensure output directory exists
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://%s" % OUTPUT_DIR))
print("[PackMap] Packaging: %s" % scene_path)
print("[PackMap] Output: %s" % output_path)
# Load the scene
var scene: PackedScene = load(scene_path)
if scene == null:
push_error("[PackMap] Failed to load scene: %s" % scene_path)
return
# Generate the .pck via Godot's built-in packer.
# We do this by creating a temporary PackedScene that encapsulates the map,
# then using ProjectSettings.save_resource_pack to extract only the needed resources.
var err: Error = _export_pck(scene, scene_path, output_path)
if err != OK:
push_error("[PackMap] Export failed: %s" % error_string(err))
return
print("[PackMap] Successfully packed: %s (→ %s)" % [map_name, output_path])
print("[PackMap] File size: %d bytes" % FileAccess.get_size(output_path))
# Write a sidecar JSON with metadata
_write_metadata(map_name, output_path, scene_path)
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
## Resolve the --map CLI argument or use the currently open scene.
func _resolve_scene_path() -> String:
# CLI argument takes precedence
for arg in OS.get_cmdline_args():
if arg.begins_with("--map="):
return arg.trim_prefix("--map=")
# Fallback: use the currently open scene in the editor
var current_scene_path: String = ""
if Engine.is_editor_hint():
# In editor mode, try to get the current scene from the editor interface
var editor_interface: EditorInterface = get_editor_interface()
if editor_interface:
current_scene_path = editor_interface.get_current_scene().scene_file_path
return current_scene_path
## Export the scene as a .pck file containing only its direct dependencies.
func _export_pck(scene: PackedScene, scene_path: String, output_path: String) -> Error:
# Strategy: pack the scene + all its dependencies into a .pck using
# ResourceSaver. We create a packaging helper that simulates what
# Godot's export process does for resource packs.
# Create a collection of all dependencies we need to include
var deps: Array[String] = _collect_dependencies(scene_path)
# Add the scene itself
deps.append(scene_path)
# We need to strip the "res://" prefix and map to correct paths
# Create a PackedScene copy with packed resources
var packed: PackedScene = _build_packed_map(scene_path, deps)
if packed == null:
return ERR_FILE_CORRUPT
# Write the .pck using ProjectSettings.save_resource_pack()
var files_to_pack: PackedStringArray = PackedStringArray()
for dep in deps:
var global_path: String = ProjectSettings.globalize_path(dep)
if FileAccess.file_exists(global_path):
files_to_pack.append(dep)
if files_to_pack.is_empty():
return ERR_FILE_NOT_FOUND
var err: Error = ProjectSettings.save_resource_pack(output_path, files_to_pack)
return err
## Collect all dependencies of a scene recursively.
func _collect_dependencies(scene_path: String) -> Array[String]:
var deps: Array[String] = []
var visited: Dictionary = {}
var pending: Array[String] = [scene_path]
while not pending.is_empty():
var current: String = pending.pop_front()
if current in visited:
continue
visited[current] = true
# Skip external / built-in resources
if current.begins_with("builtin://") or current.begins_with("uid://"):
continue
# Get dependencies from ResourceLoader
var dep_list: PackedStringArray = ResourceLoader.get_dependencies(current)
for dep in dep_list:
if dep not in visited and dep.begins_with("res://"):
deps.append(dep)
pending.append(dep)
return deps
## Build a PackedScene from the map and its dependencies.
func _build_packed_map(scene_path: String, deps: Array[String]) -> PackedScene:
# Load the scene and pack it
var scene: PackedScene = load(scene_path)
if scene == null:
return null
return scene
## Write a JSON metadata file alongside the .pck.
func _write_metadata(map_name: String, pck_path: String, scene_path: String) -> void:
var meta := {
map_name = map_name,
pck_path = pck_path,
source_scene = scene_path,
godot_version = Engine.get_version_info(),
packed_at = Time.get_datetime_string_from_system(),
}
var meta_path: String = pck_path.trim_suffix(".pck") + ".json"
var f := FileAccess.open(meta_path, FileAccess.WRITE)
if f:
f.store_string(JSON.stringify(meta, "\t", false))
f.close()
print("[PackMap] Metadata written: %s" % meta_path)