Files
wwdpublic/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs
VMSolidus 40d411bbbc Cherrypick "Shoot Over Bodies" And Related PRs (#479)
# Description
This is a manual cherry-pick of the following PRs:
https://github.com/space-wizards/space-station-14/pull/27905
https://github.com/space-wizards/space-station-14/pull/28072
https://github.com/space-wizards/space-station-14/pull/28571

I REQUIRE these for my work in PR #11 , and cannot complete said PR
without these cherry-picks. This set of PRs from Wizden adds a feature
where entities can selectively opt-out of being shot at unless a player
intentionally targets them, which I can use as a simple and elegant
solution to one of the largest glaring issues for Segmented Entities. I
could simply give Lamia segments the new
RequireProjectileTargetComponent, which adds them to the system. Future
segmented entities such as the hypothetical "Heretic Worm" may or may
not use this feature, depending on their intended implementation.

---------

Co-authored-by: Danger Revolution! <142105406+DangerRevolution@users.noreply.github.com>
2024-06-23 01:00:32 -04:00

575 lines
20 KiB
C#

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.Item; // Delta-V: Felinids in duffelbags can't shoot.
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";
public const string ModeExamineColor = "cyan";
public override void Initialize()
{
SubscribeAllEvent<RequestShootEvent>(OnShootRequest);
SubscribeAllEvent<RequestStopShootEvent>(OnStopShootRequest);
SubscribeLocalEvent<GunComponent, MeleeHitEvent>(OnGunMelee);
// Ammo providers
InitializeBallistic();
InitializeBattery();
InitializeCartridge();
InitializeChamberMagazine();
InitializeMagazine();
InitializeRevolver();
InitializeBasicEntity();
InitializeClothing();
InitializeContainer();
InitializeSolution();
// Interactions
SubscribeLocalEvent<GunComponent, GetVerbsEvent<AlternativeVerb>>(OnAltVerb);
SubscribeLocalEvent<GunComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<GunComponent, CycleModeEvent>(OnCycleMode);
SubscribeLocalEvent<GunComponent, HandSelectedEvent>(OnGunSelected);
SubscribeLocalEvent<GunComponent, MapInitEvent>(OnMapInit);
}
private void OnMapInit(Entity<GunComponent> gun, ref MapInitEvent args)
{
#if DEBUG
if (gun.Comp.NextFire > Timing.CurTime)
Log.Warning($"Initializing a map that contains an entity that is on cooldown. Entity: {ToPrettyString(gun)}");
DebugTools.Assert((gun.Comp.AvailableModes & gun.Comp.SelectedMode) != 0x0);
#endif
RefreshModifiers((gun, gun));
}
private void OnGunMelee(EntityUid uid, GunComponent component, MeleeHitEvent args)
{
if (!TryComp<MeleeWeaponComponent>(uid, out var melee))
return;
if (melee.NextAttack > component.NextFire)
{
component.NextFire = melee.NextAttack;
Dirty(component);
}
}
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) ||
HasComp<ItemComponent>(user)) // Delta-V: Felinids in duffelbags can't shoot.
{
return;
}
if (ent != GetEntity(msg.Gun))
return;
gun.ShootCoordinates = GetCoordinates(msg.Coordinates);
Log.Debug($"Set shoot coordinates to {gun.ShootCoordinates}");
gun.Target = GetEntity(msg.Target);
AttemptShoot(user.Value, ent, gun);
}
private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args)
{
var gunUid = GetEntity(ev.Gun);
if (args.SenderSession.AttachedEntity == null ||
!TryComp<GunComponent>(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;
gun.Target = null;
Dirty(uid, gun);
}
/// <summary>
/// Attempts to shoot at the target coordinates. Resets the shot counter after every shot.
/// </summary>
public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, EntityCoordinates toCoordinates)
{
gun.ShootCoordinates = toCoordinates;
AttemptShoot(user, gunUid, gun);
gun.ShotCounter = 0;
}
/// <summary>
/// Shoots by assuming the gun is the user at default coordinates.
/// </summary>
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.FireRateModified <= 0f ||
!_actionBlockerSystem.CanAttack(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.FireRateModified);
// 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, gun.ShotsPerBurstModified - 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<PhysicsComponent>(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<PhysicsComponent>(uid);
Physics.SetBodyStatus(uid, 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<ProjectileComponent>(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);
/// <summary>
/// Call this whenever the ammo count for a gun changes.
/// </summary>
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);
}
/// <summary>
/// Drops a single cartridge / shell
/// </summary>
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<CartridgeAmmoComponent>(entity, out var cartridge))
{
Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f));
}
}
protected IShootable EnsureShootable(EntityUid uid)
{
if (TryComp<CartridgeAmmoComponent>(uid, out var cartridge))
return cartridge;
return EnsureComp<AmmoComponent>(uid);
}
protected void RemoveShootable(EntityUid uid)
{
RemCompDeferred<CartridgeAmmoComponent>(uid);
RemCompDeferred<AmmoComponent>(uid);
}
protected void MuzzleFlash(EntityUid gun, AmmoComponent component, EntityUid? user = null)
{
var attemptEv = new GunMuzzleFlashAttemptEvent();
RaiseLocalEvent(gun, ref attemptEv);
if (attemptEv.Cancelled)
return;
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);
}
public void RefreshModifiers(Entity<GunComponent?> gun)
{
if (!Resolve(gun, ref gun.Comp))
return;
var comp = gun.Comp;
var ev = new GunRefreshModifiersEvent(
(gun, comp),
comp.SoundGunshot,
comp.CameraRecoilScalar,
comp.AngleIncrease,
comp.AngleDecay,
comp.MaxAngle,
comp.MinAngle,
comp.ShotsPerBurst,
comp.FireRate,
comp.ProjectileSpeed
);
RaiseLocalEvent(gun, ref ev);
comp.SoundGunshotModified = ev.SoundGunshot;
comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
comp.AngleIncreaseModified = ev.AngleIncrease;
comp.AngleDecayModified = ev.AngleDecay;
comp.MaxAngleModified = ev.MaxAngle;
comp.MinAngleModified = ev.MinAngle;
comp.ShotsPerBurstModified = ev.ShotsPerBurst;
comp.FireRateModified = ev.FireRate;
comp.ProjectileSpeedModified = ev.ProjectileSpeed;
Dirty(gun);
}
protected abstract void CreateEffect(EntityUid uid, MuzzleFlashEvent message, EntityUid? user = null);
/// <summary>
/// Used for animated effects on the client.
/// </summary>
[Serializable, NetSerializable]
public sealed class HitscanEvent : EntityEventArgs
{
public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new();
}
}
/// <summary>
/// Raised directed on the gun before firing to see if the shot should go through.
/// </summary>
/// <remarks>
/// Handling this in server exclusively will lead to mispredicts.
/// </remarks>
/// <param name="User">The user that attempted to fire this gun.</param>
/// <param name="Cancelled">Set this to true if the shot should be cancelled.</param>
/// <param name="ThrowItems">Set this to true if the ammo shouldn't actually be fired, just thrown.</param>
[ByRefEvent]
public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false);
/// <summary>
/// Raised directed on the gun after firing.
/// </summary>
/// <param name="User">The user that fired this gun.</param>
[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,
}