using System.Numerics; using Content.Server.Cargo.Systems; using Content.Server.Weapons.Ranged.Components; using Content.Shared.Cargo; using Content.Shared.Damage; using Content.Shared.Damage.Systems; using Content.Shared.Projectiles; using Content.Shared.Weapons.Melee; using Content.Shared.Weapons.Ranged; using Content.Shared.Weapons.Ranged.Components; using Content.Shared.Weapons.Ranged.Events; using Content.Shared.Weapons.Ranged.Systems; using Content.Shared.Weapons.Hitscan.Components; using Content.Shared.Weapons.Hitscan.Events; using Robust.Shared.Audio; using Robust.Shared.Map; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Utility; namespace Content.Server.Weapons.Ranged.Systems; public sealed partial class GunSystem : SharedGunSystem { [Dependency] private readonly DamageExamineSystem _damageExamine = default!; [Dependency] private readonly PricingSystem _pricing = default!; [Dependency] private readonly SharedMapSystem _map = default!; private const float DamagePitchVariation = 0.05f; public override void Initialize() { base.Initialize(); SubscribeLocalEvent(OnBallisticPrice); } private void OnBallisticPrice(EntityUid uid, BallisticAmmoProviderComponent component, ref PriceCalculationEvent args) { if (string.IsNullOrEmpty(component.Proto) || component.UnspawnedCount == 0) return; if (!ProtoManager.TryIndex(component.Proto, out var proto)) { Log.Error($"Unable to find fill prototype for price on {component.Proto} on {ToPrettyString(uid)}"); return; } // Probably good enough for most. var price = _pricing.GetEstimatedPrice(proto); args.Price += price * component.UnspawnedCount; } public override 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) { userImpulse = true; if (user != null) { var selfEvent = new SelfBeforeGunShotEvent(user.Value, (gunUid, gun), ammo); RaiseLocalEvent(user.Value, selfEvent); if (selfEvent.Cancelled) { userImpulse = false; return; } } var fromMap = TransformSystem.ToMapCoordinates(fromCoordinates); var toMap = TransformSystem.ToMapCoordinates(toCoordinates).Position; var mapDirection = toMap - fromMap.Position; var mapAngle = mapDirection.ToAngle(); var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle()); // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map. var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out _) ? TransformSystem.WithEntityId(fromCoordinates, gridUid) : new EntityCoordinates(_map.GetMapOrInvalid(fromMap.MapId), fromMap.Position); // Update shot based on the recoil toMap = fromMap.Position + angle.ToVec() * mapDirection.Length(); mapDirection = toMap - fromMap.Position; var gunVelocity = Physics.GetMapLinearVelocity(fromEnt); // I must be high because this was getting tripped even when true. // DebugTools.Assert(direction != Vector2.Zero); var shotProjectiles = new List(ammo.Count); foreach (var (ent, shootable) in ammo) { // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling if (throwItems && ent != null) { ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user); continue; } // TODO: Clean this up in a gun refactor at some point - too much copy pasting switch (shootable) { // Cartridge shoots something else case CartridgeAmmoComponent cartridge: if (!cartridge.Spent) { var uid = Spawn(cartridge.Prototype, fromEnt); CreateAndFireProjectiles(uid, cartridge); RaiseLocalEvent(ent!.Value, new AmmoShotEvent() { FiredProjectiles = shotProjectiles, }); SetCartridgeSpent(ent.Value, cartridge, true); if (cartridge.DeleteOnSpawn) Del(ent.Value); } else { userImpulse = false; Audio.PlayPredicted(gun.SoundEmpty, gunUid, user); } // Something like ballistic might want to leave it in the container still if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value)) EjectCartridge(ent.Value, angle); Dirty(ent!.Value, cartridge); break; // Ammo shoots itself case AmmoComponent newAmmo: if (ent == null) break; CreateAndFireProjectiles(ent.Value, newAmmo); break; case HitscanAmmoComponent: if (ent == null) break; var hitscanEv = new HitscanTraceEvent { FromCoordinates = fromCoordinates, ShotDirection = mapDirection.Normalized(), Gun = gunUid, Shooter = user, Target = gun.Target, }; RaiseLocalEvent(ent.Value, ref hitscanEv); Del(ent); Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); break; default: throw new ArgumentOutOfRangeException(); } } RaiseLocalEvent(gunUid, new AmmoShotEvent() { FiredProjectiles = shotProjectiles, }); void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp) { if (TryComp(ammoEnt, out var ammoSpreadComp)) { var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread); RaiseLocalEvent(gunUid, ref spreadEvent); var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2, mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count); ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user); shotProjectiles.Add(ammoEnt); for (var i = 1; i < ammoSpreadComp.Count; i++) { var newuid = Spawn(ammoSpreadComp.Proto, fromEnt); ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user); shotProjectiles.Add(newuid); } } else { ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user); shotProjectiles.Add(ammoEnt); } MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user); Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); } } private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user) { if (gun.Target is { } target && !TerminatingOrDeleted(target)) { var targeted = EnsureComp(uid); targeted.Target = target; Dirty(uid, targeted); } // Do a throw if (!HasComp(uid)) { RemoveShootable(uid); // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack. ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user); return; } ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified); } /// /// Gets a linear spread of angles between start and end. /// /// Start angle in degrees /// End angle in degrees /// How many shots there are private Angle[] LinearSpread(Angle start, Angle end, int intervals) { var angles = new Angle[intervals]; DebugTools.Assert(intervals > 1); for (var i = 0; i <= intervals - 1; i++) { angles[i] = new Angle(start + (end - start) * i / (intervals - 1)); } return angles; } private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction) { var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds; var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta); component.CurrentAngle = new Angle(newTheta); component.LastFire = component.NextFire; // Convert it so angle can go either side. var random = Random.NextFloat(-0.5f, 0.5f); var spread = component.CurrentAngle.Theta * random; var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random); DebugTools.Assert(spread <= component.MaxAngleModified.Theta); return angle; } protected override void Popup(string message, EntityUid? uid, EntityUid? user) { } protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null) { var filter = Filter.Pvs(gunUid, entityManager: EntityManager); if (TryComp(user, out var actor)) filter.RemovePlayer(actor.PlayerSession); RaiseNetworkEvent(message, filter); } public override void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound) { DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted"); // Like projectiles and melee, // 1. Entity specific sound // 2. Ammo's sound // 3. Nothing var playedSound = false; if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp(otherEntity, out var rangedSound)) { var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager); if (type != null && rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true) { Audio.PlayPvs(damageSoundType, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation)); playedSound = true; } else if (type != null && rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true) { Audio.PlayPvs(damageSoundGroup, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation)); playedSound = true; } } if (!playedSound && weaponSound != null) { Audio.PlayPvs(weaponSound, otherEntity); } } }