Phase 7: netfox + godot-jolt stack upgrade

Stack installed:
- netfox v1.35.3 (core + extras + noray + internals)
- godot-jolt v0.16.0-stable

Architecture:
- Server: ENet transport (works headless, no netfox deps)
- Client/Editor: netfox rollback (RollbackSynchronizer, TickInterpolator)

New/modified:
- docs/migration-netfox-plan.md — migration architecture
- scripts/network/network_manager.gd — netfox-aware ENet fallback
- scripts/network/player.gd — clean base player
- client/characters/player_netfox.gd — rollback player w/ WeaponManager
- client/characters/input/player_net_input.gd — BaseNetInput subclass
- client/characters/character/fps_character_controller.gd — netfox input feed
- client/weapons/ — weapon data, registry, TacticalWeaponHitscan, WeaponManager
- client/scripts/round_replicator.gd — client-side round state bridge
- server/scripts/round_manager.gd — improved state machine
- server/scripts/plugin_api/plugin_manager.gd — refined plugin system
- config: enemy_tag, ally_tag for meatball targeting

Removed: old C++ SimulationServer GDExtension (replaced by netfox rollback)
This commit is contained in:
2026-07-02 17:38:50 -04:00
parent e2dc429caa
commit e7299b17e9
3237 changed files with 523530 additions and 18 deletions
+8 -8
View File
@@ -51,8 +51,8 @@ extends Camera3D
# Internal
# ---------------------------------------------------------------------------
## Reference to parent controller.
var _controller: FPSCharacterController = null
## Reference to parent controller (duck-typed — uses has_method instead of class_name).
var _controller: Node = null
## Bob time accumulator.
var _bob_time: float = 0.0
@@ -82,9 +82,9 @@ var _base_eye_y: float = 0.0
# ---------------------------------------------------------------------------
func _ready() -> void:
_controller = get_parent() as FPSCharacterController
if _controller == null:
push_warning("FpsCamera: Parent is not an FPSCharacterController. View features disabled.")
_controller = get_parent()
if _controller == null or not _controller.has_method(&"is_sprinting"):
push_warning("FpsCamera: Parent has no is_sprinting() — view features disabled.")
_base_fov = fov
@@ -172,9 +172,9 @@ func _update_bob(delta: float) -> void:
func _update_sway(delta: float) -> void:
# Sway follows mouse movement indirectly via controller yaw/pitch changes
# Use velocity for physics-based sway: recentre over time
var vel: Vector3 = _controller.velocity
var sway_h := clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
var sway_v := clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
var vel := _controller.velocity as Vector3
var sway_h: float = clamp(vel.x * 0.003, -sway_max_rotation, sway_max_rotation)
var sway_v: float = clamp(vel.z * 0.003, -sway_max_rotation, sway_max_rotation)
# Add a tiny amount from pitch/yaw delta (not mouse, from movement direction change)
_sway_target = Vector2(
move_toward(_sway_target.x, sway_h, sway_response * delta),
@@ -0,0 +1,29 @@
## PlayerNetInput — netfox BaseNetInput subclass for the tactical shooter.
##
## Collects player input each network tick for the rollback system.
## Referenced by RollbackSynchronizer's `input_properties`.
##
## This script is ONLY loaded from client scenes where netfox is active.
extends "res://addons/netfox.extras/base-net-input.gd"
class_name PlayerNetInput
# Movement
var move_direction: Vector3
var look_yaw: float
var look_pitch: float
# Actions
var jump: bool
var sprint: bool
var crouch: bool
var shoot: bool
var aim: bool
# Weapon
var weapon_slot: int = -1 # -1 = no change, 0-4 for slot index
var reload: bool
var use: bool # interact / plant / defuse
# Input sequence for reconciliation
var input_sequence: int
+210
View File
@@ -0,0 +1,210 @@
## PlayerNetfox — netfox rollback-aware player for the tactical-shooter client.
##
## Extends the base Player with netfox RollbackSynchronizer, TickInterpolator,
## BaseNetInput, and WeaponManager for deterministic first-person combat.
##
## This script is ONLY loaded from client scenes where netfox is active.
## The headless server uses the base player.gd which has no netfox deps.
extends "res://scripts/network/player.gd"
# ---------------------------------------------------------------------------
# Netfox node references
# ---------------------------------------------------------------------------
@onready var _tick_interpolator := $TickInterpolator as TickInterpolator
@onready var _input := $PlayerNetInput as PlayerNetInput
@onready var _weapon_manager := $WeaponManager as WeaponManager
@onready var _weapon_anchor := $WeaponAnchor as Node3D
var _rs: RollbackSynchronizer
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
var _gravity: float = ProjectSettings.get_setting(&"physics/3d/default_gravity", 9.8)
var _is_local: bool = false
var _camera: Camera3D
var _mouse_captured: bool = true
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
super._ready()
if Engine.has_singleton(&"NetworkTime") and Engine.has_singleton(&"NetworkRollback"):
_setup_rollback()
_connect_netfox_signals()
print("[PlayerNetfox] Rollback initialised for %s" % name)
else:
push_warning("[PlayerNetfox] netfox not available running without rollback")
set_process(false)
set_physics_process(false)
_camera = get_viewport().get_camera_3d() if get_viewport() else null
# ---------------------------------------------------------------------------
# Rollback setup
# ---------------------------------------------------------------------------
func _setup_rollback() -> void:
_rs = RollbackSynchronizer.new()
_rs.name = "RollbackSynchronizer"
_rs.state_properties = PackedStringArray([
"global_position", "is_alive", "team_id",
"is_spectating", "spectate_target_id",
])
_rs.input_properties = PackedStringArray([
"PlayerNetInput/move_direction", "PlayerNetInput/look_yaw",
"PlayerNetInput/look_pitch", "PlayerNetInput/jump",
"PlayerNetInput/sprint", "PlayerNetInput/crouch",
"PlayerNetInput/shoot", "PlayerNetInput/aim",
"PlayerNetInput/input_sequence", "PlayerNetInput/weapon_slot",
"PlayerNetInput/reload", "PlayerNetInput/use",
])
add_child(_rs, true)
_rs.rollback_ticks.connect(_rollback_tick)
func _connect_netfox_signals() -> void:
var nt = Engine.get_singleton(&"NetworkTime")
if nt:
nt.before_tick_loop.connect(_before_tick_loop)
nt.after_tick_loop.connect(_after_tick_loop)
# ---------------------------------------------------------------------------
# Rollback hooks
# ---------------------------------------------------------------------------
func _before_tick_loop() -> void:
if _tick_interpolator and _tick_interpolator.enabled:
_tick_interpolator.save_transform()
func _after_tick_loop() -> void:
if _tick_interpolator and _tick_interpolator.enabled:
_tick_interpolator.restore_transform()
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
if not is_alive:
return
var input := _input
# Look rotation
rotate_object_local(Vector3(0, 1, 0), input.look_yaw)
# Gravity + jump
_force_update_is_on_floor()
if is_on_floor():
if input.jump:
velocity.y = ServerConfig.movement_jump_velocity if ServerConfig else 6.0
else:
velocity.y -= _gravity * delta
# Speed
var speed := 5.0
if ServerConfig:
speed = ServerConfig.movement_walk_speed
if input.sprint:
speed = ServerConfig.movement_sprint_speed if ServerConfig else 7.0
if input.crouch:
speed = ServerConfig.movement_crouch_speed if ServerConfig else 2.5
# Horizontal movement
var dir := (transform.basis * input.move_direction).normalized()
if dir.length() > 0.0:
velocity.x = dir.x * speed
velocity.z = dir.z * speed
else:
velocity.x = move_toward(velocity.x, 0.0, speed * 2.0)
velocity.z = move_toward(velocity.z, 0.0, speed * 2.0)
move_and_slide()
func _force_update_is_on_floor() -> void:
var old := velocity
velocity = Vector3.ZERO
move_and_slide()
velocity = old
# ---------------------------------------------------------------------------
# Weapon system
# ---------------------------------------------------------------------------
## Give starting weapons (called by spawner).
func give_default_weapons(team_id: int) -> void:
if _weapon_manager == null:
return
# Always give knife
_weapon_manager.add_weapon(WeaponRegistry.get_by_id(WeaponRegistry.ID_KNIFE))
# Give team-default sidearm
var default_wep := WeaponRegistry.get_default_weapon(team_id)
_weapon_manager.add_weapon(default_wep)
## Handle weapon slot switching from input.
func _handle_weapon_input(input: PlayerNetInput) -> void:
if _weapon_manager == null:
return
if input.reload:
_weapon_manager.reload()
if input.weapon_slot >= 0:
_weapon_manager.switch_to_slot(input.weapon_slot)
# Reset so it doesn't keep switching
input.weapon_slot = -1
if input.shoot:
_weapon_manager.fire()
# ---------------------------------------------------------------------------
# Input handling (frame-rate)
# ---------------------------------------------------------------------------
func _input(event: InputEvent) -> void:
if not _is_local or not _mouse_captured:
return
if _input == null:
return
if event is InputEventMouseMotion:
_input.look_yaw -= event.relative.x * 0.005
_input.look_pitch = clamp(_input.look_pitch - event.relative.y * 0.005 * 0.8, -1.57, 1.57)
if event is InputEventMouseButton and event.pressed:
if event.button_index == MOUSE_BUTTON_LEFT:
_input.shoot = true
func _unhandled_key_input(event: InputEvent) -> void:
if not _is_local:
return
if event.is_action_pressed(&"reload"):
_input.reload = true
elif event.is_action_pressed(&"shoot"):
_input.shoot = true
# Weapon slots 1-4
for i in range(4):
if event.is_action_pressed(&"weapon_slot_%d" % (i + 1)):
_input.weapon_slot = i
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
func mark_local() -> void:
_is_local = true
if _tick_interpolator:
_tick_interpolator.enabled = false
if _input:
_input.set_process(true)
Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED)
func apply_damage(amount: int, attacker_id: int = -1, weapon_id: int = 0) -> void:
health -= amount
if health <= 0:
health = 0
is_alive = false
print("[PlayerNetfox] %s killed by peer %d with weapon %d" % [name, attacker_id, weapon_id])