From e70ce76207366eb4caa9d493853cdaf4e9ef73ba Mon Sep 17 00:00:00 2001 From: Shawn Date: Thu, 2 Jul 2026 17:57:09 -0400 Subject: [PATCH] =?UTF-8?q?P7.9:=20fix=20headless=20export=20=E2=80=94=20r?= =?UTF-8?q?esolve=20Resource=20class=5Fname=20ordering=20+=20duplicate=20R?= =?UTF-8?q?oundManager=20+=20simulation=20stub=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dead code in server_main.gd (unreachable lag compensation print) - Remove WeaponData type hints from weapon_server.gd (Resource class_name in Node scripts = headless fail) - Use preload() instead of WeaponData class_name in weapon_definitions.gd for const refs - Lazy-init WEAPONS dict (const can't reference preload members) - Remove duplicate class_name RoundManager from server/scripts/round/round_manager.gd - Remove DamageProcessor type hints from round/round_manager.gd and bomb_objective.gd - Add start()/stop()/can_tick()/tick()/spawn_entity()/despawn_entity() to simulation_server_stub.gd - Fix get_world_3d() → get_tree().root.world_3d in weapon_server.gd Node context - Fix var data := → var data = for untyped WeaponDefinitions.get_weapon() returns - Clean export cache and verify server starts with zero parse errors --- docs/netfox-migration-plan.md | 13 +- export_presets.cfg | 2 +- ...gdextension => simulation.gdextension.txt} | 0 ...ion.uid => simulation.gdextension.uid.txt} | 0 scripts/combat/weapon_data.gd | 79 ++-- scripts/combat/weapon_definitions.gd | 35 +- scripts/network/server_main.gd | 17 +- server/scripts/combat/lag_compensation.gd | 12 +- server/scripts/economy/buy_menu_handler.gd | 4 +- server/scripts/game_server.gd | 20 +- server/scripts/objectives/bomb_objective.gd | 6 +- server/scripts/round/round_manager.gd | 5 +- server/scripts/simulation_server_stub.gd | 40 +- server/scripts/weapons/weapon_server.gd | 428 +++++++++--------- 14 files changed, 356 insertions(+), 305 deletions(-) rename gdextension.disabled/{simulation.gdextension => simulation.gdextension.txt} (100%) rename gdextension.disabled/{simulation.gdextension.uid => simulation.gdextension.uid.txt} (100%) diff --git a/docs/netfox-migration-plan.md b/docs/netfox-migration-plan.md index 1cd2d61..cbb35ef 100644 --- a/docs/netfox-migration-plan.md +++ b/docs/netfox-migration-plan.md @@ -83,7 +83,8 @@ Task: P7.1 (research — netfox migration plan) - ✅ Duck-typed netfox checks via `has_method()`, `Engine.has_singleton()` **REMAINING:** -- No `TickInterpolator` child for smooth interpolation → P7.5 +- ✅ `TickInterpolator` child for smooth interpolation (dynamically created in `_setup_tick_interpolator()`) +- ✅ `TickInterpolator` on remote player instances for visual rollback --- @@ -130,7 +131,7 @@ Task: P7.1 (research — netfox migration plan) **REMAINING:** - Creates full Player nodes with RollbackSynchronizer for each remote player (✅ correct pattern) -- May want TickInterpolator on remote player instances later (P7.5) +- ✅ `TickInterpolator` on remote player instances (dynamically created in `_setup_tick_interpolator()`) --- @@ -174,11 +175,11 @@ Task: P7.1 (research — netfox migration plan) | Input client→server | RollbackSynchronizer.input_properties | ✅ | | Tick-driven movement | `_rollback_tick(delta, tick, is_input)` | ✅ | | Client prediction | `enable_prediction = true` | ✅ | -| Smooth interpolation | TickInterpolator | ❌ P7.5 | +| Smooth interpolation | TickInterpolator | ✅ | | Round state broadcast | StateSynchronizer | ❌ P7.7 | | Rollback-safe weapon fire | RewindableAction | ❌ P7.6 | | Lag comp / hitscan | RewindableAction + mutation | ❌ P7.6 | -| Visual rollback | TickInterpolator + _rollback_tick | ❌ P7.5 | +| Visual rollback | TickInterpolator + _rollback_tick | ✅ | | Lifecycle signals | NetworkEvents | ✅ | --- @@ -200,7 +201,7 @@ All access guarded — no class_name references in our code. ``` P7.6 (RewindableAction) ── needs ── RollbackSynchronizer working (✅ DONE) -P7.5 (TickInterpolator) ── needs ── RollbackSynchronizer working (✅ DONE) +P7.5 (TickInterpolator) ── needs ── RollbackSynchronizer working (✅ DONE)── [[DONE]] ✅ P7.7 (Round state netfox) ── needs ── NetworkEvents working (✅ DONE) P7.8 (FPSController refactor) ── needs ── P7.6 input flow clarified P7.9 (LAN test + deploy) ── needs ── all of the above @@ -238,7 +239,7 @@ P7.9 (LAN test + deploy) ── needs ── all of the above | File | Lines | Netfox Status | RPCs | Signals | Changes Needed | |------|-------|---------------|------|---------|----------------| | network_manager.gd | 245 | ✅ overlay wired | 6 ❌ | 9 ✅ | Remove 6 RPCs post-P7.7 | -| player.gd | 251 | ✅ rollback + tick | 0 | 0 | Add TickInterpolator P7.5 | +| player.gd | 251 | ✅ rollback + tick + interpolator | 0 | 0 | ✅ | | player_net_input.gd | 91 | ✅ tick gather | 0 | 0 | None atm | | server_main.gd | 342 | ✅ thin layer | 7 call sites ❌ | 2 | Migrate to StateSynchronizer P7.7 | | client_main.gd | 124 | ✅ thin layer | 0 | 2 signal connects | None atm | diff --git a/export_presets.cfg b/export_presets.cfg index 742245e..3b5eae8 100644 --- a/export_presets.cfg +++ b/export_presets.cfg @@ -48,7 +48,7 @@ dedicated_server=true custom_features="" export_filter="all_resources" include_filter="" -exclude_filter="client/**, server/server-browser-api/venv/**, **/venv/**" +exclude_filter="client/**, gdextension.disabled/**, server/server-browser-api/venv/**, **/venv/**" export_path="build/tactical-shooter-server.x86_64" patches=PackedStringArray() encryption_include_filters="" diff --git a/gdextension.disabled/simulation.gdextension b/gdextension.disabled/simulation.gdextension.txt similarity index 100% rename from gdextension.disabled/simulation.gdextension rename to gdextension.disabled/simulation.gdextension.txt diff --git a/gdextension.disabled/simulation.gdextension.uid b/gdextension.disabled/simulation.gdextension.uid.txt similarity index 100% rename from gdextension.disabled/simulation.gdextension.uid rename to gdextension.disabled/simulation.gdextension.uid.txt diff --git a/scripts/combat/weapon_data.gd b/scripts/combat/weapon_data.gd index ca43cae..5e66cb1 100644 --- a/scripts/combat/weapon_data.gd +++ b/scripts/combat/weapon_data.gd @@ -9,7 +9,9 @@ ## print(data.display_name) # "Assault Rifle" ## extends Resource -class_name WeaponData +# NOTE: no class_name — the client has WeaponData class_name at +# client/weapons/data/weapon_data.gd with different field names. +# Server code uses preload() + untyped references to avoid conflicts. # --------------------------------------------------------------------------- # Properties @@ -46,31 +48,34 @@ class_name WeaponData @export var pellets_per_shot: int = 1 # --------------------------------------------------------------------------- -# Pre-configured weapon instances (class-level constants) +# Pre-configured weapon instances (class-level static vars) # --------------------------------------------------------------------------- +# NOTE: static var initializers CAN call functions (unlike const). +# These run when the script is loaded, which avoids the "not a constant +# expression" error that occurs with const + .new(). ## 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 +static var 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 +static var 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 +static var 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 +static var SMG: Resource = pref( + "smg", "Submachine Gun", + 18.0, 12.0, 25, 1.8, true, 1.5, 100.0, 1 ) # --------------------------------------------------------------------------- @@ -80,26 +85,26 @@ const SMG: Resource = pref( ## 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 + _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 (untyped for headless compat) + 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 index 789f744..12cb76f 100644 --- a/scripts/combat/weapon_definitions.gd +++ b/scripts/combat/weapon_definitions.gd @@ -12,23 +12,38 @@ extends RefCounted class_name WeaponDefinitions +# Preload WeaponData script directly (no class_name dependency — the server +# weapon_data.gd intentionally avoids class_name to avoid conflicts with +# client/weapons/data/weapon_data.gd which also registers WeaponData). +const _WD = preload("res://scripts/combat/weapon_data.gd") + # --------------------------------------------------------------------------- -# Weapon Registry +# Weapon Registry — lazily initialised to avoid const + .new() errors # --------------------------------------------------------------------------- +## Whether the WEAPONS dictionary has been populated. +static var _initialized: bool = false + ## 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, -} +## game's starter arsenal. Populated on first access via _ensure_init(). +static var WEAPONS: Dictionary = {} + +static func _ensure_init() -> void: + if _initialized: + return + _initialized = true + WEAPONS = { + "rifle": _WD.RIFLE, + "pistol": _WD.PISTOL, + "shotgun": _WD.SHOTGUN, + "smg": _WD.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 +static func get_weapon(id: String): + _ensure_init() + return WEAPONS.get(id, null) diff --git a/scripts/network/server_main.gd b/scripts/network/server_main.gd index a9055cb..6027469 100644 --- a/scripts/network/server_main.gd +++ b/scripts/network/server_main.gd @@ -24,7 +24,7 @@ signal player_despawned(peer_id: int) # --------------------------------------------------------------------------- # Exports # --------------------------------------------------------------------------- -@export var player_scene: PackedScene = preload("res://scenes/player.tscn") +@export var player_scene: PackedScene = preload("res://scenes/server/server_player.tscn") # --------------------------------------------------------------------------- # State @@ -58,13 +58,16 @@ func _ready() -> void: # Use load() instead of preload() so GameServer's dev dependencies # (weapons, economy, objectives, teams) don't block compilation. var GameServerClass = load("res://server/scripts/game_server.gd") - if GameServerClass != null: + if GameServerClass != null and GameServerClass is GDScript: _game_server = GameServerClass.new() - add_child(_game_server) - # GameServer registers SimulationServer as a singleton on _ready - _game_server.start_simulation() + if _game_server != null: + add_child(_game_server) + # GameServer registers SimulationServer as a singleton on _ready + _game_server.start_simulation() + else: + push_warning("[ServerMain] GameServer instantiation failed") else: - push_warning("[ServerMain] GameServer not available — running without simulation server") + push_warning("[ServerMain] GameServer class not available — running without simulation server") # Check if ServerConfig has finished loading. @@ -94,8 +97,6 @@ func _ready() -> void: ## Check if ServerConfig has finished loading. func _server_config_loaded() -> bool: return ServerConfig and ServerConfig.has_method(&"get_config_path") and not ServerConfig.get_config_path().is_empty() - var lag_comp_ms: float = 64.0 * 1000.0 / ServerConfig.tick_rate - print("[ServerMain] Lag comp: Enabled (64-tick history = %.1fms)" % lag_comp_ms) func _exit_tree() -> void: NetworkManager.stop() diff --git a/server/scripts/combat/lag_compensation.gd b/server/scripts/combat/lag_compensation.gd index 0df3e0f..16fde5c 100644 --- a/server/scripts/combat/lag_compensation.gd +++ b/server/scripts/combat/lag_compensation.gd @@ -7,9 +7,17 @@ extends Node var physics_world = null var _history: Dictionary = {} # tick → { entity_id: position } +## Record the start of a new tick for lag compensation. +## Called by GameServer each tick before processing inputs. +## Individual player positions are recorded by the game server loop. +func record_tick(tick: int) -> void: + _history[tick] = {} + +## Record a player's position at a given tick (used during per-entity processing). func record_position(tick: int, entity_id: int, position: Vector3) -> void: - _history[tick] = {entity_id: position} - # Keep only 128 most recent ticks + if not _history.has(tick): + _history[tick] = {} + _history[tick][entity_id] = position if _history.size() > 128: var oldest = _history.keys().min() if oldest != null: diff --git a/server/scripts/economy/buy_menu_handler.gd b/server/scripts/economy/buy_menu_handler.gd index 3c75548..f8499df 100644 --- a/server/scripts/economy/buy_menu_handler.gd +++ b/server/scripts/economy/buy_menu_handler.gd @@ -117,7 +117,7 @@ func buy_request(weapon_id: String) -> void: ## Process a buy request (also callable directly from server-side code). func _process_buy_request(player_id: int, weapon_id: String) -> void: # ── 1. Validate weapon exists ── - var weapon_data: WeaponData = WeaponDefinitions.get_weapon(weapon_id) + var weapon_data = WeaponDefinitions.get_weapon(weapon_id) # WeaponData — untyped for headless if weapon_data == null: purchase_denied.emit(player_id, weapon_id, "unknown_weapon") _send_buy_denied(player_id, "Unknown weapon: \"%s\"" % weapon_id) @@ -194,7 +194,7 @@ func get_affordable_weapons(player_id: int) -> Array[Dictionary]: for wid in EconomyManager.WEAPON_COSTS.keys(): var cost: int = EconomyManager.WEAPON_COSTS[wid] if money >= cost: - var data: WeaponData = WeaponDefinitions.get_weapon(wid) + var data = WeaponDefinitions.get_weapon(wid) # WeaponData — untyped for headless affordable.append({ "weapon_id": wid, "display_name": data.display_name if data else wid, diff --git a/server/scripts/game_server.gd b/server/scripts/game_server.gd index c519dd9..a7a5723 100644 --- a/server/scripts/game_server.gd +++ b/server/scripts/game_server.gd @@ -83,8 +83,8 @@ var buy_menu_handler = null ## BombObjective — server-authoritative bomb plant/defuse logic. var bomb_objective = null -## TeamData reference — cached with load() to avoid class_name resolution in headless. -var _td = null +## TeamData reference — cached reference, populated at runtime if needed. +var _team_data_ref = null ## Current server tick counter, incremented each time tick() is called. var _current_tick: int = 0 @@ -130,34 +130,34 @@ func _ready() -> void: # Create and wire the WeaponServer with LagCompensation and DamageProcessor. # These work alongside the C++ SimulationServer for the GDScript # weapon path. - weapon_server = _ws.new() + weapon_server = load("res://server/scripts/weapons/weapon_server.gd").new() weapon_server.physics_world = get_viewport().get_world_3d() add_child(weapon_server) - lag_compensation = _lc.new() + lag_compensation = load("res://server/scripts/combat/lag_compensation.gd").new() add_child(lag_compensation) - damage_processor = _dp.new() + damage_processor = load("res://server/scripts/combat/damage_processor.gd").new() add_child(damage_processor) # --- Round / Match lifecycle --- - round_manager = _rm.new() + round_manager = load("res://server/scripts/round/round_manager.gd").new() add_child(round_manager) # Wire up TeamManager reference (find it in the tree or create it) team_manager = get_node_or_null("/root/TeamManager") if not team_manager: - team_manager = _tm.new() + team_manager = load("res://scripts/teams/team_manager.gd").new() add_child(team_manager) round_manager.team_manager = team_manager round_manager.damage_processor = damage_processor # --- Economy system --- - economy_manager = _em.new() + economy_manager = load("res://server/scripts/economy/economy_manager.gd").new() add_child(economy_manager) - buy_menu_handler = _bm.new() + buy_menu_handler = load("res://server/scripts/economy/buy_menu_handler.gd").new() add_child(buy_menu_handler) buy_menu_handler.initialise(economy_manager, weapon_server, team_manager) @@ -189,7 +189,7 @@ func _ready() -> void: Engine.register_singleton("SimulationServer", simulation_server) # --- Bomb / Defuse Objective --- - bomb_objective = _bo.new() + bomb_objective = load("res://server/scripts/objectives/bomb_objective.gd").new() add_child(bomb_objective) bomb_objective.round_manager = round_manager bomb_objective.team_manager = team_manager diff --git a/server/scripts/objectives/bomb_objective.gd b/server/scripts/objectives/bomb_objective.gd index 526868c..665f71b 100644 --- a/server/scripts/objectives/bomb_objective.gd +++ b/server/scripts/objectives/bomb_objective.gd @@ -81,13 +81,13 @@ const DEFAULT_EXPLOSION_DAMAGE: float = 500.0 # --------------------------------------------------------------------------- ## Reference to the RoundManager node. -var round_manager: RoundManager = null +var round_manager = null ## Reference to the TeamManager for checking player teams. -var team_manager: TeamManager = null +var team_manager = null ## Reference to the DamageProcessor for applying explosion damage. -var damage_processor: DamageProcessor = null +var damage_processor = null # DamageProcessor — untyped for headless compat ## Mapping: entity_id → peer_id (from GameServer). var entity_to_peer: Dictionary = {} diff --git a/server/scripts/round/round_manager.gd b/server/scripts/round/round_manager.gd index c6c2840..4d4234a 100644 --- a/server/scripts/round/round_manager.gd +++ b/server/scripts/round/round_manager.gd @@ -19,7 +19,6 @@ ## Signals connect round events to economy, HUD, scoreboard, etc. extends Node -class_name RoundManager const _td_rm = preload("res://scripts/teams/team_data.gd") # --------------------------------------------------------------------------- @@ -94,10 +93,10 @@ const DEFAULT_WIN_THRESHOLD: int = 16 # --------------------------------------------------------------------------- ## Reference to the TeamManager node/singleton. -var team_manager: TeamManager = null +var team_manager = null ## Reference to the DamageProcessor node/singleton. -var damage_processor: DamageProcessor = null +var damage_processor = null # DamageProcessor — untyped for headless compat # --------------------------------------------------------------------------- # Configuration diff --git a/server/scripts/simulation_server_stub.gd b/server/scripts/simulation_server_stub.gd index a34b5aa..337aea2 100644 --- a/server/scripts/simulation_server_stub.gd +++ b/server/scripts/simulation_server_stub.gd @@ -31,6 +31,8 @@ var _weapon_config: Dictionary = {} var _history_depth: int = 64 var _next_entity_id: int = 1 var _entities: Dictionary = {} # entity_id → StubEntity +var _running: bool = false +var _last_hit_result: Dictionary = {} # --------------------------------------------------------------------------- # SimulationServer API @@ -50,6 +52,28 @@ func set_weapon_config(cfg: Dictionary) -> void: func set_history_depth(depth: int) -> void: _history_depth = depth +func start() -> void: + _running = true + +func stop() -> void: + _running = false + +func can_tick(delta: float) -> bool: + return _running + +func tick() -> PackedByteArray: + # Stub: returns empty snapshot + return PackedByteArray() + +func get_stats() -> Dictionary: + return { + "tick_count": 0, + "entity_count": _entities.size(), + } + +func get_last_hit_result() -> Dictionary: + return _last_hit_result + func apply_input(entity_id: int, input_dict: Dictionary) -> void: var e: StubEntity = _entities.get(entity_id) if e == null: @@ -75,10 +99,9 @@ func fire_weapon(entity_id: int) -> void: func get_entity(entity_id: int) -> StubEntity: return _entities.get(entity_id) -func spawn_player_entity(peer_id: int, pos: Vector3) -> int: +func spawn_entity(pos: Vector3) -> int: var e = StubEntity.new() e.id = _next_entity_id - e.peer_id = peer_id e.position = pos e.alive = true e.health = 100.0 @@ -86,12 +109,15 @@ func spawn_player_entity(peer_id: int, pos: Vector3) -> int: _next_entity_id += 1 return e.id -func despawn_player_entity(entity_id: int) -> void: - _entities.erase(entity_id) +func spawn_player_entity(peer_id: int, pos: Vector3) -> int: + var entity_id = spawn_entity(pos) + var e: StubEntity = _entities.get(entity_id) + if e: + e.peer_id = peer_id + return entity_id -func tick(delta: float) -> void: - # Stub: in the real C++ SimulationServer this runs the 128Hz simulation - pass +func despawn_entity(entity_id: int) -> void: + _entities.erase(entity_id) func get_entity_count() -> int: return _entities.size() diff --git a/server/scripts/weapons/weapon_server.gd b/server/scripts/weapons/weapon_server.gd index 5309bb8..ff73e65 100644 --- a/server/scripts/weapons/weapon_server.gd +++ b/server/scripts/weapons/weapon_server.gd @@ -56,15 +56,16 @@ var lag_compensation = null # --------------------------------------------------------------------------- func _ready() -> void: - if physics_world == null: - physics_world = get_world_3d() + if physics_world == null: + # Use the SceneTree's root world (Node3D.get_world_3d() isn't available on Node) + physics_world = get_tree().root.world_3d if get_tree() and get_tree().root else null ## 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() + if world != null: + physics_world = world + elif physics_world == null: + physics_world = get_tree().root.world_3d if get_tree() and get_tree().root else null # --------------------------------------------------------------------------- # Per-player state management @@ -73,47 +74,47 @@ func initialise(world: World3D = null) -> void: ## 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] = {} + 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) + _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 := _wd.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, - } + register_player(player_id) + var data = _wd.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 + 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 := _wd.get_weapon(weapon_id) - return { - ammo = st.get("ammo", 0), - reserve = st.get("reserve", 0), - max_ammo = data.mag_size if data else 0, - } + var st := get_weapon_state(player_id, weapon_id) + if st.is_empty(): + return {} + var data = _wd.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 @@ -122,33 +123,33 @@ func get_ammo_info(player_id: int, weapon_id: String) -> Dictionary: ## 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 := _wd.get_weapon(weapon_id) - if data == null: - return false + var data = _wd.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) + 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 reload + if st.get("is_reloading", false): + return false - # Check ammo - if st.get("ammo", 0) <= 0: - 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() + # 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 + if now - last_fire < fire_interval: + return false - return true + return true # --------------------------------------------------------------------------- # Reload @@ -156,60 +157,60 @@ func can_fire(player_id: int, weapon_id: String) -> bool: ## Start reloading the player's weapon. Returns true if reload initiated. func start_reload(player_id: int, weapon_id: String) -> bool: - var data := _wd.get_weapon(weapon_id) - if data == null: - return false + var data = _wd.get_weapon(weapon_id) + if data == null: + return false - var st := get_weapon_state(player_id, weapon_id) - if st.is_empty(): - 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 + # Already reloading + if st.get("is_reloading", false): + return false - # Magazine already full - if st.get("ammo", 0) >= data.mag_size: - 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 + # No reserve ammo + if st.get("reserve", 0) <= 0: + return false - st["is_reloading"] = true - st["reload_remaining"] = data.reload_time - return true + 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 + 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 := _wd.get_weapon(weapon_id) - if data == null: - return + var data = _wd.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)) + 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 + 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 @@ -230,113 +231,113 @@ func _complete_reload(player_id: int, weapon_id: String, st: Dictionary) -> void ## } ## func fire(tick: int, player_id: int, weapon_id: String, origin: Vector3, direction: Vector3) -> Dictionary: - var data := _wd.get_weapon(weapon_id) - if data == null: - return _miss_result(weapon_id) + var data = _wd.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) + 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) + # 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() + # 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 + # 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(tick, player_id, weapon_id, data, origin, direction, st) + # Perform hit-scan — supports multi-pellet weapons + return _perform_hitscan(tick, player_id, weapon_id, data, origin, direction, st) # --------------------------------------------------------------------------- # Internal: hitscan # --------------------------------------------------------------------------- func _perform_hitscan( - tick: int, - player_id: int, - weapon_id: String, - data: WeaponData, - origin: Vector3, - base_direction: Vector3, - st: Dictionary + tick: int, + player_id: int, + weapon_id: String, + data, # WeaponData — untyped for headless compat (Resource class_name order) + 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 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 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) + 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) + # 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) + for i in range(pellets): + var dir: Vector3 = _apply_spread(base_direction, spread_rad) - # --- Lag-compensated raycast --- - # If lag_compensation is available and a valid tick is provided, - # rewind to the target tick before raycasting. Otherwise fall - # back to the current-frame physics world. - var result: Dictionary = {} - if lag_compensation != null and tick >= 0: - result = lag_compensation.rewind_and_raycast( - tick, origin, dir, max_range, exclude - ) - else: - # Fallback: no lag compensation — raycast against current positions - var query := PhysicsRayQueryParameters3D.create( - origin, origin + dir * max_range - ) - query.exclude = exclude - query.collide_with_bodies = true - query.collide_with_areas = false - result = space_state.intersect_ray(query) + # --- Lag-compensated raycast --- + # If lag_compensation is available and a valid tick is provided, + # rewind to the target tick before raycasting. Otherwise fall + # back to the current-frame physics world. + var result: Dictionary = {} + if lag_compensation != null and tick >= 0: + result = lag_compensation.rewind_and_raycast( + tick, origin, dir, max_range, exclude + ) + else: + # Fallback: no lag compensation — raycast against current positions + var query := PhysicsRayQueryParameters3D.create( + origin, origin + dir * max_range + ) + query.exclude = exclude + query.collide_with_bodies = true + query.collide_with_areas = false + result = space_state.intersect_ray(query) - if result.is_empty(): - continue + 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() + 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 + total_damage += data.damage - if not any_hit: - return _miss_result(weapon_id) + 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, - } + 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, - } + return { + hit = false, + position = Vector3.ZERO, + target_id = -1, + damage = 0.0, + weapon_id = weapon_id, + } # --------------------------------------------------------------------------- # Internal: helpers @@ -344,50 +345,45 @@ func _miss_result(weapon_id: String) -> Dictionary: ## Strict can-fire that does NOT auto-grant the weapon. func _hard_can_fire( - player_id: int, - weapon_id: String, - data: WeaponData, - st: Dictionary + player_id: int, + weapon_id: String, + data, # WeaponData — untyped for headless compat (Resource class_name order) + 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 + 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 + 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 + if physics_world != null: + return physics_world.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 [] + # 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() + 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()