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.
This commit is contained in:
+375
-65
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user