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
+361
View File
@@ -0,0 +1,361 @@
## RollbackHitscanManager — rollback-safe hitscan weapon with lag compensation.
##
## Uses netfox RewindableAction to track fire state per tick inside the
## rollback loop, enabling server-authoritative hit detection with physics
## rewound to the exact tick when the client fired.
##
## Key capabilities:
## - Server-authoritative raycast at the rewound tick (lag compensation)
## - Per-limb damage system (head/chest/waist/legs) via collision groups
## - RewindableAction for fire state sync across the rollback loop
## - Client prediction support via fire_performed signal
##
## Usage:
## Place as a child of the Player node (sibling of RollbackSynchronizer).
## Call process_fire() from _rollback_tick() every tick.
## Call record_fire() from input gathering when shoot is pressed.
##
## Headless safety:
## - No class_name references — uses load() + has_method() duck-typing
## - Graceful degradation when netfox types are unavailable
## - Falls back to direct input-based firing without RewindableAction
extends Node
# ---------------------------------------------------------------------------
# Signals
# ---------------------------------------------------------------------------
## Emitted when a shot connects on the server or for client prediction.
signal hit_registered(tick: int, hit_position: Vector3, hit_normal: Vector3, collider: Object, final_damage: int)
## Emitted when a shot misses entirely.
signal shot_missed(tick: int)
## Emitted on any fire attempt (hit or miss) — for muzzle flash, sound, etc.
signal weapon_fired(tick: int, origin: Vector3, direction: Vector3)
# ---------------------------------------------------------------------------
# Weapon configuration
# ---------------------------------------------------------------------------
## Weapon data (WeaponData resource) — set when weapon switches
var current_weapon_data: Resource = null
## Maximum raycast distance — updated from current_weapon_data
var max_distance: float = 1000.0
## Eye height above player origin for reconstructing fire origin
var eye_height: float = 1.7
# ---------------------------------------------------------------------------
# RewindableAction reference
# ---------------------------------------------------------------------------
var _fire_action: Node = null
var _has_rewindable_action: bool = false
# ---------------------------------------------------------------------------
# Server-side state
# ---------------------------------------------------------------------------
## Tracks which ticks have already had damage applied, preventing
## double-processing during rollback re-simulation.
var _processed_damage_ticks: Dictionary = {}
# ---------------------------------------------------------------------------
# Player reference (cached parent)
# ---------------------------------------------------------------------------
var _player_node: Node3D = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
_player_node = get_parent() as Node3D
if _player_node == null:
push_warning("[RollbackHitscan] Parent is not a Node3D")
return
_setup_fire_action()
## Create the RewindableAction child node for per-tick fire tracking.
func _setup_fire_action() -> void:
if not Engine.has_singleton(&"NetworkRollback"):
push_warning("[RollbackHitscan] NetworkRollback not available — falling back to direct fire tracking")
return
var ActionScript = load("res://addons/netfox/rewindable-action.gd")
if ActionScript == null:
push_warning("[RollbackHitscan] RewindableAction script not found")
return
_fire_action = ActionScript.new()
if _fire_action == null:
return
_fire_action.name = "HitscanFireAction"
add_child(_fire_action, true)
_has_rewindable_action = true
print("[RollbackHitscan] RewindableAction created for %s" % _player_node.name)
# ---------------------------------------------------------------------------
# Public API — called from input gathering
# ---------------------------------------------------------------------------
## Record a fire event for the given tick.
##
## Called during input gathering (before the tick) when the client presses
## shoot. Stores the precise fire origin and direction as context in the
## RewindableAction so it survives rollback re-simulation.
##
## Parameters:
## tick: The network tick when the shot occurred
## origin: World-space raycast origin (typically camera/eye position)
## direction: World-space normalized raycast direction
func record_fire(tick: int, origin: Vector3, direction: Vector3) -> void:
if _has_rewindable_action and _fire_action != null:
_fire_action.set_active(true, tick)
_fire_action.set_context({
"origin": origin,
"direction": direction,
}, tick)
# ---------------------------------------------------------------------------
# Public API — called from _rollback_tick()
# ---------------------------------------------------------------------------
## Process weapon firing for the given tick.
##
## This is the core method called from the player's _rollback_tick() every
## network tick. On the server, it performs a lag-compensated raycast and
## applies per-limb damage. On all peers, it emits signals for effects.
##
## Returns: Dictionary with hit result (empty if no hit or no shot).
## - {collider, position, normal, ...} from PhysicsDirectSpaceState3D.intersect_ray
## - empty dict if no shot was fired this tick
##
## Parameters:
## tick: The current network tick
## is_server: Whether this peer is the server (authority)
## input_node: The PlayerNetInput node (must have .shoot, .look_yaw, .look_pitch)
## weapon_data: WeaponData resource for this tick (nil = use current_weapon_data)
func process_fire(tick: int, is_server: bool, input_node: Node, weapon_data: Resource = null) -> Dictionary:
if _player_node == null:
return {}
# Use passed-in weapon data or fall back to current
var wd: Resource = weapon_data if weapon_data != null else current_weapon_data
var effective_max_dist: float = max_distance
if wd != null and wd.has_method(&"get") and wd.get("max_distance") != null:
effective_max_dist = wd.get("max_distance")
# Check if the player is trying to shoot this tick
var shoot: bool = _is_shooting(tick, input_node)
if not shoot:
return {}
# Prevent double-processing damage during re-simulation.
# On the server, we only apply damage once per tick per shot.
if is_server and _processed_damage_ticks.has(tick):
return {}
# Reconstruct fire origin and direction
var origin: Vector3
var direction: Vector3
var fire_params: Dictionary = _get_fire_params(tick, input_node, effective_max_dist)
origin = fire_params.get("origin", _player_node.global_position + Vector3(0, eye_height, 0))
direction = fire_params.get("direction", -_player_node.global_basis.z)
# Perform the physics raycast
var space_state: PhysicsDirectSpaceState3D = null
if _player_node.get_world_3d() != null:
space_state = _player_node.get_world_3d().direct_space_state
var hit: Dictionary = {}
if space_state != null:
hit = _perform_raycast(space_state, origin, direction, effective_max_dist)
# Emit fire event for effects
weapon_fired.emit(tick, origin, direction)
# Server-authoritative damage application
if is_server:
_processed_damage_ticks[tick] = true
if not hit.is_empty():
var final_damage: int = _apply_damage(hit, wd)
hit_registered.emit(tick, hit.get("position", Vector3.ZERO),
hit.get("normal", Vector3.UP), hit.get("collider"), final_damage)
else:
shot_missed.emit(tick)
return hit
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
## Check if the player is shooting this tick.
## Uses RewindableAction if available, otherwise falls back to input_node.shoot.
func _is_shooting(tick: int, input_node: Node) -> bool:
# Prefer RewindableAction for accurate per-tick tracking
if _has_rewindable_action and _fire_action != null:
if _fire_action.has_method(&"is_active"):
return _fire_action.is_active(tick)
# Fallback: check raw input
if input_node != null and input_node.has_method(&"get"):
return input_node.get(&"shoot") or false
return false
## Get fire parameters (origin, direction) for the given tick.
## Uses RewindableAction context if available, otherwise reconstructs from
## player position + camera yaw/pitch at the tick.
func _get_fire_params(tick: int, input_node: Node, max_dist: float) -> Dictionary:
# Try RewindableAction context first (most precise)
if _has_rewindable_action and _fire_action != null:
if _fire_action.has_method(&"get_context"):
var ctx = _fire_action.get_context(tick)
if ctx != null and typeof(ctx) == TYPE_DICTIONARY:
var o = ctx.get("origin")
var d = ctx.get("direction")
if o != null and d != null:
return {"origin": o, "direction": d}
# Reconstruct from player position + look angles
var yaw: float = 0.0
var pitch: float = 0.0
if input_node != null:
yaw = input_node.get(&"look_yaw") if input_node.has_method(&"get") else 0.0
pitch = input_node.get(&"look_pitch") if input_node.has_method(&"get") else 0.0
var origin := _player_node.global_position + Vector3(0, eye_height, 0)
var direction := _yaw_pitch_to_dir(yaw, pitch)
return {"origin": origin, "direction": direction}
## Perform a physics raycast with the given parameters.
func _perform_raycast(space_state: PhysicsDirectSpaceState3D, origin: Vector3,
direction: Vector3, max_dist: float) -> Dictionary:
var params = PhysicsRayQueryParameters3D.new()
if params == null:
return {}
params.from = origin
params.to = origin + direction * max_dist
params.collision_mask = 0xFFFFFFFF
# Exclude the shooter's own collision body
var exclude_list: Array[RID] = []
exclude_list.append_array(_get_player_collision_r_ids())
if exclude_list.size() > 0:
params.exclude = exclude_list
return space_state.intersect_ray(params)
## Get RIDs of collision shapes on the player node to exclude from raycast.
func _get_player_collision_r_ids() -> Array[RID]:
var rids: Array[RID] = []
if _player_node == null:
return rids
for child in _player_node.get_children():
if child is CollisionShape3D and child.shape != null:
rids.append(child.shape.get_rid())
elif child is CollisionPolygon3D:
rids.append(child.get_rid())
return rids
## Apply per-limb damage to the hit player.
## Returns the final damage amount applied.
func _apply_damage(hit: Dictionary, wd: Resource) -> int:
var collider = hit.get("collider")
if collider == null or wd == null:
return 0
var multiplier: float = _get_limb_multiplier(collider, wd)
var base_damage: int = wd.get("damage") if wd.has_method(&"get") else 0
var final_damage := int(base_damage * multiplier)
# Find the player node from the collider
var hit_player = _find_player(collider)
if hit_player == null:
return 0
if not hit_player.has_method(&"apply_damage"):
return 0
var owner_id: int = 0
if _player_node != null and _player_node.has_method(&"get"):
owner_id = _player_node.get(&"peer_id") if _player_node.get(&"peer_id") != null else 0
var weapon_id: int = wd.get("weapon_id") if wd.has_method(&"get") else 0
hit_player.apply_damage(final_damage, owner_id, weapon_id)
print("[RollbackHitscan] Tick %d: %d dmg (%d base x %.1f) to %s by peer %d" % [
Engine.get_singleton(&"NetworkTime").tick if Engine.has_singleton(&"NetworkTime") else -1,
final_damage, base_damage, multiplier, hit_player.name, owner_id])
return final_damage
## Determine the damage multiplier for the hit limb.
##
## Colliders should be in collision groups:
## "head" → headshot_multiplier
## "chest" → chest_multiplier (default)
## "waist" → waist_multiplier
## "legs" → leg_multiplier
func _get_limb_multiplier(collider: Node, wd: Resource) -> float:
if wd == null:
return 1.0
var head: float = wd.get("headshot_multiplier") if wd.has_method(&"get") else 2.0
var chest: float = wd.get("chest_multiplier") if wd.has_method(&"get") else 1.0
var waist: float = wd.get("waist_multiplier") if wd.has_method(&"get") else 0.75
var legs: float = wd.get("leg_multiplier") if wd.has_method(&"get") else 0.5
if collider.is_in_group(&"head"):
return head
elif collider.is_in_group(&"chest"):
return chest
elif collider.is_in_group(&"waist"):
return waist
elif collider.is_in_group(&"legs"):
return legs
return chest
## Walk up the scene tree from a collider to find a player node.
func _find_player(node: Node) -> Node:
var current = node
while current != null:
if current.has_method(&"apply_damage") or current.is_in_group(&"players"):
return current
current = current.get_parent()
return null
## Convert yaw (radians) and pitch (radians) to a forward direction vector.
## Uses standard FPS convention: yaw around Y axis, pitch around X axis.
static func _yaw_pitch_to_dir(yaw: float, pitch: float) -> Vector3:
var cos_pitch := cos(pitch)
return Vector3(
cos_pitch * sin(yaw),
sin(pitch),
cos_pitch * cos(yaw)
)
# ---------------------------------------------------------------------------
# State management
# ---------------------------------------------------------------------------
## Reset processed ticks history (e.g. on round restart).
## Also resets the RewindableAction state.
func reset() -> void:
_processed_damage_ticks.clear()
## Update weapon data when the player switches weapons.
func set_weapon_data(wd: Resource) -> void:
current_weapon_data = wd
if wd != null and wd.has_method(&"get"):
var md = wd.get("max_distance")
if md != null:
max_distance = md
## Get the RewindableAction node (for external inspection, e.g. debug UI).
func get_fire_action():
return _fire_action
@@ -0,0 +1 @@
uid://6gnaotpppgvd