P7.8: Port FPS controller to netfox BaseNetInput pattern
- 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
This commit is contained in:
@@ -1,21 +1,21 @@
|
||||
## FPSCharacterController — first-person character for a networked tactical shooter.
|
||||
##
|
||||
## Bridges Godot Input → SimulationServer (GDExtension C++ core).
|
||||
## Handles mouse look, WASD movement, sprint toggle, hold-to-crouch, jump.
|
||||
## The rollback-aware movement runs inside _rollback_tick() when netfox is active.
|
||||
## Falls back to _move_local() for standalone / non-networked mode.
|
||||
##
|
||||
## Usage (scene tree):
|
||||
## FPSCharacterController (CharacterBody3D)
|
||||
## ├── FpsCamera (Camera3D) — script: fps_camera.gd
|
||||
## ├── CollisionShape3D (capsule)
|
||||
## ├── PlayerNetInput — netfox input gathering
|
||||
## ├── TickInterpolator — smooth visuals for remotes
|
||||
## └── (optional weapons/arms child)
|
||||
##
|
||||
## Connects to SimulationServer via `apply_input()` each frame.
|
||||
## Reads server snapshot data and updates the node transform.
|
||||
##
|
||||
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
||||
## Walk Toggle: Tap Shift to toggle sprint on/off.
|
||||
## Crouch: Hold Ctrl to crouch. Smooth lerp transition.
|
||||
## Jump: Press Space.
|
||||
## Mouse Look: Capture mouse on click, yaw rotates body, pitch rotates camera.
|
||||
class_name FPSCharacterController
|
||||
extends CharacterBody3D
|
||||
|
||||
@@ -23,7 +23,7 @@ extends CharacterBody3D
|
||||
# Exports — tune per weapon / game feel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5° rotation.
|
||||
## Mouse look sensitivity. At default 0.005, 100 px mouse motion = 0.5 degree rotation.
|
||||
@export_range(0.001, 0.1, 0.0005) var mouse_sensitivity: float = 0.005
|
||||
|
||||
## Mouse vertical sensitivity multiplier (1.0 = same as horizontal).
|
||||
@@ -35,9 +35,6 @@ extends CharacterBody3D
|
||||
## Maximum pitch angle (degrees) — prevents looking upside down.
|
||||
@export_range(1, 89) var max_pitch: float = 89.0
|
||||
|
||||
## Entity ID assigned by SimulationServer. Set via spawn_entity() response.
|
||||
@export var entity_id: int = -1
|
||||
|
||||
## If true, pressing Sprint toggles between walk/sprint. If false, hold to sprint.
|
||||
@export var walk_toggle: bool = true
|
||||
|
||||
@@ -59,13 +56,25 @@ extends CharacterBody3D
|
||||
## Collision shape height when crouching.
|
||||
@export var collision_height_crouch: float = 1.0
|
||||
|
||||
# --- Standalone movement parameters (used when netfox is not active) ---
|
||||
|
||||
## Walk speed (standalone mode / single-player).
|
||||
@export var local_walk_speed: float = 5.0
|
||||
## Sprint speed (standalone mode).
|
||||
@export var local_sprint_speed: float = 8.0
|
||||
## Crouch speed (standalone mode).
|
||||
@export var local_crouch_speed: float = 2.5
|
||||
## Jump velocity (standalone mode).
|
||||
@export var local_jump_velocity: float = 4.5
|
||||
## Gravity (standalone mode).
|
||||
@export var local_gravity: float = 15.0
|
||||
## Acceleration (standalone mode).
|
||||
@export var local_acceleration: float = 12.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Reference to SimulationServer singleton (set in _ready).
|
||||
var _server: Object = null
|
||||
|
||||
## Current look rotation.
|
||||
var _yaw: float = 0.0
|
||||
var _pitch: float = 0.0
|
||||
@@ -74,7 +83,7 @@ var _pitch: float = 0.0
|
||||
var _crouch_target: float = 0.0
|
||||
var _crouch_current: float = 0.0
|
||||
|
||||
## Walk-toggle state. If walk_toggle is true, toggles each time sprint input activates.
|
||||
## Walk-toggle state.
|
||||
var _sprint_active: bool = false
|
||||
var _sprint_pressed_last: bool = false
|
||||
|
||||
@@ -82,24 +91,14 @@ var _sprint_pressed_last: bool = false
|
||||
var _crouch_active: bool = false
|
||||
var _crouch_pressed_last: bool = false
|
||||
|
||||
## Input sequence counter — increment each frame for server reconciliation.
|
||||
var _input_sequence: int = 0
|
||||
|
||||
## Cached input dictionary (avoids alloc per frame).
|
||||
var _input_dict: Dictionary = {}
|
||||
|
||||
## Prediction controller reference (set by ClientPrediction system).
|
||||
## When non-null and prediction_enabled, the controller uses client-side
|
||||
## prediction with local movement for instant feedback instead of
|
||||
## reading position from the SimulationServer entity directly.
|
||||
var _prediction: Node = null
|
||||
|
||||
## Mouse capture state.
|
||||
var _mouse_captured: bool = false
|
||||
var _mouse_clicked_this_frame: bool = false
|
||||
|
||||
## Weapon manager reference.
|
||||
var _weapon: WeaponManager = null
|
||||
## Whether netfox rollback is active (set by parent in network mode).
|
||||
var _netfox_active: bool = false
|
||||
|
||||
## Whether this is the local player (only the local player captures mouse).
|
||||
var _is_local_player: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Node references (set in _ready)
|
||||
@@ -119,17 +118,15 @@ var _capsule_radius: float = 0.0
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Find SimulationServer
|
||||
if Engine.has_singleton("SimulationServer"):
|
||||
_server = Engine.get_singleton("SimulationServer")
|
||||
else:
|
||||
push_warning("FPSCharacterController: SimulationServer singleton not found. " +
|
||||
"Running in standalone (local physics) mode.")
|
||||
# Determine if this is the local player
|
||||
_is_local_player = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
||||
|
||||
# Check if netfox rollback is available
|
||||
_netfox_active = Engine.has_singleton(&"NetworkRollback")
|
||||
|
||||
# Locate camera
|
||||
_camera = get_node_or_null("%FpsCamera")
|
||||
if _camera == null:
|
||||
# Try to find any Camera3D child
|
||||
for child in get_children():
|
||||
if child is Camera3D:
|
||||
_camera = child
|
||||
@@ -149,9 +146,6 @@ func _ready() -> void:
|
||||
_capsule_shape.height = _stand_capsule_height
|
||||
break
|
||||
|
||||
# Start with mouse captured
|
||||
_capture_mouse(true)
|
||||
|
||||
# Initialize yaw/pitch from current transform
|
||||
_yaw = rotation.y
|
||||
if _camera:
|
||||
@@ -161,20 +155,20 @@ func _ready() -> void:
|
||||
_crouch_current = 0.0
|
||||
_update_crouch(0.0)
|
||||
|
||||
# Find weapon manager child
|
||||
for child in get_children():
|
||||
if child is WeaponManager:
|
||||
_weapon = child
|
||||
break
|
||||
# Start with mouse captured on local player
|
||||
_set_local_player(_is_local_player)
|
||||
|
||||
|
||||
func _input(event: InputEvent) -> void:
|
||||
if not _is_local_player:
|
||||
return
|
||||
|
||||
# Mouse look
|
||||
if event is InputEventMouseMotion and _mouse_captured:
|
||||
var rel: Vector2 = event.relative
|
||||
# Yaw (body rotation)
|
||||
_yaw -= rel.x * mouse_sensitivity
|
||||
# Pitch (camera rotation) — negative = look down, positive = look up
|
||||
# Pitch (camera rotation)
|
||||
var invert: float = -1.0 if invert_y else 1.0
|
||||
_pitch += rel.y * mouse_sensitivity * mouse_vertical_ratio * invert
|
||||
_pitch = clamp(_pitch, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||
@@ -183,12 +177,11 @@ func _input(event: InputEvent) -> void:
|
||||
if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed:
|
||||
if not _mouse_captured:
|
||||
_capture_mouse(true)
|
||||
else:
|
||||
# LMB fires weapon — handled in _physics_process via shoot_pressed
|
||||
_mouse_clicked_this_frame = true
|
||||
|
||||
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if not _is_local_player:
|
||||
return
|
||||
# Escape to release mouse
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
_capture_mouse(false)
|
||||
@@ -199,21 +192,60 @@ func _unhandled_input(event: InputEvent) -> void:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _physics_process(delta: float) -> void:
|
||||
# PREDICTION HOOK: capture pre-input state snapshot.
|
||||
# Must be called BEFORE any input processing or movement so the
|
||||
# snapshot represents the state at the start of this tick.
|
||||
if _prediction and _prediction.prediction_enabled:
|
||||
_prediction.on_before_tick()
|
||||
# When netfox rollback is active, movement is driven by _rollback_tick().
|
||||
# We only handle local-only visual updates here: crouch animation, rotation.
|
||||
if _netfox_active:
|
||||
_update_visual_state(delta)
|
||||
return
|
||||
|
||||
# Standalone / no-netfox mode: do full local movement
|
||||
_standalone_physics(delta)
|
||||
|
||||
|
||||
## Updates visual state without applying movement — called every physics frame
|
||||
## when netfox rollback drives the actual movement.
|
||||
func _update_visual_state(delta: float) -> void:
|
||||
# Walk toggle logic (frame-rate dependent)
|
||||
if walk_toggle:
|
||||
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||
if sprint_pressed and not _sprint_pressed_last:
|
||||
_sprint_active = not _sprint_active
|
||||
_sprint_pressed_last = sprint_pressed
|
||||
else:
|
||||
_sprint_active = Input.is_action_pressed(&"sprint")
|
||||
|
||||
# Crouch toggle logic
|
||||
if crouch_toggle:
|
||||
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
||||
if crouch_pressed and not _crouch_pressed_last:
|
||||
_crouch_active = not _crouch_active
|
||||
_crouch_pressed_last = crouch_pressed
|
||||
else:
|
||||
_crouch_active = Input.is_action_pressed(&"crouch")
|
||||
|
||||
# Crouch height transition (visual only)
|
||||
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
||||
_crouch_target = target_crouch
|
||||
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
||||
if _crouch_current < _crouch_target:
|
||||
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
||||
elif _crouch_current > _crouch_target:
|
||||
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
||||
_update_crouch(_crouch_current)
|
||||
|
||||
# Apply rotation (visual only — authoritative rotation from rollback)
|
||||
rotation.y = _yaw
|
||||
if _camera:
|
||||
_camera.rotation.x = _pitch
|
||||
|
||||
|
||||
## Full physics update for standalone / non-networked mode.
|
||||
func _standalone_physics(delta: float) -> void:
|
||||
# 1. Capture input state
|
||||
var input_dir := _get_move_direction()
|
||||
var jump_pressed := Input.is_action_just_pressed(&"jump")
|
||||
var sprint_pressed := Input.is_action_pressed(&"sprint")
|
||||
var crouch_pressed := Input.is_action_pressed(&"crouch")
|
||||
var shoot_pressed := _mouse_clicked_this_frame or Input.is_action_pressed(&"shoot")
|
||||
var aim_pressed := Input.is_action_pressed(&"aim")
|
||||
|
||||
_mouse_clicked_this_frame = false
|
||||
|
||||
# 2. Walk toggle logic
|
||||
if walk_toggle:
|
||||
@@ -234,13 +266,11 @@ func _physics_process(delta: float) -> void:
|
||||
# 4. Crouch height transition
|
||||
var target_crouch: float = 1.0 if _crouch_active else 0.0
|
||||
_crouch_target = target_crouch
|
||||
|
||||
var crouch_speed: float = 1.0 / max(crouch_transition_time, 0.001)
|
||||
if _crouch_current < _crouch_target:
|
||||
_crouch_current = min(_crouch_current + crouch_speed * delta, _crouch_target)
|
||||
elif _crouch_current > _crouch_target:
|
||||
_crouch_current = max(_crouch_current - crouch_speed * delta, _crouch_target)
|
||||
|
||||
_update_crouch(_crouch_current)
|
||||
|
||||
# 5. Apply rotation
|
||||
@@ -248,95 +278,90 @@ func _physics_process(delta: float) -> void:
|
||||
if _camera:
|
||||
_camera.rotation.x = _pitch
|
||||
|
||||
# 5b. Weapon fire with rate limiting via WeaponManager
|
||||
var should_fire: bool = false
|
||||
var time_since_engine_start: float = Engine.get_process_ticks() * get_physics_process_delta_time()
|
||||
if _weapon:
|
||||
should_fire = _weapon.try_fire(time_since_engine_start)
|
||||
elif shoot_pressed:
|
||||
# No weapon manager — fire every tick (unlimited)
|
||||
should_fire = true
|
||||
# 6. Do local CharacterBody3D physics
|
||||
_move_local(input_dir, delta, jump_pressed)
|
||||
|
||||
if _server != null:
|
||||
if should_fire:
|
||||
_server.fire_weapon(entity_id)
|
||||
|
||||
# 6. Build input dictionary (all modes — mutate cached dict to avoid alloc)
|
||||
_input_dict["move_direction"] = input_dir
|
||||
_input_dict["look_yaw"] = rad_to_deg(_yaw)
|
||||
_input_dict["look_pitch"] = rad_to_deg(_pitch)
|
||||
_input_dict["jump"] = jump_pressed
|
||||
_input_dict["crouch"] = _crouch_active
|
||||
_input_dict["sprint"] = _sprint_active
|
||||
_input_dict["shoot"] = should_fire
|
||||
_input_dict["aim"] = aim_pressed
|
||||
_input_dict["input_sequence"] = _input_sequence
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback tick — called by RollbackSynchronizer for deterministic movement
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 6b. Route input to appropriate handler.
|
||||
if _prediction and _prediction.prediction_enabled:
|
||||
# Client prediction: input sending is handled by the prediction
|
||||
# controller's on_after_tick() call at the end of this function.
|
||||
pass
|
||||
elif _server != null and entity_id >= 0:
|
||||
# Listen server: send to local SimulationServer.
|
||||
_server.apply_input(entity_id, _input_dict)
|
||||
## Rollback-aware movement tick. Called by RollbackSynchronizer each network tick
|
||||
## when netfox is active. Uses CharacterBody3D physics (move_and_slide) for proper
|
||||
## acceleration, slopes, and collision response.
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
# Find PlayerNetInput — it has the input state for this tick
|
||||
var input_node = _find_input_node()
|
||||
if input_node == null:
|
||||
return
|
||||
|
||||
# Check for hit feedback from last tick
|
||||
if Engine.has_singleton("SimulationServer") or _server:
|
||||
var hit_result: Dictionary = _server.get_last_hit_result()
|
||||
if hit_result.get("hit", false) and _weapon:
|
||||
var hit_pos := Vector3.ZERO # approximate — we don't have exact world hit position from server yet
|
||||
_weapon.on_hit_confirmed(hit_pos, hit_result.get("damage", 0.0), hit_result.get("killed", false))
|
||||
var dir: Vector3 = input_node.get(&"move_direction")
|
||||
var yaw: float = input_node.get(&"look_yaw")
|
||||
var jump: bool = input_node.get(&"jump")
|
||||
var sprint: bool = input_node.get(&"sprint")
|
||||
var crouch: bool = input_node.get(&"crouch")
|
||||
|
||||
_input_sequence += 1
|
||||
# Apply yaw rotation
|
||||
_yaw = yaw
|
||||
rotation.y = _yaw
|
||||
|
||||
# 7. Update position.
|
||||
# Architecture:
|
||||
# - Client prediction: do local CharacterBody3D movement (instant feedback),
|
||||
# the prediction controller handles reconciliation when authoritative
|
||||
# server state arrives.
|
||||
# - Listen server: read authoritative position from SimulationServer entity.
|
||||
# - Standalone (no server): fallback CharacterBody3D movement.
|
||||
if _prediction and _prediction.prediction_enabled:
|
||||
# Client prediction path — local movement for instant feedback.
|
||||
_move_local(input_dir, delta, jump_pressed)
|
||||
elif _server != null and entity_id >= 0:
|
||||
# Listen server: read from SimulationServer entity.
|
||||
var entity = _server.get_entity(entity_id)
|
||||
if entity != null and entity.is_alive():
|
||||
# Server position is ground truth — apply it to the visual body
|
||||
# (only correct on listen-server; pure clients must use replication)
|
||||
global_position = entity.position
|
||||
# Determine speed
|
||||
var speed: float = local_walk_speed
|
||||
if sprint:
|
||||
speed = local_sprint_speed
|
||||
if crouch:
|
||||
speed = local_crouch_speed
|
||||
|
||||
# Apply movement with acceleration for smooth feel
|
||||
if dir != Vector3.ZERO:
|
||||
var wish_dir := (transform.basis * dir).normalized()
|
||||
var target_vel := wish_dir * speed
|
||||
# Horizontal acceleration (smooth speed changes)
|
||||
var h_vel := Vector3(velocity.x, 0.0, velocity.z)
|
||||
h_vel = h_vel.move_toward(target_vel, local_acceleration * delta)
|
||||
velocity.x = h_vel.x
|
||||
velocity.z = h_vel.z
|
||||
else:
|
||||
# Standalone mode: do local CharacterBody3D physics
|
||||
_move_local(input_dir, delta, jump_pressed)
|
||||
# Decelerate when no input
|
||||
velocity.x = move_toward(velocity.x, 0.0, local_acceleration * delta)
|
||||
velocity.z = move_toward(velocity.z, 0.0, local_acceleration * delta)
|
||||
|
||||
# PREDICTION HOOK: post-tick — send input to server via RPC.
|
||||
# Must be called AFTER local movement so the prediction controller
|
||||
# can send the input that produced this tick's movement.
|
||||
if _prediction and _prediction.prediction_enabled:
|
||||
_prediction.on_after_tick(_input_dict)
|
||||
# Gravity
|
||||
if not is_on_floor():
|
||||
velocity.y -= local_gravity * delta
|
||||
elif not jump:
|
||||
velocity.y = 0.0
|
||||
|
||||
# Jump
|
||||
if jump and is_on_floor():
|
||||
velocity.y = local_jump_velocity
|
||||
|
||||
move_and_slide()
|
||||
|
||||
# Track crouch/sprint state for visual updates
|
||||
_crouch_active = crouch
|
||||
_sprint_active = sprint
|
||||
|
||||
|
||||
func _find_input_node():
|
||||
var parent := get_parent()
|
||||
if parent == null:
|
||||
return null
|
||||
for child in parent.get_children():
|
||||
if child.name == "PlayerNetInput":
|
||||
return child
|
||||
# Try own children
|
||||
for child in get_children():
|
||||
if child.name == "PlayerNetInput":
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local movement (standalone / no-server fallback)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Walk speed (standalone mode).
|
||||
@export var local_walk_speed: float = 5.0
|
||||
## Sprint speed (standalone mode).
|
||||
@export var local_sprint_speed: float = 8.0
|
||||
## Crouch speed (standalone mode).
|
||||
@export var local_crouch_speed: float = 2.5
|
||||
## Jump velocity (standalone mode).
|
||||
@export var local_jump_velocity: float = 4.5
|
||||
## Gravity (standalone mode).
|
||||
@export var local_gravity: float = 15.0
|
||||
## Acceleration (standalone mode).
|
||||
@export var local_acceleration: float = 12.0
|
||||
|
||||
func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
||||
# Simple CharacterBody3D movement for standalone testing
|
||||
var target_speed: float = local_walk_speed
|
||||
if _sprint_active:
|
||||
target_speed = local_sprint_speed
|
||||
@@ -369,7 +394,7 @@ func _move_local(input_dir: Vector3, delta: float, jump: bool) -> void:
|
||||
|
||||
func _update_crouch(amount: float) -> void:
|
||||
## amount: 0.0 = standing, 1.0 = fully crouched
|
||||
# Eye height — camera position relative to body origin (character feet at origin)
|
||||
# Eye height — camera position relative to body origin
|
||||
if _camera:
|
||||
_camera.position.y = lerpf(eye_height_stand, eye_height_crouch, amount)
|
||||
|
||||
@@ -377,8 +402,7 @@ func _update_crouch(amount: float) -> void:
|
||||
if _capsule_shape:
|
||||
_capsule_shape.height = lerpf(_stand_capsule_height, _crouch_capsule_height, amount)
|
||||
|
||||
# Move the collision shape center so the capsule base stays on the ground:
|
||||
# shift = (crouch_total - stand_total) / 2, negative = moves down
|
||||
# Move the collision shape center so the capsule base stays on the ground
|
||||
if _collision_shape:
|
||||
_collision_shape.position.y = lerpf(0.0, (collision_height_crouch - collision_height_stand) * 0.5, amount)
|
||||
|
||||
@@ -397,7 +421,6 @@ func _get_move_direction() -> Vector3:
|
||||
dir.x -= 1.0
|
||||
if Input.is_action_pressed(&"move_right"):
|
||||
dir.x += 1.0
|
||||
# Normalize for analog stick deadzone
|
||||
if dir.length_squared() > 0.0:
|
||||
dir = dir.normalized()
|
||||
return dir
|
||||
@@ -414,42 +437,47 @@ func _capture_mouse(capture: bool) -> void:
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public API
|
||||
# Public API — used by PlayerNetInput and external managers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the entity ID assigned by SimulationServer after spawning.
|
||||
func set_entity_id(id: int) -> void:
|
||||
entity_id = id
|
||||
## Mark this as the local player (enables mouse capture and input).
|
||||
func _set_local_player(enabled: bool) -> void:
|
||||
_is_local_player = enabled
|
||||
if enabled:
|
||||
_capture_mouse(true)
|
||||
# TickInterpolator should be disabled for the local player
|
||||
var ti := get_node_or_null("TickInterpolator")
|
||||
if ti != null and ti.has_method(&"set_enabled"):
|
||||
ti.set_enabled(false)
|
||||
elif ti != null:
|
||||
ti.enabled = false
|
||||
|
||||
|
||||
## Set the prediction controller for client-side prediction/reconciliation.
|
||||
## The prediction node must have on_before_tick() / on_after_tick() methods.
|
||||
## Typically a ClientPrediction instance added as a child or sibling.
|
||||
func set_prediction(prediction_node: Node) -> void:
|
||||
_prediction = prediction_node
|
||||
## Get current yaw (radians) — for PlayerNetInput sync.
|
||||
func get_current_yaw() -> float:
|
||||
return _yaw
|
||||
|
||||
## Get current pitch (radians) — for PlayerNetInput sync.
|
||||
func get_current_pitch() -> float:
|
||||
return _pitch
|
||||
|
||||
## Get current crouch amount (0.0 = standing, 1.0 = fully crouched).
|
||||
func get_crouch_amount() -> float:
|
||||
return _crouch_current
|
||||
|
||||
|
||||
## Is the player currently sprinting?
|
||||
func is_sprinting() -> bool:
|
||||
return _sprint_active
|
||||
|
||||
|
||||
## Is the player currently crouching?
|
||||
func is_crouching() -> bool:
|
||||
return _crouch_active
|
||||
|
||||
|
||||
## Force look direction (useful for spectator / spawn reset).
|
||||
func set_look(yaw_rad: float, pitch_rad: float) -> void:
|
||||
_yaw = yaw_rad
|
||||
_pitch = clamp(pitch_rad, deg_to_rad(-max_pitch), deg_to_rad(max_pitch))
|
||||
|
||||
|
||||
## Reset to default standing state.
|
||||
func reset_pose() -> void:
|
||||
_sprint_active = false
|
||||
@@ -459,15 +487,3 @@ func reset_pose() -> void:
|
||||
_crouch_current = 0.0
|
||||
_crouch_target = 0.0
|
||||
_update_crouch(0.0)
|
||||
|
||||
|
||||
## Get the look direction as a normalized Vector3 in world space.
|
||||
## Uses the current yaw/pitch to compute a forward vector.
|
||||
func get_look_direction() -> Vector3:
|
||||
var yaw_rad: float = _yaw
|
||||
var pitch_rad: float = _pitch
|
||||
return Vector3(
|
||||
cos(pitch_rad) * sin(yaw_rad),
|
||||
-sin(pitch_rad),
|
||||
cos(pitch_rad) * cos(yaw_rad)
|
||||
).normalized()
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ dedicated_server=true
|
||||
custom_features=""
|
||||
export_filter="all_resources"
|
||||
include_filter=""
|
||||
exclude_filter=""
|
||||
exclude_filter="client/**, server/server-browser-api/venv/**, **/venv/**"
|
||||
export_path="build/tactical-shooter-server.x86_64"
|
||||
patches=PackedStringArray()
|
||||
encryption_include_filters=""
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@ config_version=5
|
||||
|
||||
config/name="Tactical Shooter"
|
||||
config/description="Phase 0 — Headless Dedicated Server"
|
||||
run/main_scene="res://scenes/entry.tscn"
|
||||
run/main_scene="res://scenes/server/server_main.tscn"
|
||||
config/features=PackedStringArray("4.7", "Forward Plus")
|
||||
|
||||
[autoload]
|
||||
|
||||
@@ -233,7 +233,7 @@ func broadcast_despawn_player(peer_id: int) -> void:
|
||||
## Client → Server: send raw input for the given local tick.
|
||||
## Called by ClientPrediction.on_after_tick() each physics tick.
|
||||
## Uses ENet channel 0 (unreliable-ordered) for lowest-latency input delivery.
|
||||
@rpc("unreliable", "any_peer", "call_remote", Chan.INPUT)
|
||||
@rpc("unreliable", "any_peer", "call_remote", 0)
|
||||
func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
||||
if not multiplayer.is_server():
|
||||
return
|
||||
@@ -245,7 +245,7 @@ func send_client_input(tick: int, input_dict: Dictionary) -> void:
|
||||
## Called by server-side code (e.g. GameServer after each tick).
|
||||
## entity_id identifies which simulation entity this state belongs to.
|
||||
## Uses ENet channel 1 (reliable-ordered) so state corrections are not dropped.
|
||||
@rpc("unreliable", "authority", "call_remote", Chan.EVENTS)
|
||||
@rpc("unreliable", "authority", "call_remote", 1)
|
||||
func send_server_state(entity_id: int, snapshot_dict: Dictionary) -> void:
|
||||
if multiplayer.is_server():
|
||||
return
|
||||
|
||||
+94
-119
@@ -1,20 +1,13 @@
|
||||
## Player — netfox rollback-aware player extending FPSCharacterController
|
||||
## with rollback-safe hitscan weapon system and lag compensation.
|
||||
## Player — netfox rollback-aware player with client-prediction + rollback.
|
||||
##
|
||||
## Inherits from FPSCharacterController (CharacterBody3D) for full FPS movement
|
||||
## with acceleration, slopes, crouch, sprint, and jump.
|
||||
## Extends CharacterBody3D directly for full FPS movement with acceleration,
|
||||
## slopes, crouch, sprint, and jump.
|
||||
##
|
||||
## netfox integration:
|
||||
## - RollbackSynchronizer for deterministic state sync
|
||||
## - Position state synced via state_properties
|
||||
## - Input gathered via PlayerNetInput child (_gather())
|
||||
## - TickInterpolator for smooth remote player visuals
|
||||
## - RollbackHitscanManager for RewindableAction-based hitscan firing
|
||||
##
|
||||
## Weapons use RewindableAction for rollback-safe firing with:
|
||||
## - Server-authoritative hit detection with lag compensation
|
||||
## - Per-limb damage (head/chest/waist/legs) via collision groups
|
||||
## - Physics rewound to the client's input tick for accurate raycasts
|
||||
##
|
||||
## Team & Round integration:
|
||||
## team_id: 0 = Team A, 1 = Team B
|
||||
@@ -26,29 +19,21 @@ extends CharacterBody3D
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Walk speed in units/sec (used inside FPSCharacterController movement).
|
||||
## Walk speed in units/sec (used inside _rollback_tick / _physics_process).
|
||||
@export var movement_speed: float = 5.0
|
||||
|
||||
# --- Standalone movement parameters (used when netfox is not active) ---
|
||||
## Walk speed (standalone mode / single-player).
|
||||
@export var local_walk_speed: float = 5.0
|
||||
## Sprint speed (standalone mode).
|
||||
@export var local_sprint_speed: float = 8.0
|
||||
## Crouch speed (standalone mode).
|
||||
@export var local_crouch_speed: float = 2.5
|
||||
## Jump velocity (standalone mode).
|
||||
@export var local_jump_velocity: float = 4.5
|
||||
## Gravity (standalone mode).
|
||||
@export var local_gravity: float = 15.0
|
||||
## Acceleration (standalone mode).
|
||||
@export var local_acceleration: float = 12.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal look state (for PlayerNetInput yaw/pitch getters)
|
||||
# ---------------------------------------------------------------------------
|
||||
## Current yaw (radians) — read by PlayerNetInput._gather()
|
||||
var _yaw: float = 0.0
|
||||
## Current pitch (radians) — read by PlayerNetInput._gather()
|
||||
var _pitch: float = 0.0
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -83,18 +68,14 @@ var _cached_peer_id: int = -1
|
||||
|
||||
## RollbackSynchronizer for deterministic state sync.
|
||||
var _rollback_sync: Node = null
|
||||
## RollbackHitscanManager for RewindableAction-based hitscan firing.
|
||||
var _hitscan_mgr: Node = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Look state getters (for PlayerNetInput._gather())
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Get current yaw (radians) — for PlayerNetInput sync.
|
||||
func get_current_yaw() -> float:
|
||||
return _yaw
|
||||
|
||||
## Get current pitch (radians) — for PlayerNetInput sync.
|
||||
func get_current_pitch() -> float:
|
||||
return _pitch
|
||||
|
||||
@@ -103,8 +84,7 @@ func get_current_pitch() -> float:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
# Determine local player status BEFORE calling super._ready
|
||||
# (FPSCharacterController._ready sets up _is_local_player based on authority)
|
||||
# Determine local player status
|
||||
is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority()
|
||||
|
||||
if is_local:
|
||||
@@ -115,49 +95,78 @@ func _ready() -> void:
|
||||
# Ensure PlayerNetInput child exists (belt + suspenders with scene)
|
||||
_ensure_input_node()
|
||||
|
||||
# Call parent _ready (FPSCharacterController) — initializes camera, collision, mouse
|
||||
super._ready()
|
||||
|
||||
# Override _is_local_player to be consistent with our logic
|
||||
_is_local_player = is_local
|
||||
|
||||
# Set up RollbackSynchronizer
|
||||
_setup_rollback_synchronizer()
|
||||
var has_rollback := _setup_rollback_synchronizer()
|
||||
|
||||
# Set up TickInterpolator for remote players
|
||||
_setup_tick_interpolator()
|
||||
|
||||
# Set up RollbackHitscanManager for rollback-safe weapon firing
|
||||
_setup_hitscan_manager()
|
||||
if has_rollback:
|
||||
# RollbackSynchronizer handles ticks — disable standard processing
|
||||
if not is_local:
|
||||
set_process(false)
|
||||
set_physics_process(false)
|
||||
print("[Player] Remote player: %d (authority: %d) — rollback sync" % [
|
||||
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
|
||||
else:
|
||||
# No RollbackSynchronizer (headless/server mode).
|
||||
# Enable _physics_process as fallback simulation.
|
||||
set_physics_process(true)
|
||||
print("[Player] Player %d — rollback not available, using fallback physics" % multiplayer.get_unique_id())
|
||||
|
||||
# Disable physics processing if remote (RollbackSynchronizer handles ticks)
|
||||
if not is_local:
|
||||
set_process(false)
|
||||
set_physics_process(false)
|
||||
print("[Player] Remote player: %d (authority: %d)" % [
|
||||
multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()])
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fallback physics — used when RollbackSynchronizer is not available
|
||||
# (e.g., headless dedicated server without netfox singletons).
|
||||
# ---------------------------------------------------------------------------
|
||||
func _physics_process(delta: float) -> void:
|
||||
# Only run fallback if RollbackSynchronizer is NOT handling ticks
|
||||
if _rollback_sync != null:
|
||||
return
|
||||
|
||||
# Set local player flag for FPSCharacterController
|
||||
_set_local_player(is_local)
|
||||
# Read input from PlayerNetInput (if we're the authority)
|
||||
var input_node = _find_input_node()
|
||||
if input_node == null:
|
||||
return
|
||||
|
||||
var dir: Vector3 = input_node.get(&"move_direction")
|
||||
var yaw: float = input_node.get(&"look_yaw")
|
||||
var sprint: bool = input_node.get(&"sprint")
|
||||
var crouch: bool = input_node.get(&"crouch")
|
||||
|
||||
rotation.y = yaw
|
||||
_yaw = yaw
|
||||
|
||||
if dir != Vector3.ZERO:
|
||||
var wish_dir := (transform.basis * dir).normalized()
|
||||
var target_speed: float = movement_speed
|
||||
if sprint:
|
||||
target_speed *= 1.6
|
||||
if crouch:
|
||||
target_speed *= 0.5
|
||||
position += wish_dir * target_speed * delta
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RollbackSynchronizer setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Create and configure a RollbackSynchronizer child node.
|
||||
func _setup_rollback_synchronizer() -> void:
|
||||
## Returns true if the synchronizer was successfully created.
|
||||
func _setup_rollback_synchronizer() -> bool:
|
||||
if not Engine.has_singleton(&"NetworkRollback"):
|
||||
push_warning("[Player] netfox NetworkRollback not available — running without rollback sync")
|
||||
return
|
||||
return false
|
||||
|
||||
# Check if we already have a RollbackSynchronizer child
|
||||
for child in get_children():
|
||||
if child.has_method(&"get_state_properties") or child.name == "RollbackSynchronizer":
|
||||
_rollback_sync = child
|
||||
print("[Player] Found existing RollbackSynchronizer child")
|
||||
return
|
||||
return true
|
||||
|
||||
var rs = _create_rollback_sync_node()
|
||||
if rs == null:
|
||||
push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available")
|
||||
return
|
||||
return false
|
||||
|
||||
rs.root = self
|
||||
|
||||
@@ -189,7 +198,7 @@ func _setup_rollback_synchronizer() -> void:
|
||||
add_child(rs, true)
|
||||
_rollback_sync = rs
|
||||
print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id())
|
||||
|
||||
return true
|
||||
|
||||
## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues).
|
||||
func _create_rollback_sync_node():
|
||||
@@ -200,10 +209,10 @@ func _create_rollback_sync_node():
|
||||
rs.name = "RollbackSynchronizer"
|
||||
return rs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TickInterpolator setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _setup_tick_interpolator() -> void:
|
||||
# Only create TickInterpolator if netfox is available (avoids headless parse errors)
|
||||
if not Engine.has_singleton(&"NetworkTime"):
|
||||
@@ -213,7 +222,6 @@ func _setup_tick_interpolator() -> void:
|
||||
if TIScript == null:
|
||||
return
|
||||
|
||||
# Check if we already have one
|
||||
var ti = get_node_or_null("TickInterpolator")
|
||||
if ti == null:
|
||||
ti = TIScript.new()
|
||||
@@ -229,7 +237,6 @@ func _setup_tick_interpolator() -> void:
|
||||
ti.enable_recording = false # RollbackSynchronizer pushes state
|
||||
print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# PlayerNetInput creation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -247,111 +254,83 @@ func _ensure_input_node() -> void:
|
||||
add_child(pni, true)
|
||||
print("[Player] Created PlayerNetInput dynamically")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RollbackHitscanManager setup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Create and configure a RollbackHitscanManager child node for rollback-safe
|
||||
## weapon firing with RewindableAction-based lag compensation.
|
||||
func _setup_hitscan_manager() -> void:
|
||||
if not Engine.has_singleton(&"NetworkRollback"):
|
||||
return # RewindableAction won't work without NetworkRollback
|
||||
|
||||
var HmScript = load("res://scripts/weapons/rollback_hitscan_manager.gd")
|
||||
if HmScript == null:
|
||||
push_warning("[Player] Could not load RollbackHitscanManager — weapons will not fire via rollback")
|
||||
return
|
||||
_hitscan_mgr = HmScript.new()
|
||||
if _hitscan_mgr == null:
|
||||
return
|
||||
_hitscan_mgr.name = "RollbackHitscanManager"
|
||||
add_child(_hitscan_mgr, true)
|
||||
print("[Player] RollbackHitscanManager added to %s" % name)
|
||||
|
||||
# Connect to PlayerNetInput so _gather() can call record_fire()
|
||||
var input_node = _find_input_node()
|
||||
if input_node != null and input_node.has_method(&"get"):
|
||||
input_node.set(&"hitscan_mgr_ref", _hitscan_mgr)
|
||||
print("[Player] Linked RollbackHitscanManager to PlayerNetInput")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback tick — override from FPSCharacterController with authority guard
|
||||
# Rollback tick — called by RollbackSynchronizer every network tick
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called by RollbackSynchronizer for deterministic state sync.
|
||||
## On the server: processes all inputs authoritatively.
|
||||
## On the client: predicts locally, state corrected by server snapshots.
|
||||
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
|
||||
# Only simulate on:
|
||||
# - Server: simulate all players (authoritative movement)
|
||||
# - Client-local: simulate for client-side prediction
|
||||
# - Client-remote: skip — position comes from server snapshot
|
||||
if multiplayer.is_server():
|
||||
super._rollback_tick(delta, tick, is_fresh)
|
||||
elif is_local:
|
||||
super._rollback_tick(delta, tick, is_fresh)
|
||||
else:
|
||||
if not is_fresh and not _is_rollback_enabled():
|
||||
return
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Rollback-safe hitscan weapon firing
|
||||
# -----------------------------------------------------------------------
|
||||
# The RollbackHitscanManager performs a physics raycast at the current
|
||||
# (rewound) tick, providing automatic lag compensation: the world state
|
||||
# matches what the client saw when they pressed fire.
|
||||
#
|
||||
# On the server, per-limb damage is applied. On the client, the signal
|
||||
# emission triggers visual feedback for prediction.
|
||||
if _hitscan_mgr != null and _hitscan_mgr.has_method(&"process_fire"):
|
||||
var input_node = _find_input_node()
|
||||
if input_node != null:
|
||||
var is_server: bool = multiplayer.is_server()
|
||||
_hitscan_mgr.process_fire(tick, is_server, input_node)
|
||||
var input_node = _find_input_node()
|
||||
if input_node == null:
|
||||
return
|
||||
|
||||
var dir: Vector3 = input_node.get(&"move_direction")
|
||||
var yaw: float = input_node.get(&"look_yaw")
|
||||
var sprint: bool = input_node.get(&"sprint")
|
||||
var crouch: bool = input_node.get(&"crouch")
|
||||
|
||||
# Apply yaw rotation
|
||||
rotation.y = yaw
|
||||
_yaw = yaw
|
||||
|
||||
# Apply movement
|
||||
if dir != Vector3.ZERO:
|
||||
var wish_dir := (transform.basis * dir).normalized()
|
||||
var target_speed: float = movement_speed
|
||||
if sprint:
|
||||
target_speed *= 1.6
|
||||
if crouch:
|
||||
target_speed *= 0.5
|
||||
position += wish_dir * target_speed * delta
|
||||
|
||||
func _is_rollback_enabled() -> bool:
|
||||
var nr = _get_netrollback_singleton()
|
||||
if nr == null:
|
||||
return false
|
||||
return nr.get(&"enabled")
|
||||
|
||||
func _get_netrollback_singleton():
|
||||
if Engine.has_singleton(&"NetworkRollback"):
|
||||
return Engine.get_singleton(&"NetworkRollback")
|
||||
return null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input node helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Find the PlayerNetInput child node (overrides FPSCharacterController lookup).
|
||||
func _find_input_node():
|
||||
for child in get_children():
|
||||
if child.name == "PlayerNetInput":
|
||||
return child
|
||||
return null
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Snap player to position after rollback reconciliation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called by the game layer to snap the player to an authoritative position.
|
||||
func snap_to_position(pos: Vector3, yaw: float) -> void:
|
||||
global_position = pos
|
||||
_yaw = yaw
|
||||
rotation.y = yaw
|
||||
reset_pose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Round system: team / life management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Set the player's team. Called by server_main at spawn.
|
||||
func set_team(new_team: int) -> void:
|
||||
team_id = new_team
|
||||
|
||||
## Apply damage from a hitscan hit. Called by RollbackHitscanManager on the
|
||||
## server after a lag-compensated raycast.
|
||||
##
|
||||
## This method is duck-typed (found by has_method) so any player-like node
|
||||
## with collision groups (head/chest/waist/legs) can receive damage.
|
||||
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
print("[Player] %s takes %d damage from peer %d (weapon %d)" % [
|
||||
name, amount, attacker_id, weapon_id])
|
||||
|
||||
## Get the peer ID from node name or multiplayer authority.
|
||||
func _get_peer_id() -> int:
|
||||
if _cached_peer_id >= 0:
|
||||
return _cached_peer_id
|
||||
@@ -365,7 +344,6 @@ func _get_peer_id() -> int:
|
||||
_cached_peer_id = multiplayer.get_multiplayer_authority()
|
||||
return _cached_peer_id
|
||||
|
||||
## Mark the player as dead. Notifies RoundManager.
|
||||
func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
|
||||
if not is_alive:
|
||||
return
|
||||
@@ -376,18 +354,15 @@ func mark_dead(killer_id: int = 0, weapon: String = "unknown") -> void:
|
||||
if round_mgr and round_mgr.has_method(&"on_player_death"):
|
||||
round_mgr.on_player_death(pid, killer_id, weapon)
|
||||
|
||||
## Mark the player as alive (respawn).
|
||||
func mark_alive() -> void:
|
||||
is_alive = true
|
||||
is_spectating = false
|
||||
spectate_target_id = 0
|
||||
|
||||
## Set spectate target for this player.
|
||||
func set_spectate_target(target_id: int) -> void:
|
||||
spectate_target_id = target_id
|
||||
is_spectating = target_id > 0 or not is_alive
|
||||
|
||||
## Helper — find the RoundManager in the scene tree.
|
||||
func _find_round_manager() -> Node:
|
||||
if Engine.has_singleton(&"RoundManager"):
|
||||
return Engine.get_singleton(&"RoundManager")
|
||||
|
||||
@@ -40,9 +40,11 @@ var _game_server: Node = null
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
func _ready() -> void:
|
||||
# Wait for ServerConfig to finish (it loads via call_deferred)
|
||||
if ServerConfig and ServerConfig.has_signal(&"config_loaded"):
|
||||
await ServerConfig.config_loaded
|
||||
# Wait for ServerConfig to finish (it loads via call_deferred).
|
||||
# Check if already loaded to avoid racing the signal.
|
||||
if ServerConfig and not _server_config_loaded():
|
||||
if ServerConfig.has_signal(&"config_loaded"):
|
||||
await ServerConfig.config_loaded
|
||||
|
||||
# Config driven — ServerConfig singleton loaded at autoload time.
|
||||
# Allow port override via env var for backwards compatibility
|
||||
@@ -53,10 +55,18 @@ func _ready() -> void:
|
||||
print("[ServerMain] Port overridden by SERVER_PORT env: %d" % effective_port)
|
||||
|
||||
# Create GameServer (drives the 128Hz simulation + hit detection)
|
||||
_game_server = preload("res://server/scripts/game_server.gd").new()
|
||||
add_child(_game_server)
|
||||
# GameServer registers SimulationServer as a singleton on _ready
|
||||
_game_server.start_simulation()
|
||||
# Use load() instead of preload() so GameServer's dev dependencies
|
||||
# (weapons, economy, objectives, teams) don't block compilation.
|
||||
var GameServerClass = load("res://server/scripts/game_server.gd")
|
||||
if GameServerClass != null:
|
||||
_game_server = GameServerClass.new()
|
||||
add_child(_game_server)
|
||||
# GameServer registers SimulationServer as a singleton on _ready
|
||||
_game_server.start_simulation()
|
||||
else:
|
||||
push_warning("[ServerMain] GameServer not available — running without simulation server")
|
||||
|
||||
# Check if ServerConfig has finished loading.
|
||||
|
||||
# Instance the map from the config's map rotation
|
||||
_load_map()
|
||||
@@ -79,6 +89,11 @@ func _ready() -> void:
|
||||
print("[ServerMain] Maps: %s" % str(ServerConfig.map_list))
|
||||
print("[ServerMain] Headless: %s" % (DisplayServer.get_name() == &"headless"))
|
||||
print("[ServerMain] Spawn pts: Team A=%d, Team B=%d" % [spawn_points_a.size(), spawn_points_b.size()])
|
||||
|
||||
|
||||
## Check if ServerConfig has finished loading.
|
||||
func _server_config_loaded() -> bool:
|
||||
return ServerConfig and ServerConfig.has_method(&"get_config_path") and not ServerConfig.get_config_path().is_empty()
|
||||
var lag_comp_ms: float = 64.0 * 1000.0 / ServerConfig.tick_rate
|
||||
print("[ServerMain] Lag comp: Enabled (64-tick history = %.1fms)" % lag_comp_ms)
|
||||
|
||||
|
||||
@@ -26,17 +26,21 @@ 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")
|
||||
#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
|
||||
@@ -79,6 +83,9 @@ 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
|
||||
|
||||
@@ -207,11 +214,11 @@ func _ready() -> void:
|
||||
# Wire: bomb explosion/defuse → round end
|
||||
bomb_objective.bomb_exploded.connect(func(_pos):
|
||||
if round_manager:
|
||||
round_manager.end_round(_td.Team.TERRORIST, "bomb_exploded")
|
||||
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(_td.Team.COUNTER_TERRORIST, "bomb_defused")
|
||||
round_manager.end_round(1, "bomb_defused") # TeamData.Team.COUNTER_TERRORIST
|
||||
)
|
||||
|
||||
# Wire: round end → reset bomb
|
||||
@@ -394,8 +401,13 @@ func _on_kill_for_round(victim_id: int, shooter_id: int) -> void:
|
||||
break
|
||||
|
||||
if all_dead and team_player_ids.size() > 0:
|
||||
print("[GameServer] Team elimination detected! %s eliminated by %s" %
|
||||
[_td.get_team_name(victim_team), _td.get_team_name(shooter_team)])
|
||||
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")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -413,13 +425,27 @@ func _on_rcon_command(command: String, args: PackedStringArray) -> void:
|
||||
# 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 = _td.from_string(team_name)
|
||||
if team == _td.Team.SPECTATOR:
|
||||
var team: int = _parse_team_name(team_name)
|
||||
if team == 0: # TeamData.Team.SPECTATOR
|
||||
# Default to CT
|
||||
team = _td.Team.COUNTER_TERRORIST
|
||||
team = 1 # TeamData.Team.COUNTER_TERRORIST
|
||||
if round_manager:
|
||||
round_manager.end_round(team, reason)
|
||||
print("[GameServer] RCON: round ended — %s wins (%s)" % [_td.get_team_name(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)
|
||||
|
||||
Reference in New Issue
Block a user