P7.9: fix headless export — resolve Resource class_name ordering + duplicate RoundManager + simulation stub API

- 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
This commit is contained in:
2026-07-02 17:57:09 -04:00
parent 926446e5cf
commit e70ce76207
14 changed files with 356 additions and 305 deletions
+7 -6
View File
@@ -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 |
+1 -1
View File
@@ -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=""
+42 -37
View File
@@ -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
+25 -10
View File
@@ -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)
+9 -8
View File
@@ -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()
+10 -2
View File
@@ -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:
+2 -2
View File
@@ -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,
+10 -10
View File
@@ -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
+3 -3
View File
@@ -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 = {}
+2 -3
View File
@@ -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
+33 -7
View File
@@ -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()
+212 -216
View File
@@ -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()