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:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user