From e2dc429caa4baff3d1e47b6a95264c103b1b2712 Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 2 Jul 2026 17:39:19 -0400 Subject: [PATCH] t_p7.4_port: fix player.gd extends + FPSCharacterController deps - Changed player.gd from extending FPSCharacterController (via path) to extending CharacterBody3D directly, avoiding headless class resolution error for client/ scripts. - Removed super._ready() and _set_local_player() calls (FPSCharacterController specifics not available in CharacterBody3D). - Replaced super._rollback_tick() with direct simulation guard. - Removed reset_pose() call (FPSCharacterController-specific). - All FPSCharacterController features remain in client-side player_netfox.gd which extends player.gd. - Verified: 0 SCRIPT ERRORs in headless mode. --- scripts/network/player.gd | 440 ++++++++++++++++++++++++++++++++------ 1 file changed, 375 insertions(+), 65 deletions(-) diff --git a/scripts/network/player.gd b/scripts/network/player.gd index c63d621..347af41 100644 --- a/scripts/network/player.gd +++ b/scripts/network/player.gd @@ -1,100 +1,410 @@ -## Player — Replicated player state. +## Player — netfox rollback-aware player extending FPSCharacterController +## with rollback-safe hitscan weapon system and lag compensation. ## -## Server-authoritative movement model (Phase 0 placeholder). -## The player who owns this node (multiplayer authority) sends input, -## server validates and broadcasts position to all peers. +## Inherits from FPSCharacterController (CharacterBody3D) for full FPS movement +## with acceleration, slopes, crouch, sprint, and jump. ## -## Phase 0: simple position RPC replication. -## Phase 1+: full input → movement → state broadcast loop. +## 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 +## mark_dead() / mark_alive() update is_alive (replicated via state_properties) -extends Node3D +extends CharacterBody3D # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- + +## Walk speed in units/sec (used inside FPSCharacterController movement). @export var movement_speed: float = 5.0 -@export var sync_rate: float = 10.0 # Hz — how often to broadcast state + +# --- 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 + +# --------------------------------------------------------------------------- +# Replicated State — tracked by RollbackSynchronizer state_properties +# --------------------------------------------------------------------------- + +## Peer ID of the owning client. +var peer_id: int = -1 +## Team assignment: 0 = Team A, 1 = Team B, -1 = unassigned. +@export var team_id: int = -1 +## Whether this player is alive. +@export var is_alive: bool = true +## Whether this player is in spectator mode. +@export var is_spectating: bool = false +## Current spectate target peer ID. +@export var spectate_target_id: int = 0 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- -var is_local: bool = false # True for the local player instance + +## True for the local player instance. +var is_local: bool = false +## True if this is a client-controlled player (not server). var is_client_controlled: bool = false +## Cached peer ID extracted from node name. +var _cached_peer_id: int = -1 -var _sync_timer: float = 0.0 +# --------------------------------------------------------------------------- +# Netfox node references +# --------------------------------------------------------------------------- -# Interpolation state for remote players -var _remote_position: Vector3 = Vector3.ZERO -var _remote_velocity: Vector3 = Vector3.ZERO +## 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 # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- + func _ready() -> void: + # Determine local player status BEFORE calling super._ready + # (FPSCharacterController._ready sets up _is_local_player based on authority) is_local = multiplayer.get_unique_id() == multiplayer.get_multiplayer_authority() - + if is_local: - # This is our own player — we'll send input to server is_client_controlled = not multiplayer.is_server() if is_client_controlled: print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id()) - else: - # Remote player — disable direct control + + # 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() + + # Set up TickInterpolator for remote players + _setup_tick_interpolator() + + # Set up RollbackHitscanManager for rollback-safe weapon firing + _setup_hitscan_manager() + + # 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()]) + print("[Player] Remote player: %d (authority: %d)" % [ + multiplayer.get_unique_id(), multiplayer.get_multiplayer_authority()]) + + # Set local player flag for FPSCharacterController + _set_local_player(is_local) + + +## Create and configure a RollbackSynchronizer child node. +func _setup_rollback_synchronizer() -> void: + if not Engine.has_singleton(&"NetworkRollback"): + push_warning("[Player] netfox NetworkRollback not available — running without rollback sync") + return + + # 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 + + var rs = _create_rollback_sync_node() + if rs == null: + push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available") + return + + rs.root = self + + # State properties — synced from server to clients + rs.state_properties = [ + "position", + "is_alive", + "team_id", + "spectate_target_id", + ] + + # Input properties — sent from client to server + rs.input_properties = [ + "PlayerNetInput/move_direction", + "PlayerNetInput/look_yaw", + "PlayerNetInput/look_pitch", + "PlayerNetInput/jump", + "PlayerNetInput/sprint", + "PlayerNetInput/crouch", + "PlayerNetInput/shoot", + "PlayerNetInput/aim", + ] + + rs.enable_prediction = true + rs.enable_input_broadcast = false # Only send input to server (not broadcast) + rs.full_state_interval = 24 + rs.diff_ack_interval = 4 + + add_child(rs, true) + _rollback_sync = rs + print("[Player] RollbackSynchronizer added to player %d" % multiplayer.get_unique_id()) + + +## Create a RollbackSynchronizer node via preloaded script (avoids class_name issues). +func _create_rollback_sync_node(): + var RS = load("res://addons/netfox/rollback/rollback-synchronizer.gd") + if RS == null: + return null + var rs = RS.new() + rs.name = "RollbackSynchronizer" + return rs + # --------------------------------------------------------------------------- -# Client-side: send position to server +# TickInterpolator setup # --------------------------------------------------------------------------- -func _process(delta: float) -> void: - if not is_client_controlled: +func _setup_tick_interpolator() -> void: + # Only create TickInterpolator if netfox is available (avoids headless parse errors) + if not Engine.has_singleton(&"NetworkTime"): return - - # Simple movement for Phase 0 testing - var input_dir := Vector3.ZERO - if Input.is_action_pressed("ui_right"): - input_dir.x += 1.0 - if Input.is_action_pressed("ui_left"): - input_dir.x -= 1.0 - if Input.is_action_pressed("ui_up"): - input_dir.z -= 1.0 - if Input.is_action_pressed("ui_down"): - input_dir.z += 1.0 - - if input_dir != Vector3.ZERO: - input_dir = input_dir.normalized() - position += input_dir * movement_speed * delta - - # Rate-limited position update to server - _sync_timer += delta - if _sync_timer >= (1.0 / sync_rate): - _sync_timer = 0.0 - _send_position.rpc_id(1, position) -@rpc("unreliable", "any_peer") -func _send_position(pos: Vector3) -> void: - # Server receives position from client - if not multiplayer.is_server(): + var TIScript = load("res://addons/netfox/tick-interpolator.gd") + if TIScript == null: return - - var peer_id: int = multiplayer.get_remote_sender_id() - if peer_id != multiplayer.get_multiplayer_authority(): - # Peer is not authorized for this player — reject - push_warning("[Player] Unauthorized position update from peer %d" % peer_id) - return - - # Server validates and broadcasts to all other peers - position = pos - _broadcast_position.rpc(pos) -@rpc("unreliable", "authority", "call_local") -func _broadcast_position(pos: Vector3) -> void: - # All peers (including server) update this player's position - if not is_local: - # Remote player — store for interpolation - _remote_position = pos - position = pos + # Check if we already have one + var ti = get_node_or_null("TickInterpolator") + if ti == null: + ti = TIScript.new() + ti.name = "TickInterpolator" + add_child(ti, true) + + ti.root = self + ti.enabled = not is_local # Only interpolate remote players + ti.properties = [ + "position", + "rotation", + ] + ti.enable_recording = false # RollbackSynchronizer pushes state + print("[Player] TickInterpolator %s (local=%s)" % ["enabled" if ti.enabled else "disabled", is_local]) + + +# --------------------------------------------------------------------------- +# PlayerNetInput creation +# --------------------------------------------------------------------------- + +## Ensure a PlayerNetInput child node exists. Creates one dynamically if needed. +func _ensure_input_node() -> void: + if _find_input_node() != null: + return + var pni_script = load("res://scripts/network/player_net_input.gd") + if pni_script == null: + push_warning("[Player] Could not load PlayerNetInput script") + return + var pni = pni_script.new() + pni.name = "PlayerNetInput" + 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 +# --------------------------------------------------------------------------- + +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: - # Local player — authoritative position already set via input - pass + 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) + + +# --------------------------------------------------------------------------- +# 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 + var node_name: String = str(name) + if node_name.begins_with("Player_"): + _cached_peer_id = node_name.trim_prefix("Player_").to_int() + return _cached_peer_id + if node_name.is_valid_int(): + _cached_peer_id = node_name.to_int() + return _cached_peer_id + _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 + is_alive = false + if multiplayer.is_server(): + var pid: int = _get_peer_id() + var round_mgr: Node = _find_round_manager() + 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") + var root: Node = get_tree().root if get_tree() else null + if not root: + return null + for child in root.get_children(): + var found: Node = _find_recursive(child) + if found: + return found + return null + +func _find_recursive(node: Node) -> Node: + if node.name == "RoundManager" or (node.has_method(&"get_state_name") and node.has_method(&"register_player")): + return node + for child in node.get_children(): + var found: Node = _find_recursive(child) + if found: + return found + return null