Player/TopDownPlayer.cs
using System;
using System.Linq;

namespace Prototype;

public sealed class FpsPlayer : Component
{
	[Property] public bool ThirdPersonView { get; set; }
	public Vector3 VisualMuzzlePosition => Components.Get<DasherBodyVisual>() is { Ready: true } body ? body.MuzzlePosition : _viewWeapon.IsValid() && !ThirdPersonView
		? _viewWeapon.WorldPosition + _viewWeapon.WorldRotation.Right * (Weapon?.Spec.Id == "scatter" ? 49f : 61f)
		: EyePosition + EyeRotation.Forward * 38f + EyeRotation.Down * 13f;
	[Sync] public string BodyAnimation { get; private set; } = "idle";
	[Sync] public string BodyWeaponId { get; private set; } = "rifle";
	[Sync] public float WeaponSwitchProgress { get; private set; }
	public bool SwitchingWeapon => _switchLeft > 0f;
	private float _switchLeft;
	private bool _switchCommitted;
	[Sync] public float BodyReloadProgress { get; private set; }
	private float _bodyAttackUntil;
	[Sync(SyncFlags.FromHost)] public Guid ParticipantId { get; set; }
	[Sync] public string PlayerName { get; set; } = "Runner";
	public RunBuffs Buffs => Components.Get<RunBuffs>();
	public float MoveSpeed { get; set; } = 280f;
	public float DashSpeed { get; set; } = 1180f;
	public float DashDuration { get; set; } = .24f;
	public float DashCooldown { get; set; } = 1.35f;
	[Sync] public float DamageMultiplier { get; private set; } = 1f;
	[Sync] public float FireRateMultiplier { get; private set; } = 1f;
	public float SpeedMultiplier { get; private set; } = 1f;
	public HealthComponent Vitality { get; private set; }
	public WeaponController Weapon { get; private set; }
	public HeroSpec Hero { get; private set; }
	public float UltimateCooldown => MathF.Max( 0f, _ultimateCooldown );
	public float DashCooldownRemaining => MathF.Max( 0f, _dashLeft );
	public float DashReadyFraction => 1f - Math.Clamp( DashCooldownRemaining / MathF.Max( .01f, DashCooldown ), 0f, 1f );
	public float LastHitDamage { get; private set; }
	public float LastHitAt { get; private set; } = -100f;
    public bool LastHitHeadshot { get; private set; }
    public float LastHurtAt { get; private set; } = -100f;
    public float HurtStrength { get; private set; }
    private float _hitSoundAt = -1f;
    private bool _hitSoundHeadshot;
    private float _hurtSoundAt = -100f;

	private Vector3 _knockback;
	private Vector3 _velocity;
	private Angles _viewAngles;
	private bool _grounded;
    private float _hopBonus;
    private float _hopGroundTime;
    private bool _hopChain;
    private const float HopGain = .12f;
    private const float HopMaximum = .6f;
    private const float HopLandingWindow = .15f;

