Files
tbd-station-14/Content.Server/Weapons/Ranged/Systems/GunSystem.cs
beck-thompson 492a1aa9c3 Hitscans are now entities (#38035)
* Hitscans are now entities

* Cleanup

* Cleanup

* Silly mistakes but stop sign testing helps :)

* Address most of the review

* Reviews

* perry :(

* Final reviews

* Add comments

* Split event up

* better comment

* cleanup
2025-10-18 05:42:08 +00:00

295 lines
12 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.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<BallisticAmmoProviderComponent, PriceCalculationEvent>(OnBallisticPrice);
}
private void OnBallisticPrice(EntityUid uid, BallisticAmmoProviderComponent component, ref PriceCalculationEvent args)
{
if (string.IsNullOrEmpty(component.Proto) || component.UnspawnedCount == 0)
return;
if (!ProtoManager.TryIndex<EntityPrototype>(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<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, 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<ProjectileSpreadComponent>(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<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.ProjectileSpeedModified, user);
return;
}
ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.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, 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);
}
}
}