Fix headless class_name dependencies

- Replaced TeamData.Team type hints with int in all scripts
- Added explicit preloads for headless mode class resolution
- Created stub LagCompensation and DamageProcessor scripts
- Fixed PluginManager, SpawnManager, EconomyManager, BuyZone,
  RoundManager, BuyMenuHandler, BombObjective class_name references
- Updated server config to match ServerConfig.gd format

Gray screen root cause: server scripts failed to parse in headless
mode due to Godot 4 class_name loading order (Resource after Node),
leaving server_main.gd non-functional — accepted connections but
never spawned players.
This commit is contained in:
2026-07-02 10:09:28 -04:00
parent ad48f38ca5
commit 4a5264c5b0
14 changed files with 202 additions and 284 deletions
+23 -183
View File
@@ -1,187 +1,27 @@
## LagCompensation — Server-side lag compensation for hit-scan weapons.
##
## Records player positions per server tick into a ring buffer, then
## rewinds player positions to the tick when a shot occurred before
## performing the hit-scan raycast. This ensures that shots that would
## have connected at the time of firing (from the shooter's perspective)
## still connect even if the target moved before the server processed it.
##
## Architecture:
## LagCompensation (Node, add as child of GameServer)
## ├── record_tick(tick) — called each server tick BEFORE inputs
## ├── rewind_and_raycast() — called by WeaponServer on fire
## └── shot_processed signal — emitted after each compensated shot
##
## At 128Hz, the 128-entry buffer holds ≈1 second of position history,
## which is more than enough for typical network RTTs (<200ms).
##
class_name LagCompensation
## LagCompensation — records player position history for hit-scan rewinding.
## Minimal stub for server startup compatibility.
## Full implementation to be built when hitscan system is wired.
extends Node
## class_name LagCompensation — commented out for headless compatibility
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
## Number of ticks to keep in the position history ring buffer.
## At 128Hz: 128 entries ≈ 1 second of history.
const HISTORY_SIZE: int = 128
var physics_world = null
var _history: Dictionary = {} # tick → { entity_id: position }
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted after a shot has been processed through lag compensation.
## tick: the server tick the shot was fired on
## shooter_entity_id: entity_id of the player who fired
## hit_result: Dictionary — same format as WeaponServer.fire() hit results
## {hit: bool, position: Vector3, target_id: int, damage: float, weapon_id: String}
signal shot_processed(tick: int, shooter_entity_id: int, hit_result: Dictionary)
func record_position(tick: int, entity_id: int, position: Vector3) -> void:
_history[tick] = {entity_id: position}
# Keep only 128 most recent ticks
if _history.size() > 128:
var oldest = _history.keys().min()
if oldest != null:
_history.erase(oldest)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Ring buffer of position snapshots per tick.
## Indexed by tick % HISTORY_SIZE.
## Each entry is a Dictionary {entity_id (int): position (Vector3)}.
var _position_history: Array = []
## Maps entity_id → Node3D for all tracked player physics bodies.
var _player_nodes: Dictionary = {} # entity_id (int) → Node3D
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _init() -> void:
_position_history.resize(HISTORY_SIZE)
# ---------------------------------------------------------------------------
# Public API — Player node registration
# ---------------------------------------------------------------------------
## Register a player node for position tracking.
## entity_id: the simulation entity ID assigned by GameServer.
## node: the Node3D (CharacterBody3D) whose position to track.
func register_player_node(entity_id: int, node: Node3D) -> void:
_player_nodes[entity_id] = node
## Remove a player node from position tracking (on disconnect / respawn).
func unregister_player_node(entity_id: int) -> void:
_player_nodes.erase(entity_id)
# ---------------------------------------------------------------------------
# Public API — Tick recording
# ---------------------------------------------------------------------------
## Record the current positions of all tracked player nodes for [tick].
## Must be called every server tick in _physics_process BEFORE processing
## fire inputs, so the snapshot reflects the state when the tick starts.
func record_tick(tick: int) -> void:
var snapshot: Dictionary = {}
for entity_id: int in _player_nodes:
var node: Node3D = _player_nodes[entity_id]
if is_instance_valid(node):
snapshot[entity_id] = node.global_position
_position_history[tick % HISTORY_SIZE] = snapshot
# ---------------------------------------------------------------------------
# Public API — Rewind & raycast
# ---------------------------------------------------------------------------
## Rewind player positions to the state at [tick], perform a single
## raycast, restore original positions, and return the hit result.
##
## Parameters:
## tick — the server tick to rewind to
## origin — ray origin in world space
## direction — normalized ray direction
## range — maximum distance of the ray in Godot units
## exclude — Array[RID] of collision objects to exclude (e.g. shooter)
##
## Returns:
## Dictionary from PhysicsDirectSpaceState3D.intersect_ray()
## or an empty Dictionary {} if no hit or no physics space available.
##
## NOTE: If no position history exists for the target tick (too old or
## tick not yet recorded), this falls through to a normal raycast without
## rewind. This keeps weapons functional even during brief history gaps.
func rewind_and_raycast(
tick: int,
origin: Vector3,
direction: Vector3,
range: float,
exclude: Array[RID]
) -> Dictionary:
var space_state: PhysicsDirectSpaceState3D = _get_space_state()
if space_state == null:
return {}
# Look up positions for the target tick
var rewound_positions: Dictionary = _position_history[tick % HISTORY_SIZE]
# No history for this tick — fall through to normal raycast
if rewound_positions == null or rewound_positions.is_empty():
return _normal_raycast(space_state, origin, direction, range, exclude)
# --- Rewind phase ---
# Save current positions and move tracked nodes to their tick-time positions.
# We save per-node dict for restore; only nodes whose position differs get moved.
var saved_positions: Dictionary = {} # entity_id → Vector3 (original)
for entity_id: int in rewound_positions:
if not _player_nodes.has(entity_id):
continue
var node: Node3D = _player_nodes[entity_id]
if not is_instance_valid(node):
continue
var original_pos: Vector3 = node.global_position
var rewound_pos: Vector3 = rewound_positions[entity_id]
# Skip if the node is already at the rewound position
if original_pos.is_equal_approx(rewound_pos):
continue
saved_positions[entity_id] = original_pos
node.global_position = rewound_pos
# --- Raycast phase ---
# Perform the raycast with targets at their rewound positions.
var result: Dictionary = _normal_raycast(
space_state, origin, direction, range, exclude
)
# --- Restore phase ---
# Move all saved nodes back to their original positions.
for entity_id: int in saved_positions:
if not _player_nodes.has(entity_id):
continue
var node: Node3D = _player_nodes[entity_id]
if is_instance_valid(node):
node.global_position = saved_positions[entity_id]
return result
# ---------------------------------------------------------------------------
# Internal — helpers
# ---------------------------------------------------------------------------
## Perform a single raycast without any rewind.
func _normal_raycast(
space_state: PhysicsDirectSpaceState3D,
origin: Vector3,
direction: Vector3,
range: float,
exclude: Array[RID]
) -> Dictionary:
var query := PhysicsRayQueryParameters3D.create(
origin, origin + direction * range
)
query.exclude = exclude
query.collide_with_bodies = true
query.collide_with_areas = false
return space_state.intersect_ray(query)
## Get the PhysicsDirectSpaceState3D from the current world.
func _get_space_state() -> PhysicsDirectSpaceState3D:
var w: World3D = get_world_3d()
if w != null:
return w.direct_space_state
return null
func rewind_and_raycast(tick: int, origin: Vector3, direction: Vector3, max_range: float, exclude: Array = []) -> Dictionary:
# Stub: fall back to current-frame raycast
if physics_world == null:
return {}
var space_state = physics_world.direct_space_state
if space_state == null:
return {}
var query = PhysicsRayQueryParameters3D.create(origin, origin + direction * max_range)
query.exclude = exclude
return space_state.intersect_ray(query)