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
364 lines
11 KiB
Markdown
364 lines
11 KiB
Markdown
# 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 (10–100 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
|
||
```
|