From 705b068ed2a2bbd96e8e4d8b80b44e1388c1517d Mon Sep 17 00:00:00 2001 From: Shawn Date: Wed, 1 Jul 2026 20:26:18 -0400 Subject: [PATCH] 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() --- scripts/combat/damage_processor.gd | 152 ++++++++++++++++++ scripts/network/server_main.gd | 7 + server/scripts/combat/lag_compensation.gd | 187 ++++++++++++++++++++++ server/scripts/game_server.gd | 80 +++++++++ server/scripts/weapons/weapon_server.gd | 39 ++++- 5 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 scripts/combat/damage_processor.gd create mode 100644 server/scripts/combat/lag_compensation.gd diff --git a/scripts/combat/damage_processor.gd b/scripts/combat/damage_processor.gd new file mode 100644 index 0000000..e038ddf --- /dev/null +++ b/scripts/combat/damage_processor.gd @@ -0,0 +1,152 @@ +## 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() diff --git a/scripts/network/server_main.gd b/scripts/network/server_main.gd index 49b8dc8..caf24f3 100644 --- a/scripts/network/server_main.gd +++ b/scripts/network/server_main.gd @@ -171,6 +171,10 @@ func _spawn_player(peer_id: int) -> void: 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 @@ -185,6 +189,9 @@ func _despawn_player(peer_id: int) -> void: # 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() diff --git a/server/scripts/combat/lag_compensation.gd b/server/scripts/combat/lag_compensation.gd new file mode 100644 index 0000000..dfbdb86 --- /dev/null +++ b/server/scripts/combat/lag_compensation.gd @@ -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 diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index 25a520b..0a055de 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -40,6 +40,18 @@ signal player_damaged(victim_entity_id: int, shooter_entity_id: int, damage: flo var simulation_server: Object = null var is_running: bool = false +## WeaponServer — handles hit-scan raycasting and lag-compensated fire. +var weapon_server: WeaponServer = null + +## LagCompensation — records player positions per tick for rewind raycasts. +var lag_compensation: LagCompensation = null + +## DamageProcessor — applies hit results, tracks health and kills. +var damage_processor: DamageProcessor = null + +## Current server tick counter, incremented each time tick() is called. +var _current_tick: int = 0 + # Map entity_id → peer_id for broadcasting damage events var entity_to_peer: Dictionary = {} # entity_id (int) → peer_id (int) @@ -74,6 +86,29 @@ func _ready() -> void: # Set history depth for lag compensation (64 ticks = ~500ms at 128Hz) simulation_server.set_history_depth(64) + # --- Combat subsystems (GDScript lag-compensated hit-scan) --- + # Create and wire the WeaponServer with LagCompensation and DamageProcessor. + # These work alongside the C++ SimulationServer for the GDScript + # weapon path. + weapon_server = WeaponServer.new() + weapon_server.physics_world = get_world_3d() + add_child(weapon_server) + + lag_compensation = LagCompensation.new() + add_child(lag_compensation) + + damage_processor = DamageProcessor.new() + add_child(damage_processor) + + # Wire lag compensation into WeaponServer + weapon_server.lag_compensation = lag_compensation + + # Connect damage processor signals to GameServer signals for upstream relay + damage_processor.player_killed.connect(_on_player_killed) + + _current_tick = 0 + print("[GameServer] Combat subsystems ready: WeaponServer + LagCompensation + DamageProcessor") + # Register as singleton so FPSCharacterController can find us Engine.register_singleton("SimulationServer", simulation_server) @@ -147,11 +182,48 @@ func get_peer_for_entity(entity_id: int) -> int: func get_simulation_server() -> Object: return simulation_server +## Get the current server tick counter. +func get_current_tick() -> int: + return _current_tick + ## Configure weapon damage profile. func set_weapon(config: Dictionary) -> void: if simulation_server: simulation_server.set_weapon_config(config) +# --------------------------------------------------------------------------- +# Combat subsystem — player registration +# --------------------------------------------------------------------------- + +## Register a player node with LagCompensation and DamageProcessor. +## Call this after spawning a player entity and assigning its entity_id. +## entity_id: the simulation entity ID assigned by spawn_player_entity() +## node: the player's Node3D (CharacterBody3D) in the scene tree +## max_health: starting/maximum health for damage tracking +func register_player_node(entity_id: int, node: Node3D, max_health: float = 100.0) -> void: + if lag_compensation: + lag_compensation.register_player_node(entity_id, node) + if damage_processor: + damage_processor.register_player(entity_id, max_health) + print("[GameServer] Registered player node entity=%d with lag compensation + damage processor" % entity_id) + +## Unregister a player node from LagCompensation and DamageProcessor. +func unregister_player_node(entity_id: int) -> void: + if lag_compensation: + lag_compensation.unregister_player_node(entity_id) + if damage_processor: + damage_processor.unregister_player(entity_id) + print("[GameServer] Unregistered player node entity=%d" % entity_id) + +# --------------------------------------------------------------------------- +# Combat subsystem — kill handling +# --------------------------------------------------------------------------- +func _on_player_killed(victim_id: int, shooter_id: int) -> void: + # Relay the kill event upstream with the shooter's peer_id if available + var shooter_peer: int = entity_to_peer.get(shooter_id, -1) + player_damaged.emit(victim_id, shooter_id, 0.0, true) + print("[GameServer] Kill: victim_entity=%d, shooter_entity=%d (peer=%d)" % [victim_id, shooter_id, shooter_peer]) + # --------------------------------------------------------------------------- # Main loop (128 Hz) # --------------------------------------------------------------------------- @@ -162,6 +234,12 @@ func _physics_process(delta: float) -> void: # Drive the fixed-timestep simulation loop while simulation_server.can_tick(delta): + # Record positions for lag compensation BEFORE processing this tick's inputs. + # This captures the state at the start of the tick, so rewind_and_raycast + # can restore players to their pre-movement positions. + if lag_compensation: + lag_compensation.record_tick(_current_tick) + var snapshot: PackedByteArray = simulation_server.tick() # snapshot is the serialized state — send to network layer in Phase 2 # For now, just emit the tick completed signal @@ -175,3 +253,5 @@ func _physics_process(delta: float) -> void: var killed: bool = hit_result.get("killed", false) # For now we don't know the shooter — this will be wired in Phase 2 player_damaged.emit(victim_id, -1, damage, killed) + + _current_tick += 1 diff --git a/server/scripts/weapons/weapon_server.gd b/server/scripts/weapons/weapon_server.gd index 861683e..e8300a6 100644 --- a/server/scripts/weapons/weapon_server.gd +++ b/server/scripts/weapons/weapon_server.gd @@ -43,6 +43,11 @@ var _state: Dictionary = {} ## Set automatically if the node enters the tree; can also be injected. var physics_world: World3D = null +## Optional LagCompensation controller for server-side rewinding. +## When set, _perform_hitscan uses lag-compensated raycasting; +## otherwise it uses the current-frame physics world directly. +var lag_compensation: LagCompensation = null + # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- @@ -208,8 +213,11 @@ func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void # --------------------------------------------------------------------------- ## Fire the weapon from the given origin along direction. -## Performs a hit-scan raycast through the physics world. +## Performs a hit-scan raycast through the physics world with optional +## server-side lag compensation rewinding. ## +## tick: server tick when the shot occurred (used for lag compensation). +## Pass -1 to skip lag compensation and use current-frame physics. ## hit_result = { ## hit: bool, ## position: Vector3, # world-space hit point @@ -218,7 +226,7 @@ func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void ## weapon_id: String, # the weapon used ## } ## -func fire(player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary: +func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary: var data := WeaponDefinitions.get_weapon(weapon_id) if data == null: return _miss_result(weapon_id) @@ -241,13 +249,14 @@ func fire(player_id: int, weapon_id: String, origin: Vector3, direction: Vector3 st["reload_remaining"] = data.reload_time # Perform hit-scan — supports multi-pellet weapons - return _perform_hitscan(player_id, weapon_id, data, origin, direction, st) + return _perform_hitscan(tick, player_id, weapon_id, data, origin, direction, st) # --------------------------------------------------------------------------- # Internal: hitscan # --------------------------------------------------------------------------- func _perform_hitscan( + tick: int, player_id: int, weapon_id: String, data: WeaponData, @@ -273,12 +282,26 @@ func _perform_hitscan( for i in range(pellets): var dir: Vector3 = _apply_spread(base_direction, spread_rad) - var query := PhysicsRayQueryParameters3D.create(origin, origin + dir * max_range) - query.exclude = exclude - query.collide_with_bodies = true - query.collide_with_areas = false - var result: Dictionary = space_state.intersect_ray(query) + # --- Lag-compensated raycast --- + # If lag_compensation is available and a valid tick is provided, + # rewind to the target tick before raycasting. Otherwise fall + # back to the current-frame physics world. + var result: Dictionary = {} + if lag_compensation != null and tick >= 0: + result = lag_compensation.rewind_and_raycast( + tick, origin, dir, max_range, exclude + ) + else: + # Fallback: no lag compensation — raycast against current positions + var query := PhysicsRayQueryParameters3D.create( + origin, origin + dir * max_range + ) + query.exclude = exclude + query.collide_with_bodies = true + query.collide_with_areas = false + result = space_state.intersect_ray(query) + if result.is_empty(): continue