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.Damage.Systems; 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 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(OnShootRequest); SubscribeAllEvent(OnStopShootRequest); SubscribeLocalEvent(OnGunMelee); // Ammo providers InitializeBallistic(); InitializeBattery(); InitializeCartridge(); InitializeChamberMagazine(); InitializeMagazine(); InitializeRevolver(); InitializeBasicEntity(); InitializeClothing(); InitializeContainer(); InitializeSolution(); // Interactions SubscribeLocalEvent>(OnAltVerb); SubscribeLocalEvent(OnExamine); SubscribeLocalEvent(OnCycleMode); SubscribeLocalEvent(OnGunSelected); SubscribeLocalEvent(OnMapInit); } private void OnMapInit(Entity 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(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(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)); } /// /// Attempts to shoot at the target coordinates. Resets the shot counter after every shot. /// 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; } /// /// Shoots by assuming the gun is the user at default coordinates. /// 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(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(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(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); /// /// Call this whenever the ammo count for a gun changes. /// 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); } /// /// Drops a single cartridge / shell /// 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(entity, out var cartridge)) { Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f)); } } protected IShootable EnsureShootable(EntityUid uid) { if (TryComp(uid, out var cartridge)) return cartridge; if (TryComp(uid, out var hitscanAmmo)) return hitscanAmmo; return EnsureComp(uid); } protected void RemoveShootable(EntityUid uid) { RemCompDeferred(uid); RemCompDeferred(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 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); /// /// Used for animated effects on the client. /// [Serializable, NetSerializable] public sealed class HitscanEvent : EntityEventArgs { public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new(); } /// /// Get the ammo count for a given EntityUid. Can be a firearm or magazine. /// public int GetAmmoCount(EntityUid uid) { var ammoEv = new GetAmmoCountEvent(); RaiseLocalEvent(uid, ref ammoEv); return ammoEv.Count; } /// /// Get the ammo capacity for a given EntityUid. Can be a firearm or magazine. /// public int GetAmmoCapacity(EntityUid uid) { var ammoEv = new GetAmmoCountEvent(); RaiseLocalEvent(uid, ref ammoEv); return ammoEv.Capacity; } } /// /// Raised directed on the gun before firing to see if the shot should go through. /// /// /// Handling this in server exclusively will lead to mispredicts. /// /// The user that attempted to fire this gun. /// Set this to true if the shot should be cancelled. /// Set this to true if the ammo shouldn't actually be fired, just thrown. [ByRefEvent] public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false); /// /// Raised directed on the gun after firing. /// /// The user that fired this gun. [ByRefEvent] public record struct GunShotEvent(EntityUid User, List<(EntityUid? Uid, IShootable Shootable)> Ammo); /// /// 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. /// [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, }