t_p1_hitscan: Add lag-compensated hitscan weapon system
NEW: server/scripts/combat/lag_compensation.gd
- 128-entry ring buffer of player positions per server tick
- rewind_and_raycast(tick, origin, direction, range, exclude):
saves current positions, rewinds to tick-time positions,
performs PhysicsDirectSpaceState3D.intersect_ray(), restores
- Falls back to normal raycast if history missing for tick
- shot_processed signal
NEW: scripts/combat/damage_processor.gd
- Processes WeaponServer hit results: applies damage, tracks kills
- Optional distance-based damage falloff
- player_damaged / player_killed signals
- Health and kill-count queries
MODIFY: server/scripts/weapons/weapon_server.gd
- fire() now takes tick parameter: fire(tick, player_id, ...)
- _perform_hitscan() uses LagCompensation.rewind_and_raycast()
when lag_compensation reference is set and tick >= 0
- Non-compensated fallback path preserved
MODIFY: server/scripts/game_server.gd
- Adds _current_tick counter, incremented each simulation tick
- Creates WeaponServer + LagCompensation + DamageProcessor as children
- record_tick() called before each tick's simulation
- register/unregister_player_node() for player lifecycle
- _on_player_killed() relays kill events via player_damaged signal
MODIFY: scripts/network/server_main.gd
- _spawn_player() registers node with GameServer.register_player_node()
- _despawn_player() unregisters via unregister_player_node()
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
## 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
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
Reference in New Issue
Block a user