Files
tactical-shooter/examples/multiplayer-fps/scripts/weapon_manager.gd
T
shawn 7db8434f7e Fix duplicate _rollback_tick causing WeaponManager compile error
A debug-print attempt from earlier inadvertently appended a second
_rollback_tick function (with spaces instead of tabs) at the end of
weapon_manager.gd:419, causing the entire script to fail compilation.
This meant the WeaponManager node had NO script attached:
- 'has_method("set_default_loadout")' returned false
- Client-side loadout init never ran
- All weapon operations silently did nothing

This is the real root cause of why weapons never worked on the client.
2026-07-03 20:07:40 -04:00

412 lines
12 KiB
GDScript

extends Node3D
class_name WeaponManager
@onready var input: PlayerInputFPS = $"../../Input"
@onready var player: CharacterBody3D = $"../.."
@onready var sound: AudioStreamPlayer3D = $AudioStreamPlayer3D
@onready var bullethole: BulletHole = $BulletHole
@onready var fire_action := $"Fire Action" as RewindableAction
@onready var rollback_synchronizer := %RollbackSynchronizer as RollbackSynchronizer
# Rollback state
var inventory: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
var active_slot: int = 0
var ammo: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
var reserve: PackedInt32Array = PackedInt32Array([0, 0, 0, 0])
var reload_start_tick: int = -1
var last_fire_by_slot: PackedInt32Array = PackedInt32Array([-1, -1, -1, -1])
var recoil_accumulator: float = 0.0
# Grenade inventory (Phase 5)
var grenade_counts: PackedInt32Array = PackedInt32Array([0, 0]) # [flash, smoke]
var active_grenade_type: int = 0 # 0=Flash, 1=Smoke (only when slot 3 active)
var _pending_hit_result: Dictionary = {}
var _pending_grenades: Array[Dictionary] = [] # Buffered throws, consumed in _after_loop
# Weapon models (loaded at runtime)
var _knife_model: Node3D
var _gun_model: Node3D
var _grenade_model: Node3D
var _current_visible_slot: int = -1
var _knife_sound: AudioStreamPlayer3D
func _ready():
fire_action.mutate(self)
fire_action.mutate($"../../")
NetworkTime.before_tick_loop.connect(_before_loop)
NetworkTime.after_tick_loop.connect(_after_loop)
_gun_model = $TinyGun
_load_weapon_models()
func _load_weapon_models():
# Knife model
var knife_scene := load("res://examples/multiplayer-fps/models/knife.fbx") as PackedScene
if knife_scene:
_knife_model = knife_scene.instantiate()
_knife_model.scale = Vector3(0.003, 0.003, 0.003)
_knife_model.position = Vector3(0.15, -0.22, -0.35)
_knife_model.rotation_degrees = Vector3(-90, 180, 0)
add_child(_knife_model)
_knife_model.hide()
# Grenade model
var grenade_scene := load("res://examples/multiplayer-fps/models/grenade.glb") as PackedScene
if grenade_scene:
_grenade_model = grenade_scene.instantiate()
_grenade_model.scale = Vector3(0.08, 0.08, 0.08)
_grenade_model.position = Vector3(0.1, -0.18, -0.25)
add_child(_grenade_model)
_grenade_model.hide()
# Gun model — replace placeholder box with Kenney blaster
var gun_scene := load("res://assets/kenney/models/blaster.glb") as PackedScene
if gun_scene:
var blaster = gun_scene.instantiate()
blaster.scale = Vector3(0.003, 0.003, 0.003)
blaster.position = Vector3(0.09, -0.18, -0.3)
blaster.rotation_degrees = Vector3(-90, 180, 0)
add_child(blaster)
# Hide the old box mesh, use blaster instead
if _gun_model:
_gun_model.hide()
_gun_model = blaster
_gun_model.hide()
# Knife sound
var slash_stream := load("res://examples/multiplayer-fps/sounds/knife/knife_slash1.wav")
if slash_stream:
_knife_sound = AudioStreamPlayer3D.new()
_knife_sound.stream = slash_stream
_knife_sound.max_distance = 10.0
add_child(_knife_sound)
func _process(_delta: float):
# Update weapon model visibility
if active_slot != _current_visible_slot:
_current_visible_slot = active_slot
if _knife_model:
_knife_model.visible = (active_slot == 0)
if _gun_model:
_gun_model.visible = (active_slot == 1 or active_slot == 2)
if _grenade_model:
_grenade_model.visible = (active_slot == 3)
func hide_weapons():
if _knife_model:
_knife_model.hide()
if _gun_model:
_gun_model.hide()
if _grenade_model:
_grenade_model.hide()
_current_visible_slot = -1
func show_weapons():
_current_visible_slot = -1 # Force refresh on next _process
func get_active_weapon() -> WeaponData:
var wid := inventory[active_slot]
if wid == 0:
return null
return WeaponRegistry.get_weapon(wid)
func get_move_speed() -> float:
var weapon := get_active_weapon()
if weapon:
return weapon.move_speed
return 5.0
func set_default_loadout(team: int):
inventory = PackedInt32Array([WeaponRegistry.KNIFE, WeaponRegistry.get_default_pistol(team), 0, 0])
active_slot = 0
reload_start_tick = -1
recoil_accumulator = 0.0
last_fire_by_slot = PackedInt32Array([-1, -1, -1, -1])
grenade_counts = PackedInt32Array([0, 0])
active_grenade_type = 0
for i in 4:
var weapon := WeaponRegistry.get_weapon(inventory[i])
if weapon:
ammo[i] = weapon.magazine_size
reserve[i] = weapon.reserve_size
else:
ammo[i] = 0
reserve[i] = 0
NetworkRollback.mutate(self)
func strip_weapons():
inventory = PackedInt32Array([0, 0, 0, 0])
active_slot = 0
ammo = PackedInt32Array([0, 0, 0, 0])
reserve = PackedInt32Array([0, 0, 0, 0])
reload_start_tick = -1
last_fire_by_slot = PackedInt32Array([-1, -1, -1, -1])
recoil_accumulator = 0.0
grenade_counts = PackedInt32Array([0, 0])
active_grenade_type = 0
NetworkRollback.mutate(self)
func give_weapon(weapon_id: int):
var weapon := WeaponRegistry.get_weapon(weapon_id)
if weapon == null:
return
inventory[weapon.slot] = weapon_id
ammo[weapon.slot] = weapon.magazine_size
reserve[weapon.slot] = weapon.reserve_size
last_fire_by_slot[weapon.slot] = -1
active_slot = weapon.slot
NetworkRollback.mutate(self)
func _rollback_tick(delta: float, tick: int, _is_fresh: bool):
# NOTE: On the client, the player CharacterBody3D is owned by the server
# (peer 1), so `rollback_synchronizer.is_predicting()` may return true
# on early ticks when input history hasn't been built up yet.
# We process weapon actions anyway — the server reconciles via rollback.
# Only skip if the player is dead or frozen (checked below).
# Skip if player dead
if player.death_tick >= 0 and tick >= player.death_tick:
return
# Recoil recovery (always, even during freeze)
var weapon := get_active_weapon()
if weapon and recoil_accumulator > 0.0:
recoil_accumulator = move_toward(recoil_accumulator, 0.0, weapon.recoil_recovery * delta)
# Can't act checks (pass tick for correct rollback evaluation)
if not player.can_act(tick):
return
# Handle weapon switching
_handle_weapon_switch()
# Handle reload
_handle_reload(tick)
# Handle firing
_handle_fire(tick)
func _handle_weapon_switch():
var new_slot := -1
if input.slot_1 and inventory[0] != 0:
new_slot = 0
elif input.slot_2 and inventory[1] != 0:
new_slot = 1
elif input.slot_3 and inventory[2] != 0:
new_slot = 2
elif input.slot_4 and _has_grenades():
new_slot = 3
elif input.next_weapon:
new_slot = _find_next_occupied_slot(1)
elif input.prev_weapon:
new_slot = _find_next_occupied_slot(-1)
if new_slot >= 0 and new_slot != active_slot:
active_slot = new_slot
reload_start_tick = -1 # cancel reload on switch
recoil_accumulator = 0.0
elif new_slot == 3 and active_slot == 3:
# Re-pressing slot 4 while on grenade slot: cycle grenade type
_cycle_grenade_type()
func _has_grenades() -> bool:
return grenade_counts[0] > 0 or grenade_counts[1] > 0
func _is_slot_occupied(slot: int) -> bool:
if slot == 3:
return _has_grenades()
return inventory[slot] != 0
func _find_next_occupied_slot(direction: int) -> int:
for i in range(1, 5):
var slot := (active_slot + direction * i) % 4
if slot < 0:
slot += 4
if _is_slot_occupied(slot):
return slot
return active_slot
func add_grenade(type: int) -> bool:
## type: 0=flash, 1=smoke. Returns true if added.
var max_count := 2 if type == 0 else 1
if grenade_counts[type] >= max_count:
return false
grenade_counts[type] += 1
NetworkRollback.mutate(self)
return true
func _handle_reload(tick: int):
var weapon := get_active_weapon()
if weapon == null or weapon.fire_mode == WeaponData.FireMode.MELEE:
reload_start_tick = -1
return
# Start reload if requested
if input.reload_pressed and reload_start_tick < 0:
if ammo[active_slot] < weapon.magazine_size and reserve[active_slot] > 0:
reload_start_tick = tick
# Complete reload
if reload_start_tick >= 0:
if NetworkTime.seconds_between(reload_start_tick, tick) >= weapon.reload_time:
var needed := weapon.magazine_size - ammo[active_slot]
var available := mini(needed, reserve[active_slot])
ammo[active_slot] += available
reserve[active_slot] -= available
reload_start_tick = -1
func _handle_fire(tick: int):
# Grenade throw (slot 3) — server-authoritative, no rollback
if active_slot == 3 and _has_grenades():
if input.fire_pressed:
_throw_grenade()
fire_action.set_active(false)
return
var weapon := get_active_weapon()
if weapon == null:
fire_action.set_active(false)
return
# Determine if we should fire
var want_fire := false
if weapon.fire_mode == WeaponData.FireMode.AUTO:
want_fire = input.fire_held
else: # SEMI or MELEE
want_fire = input.fire_pressed
var can_fire := want_fire and _can_fire_weapon(weapon, tick)
fire_action.set_active(can_fire)
match fire_action.get_status():
RewindableAction.CONFIRMING, RewindableAction.ACTIVE:
_fire(weapon, tick)
RewindableAction.CANCELLING:
_unfire()
func _can_fire_weapon(weapon: WeaponData, tick: int) -> bool:
# Check cooldown (per-slot)
if last_fire_by_slot[active_slot] >= 0:
if NetworkTime.seconds_between(last_fire_by_slot[active_slot], tick) < weapon.fire_rate:
return false
# Check ammo (melee has no ammo)
if weapon.fire_mode != WeaponData.FireMode.MELEE:
if ammo[active_slot] <= 0:
return false
# Check reload
if reload_start_tick >= 0:
return false
return true
func _fire(weapon: WeaponData, tick: int):
last_fire_by_slot[active_slot] = tick
# Consume ammo
if weapon.fire_mode != WeaponData.FireMode.MELEE:
ammo[active_slot] -= 1
# Recoil
recoil_accumulator += weapon.recoil_per_shot
# Raycast
var hit := _raycast(weapon)
if hit.is_empty():
return
_on_hit(hit, weapon)
func _unfire():
fire_action.erase_context()
func _raycast(weapon: WeaponData) -> Dictionary:
var space := get_world_3d().direct_space_state
var origin := global_transform.origin
var direction := -global_transform.basis.z
# Apply recoil to direction
if recoil_accumulator > 0.0:
var recoil_rad := deg_to_rad(recoil_accumulator)
direction = direction.rotated(global_transform.basis.x, recoil_rad)
var query := PhysicsRayQueryParameters3D.create(
origin,
origin + direction * weapon.max_distance
)
return space.intersect_ray(query)
func _on_hit(result: Dictionary, weapon: WeaponData):
var is_new_hit := false
if not fire_action.has_context():
fire_action.set_context(true)
is_new_hit = true
if is_new_hit:
_pending_hit_result = result
if result.collider.has_method("damage"):
var attacker_id: int = player.get_player_id()
result.collider.damage(weapon.damage, weapon.weapon_id, attacker_id, is_new_hit)
NetworkRollback.mutate(result.collider)
func _cycle_grenade_type():
# Cycle through available grenade types
for i in range(1, 3):
var next := (active_grenade_type + i) % 2
if grenade_counts[next] > 0:
active_grenade_type = next
return
func _throw_grenade():
if not _has_grenades():
return
# Ensure we have the selected type, otherwise cycle
if grenade_counts[active_grenade_type] <= 0:
_cycle_grenade_type()
if grenade_counts[active_grenade_type] <= 0:
return
grenade_counts[active_grenade_type] -= 1
# Buffer the throw — actual RPC fires in _after_loop (rollback-safe)
_pending_grenades.append({
"origin": global_transform.origin,
"direction": -global_transform.basis.z,
"owner_id": player.get_player_id(),
"type": active_grenade_type
})
# Auto-switch if no grenades left
if not _has_grenades():
active_slot = 0
func _before_loop():
_pending_grenades.clear()
func _after_loop():
# Grenade spawns (deferred from rollback — fires once per throw)
for pg in _pending_grenades:
_spawn_grenade.rpc(pg["origin"], pg["direction"], pg["owner_id"], pg["type"])
_pending_grenades.clear()
if fire_action.has_confirmed():
# Play weapon-appropriate sound
if active_slot == 0 and _knife_sound:
_knife_sound.play()
else:
sound.play()
if not _pending_hit_result.is_empty():
# No bullet holes for knife
if active_slot != 0:
bullethole.action(_pending_hit_result)
_pending_hit_result = {}
@rpc("authority", "call_local", "reliable")
func _spawn_grenade(origin: Vector3, direction: Vector3, owner_id: int, type_idx: int) -> void:
var grenade := Grenade.new()
grenade.name = "Grenade_%d_%d" % [owner_id, NetworkTime.tick]
get_tree().current_scene.add_child(grenade)
var gtype: Grenade.GrenadeType = Grenade.GrenadeType.FLASH if type_idx == 0 else Grenade.GrenadeType.SMOKE
grenade.setup(origin, direction, owner_id, gtype)