Files
tbd-station-14/Content.Shared/Weapons/Ranged/Systems/SharedGunSystem.cs
nikthechampiongr bcbe2ec1af Cleanup ExecutionSystem (#24382)
* Creat Execution Component and add to sharp items

* Kill Server ExecutionSystem. Create ExecutionSystem in shared. Create ActiveExecution Component.
Transferred the Execution system into shared. Heavily re-wrote the system in order to reduce duplication,
and remove gun code from the system.
The melee weapon modifier which was dependant on swing rate was removed.

The ActiveExecutionComponent was created in order to apply the damage modifier to the shot from a gun execution.
It is added just before the gun fires and removed after an attempt is made.

* Fix bugs

The execution completed text will now only show up if the gun fires.

The client also no longer crashes because I forgot to network the component.

* Remove clumsy text

* Make BaseSword abstract

* Add ExecutionComponent to every weapon

* Fix bug

* Remove execution comp from battery weapons

Currently the gun system does not have a way to alter hitscan damage like it does with projectiles.

* Cleanup

* Revert "Remove clumsy text"

This reverts commit a46da6448d5d179a4e936f9213d5622bedb58a16.

* Actually fix the ExecutionSystem

Everything about the shot goes through the gun system now.
The Damage multiplier is only applied when a projectile impacts the target so people that get in the way don't get hit
with 9 times damage for no reason.

In order to make suicides work I needed to create fake EntityCoordinates because the gun system and the projectile
system do not play well with a projectile that has the same start and end position.

* Make launchers able to execute

* Fix prediction bug

The OnAmmoShotEvent is only raised on the server.

* Readd ability for clowns to accidentally shoot themselves while executing

* Cleanup

* Reset melee cooldown to initial value

* Address reviews fix bug

Addressed reviews on overriding messages.
Now I actually mark doafters as handled.
Return normal cooldown to some meleeweapons I forgot on the previous commit.

* Address Reviews

Remove duplication

* Exorcise codebase

Remove evil null coercion that I was sure I removed a while ago

* Address reviews again

* Remove melee weapon attack logic and rely on the system. Remove gun and
melee checks.

* Make system functional again and cleanup

* Remove code I forgot to remove

* Cleanup

* stalled

* Selectively revert gun penetration

The collision layer check doesn't work and I don't have time to fix it.

* Fixes

---------

Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
2024-02-25 22:07:10 +11:00

638 lines
22 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.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 JetBrains.Annotations;
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, EntityUnpausedEvent>(OnGunUnpaused);
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 OnGunUnpaused(EntityUid uid, GunComponent component, ref EntityUnpausedEvent args)
{
component.NextFire += args.PausedTime;
}
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))
{
return;
}
if (ent != GetEntity(msg.Gun))
return;
gun.ShootCoordinates = GetCoordinates(msg.Coordinates);
Log.Debug($"Set shoot coordinates to {gun.ShootCoordinates}");
AttemptShootInternal(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;
Dirty(uid, gun);
}
/// <summary>
/// Attempts to shoot the specified target directly.
/// This may bypass projectiles firing etc.
/// </summary>
public bool AttemptDirectShoot(EntityUid user, EntityUid gunUid, EntityUid target, GunComponent gun)
{
// Unique name so people don't think it's "shoot towards" and not "I will teleport a bullet into them".
gun.ShootCoordinates = Transform(target).Coordinates;
if (!TryTakeAmmo(user, gunUid, gun, out _, out _, out var args))
{
gun.ShootCoordinates = null;
return false;
}
var result = ShootDirect(gunUid, gun, target, args.Ammo, user: user);
gun.ShootCoordinates = null;
return result;
}
protected virtual bool ShootDirect(EntityUid gunUid, GunComponent gun, EntityUid target, List<(EntityUid? Entity, IShootable Shootable)> ammo, EntityUid user)
{
return false;
}
/// <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;
AttemptShootInternal(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;
AttemptShootInternal(gunUid, gunUid, gun);
gun.ShotCounter = 0;
}
private void AttemptShootInternal(EntityUid user, EntityUid gunUid, GunComponent gun)
{
if (!TryTakeAmmo(user, gunUid, gun, out var fromCoordinates, out var toCoordinates, out var args))
return;
Shoot(gunUid, gun, args.Ammo, fromCoordinates, toCoordinates, out var userImpulse, user: user);
if (userImpulse && TryComp<PhysicsComponent>(user, out var userPhysics))
{
if (_gravity.IsWeightless(user, userPhysics))
CauseImpulse(fromCoordinates, toCoordinates, user, userPhysics);
}
}
/// <summary>
/// Validates if a gun can currently shoot.
/// </summary>
[Pure]
private bool CanShoot(EntityUid user, EntityUid gunUid, GunComponent gun)
{
if (gun.FireRateModified <= 0f ||
!_actionBlockerSystem.CanAttack(user))
{
return false;
}
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 false;
RaiseLocalEvent(user, ref prevention);
if (prevention.Cancelled)
return false;
// 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 false;
return true;
}
/// <summary>
/// Tries to return ammo prepped for shooting if a gun is available to shoot.
/// </summary>
private bool TryTakeAmmo(
EntityUid user,
EntityUid gunUid, GunComponent gun,
out EntityCoordinates fromCoordinates,
out EntityCoordinates toCoordinates,
[NotNullWhen(true)] out TakeAmmoEvent? args)
{
toCoordinates = EntityCoordinates.Invalid;
fromCoordinates = EntityCoordinates.Invalid;
args = null;
if (!CanShoot(user, gunUid, gun))
return false;
if (gun.ShootCoordinates == null)
return false;
toCoordinates = gun.ShootCoordinates.Value;
var curTime = Timing.CurTime;
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 false;
}
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 false;
}
return false;
}
// Shoot confirmed - sounds also played here in case it's invalid (e.g. cartridge already spent).
var shotEv = new GunShotEvent(user, ev.Ammo);
RaiseLocalEvent(gunUid, ref shotEv);
args = ev;
return true;
}
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(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,
}