2452aba0d7
- WeaponData resource class with configurable weapon properties and pre-configured constants for rifle, pistol, shotgun, and SMG - WeaponDefinitions singleton/static registry with WEAPONS dictionary - WeaponServer node for server-authoritative weapon logic including fire rate cooldowns, ammo tracking, reload state machine, and multi-pellet hit-scan raycasting - Extended WeaponManager with weapon inventory array, weapon switching, per-weapon state persistence, fire_animation_triggered signal
349 lines
11 KiB
GDScript
349 lines
11 KiB
GDScript
## 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)
|