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:
@@ -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
@@ -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=""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user