feat: implement weapon set system (t_p2_weapons)

- 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
This commit is contained in:
2026-07-01 20:16:22 -04:00
parent 3c3251fa7a
commit 2452aba0d7
4 changed files with 654 additions and 5 deletions
+148 -5
View File
@@ -35,6 +35,13 @@ 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
# ---------------------------------------------------------------------------
@@ -70,6 +77,20 @@ 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
@@ -132,6 +153,9 @@ func try_fire(time: float) -> bool:
# 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:
@@ -141,6 +165,7 @@ func try_fire(time: float) -> bool:
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.
@@ -165,6 +190,7 @@ func start_reload() -> void:
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:
@@ -197,9 +223,126 @@ func get_spread_direction(base_direction: Vector3) -> Vector3:
## Reset weapon state (for respawn).
func reset() -> void:
current_ammo = max_ammo
reserve_ammo = start_ammo
is_reloading = false
_current_spread = 0.0
_last_shot_time = 0.0
# 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)
+105
View File
@@ -0,0 +1,105 @@
## WeaponData — A resource defining a weapon's static properties.
##
## Used as the data contract between weapon_definitions.gd, weapon_server.gd,
## and weapon_manager.gd. Each weapon type is pre-configured and accessible
## via the class-level constants (RIFLE, PISTOL, SHOTGUN, SMG).
##
## Usage:
## var data: WeaponData = WeaponData.RIFLE
## print(data.display_name) # "Assault Rifle"
##
extends Resource
class_name WeaponData
# ---------------------------------------------------------------------------
# Properties
# ---------------------------------------------------------------------------
## Unique identifier string (e.g., "rifle", "pistol").
@export var weapon_id: String = ""
## Human-readable weapon name.
@export var display_name: String = ""
## Base damage per projectile/pellet.
@export var damage: float = 0.0
## Rate of fire in rounds per second (Hz).
@export var fire_rate: float = 0.0
## Magazine capacity (max ammo per reload).
@export var mag_size: int = 0
## Reload duration in seconds.
@export var reload_time: float = 0.0
## If true, hold to fire continuously. If false, single-shot per press.
@export var is_automatic: bool = false
## Base weapon spread in degrees (used for accuracy cone).
@export var spread_degrees: float = 0.0
## Effective range in Godot units (metres conceptual).
@export var range: float = 0.0
## Number of pellets/projectiles per shot (1 for most weapons, 8 for shotgun).
@export var pellets_per_shot: int = 1
# ---------------------------------------------------------------------------
# Pre-configured weapon instances (class-level constants)
# ---------------------------------------------------------------------------
## Assault Rifle — 30 dmg, 10 rps, 30 mag, 2.1s reload, automatic, 0.5° spread, 200m range.
const RIFLE: Resource = pref(
"rifle", "Assault Rifle",
30.0, 10.0, 30, 2.1, true, 0.5, 200.0, 1
)
## Pistol — 25 dmg, 4 rps, 12 mag, 1.5s reload, semi-auto, 0.3° spread, 80m range.
const PISTOL: Resource = pref(
"pistol", "Pistol",
25.0, 4.0, 12, 1.5, false, 0.3, 80.0, 1
)
## Shotgun — 8 dmg × 8 pellets, 1 rps, 8 mag, 3.0s reload, semi-auto, 3° spread, 30m range.
const SHOTGUN: Resource = pref(
"shotgun", "Shotgun",
8.0, 1.0, 8, 3.0, false, 3.0, 30.0, 8
)
## SMG — 18 dmg, 12 rps, 25 mag, 1.8s reload, automatic, 1.5° spread, 100m range.
const SMG: Resource = pref(
"smg", "Submachine Gun",
18.0, 12.0, 25, 1.8, true, 1.5, 100.0, 1
)
# ---------------------------------------------------------------------------
# Factory helper
# ---------------------------------------------------------------------------
## Internal helper that constructs a WeaponData instance from raw values.
## This avoids repeating the constructor boilerplate for every constant.
static func pref(
_weapon_id: String,
_display_name: String,
_damage: float,
_fire_rate: float,
_mag_size: int,
_reload_time: float,
_is_automatic: bool,
_spread_degrees: float,
_range: float,
_pellets: int = 1
) -> WeaponData:
var w := WeaponData.new()
w.weapon_id = _weapon_id
w.display_name = _display_name
w.damage = _damage
w.fire_rate = _fire_rate
w.mag_size = _mag_size
w.reload_time = _reload_time
w.is_automatic = _is_automatic
w.spread_degrees = _spread_degrees
w.range = _range
w.pellets_per_shot = _pellets
return w
+34
View File
@@ -0,0 +1,34 @@
## WeaponDefinitions — Central registry of all weapon definitions.
##
## Provides a WEAPONS dictionary keyed by weapon_id for runtime lookup,
## plus a convenience getter. The data itself lives in WeaponData constants,
## so this file serves as a single import point for the full arsenal.
##
## Usage (as autoload singleton or direct preload):
## var data = WeaponDefinitions.get_weapon("rifle")
## # or:
## var all = WeaponDefinitions.WEAPONS
##
extends RefCounted
class_name WeaponDefinitions
# ---------------------------------------------------------------------------
# Weapon Registry
# ---------------------------------------------------------------------------
## All weapons indexed by weapon_id — the single source of truth for the
## game's starter arsenal. Add new weapons here and in WeaponData.
const WEAPONS: Dictionary = {
"rifle": WeaponData.RIFLE,
"pistol": WeaponData.PISTOL,
"shotgun": WeaponData.SHOTGUN,
"smg": WeaponData.SMG,
}
# ---------------------------------------------------------------------------
# Lookup
# ---------------------------------------------------------------------------
## Return the WeaponData for the given weapon_id, or null if unknown.
static func get_weapon(id: String) -> WeaponData:
return WEAPONS.get(id, null) as WeaponData
+367
View File
@@ -0,0 +1,367 @@
## WeaponServer — Server-authoritative weapon logic.
##
## Manages per-player, per-weapon runtime state: fire rate cooldowns,
## magazine + reserve ammo, reload state machine, and hit-scan raycasting.
##
## This is the authoritative version; clients predict locally via
## WeaponManager and reconcile with server confirmations.
##
## Usage (add as child of GameServer or any server-side node):
##
## var ws = WeaponServer.new()
## add_child(ws)
## ws.initialise()
## ...
## if ws.can_fire(player_id, weapon_id):
## var result = ws.fire(player_id, weapon_id, origin, direction)
## if result.hit:
## apply_damage(result)
##
class_name WeaponServer
extends Node
# ---------------------------------------------------------------------------
# State shape
# ---------------------------------------------------------------------------
# _state[player_id][weapon_id] = {
# ammo: int, # rounds left in magazine
# reserve: int, # rounds in reserve (total reserve)
# is_reloading: bool, # currently in reload animation
# reload_remaining: float, # seconds left on current reload
# last_fire_time: float, # time of last shot (cooldown check)
# }
# ---------------------------------------------------------------------------
# State
# ---------------------------------------------------------------------------
## Per-player, per-weapon runtime state.
## Accessed via _get_state() / _ensure_state() helpers.
var _state: Dictionary = {}
## Reference to the SceneTree's physics world for raycasting.
## Set automatically if the node enters the tree; can also be injected.
var physics_world: World3D = null
# ---------------------------------------------------------------------------
# Lifecycle
# ---------------------------------------------------------------------------
func _ready() -> void:
if physics_world == null:
physics_world = get_world_3d()
## Public initialiser (call after adding to tree, or inject a World3D).
func initialise(world: World3D = null) -> void:
if world != null:
physics_world = world
elif physics_world == null:
physics_world = get_world_3d()
# ---------------------------------------------------------------------------
# Per-player state management
# ---------------------------------------------------------------------------
## Ensure a player has an entry in the state dictionary.
## Called automatically by can_fire / fire.
func register_player(player_id: int) -> void:
if not _state.has(player_id):
_state[player_id] = {}
## Remove a player's state (on disconnect / respawn).
func unregister_player(player_id: int) -> void:
_state.erase(player_id)
## Give a player a weapon — allocates initial ammo for it.
## Also calls register_player implicitly.
func give_weapon(player_id: int, weapon_id: String) -> void:
register_player(player_id)
var data := WeaponDefinitions.get_weapon(weapon_id)
if data == null:
push_warning("WeaponServer: unknown weapon '%s'" % weapon_id)
return
var ws: Dictionary = _state[player_id]
ws[weapon_id] = {
ammo = data.mag_size,
reserve = data.mag_size * 3, # 3 spare magazines
is_reloading = false,
reload_remaining = 0.0,
last_fire_time = -INF,
}
## Return the per-weapon state dictionary for a player+weapon, or null.
func get_weapon_state(player_id: int, weapon_id: String) -> Dictionary:
var ps: Dictionary = _state.get(player_id, {})
return ps.get(weapon_id, {}) as Dictionary
## Return the current ammo and reserve for a player's weapon.
## Returns {ammo: int, reserve: int, max_ammo: int} or null.
func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary:
var st := get_weapon_state(player_id, weapon_id)
if st.is_empty():
return {}
var data := WeaponDefinitions.get_weapon(weapon_id)
return {
ammo = st.get("ammo", 0),
reserve = st.get("reserve", 0),
max_ammo = data.mag_size if data else 0,
}
# ---------------------------------------------------------------------------
# Can-fire check
# ---------------------------------------------------------------------------
## Returns true if the player can fire the specified weapon right now.
## Checks: weapon known, ammo available, not reloading, cooldown elapsed.
func can_fire(player_id: int, weapon_id: String) -> bool:
var data := WeaponDefinitions.get_weapon(weapon_id)
if data == null:
return false
var st := get_weapon_state(player_id, weapon_id)
if st.is_empty():
# Player doesn't have this weapon yet — give it to them
give_weapon(player_id, weapon_id)
st = get_weapon_state(player_id, weapon_id)
# Check reload
if st.get("is_reloading", false):
return false
# Check ammo
if st.get("ammo", 0) <= 0:
return false
# Check fire rate cooldown
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
var last_fire: float = st.get("last_fire_time", -INF)
var now: float = Time.get_unix_time_from_system()
if now - last_fire < fire_interval:
return false
return true
# ---------------------------------------------------------------------------
# Reload
# ---------------------------------------------------------------------------
## Start reloading the player's weapon. Returns true if reload initiated.
func start_reload(player_id: int, weapon_id: String) -> bool:
var data := WeaponDefinitions.get_weapon(weapon_id)
if data == null:
return false
var st := get_weapon_state(player_id, weapon_id)
if st.is_empty():
return false
# Already reloading
if st.get("is_reloading", false):
return false
# Magazine already full
if st.get("ammo", 0) >= data.mag_size:
return false
# No reserve ammo
if st.get("reserve", 0) <= 0:
return false
st["is_reloading"] = true
st["reload_remaining"] = data.reload_time
return true
## Process reload timers. Called each server tick with delta.
## Returns true if any reload completed this tick.
func process_reloads(player_id: int, delta: float) -> bool:
var any_completed: bool = false
var ps: Dictionary = _state.get(player_id, {})
for weapon_id in ps.keys():
var st: Dictionary = ps[weapon_id]
if not st.get("is_reloading", false):
continue
var remaining: float = st.get("reload_remaining", 0.0) - delta
if remaining <= 0.0:
# Reload complete
_complete_reload(player_id, weapon_id, st)
any_completed = true
else:
st["reload_remaining"] = remaining
return any_completed
func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void:
var data := WeaponDefinitions.get_weapon(weapon_id)
if data == null:
return
var needed: int = data.mag_size - st.get("ammo", 0)
var from_reserve: int = mini(needed, st.get("reserve", 0))
st["ammo"] = st.get("ammo", 0) + from_reserve
st["reserve"] = st.get("reserve", 0) - from_reserve
st["is_reloading"] = false
st["reload_remaining"] = 0.0
# ---------------------------------------------------------------------------
# Fire
# ---------------------------------------------------------------------------
## Fire the weapon from the given origin along direction.
## Performs a hit-scan raycast through the physics world.
##
## hit_result = {
## hit: bool,
## position: Vector3, # world-space hit point
## target_id: int, # entity/node ID if hitting a collider, -1 otherwise
## damage: float, # total damage dealt this shot
## weapon_id: String, # the weapon used
## }
##
func fire(player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary:
var data := WeaponDefinitions.get_weapon(weapon_id)
if data == null:
return _miss_result(weapon_id)
var st := get_weapon_state(player_id, weapon_id)
if st.is_empty():
return _miss_result(weapon_id)
# Authoritative can-fire check (belt and suspenders)
if not _hard_can_fire(player_id, weapon_id, data, st):
return _miss_result(weapon_id)
# Deduct ammo
st["ammo"] = st.get("ammo", 1) - 1
st["last_fire_time"] = Time.get_unix_time_from_system()
# Auto-reload if magazine empty
if st["ammo"] <= 0 and st.get("reserve", 0) > 0:
st["is_reloading"] = true
st["reload_remaining"] = data.reload_time
# Perform hit-scan — supports multi-pellet weapons
return _perform_hitscan(player_id, weapon_id, data, origin, direction, st)
# ---------------------------------------------------------------------------
# Internal: hitscan
# ---------------------------------------------------------------------------
func _perform_hitscan(
player_id: int,
weapon_id: String,
data: WeaponData,
origin: Vector3,
base_direction: Vector3,
st: Dictionary
) -> Dictionary:
var space_state: PhysicsDirectSpaceState3D = _get_space_state()
if space_state == null:
return _miss_result(weapon_id)
var total_damage: float = 0.0
var latest_hit_pos: Vector3 = Vector3.ZERO
var latest_target_id: int = -1
var any_hit: bool = false
var pellets: int = max(1, data.pellets_per_shot)
var max_range: float = max(1.0, data.range)
var spread_rad: float = deg_to_rad(data.spread_degrees)
# Build a collision exception list so we don't hit the shooter
var exclude: Array[RID] = _get_shooter_exclusions(player_id)
for i in range(pellets):
var dir: Vector3 = _apply_spread(base_direction, spread_rad)
var query := PhysicsRayQueryParameters3D.create(origin, origin + dir * max_range)
query.exclude = exclude
query.collide_with_bodies = true
query.collide_with_areas = false
var result: Dictionary = space_state.intersect_ray(query)
if result.is_empty():
continue
any_hit = true
latest_hit_pos = result.get("position", Vector3.ZERO)
var collider: Object = result.get("collider", null)
if collider and collider.has_method(&"get_entity_id"):
latest_target_id = collider.get_entity_id()
elif collider:
latest_target_id = collider.get_instance_id()
total_damage += data.damage
if not any_hit:
return _miss_result(weapon_id)
return {
hit = true,
position = latest_hit_pos,
target_id = latest_target_id,
damage = total_damage,
weapon_id = weapon_id,
}
func _miss_result(weapon_id: String) -> Dictionary:
return {
hit = false,
position = Vector3.ZERO,
target_id = -1,
damage = 0.0,
weapon_id = weapon_id,
}
# ---------------------------------------------------------------------------
# Internal: helpers
# ---------------------------------------------------------------------------
## Strict can-fire that does NOT auto-grant the weapon.
func _hard_can_fire(
player_id: int,
weapon_id: String,
data: WeaponData,
st: Dictionary
) -> bool:
if st.is_empty():
return false
if st.get("is_reloading", false):
return false
if st.get("ammo", 0) <= 0:
return false
var fire_interval: float = 1.0 / max(data.fire_rate, 0.001)
var last_fire: float = st.get("last_fire_time", -INF)
var now: float = Time.get_unix_time_from_system()
if now - last_fire < fire_interval:
return false
return true
func _get_space_state() -> PhysicsDirectSpaceState3D:
if physics_world != null:
return physics_world.direct_space_state
# Fallback: try from the scene tree
var w: World3D = get_world_3d()
if w != null:
physics_world = w
return w.direct_space_state
return null
func _get_shooter_exclusions(player_id: int) -> Array[RID]:
# Try to find the player's collision body via the entity system
# For now, return an empty array (no self-exclusion beyond what Godot does)
return []
## Apply spread to a base direction vector.
func _apply_spread(base: Vector3, spread_rad: float) -> Vector3:
if spread_rad <= 0.001:
return base
var theta: float = randf() * TAU
var phi: float = randf() * spread_rad
var up := Vector3.UP
if abs(base.dot(up)) > 0.99:
up = Vector3.RIGHT
var right := base.cross(up).normalized()
up = right.cross(base).normalized()
var offset := right * sin(theta) * sin(phi) + up * cos(theta) * sin(phi)
return (base + offset).normalized()