	private Vector3 _lastSafePosition;
	private float _dashLeft;
	private float _dashTime;
	private Vector3 _dashDirection;
	private float _invulnerable;
	private CapsuleCollider _hitbox;
	private GameObject _viewWeapon;
	private float _weaponKick;
	private float _weaponSwayYaw;
	private float _weaponSwayPitch;
	private float _moveIntensity;
	private float _swapAnimation;
	private float _ultimateCooldown;
	private float _ultimateTime;
	private bool _ultimateActive;
	private float _baseDamageReduction;
    [Sync] public float PermanentReloadFactor { get; private set; } = 1f;
    public float TotalDefense => _baseDamageReduction * 100f + RunBuffs.ArmorPerRank * (Buffs?.Rank("armor") ?? 0);
	public Vector3 EyePosition => WorldPosition + Vector3.Up * 65f;
	[Sync] public Rotation NetworkViewRotation { get; private set; } = Rotation.Identity;
	public Rotation EyeRotation => IsProxy ? NetworkViewRotation : _viewAngles.ToRotation();
	public FpsPlayer SpectatorTarget { get; private set; }
	public bool IsSpectating => PrototypeGame.Instance?.LocalPlayer == this && Vitality?.IsDead == true && PrototypeGame.Instance?.Phase is RunPhase.Combat or RunPhase.RoomClear or RunPhase.Reward or RunPhase.Generating;
	public Rotation ViewRigRotation => EyeRotation * Rotation.FromPitch( _weaponKick * 5f + _weaponSwayPitch * .35f ) * Rotation.FromYaw( _weaponSwayYaw * .35f );
	public Vector3 ViewRigOffset => EyeRotation.Forward * (-_weaponKick * 3f)
		+ EyeRotation.Right * ((_grounded ? MathF.Sin( Time.Now * 10.5f ) * .8f * _moveIntensity : 0f) + _weaponSwayYaw * .12f)
		- EyeRotation.Up * ((_grounded ? MathF.Abs( MathF.Cos( Time.Now * 10.5f ) ) * .6f * _moveIntensity : 0f) + _weaponSwayPitch * .1f);
	private Vector3? _pendingSpawn;
	private int _spawnSeed;

	[Rpc.Broadcast]
	public void TeleportForLevel( Vector3 position, int seed, bool freshRun )
	{
		if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
		if ( IsProxy ) return;
		_pendingSpawn = position;
		_spawnSeed = seed;
		// Player health is owner-synced: a host-only reset cannot revive a guest.
		if (freshRun && Vitality.IsValid())
        {
            _ultimateActive = false; _ultimateTime = _ultimateCooldown = 0f;
            _dashLeft = _dashTime = _switchLeft = 0f;
            Weapon?.ResetForRun();
            LastHitAt = LastHurtAt = -100f;
            _hitSoundAt = -1f; _hitSoundHeadshot = false;
            DamagePopups.Clear();
            Buffs?.ResetOwner();
            DamageMultiplier = FireRateMultiplier = SpeedMultiplier = 1f;
            DashCooldown = 1.35f; _baseDamageReduction = 0f;
            ApplyHeroLoadout();
        }
        // Surviving runners keep their health; only a new run or revival heals fully.
        if ( Vitality.IsValid() && (freshRun || Vitality.IsDead) ) Vitality.ResetTo( Vitality.MaxHealth );
	}

	protected override void OnStart()
	{
		GameProfile.Load();
		Hero = HeroCatalog.Get( GameProfile.Progress.SelectedHero );
		Vitality = Components.GetOrCreate<HealthComponent>();
		_lastSafePosition = WorldPosition;
		if ( !IsProxy ) Vitality.ResetTo( 100f );
		if ( !IsProxy ) ApplyHeroLoadout();
		Vitality.Died -= OnPlayerDied;
		Vitality.Died += OnPlayerDied;
		Weapon = Components.GetOrCreate<WeaponController>();
		var hitbox = Components.GetOrCreate<CapsuleCollider>();
		hitbox.Start = Vector3.Up * 18f;
		hitbox.End = Vector3.Up * 54f;
		hitbox.Radius = 18f;
		hitbox.IsTrigger = false;
		_hitbox = hitbox;
        // Production rig owns the player visual; do not create the old second body.
        // DasherBodyVisual is created by SpawnPlayer before NetworkSpawn.
		_viewWeapon = new GameObject( true, "View Weapon" ); _viewWeapon.WorldPosition = EyePosition;
		_viewWeapon.NetworkMode = NetworkMode.Never;
		RuinVisualFactory.SetWeaponModel( _viewWeapon, PrototypeCatalog.Rifle );
		_viewWeapon.Enabled = !IsProxy;
	}

