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.Gravity; using Content.Shared.Hands; using Content.Shared.Hands.Components; 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.Melee; using Content.Shared.Weapons.Melee.Events; using Content.Shared.Weapons.Ranged.Components; using Content.Shared.Weapons.Ranged.Events; 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 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] private readonly SharedGravitySystem _gravity = 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!; 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"; protected 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(OnGunUnpaused); #if DEBUG SubscribeLocalEvent(OnMapInit); } private void OnMapInit(EntityUid uid, GunComponent component, MapInitEvent args) { if (component.NextFire > Timing.CurTime) Log.Warning($"Initializing a map that contains an entity that is on cooldown. Entity: {ToPrettyString(uid)}"); DebugTools.Assert((component.AvailableModes & component.SelectedMode) != 0x0); #endif } 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; Dirty(component); } } private void OnGunUnpaused(EntityUid uid, GunComponent component, ref EntityUnpausedEvent args) { component.NextFire += args.PausedTime; } 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); Log.Debug($"Set shoot coordinates to {gun.ShootCoordinates}"); 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 (EntityManager.TryGetComponent(entity, out HandsComponent? hands) && hands.ActiveHandEntity 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; Log.Debug($"Stopped shooting {ToPrettyString(uid)}"); gun.ShotCounter = 0; gun.ShootCoordinates = null; Dirty(uid, gun); } /// /// Attempts to shoot at the target coordinates. Resets the shot counter after every shot. /// public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, EntityCoordinates toCoordinates) { gun.ShootCoordinates = toCoordinates; AttemptShoot(user, gunUid, gun); gun.ShotCounter = 0; } /// /// Shoots by assuming the gun is the user at default coordinates. /// public void AttemptShoot(EntityUid gunUid, GunComponent gun) { var coordinates = new EntityCoordinates(gunUid, new Vector2(0, -1)); gun.ShootCoordinates = coordinates; AttemptShoot(gunUid, gunUid, gun); gun.ShotCounter = 0; } private void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun) { if (gun.FireRate <= 0f || !_actionBlockerSystem.CanUseHeldEntity(user)) return; var toCoordinates = gun.ShootCoordinates; if (toCoordinates == null) return; var curTime = Timing.CurTime; // check if anything wants to prevent shooting var prevention = new ShotAttemptedEvent { User = user, Used = gunUid }; RaiseLocalEvent(gunUid, ref prevention); if (prevention.Cancelled) return; RaiseLocalEvent(user, ref prevention); if (prevention.Cancelled) return; // 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; var fireRate = TimeSpan.FromSeconds(1f / gun.FireRate); // 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. Dirty(gunUid, gun); // 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. switch (gun.SelectedMode) { case SelectiveFire.SemiAuto: shots = Math.Min(shots, 1 - gun.ShotCounter); break; case SelectiveFire.Burst: shots = Math.Min(shots, 3 - gun.ShotCounter); break; case SelectiveFire.FullAuto: break; default: throw new ArgumentOutOfRangeException($"No implemented shooting behavior for {gun.SelectedMode}!"); } var attemptEv = new AttemptShootEvent(user, null); RaiseLocalEvent(gunUid, ref attemptEv); if (attemptEv.Cancelled) { if (attemptEv.Message != null) { PopupSystem.PopupClient(attemptEv.Message, gunUid, user); } gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds)); return; } 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; if (ev.Ammo.Count <= 0) { // triggers effects on the gun if it's empty var emptyGunShotEvent = new OnEmptyGunShotEvent(); RaiseLocalEvent(gunUid, ref emptyGunShotEvent); // Play empty gun sounds if relevant // If they're firing an existing clip then don't play anything. if (shots > 0) { if (ev.Reason != null && Timing.IsFirstTimePredicted) { PopupSystem.PopupCursor(ev.Reason); } // 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; } return; } // 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)) { if (_gravity.IsWeightless(user, userPhysics)) CauseImpulse(fromCoordinates, toCoordinates.Value, user, userPhysics); } Dirty(gunUid, gun); } 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(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); Projectiles.SetShooter(uid, projectile, user ?? gunUid); projectile.Weapon = gunUid; TransformSystem.SetWorldRotation(uid, direction.ToWorldAngle()); } 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) Dirty(uid, cartridge); cartridge.Spent = spent; Appearance.SetData(uid, AmmoVisuals.Spent, spent); } /// /// 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(xform, Random.NextAngle()); 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; return EnsureComp(uid); } protected void RemoveShootable(EntityUid uid) { RemCompDeferred(uid); RemCompDeferred(uid); } protected void MuzzleFlash(EntityUid gun, AmmoComponent component, EntityUid? user = null) { var sprite = component.MuzzleFlash; if (sprite == null) return; var ev = new MuzzleFlashEvent(GetNetEntity(gun), sprite, user == gun); CreateEffect(gun, ev, user); } public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, EntityUid user, PhysicsComponent userPhysics) { var fromMap = fromCoordinates.ToMapPos(EntityManager, TransformSystem); var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem); var shotDirection = (toMap - fromMap).Normalized(); const float impulseStrength = 25.0f; var impulseVector = shotDirection * impulseStrength; Physics.ApplyLinearImpulse(user, -impulseVector, body: userPhysics); } protected abstract void CreateEffect(EntityUid uid, MuzzleFlashEvent message, EntityUid? user = null); /// /// 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(); } } /// /// 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); 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, }