Combat/WeaponController.cs
using System;

using System.Collections.Generic;



namespace Prototype;



public sealed class WeaponController : Component

{

	public WeaponSpec Spec { get; private set; } = PrototypeCatalog.Rifle;

	public int Ammo { get; private set; }

	public bool Reloading { get; private set; }

	public float ReloadProgress => Reloading ? Math.Clamp( 1f - _reloadLeft / MathF.Max(.01f,_reloadDuration), 0f, 1f ) : 0f;

	private float _cooldown;

	private float _reloadLeft;

    private float _reloadDuration;

	private bool _hasEquippedWeapon;

	private readonly Dictionary<string, int> _magazines = new();

	private readonly Random _random = new();



	protected override void OnStart() => Equip( PrototypeCatalog.Rifle );



    public void ResetForRun()

    {

        _magazines.Clear(); _hasEquippedWeapon = false;

        _cooldown = 0f;

        Equip(PrototypeCatalog.Rifle);

    }



	public void Equip( WeaponSpec spec )

	{

		if ( _hasEquippedWeapon ) _magazines[Spec.Id] = Ammo;

		Spec = spec;

		Ammo = _magazines.TryGetValue( spec.Id, out var storedAmmo ) ? storedAmmo : spec.Magazine;

		_magazines[spec.Id] = Ammo;

		_hasEquippedWeapon = true;

		// Switching weapons cancels this weapon's reload; it never grants ammo.

		Reloading = false;

		_reloadLeft = 0f;

	}



	protected override void OnUpdate()

	{

        if (!Networking.IsActive || Networking.IsHost) CompleteHostReload();

		_cooldown = MathF.Max( 0f, _cooldown - Time.Delta );

		if ( !Reloading ) return;

		_reloadLeft -= Time.Delta;

		if ( _reloadLeft <= 0f )

		{

			Reloading = false;

			Ammo = Spec.Magazine;

			_magazines[Spec.Id] = Ammo;

		}

	}



	public void StartReload()

	{

		if ( Reloading || Ammo >= Spec.Magazine || Components.Get<FpsPlayer>()?.SwitchingWeapon == true ) return;

        if (Networking.IsActive) HostReload(Spec.Id == "scatter");

        else ReloadAuthoritative(Spec.Id == "scatter");

		Reloading = true; _reloadDuration = Spec.ReloadSeconds*(Components.Get<RunBuffs>()?.ReloadFactor ?? 1f)*(Components.Get<FpsPlayer>()?.PermanentReloadFactor ?? 1f); _reloadLeft = _reloadDuration;

		PrototypeAudio.Play( "reload", WorldPosition );

	}



	public bool TryFire( Vector3 origin, Vector3 direction, GameObject instigator )

	{

		if ( Reloading || _cooldown > 0f || Components.Get<FpsPlayer>()?.SwitchingWeapon == true ) return false;

		if ( Ammo <= 0 ) { PrototypeAudio.Play( "dry_fire", origin ); StartReload(); return false; }

		var player = instigator?.Components.Get<FpsPlayer>();

		Ammo--;

		_magazines[Spec.Id] = Ammo;

		_cooldown = 1f / (Spec.ShotsPerSecond * (player?.FireRateMultiplier ?? 1f)*(player?.Buffs?.RateFactor ?? 1f));

		PrototypeAudio.Play( Spec.Id == "scatter" ? "scatter_fire" : "rifle_fire", origin );

        if ( Networking.IsActive ) HostFire( origin, direction, Spec.Id == "scatter" );

        else FireAuthoritative( origin, direction, Spec.Id == "scatter" );

        if ( Ammo <= 0 ) StartReload();

        return true;

    }



    [Sync(SyncFlags.FromHost)] private float _hostNextShot { get; set; }

    [Sync(SyncFlags.FromHost)] private int HostRifleAmmo { get; set; } = 28;

    [Sync(SyncFlags.FromHost)] private int HostScatterAmmo { get; set; } = 6;

    [Sync(SyncFlags.FromHost)] private bool HostScatter { get; set; }

    [Sync(SyncFlags.FromHost)] private float HostSwitchUntil { get; set; }

    [Sync(SyncFlags.FromHost)] private float HostReloadUntil { get; set; }



    public void ResetHostMagazine()

