926446e5cf
- FPSCharacterController: removed SimulationServer dependency, added _rollback_tick() with acceleration-based CharacterBody3D movement, gravity, jump, crouch/sprint speed. Kept mouse look, crouch lerp, sprint/crouch toggle, _move_local() standalone fallback. - Player: extends FPSCharacterController (CharacterBody3D). Added authority-guarded _rollback_tick that delegates to super for server and local client prediction. TickInterpolator created dynamically (avoids headless class_name parse errors). PlayerNetInput child wired via existing RollbackSynchronizer input_properties. - player.tscn: CharacterBody3D root with FpsCamera, CollisionShape3D, PlayerNetInput children. TickInterpolator created in code. - client_main.gd: _spawn_local_player() creates FPS player node with first-person camera for own peer. _spawn_remote_player() removes FpsCamera for remote players. - fps_camera.gd: duck-typed _controller (Node) instead of class_name cast for headless safety. Fixed type inference warnings. Also includes parent task P7.5-P7.7 changes: - project.godot: main_scene -> server_main.tscn - network_manager.gd: Chan enum -> raw channel ints - server_main.gd: deferred ServerConfig load, GameServer load() - game_server.gd: commented out dev deps for headless compilation
481 lines
18 KiB
GDScript
481 lines
18 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
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Force class_name script dependencies to load first
|
|
# (Godot 4 headless mode doesn't resolve global classes in dependency order)
|
|
#
|
|
# NOTE: these are commented out for P7.5 build — they reference development
|
|
# systems (weapons, economy, objectives, teams) that aren't part of this
|
|
# phase. When those systems are ready, uncomment the relevant preloads.
|
|
# ---------------------------------------------------------------------------
|
|
#const _ws = preload("res://server/scripts/weapons/weapon_server.gd")
|
|
#const _lc = preload("res://server/scripts/combat/lag_compensation.gd")
|
|
#const _dp = preload("res://server/scripts/combat/damage_processor.gd")
|
|
#const _tm = preload("res://scripts/teams/team_manager.gd")
|
|
#const _rm = preload("res://server/scripts/round/round_manager.gd")
|
|
#const _em = preload("res://server/scripts/economy/economy_manager.gd")
|
|
#const _bm = preload("res://server/scripts/economy/buy_menu_handler.gd")
|
|
#const _bo = preload("res://server/scripts/objectives/bomb_objective.gd")
|
|
#const _sm = preload("res://scripts/teams/spawn_manager.gd")
|
|
#const _td = preload("res://scripts/teams/team_data.gd")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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 = null
|
|
|
|
## LagCompensation — records player positions per tick for rewind raycasts.
|
|
var lag_compensation = null
|
|
|
|
## DamageProcessor — applies hit results, tracks health and kills.
|
|
var damage_processor = null
|
|
|
|
## TeamManager — manages team assignment and scoring.
|
|
var team_manager = null
|
|
|
|
## RoundManager — match lifecycle (warmup → prep → live → post).
|
|
var round_manager = null
|
|
|
|
## EconomyManager — per-player money tracking and earnings.
|
|
var economy_manager = null
|
|
|
|
## BuyMenuHandler — server-side buy request validation and processing.
|
|
var buy_menu_handler = null
|
|
|
|
## BombObjective — server-authoritative bomb plant/defuse logic.
|
|
var bomb_objective = null
|
|
|
|
## TeamData reference — cached with load() to avoid class_name resolution in headless.
|
|
var _td = 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)
|
|
|
|
# Map peer_id → entity_id for backwards lookup
|
|
var peer_to_entity: Dictionary = {} # peer_id (int) → entity_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 = _ws.new()
|
|
weapon_server.physics_world = get_viewport().get_world_3d()
|
|
add_child(weapon_server)
|
|
|
|
lag_compensation = _lc.new()
|
|
add_child(lag_compensation)
|
|
|
|
damage_processor = _dp.new()
|
|
add_child(damage_processor)
|
|
|
|
# --- Round / Match lifecycle ---
|
|
round_manager = _rm.new()
|
|
add_child(round_manager)
|
|
|
|
# Wire up TeamManager reference (find it in the tree or create it)
|
|
team_manager = get_node_or_null("/root/TeamManager")
|
|
if not team_manager:
|
|
team_manager = _tm.new()
|
|
add_child(team_manager)
|
|
|
|
round_manager.team_manager = team_manager
|
|
round_manager.damage_processor = damage_processor
|
|
|
|
# --- Economy system ---
|
|
economy_manager = _em.new()
|
|
add_child(economy_manager)
|
|
|
|
buy_menu_handler = _bm.new()
|
|
add_child(buy_menu_handler)
|
|
buy_menu_handler.initialise(economy_manager, weapon_server, team_manager)
|
|
|
|
# Wire kill rewards: when DamageProcessor detects a kill, award money to the shooter
|
|
# Note: shooter_id is an entity_id; we map it to peer_id for economy lookup.
|
|
damage_processor.player_killed.connect(_on_kill_for_economy)
|
|
|
|
# --- RCON command handling ---
|
|
var rcon_handler = get_node_or_null("/root/RconServer/RconCommandHandler")
|
|
if rcon_handler and rcon_handler.has_signal("rcon_command"):
|
|
rcon_handler.rcon_command.connect(_on_rcon_command)
|
|
print("[GameServer] Connected to RCON command handler")
|
|
else:
|
|
print("[GameServer] RCON handler not available — commands won't be routed")
|
|
|
|
# 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)
|
|
|
|
# Connect damage processor kills to RoundManager for round tracking and elimination
|
|
damage_processor.player_killed.connect(_on_kill_for_round)
|
|
|
|
_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)
|
|
|
|
# --- Bomb / Defuse Objective ---
|
|
bomb_objective = _bo.new()
|
|
add_child(bomb_objective)
|
|
bomb_objective.round_manager = round_manager
|
|
bomb_objective.team_manager = team_manager
|
|
bomb_objective.damage_processor = damage_processor
|
|
bomb_objective.entity_to_peer = entity_to_peer
|
|
bomb_objective.peer_to_entity = peer_to_entity
|
|
|
|
# Register bomb sites from the scene tree
|
|
if is_inside_tree():
|
|
var bomb_sites: Array[Area3D] = []
|
|
for n in get_tree().get_nodes_in_group("bomb_sites"):
|
|
if n is Area3D:
|
|
bomb_sites.append(n)
|
|
if bomb_sites.size() > 0:
|
|
bomb_objective.register_bomb_sites(bomb_sites)
|
|
print("[GameServer] Registered %d bomb sites with BombObjective" % bomb_sites.size())
|
|
else:
|
|
print("[GameServer] No bomb sites found in scene — bomb can't be planted")
|
|
else:
|
|
print("[GameServer] Not in scene tree yet — bomb sites will be registered later")
|
|
|
|
# Wire: bomb explosion/defuse → round end
|
|
bomb_objective.bomb_exploded.connect(func(_pos):
|
|
if round_manager:
|
|
round_manager.end_round(2, "bomb_exploded") # TeamData.Team.TERRORIST
|
|
)
|
|
bomb_objective.bomb_defused.connect(func(_player_id):
|
|
if round_manager:
|
|
round_manager.end_round(1, "bomb_defused") # TeamData.Team.COUNTER_TERRORIST
|
|
)
|
|
|
|
# Wire: round end → reset bomb
|
|
round_manager.round_ended.connect(func(_winning_team, _reason):
|
|
bomb_objective.reset()
|
|
)
|
|
|
|
print("[GameServer] BombObjective integrated — bomb/defuse ready")
|
|
|
|
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
|
|
peer_to_entity[peer_id] = entity_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 mappings
|
|
if entity_id in entity_to_peer:
|
|
var pid: int = entity_to_peer[entity_id]
|
|
peer_to_entity.erase(pid)
|
|
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)
|
|
# Register peer ID for economy tracking
|
|
var peer_id: int = entity_to_peer.get(entity_id, -1)
|
|
if peer_id >= 0 and economy_manager:
|
|
economy_manager.register_player(peer_id)
|
|
print("[GameServer] Registered player peer=%d with economy manager" % peer_id)
|
|
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)
|
|
# Unregister from economy tracking
|
|
var peer_id: int = entity_to_peer.get(entity_id, -1)
|
|
if peer_id >= 0 and economy_manager:
|
|
economy_manager.unregister_player(peer_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])
|
|
|
|
## Economy kill reward — award money to the shooter when they get a kill.
|
|
## Connected from DamageProcessor.player_killed in _ready().
|
|
func _on_kill_for_economy(victim_id: int, shooter_id: int) -> void:
|
|
if economy_manager == null:
|
|
return
|
|
# Map entity_id → peer_id for economy lookup
|
|
var shooter_peer: int = entity_to_peer.get(shooter_id, -1)
|
|
if shooter_peer < 0:
|
|
return
|
|
economy_manager.award_kill_reward(shooter_peer)
|
|
|
|
## Round-level kill handler — tracks kills for round stats and checks elimination.
|
|
## Connected from DamageProcessor.player_killed.
|
|
func _on_kill_for_round(victim_id: int, shooter_id: int) -> void:
|
|
# Forward to RoundManager for kill tracking
|
|
if round_manager:
|
|
round_manager.on_player_killed(victim_id, shooter_id)
|
|
|
|
# Check for team elimination using our entity→peer→team mapping
|
|
if not round_manager or not round_manager.team_manager:
|
|
return
|
|
|
|
# Get teams for victim and shooter
|
|
var victim_peer: int = entity_to_peer.get(victim_id, -1)
|
|
var shooter_peer: int = entity_to_peer.get(shooter_id, -1)
|
|
|
|
if victim_peer < 0 or shooter_peer < 0:
|
|
return
|
|
|
|
var victim_team: int = round_manager.team_manager.get_player_team(victim_peer)
|
|
var shooter_team: int = round_manager.team_manager.get_player_team(shooter_peer)
|
|
|
|
if victim_team < 0 or shooter_team < 0 or victim_team == shooter_team:
|
|
return
|
|
|
|
# Check if all players on victim's team are dead
|
|
var all_dead: bool = true
|
|
var team_player_ids: Array[int] = round_manager.team_manager.get_team_players(victim_team)
|
|
|
|
for pid in team_player_ids:
|
|
var eid: int = peer_to_entity.get(pid, -1)
|
|
if eid < 0:
|
|
continue
|
|
var health: float = damage_processor.get_health(eid) if damage_processor else -1.0
|
|
if health > 0.0:
|
|
all_dead = false
|
|
break
|
|
|
|
if all_dead and team_player_ids.size() > 0:
|
|
var victim_name: String = "Spectator"
|
|
var shooter_name: String = "Spectator"
|
|
if victim_team == 1: victim_name = "CT"
|
|
elif victim_team == 2: victim_name = "T"
|
|
if shooter_team == 1: shooter_name = "CT"
|
|
elif shooter_team == 2: shooter_name = "T"
|
|
print("[GameServer] Team elimination detected! %s eliminated by %s" % [victim_name, shooter_name])
|
|
round_manager.end_round(shooter_team, "elimination")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RCON command dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
## Handle RCON commands dispatched via rcon_command_handler.
|
|
func _on_rcon_command(command: String, args: PackedStringArray) -> void:
|
|
match command:
|
|
"start_match":
|
|
if round_manager:
|
|
round_manager.start_match()
|
|
print("[GameServer] RCON: match started")
|
|
"end_round":
|
|
# end_round [team_name] [reason]
|
|
var team_name: String = args[0] if args.size() > 0 else "ct"
|
|
var reason: String = args[1] if args.size() > 1 else "admin"
|
|
var team: int = _parse_team_name(team_name)
|
|
if team == 0: # TeamData.Team.SPECTATOR
|
|
# Default to CT
|
|
team = 1 # TeamData.Team.COUNTER_TERRORIST
|
|
if round_manager:
|
|
round_manager.end_round(team, reason)
|
|
team_name = "Spectator"
|
|
if team == 1: team_name = "CT"
|
|
elif team == 2: team_name = "T"
|
|
print("[GameServer] RCON: round ended — %s wins (%s)" % [team_name, reason])
|
|
|
|
## Parse a team name string to a team ID (replaces TeamData.from_string for headless compat).
|
|
## 0 = Spectator, 1 = CT, 2 = T. Defaults to 0 for unknown names.
|
|
func _parse_team_name(name_str: String) -> int:
|
|
match name_str.to_lower().strip_edges():
|
|
"ct", "counter_terrorist", "counter-terrorist", "team_a", "1":
|
|
return 1
|
|
"t", "terrorist", "team_b", "2":
|
|
return 2
|
|
_:
|
|
return 0 # Spectator
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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
|