CatStation/Content.Server/Weapons/Ranged/Systems/GunSystem.cs
TemporalOroboros 84b2f07157
The IMapManager is dead (#44002)
* Axes the easy IMapManager references

* Axes the references to unported (obsolete) IMapManager methods

* Converts salvageruler command to a LocalizedEntityCommand so it can use SharedMapSystem

* Removes IMapManager from GameTicker
This required axing the map nuke during restart, but that should be fine?
Maps are entities so EntityManager.FlushEntities should delete them all and that's the preceding line

* submodule update(?)

---------

Co-authored-by: ArtisticRoomba <145879011+ArtisticRoomba@users.noreply.github.com>
2026-07-01 20:44:54 -07:00

293 lines
11 KiB
C#

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.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;
using Robust.Shared.Random;
namespace Content.Server.Weapons.Ranged.Systems;
public sealed partial class GunSystem : SharedGunSystem
{
[Dependency] private PricingSystem _pricing = default!;
[Dependency] private SharedMapSystem _map = default!;
private const float DamagePitchVariation = 0.05f;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<BallisticAmmoProviderComponent, PriceCalculationEvent>(OnBallisticPrice);
}
private void OnBallisticPrice(Entity<BallisticAmmoProviderComponent> ent, ref PriceCalculationEvent args)
{
if (string.IsNullOrEmpty(ent.Comp.Proto) || ent.Comp.UnspawnedCount == 0)
return;
if (!ProtoMan.TryIndex<EntityPrototype>(ent.Comp.Proto, out var proto))
{
Log.Error($"Unable to find fill prototype for price on {ent.Comp.Proto} on {ToPrettyString(ent)}");
return;
}
// Probably good enough for most.
var price = _pricing.GetEstimatedPrice(proto);
args.Price += price * ent.Comp.UnspawnedCount;
}
public override void Shoot(Entity<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, 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 = Maps.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<EntityUid>(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, 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.Comp.SoundEmpty, gun, 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 = gun,
Shooter = user,
Target = gun.Comp.Target,
};
RaiseLocalEvent(ent.Value, ref hitscanEv);
Del(ent);
Audio.PlayPredicted(gun.Comp.SoundGunshotModified, gun, user);
break;
default:
throw new ArgumentOutOfRangeException();
}
}
RaiseLocalEvent(gun, new AmmoShotEvent()
{
FiredProjectiles = shotProjectiles,
});
void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
{
if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
{
var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
RaiseLocalEvent(gun, ref spreadEvent);
var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, 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, user);
shotProjectiles.Add(newuid);
}
}
else
{
ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, user);
shotProjectiles.Add(ammoEnt);
}
MuzzleFlash(gun, ammoComp, mapDirection.ToAngle(), user);
Audio.PlayPredicted(gun.Comp.SoundGunshotModified, gun, user);
}
}
private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, Entity<GunComponent> gun, EntityUid? user)
{
if (gun.Comp.Target is { } target && !TerminatingOrDeleted(target))
{
var targeted = EnsureComp<TargetedProjectileComponent>(uid);
targeted.Target = target;
Dirty(uid, targeted);
}
// Do a throw
if (!HasComp<ProjectileComponent>(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.Comp.ProjectileSpeedModified, user);
return;
}
ShootProjectile(uid, mapDirection, gunVelocity, gun, user, gun.Comp.ProjectileSpeedModified);
}
/// <summary>
/// Gets a linear spread of angles between start and end.
/// </summary>
/// <param name="start">Start angle in degrees</param>
/// <param name="end">End angle in degrees</param>
/// <param name="intervals">How many shots there are</param>
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<ActorComponent>(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<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
{
var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoMan);
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);
}
}
}