	protected override void OnFixedUpdate()
	{
		var phase = PrototypeGame.Instance?.LocalPhase;
		if ( IsProxy || Vitality?.IsDead == true || phase is not (RunPhase.Combat or RunPhase.Reward) ) return;
		var game = PrototypeGame.Instance;
		if ( game?.WorldReady != true ) { _velocity = Vector3.Zero; ResetBunnyHop(); return; }
		if ( _pendingSpawn.HasValue )
		{
			if ( game.Seed != _spawnSeed ) return;
			WorldPosition = _pendingSpawn.Value;
			_lastSafePosition = WorldPosition;
			_velocity = _knockback = Vector3.Zero;
			_dashTime = 0f;
			_pendingSpawn = null;
            _grounded = false;
            ResetBunnyHop();
		}
		_dashLeft = MathF.Max( 0f, _dashLeft - Time.Delta );
		_dashTime = MathF.Max( 0f, _dashTime - Time.Delta );
		_invulnerable = MathF.Max( 0f, _invulnerable - Time.Delta );
		_ultimateCooldown = MathF.Max( 0f, _ultimateCooldown - Time.Delta );
		_ultimateTime = MathF.Max( 0f, _ultimateTime - Time.Delta );
		if ( _ultimateActive && _ultimateTime <= 0f ) EndUltimate();
		if ( WorldPosition.z < -120f ) RecoverFromFall();
		if (!game.IsRunMenuOpen) UpdateLook();
		var moveInput = game.IsRunMenuOpen ? Vector3.Zero : Input.AnalogMove;
		_moveIntensity = Math.Clamp( moveInput.Length, 0f, 1f );
		var yaw = Rotation.FromYaw( _viewAngles.yaw );
		var wish = yaw * new Vector3( moveInput.x, moveInput.y, 0f );
		if ( !wish.IsNearZeroLength ) wish = wish.Normal;

		if ( !game.IsRunMenuOpen && Input.Pressed( "Dash" ) && _dashLeft <= 0f )
		{
			_dashDirection = wish.IsNearZeroLength ? yaw.Forward : wish;
			_dashTime = DashDuration;
			_dashLeft = MathF.Max(1.35f*.5f, DashCooldown*(1f-.1f*(Buffs?.Rank("dash") ?? 0)));
            Buffs?.DashStarted();
			_invulnerable = DashDuration;
			PrototypeAudio.Play( "dash", WorldPosition );
		}
        // Dash preserves the chain and gives a fresh landing window afterward.
        _hopGroundTime = _grounded && _dashTime <= 0f ? _hopGroundTime + Time.Delta : 0f;
        if (game.IsRunMenuOpen || (_dashTime <= 0f && (wish.IsNearZeroLength || _hopGroundTime > HopLandingWindow)))
            ResetBunnyHop();
        // Holding jump queues the next takeoff as soon as ground contact is detected.
        if (_grounded && !game.IsRunMenuOpen && Input.Down("Jump"))
        {
            if (!wish.IsNearZeroLength && _dashTime <= 0f)
            {
                _hopBonus = _hopChain ? MathF.Min(HopMaximum, _hopBonus + HopGain) : 0f;
                _hopChain = true;
            }
            _hopGroundTime = 0f;
            _velocity.z = 300f;
            _grounded = false;
        }
        var horizontalVelocity = wish * MoveSpeed * SpeedMultiplier * (Buffs?.SpeedFactor ?? 1f) * (1f + _hopBonus) + _knockback;
        if (_dashTime > 0f) horizontalVelocity = _dashDirection * DashSpeed * SpeedMultiplier + _knockback;
		_velocity.z -= 780f * Time.Delta;
        var beforeMove = WorldPosition;
		MoveHorizontal( horizontalVelocity * Time.Delta );
        // Pushing into a wall cannot bank a faster chain for later.
        if (_hopChain && _dashTime <= 0f && (WorldPosition - beforeMove).WithZ(0f).Length < horizontalVelocity.Length * Time.Delta * .25f)
            ResetBunnyHop();
		MoveVertical( _velocity.z * Time.Delta );
		ProbeGround();
		_knockback = Vector3.Lerp( _knockback, Vector3.Zero, Time.Delta * 9f );
		if ( phase == RunPhase.Combat && !game.IsRunMenuOpen && Input.Down( "Attack1" ) && Weapon.TryFire( EyePosition + EyeRotation.Forward * 22f, EyeRotation.Forward, GameObject ) )
		{
			_weaponKick = MathF.Min( 1.6f, _weaponKick + (Weapon.Spec.Pellets > 1 ? 1.35f : .72f) );
			_bodyAttackUntil = Time.Now + .25f;
			_viewAngles.pitch -= Weapon.Spec.Pellets > 1 ? .9f : .22f;
		}
		if ( !game.IsRunMenuOpen && Input.Pressed( "Reload" ) ) Weapon.StartReload();
		if ( !game.IsRunMenuOpen && Input.Pressed( "Menu" ) ) EquipAlternate();
		if ( !game.IsRunMenuOpen && !game.HasNearbyRunInteraction && Input.Pressed( "Use" ) ) ActivateUltimate();
	}

