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,480 @@
|
||||
## ServerConfig — CFG/JSON Dual-Format Config Manager
|
||||
##
|
||||
## Autoload singleton that loads, validates, and exposes server configuration.
|
||||
##
|
||||
## ## Architecture
|
||||
##
|
||||
## Load chain:
|
||||
## 1. SERVER_CFG env var → absolute path to override .cfg
|
||||
## 2. user://server_config.cfg (writable, operator-edited)
|
||||
## 3. res://config/default_server_config.cfg (bundled, read-only)
|
||||
##
|
||||
## Exports:
|
||||
## - Typed GDScript properties (this script)
|
||||
## - JSON file written alongside the loaded .cfg for automation
|
||||
## - ConfigChanged signal for hot-reload (future)
|
||||
##
|
||||
## ## Dual Format
|
||||
##
|
||||
## Humans edit `.cfg` (INI-style, Godot ConfigFile parser).
|
||||
## Automation reads `.json` (written on every load/save).
|
||||
##
|
||||
## File: server_config.cfg ← human editor
|
||||
## Mirror: server_config.json ← machine consumer (written atomically)
|
||||
##
|
||||
## ## Sections
|
||||
##
|
||||
## [server] — Network bind, port, name, password, tick_rate
|
||||
## [game] — Round timers, gravity, friendly fire, respawn
|
||||
## [movement] — Walk/sprint/crouch speeds, acceleration, friction
|
||||
## [match] — Win limit, time limit, map rotation
|
||||
## [rcon] — Remote console (future)
|
||||
## [logging] — Log level and file path
|
||||
## [teams] — Team count and size
|
||||
##
|
||||
## ## Extension Pattern
|
||||
##
|
||||
## ServerMain uses ServerConfig at startup. Each subsystem accesses
|
||||
## the singleton directly — no pass-through boilerplate:
|
||||
##
|
||||
## ServerConfig.port # int
|
||||
## ServerConfig.server_name # String
|
||||
## ServerConfig.tick_rate # int
|
||||
## ServerConfig.movement_walk_speed # float
|
||||
## ServerConfig.map_list # Array[String]
|
||||
## ServerConfig.to_json_string() # String
|
||||
##
|
||||
## =============================================================================
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Emitted when the config is (re)loaded. Subsystems that cache config
|
||||
## values should reconnect to this signal to pick up live changes.
|
||||
signal config_loaded()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [server]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var server_name: String = "Tactical Shooter Server"
|
||||
var description: String = ""
|
||||
var bind_ip: String = "0.0.0.0"
|
||||
var port: int = 34197
|
||||
var max_players: int = 16
|
||||
var password: String = ""
|
||||
var tick_rate: int = 128
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [game]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var round_time_seconds: int = 600
|
||||
var warmup_time_seconds: int = 60
|
||||
var friendly_fire: bool = false
|
||||
var ff_damage_multiplier: float = 0.5
|
||||
var gravity: float = -24.0
|
||||
var respawn_time_seconds: float = 5.0
|
||||
var spectate_enabled: bool = true
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [movement]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var movement_walk_speed: float = 5.0
|
||||
var movement_sprint_speed: float = 7.0
|
||||
var movement_crouch_speed: float = 2.0
|
||||
var movement_jump_velocity: float = 6.0
|
||||
var movement_acceleration: float = 20.0
|
||||
var movement_air_acceleration: float = 4.0
|
||||
var movement_friction: float = 8.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [match]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var win_limit: int = 3
|
||||
var time_limit_seconds: int = 0
|
||||
var map_rotation_mode: String = "sequence" # "sequence" | "vote" | "random"
|
||||
var maps: String = "test_range" # comma-separated
|
||||
|
||||
## Parsed map list (populated after load).
|
||||
var map_list: Array[String] = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [rcon]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var rcon_enabled: bool = false
|
||||
var rcon_password: String = ""
|
||||
var rcon_port: int = 34198
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [logging]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var log_level: String = "info"
|
||||
var log_file: String = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section: [teams]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
var team_count: int = 2
|
||||
var players_per_team: int = 8
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Absolute path to the loaded .cfg file (user override or bundled default).
|
||||
var config_path: String = "" : get = get_config_path
|
||||
## Absolute path to the mirrored .json file.
|
||||
var json_path: String = "" : get = get_json_path
|
||||
|
||||
var _loaded: bool = false
|
||||
var _config_path_resolved: String = ""
|
||||
var _json_path_resolved: String = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred(&"_load_config")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Reload the config from disk. Emits config_loaded on success.
|
||||
func reload() -> bool:
|
||||
return _load_config()
|
||||
|
||||
|
||||
## Return the entire config as a formatted JSON string.
|
||||
func to_json_string() -> String:
|
||||
var data := _build_json_dict()
|
||||
return JSON.stringify(data, "\t", false)
|
||||
|
||||
|
||||
## Return the entire config as a Godot Dictionary (same shape as JSON).
|
||||
func to_dict() -> Dictionary:
|
||||
return _build_json_dict()
|
||||
|
||||
|
||||
## Return the movement config as a Dictionary (for SimulationServer.set_movement_config()).
|
||||
func make_movement_dict() -> Dictionary:
|
||||
return {
|
||||
walk_speed = movement_walk_speed,
|
||||
sprint_speed = movement_sprint_speed,
|
||||
crouch_speed = movement_crouch_speed,
|
||||
acceleration = movement_acceleration,
|
||||
air_acceleration = movement_air_acceleration,
|
||||
friction = movement_friction,
|
||||
jump_velocity = movement_jump_velocity,
|
||||
gravity = gravity,
|
||||
}
|
||||
|
||||
|
||||
## Write the current config state to the JSON mirror file.
|
||||
## Does NOT touch the .cfg (only humans edit the .cfg).
|
||||
func save_json() -> Error:
|
||||
if _json_path_resolved.is_empty():
|
||||
return ERR_FILE_BAD_PATH
|
||||
|
||||
var f := FileAccess.open(_json_path_resolved, FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("[ServerConfig] Failed to write JSON mirror: %s" % _json_path_resolved)
|
||||
return FileAccess.get_open_error()
|
||||
|
||||
f.store_string(to_json_string())
|
||||
f.close()
|
||||
return OK
|
||||
|
||||
|
||||
## Write the current config state to the .cfg file (serialises all sections).
|
||||
## Returns OK on success, ERR_* on failure.
|
||||
func save_cfg() -> Error:
|
||||
var cfg := ConfigFile.new()
|
||||
_populate_cfg(cfg)
|
||||
|
||||
var path := _config_path_resolved if not _config_path_resolved.is_empty() else "user://server_config.cfg"
|
||||
var err := cfg.save(path)
|
||||
if err != OK:
|
||||
push_error("[ServerConfig] Failed to save config: %s" % error_string(err))
|
||||
return err
|
||||
|
||||
# Re-init from file to ensure in-memory state matches
|
||||
_load_from_path(cfg, _config_path_resolved)
|
||||
return OK
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: Loading
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _load_config() -> bool:
|
||||
# Determine config source path
|
||||
var cfg_path := _resolve_source_path()
|
||||
if cfg_path.is_empty():
|
||||
push_error("[ServerConfig] No config file could be resolved.")
|
||||
return false
|
||||
|
||||
var cfg := ConfigFile.new()
|
||||
var err := cfg.load(cfg_path)
|
||||
if err != OK:
|
||||
push_error("[ServerConfig] Failed to load config: %s (error %d)" % [cfg_path, err])
|
||||
return false
|
||||
|
||||
_load_from_path(cfg, cfg_path)
|
||||
|
||||
# Write JSON mirror
|
||||
save_json()
|
||||
|
||||
_loaded = true
|
||||
config_loaded.emit()
|
||||
print("[ServerConfig] Loaded config: %s (%d keys)" % [cfg_path, cfg.get_sections().size() * 8])
|
||||
return true
|
||||
|
||||
|
||||
## Path resolution order:
|
||||
## 1. SERVER_CFG env var (absolute path)
|
||||
## 2. user://server_config.cfg (writable operator override)
|
||||
## 3. res://config/default_server_config.cfg (bundled default)
|
||||
func _resolve_source_path() -> String:
|
||||
# Env var override
|
||||
if OS.has_environment("SERVER_CFG"):
|
||||
var env_path := OS.get_environment("SERVER_CFG")
|
||||
if FileAccess.file_exists(env_path):
|
||||
_config_path_resolved = env_path
|
||||
_json_path_resolved = env_path.get_basename() + ".json"
|
||||
return env_path
|
||||
else:
|
||||
push_warning("[ServerConfig] SERVER_CFG path does not exist: %s — falling back" % env_path)
|
||||
|
||||
# User override
|
||||
var user_path := ProjectSettings.globalize_path("user://server_config.cfg")
|
||||
if FileAccess.file_exists(user_path):
|
||||
_config_path_resolved = user_path
|
||||
_json_path_resolved = user_path.get_basename() + ".json"
|
||||
return user_path
|
||||
|
||||
# Bundled default — convert to absolute path for JSON mirror
|
||||
var res_path := ProjectSettings.globalize_path("res://config/default_server_config.cfg")
|
||||
_config_path_resolved = res_path
|
||||
_json_path_resolved = res_path.get_basename() + ".json"
|
||||
|
||||
# First run: copy bundled default to user:// so operators can edit it
|
||||
var user_dir := ProjectSettings.globalize_path("user://")
|
||||
var copy_path := user_dir.path_join("server_config.cfg")
|
||||
if not FileAccess.file_exists(copy_path):
|
||||
var src := FileAccess.open(res_path, FileAccess.READ)
|
||||
if src:
|
||||
var dst := FileAccess.open(copy_path, FileAccess.WRITE)
|
||||
if dst:
|
||||
dst.store_string(src.get_as_text())
|
||||
dst.close()
|
||||
print("[ServerConfig] First run — copied default config to: %s" % copy_path)
|
||||
_config_path_resolved = copy_path
|
||||
_json_path_resolved = copy_path.get_basename() + ".json"
|
||||
return copy_path
|
||||
src.close()
|
||||
|
||||
return res_path
|
||||
|
||||
|
||||
func _load_from_path(cfg: ConfigFile, path: String) -> void:
|
||||
_config_path_resolved = path
|
||||
_json_path_resolved = path.get_basename() + ".json"
|
||||
|
||||
# [server]
|
||||
server_name = cfg.get_value("server", "server_name", server_name)
|
||||
description = cfg.get_value("server", "description", description)
|
||||
bind_ip = cfg.get_value("server", "bind_ip", bind_ip)
|
||||
port = cfg.get_value("server", "port", port)
|
||||
max_players = cfg.get_value("server", "max_players", max_players)
|
||||
password = cfg.get_value("server", "password", password)
|
||||
tick_rate = cfg.get_value("server", "tick_rate", tick_rate)
|
||||
|
||||
# [game]
|
||||
round_time_seconds = cfg.get_value("game", "round_time_seconds", round_time_seconds)
|
||||
warmup_time_seconds = cfg.get_value("game", "warmup_time_seconds", warmup_time_seconds)
|
||||
friendly_fire = cfg.get_value("game", "friendly_fire", friendly_fire)
|
||||
ff_damage_multiplier = cfg.get_value("game", "ff_damage_multiplier", ff_damage_multiplier)
|
||||
gravity = cfg.get_value("game", "gravity", gravity)
|
||||
respawn_time_seconds = cfg.get_value("game", "respawn_time_seconds", respawn_time_seconds)
|
||||
spectate_enabled = cfg.get_value("game", "spectate_enabled", spectate_enabled)
|
||||
|
||||
# [movement]
|
||||
movement_walk_speed = cfg.get_value("movement", "walk_speed", movement_walk_speed)
|
||||
movement_sprint_speed = cfg.get_value("movement", "sprint_speed", movement_sprint_speed)
|
||||
movement_crouch_speed = cfg.get_value("movement", "crouch_speed", movement_crouch_speed)
|
||||
movement_jump_velocity = cfg.get_value("movement", "jump_velocity", movement_jump_velocity)
|
||||
movement_acceleration = cfg.get_value("movement", "acceleration", movement_acceleration)
|
||||
movement_air_acceleration = cfg.get_value("movement", "air_acceleration", movement_air_acceleration)
|
||||
movement_friction = cfg.get_value("movement", "friction", movement_friction)
|
||||
|
||||
# [match]
|
||||
win_limit = cfg.get_value("match", "win_limit", win_limit)
|
||||
time_limit_seconds = cfg.get_value("match", "time_limit_seconds", time_limit_seconds)
|
||||
map_rotation_mode = cfg.get_value("match", "map_rotation_mode", map_rotation_mode)
|
||||
maps = cfg.get_value("match", "maps", maps)
|
||||
|
||||
# Parse map list
|
||||
map_list.clear()
|
||||
var raw := maps.strip_edges()
|
||||
if not raw.is_empty():
|
||||
for m in raw.split(","):
|
||||
var trimmed := m.strip_edges()
|
||||
if not trimmed.is_empty():
|
||||
map_list.append(trimmed)
|
||||
|
||||
# [rcon]
|
||||
rcon_enabled = cfg.get_value("rcon", "enabled", rcon_enabled)
|
||||
rcon_password = cfg.get_value("rcon", "password", rcon_password)
|
||||
rcon_port = cfg.get_value("rcon", "port", rcon_port)
|
||||
|
||||
# [logging]
|
||||
log_level = cfg.get_value("logging", "log_level", log_level)
|
||||
log_file = cfg.get_value("logging", "log_file", log_file)
|
||||
|
||||
# [teams]
|
||||
team_count = cfg.get_value("teams", "team_count", team_count)
|
||||
players_per_team = cfg.get_value("teams", "players_per_team", players_per_team)
|
||||
|
||||
# Validation
|
||||
port = clampi(port, 1024, 65535)
|
||||
max_players = clampi(max_players, 1, 64)
|
||||
tick_rate = clampi(tick_rate, 30, 1000)
|
||||
team_count = clampi(team_count, 1, 8)
|
||||
players_per_team = clampi(players_per_team, 1, 32)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal: Serialization
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _build_json_dict() -> Dictionary:
|
||||
return {
|
||||
"server": {
|
||||
"server_name": server_name,
|
||||
"description": description,
|
||||
"bind_ip": bind_ip,
|
||||
"port": port,
|
||||
"max_players": max_players,
|
||||
"password": "", # intentionally omitted from JSON mirror
|
||||
"tick_rate": tick_rate,
|
||||
},
|
||||
"game": {
|
||||
"round_time_seconds": round_time_seconds,
|
||||
"warmup_time_seconds": warmup_time_seconds,
|
||||
"friendly_fire": friendly_fire,
|
||||
"ff_damage_multiplier": ff_damage_multiplier,
|
||||
"gravity": gravity,
|
||||
"respawn_time_seconds": respawn_time_seconds,
|
||||
"spectate_enabled": spectate_enabled,
|
||||
},
|
||||
"movement": {
|
||||
"walk_speed": movement_walk_speed,
|
||||
"sprint_speed": movement_sprint_speed,
|
||||
"crouch_speed": movement_crouch_speed,
|
||||
"jump_velocity": movement_jump_velocity,
|
||||
"acceleration": movement_acceleration,
|
||||
"air_acceleration": movement_air_acceleration,
|
||||
"friction": movement_friction,
|
||||
},
|
||||
"match": {
|
||||
"win_limit": win_limit,
|
||||
"time_limit_seconds": time_limit_seconds,
|
||||
"map_rotation_mode": map_rotation_mode,
|
||||
"maps": maps,
|
||||
},
|
||||
"rcon": {
|
||||
"enabled": rcon_enabled,
|
||||
"password": "", # intentionally omitted from JSON mirror
|
||||
"port": rcon_port,
|
||||
},
|
||||
"logging": {
|
||||
"log_level": log_level,
|
||||
"log_file": log_file,
|
||||
},
|
||||
"teams": {
|
||||
"team_count": team_count,
|
||||
"players_per_team": players_per_team,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
## Populate a ConfigFile object from the current property values.
|
||||
## Used by save_cfg() to serialise all sections back to disk.
|
||||
func _populate_cfg(cfg: ConfigFile) -> void:
|
||||
# [server]
|
||||
cfg.set_value("server", "server_name", server_name)
|
||||
cfg.set_value("server", "description", description)
|
||||
cfg.set_value("server", "bind_ip", bind_ip)
|
||||
cfg.set_value("server", "port", port)
|
||||
cfg.set_value("server", "max_players", max_players)
|
||||
cfg.set_value("server", "password", password)
|
||||
cfg.set_value("server", "tick_rate", tick_rate)
|
||||
|
||||
# [game]
|
||||
cfg.set_value("game", "round_time_seconds", round_time_seconds)
|
||||
cfg.set_value("game", "warmup_time_seconds", warmup_time_seconds)
|
||||
cfg.set_value("game", "friendly_fire", friendly_fire)
|
||||
cfg.set_value("game", "ff_damage_multiplier", ff_damage_multiplier)
|
||||
cfg.set_value("game", "gravity", gravity)
|
||||
cfg.set_value("game", "respawn_time_seconds", respawn_time_seconds)
|
||||
cfg.set_value("game", "spectate_enabled", spectate_enabled)
|
||||
|
||||
# [movement]
|
||||
cfg.set_value("movement", "walk_speed", movement_walk_speed)
|
||||
cfg.set_value("movement", "sprint_speed", movement_sprint_speed)
|
||||
cfg.set_value("movement", "crouch_speed", movement_crouch_speed)
|
||||
cfg.set_value("movement", "jump_velocity", movement_jump_velocity)
|
||||
cfg.set_value("movement", "acceleration", movement_acceleration)
|
||||
cfg.set_value("movement", "air_acceleration", movement_air_acceleration)
|
||||
cfg.set_value("movement", "friction", movement_friction)
|
||||
|
||||
# [match]
|
||||
cfg.set_value("match", "win_limit", win_limit)
|
||||
cfg.set_value("match", "time_limit_seconds", time_limit_seconds)
|
||||
cfg.set_value("match", "map_rotation_mode", map_rotation_mode)
|
||||
cfg.set_value("match", "maps", maps)
|
||||
|
||||
# [rcon]
|
||||
cfg.set_value("rcon", "enabled", rcon_enabled)
|
||||
cfg.set_value("rcon", "password", rcon_password)
|
||||
cfg.set_value("rcon", "port", rcon_port)
|
||||
|
||||
# [logging]
|
||||
cfg.set_value("logging", "log_level", log_level)
|
||||
cfg.set_value("logging", "log_file", log_file)
|
||||
|
||||
# [teams]
|
||||
cfg.set_value("teams", "team_count", team_count)
|
||||
cfg.set_value("teams", "players_per_team", players_per_team)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Getters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func get_config_path() -> String:
|
||||
return _config_path_resolved
|
||||
|
||||
func get_json_path() -> String:
|
||||
return _json_path_resolved
|
||||
@@ -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)
|
||||
@@ -0,0 +1,124 @@
|
||||
## Client Main — Client entry point for testing.
|
||||
##
|
||||
## Connects to the server. Receives broadcast_spawn_player /
|
||||
## broadcast_despawn_player RPCs from the server and creates
|
||||
## visual player nodes locally so each client sees every other player.
|
||||
##
|
||||
## Phase 0: simple position replication via RPC + box mesh player nodes.
|
||||
|
||||
extends Node3D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var server_host: String = "127.0.0.1"
|
||||
@export var server_port: int = 34197
|
||||
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
# remote_players[peer_id] = Node3D — visual representation of other players
|
||||
var remote_players: Dictionary = {}
|
||||
var connected: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
# Connect to server after a short delay
|
||||
await get_tree().create_timer(0.5).timeout
|
||||
_connect_to_server()
|
||||
|
||||
func _connect_to_server() -> void:
|
||||
if OS.has_environment("SERVER_HOST"):
|
||||
server_host = OS.get_environment("SERVER_HOST")
|
||||
if OS.has_environment("SERVER_PORT"):
|
||||
server_port = int(OS.get_environment("SERVER_PORT"))
|
||||
|
||||
print("[ClientMain] Connecting to %s:%d ..." % [server_host, server_port])
|
||||
|
||||
var err: Error = NetworkManager.join_server(server_host, server_port)
|
||||
if err != OK:
|
||||
push_error("[ClientMain] Connection failed: %s" % error_string(err))
|
||||
await get_tree().create_timer(2.0).timeout
|
||||
_connect_to_server()
|
||||
return
|
||||
|
||||
# Setup camera and lights
|
||||
_setup_scene()
|
||||
|
||||
connected = true
|
||||
|
||||
# Connect replication signals from NetworkManager
|
||||
# These fire when the server broadcasts spawn_player / despawn_player
|
||||
NetworkManager.remote_player_spawned.connect(_on_remote_player_spawned)
|
||||
NetworkManager.remote_player_despawned.connect(_on_remote_player_despawned)
|
||||
|
||||
print("[ClientMain] Connected to server. Peer ID: %d" % multiplayer.get_unique_id())
|
||||
|
||||
func _setup_scene() -> void:
|
||||
# Simple 3/4 top-down camera for Phase 0 testing
|
||||
var camera := Camera3D.new()
|
||||
camera.current = true
|
||||
camera.position = Vector3(0, 16, 12)
|
||||
camera.rotation_degrees.x = -55
|
||||
add_child(camera)
|
||||
|
||||
# Ambient light so we can see the box meshes
|
||||
var light := DirectionalLight3D.new()
|
||||
light.light_energy = 1.0
|
||||
light.shadow_enabled = true
|
||||
light.position = Vector3(10, 20, 10)
|
||||
light.look_at(Vector3.ZERO)
|
||||
add_child(light)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player replication handlers (called when server broadcasts via RPC)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_remote_player_spawned(peer_id: int, pos: Vector3) -> void:
|
||||
if peer_id == multiplayer.get_unique_id():
|
||||
# This is OUR player — the server has authority over it,
|
||||
# but we don't need to create a duplicate. Our local player
|
||||
# node is managed by the server's authority system.
|
||||
# The player.gd script will handle input on this peer.
|
||||
return
|
||||
|
||||
if peer_id in remote_players:
|
||||
push_warning("[ClientMain] Remote player %d already exists, skipping" % peer_id)
|
||||
return
|
||||
|
||||
# Create a remote player node for visualization
|
||||
var player: Node3D
|
||||
if player_scene:
|
||||
player = player_scene.instantiate()
|
||||
else:
|
||||
player = Node3D.new()
|
||||
player.set_script(preload("res://scripts/network/player.gd"))
|
||||
|
||||
player.name = "RemotePlayer_%d" % peer_id
|
||||
player.set_multiplayer_authority(peer_id)
|
||||
player.position = pos
|
||||
|
||||
add_child(player, true)
|
||||
remote_players[peer_id] = player
|
||||
|
||||
print("[ClientMain] Spawned remote player %d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
|
||||
|
||||
func _on_remote_player_despawned(peer_id: int) -> void:
|
||||
if peer_id == multiplayer.get_unique_id():
|
||||
return
|
||||
|
||||
if peer_id in remote_players:
|
||||
remote_players[peer_id].queue_free()
|
||||
remote_players.erase(peer_id)
|
||||
print("[ClientMain] Despawned remote player %d" % peer_id)
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if connected:
|
||||
NetworkManager.stop()
|
||||
|
||||
# Clean up any remaining remote players
|
||||
for p in remote_players.values():
|
||||
p.queue_free()
|
||||
remote_players.clear()
|
||||
@@ -0,0 +1 @@
|
||||
uid://blpy6rdyfy2b1
|
||||
@@ -0,0 +1,168 @@
|
||||
## NetworkManager — ENet Transport + Player Replication Singleton
|
||||
##
|
||||
## Autoload that wraps Godot 4's ENetMultiplayerPeer and provides
|
||||
## player spawn/despawn broadcasting to all connected clients.
|
||||
## Server creates RPC broadcasts; clients receive and emit signals.
|
||||
##
|
||||
## Architecture:
|
||||
## server mode → start_server(port) → ENetMultiplayerPeer server
|
||||
## client mode → join_server(host,port)→ ENetMultiplayerPeer client
|
||||
## replication → _broadcast_spawn_player / _broadcast_despawn_player
|
||||
## (server→all clients RPC)
|
||||
##
|
||||
## Channels (3-lane layout per Phase 0 research):
|
||||
## 0 unreliable-ordered → 128Hz input / transform deltas
|
||||
## 1 reliable-ordered → game events, spawn, damage, chat
|
||||
## 2 unreliable → telemetry / VOIP metadata
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
signal server_started(port: int)
|
||||
signal server_stopped()
|
||||
signal player_connected(peer_id: int)
|
||||
signal player_disconnected(peer_id: int)
|
||||
signal connection_succeeded()
|
||||
signal connection_failed(error_message: String)
|
||||
|
||||
# --- Player replication signals (emitted on all peers after RPC broadcast) ---
|
||||
signal remote_player_spawned(peer_id: int, pos: Vector3)
|
||||
signal remote_player_despawned(peer_id: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
const DEFAULT_PORT: int = 34197
|
||||
const CHANNELS: int = 3 # 0=input, 1=events, 2=telemetry
|
||||
|
||||
# Max clients is read from ServerConfig at start_server() time.
|
||||
# This default is used before ServerConfig is available.
|
||||
var max_clients: int = 16
|
||||
|
||||
# Channel indices
|
||||
enum Chan {
|
||||
INPUT = 0,
|
||||
EVENTS = 1,
|
||||
TELEMETRY = 2,
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var is_server: bool = false : get = _is_server
|
||||
var is_client: bool = false : get = _is_client
|
||||
var peer: ENetMultiplayerPeer = null
|
||||
|
||||
func _is_server() -> bool:
|
||||
return is_server
|
||||
|
||||
func _is_client() -> bool:
|
||||
return is_client
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server API
|
||||
# ---------------------------------------------------------------------------
|
||||
## Start a dedicated server on [port].
|
||||
## Uses max_clients (which should be set from ServerConfig before calling).
|
||||
## Returns OK or ERR_* on failure.
|
||||
func start_server(port: int = DEFAULT_PORT) -> Error:
|
||||
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||
stop()
|
||||
|
||||
# If ServerConfig is available, use it for max_clients
|
||||
if ServerConfig and ServerConfig.has_method(&"get_config_path"):
|
||||
max_clients = ServerConfig.max_players
|
||||
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
peer.set_bind_ip("*")
|
||||
|
||||
var err: Error = peer.create_server(port, max_clients, CHANNELS, 0, 0)
|
||||
if err != OK:
|
||||
peer = null
|
||||
return err
|
||||
|
||||
multiplayer.multiplayer_peer = peer
|
||||
multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected)
|
||||
multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected)
|
||||
|
||||
is_server = true
|
||||
server_started.emit(port)
|
||||
print("[NetworkManager] Server started on port %d" % port)
|
||||
return OK
|
||||
|
||||
## Stop the server / disconnect.
|
||||
func stop() -> void:
|
||||
if not peer:
|
||||
return
|
||||
|
||||
if is_server:
|
||||
multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected)
|
||||
multiplayer.multiplayer_peer.peer_disconnected.disconnect(_on_peer_disconnected)
|
||||
|
||||
peer.close()
|
||||
multiplayer.multiplayer_peer = null
|
||||
peer = null
|
||||
is_server = false
|
||||
is_client = false
|
||||
server_stopped.emit()
|
||||
print("[NetworkManager] Stopped")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client API
|
||||
# ---------------------------------------------------------------------------
|
||||
## Connect to a remote server.
|
||||
func join_server(host: String, port: int = DEFAULT_PORT) -> Error:
|
||||
if peer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_DISCONNECTED:
|
||||
stop()
|
||||
|
||||
peer = ENetMultiplayerPeer.new()
|
||||
var err: Error = peer.create_client(host, port, CHANNELS, 0, 0)
|
||||
if err != OK:
|
||||
peer = null
|
||||
return err
|
||||
|
||||
multiplayer.multiplayer_peer = peer
|
||||
connection_succeeded.emit()
|
||||
print("[NetworkManager] Connecting to %s:%d ..." % [host, port])
|
||||
|
||||
is_client = true
|
||||
return OK
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player Replication RPCs (broadcast server → all clients)
|
||||
# ---------------------------------------------------------------------------
|
||||
## Server calls this when a new player joins.
|
||||
## Broadcasts to all clients so they can create a visual player node.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func broadcast_spawn_player(peer_id: int, pos: Vector3, is_team_a: bool) -> void:
|
||||
if not multiplayer.is_server():
|
||||
print("[NetworkManager] Client received spawn: peer=%d at (%.1f, %.1f)" % [peer_id, pos.x, pos.z])
|
||||
remote_player_spawned.emit(peer_id, pos)
|
||||
|
||||
## Server calls this when a player leaves.
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
func broadcast_despawn_player(peer_id: int) -> void:
|
||||
if not multiplayer.is_server():
|
||||
print("[NetworkManager] Client received despawn: peer=%d" % peer_id)
|
||||
remote_player_despawned.emit(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Event handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_peer_connected(id: int) -> void:
|
||||
print("[NetworkManager] Peer connected: %d" % id)
|
||||
player_connected.emit(id)
|
||||
|
||||
func _on_peer_disconnected(id: int) -> void:
|
||||
print("[NetworkManager] Peer disconnected: %d" % id)
|
||||
player_disconnected.emit(id)
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
# Godot does internal ENet polling via MultiplayerAPI;
|
||||
# explicit polling is reserved for future custom packet handling.
|
||||
pass
|
||||
|
||||
func _exit_tree() -> void:
|
||||
stop()
|
||||
@@ -0,0 +1 @@
|
||||
uid://crq322sfr42al
|
||||
@@ -0,0 +1,100 @@
|
||||
## Player — Replicated player state.
|
||||
##
|
||||
## Server-authoritative movement model (Phase 0 placeholder).
|
||||
## The player who owns this node (multiplayer authority) sends input,
|
||||
## server validates and broadcasts position to all peers.
|
||||
##
|
||||
## Phase 0: simple position RPC replication.
|
||||
## Phase 1+: full input → movement → state broadcast loop.
|
||||
|
||||
extends Node3D
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var movement_speed: float = 5.0
|
||||
@export var sync_rate: float = 10.0 # Hz — how often to broadcast state
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var is_local: bool = false # True for the local player instance
|
||||
var is_client_controlled: bool = false
|
||||
|
||||
var _sync_timer: float = 0.0
|
||||
|
||||
# Interpolation state for remote players
|
||||
var _remote_position: Vector3 = Vector3.ZERO
|
||||
var _remote_velocity: Vector3 = Vector3.ZERO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
||||
|
||||
if is_local:
|
||||
# This is our own player — we'll send input to server
|
||||
is_client_controlled = not multiplayer.is_server()
|
||||
if is_client_controlled:
|
||||
print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id())
|
||||
else:
|
||||
# Remote player — disable direct control
|
||||
set_process(false)
|
||||
set_physics_process(false)
|
||||
print("[Player] Remote player: %d (authority: %d)" % [multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Client-side: send position to server
|
||||
# ---------------------------------------------------------------------------
|
||||
func _process(delta: float) -> void:
|
||||
if not is_client_controlled:
|
||||
return
|
||||
|
||||
# Simple movement for Phase 0 testing
|
||||
var input_dir := Vector3.ZERO
|
||||
if Input.is_action_pressed("ui_right"):
|
||||
input_dir.x += 1.0
|
||||
if Input.is_action_pressed("ui_left"):
|
||||
input_dir.x -= 1.0
|
||||
if Input.is_action_pressed("ui_up"):
|
||||
input_dir.z -= 1.0
|
||||
if Input.is_action_pressed("ui_down"):
|
||||
input_dir.z += 1.0
|
||||
|
||||
if input_dir != Vector3.ZERO:
|
||||
input_dir = input_dir.normalized()
|
||||
position += input_dir * movement_speed * delta
|
||||
|
||||
# Rate-limited position update to server
|
||||
_sync_timer += delta
|
||||
if _sync_timer >= (1.0 / sync_rate):
|
||||
_sync_timer = 0.0
|
||||
_send_position.rpc_id(1, position)
|
||||
|
||||
@rpc("unreliable", "any_peer")
|
||||
func _send_position(pos: Vector3) -> void:
|
||||
# Server receives position from client
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
|
||||
var peer_id: int = multiplayer.get_remote_sender_id()
|
||||
if peer_id != multiplayer.get_multiplayer_authority():
|
||||
# Peer is not authorized for this player — reject
|
||||
push_warning("[Player] Unauthorized position update from peer %d" % peer_id)
|
||||
return
|
||||
|
||||
# Server validates and broadcasts to all other peers
|
||||
position = pos
|
||||
_broadcast_position.rpc(pos)
|
||||
|
||||
@rpc("unreliable", "authority", "call_local")
|
||||
func _broadcast_position(pos: Vector3) -> void:
|
||||
# All peers (including server) update this player's position
|
||||
if not is_local:
|
||||
# Remote player — store for interpolation
|
||||
_remote_position = pos
|
||||
position = pos
|
||||
else:
|
||||
# Local player — authoritative position already set via input
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5b3teqp6yha
|
||||
@@ -0,0 +1,183 @@
|
||||
## Server Main — Headless dedicated server entry point.
|
||||
##
|
||||
## Runs as `server_main.tscn` which is the project's main scene.
|
||||
## In headless mode (--headless), this is the only running scene.
|
||||
##
|
||||
## On ready:
|
||||
## 1. Load config (env overrides)
|
||||
## 2. Load the first map from the config's map rotation
|
||||
## 3. Start ENet server on the configured port
|
||||
## 4. Spawn players on the map using spawn markers
|
||||
## 5. Broadcast spawn via RPC so clients create visual player nodes
|
||||
## 6. Log player join/leave and replicate position
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Signals
|
||||
# ---------------------------------------------------------------------------
|
||||
signal player_spawned(peer_id: int)
|
||||
signal player_despawned(peer_id: int)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
@export var player_scene: PackedScene = preload("res://scenes/player.tscn")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State
|
||||
# ---------------------------------------------------------------------------
|
||||
var players: Dictionary = {} # peer_id → Node (player instance)
|
||||
var spawn_points_a: Array[Vector3] = [] # Team A spawns
|
||||
var spawn_points_b: Array[Vector3] = [] # Team B spawns
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
# Config driven — ServerConfig singleton loaded at autoload time.
|
||||
# Allow port override via env var for backwards compatibility
|
||||
# (container/VPS deployments that set SERVER_PORT).
|
||||
var effective_port: int = ServerConfig.port
|
||||
if OS.has_environment("SERVER_PORT"):
|
||||
effective_port = int(OS.get_environment("SERVER_PORT"))
|
||||
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
|
||||
|
||||
# Instance the map from the config's map rotation
|
||||
_load_map()
|
||||
|
||||
# Start the ENet server
|
||||
var err: Error = NetworkManager.start_server(effective_port)
|
||||
if err != OK:
|
||||
push_error("[ServerMain] Failed to start server: %s" % error_string(err))
|
||||
get_tree().quit(1)
|
||||
return
|
||||
|
||||
# Connect signals
|
||||
NetworkManager.player_connected.connect(_on_player_connected)
|
||||
NetworkManager.player_disconnected.connect(_on_player_disconnected)
|
||||
|
||||
print("[ServerMain] Tactical Shooter server ready")
|
||||
print("[ServerMain] Port: %d" % effective_port)
|
||||
print("[ServerMain] Name: \"%s\"" % ServerConfig.server_name)
|
||||
print("[ServerMain] Tick rate: %d Hz" % ServerConfig.tick_rate)
|
||||
print("[ServerMain] Maps: %s" % ServerConfig.map_list)
|
||||
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
|
||||
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
||||
|
||||
func _exit_tree() -> void:
|
||||
NetworkManager.stop()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Map loading
|
||||
# ---------------------------------------------------------------------------
|
||||
func _load_map() -> void:
|
||||
# Load the first map from the config's rotation list
|
||||
var maps_list: Array[String] = ServerConfig.map_list
|
||||
if maps_list.is_empty():
|
||||
push_warning("[ServerMain] Map rotation is empty — running without map geometry")
|
||||
return
|
||||
|
||||
var first_map_path: String = maps_list[0]
|
||||
var full_path: String = "res://scenes/map/%s.tscn" % first_map_path
|
||||
|
||||
if not ResourceLoader.exists(full_path):
|
||||
push_error("[ServerMain] Map scene not found: %s" % full_path)
|
||||
return
|
||||
|
||||
var map_scene_res: PackedScene = load(full_path)
|
||||
var map_instance: Node = map_scene_res.instantiate()
|
||||
map_instance.name = "Map_%s" % first_map_path
|
||||
add_child(map_instance, true)
|
||||
|
||||
print("[ServerMain] Loaded map: %s" % first_map_path)
|
||||
|
||||
# Collect spawn markers from the map scene
|
||||
_collect_spawn_points(map_instance)
|
||||
|
||||
## Walk the map scene looking for Marker3D nodes named "SpawnA*" and "SpawnB*".
|
||||
func _collect_spawn_points(map_root: Node) -> void:
|
||||
_collect_spawn_points_recursive(map_root)
|
||||
|
||||
# Fallback: only after full recursion, if still empty
|
||||
if spawn_points_a.is_empty() and spawn_points_b.is_empty():
|
||||
print("[ServerMain] No spawn markers found — using default positions")
|
||||
spawn_points_a.append(Vector3(-16, 0, -4))
|
||||
spawn_points_a.append(Vector3(-16, 0, 4))
|
||||
spawn_points_b.append(Vector3(16, 0, -4))
|
||||
spawn_points_b.append(Vector3(16, 0, 4))
|
||||
|
||||
func _collect_spawn_points_recursive(node: Node) -> void:
|
||||
if node is Marker3D:
|
||||
var name_lower: String = node.name.to_lower()
|
||||
if name_lower.begins_with("spawna") or name_lower.begins_with("spawn_a"):
|
||||
spawn_points_a.append(node.position)
|
||||
elif name_lower.begins_with("spawnb") or name_lower.begins_with("spawn_b"):
|
||||
spawn_points_b.append(node.position)
|
||||
|
||||
for child in node.get_children():
|
||||
_collect_spawn_points_recursive(child)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Player management
|
||||
# ---------------------------------------------------------------------------
|
||||
func _on_player_connected(peer_id: int) -> void:
|
||||
print("[ServerMain] Player joined: %d. Total: %d" % [peer_id, multiplayer.get_peers().size() + 1])
|
||||
_spawn_player(peer_id)
|
||||
|
||||
func _on_player_disconnected(peer_id: int) -> void:
|
||||
print("[ServerMain] Player left: %d" % peer_id)
|
||||
_despawn_player(peer_id)
|
||||
|
||||
func _spawn_player(peer_id: int) -> void:
|
||||
# Create player node on the server
|
||||
var player: Node
|
||||
if player_scene:
|
||||
player = player_scene.instantiate()
|
||||
else:
|
||||
player = Node3D.new()
|
||||
player.set_script(preload("res://scripts/network/player.gd"))
|
||||
|
||||
player.name = "Player_%d" % peer_id
|
||||
player.set_multiplayer_authority(peer_id)
|
||||
|
||||
# Assign spawn position — alternate between Team A and Team B spawns
|
||||
var is_team_a: bool = (peer_id % 2) == 0
|
||||
var pool: Array[Vector3] = spawn_points_a if is_team_a else spawn_points_b
|
||||
var spawn_pos: Vector3 = pool[peer_id % pool.size()] if pool.size() > 0 else Vector3.ZERO
|
||||
spawn_pos.y = 0.0
|
||||
player.position = spawn_pos
|
||||
|
||||
add_child(player, true)
|
||||
players[peer_id] = player
|
||||
player_spawned.emit(peer_id)
|
||||
|
||||
# Broadcast spawn to all clients so they create a visual player node
|
||||
NetworkManager.broadcast_spawn_player.rpc(peer_id, spawn_pos, is_team_a)
|
||||
|
||||
print("[ServerMain] Spawned player %d at (%.1f, %.1f) team=%s" % [peer_id, spawn_pos.x, spawn_pos.z, "A" if is_team_a else "B"])
|
||||
|
||||
func _despawn_player(peer_id: int) -> void:
|
||||
if peer_id in players:
|
||||
var player: Node = players[peer_id]
|
||||
player.queue_free()
|
||||
players.erase(peer_id)
|
||||
player_despawned.emit(peer_id)
|
||||
|
||||
# Broadcast despawn to all clients
|
||||
NetworkManager.broadcast_despawn_player.rpc(peer_id)
|
||||
|
||||
print("[ServerMain] Despawned player %d" % peer_id)
|
||||
else:
|
||||
# Still broadcast even if server doesn't have the player
|
||||
NetworkManager.broadcast_despawn_player.rpc(peer_id)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Server tick (128 Hz)
|
||||
# ---------------------------------------------------------------------------
|
||||
func _physics_process(delta: float) -> void:
|
||||
if not NetworkManager.is_server:
|
||||
return
|
||||
|
||||
# Future: authoritative physics tick, state broadcast, etc.
|
||||
pass
|
||||
@@ -0,0 +1 @@
|
||||
uid://dtwj2vairvxnt
|
||||
Reference in New Issue
Block a user