diff --git a/client/characters/weapon/weapon_manager.gd b/client/characters/weapon/weapon_manager.gd index 943052e..dcf3abe 100644 --- a/client/characters/weapon/weapon_manager.gd +++ b/client/characters/weapon/weapon_manager.gd @@ -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) diff --git a/scripts/combat/weapon_data.gd b/scripts/combat/weapon_data.gd new file mode 100644 index 0000000..ca43cae --- /dev/null +++ b/scripts/combat/weapon_data.gd @@ -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 diff --git a/scripts/combat/weapon_definitions.gd b/scripts/combat/weapon_definitions.gd new file mode 100644 index 0000000..789f744 --- /dev/null +++ b/scripts/combat/weapon_definitions.gd @@ -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 diff --git a/server/scripts/weapons/weapon_server.gd b/server/scripts/weapons/weapon_server.gd new file mode 100644 index 0000000..861683e --- /dev/null +++ b/server/scripts/weapons/weapon_server.gd @@ -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()