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:
@@ -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