## WeaponManager — client-side weapon inventory with rollback-safe firing. ## ## Manages weapon switching, ammo tracking. Firing is handled by the ## RollbackHitscanManager (in _rollback_tick) for lag compensation. ## ## This script is ONLY loaded from client scenes where netfox is active. extends Node class_name WeaponManager signal weapon_switched(old_slot: int, new_slot: int, weapon_data: WeaponData) signal ammo_changed(weapon_id: int, magazine: int, reserve: int) # Preload the weapon scripts (only works when netfox is active) const _HitscanWeapon := preload("res://client/weapons/scripts/tactical_weapon_hitscan.gd") ## Reference to the player's weapon anchor node. @export var weapon_anchor: Node3D ## Reference to the RollbackHitscanManager (set by player). var rollback_hitscan_mgr: Node = null ## Current weapon data. var current_weapon: WeaponData = null var current_weapon_node: Node3D = null var current_slot: int = 1 ## Inventory: slot → { data, node, magazine, reserve } var _inventory: Dictionary = {} var _last_fire_time: float = 0.0 func add_weapon(data: WeaponData) -> void: var slot: int = data.slot if _inventory.has(slot): _remove_weapon(slot) var weapon_node: Node3D = _create_weapon(data) if weapon_node == null: return weapon_node.visible = false add_child(weapon_node) _inventory[slot] = { "data": data, "node": weapon_node, "magazine": data.magazine_size, "reserve": data.reserve_size, } if current_weapon == null: switch_to_slot(slot) func remove_weapon(slot: int) -> void: _remove_weapon(slot) if current_slot == slot: _switch_to_next_available() func switch_to_slot(slot: int) -> bool: if not _inventory.has(slot): return false var entry = _inventory[slot] var old_slot := current_slot current_slot = slot current_weapon = entry["data"] as WeaponData current_weapon_node = entry["node"] as Node3D _update_visibility() weapon_switched.emit(old_slot, slot, current_weapon) # Update the RollbackHitscanManager with the new weapon data _notify_hitscan_mgr_of_weapon_change(current_weapon) return true func switch_next() -> void: var slots := _inventory.keys() slots.sort() var idx := slots.find(current_slot) if idx >= 0 and idx + 1 < slots.size(): switch_to_slot(slots[idx + 1]) func switch_prev() -> void: var slots := _inventory.keys() slots.sort() var idx := slots.find(current_slot) if idx > 0: switch_to_slot(slots[idx - 1]) # --------------------------------------------------------------------------- # Firing (rollback-safe — delegates to RollbackHitscanManager) # --------------------------------------------------------------------------- ## Called by the client to attempt firing through the rollback system. ## Returns true if ammo was consumed. func fire() -> bool: if current_weapon_node == null or current_weapon == null: return false if current_weapon.magazine_size > 0 and _get_magazine() <= 0: return false if not _check_fire_rate(): return false # Consume ammo immediately for client prediction var entry = _inventory[current_slot] entry["magazine"] -= 1 ammo_changed.emit(current_weapon.weapon_id, entry["magazine"], entry["reserve"]) _last_fire_time = Time.get_ticks_msec() / 1000.0 # The actual raycast and damage happen in the RollbackHitscanManager's # process_fire() during _rollback_tick(), triggered by the shoot input. # This method just handles the client-side ammo tracking. return true func reload() -> void: if current_weapon == null or current_weapon.magazine_size <= 0: return var entry = _inventory[current_slot] if entry["magazine"] >= current_weapon.magazine_size or entry["reserve"] <= 0: return var needed := current_weapon.magazine_size - entry["magazine"] var available := min(needed, entry["reserve"]) entry["magazine"] += available entry["reserve"] -= available ammo_changed.emit(current_weapon.weapon_id, entry["magazine"], entry["reserve"]) func get_ammo() -> Vector2i: if current_weapon == null or not _inventory.has(current_slot): return Vector2i.ZERO var e = _inventory[current_slot] return Vector2i(e["magazine"], e["reserve"]) ## Get the current weapon's maximum distance for raycasting. func get_max_distance() -> float: if current_weapon != null: return current_weapon.max_distance return 1000.0 ## Get the current weapon ID. func get_weapon_id() -> int: if current_weapon != null: return current_weapon.weapon_id return 0 func get_current_weapon_data() -> WeaponData: return current_weapon # --------------------------------------------------------------------------- # Rollback integration # --------------------------------------------------------------------------- ## Notify the RollbackHitscanManager when the weapon changes. func _notify_hitscan_mgr_of_weapon_change(wd: WeaponData) -> void: if rollback_hitscan_mgr != null and rollback_hitscan_mgr.has_method(&"set_weapon_data"): rollback_hitscan_mgr.set_weapon_data(wd) # --------------------------------------------------------------------------- # Internal # --------------------------------------------------------------------------- func _create_weapon(data: WeaponData) -> Node3D: var weapon := Node3D.new() weapon.name = data.weapon_name if data.fire_mode != WeaponData.FireMode.MELEE: var hs: TacticalWeaponHitscan = _HitscanWeapon.new() hs.name = "Hitscan" hs.weapon_data = data hs.max_distance = data.max_distance weapon.add_child(hs, true) return weapon func _get_magazine() -> int: var e = _inventory.get(current_slot) return e["magazine"] if e else 0 func _check_fire_rate() -> bool: if current_weapon.fire_rate <= 0: return false var now := Time.get_ticks_msec() / 1000.0 return (now - _last_fire_time) >= (1.0 / current_weapon.fire_rate) func _remove_weapon(slot: int) -> void: var e = _inventory.get(slot) if e and e["node"]: e["node"].queue_free() _inventory.erase(slot) func _switch_to_next_available() -> void: var slots := _inventory.keys() slots.sort() if slots.size() > 0: switch_to_slot(slots[0]) else: current_weapon = null current_weapon_node = null _update_visibility() func _update_visibility() -> void: for slot in _inventory: _inventory[slot]["node"].visible = (slot == current_slot)