	protected override void OnUpdate()
	{
		if ( _hitbox.IsValid() ) _hitbox.Enabled = Vitality?.IsDead != true;
		if ( IsProxy ) return;
		if (_hitSoundAt >= 0f && Time.Now >= _hitSoundAt)
        {
            PrototypeAudio.Play(_hitSoundHeadshot ? "hit_head" : "hit_body", EyePosition);
            _hitSoundAt = -1f;
            _hitSoundHeadshot = false;
        }
        // Cumulative host state repairs a missed reward RPC without double credit.
        var economy = PrototypeGame.Instance;
        if (economy?.LocalInRun == true) GameProfile.BankCoins(economy.CoinRunId, economy.LocalRunCoins);
        NetworkViewRotation = _viewAngles.ToRotation();
		UpdateSpectator();
		if ( _switchLeft > 0f )
		{
			_switchLeft = MathF.Max( 0f, _switchLeft - Time.Delta );
			WeaponSwitchProgress = 1f - _switchLeft / .55f;
			if ( !_switchCommitted && WeaponSwitchProgress >= .5f )
			{
				_switchCommitted = true;
				Weapon.Equip( Weapon.Spec.Id == "rifle" ? PrototypeCatalog.Scatter : PrototypeCatalog.Rifle );
				RuinVisualFactory.SetWeaponModel( _viewWeapon, Weapon.Spec );
			}
		}
		else WeaponSwitchProgress = 0f;
		BodyWeaponId = Weapon?.Spec.Id ?? "rifle";
		BodyReloadProgress = Weapon?.ReloadProgress ?? 0f;
		var dash = WorldRotation.Inverse * _dashDirection;
		BodyAnimation = Vitality?.IsDead == true ? "death"
			: _dashTime > 0f ? (MathF.Abs( dash.x ) >= MathF.Abs( dash.y )
				? (dash.x >= 0 ? "dashforward" : "dashback")
				: (dash.y >= 0 ? "dashleft" : "dashright"))
			: Weapon?.Reloading == true ? "reload"
			: Time.Now < _bodyAttackUntil ? "attack"
			: _moveIntensity > .1f ? "run" : "idle";
	}

    private void UpdateSpectator()
    {
        if ( !IsSpectating ) { SpectatorTarget = null; return; }
        var living = PrototypeGame.Instance.RunPlayers.Where( p => p != this && p.Vitality?.IsDead == false )
            .OrderBy( p => p.GameObject.Network.OwnerId ).ToArray();
        if ( living.Length == 0 ) { SpectatorTarget = null; return; }
        var index = Array.IndexOf( living, SpectatorTarget );
        if ( index < 0 ) index = 0;
        else if ( PrototypeGame.Instance?.IsRunMenuOpen != true && Input.Pressed( "Attack1" ) ) index = (index + 1) % living.Length;
        else if ( PrototypeGame.Instance?.IsRunMenuOpen != true && Input.Pressed( "Attack2" ) ) index = (index + living.Length - 1) % living.Length;
        SpectatorTarget = living[index];
    }

