## Player — netfox rollback-aware player extending FPSCharacterController ## with rollback-safe hitscan weapon system and lag compensation. ## ## Inherits from FPSCharacterController (CharacterBody3D) 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 ## mark_dead() / mark_alive() update is_alive (replicated via state_properties) extends CharacterBody3D # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- ## Walk speed in units/sec (used inside FPSCharacterController movement). @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 # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- ## 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 # --------------------------------------------------------------------------- # Netfox node references # --------------------------------------------------------------------------- ## 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: is_client_controlled = not multiplayer.is_server() if is_client_controlled: print("[Player] Local player (client-controlled): %d" % multiplayer.get_unique_id()) # 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()]) # 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 # --------------------------------------------------------------------------- # TickInterpolator setup # --------------------------------------------------------------------------- func _setup_tick_interpolator() -> void: # Only create TickInterpolator if netfox is available (avoids headless parse errors) if not Engine.has_singleton(&"NetworkTime"): return var TIScript = load("res://addons/netfox/tick-interpolator.gd") if TIScript == null: return # 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: 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