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:
@@ -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 (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
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user