	private void UpdateLook()
	{
		var lookInput = Input.AnalogLook;
        if (GameProfile.Settings.InvertMouse) lookInput.pitch = -lookInput.pitch;
		_viewAngles += lookInput * GameProfile.Settings.MouseSensitivity;
		_weaponSwayYaw = MathX.Lerp( _weaponSwayYaw, -lookInput.yaw * (GameProfile.Settings.ReduceWeaponMotion ? 0f : 1.8f), Time.Delta * 14f );
		_weaponSwayPitch = MathX.Lerp( _weaponSwayPitch, lookInput.pitch * (GameProfile.Settings.ReduceWeaponMotion ? 0f : 1.45f), Time.Delta * 14f );
		_viewAngles.pitch = Math.Clamp( _viewAngles.pitch, -86f, 86f );
		_viewAngles.roll = 0f;
		WorldRotation = Rotation.FromYaw( _viewAngles.yaw );
	}

	private void MoveHorizontal( Vector3 delta )
	{
		if ( delta.IsNearZeroLength ) return;
		var center = WorldPosition + Vector3.Up * 36f;
		var trace = Scene.Trace.Sphere( 18f, center, center + delta ).WithoutTags( "player", "enemy", "projectile" ).Run();
		// At a box edge the sweep may begin touching/inside the collider and return
		// zero movement forever. Try a conventional stair step before sliding; this
		// also lets the controller recover from shallow overlap on props.
		if ( trace.Hit && trace.Fraction <= .03f && TryStepMove( delta ) ) return;
		WorldPosition += delta * MathF.Max( 0f, trace.Fraction - .02f );
		if ( !trace.Hit ) return;
		var remaining = delta * (1f - trace.Fraction);
		var normal = trace.Normal.WithZ( 0f ).Normal;
		var slide = remaining - normal * Vector3.Dot( remaining, normal );
		center = WorldPosition + Vector3.Up * 36f;
		var slideTrace = Scene.Trace.Sphere( 18f, center, center + slide ).WithoutTags( "player", "enemy", "projectile" ).Run();
		WorldPosition += slide * MathF.Max( 0f, slideTrace.Fraction - .02f );
	}

	private bool TryStepMove( Vector3 delta )
	{
		const float stepHeight = 24f;
		var raisedCenter = WorldPosition + Vector3.Up * (36f + stepHeight);
		var raisedTrace = Scene.Trace.Sphere( 18f, raisedCenter, raisedCenter + delta )
			.WithoutTags( "player", "enemy", "projectile" ).Run();
		if ( raisedTrace.Hit && raisedTrace.Fraction < .98f ) return false;
		var destination = WorldPosition + Vector3.Up * stepHeight + delta;
		var ground = Scene.Trace.Ray( destination + Vector3.Up * 6f, destination + Vector3.Down * (stepHeight + 10f) )
			.WithoutTags( "player", "enemy", "projectile" ).Run();
		if ( !ground.Hit ) return false;
		WorldPosition = destination.WithZ( ground.HitPosition.z );
		_velocity.z = 0f;
		_grounded = true;
		return true;
	}

	private void MoveVertical( float amount )
	{
		if ( MathF.Abs( amount ) < .001f ) return;
		var center = WorldPosition + Vector3.Up * 36f;
		var delta = Vector3.Up * amount;
		var trace = Scene.Trace.Sphere( 18f, center, center + delta ).WithoutTags( "player", "enemy", "projectile" ).Run();
		WorldPosition += delta * MathF.Max( 0f, trace.Fraction - .02f );
		if ( trace.Hit ) _velocity.z = 0f;
	}

	private void ProbeGround()
	{
		var trace = Scene.Trace.Ray( WorldPosition + Vector3.Up * 6f, WorldPosition + Vector3.Down * 12f ).WithoutTags( "player", "enemy", "projectile" ).Run();
		_grounded = trace.Hit && _velocity.z <= 0f;
		if ( _grounded )
		{
			WorldPosition = WorldPosition.WithZ( trace.HitPosition.z );
			_velocity.z = 0f;
			_lastSafePosition = WorldPosition;
		}
	}

    private void ResetBunnyHop()
    {
        _hopBonus = 0f;
        _hopChain = false;
        _hopGroundTime = 0f;
    }

