Files
shawn 91b878f347 Quaternius weapons, input fixes, sound & path fixes
- 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)
2026-07-05 22:53:27 -04:00

404 lines
12 KiB
GDScript

extends CharacterBody3D
@export var jump_strength = 5.0
@onready var display_name := $DisplayNameLabel3D as Label3D
@onready var input := $Input as PlayerInputFPS
@onready var tick_interpolator := $TickInterpolator as TickInterpolator
@onready var head := $Head as Node3D
@onready var hud := $HUD as CanvasGroup
@onready var camera: Camera3D = $Head/Camera3D
@onready var weapon_manager: WeaponManager = $Head/WeaponManager
static var _logger := NetfoxLogger.new("game", "Player")
var gravity = ProjectSettings.get_setting(&"physics/3d/default_gravity")
var health: int = 100
var death_tick: int = -1
var respawn_position: Vector3
var did_respawn := false
var respawn_tick: int = -1
var deaths := 0
var is_dead := false
# Kill attribution (rollback state)
var last_damage_attacker_id: int = -1
var last_damage_weapon_id: int = 0
var last_damage_tick: int = -1
# Armor and equipment
var armor: int = 0
var has_defuse_kit: bool = false
# Track deaths across tick loops to detect state changes
var _ackd_deaths := 0
var _was_hit := false
var _needs_teleport := false
var _has_played_round := false
# Frame-rate local camera
var _is_local := false
var _sim_transform: Transform3D
var _sim_head_transform: Transform3D
var _sim_yaw: float
var _sim_pitch: float
var _lerp_from_pos: Vector3
var _lerp_to_pos: Vector3
var team_manager: TeamManager
var round_manager: RoundManager
func _ready():
display_name.text = name
hud.hide()
NetworkTime.before_tick_loop.connect(_before_tick_loop)
NetworkTime.after_tick_loop.connect(_after_tick_loop)
NetworkTime.on_tick.connect(_bomb_tick)
func setup_references(tm: TeamManager, rm: RoundManager):
team_manager = tm
round_manager = rm
func _setup_local_camera():
_is_local = true
tick_interpolator._disconnect_signals()
tick_interpolator.enabled = false
_sim_transform = global_transform
_sim_head_transform = head.transform
_sim_yaw = rotation.y
_sim_pitch = head.rotation.x
_lerp_from_pos = global_position
_lerp_to_pos = global_position
func _before_tick_loop():
_ackd_deaths = deaths
_needs_teleport = false
if _is_local:
global_transform = _sim_transform
head.transform = _sim_head_transform
func _after_tick_loop():
if _is_local:
_sim_transform = global_transform
_sim_head_transform = head.transform
_sim_yaw = rotation.y
_sim_pitch = head.rotation.x
_lerp_from_pos = _lerp_to_pos
_lerp_to_pos = global_position
if _needs_teleport:
if _is_local:
_lerp_from_pos = global_position
_lerp_to_pos = global_position
else:
tick_interpolator.teleport()
# Death detected during rollback loop
if deaths > _ackd_deaths:
if _is_local:
_lerp_from_pos = global_position
_lerp_to_pos = global_position
else:
tick_interpolator.teleport()
is_dead = true
$DieSFX.play()
_hide_player()
# Server: report kill with attribution, drop bomb
if is_multiplayer_authority():
_logger.warning("%s died at tick %s", [name, death_tick])
# Drop bomb if carrier
var bomb_node := get_tree().current_scene.get_node_or_null("Network/Bomb") as Bomb
if bomb_node and bomb_node.carrier_id == get_player_id():
bomb_node.drop_at(global_position)
if round_manager:
round_manager.report_kill(get_player_id(), last_damage_attacker_id, last_damage_weapon_id, death_tick)
_sync_death.rpc(death_tick)
_ackd_deaths = deaths
if _was_hit:
$HitSFX.play()
_was_hit = false
func _process(_delta: float):
if not _is_local:
if input.is_multiplayer_authority():
print("[PLAYER DEBUG] First _process — calling _setup_local_camera (authority: %d)" % multiplayer.get_unique_id())
_setup_local_camera()
else:
print("[PLAYER DEBUG] _process skipping — input not authority (unique_id=%d, input_auth=%d)" % [multiplayer.get_unique_id(), input.get_multiplayer_authority()])
return
# DEBUG: Show input state in window title
if _is_local and input:
var mx := Input.get_axis("move_west", "move_east")
var mz := Input.get_axis("move_north", "move_south")
var dead := death_tick >= 0
var frozen := round_manager != null and round_manager.freeze_end_tick >= 0 and NetworkTime.tick < round_manager.freeze_end_tick
var hp_str := str(health)
var rm_ok := round_manager != null
# Weapon model load status from weapon_manager
var knife_ok := weapon_manager != null and weapon_manager.has_knife_model()
var gun_ok := weapon_manager != null and weapon_manager.has_gun_model()
get_window().title = "TS [mv=%.1f,%.1f] hp=%s d=%s f=%s k=%s g=%s [fire=%s]" % [mx, mz, hp_str, dead, frozen, knife_ok, gun_ok, input.fire_held]
# Smooth position interpolation between ticks
var f := NetworkTime.tick_factor
global_position = _lerp_from_pos.lerp(_lerp_to_pos, f)
# Frame-rate mouse look (recoil only affects raycast, not camera)
var old_yaw = _sim_yaw
rotation.y = _sim_yaw + (-input.mouse_rotation.y)
if _sim_yaw != old_yaw or input.mouse_rotation.length_squared() > 0:
pass # mouse look active
head.rotation.x = clamp(_sim_pitch + (-input.mouse_rotation.x), -1.57, 1.57)
head.rotation.y = 0
head.rotation.z = 0
func _rollback_tick(delta: float, tick: int, is_fresh: bool) -> void:
# DEBUG movement state
if input and input.movement.length_squared() > 0:
print("[PLAYER RT] tick=%d movement=%s hp=%d death_tick=%d frozen=%s" % [tick, input.movement, health, death_tick, round_manager and round_manager.freeze_end_tick >= 0 and tick < round_manager.freeze_end_tick])
# Handle round respawn teleport inside rollback
if respawn_tick >= 0 and tick == respawn_tick:
global_position = respawn_position
velocity = Vector3.ZERO
did_respawn = true
_needs_teleport = true
else:
did_respawn = false
# Dead check
if death_tick >= 0 and tick >= death_tick:
return
# Death detection inside rollback
if health <= 0:
death_tick = tick
deaths += 1
return
# Freeze detection
var is_frozen := round_manager != null and round_manager.freeze_end_tick >= 0 and tick < round_manager.freeze_end_tick
# Handle look (always allowed)
rotate_object_local(Vector3(0, 1, 0), input.look_angle.x)
head.rotate_object_local(Vector3(1, 0, 0), input.look_angle.y)
head.rotation.x = clamp(head.rotation.x, -1.57, 1.57)
head.rotation.z = 0
head.rotation.y = 0
# Gravity + Jump
_force_update_is_on_floor()
if is_on_floor():
if input.jump:
velocity.y = jump_strength
else:
velocity.y -= gravity * delta
# Movement speed from active weapon
var speed := weapon_manager.get_move_speed() if weapon_manager else 5.0
if is_frozen:
velocity.x = 0.0
velocity.z = 0.0
velocity *= NetworkTime.physics_factor
move_and_slide()
velocity /= NetworkTime.physics_factor
return
# Apply movement
var input_dir = input.movement
var direction = (transform.basis * Vector3(input_dir.x, 0, input_dir.z)).normalized()
if direction:
velocity.x = direction.x * speed
velocity.z = direction.z * speed
else:
velocity.x = move_toward(velocity.x, 0, speed)
velocity.z = move_toward(velocity.z, 0, speed)
velocity *= NetworkTime.physics_factor
move_and_slide()
velocity /= NetworkTime.physics_factor
func _force_update_is_on_floor():
var old_velocity = velocity
velocity = Vector3.ZERO
move_and_slide()
velocity = old_velocity
func damage(amount: int, weapon_id: int = 0, attacker_id: int = -1, is_new_hit: bool = true):
# Friendly fire check
if team_manager and attacker_id >= 0 and attacker_id != get_player_id():
if team_manager.get_team(attacker_id) == team_manager.get_team(get_player_id()):
return
# Armor absorption (Phase 3 — armor starts at 0)
var armor_pen := 1.0
var weapon := WeaponRegistry.get_weapon(weapon_id)
if weapon:
armor_pen = weapon.armor_penetration
var effective := amount
if armor > 0 and armor_pen < 1.0:
var max_absorb := int(amount * (1.0 - armor_pen))
var actual_absorb := mini(max_absorb, armor)
effective = amount - actual_absorb
armor -= actual_absorb
health -= effective
# Kill attribution
if attacker_id >= 0:
last_damage_attacker_id = attacker_id
last_damage_weapon_id = weapon_id
last_damage_tick = NetworkRollback.tick
if is_new_hit:
_was_hit = true
_logger.warning("%s HP now at %s", [name, health])
func can_act(tick: int = -1) -> bool:
if death_tick >= 0:
return false
var t := tick if tick >= 0 else NetworkRollback.tick
if round_manager:
if round_manager.freeze_end_tick >= 0 and t < round_manager.freeze_end_tick:
return false
if round_manager.round_end_tick >= 0 and t >= round_manager.round_end_tick:
return false
return true
func set_spectator():
is_dead = true
death_tick = NetworkTime.tick
_hide_player()
_sync_spectator.rpc(death_tick)
func round_respawn(spawn_pos: Vector3, survived: bool = false):
is_dead = false
health = 100
deaths = 0
death_tick = -1
last_damage_attacker_id = -1
last_damage_weapon_id = 0
last_damage_tick = -1
respawn_position = spawn_pos
respawn_tick = NetworkTime.tick
global_position = spawn_pos
velocity = Vector3.ZERO
did_respawn = true
_has_played_round = true
_show_player()
if not survived:
armor = 0
has_defuse_kit = false
var team := team_manager.get_team(get_player_id()) if team_manager else TeamManager.Team.T
weapon_manager.set_default_loadout(team)
else:
# Keep weapons + armor, just cancel reload
weapon_manager.reload_start_tick = -1
weapon_manager.recoil_accumulator = 0.0
_sync_round_respawn.rpc(spawn_pos, respawn_tick)
func _hide_player():
$MeshInstance3D.hide()
$Head/Nose.hide()
$Head/BigGun.hide()
weapon_manager.hide_weapons()
$CollisionShape3D.disabled = true
$DisplayNameLabel3D.hide()
$Projection3D.hide()
func _show_player():
$MeshInstance3D.show()
$Head/Nose.show()
$Head/BigGun.show()
weapon_manager.show_weapons()
$CollisionShape3D.disabled = false
$DisplayNameLabel3D.show()
$Projection3D.show()
func _bomb_tick(_delta: float, _tick: int):
if not multiplayer.is_server():
return
if not is_multiplayer_authority():
return
if is_dead:
return
var bomb_node := get_tree().current_scene.get_node_or_null("Network/Bomb") as Bomb
if bomb_node == null:
return
var pid := get_player_id()
var team := team_manager.get_team(pid) if team_manager else TeamManager.Team.NONE
# T player: start plant or pickup (cancel/validation handled by bomb._server_tick)
if team == TeamManager.Team.T:
if bomb_node.state == Bomb.BombState.CARRIED and bomb_node.carrier_id == pid:
if input.use_held:
var bombsites := get_tree().get_nodes_in_group("bombsite")
for site in bombsites:
if site is Bombsite and site.is_player_inside(self):
bomb_node.try_start_plant(pid, site.site_name)
break
elif bomb_node.state == Bomb.BombState.DROPPED:
if global_position.distance_to(bomb_node.position) < 2.0:
bomb_node.try_pickup(pid)
# CT player: start defuse (cancel/validation handled by bomb._server_tick)
elif team == TeamManager.Team.CT:
if bomb_node.state == Bomb.BombState.PLANTED:
if input.use_held and global_position.distance_to(bomb_node.position) < 2.0:
bomb_node.try_start_defuse(pid)
func get_player_id() -> int:
return input.get_multiplayer_authority()
# --- RPCs ---
@rpc("authority", "call_local", "reliable")
func _sync_death(server_death_tick: int) -> void:
is_dead = true
death_tick = server_death_tick
_hide_player()
if _is_local:
_lerp_from_pos = global_position
_lerp_to_pos = global_position
@rpc("authority", "call_local", "reliable")
func _sync_spectator(server_death_tick: int) -> void:
is_dead = true
death_tick = server_death_tick
_hide_player()
if _is_local:
_lerp_from_pos = global_position
_lerp_to_pos = global_position
@rpc("authority", "call_local", "reliable")
func _sync_round_respawn(spawn_pos: Vector3, _respawn_tick: int) -> void:
is_dead = false
health = 100
deaths = 0
death_tick = -1
last_damage_attacker_id = -1
last_damage_weapon_id = 0
last_damage_tick = -1
respawn_position = spawn_pos
respawn_tick = _respawn_tick
global_position = spawn_pos
velocity = Vector3.ZERO
did_respawn = true
_ackd_deaths = 0
_show_player()
if _is_local:
_sim_transform = global_transform
_sim_head_transform = head.transform
_sim_yaw = rotation.y
_sim_pitch = head.rotation.x
_lerp_from_pos = spawn_pos
_lerp_to_pos = spawn_pos