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,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()
|
||||||
@@ -171,6 +171,10 @@ func _spawn_player(peer_id: int) -> void:
|
|||||||
if entity_id >= 0:
|
if entity_id >= 0:
|
||||||
# Store entity_id on player node for reference
|
# Store entity_id on player node for reference
|
||||||
player.set_meta(&"entity_id", entity_id)
|
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])
|
print("[ServerMain] Player %d assigned entity %d" % [peer_id, entity_id])
|
||||||
|
|
||||||
# Broadcast spawn to all clients so they create a visual player node
|
# 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
|
# Despawn entity from GameServer
|
||||||
if _game_server != null and player.has_meta(&"entity_id"):
|
if _game_server != null and player.has_meta(&"entity_id"):
|
||||||
var entity_id: int = player.get_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)
|
_game_server.despawn_player_entity(entity_id)
|
||||||
|
|
||||||
player.queue_free()
|
player.queue_free()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -40,6 +40,18 @@ signal player_damaged(victim_entity_id: int, shooter_entity_id: int, damage: flo
|
|||||||
var simulation_server: Object = null
|
var simulation_server: Object = null
|
||||||
var is_running: bool = false
|
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
|
# Map entity_id → peer_id for broadcasting damage events
|
||||||
var entity_to_peer: Dictionary = {} # entity_id (int) → peer_id (int)
|
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)
|
# Set history depth for lag compensation (64 ticks = ~500ms at 128Hz)
|
||||||
simulation_server.set_history_depth(64)
|
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
|
# Register as singleton so FPSCharacterController can find us
|
||||||
Engine.register_singleton("SimulationServer", simulation_server)
|
Engine.register_singleton("SimulationServer", simulation_server)
|
||||||
|
|
||||||
@@ -147,11 +182,48 @@ func get_peer_for_entity(entity_id: int) -> int:
|
|||||||
func get_simulation_server() -> Object:
|
func get_simulation_server() -> Object:
|
||||||
return simulation_server
|
return simulation_server
|
||||||
|
|
||||||
|
## Get the current server tick counter.
|
||||||
|
func get_current_tick() -> int:
|
||||||
|
return _current_tick
|
||||||
|
|
||||||
## Configure weapon damage profile.
|
## Configure weapon damage profile.
|
||||||
func set_weapon(config: Dictionary) -> void:
|
func set_weapon(config: Dictionary) -> void:
|
||||||
if simulation_server:
|
if simulation_server:
|
||||||
simulation_server.set_weapon_config(config)
|
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)
|
# Main loop (128 Hz)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -162,6 +234,12 @@ func _physics_process(delta: float) -> void:
|
|||||||
|
|
||||||
# Drive the fixed-timestep simulation loop
|
# Drive the fixed-timestep simulation loop
|
||||||
while simulation_server.can_tick(delta):
|
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()
|
var snapshot: PackedByteArray = simulation_server.tick()
|
||||||
# snapshot is the serialized state — send to network layer in Phase 2
|
# snapshot is the serialized state — send to network layer in Phase 2
|
||||||
# For now, just emit the tick completed signal
|
# 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)
|
var killed: bool = hit_result.get("killed", false)
|
||||||
# For now we don't know the shooter — this will be wired in Phase 2
|
# For now we don't know the shooter — this will be wired in Phase 2
|
||||||
player_damaged.emit(victim_id, -1, damage, killed)
|
player_damaged.emit(victim_id, -1, damage, killed)
|
||||||
|
|
||||||
|
_current_tick += 1
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ var _state: Dictionary = {}
|
|||||||
## Set automatically if the node enters the tree; can also be injected.
|
## Set automatically if the node enters the tree; can also be injected.
|
||||||
var physics_world: World3D = null
|
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
|
# 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.
|
## 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_result = {
|
||||||
## hit: bool,
|
## hit: bool,
|
||||||
## position: Vector3, # world-space hit point
|
## 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
|
## 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)
|
var data := WeaponDefinitions.get_weapon(weapon_id)
|
||||||
if data == null:
|
if data == null:
|
||||||
return _miss_result(weapon_id)
|
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
|
st["reload_remaining"] = data.reload_time
|
||||||
|
|
||||||
# Perform hit-scan — supports multi-pellet weapons
|
# 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
|
# Internal: hitscan
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _perform_hitscan(
|
func _perform_hitscan(
|
||||||
|
tick: int,
|
||||||
player_id: int,
|
player_id: int,
|
||||||
weapon_id: String,
|
weapon_id: String,
|
||||||
data: WeaponData,
|
data: WeaponData,
|
||||||
@@ -273,12 +282,26 @@ func _perform_hitscan(
|
|||||||
|
|
||||||
for i in range(pellets):
|
for i in range(pellets):
|
||||||
var dir: Vector3 = _apply_spread(base_direction, spread_rad)
|
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():
|
if result.is_empty():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user