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:
@@ -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 +0,0 @@
|
||||
uid://b6rixkmc2v0lt
|
||||
@@ -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.0–1.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 +0,0 @@
|
||||
uid://ci55pc8ou2iuq
|
||||
@@ -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 +0,0 @@
|
||||
uid://bn4sdresufqbo
|
||||
@@ -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 +0,0 @@
|
||||
uid://dktmtadg8o0qa
|
||||
@@ -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 +0,0 @@
|
||||
uid://bn01mhwtitvyf
|
||||
@@ -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 +0,0 @@
|
||||
uid://21c1sct06141
|
||||
@@ -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 (10–100 MB depending on texture resolution).
|
||||
Consider:
|
||||
|
||||
```gdscript
|
||||
# Check cache size
|
||||
var total_size := 0
|
||||
for name in MapDownloader.get_cached_maps():
|
||||
var info = MapDownloader.get_cached_map_info(name)
|
||||
total_size += info.get("size", 0)
|
||||
print("Cache size: %.1f MB" % (total_size / 1048576.0))
|
||||
|
||||
# Remove least-recently-downloaded maps if over budget
|
||||
const MAX_CACHE_MB := 500
|
||||
if total_size > MAX_CACHE_MB * 1048576:
|
||||
var cached = MapDownloader.get_cached_maps()
|
||||
for name in cached:
|
||||
MapDownloader.remove_map(name)
|
||||
break # remove one at a time
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Security Considerations
|
||||
|
||||
- **.pck files cannot execute scripts** that weren't already in the game binary.
|
||||
Resource packs only add data (scenes, textures, meshes). Game logic lives
|
||||
in the binary and cannot be injected via a .pck.
|
||||
|
||||
- **Checksum verification**: The server provides SHA-256 checksums. The
|
||||
client can verify integrity before loading:
|
||||
|
||||
```gdscript
|
||||
func verify_map(map_name: String) -> bool:
|
||||
var pck_path = "user://maps/%s.pck" % map_name
|
||||
var global_path = ProjectSettings.globalize_path(pck_path)
|
||||
var f = FileAccess.open(global_path, FileAccess.READ)
|
||||
if not f:
|
||||
return false
|
||||
|
||||
var ctx = HashingContext.new()
|
||||
ctx.start(HashingContext.HASH_SHA256)
|
||||
while f.get_position() < f.get_length():
|
||||
ctx.update(f.get_buffer(65536))
|
||||
var checksum = ctx.finish().hex_encode()
|
||||
f.close()
|
||||
|
||||
# Compare with server checksum
|
||||
var meta = MapDownloader.get_cached_map_info(map_name)
|
||||
var expected = meta.get("checksum_sha256", "")
|
||||
return expected.is_empty() or checksum == expected
|
||||
```
|
||||
|
||||
- **HTTPS**: Deploy the registry server behind an NPMplus/nginx proxy with
|
||||
SSL termination. The client supports HTTPS natively via HTTPRequest.
|
||||
|
||||
---
|
||||
|
||||
## 7. Workflow Summary
|
||||
|
||||
### For Map Creators
|
||||
|
||||
```
|
||||
1. Open map_template/ in Godot 4
|
||||
2. Build map using CSG prefabs (scenes/maps/<name>.tscn)
|
||||
3. Run: godot --headless --script scripts/map_packaging/pack_map.gd --map=res://scenes/maps/<name>.tscn
|
||||
4. Upload output/user://packed_maps/<name>.pck to registry server's maps directory
|
||||
```
|
||||
|
||||
### For Server Operators
|
||||
|
||||
```
|
||||
1. Deploy map_registry_server.py on your backend (or alongside the game server)
|
||||
2. Copy .pck files into the maps directory
|
||||
3. SIGHUP the server to rescan (or it auto-scans every 5 seconds)
|
||||
4. Configure [map_registry] in server_config.cfg
|
||||
5. Game clients auto-download on connect
|
||||
```
|
||||
|
||||
### For Developers
|
||||
|
||||
```
|
||||
1. Add MapDownloader as project autoload
|
||||
2. On server start, call MapDownloader.fetch_map_list()
|
||||
3. Before loading a map scene, call MapDownloader.load_map() if it's a community map
|
||||
4. Handle download signals for progress UI
|
||||
```
|
||||
@@ -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()
|
||||
@@ -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 +0,0 @@
|
||||
uid://dskw00g1p1ghe
|
||||
@@ -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 +0,0 @@
|
||||
uid://blpy6rdyfy2b1
|
||||
@@ -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 +0,0 @@
|
||||
uid://b4yrh2xnjfrch
|
||||
@@ -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 +0,0 @@
|
||||
uid://duwmyvcdn55qe
|
||||
@@ -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 +0,0 @@
|
||||
uid://cwipcjgj8cyym
|
||||
@@ -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 +0,0 @@
|
||||
uid://crq322sfr42al
|
||||
@@ -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 +0,0 @@
|
||||
uid://c5b3teqp6yha
|
||||
@@ -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
|
||||
@@ -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 +0,0 @@
|
||||
uid://syopmnypsnfo
|
||||
@@ -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 +0,0 @@
|
||||
uid://dtwj2vairvxnt
|
||||
@@ -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 +0,0 @@
|
||||
uid://vxt3rh3f2j35
|
||||
@@ -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 +0,0 @@
|
||||
uid://dixk0c0uniyp6
|
||||
@@ -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 +0,0 @@
|
||||
uid://w0y1tql3o5vd
|
||||
@@ -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 +0,0 @@
|
||||
uid://b1hh1yrejn46f
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user