Files
tbd-station-14/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs
beck-thompson 492a1aa9c3 Hitscans are now entities (#38035)
* Hitscans are now entities

* Cleanup

* Cleanup

* Silly mistakes but stop sign testing helps :)

* Address most of the review

* Reviews

* perry :(

* Final reviews

* Add comments

* Split event up

* better comment

* cleanup
2025-10-18 05:42:08 +00:00

677 lines
24 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Numerics;
using Content.Shared.ActionBlocker;
using Content.Shared.Actions;
using Content.Shared.Administration.Logs;
using Content.Shared.Audio;
using Content.Shared.CombatMode;
using Content.Shared.Containers.ItemSlots;
using Content.Shared.Damage;
using Content.Shared.Examine;
using Content.Shared.Hands;
using Content.Shared.Hands.EntitySystems;
using Content.Shared.Popups;
using Content.Shared.Projectiles;
using Content.Shared.Tag;
using Content.Shared.Throwing;
using Content.Shared.Timing;
using Content.Shared.Verbs;
using Content.Shared.Weapons.Hitscan.Components;
using Content.Shared.Weapons.Melee;
using Content.Shared.Weapons.Melee.Events;
using Content.Shared.Weapons.Ranged.Components;
using Content.Shared.Weapons.Ranged.Events;
using Content.Shared.Whitelist;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
using Robust.Shared.Map;
using Robust.Shared.Network;
using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Shared.Weapons.Ranged.Systems;
public abstract partial class SharedGunSystem : EntitySystem
{
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] protected readonly IGameTiming Timing = default!;
[Dependency] protected readonly IMapManager MapManager = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] protected readonly IPrototypeManager ProtoManager = default!;
[Dependency] protected readonly IRobustRandom Random = default!;
[Dependency] protected readonly ISharedAdminLogManager Logs = default!;
[Dependency] protected readonly DamageableSystem Damageable = default!;
[Dependency] protected readonly ExamineSystemShared Examine = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly ItemSlotsSystem _slots = default!;
[Dependency] private readonly RechargeBasicEntityAmmoSystem _recharge = default!;
[Dependency] protected readonly SharedActionsSystem Actions = default!;
[Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
[Dependency] protected readonly SharedAudioSystem Audio = default!;
[Dependency] private readonly SharedCombatModeSystem _combatMode = default!;
[Dependency] protected readonly SharedContainerSystem Containers = default!;
[Dependency] protected readonly SharedPointLightSystem Lights = default!;
[Dependency] protected readonly SharedPopupSystem PopupSystem = default!;
[Dependency] protected readonly SharedPhysicsSystem Physics = default!;
[Dependency] protected readonly SharedProjectileSystem Projectiles = default!;
[Dependency] protected readonly SharedTransformSystem TransformSystem = default!;
[Dependency] protected readonly TagSystem TagSystem = default!;
[Dependency] protected readonly ThrowingSystem ThrowingSystem = default!;
[Dependency] private readonly UseDelaySystem _useDelay = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
private static readonly ProtoId<TagPrototype> TrashTag = "Trash";
private const float InteractNextFire = 0.3f;
private const double SafetyNextFire = 0.5;
private const float EjectOffset = 0.4f;
protected const string AmmoExamineColor = "yellow";
protected const string FireRateExamineColor = "yellow";
public const string ModeExamineColor = "cyan";
public override void Initialize()
{
SubscribeAllEvent<RequestShootEvent>(OnShootRequest);
SubscribeAllEvent<RequestStopShootEvent>(OnStopShootRequest);
SubscribeLocalEvent<GunComponent, MeleeHitEvent>(OnGunMelee);
// Ammo providers
InitializeBallistic();
InitializeBattery();
InitializeCartridge();
InitializeChamberMagazine();
InitializeMagazine();
InitializeRevolver();
InitializeBasicEntity();
InitializeClothing();
InitializeContainer();
InitializeSolution();
// Interactions
SubscribeLocalEvent<GunComponent, GetVerbsEvent<AlternativeVerb>>(OnAltVerb);
SubscribeLocalEvent<GunComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<GunComponent, CycleModeEvent>(OnCycleMode);
SubscribeLocalEvent<GunComponent, HandSelectedEvent>(OnGunSelected);
SubscribeLocalEvent<GunComponent, MapInitEvent>(OnMapInit);
}
private void OnMapInit(Entity<GunComponent> gun, ref MapInitEvent args)
{
#if DEBUG
if (gun.Comp.NextFire > Timing.CurTime)
Log.Warning($"Initializing a map that contains an entity that is on cooldown. Entity: {ToPrettyString(gun)}");
DebugTools.Assert((gun.Comp.AvailableModes & gun.Comp.SelectedMode) != 0x0);
#endif
RefreshModifiers((gun, gun));
}
private void OnGunMelee(EntityUid uid, GunComponent component, MeleeHitEvent args)
{
if (!TryComp<MeleeWeaponComponent>(uid, out var melee))
return;
if (melee.NextAttack > component.NextFire)
{
component.NextFire = melee.NextAttack;
DirtyField(uid, component, nameof(GunComponent.NextFire));
}
}
private void OnShootRequest(RequestShootEvent msg, EntitySessionEventArgs args)
{
var user = args.SenderSession.AttachedEntity;
if (user == null ||
!_combatMode.IsInCombatMode(user) ||
!TryGetGun(user.Value, out var ent, out var gun))
{
return;
}
if (ent != GetEntity(msg.Gun))
return;
gun.ShootCoordinates = GetCoordinates(msg.Coordinates);
gun.Target = GetEntity(msg.Target);
AttemptShoot(user.Value, ent, gun);
}
private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args)
{
var gunUid = GetEntity(ev.Gun);
if (args.SenderSession.AttachedEntity == null ||
!TryComp<GunComponent>(gunUid, out var gun) ||
!TryGetGun(args.SenderSession.AttachedEntity.Value, out _, out var userGun))
{
return;
}
if (userGun != gun)
return;
StopShooting(gunUid, gun);
}
public bool CanShoot(GunComponent component)
{
if (component.NextFire > Timing.CurTime)
return false;
return true;
}
public bool TryGetGun(EntityUid entity, out EntityUid gunEntity, [NotNullWhen(true)] out GunComponent? gunComp)
{
gunEntity = default;
gunComp = null;
if (_hands.GetActiveItem(entity) is { } held &&
TryComp(held, out GunComponent? gun))
{
gunEntity = held;
gunComp = gun;
return true;
}
// Last resort is check if the entity itself is a gun.
if (TryComp(entity, out gun))
{
gunEntity = entity;
gunComp = gun;
return true;
}
return false;
}
private void StopShooting(EntityUid uid, GunComponent gun)
{
if (gun.ShotCounter == 0)
return;
gun.ShotCounter = 0;
gun.ShootCoordinates = null;
gun.Target = null;
DirtyField(uid, gun, nameof(GunComponent.ShotCounter));
}
/// <summary>
/// Attempts to shoot at the target coordinates. Resets the shot counter after every shot.
/// </summary>
public bool AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, EntityCoordinates toCoordinates, EntityUid? target = null)
{
gun.ShootCoordinates = toCoordinates;
gun.Target = target;
var result = AttemptShoot(user, gunUid, gun);
gun.ShotCounter = 0;
DirtyField(gunUid, gun, nameof(GunComponent.ShotCounter));
return result;
}
/// <summary>
/// Shoots by assuming the gun is the user at default coordinates.
/// </summary>
public bool AttemptShoot(EntityUid gunUid, GunComponent gun)
{
var coordinates = new EntityCoordinates(gunUid, gun.DefaultDirection);
gun.ShootCoordinates = coordinates;
var result = AttemptShoot(gunUid, gunUid, gun);
gun.ShotCounter = 0;
return result;
}
private bool AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
{
if (gun.FireRateModified <= 0f ||
!_actionBlockerSystem.CanAttack(user))
{
return false;
}
var toCoordinates = gun.ShootCoordinates;
if (toCoordinates == null)
return false;
var curTime = Timing.CurTime;
// check if anything wants to prevent shooting
var prevention = new ShotAttemptedEvent
{
User = user,
Used = (gunUid, gun)
};
RaiseLocalEvent(gunUid, ref prevention);
if (prevention.Cancelled)
return false;
RaiseLocalEvent(user, ref prevention);
if (prevention.Cancelled)
return false;
// Need to do this to play the clicking sound for empty automatic weapons
// but not play anything for burst fire.
if (gun.NextFire > curTime)
return false;
var fireRate = TimeSpan.FromSeconds(1f / gun.FireRateModified);
if (gun.SelectedMode == SelectiveFire.Burst || gun.BurstActivated)
fireRate = TimeSpan.FromSeconds(1f / gun.BurstFireRate);
// First shot
// Previously we checked shotcounter but in some cases all the bullets got dumped at once
// curTime - fireRate is insufficient because if you time it just right you can get a 3rd shot out slightly quicker.
if (gun.NextFire < curTime - fireRate || gun.ShotCounter == 0 && gun.NextFire < curTime)
gun.NextFire = curTime;
var shots = 0;
var lastFire = gun.NextFire;
while (gun.NextFire <= curTime)
{
gun.NextFire += fireRate;
shots++;
}
// NextFire has been touched regardless so need to dirty the gun.
DirtyField(gunUid, gun, nameof(GunComponent.NextFire));
// Get how many shots we're actually allowed to make, due to clip size or otherwise.
// Don't do this in the loop so we still reset NextFire.
if (!gun.BurstActivated)
{
switch (gun.SelectedMode)
{
case SelectiveFire.SemiAuto:
shots = Math.Min(shots, 1 - gun.ShotCounter);
break;
case SelectiveFire.Burst:
shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
break;
case SelectiveFire.FullAuto:
break;
default:
throw new ArgumentOutOfRangeException($"No implemented shooting behavior for {gun.SelectedMode}!");
}
} else
{
shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
}
var attemptEv = new AttemptShootEvent(user, null);
RaiseLocalEvent(gunUid, ref attemptEv);
if (attemptEv.Cancelled)
{
if (attemptEv.Message != null)
{
PopupSystem.PopupClient(attemptEv.Message, gunUid, user);
}
gun.BurstActivated = false;
gun.BurstShotsCount = 0;
gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
return false;
}
var fromCoordinates = Transform(user).Coordinates;
// Remove ammo
var ev = new TakeAmmoEvent(shots, new List<(EntityUid? Entity, IShootable Shootable)>(), fromCoordinates, user);
// Listen it just makes the other code around it easier if shots == 0 to do this.
if (shots > 0)
RaiseLocalEvent(gunUid, ev);
DebugTools.Assert(ev.Ammo.Count <= shots);
DebugTools.Assert(shots >= 0);
UpdateAmmoCount(gunUid);
// Even if we don't actually shoot update the ShotCounter. This is to avoid spamming empty sounds
// where the gun may be SemiAuto or Burst.
gun.ShotCounter += shots;
DirtyField(gunUid, gun, nameof(GunComponent.ShotCounter));
if (ev.Ammo.Count <= 0)
{
// triggers effects on the gun if it's empty
var emptyGunShotEvent = new OnEmptyGunShotEvent(user);
RaiseLocalEvent(gunUid, ref emptyGunShotEvent);
gun.BurstActivated = false;
gun.BurstShotsCount = 0;
gun.NextFire += TimeSpan.FromSeconds(gun.BurstCooldown);
// Play empty gun sounds if relevant
// If they're firing an existing clip then don't play anything.
if (shots > 0)
{
PopupSystem.PopupCursor(ev.Reason ?? Loc.GetString("gun-magazine-fired-empty"));
// Don't spam safety sounds at gun fire rate, play it at a reduced rate.
// May cause prediction issues? Needs more tweaking
gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
return false;
}
return false;
}
// Handle burstfire
if (gun.SelectedMode == SelectiveFire.Burst)
{
gun.BurstActivated = true;
}
if (gun.BurstActivated)
{
gun.BurstShotsCount += shots;
if (gun.BurstShotsCount >= gun.ShotsPerBurstModified)
{
gun.NextFire += TimeSpan.FromSeconds(gun.BurstCooldown);
gun.BurstActivated = false;
gun.BurstShotsCount = 0;
}
}
// Shoot confirmed - sounds also played here in case it's invalid (e.g. cartridge already spent).
Shoot(gunUid, gun, ev.Ammo, fromCoordinates, toCoordinates.Value, out var userImpulse, user, throwItems: attemptEv.ThrowItems);
var shotEv = new GunShotEvent(user, ev.Ammo);
RaiseLocalEvent(gunUid, ref shotEv);
if (!userImpulse || !TryComp<PhysicsComponent>(user, out var userPhysics))
return true;
var shooterEv = new ShooterImpulseEvent();
RaiseLocalEvent(user, ref shooterEv);
if (shooterEv.Push)
CauseImpulse(fromCoordinates, toCoordinates.Value, user, userPhysics);
return true;
}
public void Shoot(
EntityUid gunUid,
GunComponent gun,
EntityUid ammo,
EntityCoordinates fromCoordinates,
EntityCoordinates toCoordinates,
out bool userImpulse,
EntityUid? user = null,
bool throwItems = false)
{
var shootable = EnsureShootable(ammo);
Shoot(gunUid, gun, new List<(EntityUid? Entity, IShootable Shootable)>(1) { (ammo, shootable) }, fromCoordinates, toCoordinates, out userImpulse, user, throwItems);
}
public abstract void Shoot(
EntityUid gunUid,
GunComponent gun,
List<(EntityUid? Entity, IShootable Shootable)> ammo,
EntityCoordinates fromCoordinates,
EntityCoordinates toCoordinates,
out bool userImpulse,
EntityUid? user = null,
bool throwItems = false);
public void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid? gunUid, EntityUid? user = null, float speed = 20f)
{
var physics = EnsureComp<PhysicsComponent>(uid);
Physics.SetBodyStatus(uid, physics, BodyStatus.InAir);
var targetMapVelocity = gunVelocity + direction.Normalized() * speed;
var currentMapVelocity = Physics.GetMapLinearVelocity(uid, physics);
var finalLinear = physics.LinearVelocity + targetMapVelocity - currentMapVelocity;
Physics.SetLinearVelocity(uid, finalLinear, body: physics);
var projectile = EnsureComp<ProjectileComponent>(uid);
projectile.Weapon = gunUid;
var shooter = user ?? gunUid;
if (shooter != null)
Projectiles.SetShooter(uid, projectile, shooter.Value);
TransformSystem.SetWorldRotation(uid, direction.ToWorldAngle() + projectile.Angle);
}
protected abstract void Popup(string message, EntityUid? uid, EntityUid? user);
/// <summary>
/// Call this whenever the ammo count for a gun changes.
/// </summary>
protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) {}
protected void SetCartridgeSpent(EntityUid uid, CartridgeAmmoComponent cartridge, bool spent)
{
if (cartridge.Spent != spent)
DirtyField(uid, cartridge, nameof(CartridgeAmmoComponent.Spent));
cartridge.Spent = spent;
Appearance.SetData(uid, AmmoVisuals.Spent, spent);
if (!cartridge.MarkSpentAsTrash)
return;
if (spent)
TagSystem.AddTag(uid, TrashTag);
else
TagSystem.RemoveTag(uid, TrashTag);
}
/// <summary>
/// Drops a single cartridge / shell
/// </summary>
protected void EjectCartridge(
EntityUid entity,
Angle? angle = null,
bool playSound = true)
{
// TODO: Sound limit version.
var offsetPos = Random.NextVector2(EjectOffset);
var xform = Transform(entity);
var coordinates = xform.Coordinates;
coordinates = coordinates.Offset(offsetPos);
TransformSystem.SetLocalRotation(entity, Random.NextAngle(), xform);
TransformSystem.SetCoordinates(entity, xform, coordinates);
// decides direction the casing ejects and only when not cycling
if (angle != null)
{
Angle ejectAngle = angle.Value;
ejectAngle += 3.7f; // 212 degrees; casings should eject slightly to the right and behind of a gun
ThrowingSystem.TryThrow(entity, ejectAngle.ToVec().Normalized() / 100, 5f);
}
if (playSound && TryComp<CartridgeAmmoComponent>(entity, out var cartridge))
{
Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f));
}
}
protected IShootable EnsureShootable(EntityUid uid)
{
if (TryComp<CartridgeAmmoComponent>(uid, out var cartridge))
return cartridge;
if (TryComp<HitscanAmmoComponent>(uid, out var hitscanAmmo))
return hitscanAmmo;
return EnsureComp<AmmoComponent>(uid);
}
protected void RemoveShootable(EntityUid uid)
{
RemCompDeferred<CartridgeAmmoComponent>(uid);
RemCompDeferred<AmmoComponent>(uid);
}
protected void MuzzleFlash(EntityUid gun, AmmoComponent component, Angle worldAngle, EntityUid? user = null)
{
var attemptEv = new GunMuzzleFlashAttemptEvent();
RaiseLocalEvent(gun, ref attemptEv);
if (attemptEv.Cancelled)
return;
var sprite = component.MuzzleFlash;
if (sprite == null)
return;
var ev = new MuzzleFlashEvent(GetNetEntity(gun), sprite, worldAngle);
CreateEffect(gun, ev, user);
}
public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, EntityUid user, PhysicsComponent userPhysics)
{
var fromMap = TransformSystem.ToMapCoordinates(fromCoordinates).Position;
var toMap = TransformSystem.ToMapCoordinates(toCoordinates).Position;
var shotDirection = (toMap - fromMap).Normalized();
const float impulseStrength = 25.0f;
var impulseVector = shotDirection * impulseStrength;
Physics.ApplyLinearImpulse(user, -impulseVector, body: userPhysics);
}
public void RefreshModifiers(Entity<GunComponent?> gun)
{
if (!Resolve(gun, ref gun.Comp))
return;
var comp = gun.Comp;
var ev = new GunRefreshModifiersEvent(
(gun, comp),
comp.SoundGunshot,
comp.CameraRecoilScalar,
comp.AngleIncrease,
comp.AngleDecay,
comp.MaxAngle,
comp.MinAngle,
comp.ShotsPerBurst,
comp.FireRate,
comp.ProjectileSpeed
);
RaiseLocalEvent(gun, ref ev);
if (comp.SoundGunshotModified != ev.SoundGunshot)
{
comp.SoundGunshotModified = ev.SoundGunshot;
DirtyField(gun, nameof(GunComponent.SoundGunshotModified));
}
if (!MathHelper.CloseTo(comp.CameraRecoilScalarModified, ev.CameraRecoilScalar))
{
comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
DirtyField(gun, nameof(GunComponent.CameraRecoilScalarModified));
}
if (!comp.AngleIncreaseModified.EqualsApprox(ev.AngleIncrease))
{
comp.AngleIncreaseModified = ev.AngleIncrease;
DirtyField(gun, nameof(GunComponent.AngleIncreaseModified));
}
if (!comp.AngleDecayModified.EqualsApprox(ev.AngleDecay))
{
comp.AngleDecayModified = ev.AngleDecay;
DirtyField(gun, nameof(GunComponent.AngleDecayModified));
}
if (!comp.MaxAngleModified.EqualsApprox(ev.MaxAngle))
{
comp.MaxAngleModified = ev.MaxAngle;
DirtyField(gun, nameof(GunComponent.MaxAngleModified));
}
if (!comp.MinAngleModified.EqualsApprox(ev.MinAngle))
{
comp.MinAngleModified = ev.MinAngle;
DirtyField(gun, nameof(GunComponent.MinAngleModified));
}
if (comp.ShotsPerBurstModified != ev.ShotsPerBurst)
{
comp.ShotsPerBurstModified = ev.ShotsPerBurst;
DirtyField(gun, nameof(GunComponent.ShotsPerBurstModified));
}
if (!MathHelper.CloseTo(comp.FireRateModified, ev.FireRate))
{
comp.FireRateModified = ev.FireRate;
DirtyField(gun, nameof(GunComponent.FireRateModified));
}
if (!MathHelper.CloseTo(comp.ProjectileSpeedModified, ev.ProjectileSpeed))
{
comp.ProjectileSpeedModified = ev.ProjectileSpeed;
DirtyField(gun, nameof(GunComponent.ProjectileSpeedModified));
}
}
protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null);
public abstract void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound);
/// <summary>
/// Used for animated effects on the client.
/// </summary>
[Serializable, NetSerializable]
public sealed class HitscanEvent : EntityEventArgs
{
public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new();
}
}
/// <summary>
/// Raised directed on the gun before firing to see if the shot should go through.
/// </summary>
/// <remarks>
/// Handling this in server exclusively will lead to mispredicts.
/// </remarks>
/// <param name="User">The user that attempted to fire this gun.</param>
/// <param name="Cancelled">Set this to true if the shot should be cancelled.</param>
/// <param name="ThrowItems">Set this to true if the ammo shouldn't actually be fired, just thrown.</param>
[ByRefEvent]
public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false);
/// <summary>
/// Raised directed on the gun after firing.
/// </summary>
/// <param name="User">The user that fired this gun.</param>
[ByRefEvent]
public record struct GunShotEvent(EntityUid User, List<(EntityUid? Uid, IShootable Shootable)> Ammo);
/// <summary>
/// Raised on an entity after firing a gun to see if any components or systems would allow this entity to be pushed
/// by the gun they're firing. If true, GunSystem will create an impulse on our entity.
/// </summary>
[ByRefEvent]
public record struct ShooterImpulseEvent()
{
public bool Push;
};
public enum EffectLayers : byte
{
Unshaded,
}
[Serializable, NetSerializable]
public enum AmmoVisuals : byte
{
Spent,
AmmoCount,
AmmoMax,
HasAmmo, // used for generic visualizers. c# stuff can just check ammocount != 0
MagLoaded,
BoltClosed,
}