## Player — netfox rollback-aware player with client-prediction + rollback. ## ## 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 ## ## 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 _rollback_tick / _physics_process). @export var movement_speed: float = 5.0 # --- Standalone movement parameters (used when netfox is not active) --- @export var local_walk_speed: float = 5.0 @export var local_sprint_speed: float = 8.0 @export var local_crouch_speed: float = 2.5 @export var local_jump_velocity: float = 4.5 @export var local_gravity: float = 15.0 @export var local_acceleration: float = 12.0 # --------------------------------------------------------------------------- # Internal look state (for PlayerNetInput yaw/pitch getters) # --------------------------------------------------------------------------- var _yaw: float = 0.0 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 # --------------------------------------------------------------------------- # Look state getters (for PlayerNetInput._gather()) # --------------------------------------------------------------------------- func get_current_yaw() -> float: return _yaw func get_current_pitch() -> float: return _pitch # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: # Determine local player status 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() # Set up RollbackSynchronizer var has_rollback := _setup_rollback_synchronizer() # Set up TickInterpolator for remote players _setup_tick_interpolator() 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 or exported build). # Enable _physics_process as fallback simulation. set_physics_process(true) # Enable _input for mouse look when this is the local player set_process_input(is_local) print("[Player] Player %d — rollback not available, using fallback physics" % multiplayer.get_unique_id()) # --------------------------------------------------------------------------- # Input (mouse look) — used only in fallback mode (no rollback sync) # --------------------------------------------------------------------------- ## Mouse look variables for fallback mode. var _mouse_look_sensitivity: float = 0.003 var _mouse_look_invert_y: bool = false var _mouse_captured: bool = false ## Handle mouse input for looking around in fallback mode. ## Only active when set_process_input(true) was called in _ready. func _input(event: InputEvent) -> void: if not is_local or _rollback_sync != null: return # Mouse look if event is InputEventMouseMotion and _mouse_captured: var rel: Vector2 = event.relative _yaw -= rel.x * _mouse_look_sensitivity var invert: float = -1.0 if _mouse_look_invert_y else 1.0 _pitch += rel.y * _mouse_look_sensitivity * invert _pitch = clamp(_pitch, deg_to_rad(-89.0), deg_to_rad(89.0)) # Click to capture mouse if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and event.pressed: if not _mouse_captured: _capture_mouse_fallback(true) func _unhandled_input(event: InputEvent) -> void: if not is_local or _rollback_sync != null: return # Escape to release mouse if event.is_action_pressed("ui_cancel"): _capture_mouse_fallback(false) func _capture_mouse_fallback(capture: bool) -> void: if capture == _mouse_captured: return _mouse_captured = capture if capture: Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) else: Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) # --------------------------------------------------------------------------- # Fallback physics — used when RollbackSynchronizer is not available # (e.g., headless dedicated server without netfox singletons, or exported builds). # --------------------------------------------------------------------------- func _physics_process(delta: float) -> void: # Only run fallback if RollbackSynchronizer is NOT handling ticks if _rollback_sync != null: return # Gather current frame input into PlayerNetInput (standalone mode) var input_node = _find_input_node() if input_node != null and input_node.has_method(&"_gather"): input_node._gather() # Read input from PlayerNetInput (if we're the authority) 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. ## 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 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 true var rs = _create_rollback_sync_node() if rs == null: push_warning("[Player] Could not create RollbackSynchronizer — netfox types not available") return false 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()) return true ## 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 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") # --------------------------------------------------------------------------- # 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: if not is_fresh and not _is_rollback_enabled(): return 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 # --------------------------------------------------------------------------- func _find_input_node(): for child in get_children(): if child.name == "PlayerNetInput": return child return null # --------------------------------------------------------------------------- # Snap player to position after rollback reconciliation # --------------------------------------------------------------------------- func snap_to_position(pos: Vector3, yaw: float) -> void: global_position = pos _yaw = yaw rotation.y = yaw # --------------------------------------------------------------------------- # Round system: team / life management # --------------------------------------------------------------------------- func set_team(new_team: int) -> void: team_id = new_team 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]) 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 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) func mark_alive() -> void: is_alive = true is_spectating = false spectate_target_id = 0 func set_spectate_target(target_id: int) -> void: spectate_target_id = target_id is_spectating = target_id > 0 or not is_alive 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