## WeaponManager — hitscan weapon system for tactical FPS. ## ## Manages a single hitscan weapon: fire rate limiting, ammo, reload. ## Communicates with SimulationServer for server-authoritative hit detection. ## ## Architecture: ## - Client-side: fire rate limiting, muzzle VFX, hit marker feedback ## - Server-side (via SimulationServer): hit detection, damage, lag compensation ## - Both sides: ammo tracking (server-authoritative, client-predicted) ## ## Usage (attach as child of FPSCharacterController): ## ## FPSCharacterController ## ├── WeaponManager — fire rate, ammo, VFX ## │ ├── MuzzleFlash — instantiated VFX ## │ └── HitMarker — instantiated VFX ## └── FpsCamera ## class_name WeaponManager extends Node # --------------------------------------------------------------------------- # Signals # --------------------------------------------------------------------------- ## Emitted when the weapon fires (for visual feedback). signal weapon_fired(origin: Vector3, direction: Vector3) ## Emitted when this weapon hits an entity. signal hit_marked(hit_pos: Vector3, damage: float, killed: bool) ## Emitted when ammo changes. signal ammo_changed(current: int, max: int) ## Emitted when player is killed. signal player_killed() ## Emitted when the player switches weapons (for animation/UI). signal weapon_switched(index: int, weapon_id: String) ## Emitted when the current weapon fires (for animation/visual sync). ## Connected by animation players or VFX systems on the weapon model. signal fire_animation_triggered() # --------------------------------------------------------------------------- # Exports — Assault Rifle Config # --------------------------------------------------------------------------- ## Maximum ammo capacity. @export var max_ammo: int = 30 ## Starting ammo (including one magazine). @export var start_ammo: int = 90 ## Time between shots (seconds). 10 Hz = 0.1s between shots. @export var fire_rate: float = 0.1 ## Reload time in seconds. @export var reload_time: float = 2.0 ## Spread increases while firing (degrees). @export var spread_per_shot: float = 0.5 ## Max spread when firing continuously (degrees). @export var max_spread: float = 4.0 ## Spread recovery per second (degrees/s). @export var spread_recovery: float = 8.0 # --------------------------------------------------------------------------- # State # --------------------------------------------------------------------------- var current_ammo: int = 30 var reserve_ammo: int = 90 var is_reloading: bool = false var _last_shot_time: float = 0.0 var _current_spread: float = 0.0 # --------------------------------------------------------------------------- # Multi-weapon inventory state # --------------------------------------------------------------------------- ## Array of weapon IDs the player carries (the inventory). var _weapon_inventory: Array[String] = [] ## Index into _weapon_inventory for the currently equipped weapon. var _current_weapon_index: int = -1 ## Per-weapon persistent state saved when switching away, restored on switch-back. ## weapon_id -> { ammo, reserve, is_reloading, last_shot_time, current_spread } var _weapon_states: Dictionary = {} ## Reference to the parent FPSCharacterController (set in _ready). var _controller: FPSCharacterController = null ## Reference to SimulationServer (if available). var _server: Object = null # --------------------------------------------------------------------------- # Lifecycle # --------------------------------------------------------------------------- func _ready() -> void: _controller = get_parent() as FPSCharacterController if _controller == null: push_warning("WeaponManager: Parent is not FPSCharacterController. Disabled.") set_process(false) return # Find SimulationServer singleton if Engine.has_singleton("SimulationServer"): _server = Engine.get_singleton("SimulationServer") elif Engine.get_main_loop() and Engine.get_main_loop().root: # Try finding it in the scene tree var game_server = Engine.get_main_loop().root.find_child("GameServer", true, false) if game_server and game_server.has_method(&"get_simulation_server"): _server = game_server.get_simulation_server() # Initialize ammo current_ammo = max_ammo reserve_ammo = start_ammo # Signal that ammo changed ammo_changed.emit(current_ammo, max_ammo) # --------------------------------------------------------------------------- # Main loop — called from FPSCharacterController._physics_process per tick # --------------------------------------------------------------------------- ## Try to fire the weapon. Returns true if a shot was fired. ## Called by the controller when shoot input is detected. func try_fire(time: float) -> bool: # Check fire rate if time - _last_shot_time < fire_rate: return false # Check ammo if current_ammo <= 0: # Auto-reload on empty — or play empty-click sound if not is_reloading: start_reload() return false # Check reloading if is_reloading: return false # Deduct ammo current_ammo -= 1 _last_shot_time = time # Increase spread _current_spread = min(_current_spread + spread_per_shot, max_spread) # Persist state back to per-weapon dict _save_current_weapon_state() # Emit fire signal (for visual/hit marker) # Direction comes from the controller's look direction if _controller: var origin: Vector3 = _controller.global_position origin.y += _controller.eye_height_stand var direction: Vector3 = _controller.get_look_direction() weapon_fired.emit(origin, direction) ammo_changed.emit(current_ammo, max_ammo) fire_animation_triggered.emit() return true ## Called every frame to recover spread and handle spread animation. func _process(delta: float) -> void: # Recover spread if _current_spread > 0.0: _current_spread = max(0.0, _current_spread - spread_recovery * delta) ## Start reloading. func start_reload() -> void: if is_reloading or current_ammo >= max_ammo or reserve_ammo <= 0: return is_reloading = true await get_tree().create_timer(reload_time).timeout # Reload logic var needed: int = max_ammo - current_ammo var from_reserve: int = min(needed, reserve_ammo) current_ammo += from_reserve reserve_ammo -= from_reserve is_reloading = false ammo_changed.emit(current_ammo, max_ammo) _save_current_weapon_state() ## Called when a hit is confirmed by the server. func on_hit_confirmed(hit_pos: Vector3, damage: float, killed: bool) -> void: hit_marked.emit(hit_pos, damage, killed) if killed: player_killed.emit() ## Get current spread (degrees). func get_current_spread() -> float: return _current_spread ## Get the current weapon fire direction including spread cone. func get_spread_direction(base_direction: Vector3) -> Vector3: if _current_spread <= 0.001: return base_direction var spread_rad: float = deg_to_rad(_current_spread) var theta: float = randf() * TAU var phi: float = randf() * spread_rad # Create a random perpendicular vector var up := Vector3.UP if abs(base_direction.dot(up)) > 0.99: up = Vector3.RIGHT var right := base_direction.cross(up).normalized() up = right.cross(base_direction).normalized() var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi) return (base_direction + offset).normalized() ## Reset weapon state (for respawn). func reset() -> void: # Re-initialise all weapon states in inventory for wid in _weapon_inventory: _initialize_weapon_state(wid) # Apply current weapon (or use defaults if no inventory) if _current_weapon_index >= 0 and _current_weapon_index < _weapon_inventory.size(): _apply_current_weapon() else: current_ammo = max_ammo reserve_ammo = start_ammo is_reloading = false _current_spread = 0.0 _last_shot_time = 0.0 ammo_changed.emit(current_ammo, max_ammo) # --------------------------------------------------------------------------- # Weapon inventory management # --------------------------------------------------------------------------- ## Add a weapon to the player's inventory. ## If no weapon is currently equipped, this one is automatically selected. func add_weapon(weapon_id: String) -> void: if weapon_id in _weapon_inventory: return _weapon_inventory.append(weapon_id) _initialize_weapon_state(weapon_id) if _current_weapon_index < 0: _current_weapon_index = _weapon_inventory.size() - 1 _apply_current_weapon() ## Switch to a weapon by its inventory index. ## Returns true if the switch was performed. func switch_weapon(index: int) -> bool: if index < 0 or index >= _weapon_inventory.size(): return false if index == _current_weapon_index: return false _save_current_weapon_state() _current_weapon_index = index _apply_current_weapon() weapon_switched.emit(index, _weapon_inventory[index]) return true ## Return the WeaponData for the currently equipped weapon, or null. func current_weapon_data() -> WeaponData: var wid := get_current_weapon_id() if wid.is_empty(): return null return WeaponDefinitions.get_weapon(wid) ## Return the current weapon's ID string, or empty string. func get_current_weapon_id() -> String: if _current_weapon_index >= 0 and _current_weapon_index < _weapon_inventory.size(): return _weapon_inventory[_current_weapon_index] return "" ## Return the number of weapons in the inventory. func get_weapon_count() -> int: return _weapon_inventory.size() ## Return the list of weapon IDs in the inventory. func get_inventory() -> Array[String]: return _weapon_inventory.duplicate() # --------------------------------------------------------------------------- # Internal: per-weapon state management # --------------------------------------------------------------------------- ## Initialise state dict for a weapon with default values. func _initialize_weapon_state(weapon_id: String) -> void: var data := WeaponDefinitions.get_weapon(weapon_id) if data == null: return _weapon_states[weapon_id] = { ammo = data.mag_size, reserve = data.mag_size * 3, is_reloading = false, last_shot_time = 0.0, current_spread = 0.0, } ## Save the current weapon's runtime state back into _weapon_states. func _save_current_weapon_state() -> void: var wid := get_current_weapon_id() if wid.is_empty() or not (wid in _weapon_states): return _weapon_states[wid] = { ammo = current_ammo, reserve = reserve_ammo, is_reloading = is_reloading, last_shot_time = _last_shot_time, current_spread = _current_spread, } ## Load the weapon at _current_weapon_index and apply its data + state ## to the live export properties and state variables. func _apply_current_weapon() -> void: var wid := get_current_weapon_id() if wid.is_empty(): return var data := WeaponDefinitions.get_weapon(wid) if data == null: return # Set export defaults from weapon data max_ammo = data.mag_size start_ammo = data.mag_size * 4 # 1 in mag + 3 reserve = 4x fire_rate = 1.0 / max(data.fire_rate, 0.001) reload_time = data.reload_time spread_per_shot = data.spread_degrees * 0.1 max_spread = data.spread_degrees * 3.0 spread_recovery = data.spread_degrees * 8.0 # Restore per-weapon state var st: Dictionary = _weapon_states.get(wid, {}) if st.is_empty(): _initialize_weapon_state(wid) st = _weapon_states.get(wid, {}) current_ammo = st.get("ammo", data.mag_size) reserve_ammo = st.get("reserve", data.mag_size * 3) is_reloading = st.get("is_reloading", false) _last_shot_time = st.get("last_shot_time", 0.0) _current_spread = st.get("current_spread", 0.0)