91b878f347
- Added 6 Quaternius weapon models (Pistol, Revolver, Rifle, Shotgun, P90, SniperRifle) - Created weapon resource files with balanced stats - Wired Pistol + Rifle into player.tscn replacing Kenney blasters - Fixed WASD input (matched input map action names) - Fixed Escape key toggles mouse capture - Added Audio autoload singleton - Fixed broken sound asset paths across all scripts - Fixed all scene ext_resource text paths (nested under assets/kenney-fps/) - Fixed all .import source_file paths - Deleted stale .import files (will be regenerated by editor on open)
453 lines
14 KiB
GDScript
453 lines
14 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 _pistol_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():
|
|
# Weapon models from Quaternius FPS Pack — GLB format, reliable import
|
|
|
|
# Slot 0 (melee) + Slot 1 (pistol): Use Quaternius Pistol model
|
|
var pistol_scene := load("res://assets/quaternius/models/Pistol.glb") as PackedScene
|
|
if pistol_scene:
|
|
# Slot 0: small pistol as melee visual
|
|
var knife = pistol_scene.instantiate()
|
|
knife.scale = Vector3(0.35, 0.35, 0.35)
|
|
knife.position = Vector3(0.08, -0.18, -0.28)
|
|
knife.rotation_degrees = Vector3(-90, 180, 0)
|
|
add_child(knife)
|
|
_knife_model = knife
|
|
_knife_model.hide()
|
|
|
|
# Slot 1: same pistol model, slightly different position
|
|
var pistol = pistol_scene.instantiate()
|
|
pistol.scale = Vector3(0.35, 0.35, 0.35)
|
|
pistol.position = Vector3(0.08, -0.18, -0.28)
|
|
pistol.rotation_degrees = Vector3(-90, 180, 0)
|
|
add_child(pistol)
|
|
_pistol_model = pistol
|
|
_pistol_model.hide()
|
|
else:
|
|
print("[WM] Pistol model not available — using placeholder")
|
|
for slot in [0, 1]:
|
|
var box := MeshInstance3D.new()
|
|
box.mesh = BoxMesh.new()
|
|
box.mesh.size = Vector3(0.3, 0.05, 0.02)
|
|
box.position = Vector3(0.15, -0.22, -0.35)
|
|
box.rotation_degrees = Vector3(-90, 0, 0)
|
|
add_child(box)
|
|
if slot == 0:
|
|
_knife_model = box
|
|
else:
|
|
_pistol_model = box
|
|
box.hide()
|
|
|
|
# Slot 2 (primary rifle): Use Quaternius Rifle model
|
|
var rifle_scene := load("res://assets/quaternius/models/Rifle.glb") as PackedScene
|
|
if not rifle_scene:
|
|
rifle_scene = load("res://assets/quaternius/models/P90.glb") as PackedScene
|
|
if rifle_scene:
|
|
var rifle = rifle_scene.instantiate()
|
|
rifle.scale = Vector3(0.35, 0.35, 0.35)
|
|
rifle.position = Vector3(0.06, -0.16, -0.25)
|
|
rifle.rotation_degrees = Vector3(-90, 180, 0)
|
|
add_child(rifle)
|
|
if _gun_model:
|
|
_gun_model.hide()
|
|
_gun_model = rifle
|
|
_gun_model.hide()
|
|
else:
|
|
print("[WM] Rifle model not available — using placeholder TinyGun")
|
|
|
|
# Grenade model (keep existing)
|
|
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()
|
|
|
|
# Knife slash 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 _pistol_model:
|
|
_pistol_model.visible = (active_slot == 1)
|
|
if _gun_model:
|
|
_gun_model.visible = (active_slot == 2)
|
|
if _grenade_model:
|
|
_grenade_model.visible = (active_slot == 3)
|
|
|
|
func hide_weapons():
|
|
if _knife_model:
|
|
_knife_model.hide()
|
|
if _pistol_model:
|
|
_pistol_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
|
|
|
|
# Debug helpers for window title
|
|
func has_knife_model() -> bool:
|
|
return _knife_model != null
|
|
func has_gun_model() -> bool:
|
|
return _gun_model != null
|
|
|
|
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)
|