using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Content.Server.Administration.Logs; using Content.Server.Camera; using Content.Server.Projectiles.Components; using Content.Server.Weapon.Ranged.Ammunition.Components; using Content.Shared.Damage; using Content.Shared.Database; using Content.Shared.Examine; using Content.Shared.Interaction; using Content.Shared.Sound; using Content.Shared.Weapons.Ranged.Components; using Robust.Shared.Audio; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Localization; using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Maths; using Robust.Shared.Physics; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Random; using Robust.Shared.Serialization; using Robust.Shared.Serialization.Manager.Attributes; using Robust.Shared.Timing; using Robust.Shared.Utility; using Robust.Shared.Utility.Markup; namespace Content.Server.Weapon.Ranged.Barrels.Components { /// /// All of the ranged weapon components inherit from this to share mechanics like shooting etc. /// Only difference between them is how they retrieve a projectile to shoot (battery, magazine, etc.) /// #pragma warning disable 618 public abstract class ServerRangedBarrelComponent : SharedRangedBarrelComponent, IExamine, ISerializationHooks #pragma warning restore 618 { // There's still some of py01 and PJB's work left over, especially in underlying shooting logic, // it's just when I re-organised it changed me as the contributor [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly IRobustRandom _robustRandom = default!; [Dependency] private readonly IEntityManager _entities = default!; public override FireRateSelector FireRateSelector => _fireRateSelector; [DataField("currentSelector")] private FireRateSelector _fireRateSelector = FireRateSelector.Safety; public override FireRateSelector AllRateSelectors => _fireRateSelector; [DataField("fireRate")] public override float FireRate { get; } = 2f; // _lastFire is when we actually fired (so if we hold the button then recoil doesn't build up if we're not firing) private TimeSpan _lastFire; public abstract EntityUid? PeekAmmo(); public abstract EntityUid? TakeProjectile(EntityCoordinates spawnAt); // Recoil / spray control [DataField("minAngle")] private float _minAngleDegrees; public Angle MinAngle { get; private set; } [DataField("maxAngle")] private float _maxAngleDegrees = 45; public Angle MaxAngle { get; private set; } private Angle _currentAngle = Angle.Zero; [DataField("angleDecay")] private float _angleDecayDegrees = 20; /// /// How slowly the angle's theta decays per second in radians /// public float AngleDecay { get; private set; } [DataField("angleIncrease")] private float? _angleIncreaseDegrees; /// /// How quickly the angle's theta builds for every shot fired in radians /// public float AngleIncrease { get; private set; } // Multiplies the ammo spread to get the final spread of each pellet [DataField("ammoSpreadRatio")] public float SpreadRatio { get; private set; } [DataField("canMuzzleFlash")] public bool CanMuzzleFlash { get; } = true; // Sounds [DataField("soundGunshot", required: true)] public SoundSpecifier SoundGunshot { get; set; } = default!; [DataField("soundEmpty")] public SoundSpecifier SoundEmpty { get; } = new SoundPathSpecifier("/Audio/Weapons/Guns/Empty/empty.ogg"); void ISerializationHooks.BeforeSerialization() { _minAngleDegrees = (float) (MinAngle.Degrees * 2); _maxAngleDegrees = (float) (MaxAngle.Degrees * 2); _angleIncreaseDegrees = MathF.Round(AngleIncrease / ((float) Math.PI / 180f), 2); AngleDecay = MathF.Round(AngleDecay / ((float) Math.PI / 180f), 2); } void ISerializationHooks.AfterDeserialization() { // This hard-to-read area's dealing with recoil // Use degrees in yaml as it's easier to read compared to "0.0125f" MinAngle = Angle.FromDegrees(_minAngleDegrees / 2f); // Random doubles it as it's +/- so uhh we'll just half it here for readability MaxAngle = Angle.FromDegrees(_maxAngleDegrees / 2f); _angleIncreaseDegrees ??= 40 / FireRate; AngleIncrease = _angleIncreaseDegrees.Value * (float) Math.PI / 180f; AngleDecay = _angleDecayDegrees * (float) Math.PI / 180f; // For simplicity we'll enforce it this way; ammo determines max spread if (SpreadRatio > 1.0f) { Logger.Error("SpreadRatio must be <= 1.0f for guns"); throw new InvalidOperationException(); } } protected override void Initialize() { base.Initialize(); Owner.EnsureComponentWarn(out ServerRangedWeaponComponent rangedWeaponComponent); rangedWeaponComponent.Barrel ??= this; rangedWeaponComponent.FireHandler += Fire; rangedWeaponComponent.WeaponCanFireHandler += WeaponCanFire; } protected override void OnRemove() { base.OnRemove(); if (_entities.TryGetComponent(Owner, out ServerRangedWeaponComponent? rangedWeaponComponent)) { rangedWeaponComponent.Barrel = null; rangedWeaponComponent.FireHandler -= Fire; rangedWeaponComponent.WeaponCanFireHandler -= WeaponCanFire; } } private Angle GetRecoilAngle(Angle direction) { var currentTime = _gameTiming.CurTime; var timeSinceLastFire = (currentTime - _lastFire).TotalSeconds; var newTheta = MathHelper.Clamp(_currentAngle.Theta + AngleIncrease - AngleDecay * timeSinceLastFire, MinAngle.Theta, MaxAngle.Theta); _currentAngle = new Angle(newTheta); var random = (_robustRandom.NextDouble() - 0.5) * 2; var angle = Angle.FromDegrees(direction.Degrees + _currentAngle.Degrees * random); return angle; } public void ChangeFireSelector(FireRateSelector rateSelector) { if ((rateSelector & AllRateSelectors) != 0) { _fireRateSelector = rateSelector; return; } throw new InvalidOperationException(); } protected virtual bool WeaponCanFire() { // If the ServerRangedWeaponComponent gets re-done probably need to add the checks here return true; } /// /// Fires a round of ammo out of the weapon. /// /// Entity that is operating the weapon, usually the player. /// Target position on the map to shoot at. private void Fire(EntityUid shooter, Vector2 targetPos) { if (ShotsLeft == 0) { SoundSystem.Play(Filter.Broadcast(), SoundEmpty.GetSound(), Owner); return; } var ammo = PeekAmmo(); if (TakeProjectile(_entities.GetComponent(shooter).Coordinates) is not {Valid: true} projectile) { SoundSystem.Play(Filter.Broadcast(), SoundEmpty.GetSound(), Owner); return; } // At this point firing is confirmed var direction = (targetPos - _entities.GetComponent(shooter).WorldPosition).ToAngle(); var angle = GetRecoilAngle(direction); // This should really be client-side but for now we'll just leave it here if (_entities.TryGetComponent(shooter, out CameraRecoilComponent? recoilComponent)) { recoilComponent.Kick(-angle.ToVec() * 0.15f); } // This section probably needs tweaking so there can be caseless hitscan etc. if (_entities.TryGetComponent(projectile, out HitscanComponent? hitscan)) { FireHitscan(shooter, hitscan, angle); } else if (_entities.HasComponent(projectile) && _entities.TryGetComponent(ammo, out AmmoComponent? ammoComponent)) { FireProjectiles(shooter, projectile, ammoComponent.ProjectilesFired, ammoComponent.EvenSpreadAngle, angle, ammoComponent.Velocity, ammo.Value); if (CanMuzzleFlash) { ammoComponent.MuzzleFlash(Owner, angle); } if (ammoComponent.Caseless) { _entities.DeleteEntity(ammo.Value); } } else { // Invalid types throw new InvalidOperationException(); } SoundSystem.Play(Filter.Broadcast(), SoundGunshot.GetSound(), Owner); _lastFire = _gameTiming.CurTime; } /// /// Drops a single cartridge / shell /// Made as a static function just because multiple places need it /// /// /// /// /// /// public static void EjectCasing( EntityUid entity, bool playSound = true, Direction[]? ejectDirections = null, IRobustRandom? robustRandom = null, IPrototypeManager? prototypeManager = null, IEntityManager? entities = null) { IoCManager.Resolve(ref robustRandom, ref prototypeManager, ref entities); ejectDirections ??= new[] {Direction.East, Direction.North, Direction.NorthWest, Direction.South, Direction.SouthEast, Direction.West}; const float ejectOffset = 1.8f; var ammo = entities.GetComponent(entity); var offsetPos = ((robustRandom.NextFloat() - 0.5f) * ejectOffset, (robustRandom.NextFloat() - 0.5f) * ejectOffset); entities.GetComponent(entity).Coordinates = entities.GetComponent(entity).Coordinates.Offset(offsetPos); entities.GetComponent(entity).LocalRotation = robustRandom.Pick(ejectDirections).ToAngle(); var coordinates = entities.GetComponent(entity).Coordinates; SoundSystem.Play(Filter.Broadcast(), ammo.SoundCollectionEject.GetSound(), coordinates, AudioParams.Default.WithVolume(-1)); } /// /// Drops multiple cartridges / shells on the floor /// Wraps EjectCasing to make it less toxic for bulk ejections /// /// public static void EjectCasings(IEnumerable entities) { var robustRandom = IoCManager.Resolve(); var prototypeManager = IoCManager.Resolve(); var ejectDirections = new[] {Direction.East, Direction.North, Direction.NorthWest, Direction.South, Direction.SouthEast, Direction.West}; var soundPlayCount = 0; var playSound = true; foreach (var entity in entities) { EjectCasing(entity, playSound, ejectDirections, robustRandom, prototypeManager); soundPlayCount++; if (soundPlayCount > 3) { playSound = false; } } } #region Firing /// /// Handles firing one or many projectiles /// private void FireProjectiles(EntityUid shooter, EntityUid baseProjectile, int count, float evenSpreadAngle, Angle angle, float velocity, EntityUid ammo) { List? sprayAngleChange = null; if (count > 1) { evenSpreadAngle *= SpreadRatio; sprayAngleChange = Linspace(-evenSpreadAngle / 2, evenSpreadAngle / 2, count); } var firedProjectiles = new EntityUid[count]; for (var i = 0; i < count; i++) { EntityUid projectile; if (i == 0) { projectile = baseProjectile; } else { projectile = _entities.SpawnEntity( _entities.GetComponent(baseProjectile).EntityPrototype?.ID, _entities.GetComponent(baseProjectile).Coordinates); } firedProjectiles[i] = projectile; Angle projectileAngle; if (sprayAngleChange != null) { projectileAngle = angle + sprayAngleChange[i]; } else { projectileAngle = angle; } var physics = _entities.GetComponent(projectile); physics.BodyStatus = BodyStatus.InAir; var projectileComponent = _entities.GetComponent(projectile); projectileComponent.IgnoreEntity(shooter); // FIXME: Work around issue where inserting and removing an entity from a container, // then setting its linear velocity in the same tick resets velocity back to zero. // See SharedBroadphaseSystem.HandleContainerInsert()... It sets Awake to false, which causes this. projectile.SpawnTimer(TimeSpan.FromMilliseconds(25), () => { _entities.GetComponent(projectile) .LinearVelocity = projectileAngle.ToVec() * velocity; }); _entities.GetComponent(projectile).WorldRotation = projectileAngle + MathHelper.PiOver2; } _entities.EventBus.RaiseLocalEvent(Owner, new GunShotEvent(firedProjectiles)); _entities.EventBus.RaiseLocalEvent(ammo, new AmmoShotEvent(firedProjectiles)); } /// /// Returns a list of numbers that form a set of equal intervals between the start and end value. Used to calculate shotgun spread angles. /// private List Linspace(double start, double end, int intervals) { DebugTools.Assert(intervals > 1); var linspace = new List(intervals); for (var i = 0; i <= intervals - 1; i++) { linspace.Add(Angle.FromDegrees(start + (end - start) * i / (intervals - 1))); } return linspace; } /// /// Fires hitscan entities and then displays their effects /// private void FireHitscan(EntityUid shooter, HitscanComponent hitscan, Angle angle) { var ray = new CollisionRay(_entities.GetComponent(Owner).Coordinates.ToMapPos(_entities), angle.ToVec(), (int) hitscan.CollisionMask); var physicsManager = EntitySystem.Get(); var rayCastResults = physicsManager.IntersectRay(_entities.GetComponent(Owner).MapID, ray, hitscan.MaxLength, shooter, false).ToList(); if (rayCastResults.Count >= 1) { var result = rayCastResults[0]; var distance = result.Distance; hitscan.FireEffects(shooter, distance, angle, result.HitEntity); var dmg = EntitySystem.Get().TryChangeDamage(result.HitEntity, hitscan.Damage); if (dmg != null) EntitySystem.Get().Add(LogType.HitScanHit, $"{_entities.ToPrettyString(shooter):user} hit {_entities.ToPrettyString(result.HitEntity):target} using {_entities.ToPrettyString(hitscan.Owner):used} and dealt {dmg.Total:damage} damage"); } else { hitscan.FireEffects(shooter, hitscan.MaxLength, angle); } } #endregion public virtual void Examine(FormattedMessage.Builder message, bool inDetailsRange) { var fireRateMessage = Loc.GetString(FireRateSelector switch { FireRateSelector.Safety => "server-ranged-barrel-component-on-examine-fire-rate-safety-description", FireRateSelector.Single => "server-ranged-barrel-component-on-examine-fire-rate-single-description", FireRateSelector.Automatic => "server-ranged-barrel-component-on-examine-fire-rate-automatic-description", _ => throw new IndexOutOfRangeException() }); message.AddText(fireRateMessage); } } /// /// Raised on a gun when it fires projectiles. /// public sealed class GunShotEvent : EntityEventArgs { /// /// Uid of the entity that shot. /// public EntityUid Uid; public readonly EntityUid[] FiredProjectiles; public GunShotEvent(EntityUid[] firedProjectiles) { FiredProjectiles = firedProjectiles; } } /// /// Raised on ammo when it is fired. /// public sealed class AmmoShotEvent : EntityEventArgs { /// /// Uid of the entity that shot. /// public EntityUid Uid; public readonly EntityUid[] FiredProjectiles; public AmmoShotEvent(EntityUid[] firedProjectiles) { FiredProjectiles = firedProjectiles; } } }