a69c60ce85
- Changed run/main_scene to entry.tscn (smart bootstrap detects headless vs display) - client_main.gd now uses player.tscn instead of old FPS template - Added mouse look (click-to-capture, escape-to-release) to player.gd fallback path - Added _gather() call to player.gd fallback _physics_process() so PlayerNetInput is populated even without NetworkTime singleton (exported builds) - Re-exported Windows build with same tag v0.1.0-windows
436 lines
15 KiB
GDScript
436 lines
15 KiB
GDScript
## 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
|