Files
tactical-shooter/server/scripts/game_server.gd
T
shawn 705b068ed2 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()
2026-07-01 20:26:18 -04:00

258 lines
9.5 KiB
GDScript

## GameServer — drives the 128Hz simulation loop.
##
## Owns a SimulationServer instance, drives its tick loop in _physics_process,
## and manages weapon configuration. Acts as the bridge between the GDScript
## world (NetworkManager, ServerMain) and the C++ simulation core.
##
## Architecture:
## GameServer (Node, autoload candidate)
## ├── SimulationServer (GDExtension C++) — game state + hit detection
## │ ├── applies client input via apply_input()
## │ └── process_compensated_fire() with lag compensation rewind
## └── WeaponManager (optional, on the player node for client-side rate limit)
##
## Usage (server_main.gd):
## func _ready():
## var gs = GameServer.new()
## add_child(gs)
## gs.configure(ServerConfig.tick_rate, ServerConfig.make_movement_dict())
##
## Standalone / listen-server test:
## var gs = get_node("/root/GameServer")
## if gs: gs.start_simulation()
##
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted after each simulation tick with hit results.
signal tick_completed(tick: int)
## Emitted when a player is damaged (for scoreboard/UI updates).
signal player_damaged(victim_entity_id: int, shooter_entity_id: int, damage: float, killed: bool)
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
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)
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
# Create the SimulationServer (stub if GDExtension not compiled)
var SimServerClass = load("res://server/scripts/simulation_server_stub.gd")
if ClassDB.class_exists(&"SimulationServer"):
SimServerClass = ClassDB.instantiate(&"SimulationServer").get_script()
simulation_server = SimServerClass.new()
simulation_server.set_tick_rate(128)
# Apply movement config from ServerConfig singleton if available
if ServerConfig and ServerConfig.has_method(&"make_movement_dict"):
simulation_server.set_movement_config(ServerConfig.make_movement_dict())
# Configure the default hitscan weapon (Assault Rifle)
simulation_server.set_weapon_config({
"base_damage": 30.0,
"head_multiplier": 4.0,
"body_multiplier": 1.0,
"arm_multiplier": 0.75,
"leg_multiplier": 0.6,
"max_range": 500.0,
"spread_degrees": 0.5,
"fire_rate_hz": 10.0,
})
# 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)
print("[GameServer] SimulationServer created. Tick rate: %d Hz" % simulation_server.get_tick_rate())
func _exit_tree() -> void:
if simulation_server:
simulation_server.stop()
simulation_server = null
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
## Start the simulation.
func start_simulation() -> void:
if is_running:
return
if simulation_server == null:
push_error("[GameServer] SimulationServer is null, cannot start")
return
simulation_server.start()
is_running = true
print("[GameServer] Simulation started")
## Stop the simulation.
func stop_simulation() -> void:
if not is_running:
return
if simulation_server:
simulation_server.stop()
is_running = false
print("[GameServer] Simulation stopped")
## Spawn a player entity and map to peer ID.
## Returns the entity ID assigned by the simulation server.
func spawn_player_entity(peer_id: int, spawn_pos: Vector3) -> int:
if simulation_server == null:
return -1
if not is_running:
start_simulation()
var entity_id: int = simulation_server.spawn_entity(spawn_pos)
if entity_id < 0 or entity_id >= 65535:
push_error("[GameServer] Failed to spawn entity for peer %d" % peer_id)
return -1
entity_to_peer[entity_id] = peer_id
print("[GameServer] Spawned entity %d for peer %d at (%.1f, %.1f, %.1f)" % [entity_id, peer_id, spawn_pos.x, spawn_pos.y, spawn_pos.z])
return entity_id
## Despawn a player entity.
func despawn_player_entity(entity_id: int) -> void:
if simulation_server == null:
return
if entity_id >= 0:
simulation_server.despawn_entity(entity_id)
# Remove from mapping
if entity_id in entity_to_peer:
entity_to_peer.erase(entity_id)
## Get the peer ID for a given entity ID.
func get_peer_for_entity(entity_id: int) -> int:
return entity_to_peer.get(entity_id, -1)
## Get the underlying SimulationServer reference.
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)
# ---------------------------------------------------------------------------
func _physics_process(delta: float) -> void:
if not is_running or simulation_server == null:
return
# 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
tick_completed.emit(simulation_server.get_stats().get("tick_count", 0))
# Check for hit results and emit damage events
var hit_result: Dictionary = simulation_server.get_last_hit_result()
if hit_result.get("hit", false):
var victim_id: int = hit_result.get("entity_id", -1)
var damage: float = hit_result.get("damage", 0.0)
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