	private void RecoverFromFall()
	{
		WorldPosition = _lastSafePosition + Vector3.Up * 8f;
        _grounded = false;
        ResetBunnyHop();
		_velocity = Vector3.Zero;
		_knockback = Vector3.Zero;
	}

    public void UpdateLegacyVisualVisibility(bool firstPerson, bool hasRig)
    {
        // Never put the camera inside the fallback head, even while the rig loads.
        if (_viewWeapon.IsValid()) _viewWeapon.Enabled = !IsProxy && !IsSpectating && Vitality?.IsDead != true && firstPerson && !hasRig;
    }

	protected override void OnPreRender()
	{
		if ( PrototypeGame.Instance?.LocalPlayer != this || Scene.Camera is null ) return;
        UpdateSpectator();
        var viewTarget = IsSpectating ? SpectatorTarget : this;
        if (viewTarget.IsValid())
        {
            viewTarget.UpdateLegacyVisualVisibility(true, viewTarget.Components.Get<DasherBodyVisual>()?.Ready == true);
            viewTarget.Components.Get<DasherBodyVisual>()?.PrepareView();
        }
        if ( IsSpectating && SpectatorTarget.IsValid() && SpectatorTarget.Vitality?.IsDead == false )
        {
            Scene.Camera.WorldPosition = SpectatorTarget.EyePosition;
            Scene.Camera.WorldRotation = SpectatorTarget.EyeRotation;
            Scene.Camera.FieldOfView = GameProfile.Settings.FieldOfView;
            if ( _viewWeapon.IsValid() ) _viewWeapon.Enabled = false;
            return;
        }
		var cameraTrace = Scene.Trace.Ray( EyePosition, EyePosition - EyeRotation.Forward * 140f + Vector3.Up * 18f ).WithoutTags( "player", "projectile" ).Run();
		Scene.Camera.WorldPosition = ThirdPersonView ? cameraTrace.EndPosition : EyePosition;
		if ( _viewWeapon.IsValid() ) _viewWeapon.Enabled = Vitality?.IsDead != true && !ThirdPersonView && Components.Get<DasherBodyVisual>()?.Ready != true;
		Scene.Camera.WorldRotation = EyeRotation;
		Scene.Camera.FieldOfView = GameProfile.Settings.FieldOfView;
		var colorAdjustments = Scene.Camera.Components.GetOrCreate<ColorAdjustments>();
		colorAdjustments.Brightness = GameProfile.Settings.Brightness;
		_weaponKick = MathX.Lerp( _weaponKick, 0f, Time.Delta * 12f );
		_swapAnimation = MathF.Max( 0f, _swapAnimation - Time.Delta * 3.8f );
		if ( _viewWeapon.IsValid() )
		{
			var reload = Weapon?.ReloadProgress ?? 0f;
			var reloadArc = MathF.Sin( reload * MathF.PI );
			var walkBob = _grounded ? MathF.Sin( Time.Now * 10.5f ) * 1.45f * _moveIntensity : 0f;
			var walkLift = _grounded ? MathF.Abs( MathF.Cos( Time.Now * 10.5f ) ) * 1.1f * _moveIntensity : 0f;
			var lowered = reloadArc * 13f + MathF.Sin( _swapAnimation * MathF.PI ) * 18f;
			_viewWeapon.WorldPosition = EyePosition
				+ EyeRotation.Forward * (38f - _weaponKick * 7f)
				+ EyeRotation.Right * (11f + walkBob + _weaponSwayYaw * .18f)
				+ EyeRotation.Down * (16f + lowered + walkLift + _weaponSwayPitch * .14f);
			// Both FBX weapons import with their barrel along local -Y, Z up.
			var productionOrientation = Rotation.FromYaw( 90f );
			_viewWeapon.WorldRotation = EyeRotation
				* productionOrientation
				* Rotation.FromPitch( reloadArc * 48f + _weaponKick * 9f + _weaponSwayPitch )
				* Rotation.FromYaw( _weaponSwayYaw )
				* Rotation.FromRoll( reloadArc * -32f + _swapAnimation * 75f + walkBob * .65f - _weaponKick * 2.5f );
			_viewWeapon.Components.Get<ModelRenderer>()?.Tint = Color.White;
		}
	}

