## 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()