e9dc05983c
Includes code from task workspaces that was never pushed: - client/characters/ — fps_character_controller.gd (400 lines), fps_camera.gd, input_handler.gd - gdextension/simulation/ — C++ GDExtension scaffold: entity, movement, hit detection, simulation server, state serializer, bitstream (14 files) - scripts/network/ — ENet-based network_manager.gd, server/client_main.gd, player.gd - scripts/map_packaging/ — PCK pipeline: pack_map.gd, map_downloader.gd, map_registry_server.py, README - scripts/config/ — server_config.gd (480 lines) - scenes/ — client_main, server_main, player, test_range - project.godot, export_presets.cfg
481 lines
16 KiB
GDScript
481 lines
16 KiB
GDScript
## 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
|