    {

        if (Networking.IsActive && !Networking.IsHost) return;

        HostRifleAmmo = PrototypeCatalog.Rifle.Magazine;

        HostScatterAmmo = PrototypeCatalog.Scatter.Magazine;

        HostScatter = false;

        HostReloadUntil = HostSwitchUntil = _hostNextShot = 0f;

    }



    private bool CanRequestWeapon()

    {

        if (Networking.IsActive && Rpc.Caller is not null && Rpc.Caller.Id != GameObject.Network.OwnerId) return false;

        var player = Components.Get<FpsPlayer>();

        return player.IsValid() && player.Vitality?.IsDead == false && PrototypeGame.Instance?.IsRunPlayer(player) == true;

    }



    public void BeginSwitch()

    {

        // Offline pawns have no network owner and must not go through an RPC.

        if (Networking.IsActive) HostBeginSwitch();

        else SwitchAuthoritative();

    }



    [Rpc.Host]

    private void HostBeginSwitch() => SwitchAuthoritative();



    private void SwitchAuthoritative()

    {

        if (!CanRequestWeapon() || Time.Now < HostSwitchUntil) return;

        CompleteHostReload();

        HostReloadUntil = 0f;

        HostScatter = !HostScatter;

        HostSwitchUntil = Time.Now + .55f;

    }



    [Rpc.Host]

    private void HostReload(bool scatter) => ReloadAuthoritative(scatter);



    private void ReloadAuthoritative(bool scatter)

    {

        if (!CanRequestWeapon() || scatter != HostScatter || Time.Now < HostSwitchUntil) return;

        CompleteHostReload();

        if (HostReloadUntil > 0f) return;

        var spec = scatter ? PrototypeCatalog.Scatter : PrototypeCatalog.Rifle;

        if ((scatter ? HostScatterAmmo : HostRifleAmmo) >= spec.Magazine) return;

        var player = Components.Get<FpsPlayer>();

        var factor = player.PermanentReloadFactor;

        if (!float.IsFinite(factor)) factor = 1f;

        HostReloadUntil = Time.Now + spec.ReloadSeconds * Math.Clamp(factor, 1f / 1.15f, 1f) * (player.Buffs?.ReloadFactor ?? 1f);

    }



    private void CompleteHostReload()

    {

        if (HostReloadUntil <= 0f || Time.Now < HostReloadUntil) return;

        if (HostScatter) HostScatterAmmo = PrototypeCatalog.Scatter.Magazine;

        else HostRifleAmmo = PrototypeCatalog.Rifle.Magazine;

        HostReloadUntil = 0f;

    }

    [Rpc.Host]

    private void HostFire( Vector3 origin, Vector3 direction, bool scatter )

    {

        if ( Rpc.Caller is not null && Rpc.Caller.Id != GameObject.Network.OwnerId ) return;

        FireAuthoritative( origin, direction, scatter );

    }



    // Offline pawns have no network owner. Local shots still use exactly the

    // same hit validation and damage path, without an ownership-dependent RPC.

    private void FireAuthoritative( Vector3 origin, Vector3 direction, bool scatter )

