From 926446e5cf8a0fbb443494f642003a7da68343bf Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 2 Jul 2026 17:45:03 -0400 Subject: [PATCH] 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 --- .../character/fps_character_controller.gd | 338 +++++++++--------- export_presets.cfg | 2 +- project.godot | 2 +- scripts/network/network_manager.gd | 4 +- scripts/network/player.gd | 213 +++++------ scripts/network/server_main.gd | 29 +- server/scripts/game_server.gd | 62 +++- 7 files changed, 341 insertions(+), 309 deletions(-) diff --git a/client/characters/character/fps_character_controller.gd b/client/characters/character/fps_character_controller.gd index 9bde5f0..0cae319 100644 --- a/client/characters/character/fps_character_controller.gd +++ b/client/characters/character/fps_character_controller.gd @@ -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() diff --git a/export_presets.cfg b/export_presets.cfg index 05700fb..742245e 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -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="" diff --git a/project.godot b/project.godot index 4ea4a39..f12db01 100644 --- a/project.godot +++ b/project.godot @@ -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] diff --git a/scripts/network/network_manager.gd b/scripts/network/network_manager.gd index b32135c..5fbac01 100644 --- a/scripts/network/network_manager.gd +++ b/scripts/network/network_manager.gd @@ -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 diff --git a/scripts/network/player.gd b/scripts/network/player.gd index 347af41..4b8cc98 100644 --- a/scripts/network/player.gd +++ b/scripts/network/player.gd @@ -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") diff --git a/scripts/network/server_main.gd b/scripts/network/server_main.gd index d64abf6..a9055cb 100644 --- a/scripts/network/server_main.gd +++ b/scripts/network/server_main.gd @@ -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) diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index 82e220b..c519dd9 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -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)