## 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