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;
}
}