    {

        var player = Components.Get<FpsPlayer>();

        if ( !player.IsValid() || PrototypeGame.Instance?.IsRunPlayer( player ) != true || player.Vitality?.IsDead != false || PrototypeGame.Instance?.Phase != RunPhase.Combat ) return;

        if ( !float.IsFinite( origin.x + origin.y + origin.z + direction.x + direction.y + direction.z ) || origin.Distance( player.EyePosition ) > 128f || direction.Length < .9f || direction.Length > 1.1f ) return;

        CompleteHostReload();

        if (Time.Now < _hostNextShot || Time.Now < HostSwitchUntil || HostReloadUntil > 0f || scatter != HostScatter) return;

        if ((scatter ? HostScatterAmmo : HostRifleAmmo) <= 0) return;

        if (scatter) HostScatterAmmo--; else HostRifleAmmo--;

        var shotSpec = scatter ? PrototypeCatalog.Scatter : PrototypeCatalog.Rifle;

        _hostNextShot = Time.Now + 1f / (shotSpec.ShotsPerSecond * Math.Clamp(float.IsFinite(player.FireRateMultiplier) ? player.FireRateMultiplier : 1f, 1f, 1.5f) * (player.Buffs?.RateFactor ?? 1f)) * .9f;

        var instigator = GameObject;

        var muzzle = player.VisualMuzzlePosition;

        BroadcastMuzzle( muzzle, direction.Normal, scatter );

		for ( var i = 0; i < shotSpec.Pellets; i++ )

		{

			var angle = (float)_random.NextDouble() * MathF.PI * 2f;

			var radius = MathF.Sqrt( (float)_random.NextDouble() ) * MathF.Tan( shotSpec.Spread * MathF.PI / 180f );

			var aim = Rotation.LookAt( direction.Normal, Vector3.Up );

			var shotDirection = (direction.Normal + aim.Right * (MathF.Cos( angle ) * radius) + aim.Up * (MathF.Sin( angle ) * radius)).Normal;

			var end = origin + shotDirection * shotSpec.Range;

			var trace = EnemyShotTrace.Run( Scene, origin, end );

			var hitPosition = trace.Hit ? trace.HitPosition : end;

			var target = trace.GameObject?.Components.Get<EnemyBrain>( FindMode.EverythingInSelfAndAncestors );

			var headshot = trace.Hitbox is not null && trace.Hitbox.Tags.Contains( "head" );
			if ( target?.Vitality is not null )

			{

				var damage = shotSpec.Damage * Math.Clamp(float.IsFinite(player.DamageMultiplier) ? player.DamageMultiplier : 1f, 0f, 2f) * (player?.Buffs?.DamageFactor(target.Vitality.Health / MathF.Max(1f,target.Vitality.MaxHealth)) ?? 1f);

				damage *= headshot ? 1.5f : 1f;

				target.Vitality.ApplyDamage( damage, instigator );

				ConfirmHit( damage, headshot );

			}

			BroadcastTracer( muzzle, hitPosition, shotSpec.Id == "scatter" );

		}

	}



    [Rpc.Broadcast]

    private void ConfirmHit( float damage, bool headshot )

    {

        if ( Rpc.Caller is not null && !Rpc.Caller.IsHost ) return;

        if ( !IsProxy ) Components.Get<FpsPlayer>()?.ReportHit( damage, headshot );

    }



	[Rpc.Broadcast]

	private void BroadcastMuzzle( Vector3 position, Vector3 direction, bool scatter )

	{

        if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;

		var body = Components.Get<DasherBodyVisual>();

		if ( body?.Ready == true ) position = body.MuzzlePosition;

		var color = (scatter ? PrototypeCatalog.Scatter : PrototypeCatalog.Rifle).Color;

		var flash = new GameObject( true, "Muzzle Light" );

		flash.WorldPosition = position;

		var light = flash.Components.Create<PointLight>();

		light.LightColor = color * 3f;

		light.Radius = 85f;

		light.Shadows = false;

		flash.Tags.Add( "projectile" );

		flash.WorldRotation = Rotation.LookAt( direction, Vector3.Up );

		flash.Components.Create<TransientEffect>().Lifetime = .045f;

	}



	[Rpc.Broadcast]

	private void BroadcastTracer( Vector3 start, Vector3 end, bool scatter )

	{

        if (Rpc.Caller is not null && !Rpc.Caller.IsHost) return;

		var body = Components.Get<DasherBodyVisual>();

		if ( body?.Ready == true ) start = body.MuzzlePosition;

		var delta = end - start;

		if ( delta.Length < 1f ) return;

		var tracer = new GameObject( false, "Bullet Streak" );

		tracer.NetworkMode = NetworkMode.Never;

		tracer.WorldPosition = start;

		tracer.Tags.Add( "projectile" );

		tracer.WorldRotation = Rotation.LookAt( delta.Normal, Vector3.Up );

		var line = tracer.Components.Create<TracerEffect>();

		line.SetStartPoint( new SceneAnchor { LocalPosition = start } );

		line.SetTarget( new SceneAnchor { LocalPosition = end } );

		line.DistancePerSecond = 6000f;

		line.Length = 240f;

		line.StartDistance = 0f;

		line.LineColor = scatter ? new Color( 1f, .65f, .22f ) : new Color( .35f, .9f, 1f );

		line.LineWidth = 1.4f;

		line.CastShadows = false;

		line.EnableLight = false;

		tracer.Components.Create<TransientEffect>().Lifetime = (delta.Length + 240f) / 6000f + .1f;

		tracer.Enabled = true;

	}

}