	[Rpc.Broadcast]
    public void ReceiveCoins(string runId, int total)
    {
        if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;
        if (Networking.IsActive ? Connection.Local?.Id != ParticipantId : IsProxy) return;
        GameProfile.BankCoins(runId, total);
    }
	public sealed class DamagePopup
    {
        public float Damage { get; set; }
        public float Started { get; init; }
        public int Slot { get; init; }
        public bool Headshot { get; init; }
    }
    public System.Collections.Generic.List<DamagePopup> DamagePopups { get; } = new();
    private readonly System.Random _popupRandom = new();
    public void ReportHit(float damage, bool headshot = false)
    {
        if (IsProxy || !float.IsFinite(damage) || damage <= 0f) return;
        // Coalesce pellets into one sound; a head pellet takes priority.
        if (_hitSoundAt < 0f) _hitSoundAt = Time.Now + .025f;
        _hitSoundHeadshot |= headshot;
        LastHitHeadshot = headshot || (LastHitHeadshot && Time.Now - LastHitAt < .08f);
        LastHitDamage = damage; LastHitAt = Time.Now;
        DamagePopups.RemoveAll(x => Time.Now - x.Started >= .7f);
        if (!GameProfile.Settings.ShowDamageNumbers) return;
        // Combine near-simultaneous pellet hits instead of stacking their labels.
        var last = DamagePopups.LastOrDefault(x => x.Headshot == headshot);
        if (last is not null && Time.Now - last.Started < .025f) { last.Damage += damage; return; }
        if (DamagePopups.Count >= 6) DamagePopups.RemoveAt(0);
        var free = Enumerable.Range(0,6).Where(slot => !DamagePopups.Any(x => x.Headshot == headshot && x.Slot == slot)).ToArray();
        DamagePopups.Add(new DamagePopup { Damage=damage, Headshot=headshot, Started=Time.Now, Slot=headshot ? free[0] : free[_popupRandom.Next(free.Length)] });
    }
    [Rpc.Broadcast]
    public void ReceiveReward( string buff )
    {
        if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
        if ( !IsProxy ) ApplyBuff( buff );
    }

	public void ApplyBuff( string buff )
	{
		if ( Vitality?.IsDead != false ) return;
		switch ( buff )
		{
			case "power": DamageMultiplier *= 1.25f; break;
			case "haste": FireRateMultiplier *= 1.2f; SpeedMultiplier *= 1.08f; break;
			case "vitality": Vitality.MaxHealth += 30f; Vitality.Health = Vitality.MaxHealth; break;
		}
	}
	public void EquipAlternate()
	{
		if ( SwitchingWeapon ) return;
        Weapon?.BeginSwitch();
		_switchLeft = .55f;
		_switchCommitted = false;
		_swapAnimation = 1f;
		PrototypeAudio.Play( "weapon_swap", WorldPosition );
	}
	public void ReceiveDamage( float amount, GameObject source )
	{
		if ( PrototypeGame.Instance?.IsHost != true ) return;
		ReceiveHostDamage( amount, source );
	}

	[Rpc.Broadcast]
	private void ReceiveHostDamage( float amount, GameObject source )
	{
		if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;
		if ( IsProxy || !Vitality.IsValid() || Vitality.IsDead || !float.IsFinite( amount ) || amount <= 0f || _invulnerable > 0f ) return;
		var reduced = BuffRules.ReducedDamage(amount, TotalDefense);
        if (Buffs?.AbsorbLethal(reduced, Vitality) == true) return;
        Vitality.ApplyDamage( reduced, source );
        if (reduced > 0f)
        {
            LastHurtAt = Time.Now;
            HurtStrength = Math.Clamp(reduced / MathF.Max(1f, Vitality.MaxHealth) * 2f, .35f, 1f);
            if (Time.Now - _hurtSoundAt >= .12f)
            {
                PrototypeAudio.Play("player_hurt", EyePosition);
                _hurtSoundAt = Time.Now;
            }
        }
		if ( source.IsValid() )
		{
			var away = (WorldPosition - source.WorldPosition).WithZ( 0f ).Normal;
			_knockback = away * 520f;
		}
	}

