Fresh start: replace with naxIO/netfox-cs-sample foundation

Complete replacement of the tactical-shooter project with the
netfox-cs-sample (MIT) — a CS 1.6 inspired multiplayer FPS built
with Godot 4 and netfox.

## What's new
- Full CS-style gameplay: teams (T/CT), rounds, economy, buy menu
- 6 weapons: Knife, Glock, USP, AK-47, M4A1, AWP
- Bomb plant/defuse with 2 bombsites
- Flashbang & smoke grenades
- Proper netfox rollback netcode at 64 tick
- Network popup UI for host/join
- HUD, crosshair, round timer, scoreboard
- All netfox singletons registered as autoloads (works in exported builds)

## Architecture
- Listen-server (host from client, no dedicated server binary)
- Multiplayer-fps game lives at examples/multiplayer-fps/
- Netfox addons registered as autoloads for exported build compat
- Godot 4.7 with Forward+ renderer

## Removed
- Old headless-server architecture (client_main, server_main, player.gd, etc.)
- Custom netfox bootstrap with ENet fallback
- Old ChaffGames FPS template (2,420 lines, 844 KB)
- SimulationServer GDExtension stub
- Godot-jolt physics (netfox sample uses default Godot physics)
- Duplicate weapon_data.gd, anti_cheat.gd, round_manager.gd, etc.
- Server browser API Python venv (87 MB)
- test_range map and modular assets

## Preserved
- Git history
- Server config at config/default_server_config.cfg
- Windows export preset
- Build directory (gitignored)

