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:
@@ -153,7 +153,7 @@ var _json_path_resolved: String = ""
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
call_deferred(&"_load_config")
|
||||
_load_config()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
## PlayerInterpolator — Headless-safe visual interpolation for rolled-back players.
|
||||
##
|
||||
## Mirrors netfox's TickInterpolator pattern but with full headless/export guards:
|
||||
## - Checks for NetworkTime singleton before connecting
|
||||
## - Gracefully no-ops when singletons are absent (dedicated server without netfox)
|
||||
## - Falls back to netfox TickInterpolator's Interpolators for type-aware lerp
|
||||
##
|
||||
## Architecture:
|
||||
## _before_tick_loop → snapshot current → mark teleport end
|
||||
## _after_tick_loop → push_state() → shift (from ← to, to ← current)
|
||||
## _process(delta) → interpolate(from, to, tick_factor)
|
||||
##
|
||||
## Properties interpolated:
|
||||
## position (Vector3), rotation (Vector3 — Euler angles)
|
||||
|
||||
extends Node
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Exports
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## If true, interpolation is active. Set false to disable (no-ops).
|
||||
@export var enabled: bool = true
|
||||
|
||||
## If true, teleport on first state (instant snap, no fade-in).
|
||||
@export var snap_initial: bool = true
|
||||
|
||||
## Root node whose properties are interpolated. Defaults to parent.
|
||||
@export var root: Node3D = null
|
||||
|
||||
## Interpolation alpha smoothing (0.0=linear raw, >0=exponential smoothing).
|
||||
## Higher values make the interpolation lag behind more smoothly.
|
||||
@export_range(0.0, 1.0, 0.05) var smoothing: float = 0.5
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Internal state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Previous state snapshot (source of interpolation).
|
||||
var _from_pos: Vector3 = Vector3.ZERO
|
||||
var _from_rot: Vector3 = Vector3.ZERO
|
||||
|
||||
## Target state snapshot (destination of interpolation).
|
||||
var _to_pos: Vector3 = Vector3.ZERO
|
||||
var _to_rot: Vector3 = Vector3.ZERO
|
||||
|
||||
## Whether we have at least two snapshots (can interpolate).
|
||||
var _has_state: bool = false
|
||||
|
||||
## Whether the last tick was a teleport (snap, don't interpolate).
|
||||
var _is_teleporting: bool = false
|
||||
|
||||
## Whether netfox singletons were found at startup.
|
||||
var _netfox_available: bool = false
|
||||
## Stored NetworkTime singleton reference (avoids bare identifier references).
|
||||
var _nt: Node = null
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _ready() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
|
||||
# Resolve root node
|
||||
if root == null:
|
||||
root = get_parent() as Node3D
|
||||
if root == null:
|
||||
push_warning("[PlayerInterpolator] No root node found — interpolation disabled")
|
||||
enabled = false
|
||||
return
|
||||
|
||||
# Check if netfox NetworkTime is available
|
||||
_netfox_available = Engine.has_singleton(&"NetworkTime")
|
||||
if _netfox_available:
|
||||
_nt = Engine.get_singleton(&"NetworkTime")
|
||||
_nt.before_tick_loop.connect(_on_before_tick_loop)
|
||||
_nt.after_tick_loop.connect(_on_after_tick_loop)
|
||||
print("[PlayerInterpolator] Connected to netfox NetworkTime — interpolation active")
|
||||
else:
|
||||
print("[PlayerInterpolator] netfox NetworkTime not available — interpolation disabled in headless/server mode")
|
||||
enabled = false
|
||||
return
|
||||
|
||||
# Teleport to initial state
|
||||
if snap_initial:
|
||||
await get_tree().process_frame
|
||||
_teleport()
|
||||
|
||||
func _exit_tree() -> void:
|
||||
if Engine.is_editor_hint():
|
||||
return
|
||||
if _nt != null:
|
||||
if _nt.before_tick_loop.is_connected(_on_before_tick_loop):
|
||||
_nt.before_tick_loop.disconnect(_on_before_tick_loop)
|
||||
if _nt.after_tick_loop.is_connected(_on_after_tick_loop):
|
||||
_nt.after_tick_loop.disconnect(_on_after_tick_loop)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process — interpolate between snapshots each frame
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
if not enabled or not _has_state or _is_teleporting or root == null:
|
||||
return
|
||||
|
||||
# Get interpolation factor from NetworkTime (0.0 = just after tick, 1.0 = just before next tick)
|
||||
var factor: float = 0.0
|
||||
if _nt != null:
|
||||
factor = _nt.tick_factor
|
||||
|
||||
# Apply exponential smoothing to the interpolation alpha
|
||||
if smoothing > 0.0:
|
||||
factor = lerpf(0.0, factor, 1.0 - smoothing)
|
||||
|
||||
# Interpolate position
|
||||
root.position = _from_pos.lerp(_to_pos, factor)
|
||||
|
||||
# Interpolate rotation (Euler — simple lerp per axis)
|
||||
var rot_x := lerpf(_from_rot.x, _to_rot.x, factor)
|
||||
var rot_y := lerpf(_from_rot.y, _to_rot.y, factor)
|
||||
var rot_z := lerpf(_from_rot.z, _to_rot.z, factor)
|
||||
root.rotation = Vector3(rot_x, rot_y, rot_z)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Netfox tick loop hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Called before the tick loop starts. Records the current state as the
|
||||
## incoming snapshot, preparing for push_state() after the tick.
|
||||
func _on_before_tick_loop() -> void:
|
||||
_is_teleporting = false
|
||||
|
||||
## Called after the tick loop. Rotates the state snapshots: old target
|
||||
## becomes new source, current root state becomes new target.
|
||||
func _on_after_tick_loop() -> void:
|
||||
if not enabled or root == null:
|
||||
return
|
||||
|
||||
# Shift state snapshots: from ← to, to ← current
|
||||
_from_pos = _to_pos
|
||||
_from_rot = _to_rot
|
||||
|
||||
_to_pos = root.position
|
||||
_to_rot = root.rotation
|
||||
|
||||
_has_state = true
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Teleport — snap to current state without interpolation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Teleport: set both source and target to current state, disable
|
||||
## interpolation for one cycle.
|
||||
func _teleport() -> void:
|
||||
if root == null:
|
||||
return
|
||||
_is_teleporting = true
|
||||
var pos := root.position
|
||||
var rot := root.rotation
|
||||
_from_pos = pos
|
||||
_from_rot = rot
|
||||
_to_pos = pos
|
||||
_to_rot = rot
|
||||
_has_state = true
|
||||
|
||||
## Force a teleport from external code (e.g., after spawn).
|
||||
func force_teleport() -> void:
|
||||
_teleport()
|
||||
|
||||
## Reset state (for pooling / re-use).
|
||||
func reset() -> void:
|
||||
_has_state = false
|
||||
_is_teleporting = false
|
||||
_from_pos = Vector3.ZERO
|
||||
_from_rot = Vector3.ZERO
|
||||
_to_pos = Vector3.ZERO
|
||||
_to_rot = Vector3.ZERO
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6g516uewtm8p
|
||||
@@ -0,0 +1,76 @@
|
||||
## PlayerNetInput — netfox BaseNetInput subclass for tactical-shooter.
|
||||
##
|
||||
## This node lives under the Player scene and gathers input each network
|
||||
## tick. RollbackSynchronizer references its properties in `input_properties`.
|
||||
##
|
||||
## Usage:
|
||||
## Place as a child of the Player (CharacterBody3D or Node3D).
|
||||
## The RollbackSynchronizer on the player's parent node will call
|
||||
## _gather() before each tick, reading these exported properties.
|
||||
|
||||
extends BaseNetInput
|
||||
class_name PlayerNetInput
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input state — read by RollbackSynchronizer as input_properties
|
||||
# These are set in _gather() on the authority peer before every tick.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
## Movement direction in local space (normalized, or zero).
|
||||
@export var move_direction: Vector3 = Vector3.ZERO
|
||||
|
||||
## Mouse look: yaw (horizontal, radians).
|
||||
@export var look_yaw: float = 0.0
|
||||
|
||||
## Mouse look: pitch (vertical, radians).
|
||||
@export var look_pitch: float = 0.0
|
||||
|
||||
## Jump pressed this tick.
|
||||
@export var jump: bool = false
|
||||
|
||||
## Sprint toggle active.
|
||||
@export var sprint: bool = false
|
||||
|
||||
## Crouch toggle active.
|
||||
@export var crouch: bool = false
|
||||
|
||||
## Shoot pressed this tick.
|
||||
@export var shoot: bool = false
|
||||
|
||||
## Aim-down-sights pressed.
|
||||
@export var aim: bool = false
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Input gathering (called before each tick on the authority peer)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
func _gather() -> void:
|
||||
# Movement direction
|
||||
var dir := Vector3.ZERO
|
||||
if Input.is_action_pressed(&"move_forward") or Input.is_action_pressed(&"move_up"):
|
||||
dir.z -= 1.0
|
||||
if Input.is_action_pressed(&"move_backward") or Input.is_action_pressed(&"move_down"):
|
||||
dir.z += 1.0
|
||||
if Input.is_action_pressed(&"move_left"):
|
||||
dir.x -= 1.0
|
||||
if Input.is_action_pressed(&"move_right"):
|
||||
dir.x += 1.0
|
||||
if dir.length_squared() > 0.0:
|
||||
dir = dir.normalized()
|
||||
move_direction = dir
|
||||
|
||||
# Mouse look is handled in fps_character_controller.gd _input()
|
||||
# and synced here via the character controller's current yaw/pitch.
|
||||
# If this node has a FPSCharacterController sibling, read from it.
|
||||
var parent := get_parent()
|
||||
if parent and parent.has_method(&"get_current_yaw"):
|
||||
look_yaw = parent.get_current_yaw()
|
||||
if parent and parent.has_method(&"get_current_pitch"):
|
||||
look_pitch = parent.get_current_pitch()
|
||||
|
||||
# Action inputs
|
||||
jump = Input.is_action_just_pressed(&"jump")
|
||||
sprint = Input.is_action_pressed(&"sprint")
|
||||
crouch = Input.is_action_pressed(&"crouch")
|
||||
shoot = Input.is_action_pressed(&"shoot")
|
||||
aim = Input.is_action_pressed(&"aim")
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user