	private void ApplyHeroLoadout()
	{
		Func<string, int> rank = id => GameProfile.SkillRank( id );
		switch ( Hero.Id )
		{
			case "warden": Vitality.MaxHealth = 125f + rank("warden_vigor") * 8f; Vitality.Health = Vitality.MaxHealth; _baseDamageReduction = .10f + rank("warden_guard") * .04f; break;
			case "seer": FireRateMultiplier *= 1.10f + rank("seer_flux") * .04f; SpeedMultiplier *= 1f + rank("seer_stride") * .03f; break;
			case "relic": DamageMultiplier *= 1.15f + rank("relic_focus") * .05f; break;
            case "nomad":
                Vitality.MaxHealth = 100f + 10f*rank("tree_heart") + 5f*rank("tree_vitality") + 10f*rank("tree_bastion");
                Vitality.Health = Vitality.MaxHealth;
                DamageMultiplier = 1f + .05f*rank("tree_power") + .03f*rank("tree_precision");
                FireRateMultiplier = 1f + .03f*rank("tree_haste") + .02f*rank("tree_precision");
                _baseDamageReduction = .05f*rank("tree_armor") + .02f*rank("tree_vitality") + .03f*rank("tree_bastion");
                SpeedMultiplier *= 1.12f + .02f*rank("tree_stride");
                DashCooldown /= 1f + .05f*rank("tree_dash");
                PermanentReloadFactor = 1f/(1f + .05f*rank("tree_reload"));
                break;
		}
	}

	private void ActivateUltimate()
	{
		if ( _ultimateCooldown > 0f || PrototypeGame.Instance?.Phase != RunPhase.Combat ) return;
		var cooldownReduction = GameProfile.SkillRank( Hero.Id is "warden" ? "warden_aegis" : Hero.Id is "relic" ? "relic_lance" : "" ) * .07f;
		_ultimateCooldown = MathF.Max(14f,28f / (1f + .05f*GameProfile.SkillRank("tree_focus")) * (1f - cooldownReduction)*(1f-.1f*(Buffs?.Rank("focus") ?? 0)));
		var durationBonus = .4f * GameProfile.SkillRank("tree_dust");
		_ultimateTime = 5f + durationBonus;
		_ultimateActive = true;
		switch ( Hero.Id )
		{
			case "warden": _invulnerable = 4f + durationBonus; foreach ( var enemy in Scene.GetAll<EnemyBrain>().Where(e => e.WorldPosition.Distance(WorldPosition) < 420f) ) enemy.Vitality.ApplyDamage( 40f, GameObject ); break;
			case "seer": FireRateMultiplier *= 1.65f; SpeedMultiplier *= 1.35f; break;
			case "relic": DamageMultiplier *= 2.2f; break;
			case "nomad": _dashLeft = 0f; SpeedMultiplier *= 1.45f; break;
		}
	}

	private void EndUltimate()
	{
		_ultimateActive = false;
		switch ( Hero.Id )
		{
			case "seer": FireRateMultiplier /= 1.65f; SpeedMultiplier /= 1.35f; break;
			case "relic": DamageMultiplier /= 2.2f; break;
			case "nomad": SpeedMultiplier /= 1.45f; break;
		}
	}

	private void OnPlayerDied( GameObject killer )
    {
        ResetBunnyHop();
        PrototypeGame.Instance?.NotifyPlayerDeath( this );
    }

	protected override void OnDestroy()
	{
		if ( Vitality.IsValid() ) Vitality.Died -= OnPlayerDied;
		if ( _viewWeapon.IsValid() ) _viewWeapon.Destroy();
	}
}