Co-authored-by: naxIO <naxIO@users.noreply.github.com>
This commit is contained in:
2026-07-02 20:55:20 -04:00
parent ce39b237c3
commit b0c83af092
4416 changed files with 57418 additions and 902676 deletions
-220
View File
@@ -1,220 +0,0 @@
## FpsCamera — first-person camera view effects for tactical FPS.
##
## Manages view bobbing (head bob from movement), weapon sway, and FOV kick.
## The parent FPSCharacterController handles mouse look (yaw/pitch).
##
## Place as a Camera3D child of FPSCharacterController.
class_name FpsCamera
extends Camera3D
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
## Enable view bobbing (head bob when walking/running).
@export var view_bob_enabled: bool = true
## How much the camera bobs horizontally (units).
@export_range(0.0, 0.5) var bob_amplitude_h: float = 0.04
## How much the camera bobs vertically.
@export_range(0.0, 0.5) var bob_amplitude_v: float = 0.04
## Frequency multiplier for bobbing (higher = faster).
@export_range(0.5, 5.0) var bob_frequency: float = 2.5
## Sprint bob multiplier (more aggressive).
@export_range(1.0, 3.0) var bob_sprint_mult: float = 1.6
## Crouch bob multiplier (less movement).
@export_range(0.1, 1.0) var bob_crouch_mult: float = 0.4
## FOV kick on sprint (tactical FOV increase).
@export_range(0.0, 10.0) var sprint_fov_kick: float = 3.0
## FOV kick when firing weapon.
@export_range(-5.0, 10.0) var shoot_fov_kick: float = 0.5
## How fast FOV recovers.
@export var fov_recovery_speed: float = 6.0
## Enable weapon sway (subtle rotation from movement).
@export var weapon_sway_enabled: bool = true
## Maximum weapon sway rotation (degrees).
@export_range(0.0, 5.0) var sway_max_rotation: float = 1.5
## Sway responsiveness (higher = snappier).
@export_range(1.0, 20.0) var sway_response: float = 8.0
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
## Reference to parent controller (duck-typed — uses has_method instead of class_name).
var _controller: Node = null
## Bob time accumulator.
var _bob_time: float = 0.0
## Current bob offset.
var _bob_offset: Vector2 = Vector2.ZERO
## Current weapon node (child named "Weapon" or first MeshInstance3D).
var _weapon: Node3D = null
## Weapon rest position (local transform when no sway).
var _weapon_rest: Transform3D
## FOV state.
var _base_fov: float = 75.0
var _current_fov_offset: float = 0.0
## Sway state (used as both current value and interpolation target).
var _sway_current: Vector2 = Vector2.ZERO
var _sway_target: Vector2 = Vector2.ZERO
## Base eye Y set by parent controller's crouch logic (for additive bob).
var _base_eye_y: float = 0.0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_controller = get_parent()
if _controller == null or not _controller.has_method(&"is_sprinting"):
push_warning("FpsCamera: Parent has no is_sprinting() — view features disabled.")
_base_fov = fov
# Find weapon node (for sway)
for child in get_children():
if child is Node3D:
_weapon = child
_weapon_rest = child.transform
break
func _process(delta: float) -> void:
if _controller == null:
return
# Capture the eye height set by parent controller's crouch logic
# before we apply view bob offsets (which must be additive).
_base_eye_y = position.y
# FOV management
_update_fov(delta)
# View bobbing
if view_bob_enabled:
_update_bob(delta)
# Weapon sway
if weapon_sway_enabled and _weapon:
_update_sway(delta)
# ---------------------------------------------------------------------------
# FOV
# ---------------------------------------------------------------------------
func _update_fov(delta: float) -> void:
var target_offset: float = 0.0
# Sprint FOV kick — sustained while sprinting (not just on transition)
if _controller.is_sprinting():
target_offset = sprint_fov_kick
# Shoot FOV kick is triggered externally via trigger_shoot_fov()
# and decays toward the sprint/walk target naturally via move_toward.
_current_fov_offset = move_toward(_current_fov_offset, target_offset, fov_recovery_speed * delta)
fov = _base_fov + _current_fov_offset
# ---------------------------------------------------------------------------
# View bobbing
# ---------------------------------------------------------------------------
func _update_bob(delta: float) -> void:
var crouch_amount: float = _controller.get_crouch_amount()
var sprinting: bool = _controller.is_sprinting()
# Calculate bob speed multiplier from controller state
var speed_mult: float = 1.0
if sprinting:
speed_mult = bob_sprint_mult
elif crouch_amount > 0.0:
speed_mult = lerpf(1.0, bob_crouch_mult, crouch_amount)
if speed_mult < 0.01:
# Stationary — reset to zero
_bob_offset = _bob_offset.move_toward(Vector2.ZERO, delta * 10.0)
else:
_bob_time += delta * bob_frequency * speed_mult
_bob_offset = Vector2(
sin(_bob_time * 2.0) * bob_amplitude_h * speed_mult,
sin(_bob_time) * bob_amplitude_v * speed_mult
)
# Apply bob to camera position (relative to parent's eye offset)
position.x = _bob_offset.x
# Bob is additive on Y so it does NOT overwrite the crouch eye height
# set by the parent FPSCharacterController._update_crouch().
position.y = _base_eye_y + _bob_offset.y
# ---------------------------------------------------------------------------
# Weapon sway
# ---------------------------------------------------------------------------
func _update_sway(delta: float) -> void:
# Sway follows mouse movement indirectly via controller yaw/pitch changes
# Use velocity for physics-based sway: recentre over time
var vel := _controller.velocity as Vector3
var sway_h: float = clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
var sway_v: float = clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
# Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change)
_sway_target = Vector2(
move_toward(_sway_target.x, sway_h, sway_response * delta),
move_toward(_sway_target.y, sway_v, sway_response * delta)
)
# Apply to weapon node as small rotation offsets
if _weapon:
var target := Transform3D(
Basis.from_euler(Vector3(
deg_to_rad(_sway_target.y),
deg_to_rad(_sway_target.x),
0.0
)),
_weapon_rest.origin
)
_weapon.transform = _weapon.transform.interpolate_with(target, sway_response * delta * 0.5)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Trigger a shoot FOV kick. Call from weapon script.
func trigger_shoot_fov() -> void:
_current_fov_offset += shoot_fov_kick
## Reset view to resting state (for respawn).
func reset_view() -> void:
_bob_time = 0.0
_bob_offset = Vector2.ZERO
_current_fov_offset = 0.0
_sway_current = Vector2.ZERO
_sway_target = Vector2.ZERO
fov = _base_fov
if _weapon:
_weapon.transform = _weapon_rest
## Get the current bob offset for external effects.
func get_bob_offset() -> Vector2:
return _bob_offset
-1
View File
@@ -1 +0,0 @@
uid://b6rixkmc2v0lt
-152
View File
@@ -1,152 +0,0 @@
## DamageProcessor — Apply hit results and track kills.
##
## Receives hit results from WeaponServer (via LagCompensation), applies
## damage to the target entity, and emits events for scoreboard/UI updates.
##
## Usage (add as child of GameServer or combat manager):
##
## var dp = DamageProcessor.new()
## add_child(dp)
## dp.register_player(entity_id, 100.0)
## dp.process_hit(victim_id, shooter_id, 30.0)
##
class_name DamageProcessor
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a player takes damage.
## victim_id: entity_id of the damaged player
## shooter_id: entity_id of the player who dealt the damage (-1 if world/fall)
## damage: raw damage amount before any modifiers
## killed: true if this damage reduced health to 0
signal player_damaged(victim_id: int, shooter_id: int, damage: float, killed: bool)
## Emitted when a player dies from damage.
## victim_id: entity_id of the killed player
## shooter_id: entity_id of the killer (-1 if world/fall)
signal player_killed(victim_id: int, shooter_id: int)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Current health per entity_id.
var _health: Dictionary = {} # entity_id (int) → current_health (float)
## Maximum health per entity_id (for respawn reset).
var _max_health: Dictionary = {} # entity_id (int) → max_health (float)
## Kill count per shooter entity_id.
var _kills: Dictionary = {} # shooter_id (int) → kill_count (int)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Register a player for damage tracking with the given maximum health.
func register_player(entity_id: int, max_health: float = 100.0) -> void:
_health[entity_id] = max_health
_max_health[entity_id] = max_health
# Preserve existing kill count if re-registering
## Remove a player from damage tracking (on disconnect / respawn).
func unregister_player(entity_id: int) -> void:
_health.erase(entity_id)
_max_health.erase(entity_id)
_kills.erase(entity_id)
## Reset a player's health to their maximum (on respawn).
## Does NOT reset kill count.
func reset_health(entity_id: int) -> void:
if _max_health.has(entity_id):
_health[entity_id] = _max_health[entity_id]
## Process a hit result: apply damage, check for kill, emit signals.
##
## hit_result format (from WeaponServer.fire()):
## {hit: bool, position: Vector3, target_id: int,
## damage: float, weapon_id: String}
##
## shooter_id is the entity_id of the player who fired.
func process_hit(hit_result: Dictionary, shooter_id: int) -> void:
if not hit_result.get("hit", false):
return
var victim_id: int = hit_result.get("target_id", -1)
var damage: float = hit_result.get("damage", 0.0)
if victim_id < 0 or damage <= 0.0:
return
# Apply damage
var current: float = _health.get(victim_id, 100.0)
current -= damage
var killed: bool = false
if current <= 0.0:
current = 0.0
killed = true
# Track kill for the shooter
if shooter_id >= 0:
_kills[shooter_id] = _kills.get(shooter_id, 0) + 1
player_killed.emit(victim_id, shooter_id)
_health[victim_id] = max(current, 0.0)
# Emit damage event (always, even on kill)
player_damaged.emit(victim_id, shooter_id, damage, killed)
## Convenience: process a hit result with distance-based damage falloff.
## hit_result must contain a "position" key. damage_falloff_start is the
## distance in Godot units beyond which damage begins to decrease.
## damage_falloff_min is the minimum damage multiplier (clamped 0.01.0).
func process_hit_with_falloff(
hit_result: Dictionary,
shooter_id: int,
origin: Vector3,
falloff_start: float = 100.0,
falloff_min: float = 0.5
) -> void:
if not hit_result.get("hit", false):
return
var damage: float = hit_result.get("damage", 0.0)
var hit_pos: Vector3 = hit_result.get("position", Vector3.ZERO)
var distance: float = origin.distance_to(hit_pos)
if distance > falloff_start:
# Linear falloff from falloff_start to some max range (2x falloff_start)
var max_range: float = falloff_start * 2.0
var t: float = clamp(
(distance - falloff_start) / (max_range - falloff_start),
0.0, 1.0
)
var multiplier: float = 1.0 - (1.0 - falloff_min) * t
damage *= multiplier
var modified_result: Dictionary = hit_result.duplicate()
modified_result["damage"] = damage
process_hit(modified_result, shooter_id)
# ---------------------------------------------------------------------------
# Query
# ---------------------------------------------------------------------------
## Get current health for an entity, or -1 if not registered.
func get_health(entity_id: int) -> float:
return _health.get(entity_id, -1.0)
## Get kill count for a shooter entity_id.
func get_kills(player_id: int) -> int:
return _kills.get(player_id, 0)
## Get all kills (shooter_id → kill_count).
func get_all_kills() -> Dictionary:
return _kills.duplicate()
## Reset all state (for round restart).
func reset_all() -> void:
_health.clear()
_max_health.clear()
_kills.clear()
-1
View File
@@ -1 +0,0 @@
uid://ci55pc8ou2iuq
-68
View File
@@ -1,68 +0,0 @@
## WeaponData — A resource defining a weapon's static properties.
##
## Used as the data contract between weapon_definitions.gd, weapon_server.gd,
## and weapon_manager.gd. Each weapon type is pre-configured and accessible
## via the class-level constants (RIFLE, PISTOL, SHOTGUN, SMG).
##
## Usage:
## var wd = preload("res://scripts/combat/weapon_data.gd")
## var data = wd.make("rifle", "Assault Rifle", 30.0, 10.0, 30, ...)
extends Resource
class_name WeaponData
# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------
## Unique identifier string (e.g., "rifle", "pistol").
@export var weapon_id: String = ""
## Human-readable weapon name.
@export var display_name: String = ""
## Base damage per projectile/pellet.
@export var damage: float = 0.0
## Rate of fire in rounds per second (Hz).
@export var fire_rate: float = 0.0
## Magazine capacity (max ammo per reload).
@export var mag_size: int = 0
## Reload duration in seconds.
@export var reload_time: float = 0.0
## If true, hold to fire continuously. If false, single-shot per press.
@export var is_automatic: bool = false
## Base weapon spread in degrees (used for accuracy cone).
@export var spread_degrees: float = 0.0
## Effective range in Godot units (metres conceptual).
@export var range: float = 0.0
## Number of pellets/projectiles per shot (1 for most weapons, 8 for shotgun).
@export var pellets_per_shot: int = 1
# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------
## Construct a WeaponData instance from raw stat values.
## Avoids class_name references in return types for headless compatibility.
static func make(
_weapon_id: String,
_display_name: String,
_damage: float,
_fire_rate: float,
_mag_size: int,
_reload_time: float,
_is_automatic: bool,
_spread_degrees: float,
_range: float,
_pellets: int = 1
):
var w = WeaponData.new()
w.weapon_id = _weapon_id
w.display_name = _display_name
w.damage = _damage
w.fire_rate = _fire_rate
w.mag_size = _mag_size
w.reload_time = _reload_time
w.is_automatic = _is_automatic
w.spread_degrees = _spread_degrees
w.range = _range
w.pellets_per_shot = _pellets
return w
-1
View File
@@ -1 +0,0 @@
uid://bn4sdresufqbo
-49
View File
@@ -1,49 +0,0 @@
## WeaponDefinitions — Central registry of all weapon definitions.
##
## Provides a WEAPONS dictionary keyed by weapon_id for runtime lookup,
## plus a convenience getter. The data itself lives in WeaponData constants,
## so this file serves as a single import point for the full arsenal.
##
## Usage (as autoload singleton or direct preload):
## var data = WeaponDefinitions.get_weapon("rifle")
## # or:
## var all = WeaponDefinitions.WEAPONS
##
extends RefCounted
class_name WeaponDefinitions
# Preload WeaponData script directly (no class_name dependency — the server
# weapon_data.gd intentionally avoids class_name to avoid conflicts with
# client/weapons/data/weapon_data.gd which also registers WeaponData).
const _WD = preload("res://scripts/combat/weapon_data.gd")
# ---------------------------------------------------------------------------
# Weapon Registry — lazily initialised to avoid const + .new() errors
# ---------------------------------------------------------------------------
## Whether the WEAPONS dictionary has been populated.
static var _initialized: bool = false
## All weapons indexed by weapon_id — the single source of truth for the
## game's starter arsenal. Populated on first access via _ensure_init().
static var WEAPONS: Dictionary = {}
static func _ensure_init() -> void:
if _initialized:
return
_initialized = true
WEAPONS = {
"rifle": _WD.make("rifle", "Assault Rifle", 30.0, 10.0, 30, 2.1, true, 0.5, 200.0, 1),
"pistol": _WD.make("pistol", "Pistol", 25.0, 4.0, 12, 1.5, false, 0.3, 80.0, 1),
"shotgun": _WD.make("shotgun","Shotgun", 8.0, 1.0, 8, 3.0, false, 3.0, 30.0, 8),
"smg": _WD.make("smg", "Submachine Gun", 18.0, 12.0, 25, 1.8, true, 1.5, 100.0, 1),
}
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
## Return the WeaponData for the given weapon_id, or null if unknown.
static func get_weapon(id: String):
_ensure_init()
return WEAPONS.get(id, null)
-1
View File
@@ -1 +0,0 @@
uid://dktmtadg8o0qa
-480
View File
@@ -1,480 +0,0 @@
## 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:
_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
-1
View File
@@ -1 +0,0 @@
uid://bn01mhwtitvyf
-28
View File
@@ -1,28 +0,0 @@
## Entry — Smart entry point.
## Loads the server scene if running headless (dedicated server),
## or the client scene if running with a display (Windows client).
extends Node
@export var server_host: String = "68.202.6.107"
@export var server_port: int = 34197
func _ready() -> void:
var is_headless := DisplayServer.get_name() == "headless"
if is_headless:
# Dedicated server mode
var server_tscn = load("res://scenes/server/server_main.tscn")
if server_tscn == null:
push_error("[Entry] Failed to load server scene")
return
add_child(server_tscn.instantiate())
print("[Entry] Starting in SERVER mode (headless)")
else:
# Client mode (Windows, etc.)
var client_tscn = load("res://scenes/client/client_main.tscn")
if client_tscn == null:
push_error("[Entry] Failed to load client scene")
return
var client = client_tscn.instantiate()
add_child(client)
print("[Entry] Starting in CLIENT mode (display available)")
-1
View File
@@ -1 +0,0 @@
uid://21c1sct06141
-363
View File
@@ -1,363 +0,0 @@
# Map PCK Packaging Pipeline
## Overview
The PCK packaging pipeline converts map scenes (`.tscn`) into standalone
resource packs (`.pck`) that can be downloaded and loaded at runtime.
This is Godot's built-in DLC/addon pattern — no engine modifications
required.
```
Map Creator Registry Server Game Client
────────────────── ────────────────── ──────────────────
│ │ │
├── Build map in template project │ │
├── Run pack_map.gd ──────────────►│ (upload .pck) │
│ │ │
│ ├── GET /maps ───────────────►│
│ │◄── [map list JSON] ────────┤
│ │ ├── Check user://maps/
│ │ │
│ │◄── GET /maps/<name>.pck ───┤ (if not cached)
│ ├── .pck file ───────────────►│
│ │ ├── Save to user://maps/
│ │ ├── load_resource_pack()
│ │ ├── Load res://maps/<name>.tscn
│ │ │
```
## Components
| File | Role |
|------|------|
| `pack_map.gd` | Godot editor tool — packages a `.tscn``.pck` |
| `map_downloader.gd` | Godot autoload — client-side download + cache + load |
| `map_registry_server.py` | Python HTTP server — serves `.pck` files + metadata |
| `config-ext-map-registry.cfg` | Server config extension for registry integration |
| `README.md` | This file |
---
## 1. Packaging Maps (`pack_map.gd`)
### Prerequisites
- Godot 4.2+
- The map scene and all its dependencies must be local (`res://` paths)
### In the Editor
1. Open the map template project (`client/map_template/`)
2. Build your map in `scenes/maps/` (e.g., `scenes/maps/my_map.tscn`)
3. Open the map scene in the editor
4. Run **Project > Tools > Pack Current Map**
Output goes to `user://packed_maps/my_map.pck`.
### Headless / CLI
```bash
godot --headless --script scripts/map_packaging/pack_map.gd \
--map=res://scenes/maps/my_map.tscn
```
### Output
- `user://packed_maps/<map_name>.pck` — the resource pack
- `user://packed_maps/<map_name>.json` — metadata sidecar
### What's in the .pck?
The pack contains only the map scene and its direct resource dependencies
(meshes, textures, materials). No game logic, no scripts, no config files.
This keeps packs small and safe — a map can't inject code.
---
## 2. Map Registry Server (`map_registry_server.py`)
A lightweight Python HTTP server that serves `.pck` files and map metadata.
### Quick Start
```bash
# Install — no dependencies, pure stdlib
python3 scripts/map_packaging/map_registry_server.py
# With custom port and maps directory
python3 scripts/map_packaging/map_registry_server.py \
--port 8090 \
--maps-dir /data/maps
# Via environment variables
export MAP_REGISTRY_PORT=8080
export MAP_REGISTRY_MAPS=/data/maps
python3 scripts/map_packaging/map_registry_server.py
```
### API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/maps` | GET | JSON list of all available maps with metadata |
| `/maps/<name>.pck` | GET | Download a map pack (binary) |
| `/maps/<name>.json` | GET | Metadata for a single map |
| `/` | GET | Server info and endpoint documentation |
### Deployment
#### Systemd Service
```ini
[Unit]
Description=Tactical Shooter Map Registry
After=network.target
[Service]
Type=simple
User=tactical-shooter
WorkingDirectory=/opt/tactical-shooter
ExecStart=/usr/bin/python3 /opt/tactical-shooter/scripts/map_packaging/map_registry_server.py \
--port 8090 \
--maps-dir /var/lib/tactical-shooter/maps
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
```
#### NPMplus Reverse Proxy
```nginx
# NPMplus custom location for map registry
location /maps/ {
proxy_pass http://127.0.0.1:8090;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
}
```
---
## 3. Client Map Downloader (`map_downloader.gd`)
### Installation
Add `map_downloader.gd` as an autoload singleton in `project.godot`:
```ini
[autoload]
MapDownloader="*res://scripts/map_packaging/map_downloader.gd"
```
### Usage
```gdscript
# Set the registry URL (default: http://127.0.0.1:8090)
MapDownloader.registry_url = "http://maps.example.com:8090"
# Fetch the map list from the registry (auto-downloads missing maps)
MapDownloader.fetch_map_list()
# Or download specific maps
MapDownloader.download_map("de_dust2")
MapDownloader.download_maps(["de_dust2", "de_inferno"])
# Check cache
if MapDownloader.is_map_cached("de_dust2"):
print("Map is ready")
MapDownloader.load_map("de_dust2")
# Now res://maps/de_dust2.tscn is available
# Get cached maps
var cached = MapDownloader.get_cached_maps()
# Remove a map
MapDownloader.remove_map("de_dust2")
# Clear all caches
MapDownloader.clear_cache()
```
### Signals
```gdscript
# Progress during download
MapDownloader.map_download_progress.connect(
func(map_name, received, total):
var pct = float(received) / total * 100
print("%s: %.1f%%" % [map_name, pct])
)
# Download completed
MapDownloader.map_download_complete.connect(
func(map_name, success):
if success:
print("%s ready to play!" % map_name)
)
# Map loaded into resource system
MapDownloader.map_loaded.connect(
func(map_name):
print("%s is now available at res://maps/%s.tscn" % [map_name, map_name])
)
```
### Environment Overrides
| Variable | Description |
|----------|-------------|
| `MAP_REGISTRY_URL` | Override the registry server URL |
---
## 4. Integration with Server Main
To wire map downloads into the existing server flow (`server_main.gd`):
```gdscript
# In server_main.gd or a dedicated map manager:
func _on_server_started() -> void:
# Start map registry if configured
if ServerConfig.has_property("map_registry_enabled") and ServerConfig.map_registry_enabled:
var registry_url: String = ServerConfig.get("map_registry_url", "")
if not registry_url.is_empty():
MapDownloader.registry_url = registry_url
# Fetch map list — MapDownloader auto-downloads missing maps
MapDownloader.fetch_map_list()
func _on_map_needed(map_name: String) -> void:
if not MapDownloader.is_map_cached(map_name):
# Wait for download
var waiter := func(name, success):
if success:
_start_map(name)
MapDownloader.map_download_complete.connect(waiter.bind(map_name), CONNECT_ONE_SHOT)
MapDownloader.download_map(map_name)
else:
_start_map(map_name)
func _start_map(map_name: String) -> void:
if MapDownloader.load_map(map_name):
# Now load the scene
var map_scene: PackedScene = load("res://scenes/maps/%s.tscn" % map_name)
var instance := map_scene.instantiate()
add_child(instance)
```
### Server Config Extension
Add this section to your `server_config.cfg` to enable map registry:
```ini
[map_registry]
; Enable automatic map download on server start
enabled=false
; URL of the map registry server
url="http://127.0.0.1:8090"
; Auto-download all maps from the registry, not just advertised ones
auto_download_all=false
```
---
## 5. Cache and Storage
### Client Cache
- Location: `user://maps/`
- Files: `<map_name>.pck` + `manifest.json`
- Manifest is validated on startup — stale entries (missing .pck files) are pruned
- The cache is persistent across restarts
### Cache Size Management
Maps can be large (10100 MB depending on texture resolution).
Consider:
```gdscript
# Check cache size
var total_size := 0
for name in MapDownloader.get_cached_maps():
var info = MapDownloader.get_cached_map_info(name)
total_size += info.get("size", 0)
print("Cache size: %.1f MB" % (total_size / 1048576.0))
# Remove least-recently-downloaded maps if over budget
const MAX_CACHE_MB := 500
if total_size > MAX_CACHE_MB * 1048576:
var cached = MapDownloader.get_cached_maps()
for name in cached:
MapDownloader.remove_map(name)
break # remove one at a time
```
---
## 6. Security Considerations
- **.pck files cannot execute scripts** that weren't already in the game binary.
Resource packs only add data (scenes, textures, meshes). Game logic lives
in the binary and cannot be injected via a .pck.
- **Checksum verification**: The server provides SHA-256 checksums. The
client can verify integrity before loading:
```gdscript
func verify_map(map_name: String) -> bool:
var pck_path = "user://maps/%s.pck" % map_name
var global_path = ProjectSettings.globalize_path(pck_path)
var f = FileAccess.open(global_path, FileAccess.READ)
if not f:
return false
var ctx = HashingContext.new()
ctx.start(HashingContext.HASH_SHA256)
while f.get_position() < f.get_length():
ctx.update(f.get_buffer(65536))
var checksum = ctx.finish().hex_encode()
f.close()
# Compare with server checksum
var meta = MapDownloader.get_cached_map_info(map_name)
var expected = meta.get("checksum_sha256", "")
return expected.is_empty() or checksum == expected
```
- **HTTPS**: Deploy the registry server behind an NPMplus/nginx proxy with
SSL termination. The client supports HTTPS natively via HTTPRequest.
---
## 7. Workflow Summary
### For Map Creators
```
1. Open map_template/ in Godot 4
2. Build map using CSG prefabs (scenes/maps/<name>.tscn)
3. Run: godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/<name>.tscn
4. Upload output/user://packed_maps/<name>.pck to registry server's maps directory
```
### For Server Operators
```
1. Deploy map_registry_server.py on your backend (or alongside the game server)
2. Copy .pck files into the maps directory
3. SIGHUP the server to rescan (or it auto-scans every 5 seconds)
4. Configure [map_registry] in server_config.cfg
5. Game clients auto-download on connect
```
### For Developers
```
1. Add MapDownloader as project autoload
2. On server start, call MapDownloader.fetch_map_list()
3. Before loading a map scene, call MapDownloader.load_map() if it's a community map
4. Handle download signals for progress UI
```
-399
View File
@@ -1,399 +0,0 @@
## 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()
@@ -1 +0,0 @@
uid://bhgxvmel117tg
@@ -1,393 +0,0 @@
#!/usr/bin/env python3
"""
map_registry_server.py — Map Registry HTTP Server
Serves packaged .pck map files and a JSON map listing for the Godot
client's MapDownloader to consume.
Endpoints
---------
GET /maps
Returns a JSON list of available maps with metadata.
Response:
{
"maps": [
{
"name": "de_dust2",
"size": 4194304,
"version": 1,
"description": "Classic bomb-defusal map",
"scene": "res://scenes/maps/de_dust2.tscn",
"checksum_sha256": "a1b2c3..."
}
],
"server_name": "Tactical Shooter Map Registry",
"map_count": 1
}
GET /maps/<name>.pck
Download a packaged map file. Content-Type: application/octet-stream.
GET /maps/<name>.json
Download metadata for a specific map.
Response:
{
"name": "de_dust2",
"size": 4194304,
"version": 1,
"description": "Classic bomb-defusal map",
"scene": "res://scenes/maps/de_dust2.tscn",
"checksum_sha256": "a1b2c3...",
"packed_at": "2026-06-30 12:00:00"
}
Usage
-----
# Start the server on the default port (8090):
python3 map_registry_server.py
# Custom port and map directory:
python3 map_registry_server.py --port 8080 --maps-dir /data/maps
# Docker / container friendly:
MAP_REGISTRY_PORT=8080 MAP_REGISTRY_MAPS=/data/maps python3 map_registry_server.py
The server scans `maps_dir` (default: ./packed_maps/) for *.pck files and
their matching *.json metadata files on startup and on SIGHUP.
Integration
-----------
In your server's ServerConfig or config, add a registry section:
[map_registry]
enabled=true
url="http://maps.example.com:8090"
Or set env: MAP_REGISTRY_URL on the game server.
The game client's MapDownloader singleton will connect to this server
to fetch the map list and download .pck files.
"""
import argparse
import hashlib
import json
import logging
import os
import signal
import sys
import time
from http.server import HTTPServer, BaseHTTPRequestHandler
from pathlib import Path
from typing import Dict, Optional
logger = logging.getLogger("MapRegistry")
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DEFAULT_PORT = 8090
DEFAULT_MAPS_DIR = "packed_maps"
SERVER_NAME = "Tactical Shooter Map Registry"
VERSION = "1.0.0"
# ---------------------------------------------------------------------------
# Map Registry
# ---------------------------------------------------------------------------
class MapRegistry:
"""Scans a directory for .pck files and their metadata."""
def __init__(self, maps_dir: str):
self.maps_dir = Path(maps_dir)
self.maps_dir.mkdir(parents=True, exist_ok=True)
self._cache: Dict[str, dict] = {}
self._last_scan: float = 0
self._scan_interval: float = 5.0 # seconds between rescans
self._scan()
def _scan(self) -> None:
"""Scan the maps directory for .pck and .json files."""
now = time.time()
if now - self._last_scan < self._scan_interval:
return
self._last_scan = now
self._cache.clear()
if not self.maps_dir.exists():
logger.warning("Maps directory does not exist: %s", self.maps_dir)
return
for pck_file in sorted(self.maps_dir.glob("*.pck")):
map_name = pck_file.stem
json_file = pck_file.with_suffix(".json")
entry = {
"name": map_name,
"size": pck_file.stat().st_size,
"path": str(pck_file.relative_to(self.maps_dir)),
"version": 1,
"description": "",
"scene": f"res://scenes/maps/{map_name}.tscn",
"checksum_sha256": "",
"packed_at": "",
}
# Load metadata from sidecar JSON if it exists
if json_file.exists():
try:
with open(json_file, "r") as f:
meta = json.load(f)
entry["version"] = meta.get("version", 1)
entry["description"] = meta.get("description", "")
entry["packed_at"] = meta.get("packed_at", "")
entry["source_scene"] = meta.get("source_scene", "")
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to parse metadata for %s: %s", map_name, e)
# Compute SHA-256 checksum (on first scan, cached in memory)
ck = self._compute_checksum(pck_file)
if ck:
entry["checksum_sha256"] = ck
self._cache[map_name] = entry
logger.debug("Registered map: %s (%d bytes)", map_name, entry["size"])
logger.info("Scanned %s: %d maps registered", self.maps_dir, len(self._cache))
def _compute_checksum(self, path: Path) -> str:
"""Compute SHA-256 checksum of a file."""
try:
h = hashlib.sha256()
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
except OSError as e:
logger.warning("Checksum failed for %s: %s", path.name, e)
return ""
def get_map_list(self) -> dict:
"""Return the full map listing as a dict."""
self._scan()
return {
"maps": list(self._cache.values()),
"server_name": SERVER_NAME,
"server_version": VERSION,
"map_count": len(self._cache),
}
def get_map_info(self, name: str) -> Optional[dict]:
"""Return metadata for a single map."""
self._scan()
return self._cache.get(name)
def get_map_file(self, name: str) -> Optional[Path]:
"""Return the filesystem path to a .pck file, or None."""
self._scan()
entry = self._cache.get(name)
if entry is None:
return None
pck_path = self.maps_dir / entry["path"]
return pck_path if pck_path.exists() else None
# ---------------------------------------------------------------------------
# HTTP Handler
# ---------------------------------------------------------------------------
class MapRegistryHandler(BaseHTTPRequestHandler):
"""HTTP request handler for the map registry API."""
# Shared across all instances (set by the server)
registry: MapRegistry = None # type: ignore
def log_message(self, format: str, *args) -> None:
logger.info("%s - %s", self.client_address[0], format % args)
def _send_json(self, data: dict, status: int = 200) -> None:
body = json.dumps(data, indent=2).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body)
def _send_error(self, status: int, message: str) -> None:
self._send_json({"error": message}, status)
def _send_file(self, path: Path, content_type: str = "application/octet-stream") -> None:
try:
file_size = path.stat().st_size
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(file_size))
self.send_header("Content-Disposition", f'attachment; filename="{path.name}"')
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
with open(path, "rb") as f:
while True:
chunk = f.read(65536)
if not chunk:
break
self.wfile.write(chunk)
except OSError as e:
logger.error("File send failed for %s: %s", path.name, e)
self._send_error(500, "Internal server error")
def do_GET(self) -> None:
path = self.path.rstrip("/")
# GET /maps — list all available maps
if path == "/maps":
self._send_json(self.registry.get_map_list())
return
# GET /maps/<name>.pck — download a map file
if path.startswith("/maps/") and path.endswith(".pck"):
map_name = path[len("/maps/"):-len(".pck")]
map_file = self.registry.get_map_file(map_name)
if map_file:
self._send_file(map_file)
else:
self._send_error(404, f"Map '{map_name}' not found")
return
# GET /maps/<name>.json — get metadata for a single map
if path.startswith("/maps/") and path.endswith(".json"):
map_name = path[len("/maps/"):-len(".json")]
info = self.registry.get_map_info(map_name)
if info:
self._send_json(info)
else:
self._send_error(404, f"Map '{map_name}' not found")
return
# GET / — server info
if path == "/" or path == "":
self._send_json({
"service": SERVER_NAME,
"version": VERSION,
"endpoints": {
"list_maps": "/maps",
"download_map": "/maps/<name>.pck",
"map_metadata": "/maps/<name>.json",
},
})
return
self._send_error(404, f"Not found: {path}")
def do_OPTIONS(self) -> None:
"""CORS preflight."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
# ---------------------------------------------------------------------------
# Server
# ---------------------------------------------------------------------------
def create_server(port: int, maps_dir: str) -> HTTPServer:
"""Create and configure the HTTP server."""
registry = MapRegistry(maps_dir)
MapRegistryHandler.registry = registry
server = HTTPServer(("0.0.0.0", port), MapRegistryHandler)
server.timeout = 0.5 # allow signal handling
logger.info("Map Registry Server v%s", VERSION)
logger.info(" Listen: http://0.0.0.0:%d", port)
logger.info(" Maps dir: %s", registry.maps_dir.resolve())
logger.info(" Maps found: %d", len(registry._cache))
logger.info(" Endpoints:")
logger.info(" List: GET /maps")
logger.info(" Download: GET /maps/<name>.pck")
logger.info(" Metadata: GET /maps/<name>.json")
return server
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Tactical Shooter Map Registry Server",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
parser.add_argument(
"--port", "-p",
type=int,
default=int(os.environ.get("MAP_REGISTRY_PORT", DEFAULT_PORT)),
help="HTTP port (default: %d)" % DEFAULT_PORT,
)
parser.add_argument(
"--maps-dir", "-d",
type=str,
default=os.environ.get("MAP_REGISTRY_MAPS", DEFAULT_MAPS_DIR),
help="Directory containing .pck files (default: %s)" % DEFAULT_MAPS_DIR,
)
parser.add_argument(
"--verbose", "-v",
action="store_true",
help="Enable debug logging",
)
return parser.parse_args()
def main():
args = parse_args()
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
server = create_server(args.port, args.maps_dir)
# Handle SIGTERM/SIGINT for graceful shutdown
shutdown_requested = False
def handle_signal(sig, frame):
nonlocal shutdown_requested
if not shutdown_requested:
logger.info("Shutdown requested (signal %d)", sig)
shutdown_requested = True
server.shutdown()
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
# SIGHUP rescans the map directory
if hasattr(signal, "SIGHUP"):
def handle_hup(sig, frame):
logger.info("SIGHUP received — rescanning maps")
server.RequestHandlerClass.registry._scan()
signal.signal(signal.SIGHUP, handle_hup)
try:
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
logger.info("Server stopped")
if __name__ == "__main__":
main()
-190
View File
@@ -1,190 +0,0 @@
## 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)
-1
View File
@@ -1 +0,0 @@
uid://dskw00g1p1ghe
-151
View File
@@ -1,151 +0,0 @@
## Client Main — Client entry point for testing.
##
## Connects to the server. For the local player, spawns a full FPS
## character (from the ChaffGames FPS template) with mouse look,
## weapons, HUD, and movement. For remote players, creates simple
## box-mesh representations for visibility.
##
## The local FPS character moves independently (single-player style).
## Position is replicated to the server each tick so other clients
## see where we are.
extends Node3D
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@export var server_host: String = "68.202.6.107"
@export var server_port: int = 34197
@export var fps_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
# Our local FPS character
var _local_player: Node = null
# Position sync timer
var _sync_timer: float = 0.0
const SYNC_RATE: float = 20.0 # Hz — how often to send position to server
# ---------------------------------------------------------------------------
# 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
connected = true
# Connect replication signals from NetworkManager
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())
# ---------------------------------------------------------------------------
# Local FPS character + position sync
# ---------------------------------------------------------------------------
func _process(delta: float) -> void:
# Sync our position to the server at a fixed rate
if _local_player:
_sync_timer += delta
if _sync_timer >= 1.0 / SYNC_RATE:
_sync_timer = 0.0
var pos: Vector3 = _local_player.global_position
rpc_id(1, "_send_position", pos)
@rpc("unreliable")
func _send_position(pos: Vector3) -> void:
# Server receives our position and broadcasts to other clients
pass
# Server broadcasts other players' positions to us
@rpc("unreliable", "authority")
func _replicate_position(pos: Vector3, moving_peer_id: int) -> void:
# Update the position of a remote player on our screen
if moving_peer_id in remote_players:
remote_players[moving_peer_id].position = pos
# ---------------------------------------------------------------------------
# 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 — spawn the full FPS character
if _local_player:
push_warning("[ClientMain] Local player already exists, despawning old")
_local_player.queue_free()
_local_player = fps_scene.instantiate()
_local_player.name = "LocalPlayer"
_local_player.global_position = pos
add_child(_local_player, true)
print("[ClientMain] Spawned LOCAL FPS player at (%.1f, %.1f)" % [pos.x, pos.z])
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 (simple box mesh)
var player := Node3D.new()
player.name = "RemotePlayer_%d" % peer_id
player.set_multiplayer_authority(peer_id)
player.position = pos
# Add a simple box mesh so we can see where other players are
var mesh := MeshInstance3D.new()
mesh.mesh = BoxMesh.new()
mesh.mesh.size = Vector3(1, 1, 1)
mesh.position.y = 0.5
player.add_child(mesh)
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():
if _local_player:
_local_player.queue_free()
_local_player = null
print("[ClientMain] Local player despawned")
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()
if _local_player:
_local_player.queue_free()
for p in remote_players.values():
p.queue_free()
remote_players.clear()
-1
View File
@@ -1 +0,0 @@
uid://blpy6rdyfy2b1
-313
View File
@@ -1,313 +0,0 @@
## ClientPrediction — Client-side prediction & reconciliation for networked FPS.
##
## Architecture (tick loop):
## 1. on_before_tick() → captures a local-world Snapshot into a 64-entry
## ring buffer BEFORE input is applied.
## 2. The character controller runs its normal local movement (predicted
## simulation — instant feedback).
## 3. on_after_tick() → sends the raw input to the server via ENet
## channel 0 and advances the local tick counter.
## 4. When a server state snapshot arrives, the controller reconciles:
## a) Compares predicted state with authoritative state at that tick.
## b) If mismatch detected → emits state_mispredicted(delta).
## c) Rewinds to confirmed state, re-applies all unconfirmed inputs,
## then emits reconciled().
##
## The same node also supports INTERPOLATION MODE for remote players
## (those not owned by this peer). See set_interpolate_mode().
##
## Server-side input queue:
## This script also contains server-side logic for receiving client
## inputs and making them available to GameServer via consume_pending_inputs().
##
## Dependencies:
## - NetworkManager autoload (for RPC relay)
## - Snapshot (data container)
## - FPSCharacterController (linked via setup())
extends Node
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const BUFFER_SIZE: int = 64
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a misprediction is detected during reconciliation.
## delta_position: Vector3 — the position difference (server - predicted).
signal state_mispredicted(delta_position: Vector3)
## Emitted after successful reconciliation (state corrected).
signal reconciled()
# ---------------------------------------------------------------------------
# State — Mode
# ---------------------------------------------------------------------------
## If true, this instance is actively predicting (client-side local player).
var prediction_enabled: bool = false
## If true, this instance is interpolating a remote player's position.
var interpolate_mode: bool = false
# ---------------------------------------------------------------------------
# State — Prediction (local player)
# ---------------------------------------------------------------------------
## Local tick counter, incremented each physics tick.
var local_tick: int = 0
## Ring buffer of snapshots taken BEFORE input each tick.
## Indexed by tick % BUFFER_SIZE.
var _snapshot_buffer: Array[Snapshot] = []
## Pending (unconfirmed) inputs, keyed by local_tick.
## Each value is a Dictionary matching the FPSCharacterController input format.
var _pending_inputs: Dictionary = {}
## Last confirmed snapshot received from the server.
var _last_confirmed_snapshot: Snapshot = null
## Tick of the last confirmed snapshot.
var _last_confirmed_tick: int = -1
# ---------------------------------------------------------------------------
# State — Interpolation (remote players)
# ---------------------------------------------------------------------------
var _prev_snapshot: Snapshot = null
var _next_snapshot: Snapshot = null
var _interp_fraction: float = 0.0
# ---------------------------------------------------------------------------
# References
# ---------------------------------------------------------------------------
var _controller: FPSCharacterController = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init() -> void:
_snapshot_buffer.resize(BUFFER_SIZE)
func _ready() -> void:
if not multiplayer.is_server() and NetworkManager:
# Client instances: listen for authoritative server state.
NetworkManager.server_state_received.connect(_on_client_state_received)
# ---------------------------------------------------------------------------
# Public API — setup
# ---------------------------------------------------------------------------
## Link this prediction node to its FPSCharacterController.
func setup(controller: FPSCharacterController) -> void:
_controller = controller
if not multiplayer.is_server():
prediction_enabled = true
print("[ClientPrediction] Prediction active for entity %d" % controller.entity_id)
## Switch to interpolation mode (for remote player representations).
func set_interpolate_mode(enabled: bool) -> void:
interpolate_mode = enabled
if enabled:
prediction_enabled = false
# ---------------------------------------------------------------------------
# Prediction hooks (called by FPSCharacterController._physics_process)
# ---------------------------------------------------------------------------
## MUST be called at the start of _physics_process, BEFORE any input
## processing or movement. Captures the current character state into
## the snapshot ring buffer.
func on_before_tick() -> void:
if _controller == null or interpolate_mode or not prediction_enabled:
return
var snap: Snapshot = _capture_snapshot(local_tick)
_snapshot_buffer[local_tick % BUFFER_SIZE] = snap
_pending_inputs[local_tick] = null # placeholder, filled by on_after_tick
## MUST be called at the end of _physics_process, AFTER local movement
## and input processing. Stores the applied input, sends it to the
## server via RPC, and advances the local tick.
func on_after_tick(input_dict: Dictionary) -> void:
if _controller == null or interpolate_mode or not prediction_enabled:
return
# Store the input for potential reconciliation.
_pending_inputs[local_tick] = input_dict.duplicate()
# Send input to the server via NetworkManager RPC (ENet channel 0).
if NetworkManager and NetworkManager.has_method(&"send_client_input"):
NetworkManager.send_client_input.rpc_id(1, local_tick, input_dict)
local_tick += 1
# ---------------------------------------------------------------------------
# Snapshot capture
# ---------------------------------------------------------------------------
func _capture_snapshot(tick: int) -> Snapshot:
var s = Snapshot.new()
s.timestamp = tick
s.position = _controller.global_position
s.rotation = _controller.global_transform.basis.get_rotation_quaternion()
s.velocity = _controller.velocity
s.grounded = _controller.is_on_floor()
return s
# ---------------------------------------------------------------------------
# Server state handling (called when authoritative state arrives)
# ---------------------------------------------------------------------------
## Handle a server state snapshot: detect misprediction and reconcile.
func on_server_state(server_snapshot: Snapshot) -> void:
if prediction_enabled:
_reconcile_from_server(server_snapshot)
elif interpolate_mode:
_store_interp_snapshot(server_snapshot)
func _reconcile_from_server(server_snapshot: Snapshot) -> void:
var server_tick: int = server_snapshot.timestamp
# Ignore stale / out-of-order state.
if server_tick <= _last_confirmed_tick:
return
# 1. Retrieve our predicted snapshot for this tick.
var predicted: Snapshot = _snapshot_buffer[server_tick % BUFFER_SIZE]
if predicted != null and predicted.timestamp == server_tick:
# 2. Compare predicted vs authoritative state.
var delta: Vector3 = server_snapshot.position - predicted.position
if delta.length_squared() > 0.0001:
state_mispredicted.emit(delta)
# 3. Update last confirmed state.
_last_confirmed_snapshot = server_snapshot
_last_confirmed_tick = server_tick
# 4. Rewind and re-predict.
_reconcile(server_snapshot, server_tick)
## Core reconciliation: rewind to the confirmed server state, then
## re-apply every unconfirmed input that was sent after that tick.
func _reconcile(confirmed: Snapshot, confirmed_tick: int) -> void:
if _controller == null:
return
# Collect pending ticks that are strictly after the confirmed tick.
var pending_ticks: Array[int] = []
for tick in _pending_inputs.keys():
if tick > confirmed_tick:
pending_ticks.append(tick)
pending_ticks.sort()
if pending_ticks.is_empty():
# Nothing to re-apply — snap directly to confirmed state.
_apply_snapshot(confirmed)
reconciled.emit()
return
# Rewind to the confirmed state.
_apply_snapshot(confirmed)
# Re-apply every pending input in tick order.
var tick_delta: float = 1.0 / 128.0 # matches the 128 Hz tick rate
for tick in pending_ticks:
var input_dict: Dictionary = _pending_inputs.get(tick, {})
if input_dict.is_empty():
continue
_simulate_input(input_dict, tick_delta)
reconciled.emit()
## Apply a snapshot directly to the character controller (rewind step).
func _apply_snapshot(snap: Snapshot) -> void:
if _controller == null:
return
_controller.global_position = snap.position
_controller.global_transform.basis = Basis(snap.rotation)
_controller.velocity = snap.velocity
## Re-simulate a single tick of input on the character controller.
## Uses the controller's own _move_local() for identical physics.
## NOTE: crouch and sprint state are not re-applied during reconciliation
## because they are frame-state toggles; the server state already accounts
## for them. Only movement direction and jump matter for position correction.
func _simulate_input(input_dict: Dictionary, delta: float) -> void:
if _controller == null:
return
var move_dir: Vector3 = input_dict.get("move_direction", Vector3.ZERO)
var jump: bool = input_dict.get("jump", false)
var yaw_deg: float = input_dict.get("look_yaw", 0.0)
# Re-apply look yaw so movement is oriented correctly.
_controller.rotation.y = deg_to_rad(yaw_deg)
# Re-run local movement (this calls move_and_slide internally).
_controller._move_local(move_dir, delta, jump)
# ---------------------------------------------------------------------------
# Remote player interpolation
# ---------------------------------------------------------------------------
func _store_interp_snapshot(snap: Snapshot) -> void:
_prev_snapshot = _next_snapshot
_next_snapshot = snap
_interp_fraction = 0.0
## Advance interpolation for a remote player. Call each _process frame.
func advance_interpolation(delta: float) -> void:
if not interpolate_mode or _controller == null:
return
if _prev_snapshot == null or _next_snapshot == null:
return
_interp_fraction += delta * 128.0 # tick rate
if _interp_fraction >= 1.0:
_apply_snapshot(_next_snapshot)
_prev_snapshot = _next_snapshot
_next_snapshot = null
return
# Smooth interpolation between snapshots.
_controller.global_position = _prev_snapshot.position.lerp(
_next_snapshot.position, _interp_fraction
)
_controller.global_transform.basis = Basis(_prev_snapshot.rotation).slerp(
Basis(_next_snapshot.rotation), _interp_fraction
)
_controller.velocity = _prev_snapshot.velocity.lerp(
_next_snapshot.velocity, _interp_fraction
)
# ---------------------------------------------------------------------------
# Client-side state reception
# ---------------------------------------------------------------------------
## Client-side: called when NetworkManager emits server_state_received.
## Converts the raw dict into a Snapshot and passes it to the
## reconciliation pipeline.
func _on_client_state_received(entity_id: int, snapshot_dict: Dictionary) -> void:
# Route to the correct prediction controller based on entity_id.
if _controller == null or _controller.entity_id != entity_id:
return
var snap: Snapshot = Snapshot.from_dict(snapshot_dict)
on_server_state(snap)
# ---------------------------------------------------------------------------
# Utility
# ---------------------------------------------------------------------------
## Reset all prediction state (call on respawn / round restart).
func reset() -> void:
_snapshot_buffer = []
_snapshot_buffer.resize(BUFFER_SIZE)
_pending_inputs.clear()
_last_confirmed_snapshot = null
_last_confirmed_tick = -1
local_tick = 0
_prev_snapshot = null
_next_snapshot = null
_interp_fraction = 0.0
-1
View File
@@ -1 +0,0 @@
uid://b4yrh2xnjfrch
-119
View File
@@ -1,119 +0,0 @@
## MapBrowserRPC — Client-Server RPC Bridge for Workshop Map Browser
##
## Autoload candidate (or child of NetworkManager) that provides ENet RPC
## methods for the workshop map browser system.
##
## ## Architecture
##
## ┌──────────────┐ request_map_list() ┌──────────────┐
## │ Client │ ──────────────────────► │ Server │
## │ │ ◄────────────────────── │ │
## │ │ send_map_list() │ │
## │ │ │ │
## │ │ request_map_change() │ │
## │ │ ──────────────────────► │ │
## └──────────────┘ └──────────────┘
##
## ## Signals
##
## map_list_received(map_list: Array) — emitted on client when map list arrives
## map_change_requested(map_id: String, peer_id: int) — emitted on server
## when a client requests a map change
## map_vote_started(map_id: String) — emitted on server when vote begins
##
## ## Integration
##
## Add as an autoload in project.godot:
## MapBrowserRPC="*res://scripts/network/map_browser_rpc.gd"
##
## Or add as a child of NetworkManager at runtime.
##
## ## RPC Visibility
##
## - Server → Client: send_map_list (authority, call_remote)
## - Client → Server: request_map_list (any_peer, call_remote)
## - Client → Server: request_map_change (any_peer, call_remote)
##
## =============================================================================
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted on the client when the server sends the map list.
signal map_list_received(map_list: Array)
## Emitted on the server when a client requests a map change.
signal map_change_requested(map_id: String, peer_id: int)
## Emitted on the server when a map vote is initiated by a client request.
signal map_vote_started(map_id: String)
## Emitted on the client when the vote result is sent back.
signal map_vote_result(map_id: String, passed: bool, yes_count: int, no_count: int)
# ---------------------------------------------------------------------------
# RPC: Server → Client
# ---------------------------------------------------------------------------
## Server sends the serialized map list to a specific client.
## Called by server code after receiving request_map_list().
@rpc("authority", "call_remote", "reliable")
func send_map_list(map_list: Array) -> void:
if multiplayer.is_server():
return
map_list_received.emit(map_list)
print("[MapBrowserRPC] Received map list with %d maps" % map_list.size())
## Server broadcasts the final vote result to all clients.
@rpc("authority", "call_remote", "reliable")
func broadcast_vote_result(map_id: String, passed: bool, yes_count: int, no_count: int) -> void:
if multiplayer.is_server():
return
map_vote_result.emit(map_id, passed, yes_count, no_count)
print("[MapBrowserRPC] Vote result for %s: passed=%s (%d yes, %d no)" % [map_id, passed, yes_count, no_count])
# ---------------------------------------------------------------------------
# RPC: Client → Server
# ---------------------------------------------------------------------------
## Client requests the current map list from the server.
## The server should call send_map_list(peer_id, map_list) in response.
@rpc("any_peer", "call_remote", "reliable")
func request_map_list() -> void:
if not multiplayer.is_server():
return
var peer_id: int = multiplayer.get_remote_sender_id()
print("[MapBrowserRPC] Map list requested by peer %d" % peer_id)
# Delegate to WorkshopBrowser if available
var list: Array = []
if WorkshopBrowser and WorkshopBrowser.has_method(&"get_map_list"):
list = WorkshopBrowser.get_map_list()
else:
push_warning("[MapBrowserRPC] WorkshopBrowser singleton not available — returning empty list")
# Send the map list back to the requesting client
send_map_list.rpc_id(peer_id, list)
## Client requests a map change (initiates a vote or signals admin).
## Emits map_change_requested on the server for handling.
@rpc("any_peer", "call_remote", "reliable")
func request_map_change(map_id: String) -> void:
if not multiplayer.is_server():
return
var peer_id: int = multiplayer.get_remote_sender_id()
print("[MapBrowserRPC] Map change requested by peer %d: %s" % [peer_id, map_id])
# Validate the map exists via WorkshopBrowser
if WorkshopBrowser and WorkshopBrowser.has_method(&"has_map"):
if not WorkshopBrowser.has_map(map_id):
print("[MapBrowserRPC] Peer %d requested unknown map: %s" % [peer_id, map_id])
return
map_change_requested.emit(map_id, peer_id)
map_vote_started.emit(map_id)
-1
View File
@@ -1 +0,0 @@
uid://duwmyvcdn55qe
-10
View File
@@ -1,10 +0,0 @@
## NetfoxBootstrap — minimal. All class_names should be registered by
## Godot's global script scanning. We don't reference any netfox
## class_name directly in our code.
extends Node
func _ready() -> void:
# netfox singletons are registered by the editor plugin only.
# In headless/export builds, Engine.has_singleton checks handle this.
pass
-1
View File
@@ -1 +0,0 @@
uid://cwipcjgj8cyym
-271
View File
@@ -1,271 +0,0 @@
## NetworkManager — netfox-aware transport with graceful ENet fallback
##
## Dual-path architecture:
## Path A (netfox available) → NetworkEvents drives lifecycle signals
## Path B (headless/export) → ENetMultiplayerPeer drives lifecycle signals
##
## The server always uses ENetMultiplayerPeer as the underlying transport
## (netfox layers on top of Godot's MultiplayerAPI). When netfox NetworkEvents
## is available, it provides lifecycle signals (on_peer_join, etc.) and the
## ENet signal connections are skipped to avoid double emissions. When netfox
## is unavailable (headless mode), the built-in ENet peer_connected/
## peer_disconnected signals are used directly.
##
## Broadcast RPCs (player spawn/despawn) work identically in both paths
## since they use Godot's built-in MultiplayerAPI, which netfox enhances
## without replacing. Round state/scores are now distributed via
## round_manager's netfox Self-RPC pattern directly.
##
## Architecture:
## server mode → start_server(port) → ENetMultiplayerPeer server
## client mode → join_server(host,port)→ ENetMultiplayerPeer client
## netfox path → NetworkEvents signals when available (editor)
## fallback → ENet peer signals in headless mode
##
## Channels (3-lane layout):
## 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)
# --- Client prediction signals ---
## Emitted on the server when a client sends input. payload: {peer_id, tick, input_dict}
signal client_input_received(peer_id: int, tick: int, input_dict: Dictionary)
## Emitted on the client when the server sends authoritative state.
## entity_id: the simulation entity this state belongs to.
signal server_state_received(entity_id: int, snapshot_dict: Dictionary)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
const DEFAULT_PORT: int = 34197
const CHANNELS: int = 3
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var is_server: bool = false : get = _is_server
var is_client: bool = false : get = _is_client
var peer: ENetMultiplayerPeer = null
var max_clients: int = 16
# netfox overlay (optional — only available when netfox plugin is active)
var _netfox_events = null
var _netfox_events_connected: bool = false
func _is_server() -> bool:
return is_server
func _is_client() -> bool:
return is_client
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Try to connect to netfox NetworkEvents singleton (non-critical)
# We use StringName for singleton access to avoid parser errors
# when netfox types aren't available (headless mode).
_try_connect_netfox()
func _try_connect_netfox() -> void:
# Use singleton name string to avoid referencing netfox types directly
if not Engine.has_singleton("NetworkEvents"):
_netfox_events_connected = false
print("[NetworkManager] netfox not available — using ENet directly")
return
var events = Engine.get_singleton("NetworkEvents")
if events == null or not events.has_signal("on_server_start"):
_netfox_events_connected = false
print("[NetworkManager] netfox singleton found but unexpected shape — using ENet directly")
return
_netfox_events = events
_netfox_events.on_server_start.connect(_on_netfox_server_start)
_netfox_events.on_server_stop.connect(_on_netfox_server_stop)
_netfox_events.on_client_start.connect(_on_netfox_client_start)
_netfox_events.on_client_stop.connect(_on_netfox_client_stop)
_netfox_events.on_peer_join.connect(_on_netfox_peer_join)
_netfox_events.on_peer_leave.connect(_on_netfox_peer_leave)
_netfox_events_connected = true
print("[NetworkManager] netfox NetworkEvents overlay active")
# netfox signal handlers
func _on_netfox_server_start() -> void:
print("[NetworkManager] Server started (netfox)")
is_server = true
func _on_netfox_server_stop() -> void:
print("[NetworkManager] Server stopped (netfox)")
is_server = false
func _on_netfox_client_start(id: int) -> void:
print("[NetworkManager] Client started (netfox, id=%d)" % id)
is_client = true
func _on_netfox_client_stop() -> void:
print("[NetworkManager] Client stopped (netfox)")
is_client = false
func _on_netfox_peer_join(id: int) -> void:
print("[NetworkManager] Peer joined (netfox, id=%d)" % id)
player_connected.emit(id)
func _on_netfox_peer_leave(id: int) -> void:
print("[NetworkManager] Peer left (netfox, id=%d)" % id)
player_disconnected.emit(id)
# ---------------------------------------------------------------------------
# Server API
# ---------------------------------------------------------------------------
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
# Only connect ENet signals when netfox is unavailable to avoid
# double-emission with NetworkEvents.on_peer_join/on_peer_leave
if not _netfox_events_connected:
multiplayer.multiplayer_peer.peer_connected.connect(_on_peer_connected)
multiplayer.multiplayer_peer.peer_disconnected.connect(_on_peer_disconnected)
print("[NetworkManager] Using ENet peer signals (netfox not available)")
else:
print("[NetworkManager] Using netfox NetworkEvents for lifecycle signals")
is_server = true
server_started.emit(port)
print("[NetworkManager] Server started on port %d" % port)
return OK
func stop() -> void:
if not peer:
return
if is_server:
# Only disconnect ENet signals if they were connected
# (when netfox was unavailable, see start_server)
if not _netfox_events_connected:
if multiplayer.multiplayer_peer.peer_connected.is_connected(_on_peer_connected):
multiplayer.multiplayer_peer.peer_connected.disconnect(_on_peer_connected)
if multiplayer.multiplayer_peer.peer_disconnected.is_connected(_on_peer_disconnected):
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
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
@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)
@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)
# ---------------------------------------------------------------------------
# Client Prediction RPCs (Phase 1 — client-side prediction)
# ---------------------------------------------------------------------------
## Client → Server: send raw input for the given local tick.
## Called by ClientPrediction.on_after_tick() each physics tick.
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
@rpc("unreliable", "any_peer", "call_remote", 0)
func send_client_input(tick: int, input_dict: Dictionary) -> void:
if not multiplayer.is_server():
return
var peer_id: int = multiplayer.get_remote_sender_id()
client_input_received.emit(peer_id, tick, input_dict)
## Server → Client: send authoritative entity snapshot for reconciliation.
## Called by server-side code (e.g. GameServer after each tick).
## entity_id identifies which simulation entity this state belongs to.
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
@rpc("unreliable", "authority", "call_remote", 1)
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
if multiplayer.is_server():
return
server_state_received.emit(entity_id, snapshot_dict)
# ---------------------------------------------------------------------------
# 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:
pass
func _exit_tree() -> void:
stop()
-1
View File
@@ -1 +0,0 @@
uid://crq322sfr42al
-435
View File
@@ -1,435 +0,0 @@
## Player — netfox rollback-aware player with client-prediction + rollback.
##
## Extends CharacterBody3D directly for full FPS movement with acceleration,
## slopes, crouch, sprint, and jump.
##
## netfox integration:
## - RollbackSynchronizer for deterministic state sync
## - Position state synced via state_properties
## - Input gathered via PlayerNetInput child (_gather())
## - TickInterpolator for smooth remote player visuals
##
## Team & Round integration:
## team_id: 0 = Team A, 1 = Team B
## mark_dead() / mark_alive() update is_alive (replicated via state_properties)
extends CharacterBody3D
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
## Walk speed in units/sec (used inside _rollback_tick / _physics_process).
@export var movement_speed: float = 5.0
# --- Standalone movement parameters (used when netfox is not active) ---
@export var local_walk_speed: float = 5.0
@export var local_sprint_speed: float = 8.0
@export var local_crouch_speed: float = 2.5
@export var local_jump_velocity: float = 4.5
@export var local_gravity: float = 15.0
@export var local_acceleration: float = 12.0
# ---------------------------------------------------------------------------
# Internal look state (for PlayerNetInput yaw/pitch getters)
# ---------------------------------------------------------------------------
var _yaw: float = 0.0
var _pitch: float = 0.0
# ---------------------------------------------------------------------------
# Replicated State — tracked by RollbackSynchronizer state_properties
# ---------------------------------------------------------------------------
## Peer ID of the owning client.
var peer_id: int = -1
## Team assignment: 0 = Team A, 1 = Team B, -1 = unassigned.
@export var team_id: int = -1
## Whether this player is alive.
@export var is_alive: bool = true
## Whether this player is in spectator mode.
@export var is_spectating: bool = false
## Current spectate target peer ID.
@export var spectate_target_id: int = 0
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## True for the local player instance.
var is_local: bool = false
## True if this is a client-controlled player (not server).
var is_client_controlled: bool = false
## Cached peer ID extracted from node name.
var _cached_peer_id: int = -1
# ---------------------------------------------------------------------------
# Netfox node references
# ---------------------------------------------------------------------------
## RollbackSynchronizer for deterministic state sync.
var _rollback_sync: Node = null
# ---------------------------------------------------------------------------
# Look state getters (for PlayerNetInput._gather())
# ---------------------------------------------------------------------------
func get_current_yaw() -> float:
return _yaw
func get_current_pitch() -> float:
return _pitch
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Determine local player status
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
if is_local:
is_client_controlled = not multiplayer.is_server()
if is_client_controlled:
print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id())
# Ensure PlayerNetInput child exists (belt + suspenders with scene)
_ensure_input_node()
# Set up RollbackSynchronizer
var has_rollback := _setup_rollback_synchronizer()
# Set up TickInterpolator for remote players
_setup_tick_interpolator()
if has_rollback:
# RollbackSynchronizer handles ticks — disable standard processing
if not is_local:
set_process(false)
set_physics_process(false)
print("[Player] Remote player: %d (authority: %d) — rollback sync" % [
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
else:
# No RollbackSynchronizer (headless/server mode or exported build).
# Enable _physics_process as fallback simulation.
set_physics_process(true)
# Enable _input for mouse look when this is the local player
set_process_input(is_local)
print("[Player] Player %d — rollback not available, using fallback physics" % multiplayer.get_unique_id())
# ---------------------------------------------------------------------------
# Input (mouse look) — used only in fallback mode (no rollback sync)
# ---------------------------------------------------------------------------
## Mouse look variables for fallback mode.
var _mouse_look_sensitivity: float = 0.003
var _mouse_look_invert_y: bool = false
var _mouse_captured: bool = false
## Handle mouse input for looking around in fallback mode.
## Only active when set_process_input(true) was called in _ready.
func _input(event: InputEvent) -> void:
if not is_local or _rollback_sync != null:
return
# Mouse look
if event is InputEventMouseMotion and _mouse_captured:
var rel: Vector2 = event.relative
_yaw -= rel.x * _mouse_look_sensitivity
var invert: float = -1.0 if _mouse_look_invert_y else 1.0
_pitch += rel.y * _mouse_look_sensitivity * invert
_pitch = clamp(_pitch, deg_to_rad(-89.0), deg_to_rad(89.0))
# Click to capture mouse
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
if not _mouse_captured:
_capture_mouse_fallback(true)
func _unhandled_input(event: InputEvent) -> void:
if not is_local or _rollback_sync != null:
return
# Escape to release mouse
if event.is_action_pressed("ui_cancel"):
_capture_mouse_fallback(false)
func _capture_mouse_fallback(capture: bool) -> void:
if capture == _mouse_captured:
return
_mouse_captured = capture
if capture:
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
else:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# ---------------------------------------------------------------------------
# Fallback physics — used when RollbackSynchronizer is not available
# (e.g., headless dedicated server without netfox singletons, or exported builds).
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
# Only run fallback if RollbackSynchronizer is NOT handling ticks
if _rollback_sync != null:
return
# Gather current frame input into PlayerNetInput (standalone mode)
var input_node = _find_input_node()
if input_node != null and input_node.has_method(&"_gather"):
input_node._gather()
# Read input from PlayerNetInput (if we're the authority)
if input_node == null:
return
var dir: Vector3 = input_node.get(&"move_direction")
var yaw: float = input_node.get(&"look_yaw")
var sprint: bool = input_node.get(&"sprint")
var crouch: bool = input_node.get(&"crouch")
rotation.y = yaw
_yaw = yaw
if dir != Vector3.ZERO:
var wish_dir := (transform.basis * dir).normalized()
var target_speed: float = movement_speed
if sprint:
target_speed *= 1.6
if crouch:
target_speed *= 0.5
position += wish_dir * target_speed * delta
# ---------------------------------------------------------------------------
# RollbackSynchronizer setup
# ---------------------------------------------------------------------------
## Create and configure a RollbackSynchronizer child node.
## Returns true if the synchronizer was successfully created.
func _setup_rollback_synchronizer() -> bool:
if not Engine.has_singleton(&"NetworkRollback"):
push_warning("[Player] netfox NetworkRollback not available — running without rollback sync")
return false
# Check if we already have a RollbackSynchronizer child
for child in get_children():
if child.has_method(&"get_state_properties") or child.name == "RollbackSynchronizer":
_rollback_sync = child
print("[Player] Found existing RollbackSynchronizer child")
return true
var rs = _create_rollback_sync_node()
if rs == null:
push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available")
return false
rs.root = self
# State properties — synced from server to clients
rs.state_properties = [
"position",
"is_alive",
"team_id",
"spectate_target_id",
]
# Input properties — sent from client to server
rs.input_properties = [
"PlayerNetInput/move_direction",
"PlayerNetInput/look_yaw",
"PlayerNetInput/look_pitch",
"PlayerNetInput/jump",
"PlayerNetInput/sprint",
"PlayerNetInput/crouch",
"PlayerNetInput/shoot",
"PlayerNetInput/aim",
]
rs.enable_prediction = true
rs.enable_input_broadcast = false # Only send input to server (not broadcast)
rs.full_state_interval = 24
rs.diff_ack_interval = 4
add_child(rs, true)
_rollback_sync = rs
print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id())
return true
## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues).
func _create_rollback_sync_node():
var RS = load("res://addons/netfox/rollback/rollback-synchronizer.gd")
if RS == null:
return null
var rs = RS.new()
rs.name = "RollbackSynchronizer"
return rs
# ---------------------------------------------------------------------------
# TickInterpolator setup
# ---------------------------------------------------------------------------
func _setup_tick_interpolator() -> void:
# Only create TickInterpolator if netfox is available (avoids headless parse errors)
if not Engine.has_singleton(&"NetworkTime"):
return
var TIScript = load("res://addons/netfox/tick-interpolator.gd")
if TIScript == null:
return
var ti = get_node_or_null("TickInterpolator")
if ti == null:
ti = TIScript.new()
ti.name = "TickInterpolator"
add_child(ti, true)
ti.root = self
ti.enabled = not is_local # Only interpolate remote players
ti.properties = [
"position",
"rotation",
]
ti.enable_recording = false # RollbackSynchronizer pushes state
print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local])
# ---------------------------------------------------------------------------
# PlayerNetInput creation
# ---------------------------------------------------------------------------
## Ensure a PlayerNetInput child node exists. Creates one dynamically if needed.
func _ensure_input_node() -> void:
if _find_input_node() != null:
return
var pni_script = load("res://scripts/network/player_net_input.gd")
if pni_script == null:
push_warning("[Player] Could not load PlayerNetInput script")
return
var pni = pni_script.new()
pni.name = "PlayerNetInput"
add_child(pni, true)
print("[Player] Created PlayerNetInput dynamically")
# ---------------------------------------------------------------------------
# Rollback tick — called by RollbackSynchronizer every network tick
# ---------------------------------------------------------------------------
## Called by RollbackSynchronizer for deterministic state sync.
## On the server: processes all inputs authoritatively.
## On the client: predicts locally, state corrected by server snapshots.
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
if not is_fresh and not _is_rollback_enabled():
return
var input_node = _find_input_node()
if input_node == null:
return
var dir: Vector3 = input_node.get(&"move_direction")
var yaw: float = input_node.get(&"look_yaw")
var sprint: bool = input_node.get(&"sprint")
var crouch: bool = input_node.get(&"crouch")
# Apply yaw rotation
rotation.y = yaw
_yaw = yaw
# Apply movement
if dir != Vector3.ZERO:
var wish_dir := (transform.basis * dir).normalized()
var target_speed: float = movement_speed
if sprint:
target_speed *= 1.6
if crouch:
target_speed *= 0.5
position += wish_dir * target_speed * delta
func _is_rollback_enabled() -> bool:
var nr = _get_netrollback_singleton()
if nr == null:
return false
return nr.get(&"enabled")
func _get_netrollback_singleton():
if Engine.has_singleton(&"NetworkRollback"):
return Engine.get_singleton(&"NetworkRollback")
return null
# ---------------------------------------------------------------------------
# Input node helper
# ---------------------------------------------------------------------------
func _find_input_node():
for child in get_children():
if child.name == "PlayerNetInput":
return child
return null
# ---------------------------------------------------------------------------
# Snap player to position after rollback reconciliation
# ---------------------------------------------------------------------------
func snap_to_position(pos: Vector3, yaw: float) -> void:
global_position = pos
_yaw = yaw
rotation.y = yaw
# ---------------------------------------------------------------------------
# Round system: team / life management
# ---------------------------------------------------------------------------
func set_team(new_team: int) -> void:
team_id = new_team
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
if not is_alive:
return
print("[Player] %s takes %d damage from peer %d (weapon %d)" % [
name, amount, attacker_id, weapon_id])
func _get_peer_id() -> int:
if _cached_peer_id >= 0:
return _cached_peer_id
var node_name: String = str(name)
if node_name.begins_with("Player_"):
_cached_peer_id = node_name.trim_prefix("Player_").to_int()
return _cached_peer_id
if node_name.is_valid_int():
_cached_peer_id = node_name.to_int()
return _cached_peer_id
_cached_peer_id = multiplayer.get_multiplayer_authority()
return _cached_peer_id
func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
if not is_alive:
return
is_alive = false
if multiplayer.is_server():
var pid: int = _get_peer_id()
var round_mgr: Node = _find_round_manager()
if round_mgr and round_mgr.has_method(&"on_player_death"):
round_mgr.on_player_death(pid, killer_id, weapon)
func mark_alive() -> void:
is_alive = true
is_spectating = false
spectate_target_id = 0
func set_spectate_target(target_id: int) -> void:
spectate_target_id = target_id
is_spectating = target_id > 0 or not is_alive
func _find_round_manager() -> Node:
if Engine.has_singleton(&"RoundManager"):
return Engine.get_singleton(&"RoundManager")
var root: Node = get_tree().root if get_tree() else null
if not root:
return null
for child in root.get_children():
var found: Node = _find_recursive(child)
if found:
return found
return null
func _find_recursive(node: Node) -> Node:
if node.name == "RoundManager" or (node.has_method(&"get_state_name") and node.has_method(&"register_player")):
return node
for child in node.get_children():
var found: Node = _find_recursive(child)
if found:
return found
return null
-1
View File
@@ -1 +0,0 @@
uid://c5b3teqp6yha
-179
View File
@@ -1,179 +0,0 @@
## PlayerInterpolator — Headless-safe visual interpolation for rolled-back players.
##
## Mirrors netfox's TickInterpolator pattern but with full headless/export guards:
## - Checks for NetworkTime singleton before connecting
## - Gracefully no-ops when singletons are absent (dedicated server without netfox)
## - Falls back to netfox TickInterpolator's Interpolators for type-aware lerp
##
## Architecture:
## _before_tick_loop → snapshot current → mark teleport end
## _after_tick_loop → push_state() → shift (from ← to, to ← current)
## _process(delta) → interpolate(from, to, tick_factor)
##
## Properties interpolated:
## position (Vector3), rotation (Vector3 — Euler angles)
extends Node
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
## If true, interpolation is active. Set false to disable (no-ops).
@export var enabled: bool = true
## If true, teleport on first state (instant snap, no fade-in).
@export var snap_initial: bool = true
## Root node whose properties are interpolated. Defaults to parent.
@export var root: Node3D = null
## Interpolation alpha smoothing (0.0=linear raw, >0=exponential smoothing).
## Higher values make the interpolation lag behind more smoothly.
@export_range(0.0, 1.0, 0.05) var smoothing: float = 0.5
# ---------------------------------------------------------------------------
# Internal state
# ---------------------------------------------------------------------------
## Previous state snapshot (source of interpolation).
var _from_pos: Vector3 = Vector3.ZERO
var _from_rot: Vector3 = Vector3.ZERO
## Target state snapshot (destination of interpolation).
var _to_pos: Vector3 = Vector3.ZERO
var _to_rot: Vector3 = Vector3.ZERO
## Whether we have at least two snapshots (can interpolate).
var _has_state: bool = false
## Whether the last tick was a teleport (snap, don't interpolate).
var _is_teleporting: bool = false
## Whether netfox singletons were found at startup.
var _netfox_available: bool = false
## Stored NetworkTime singleton reference (avoids bare identifier references).
var _nt: Node = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if Engine.is_editor_hint():
return
# Resolve root node
if root == null:
root = get_parent() as Node3D
if root == null:
push_warning("[PlayerInterpolator] No root node found — interpolation disabled")
enabled = false
return
# Check if netfox NetworkTime is available
_netfox_available = Engine.has_singleton(&"NetworkTime")
if _netfox_available:
_nt = Engine.get_singleton(&"NetworkTime")
_nt.before_tick_loop.connect(_on_before_tick_loop)
_nt.after_tick_loop.connect(_on_after_tick_loop)
print("[PlayerInterpolator] Connected to netfox NetworkTime — interpolation active")
else:
print("[PlayerInterpolator] netfox NetworkTime not available — interpolation disabled in headless/server mode")
enabled = false
return
# Teleport to initial state
if snap_initial:
await get_tree().process_frame
_teleport()
func _exit_tree() -> void:
if Engine.is_editor_hint():
return
if _nt != null:
if _nt.before_tick_loop.is_connected(_on_before_tick_loop):
_nt.before_tick_loop.disconnect(_on_before_tick_loop)
if _nt.after_tick_loop.is_connected(_on_after_tick_loop):
_nt.after_tick_loop.disconnect(_on_after_tick_loop)
# ---------------------------------------------------------------------------
# Process — interpolate between snapshots each frame
# ---------------------------------------------------------------------------
func _process(_delta: float) -> void:
if not enabled or not _has_state or _is_teleporting or root == null:
return
# Get interpolation factor from NetworkTime (0.0 = just after tick, 1.0 = just before next tick)
var factor: float = 0.0
if _nt != null:
factor = _nt.tick_factor
# Apply exponential smoothing to the interpolation alpha
if smoothing > 0.0:
factor = lerpf(0.0, factor, 1.0 - smoothing)
# Interpolate position
root.position = _from_pos.lerp(_to_pos, factor)
# Interpolate rotation (Euler — simple lerp per axis)
var rot_x := lerpf(_from_rot.x, _to_rot.x, factor)
var rot_y := lerpf(_from_rot.y, _to_rot.y, factor)
var rot_z := lerpf(_from_rot.z, _to_rot.z, factor)
root.rotation = Vector3(rot_x, rot_y, rot_z)
# ---------------------------------------------------------------------------
# Netfox tick loop hooks
# ---------------------------------------------------------------------------
## Called before the tick loop starts. Records the current state as the
## incoming snapshot, preparing for push_state() after the tick.
func _on_before_tick_loop() -> void:
_is_teleporting = false
## Called after the tick loop. Rotates the state snapshots: old target
## becomes new source, current root state becomes new target.
func _on_after_tick_loop() -> void:
if not enabled or root == null:
return
# Shift state snapshots: from ← to, to ← current
_from_pos = _to_pos
_from_rot = _to_rot
_to_pos = root.position
_to_rot = root.rotation
_has_state = true
# ---------------------------------------------------------------------------
# Teleport — snap to current state without interpolation
# ---------------------------------------------------------------------------
## Teleport: set both source and target to current state, disable
## interpolation for one cycle.
func _teleport() -> void:
if root == null:
return
_is_teleporting = true
var pos := root.position
var rot := root.rotation
_from_pos = pos
_from_rot = rot
_to_pos = pos
_to_rot = rot
_has_state = true
## Force a teleport from external code (e.g., after spawn).
func force_teleport() -> void:
_teleport()
## Reset state (for pooling / re-use).
func reset() -> void:
_has_state = false
_is_teleporting = false
_from_pos = Vector3.ZERO
_from_rot = Vector3.ZERO
_to_pos = Vector3.ZERO
_to_rot = Vector3.ZERO
@@ -1 +0,0 @@
uid://b6g516uewtm8p
-133
View File
@@ -1,133 +0,0 @@
## PlayerNetInput — netfox-compatible input gathering for tactical-shooter.
##
## Extends Node directly (avoids headless class_name issues with BaseNetInput)
## but follows the same pattern: connects to NetworkTime.before_tick_loop to
## call _gather() on each tick.
##
## RollbackSynchronizer references the exported properties as input_properties.
##
## Usage:
## Place as a child of the Player (Node3D). The parent node's
## RollbackSynchronizer will pick up the input_properties paths.
##
## Set hitscan_manager_ref to the RollbackHitscanManager child so that
## _gather() can call record_fire() for RewindableAction-based fire tracking.
extends Node
# ---------------------------------------------------------------------------
# Input state — read by RollbackSynchronizer as input_properties
# ---------------------------------------------------------------------------
## Movement direction in local space (normalized, or zero).
@export var move_direction: Vector3 = Vector3.ZERO
## Mouse look: yaw (horizontal, radians).
@export var look_yaw: float = 0.0
## Mouse look: pitch (vertical, radians).
@export var look_pitch: float = 0.0
## Jump pressed this tick.
@export var jump: bool = false
## Sprint toggle active.
@export var sprint: bool = false
## Crouch toggle active.
@export var crouch: bool = false
## Shoot pressed this tick.
@export var shoot: bool = false
## Aim-down-sights pressed.
@export var aim: bool = false
# ---------------------------------------------------------------------------
# RollbackHitscanManager reference (set by player.gd)
# ---------------------------------------------------------------------------
## Reference to the RollbackHitscanManager for RewindableAction fire tracking.
var hitscan_mgr_ref: Node = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Same pattern as netfox's BaseNetInput:
# connect to NetworkTime.before_tick_loop to gather input each tick
# Use Engine.get_singleton instead of direct NetworkTime reference to
# avoid headless parse errors (NetworkTime is an editor-plugin type).
var nt = Engine.get_singleton(&"NetworkTime") if Engine.has_singleton(&"NetworkTime") else null
if nt:
nt.before_tick_loop.connect(_on_before_tick_loop)
else:
push_warning("[PlayerNetInput] netfox NetworkTime not available — " +
"input gathering disabled")
func _on_before_tick_loop() -> void:
if is_multiplayer_authority():
_gather()
# ---------------------------------------------------------------------------
# Input gathering (called before each tick on the authority peer)
# ---------------------------------------------------------------------------
func _gather() -> void:
# Movement direction
var dir := Vector3.ZERO
if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
dir.z -= 1.0
if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
dir.z += 1.0
if Input.is_action_pressed(&"move_left"):
dir.x -= 1.0
if Input.is_action_pressed(&"move_right"):
dir.x += 1.0
if dir.length_squared() > 0.0:
dir = dir.normalized()
move_direction = dir
# Read look direction from parent's FPSCharacterController if available
var parent := get_parent()
if parent and parent.has_method(&"get_current_yaw"):
look_yaw = parent.get_current_yaw()
if parent and parent.has_method(&"get_current_pitch"):
look_pitch = parent.get_current_pitch()
# Action inputs
jump = Input.is_action_just_pressed(&"jump")
sprint = Input.is_action_pressed(&"sprint")
crouch = Input.is_action_pressed(&"crouch")
shoot = Input.is_action_pressed(&"shoot")
aim = Input.is_action_pressed(&"aim")
# -----------------------------------------------------------------------
# Fire recording for RewindableAction rollback sync
# -----------------------------------------------------------------------
# When the client fires, record the shot in the RollbackHitscanManager's
# RewindableAction. This propagates to the server via netfox sync so
# the server can process the lag-compensated raycast at the correct tick.
#
# We compute the eye position and look direction at gather time for
# the RewindableAction context. The server also reconstructs these from
# the synced state (position + yaw/pitch) for authoritative hit detection.
if shoot and hitscan_mgr_ref != null and hitscan_mgr_ref.has_method(&"record_fire"):
var current_tick: int =_get_current_tick()
var eye_pos: Vector3 = parent.global_position + Vector3(0, 1.7, 0) if parent else Vector3.ZERO
var look_dir: Vector3 = _yaw_pitch_to_dir(look_yaw, look_pitch)
hitscan_mgr_ref.record_fire(current_tick, eye_pos, look_dir)
## Get the current network tick, or 0 if NetworkTime is not available.
func _get_current_tick() -> int:
var nt = Engine.get_singleton(&"NetworkTime") if Engine.has_singleton(&"NetworkTime") else null
if nt != null and nt.has_method(&"get_tick"):
return nt.get_tick()
if nt != null:
return nt.tick if nt.get("tick") != null else 0
return 0
## Convert yaw/pitch to a forward direction vector.
static func _yaw_pitch_to_dir(yaw: float, pitch: float) -> Vector3:
var cp := cos(pitch)
return Vector3(cp * sin(yaw), sin(pitch), cp * cos(yaw))
@@ -1,76 +0,0 @@
## PlayerNetInput — netfox BaseNetInput subclass for tactical-shooter.
##
## This node lives under the Player scene and gathers input each network
## tick. RollbackSynchronizer references its properties in `input_properties`.
##
## Usage:
## Place as a child of the Player (CharacterBody3D or Node3D).
## The RollbackSynchronizer on the player's parent node will call
## _gather() before each tick, reading these exported properties.
extends BaseNetInput
class_name PlayerNetInput
# ---------------------------------------------------------------------------
# Input state — read by RollbackSynchronizer as input_properties
# These are set in _gather() on the authority peer before every tick.
# ---------------------------------------------------------------------------
## Movement direction in local space (normalized, or zero).
@export var move_direction: Vector3 = Vector3.ZERO
## Mouse look: yaw (horizontal, radians).
@export var look_yaw: float = 0.0
## Mouse look: pitch (vertical, radians).
@export var look_pitch: float = 0.0
## Jump pressed this tick.
@export var jump: bool = false
## Sprint toggle active.
@export var sprint: bool = false
## Crouch toggle active.
@export var crouch: bool = false
## Shoot pressed this tick.
@export var shoot: bool = false
## Aim-down-sights pressed.
@export var aim: bool = false
# ---------------------------------------------------------------------------
# Input gathering (called before each tick on the authority peer)
# ---------------------------------------------------------------------------
func _gather() -> void:
# Movement direction
var dir := Vector3.ZERO
if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
dir.z -= 1.0
if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
dir.z += 1.0
if Input.is_action_pressed(&"move_left"):
dir.x -= 1.0
if Input.is_action_pressed(&"move_right"):
dir.x += 1.0
if dir.length_squared() > 0.0:
dir = dir.normalized()
move_direction = dir
# Mouse look is handled in fps_character_controller.gd _input()
# and synced here via the character controller's current yaw/pitch.
# If this node has a FPSCharacterController sibling, read from it.
var parent := get_parent()
if parent and parent.has_method(&"get_current_yaw"):
look_yaw = parent.get_current_yaw()
if parent and parent.has_method(&"get_current_pitch"):
look_pitch = parent.get_current_pitch()
# Action inputs
jump = Input.is_action_just_pressed(&"jump")
sprint = Input.is_action_pressed(&"sprint")
crouch = Input.is_action_pressed(&"crouch")
shoot = Input.is_action_pressed(&"shoot")
aim = Input.is_action_pressed(&"aim")
-1
View File
@@ -1 +0,0 @@
uid://syopmnypsnfo
-257
View File
@@ -1,257 +0,0 @@
## 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. Create GameServer and start simulation
## 5. Spawn players on the map using spawn markers
## 6. Broadcast spawn via RPC so clients create visual player nodes
## 7. Log player join/leave and replicate position
## 8. Hitscan weapon with lag compensation runs in GameServer
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
signal player_spawned(peer_id: int)
signal player_despawned(peer_id: int)
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
@export var player_scene: PackedScene = preload("res://scenes/server/server_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
# Game server (drives simulation)
var _game_server: Node = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Wait for ServerConfig to finish (it loads via call_deferred).
# Check if already loaded to avoid racing the signal.
if ServerConfig and not _server_config_loaded():
if ServerConfig.has_signal(&"config_loaded"):
await ServerConfig.config_loaded
# 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)
# Create GameServer (drives the 128Hz simulation + hit detection)
# Use load() instead of preload() so GameServer's dev dependencies
# (weapons, economy, objectives, teams) don't block compilation.
var GameServerClass = load("res://server/scripts/game_server.gd")
if GameServerClass != null and GameServerClass is GDScript:
_game_server = GameServerClass.new()
if _game_server != null:
add_child(_game_server)
# GameServer registers SimulationServer as a singleton on _ready
_game_server.start_simulation()
else:
push_warning("[ServerMain] GameServer instantiation failed")
else:
push_warning("[ServerMain] GameServer class not available — running without simulation server")
# Check if ServerConfig has finished loading.
# 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" % str(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()])
## Check if ServerConfig has finished loading.
func _server_config_loaded() -> bool:
return ServerConfig and ServerConfig.has_method(&"get_config_path") and not ServerConfig.get_config_path().is_empty()
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)
# Register with GameServer to get a simulation entity
if _game_server != null and _game_server.has_method(&"spawn_player_entity"):
var entity_id: int = _game_server.spawn_player_entity(peer_id, spawn_pos)
if entity_id >= 0:
# Store entity_id on player node for reference
player.set_meta(&"entity_id", entity_id)
# Register with lag compensation and damage processor
# (player is a Node3D, which is a valid argument for register_player_node)
if _game_server.has_method(&"register_player_node"):
_game_server.register_player_node(entity_id, player)
print("[ServerMain] Player %d assigned entity %d" % [peer_id, entity_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]
# Despawn entity from GameServer
if _game_server != null and player.has_meta(&"entity_id"):
var entity_id: int = player.get_meta(&"entity_id")
# Unregister from lag compensation and damage processor first
if _game_server.has_method(&"unregister_player_node"):
_game_server.unregister_player_node(entity_id)
_game_server.despawn_player_entity(entity_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
# ---------------------------------------------------------------------------
# Client position replication (receives from FPS character clients)
# ---------------------------------------------------------------------------
@rpc("unreliable", "any_peer")
func _send_position(pos: Vector3) -> void:
# Called by remote clients to update their position on the server.
# Broadcast to all other peers so they see the player move.
var peer_id: int = multiplayer.get_remote_sender_id()
if peer_id in players:
players[peer_id].position = pos
# Re-broadcast to all other peers
_replicate_position.rpc(pos, peer_id)
@rpc("unreliable", "authority")
func _replicate_position(pos: Vector3, exclude_peer: int) -> void:
# All clients receive this and update the specific player's position.
# We need to find which remote player this belongs to by excluding our own.
# For now, the non-authority clients will need to filter — handled in client_main.gd
pass
-1
View File
@@ -1 +0,0 @@
uid://dtwj2vairvxnt
-60
View File
@@ -1,60 +0,0 @@
## Snapshot — Lightweight world-state snapshot for client prediction & reconciliation.
##
## Stores a point-in-time record of a player's physical state.
## Used by ClientPrediction to build its ring buffer, send inputs,
## and reconcile against authoritative server state.
##
## Serialization: to_dict() / from_dict() for RPC transport over ENet.
class_name Snapshot
extends RefCounted
# ---------------------------------------------------------------------------
# Fields
# ---------------------------------------------------------------------------
## Server tick / local tick number this snapshot corresponds to.
var timestamp: int = 0
## World-space position of the character's feet / body origin.
var position: Vector3 = Vector3.ZERO
## Character body rotation as a quaternion (avoids gimbal lock in interpolation).
var rotation: Quaternion = Quaternion.IDENTITY
## Linear velocity in world space.
var velocity: Vector3 = Vector3.ZERO
## Whether the character was on the ground (is_on_floor()) at capture time.
var grounded: bool = false
# ---------------------------------------------------------------------------
# Serialization
# ---------------------------------------------------------------------------
## Convert to a Dictionary suitable for RPC transfer.
## Arrays are used instead of Vector3/Quaternion because Godot's RPC
## serialization handles basic types more reliably.
func to_dict() -> Dictionary:
return {
"timestamp": timestamp,
"position": [position.x, position.y, position.z],
"rotation": [rotation.x, rotation.y, rotation.z, rotation.w],
"velocity": [velocity.x, velocity.y, velocity.z],
"grounded": grounded,
}
## Deserialize from a Dictionary produced by to_dict().
static func from_dict(data: Dictionary) -> Snapshot:
var s = Snapshot.new()
s.timestamp = data.get("timestamp", 0)
var p: Array = data.get("position", [0.0, 0.0, 0.0])
s.position = Vector3(p[0], p[1], p[2])
var r: Array = data.get("rotation", [0.0, 0.0, 0.0, 1.0])
s.rotation = Quaternion(r[0], r[1], r[2], r[3])
var v: Array = data.get("velocity", [0.0, 0.0, 0.0])
s.velocity = Vector3(v[0], v[1], v[2])
s.grounded = data.get("grounded", false)
return s
-1
View File
@@ -1 +0,0 @@
uid://vxt3rh3f2j35
-237
View File
@@ -1,237 +0,0 @@
## SpawnManager — Spawn point selection for team-based spawning.
##
## Scans the current scene for Marker3D nodes in team-specific groups
## ("spawn_points_ct", "spawn_points_t") and selects valid spawns.
##
## Spawn validity rules:
## - No enemies within a configurable minimum distance (proximity_check_radius)
## - Fallback: first available spawn if all are contested
## - Preference: farthest from known enemy positions
##
## Usage:
## var sm = SpawnManager.new()
## add_child(sm)
## sm.team_manager = get_node("/root/TeamManager")
## var spawn_pos: Vector3 = sm.select_spawn(player_id, Team.COUNTER_TERRORIST)
##
## Spawn points are found at runtime by scanning scene groups.
## Map authors place Marker3D nodes and add them to the appropriate group.
extends Node
class_name SpawnManager
# Preload for headless compat
const _td_ref = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Exports
# ---------------------------------------------------------------------------
## Minimum distance (in Godot units) from any enemy player for a spawn to be valid.
@export var proximity_check_radius: float = 15.0
## If true, fall back to first available spawn when all are contested.
@export var allow_fallback: bool = true
## Group name for Counter-Terrorist spawn markers.
@export var ct_spawn_group: String = "spawn_points_ct"
## Group name for Terrorist spawn markers.
@export var t_spawn_group: String = "spawn_points_t"
# ---------------------------------------------------------------------------
# Dependencies
# ---------------------------------------------------------------------------
## Reference to the TeamManager singleton or node.
## If not set, spawn safety checks are skipped (all spawns considered valid).
var team_manager: TeamManager = null
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Cached spawn points for the current map.
## Keyed by group name → Array of Marker3D nodes.
var _spawn_points: Dictionary = {}
## Tracks which player last occupied which spawn (for spread).
## player_id (int) → Marker3D global position
var _last_spawn_positions: Dictionary = {}
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## (Re)scan the scene tree for spawn point markers.
## Call this when the map changes.
func refresh_spawn_points() -> void:
_spawn_points.clear()
var groups_to_scan: Array[String] = [ct_spawn_group, t_spawn_group]
for group_name in groups_to_scan:
var markers: Array[Marker3D] = []
var nodes: Array[Node] = get_tree().get_nodes_in_group(group_name)
for node in nodes:
if node is Marker3D:
markers.append(node as Marker3D)
_spawn_points[group_name] = markers
print("[SpawnManager] Refreshed spawn points — CT: %d, T: %d" % [
_spawn_points.get(ct_spawn_group, []).size(),
_spawn_points.get(t_spawn_group, []).size(),
])
## Get all spawn point markers for a given team.
func get_spawn_points_for_team(team: int) -> Array[Marker3D]:
var group_name: String = _group_for_team(team)
return _spawn_points.get(group_name, []).duplicate()
## Select a valid spawn position for a player on a given team.
##
## Strategy:
## 1. Filter to valid spawns (no enemies within proximity_check_radius).
## 2. Among valid spawns, pick the one farthest from known enemies.
## 3. If no spawn is valid, use fallback behaviour.
## 4. If no spawns exist at all, return Vector3.ZERO.
##
## Returns the global position of the chosen spawn point.
func select_spawn(player_id: int, team: int) -> Vector3:
var markers: Array[Marker3D] = get_spawn_points_for_team(team)
if markers.is_empty():
push_warning("[SpawnManager] No spawn points found for team %s" % _td_ref.get_team_name(team))
return Vector3.ZERO
# Separate into valid and contested spawns
var valid_spawns: Array[Marker3D] = []
var contested_spawns: Array[Marker3D] = []
if team_manager != null:
for marker in markers:
if _is_spawn_safe(marker.global_position, team):
valid_spawns.append(marker)
else:
contested_spawns.append(marker)
else:
# No team manager — all spawns are valid
valid_spawns = markers.duplicate()
# Choose the best spawn
var chosen: Marker3D = null
if not valid_spawns.is_empty():
# Pick the farthest from known enemy last-positions
chosen = _pick_farthest_spawn(valid_spawns, team)
elif allow_fallback:
# Fallback: pick the first contested spawn
if not contested_spawns.is_empty():
chosen = contested_spawns[0]
print("[SpawnManager] Fallback: all spawns contested, using first available")
else:
chosen = markers[0]
print("[SpawnManager] Fallback: no spawns available, using first marker")
else:
chosen = markers[0]
print("[SpawnManager] No valid spawns and fallback disabled, using first marker")
# Record this spawn as the player's last position
var pos: Vector3 = chosen.global_position
_last_spawn_positions[player_id] = pos
return pos
## Return the number of spawn points for a team.
func get_spawn_count(team: int) -> int:
var group_name: String = _group_for_team(team)
return _spawn_points.get(group_name, []).size()
# ---------------------------------------------------------------------------
# Internal: Spawn selection helpers
# ---------------------------------------------------------------------------
## Check if a position is safe from enemy proximity.
func _is_spawn_safe(pos: Vector3, friendly_team: int) -> bool:
if team_manager == null:
return true
# Get the enemy team(s)
var enemy_teams: Array[int] = _get_enemy_teams(friendly_team)
for enemy_team in enemy_teams:
var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team)
for enemy_id in enemy_ids:
# Check last known position
var enemy_pos: Vector3 = _last_spawn_positions.get(enemy_id, Vector3.ZERO)
if enemy_pos != Vector3.ZERO:
var dist: float = pos.distance_to(enemy_pos)
if dist < proximity_check_radius:
return false
return true
## Pick the spawn farthest from all known enemy positions.
func _pick_farthest_spawn(candidates: Array[Marker3D], friendly_team: int) -> Marker3D:
if candidates.is_empty():
return null
if candidates.size() == 1:
return candidates[0]
# Collect enemy positions
var enemy_positions: Array[Vector3] = []
if team_manager != null:
var enemy_teams: Array[int] = _get_enemy_teams(friendly_team)
for enemy_team in enemy_teams:
var enemy_ids: Array[int] = team_manager.get_team_players(enemy_team)
for enemy_id in enemy_ids:
var enemy_pos: Vector3 = _last_spawn_positions.get(enemy_id, Vector3.ZERO)
if enemy_pos != Vector3.ZERO:
enemy_positions.append(enemy_pos)
if enemy_positions.is_empty():
# No known enemy positions — pick randomly among candidates
return candidates[randi() % candidates.size()]
# Pick candidate with max minimum-distance to any enemy
var best: Marker3D = candidates[0]
var best_min_dist: float = 0.0
for marker in candidates:
var pos: Vector3 = marker.global_position
var min_dist: float = INF
for epos in enemy_positions:
var d: float = pos.distance_to(epos)
if d < min_dist:
min_dist = d
if min_dist > best_min_dist:
best_min_dist = min_dist
best = marker
return best
## Get the teams that are enemies of the given team.
## For now, CT and T are enemies of each other; SPECTATOR has no enemies.
func _get_enemy_teams(team: int) -> Array[int]:
match team:
_td_ref.Team.COUNTER_TERRORIST:
return [_td_ref.Team.TERRORIST]
_td_ref.Team.TERRORIST:
return [_td_ref.Team.COUNTER_TERRORIST]
_:
return []
## Translate a Team enum to the spawn group name.
func _group_for_team(team: int) -> String:
match team:
_td_ref.Team.COUNTER_TERRORIST:
return ct_spawn_group
_td_ref.Team.TERRORIST:
return t_spawn_group
_:
return ""
-1
View File
@@ -1 +0,0 @@
uid://dixk0c0uniyp6
-123
View File
@@ -1,123 +0,0 @@
## TeamData — Team definitions and resource for a tactical FPS.
##
## Defines the Team enum, named constants for team metadata,
## and a TeamData resource for per-team configuration.
##
## Usage:
## var team: Team = Team.COUNTER_TERRORIST
## var name: String = TeamData.get_team_name(team)
## var color: Color = TeamData.get_team_color(team)
##
## Persists across rounds (round-independent).
##
## Enum values follow Godot-friendly ordering:
## 0 = SPECTATOR (no team)
## 1 = COUNTER_TERRORIST
## 2 = TERRORIST
extends Resource
class_name TeamData
# ---------------------------------------------------------------------------
# Team enum
# ---------------------------------------------------------------------------
enum Team {
SPECTATOR = 0,
COUNTER_TERRORIST = 1,
TERRORIST = 2,
}
# ---------------------------------------------------------------------------
# Named constants
# ---------------------------------------------------------------------------
const TEAM_NAMES: Dictionary = {
Team.SPECTATOR: "Spectator",
Team.COUNTER_TERRORIST: "Counter-Terrorist",
Team.TERRORIST: "Terrorist",
}
const TEAM_COLORS: Dictionary = {
Team.SPECTATOR: Color(0.6, 0.6, 0.6, 1.0),
Team.COUNTER_TERRORIST: Color(0.2, 0.5, 0.9, 1.0), # Blue
Team.TERRORIST: Color(0.9, 0.3, 0.2, 1.0), # Red
}
## Default starting money per player on each team.
const STARTING_MONEY: Dictionary = {
Team.COUNTER_TERRORIST: 800,
Team.TERRORIST: 800,
Team.SPECTATOR: 0,
}
## Default spawn group suffixes used by SpawnManager when looking for markers.
const TEAM_SPAWN_GROUPS: Dictionary = {
Team.COUNTER_TERRORIST: "spawn_points_ct",
Team.TERRORIST: "spawn_points_t",
}
# ---------------------------------------------------------------------------
# Resource properties
# ---------------------------------------------------------------------------
## The team this resource describes.
@export var team_id: Team = Team.SPECTATOR
## Human-readable display name for this team.
@export var display_name: String = ""
## Team colour (used for UI, minimap, etc.).
@export var color: Color = Color.WHITE
## Default spawn group name to scan when placing this team on a map.
@export var default_spawn_group: String = ""
# ---------------------------------------------------------------------------
# Static helpers
# ---------------------------------------------------------------------------
## Return the human-readable name for a Team enum value.
static func get_team_name(team: Team) -> String:
return TEAM_NAMES.get(team, "Unknown")
## Return the colour for a Team enum value.
static func get_team_color(team: Team) -> Color:
return TEAM_COLORS.get(team, Color.WHITE)
## Return the starting money for a Team enum value.
static func get_starting_money(team: Team) -> int:
return STARTING_MONEY.get(team, 0)
## Return the spawn group name for a Team enum value.
static func get_spawn_group(team: Team) -> String:
return TEAM_SPAWN_GROUPS.get(team, "")
## Return true if the team is a playable team (not spectator).
static func is_playable_team(team: Team) -> bool:
return team == Team.COUNTER_TERRORIST or team == Team.TERRORIST
## Convert a string to a Team enum value. Returns SPECTATOR if unknown.
static func from_string(name: String) -> Team:
match name.to_lower():
"counter_terrorist", "counter-terrorist", "ct", "counterterrorist":
return Team.COUNTER_TERRORIST
"terrorist", "t":
return Team.TERRORIST
_:
return Team.SPECTATOR
## Convert a Team enum value to its short string identifier.
static func to_short_string(team: Team) -> String:
match team:
Team.COUNTER_TERRORIST:
return "CT"
Team.TERRORIST:
return "T"
_:
return "SPEC"
-1
View File
@@ -1 +0,0 @@
uid://w0y1tql3o5vd
-250
View File
@@ -1,250 +0,0 @@
## TeamManager — Team assignment, tracking, and auto-balance.
##
## Manages which player belongs to which team, tracks team scores,
## and provides auto-balancing logic to keep team sizes fair.
##
## Teams are round-independent — player assignments persist across rounds.
##
## Usage:
## var tm = TeamManager.new()
## add_child(tm)
## tm.assign_player(player_id, Team.COUNTER_TERRORIST)
## var team: Team = tm.get_player_team(player_id)
## var ct_players: Array[int] = tm.get_team_players(Team.COUNTER_TERRORIST)
##
## Signals:
## player_joined_team(player_id, team)
## player_left_team(player_id, team)
## team_scores_updated(team_scores)
extends Node
class_name TeamManager
# Force TeamData to be loaded first (Godot 4 class_name loading order: Resources after Nodes)
const _team_data_ref = preload("res://scripts/teams/team_data.gd")
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a player is assigned to a team (including on team switch).
## team is an int (TeamData.Team enum value).
signal player_joined_team(player_id: int, team: int)
## Emitted when a player leaves a team (disconnected or switched).
## team is an int (TeamData.Team enum value).
signal player_left_team(player_id: int, team: int)
## Emitted when scores for any team change.
signal team_scores_updated(team_scores: Dictionary)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Mapping: player_id (int) → Team enum value.
var _player_teams: Dictionary = {}
## Mapping: Team enum → Array of player IDs.
var _team_players: Dictionary = {}
## Score per team: Team enum → int (rounds won, kills, etc.).
var _team_scores: Dictionary = {}
## Maximum players allowed on a single team (0 = unlimited).
## Set from ServerConfig on _ready if available.
var max_players_per_team: int = 0
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
var cfg = get_node_or_null("/root/ServerConfig")
if cfg and cfg.has_method(&"get_config_path"):
max_players_per_team = cfg.players_per_team
else:
max_players_per_team = 8
# ---------------------------------------------------------------------------
# Public API — Assignment
# ---------------------------------------------------------------------------
## Assign a player to a team. Returns true if successful, false if the team is full.
## Emits player_left_team for the old team (if any) and player_joined_team for the new team.
func assign_player(player_id: int, team: int) -> bool:
# Validate team
if team < 0 or team > TeamData.Team.size() - 1:
push_warning("[TeamManager] Invalid team value: %d" % team)
return false
# Check if team is full (skip for spectator)
if team != TeamData.Team.SPECTATOR and max_players_per_team > 0:
var current_count: int = _team_players.get(team, []).size()
if current_count >= max_players_per_team:
# Allow if the player is already on this team
if _player_teams.get(player_id, TeamData.Team.SPECTATOR) != team:
push_warning("[TeamManager] Team %s is full (%d/%d)" % [TeamData.get_team_name(team), current_count, max_players_per_team])
return false
# Remove from old team if assigned
var old_team: int = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
if old_team != team:
_remove_from_team(player_id, old_team)
# Add to new team
_player_teams[player_id] = team
if not _team_players.has(team):
_team_players[team] = []
if player_id not in _team_players[team]:
_team_players[team].append(player_id)
player_joined_team.emit(player_id, team)
return true
## Remove a player from any team (equivalent to assigning to SPECTATOR).
func remove_player(player_id: int) -> void:
var current_team: int = _player_teams.get(player_id, TeamData.Team.SPECTATOR)
_remove_from_team(player_id, current_team)
## Unregister a player entirely (on disconnect).
func unregister_player(player_id: int) -> void:
remove_player(player_id)
_player_teams.erase(player_id)
# ---------------------------------------------------------------------------
# Public API — Querying
# ---------------------------------------------------------------------------
## Get the team a player belongs to. Returns SPECTATOR if unassigned.
## Returns an int (TeamData.Team enum value).
func get_player_team(player_id: int) -> int:
return _player_teams.get(player_id, TeamData.Team.SPECTATOR)
## Get an array of all player IDs on a given team.
func get_team_players(team: int) -> Array[int]:
return _team_players.get(team, []).duplicate()
## Get the total number of players on a given team.
func get_team_player_count(team: int) -> int:
return _team_players.get(team, []).size()
## Check if a player is on a specific team.
func is_player_on_team(player_id: int, team: int) -> bool:
return _player_teams.get(player_id, TeamData.Team.SPECTATOR) == team
## Get a dictionary of all player-to-team mappings.
func get_all_assignments() -> Dictionary:
return _player_teams.duplicate()
# ---------------------------------------------------------------------------
# Public API — Auto-balance
# ---------------------------------------------------------------------------
## Assign a player to the team with the fewest members (ties: random).
## Returns the team as an int (TeamData.Team enum value) the player was assigned to,
## or SPECTATOR if both teams are full.
func assign_to_team_with_fewest(player_id: int) -> int:
var ct_count: int = _team_players.get(TeamData.Team.COUNTER_TERRORIST, []).size()
var t_count: int = _team_players.get(TeamData.Team.TERRORIST, []).size()
# If one team is at capacity, force to the other
if max_players_per_team > 0:
if ct_count >= max_players_per_team and t_count < max_players_per_team:
assign_player(player_id, TeamData.Team.TERRORIST)
return TeamData.Team.TERRORIST
if t_count >= max_players_per_team and ct_count < max_players_per_team:
assign_player(player_id, TeamData.Team.COUNTER_TERRORIST)
return TeamData.Team.COUNTER_TERRORIST
if ct_count >= max_players_per_team and t_count >= max_players_per_team:
push_warning("[TeamManager] Both teams are full")
return TeamData.Team.SPECTATOR
# Assign to team with fewer players (ties: randomly pick one)
var team: int = TeamData.Team.COUNTER_TERRORIST
if ct_count < t_count:
team = TeamData.Team.COUNTER_TERRORIST
elif t_count < ct_count:
team = TeamData.Team.TERRORIST
else:
# Tied — pick randomly
team = TeamData.Team.COUNTER_TERRORIST if randi() % 2 == 0 else TeamData.Team.TERRORIST
assign_player(player_id, team)
return team
# ---------------------------------------------------------------------------
# Public API — Scoring
# ---------------------------------------------------------------------------
## Set the score for a given team.
func set_team_score(team: int, score: int) -> void:
_team_scores[team] = score
team_scores_updated.emit(_team_scores.duplicate())
## Add to a team's current score.
func add_team_score(team: int, amount: int = 1) -> void:
var current: int = _team_scores.get(team, 0)
_team_scores[team] = current + amount
team_scores_updated.emit(_team_scores.duplicate())
## Get the score for a given team.
func get_team_score(team: int) -> int:
return _team_scores.get(team, 0)
## Get all team scores as a dictionary: Team enum → int.
func get_all_scores() -> Dictionary:
return _team_scores.duplicate()
## Reset all scores to zero.
func reset_scores() -> void:
for team in [TeamData.Team.COUNTER_TERRORIST, TeamData.Team.TERRORIST]:
_team_scores[team] = 0
team_scores_updated.emit(_team_scores.duplicate())
# ---------------------------------------------------------------------------
# Public API — Utility
# ---------------------------------------------------------------------------
## Reset all teams (clears all assignments and scores).
func reset_all() -> void:
# Emit leave signals for all assigned players
for player_id in _player_teams.keys():
var team: int = _player_teams[player_id]
player_left_team.emit(player_id, team)
_player_teams.clear()
_team_players.clear()
reset_scores()
# ---------------------------------------------------------------------------
# Internal
# ---------------------------------------------------------------------------
func _remove_from_team(player_id: int, team: int) -> void:
if _player_teams.has(player_id):
_player_teams.erase(player_id)
if _team_players.has(team):
var arr: Array = _team_players[team]
var idx: int = arr.find(player_id)
if idx >= 0:
arr.remove_at(idx)
player_left_team.emit(player_id, team)
-1
View File
@@ -1 +0,0 @@
uid://b1hh1yrejn46f
-361
View File
@@ -1,361 +0,0 @@
## RollbackHitscanManager — rollback-safe hitscan weapon with lag compensation.
##
## Uses netfox RewindableAction to track fire state per tick inside the
## rollback loop, enabling server-authoritative hit detection with physics
## rewound to the exact tick when the client fired.
##
## Key capabilities:
## - Server-authoritative raycast at the rewound tick (lag compensation)
## - Per-limb damage system (head/chest/waist/legs) via collision groups
## - RewindableAction for fire state sync across the rollback loop
## - Client prediction support via fire_performed signal
##
## Usage:
## Place as a child of the Player node (sibling of RollbackSynchronizer).
## Call process_fire() from _rollback_tick() every tick.
## Call record_fire() from input gathering when shoot is pressed.
##
## Headless safety:
## - No class_name references — uses load() + has_method() duck-typing
## - Graceful degradation when netfox types are unavailable
## - Falls back to direct input-based firing without RewindableAction
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a shot connects on the server or for client prediction.
signal hit_registered(tick: int, hit_position: Vector3, hit_normal: Vector3, collider: Object, final_damage: int)
## Emitted when a shot misses entirely.
signal shot_missed(tick: int)
## Emitted on any fire attempt (hit or miss) — for muzzle flash, sound, etc.
signal weapon_fired(tick: int, origin: Vector3, direction: Vector3)
# ---------------------------------------------------------------------------
# Weapon configuration
# ---------------------------------------------------------------------------
## Weapon data (WeaponData resource) — set when weapon switches
var current_weapon_data: Resource = null
## Maximum raycast distance — updated from current_weapon_data
var max_distance: float = 1000.0
## Eye height above player origin for reconstructing fire origin
var eye_height: float = 1.7
# ---------------------------------------------------------------------------
# RewindableAction reference
# ---------------------------------------------------------------------------
var _fire_action: Node = null
var _has_rewindable_action: bool = false
# ---------------------------------------------------------------------------
# Server-side state
# ---------------------------------------------------------------------------
## Tracks which ticks have already had damage applied, preventing
## double-processing during rollback re-simulation.
var _processed_damage_ticks: Dictionary = {}
# ---------------------------------------------------------------------------
# Player reference (cached parent)
# ---------------------------------------------------------------------------
var _player_node: Node3D = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_player_node = get_parent() as Node3D
if _player_node == null:
push_warning("[RollbackHitscan] Parent is not a Node3D")
return
_setup_fire_action()
## Create the RewindableAction child node for per-tick fire tracking.
func _setup_fire_action() -> void:
if not Engine.has_singleton(&"NetworkRollback"):
push_warning("[RollbackHitscan] NetworkRollback not available — falling back to direct fire tracking")
return
var ActionScript = load("res://addons/netfox/rewindable-action.gd")
if ActionScript == null:
push_warning("[RollbackHitscan] RewindableAction script not found")
return
_fire_action = ActionScript.new()
if _fire_action == null:
return
_fire_action.name = "HitscanFireAction"
add_child(_fire_action, true)
_has_rewindable_action = true
print("[RollbackHitscan] RewindableAction created for %s" % _player_node.name)
# ---------------------------------------------------------------------------
# Public API — called from input gathering
# ---------------------------------------------------------------------------
## Record a fire event for the given tick.
##
## Called during input gathering (before the tick) when the client presses
## shoot. Stores the precise fire origin and direction as context in the
## RewindableAction so it survives rollback re-simulation.
##
## Parameters:
## tick: The network tick when the shot occurred
## origin: World-space raycast origin (typically camera/eye position)
## direction: World-space normalized raycast direction
func record_fire(tick: int, origin: Vector3, direction: Vector3) -> void:
if _has_rewindable_action and _fire_action != null:
_fire_action.set_active(true, tick)
_fire_action.set_context({
"origin": origin,
"direction": direction,
}, tick)
# ---------------------------------------------------------------------------
# Public API — called from _rollback_tick()
# ---------------------------------------------------------------------------
## Process weapon firing for the given tick.
##
## This is the core method called from the player's _rollback_tick() every
## network tick. On the server, it performs a lag-compensated raycast and
## applies per-limb damage. On all peers, it emits signals for effects.
##
## Returns: Dictionary with hit result (empty if no hit or no shot).
## - {collider, position, normal, ...} from PhysicsDirectSpaceState3D.intersect_ray
## - empty dict if no shot was fired this tick
##
## Parameters:
## tick: The current network tick
## is_server: Whether this peer is the server (authority)
## input_node: The PlayerNetInput node (must have .shoot, .look_yaw, .look_pitch)
## weapon_data: WeaponData resource for this tick (nil = use current_weapon_data)
func process_fire(tick: int, is_server: bool, input_node: Node, weapon_data: Resource = null) -> Dictionary:
if _player_node == null:
return {}
# Use passed-in weapon data or fall back to current
var wd: Resource = weapon_data if weapon_data != null else current_weapon_data
var effective_max_dist: float = max_distance
if wd != null and wd.has_method(&"get") and wd.get("max_distance") != null:
effective_max_dist = wd.get("max_distance")
# Check if the player is trying to shoot this tick
var shoot: bool = _is_shooting(tick, input_node)
if not shoot:
return {}
# Prevent double-processing damage during re-simulation.
# On the server, we only apply damage once per tick per shot.
if is_server and _processed_damage_ticks.has(tick):
return {}
# Reconstruct fire origin and direction
var origin: Vector3
var direction: Vector3
var fire_params: Dictionary = _get_fire_params(tick, input_node, effective_max_dist)
origin = fire_params.get("origin", _player_node.global_position + Vector3(0, eye_height, 0))
direction = fire_params.get("direction", -_player_node.global_basis.z)
# Perform the physics raycast
var space_state: PhysicsDirectSpaceState3D = null
if _player_node.get_world_3d() != null:
space_state = _player_node.get_world_3d().direct_space_state
var hit: Dictionary = {}
if space_state != null:
hit = _perform_raycast(space_state, origin, direction, effective_max_dist)
# Emit fire event for effects
weapon_fired.emit(tick, origin, direction)
# Server-authoritative damage application
if is_server:
_processed_damage_ticks[tick] = true
if not hit.is_empty():
var final_damage: int = _apply_damage(hit, wd)
hit_registered.emit(tick, hit.get("position", Vector3.ZERO),
hit.get("normal", Vector3.UP), hit.get("collider"), final_damage)
else:
shot_missed.emit(tick)
return hit
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
## Check if the player is shooting this tick.
## Uses RewindableAction if available, otherwise falls back to input_node.shoot.
func _is_shooting(tick: int, input_node: Node) -> bool:
# Prefer RewindableAction for accurate per-tick tracking
if _has_rewindable_action and _fire_action != null:
if _fire_action.has_method(&"is_active"):
return _fire_action.is_active(tick)
# Fallback: check raw input
if input_node != null and input_node.has_method(&"get"):
return input_node.get(&"shoot") or false
return false
## Get fire parameters (origin, direction) for the given tick.
## Uses RewindableAction context if available, otherwise reconstructs from
## player position + camera yaw/pitch at the tick.
func _get_fire_params(tick: int, input_node: Node, max_dist: float) -> Dictionary:
# Try RewindableAction context first (most precise)
if _has_rewindable_action and _fire_action != null:
if _fire_action.has_method(&"get_context"):
var ctx = _fire_action.get_context(tick)
if ctx != null and typeof(ctx) == TYPE_DICTIONARY:
var o = ctx.get("origin")
var d = ctx.get("direction")
if o != null and d != null:
return {"origin": o, "direction": d}
# Reconstruct from player position + look angles
var yaw: float = 0.0
var pitch: float = 0.0
if input_node != null:
yaw = input_node.get(&"look_yaw") if input_node.has_method(&"get") else 0.0
pitch = input_node.get(&"look_pitch") if input_node.has_method(&"get") else 0.0
var origin := _player_node.global_position + Vector3(0, eye_height, 0)
var direction := _yaw_pitch_to_dir(yaw, pitch)
return {"origin": origin, "direction": direction}
## Perform a physics raycast with the given parameters.
func _perform_raycast(space_state: PhysicsDirectSpaceState3D, origin: Vector3,
direction: Vector3, max_dist: float) -> Dictionary:
var params = PhysicsRayQueryParameters3D.new()
if params == null:
return {}
params.from = origin
params.to = origin + direction * max_dist
params.collision_mask = 0xFFFFFFFF
# Exclude the shooter's own collision body
var exclude_list: Array[RID] = []
exclude_list.append_array(_get_player_collision_r_ids())
if exclude_list.size() > 0:
params.exclude = exclude_list
return space_state.intersect_ray(params)
## Get RIDs of collision shapes on the player node to exclude from raycast.
func _get_player_collision_r_ids() -> Array[RID]:
var rids: Array[RID] = []
if _player_node == null:
return rids
for child in _player_node.get_children():
if child is CollisionShape3D and child.shape != null:
rids.append(child.shape.get_rid())
elif child is CollisionPolygon3D:
rids.append(child.get_rid())
return rids
## Apply per-limb damage to the hit player.
## Returns the final damage amount applied.
func _apply_damage(hit: Dictionary, wd: Resource) -> int:
var collider = hit.get("collider")
if collider == null or wd == null:
return 0
var multiplier: float = _get_limb_multiplier(collider, wd)
var base_damage: int = wd.get("damage") if wd.has_method(&"get") else 0
var final_damage := int(base_damage * multiplier)
# Find the player node from the collider
var hit_player = _find_player(collider)
if hit_player == null:
return 0
if not hit_player.has_method(&"apply_damage"):
return 0
var owner_id: int = 0
if _player_node != null and _player_node.has_method(&"get"):
owner_id = _player_node.get(&"peer_id") if _player_node.get(&"peer_id") != null else 0
var weapon_id: int = wd.get("weapon_id") if wd.has_method(&"get") else 0
hit_player.apply_damage(final_damage, owner_id, weapon_id)
print("[RollbackHitscan] Tick %d: %d dmg (%d base x %.1f) to %s by peer %d" % [
Engine.get_singleton(&"NetworkTime").tick if Engine.has_singleton(&"NetworkTime") else -1,
final_damage, base_damage, multiplier, hit_player.name, owner_id])
return final_damage
## Determine the damage multiplier for the hit limb.
##
## Colliders should be in collision groups:
## "head" → headshot_multiplier
## "chest" → chest_multiplier (default)
## "waist" → waist_multiplier
## "legs" → leg_multiplier
func _get_limb_multiplier(collider: Node, wd: Resource) -> float:
if wd == null:
return 1.0
var head: float = wd.get("headshot_multiplier") if wd.has_method(&"get") else 2.0
var chest: float = wd.get("chest_multiplier") if wd.has_method(&"get") else 1.0
var waist: float = wd.get("waist_multiplier") if wd.has_method(&"get") else 0.75
var legs: float = wd.get("leg_multiplier") if wd.has_method(&"get") else 0.5
if collider.is_in_group(&"head"):
return head
elif collider.is_in_group(&"chest"):
return chest
elif collider.is_in_group(&"waist"):
return waist
elif collider.is_in_group(&"legs"):
return legs
return chest
## Walk up the scene tree from a collider to find a player node.
func _find_player(node: Node) -> Node:
var current = node
while current != null:
if current.has_method(&"apply_damage") or current.is_in_group(&"players"):
return current
current = current.get_parent()
return null
## Convert yaw (radians) and pitch (radians) to a forward direction vector.
## Uses standard FPS convention: yaw around Y axis, pitch around X axis.
static func _yaw_pitch_to_dir(yaw: float, pitch: float) -> Vector3:
var cos_pitch := cos(pitch)
return Vector3(
cos_pitch * sin(yaw),
sin(pitch),
cos_pitch * cos(yaw)
)
# ---------------------------------------------------------------------------
# State management
# ---------------------------------------------------------------------------
## Reset processed ticks history (e.g. on round restart).
## Also resets the RewindableAction state.
func reset() -> void:
_processed_damage_ticks.clear()
## Update weapon data when the player switches weapons.
func set_weapon_data(wd: Resource) -> void:
current_weapon_data = wd
if wd != null and wd.has_method(&"get"):
var md = wd.get("max_distance")
if md != null:
max_distance = md
## Get the RewindableAction node (for external inspection, e.g. debug UI).
func get_fire_action():
return _fire_action
@@ -1 +0,0 @@
uid://6gnaotpppgvd