using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Numerics; using Content.Shared._RMC14.CCVar; using Content.Shared._RMC14.Random; using Content.Shared._RMC14.Weapons.Ranged.Prediction; using Content.Shared.ActionBlocker; using Content.Shared.Actions; using Content.Shared.Administration.Logs; using Content.Shared.Audio; using Content.Shared.Camera; using Content.Shared.CombatMode; using Content.Shared.Containers.ItemSlots; using Content.Shared.Damage; using Content.Shared.Damage.Components; using Content.Shared.Damage.Systems; using Content.Shared.Database; using Content.Shared.Effects; using Content.Shared.Examine; using Content.Shared.Gravity; using Content.Shared.Hands; using Content.Shared.Hands.Components; using Content.Shared.Interaction; using Content.Shared.Interaction.Components; using Content.Shared.Popups; using Content.Shared.Projectiles; using Content.Shared.Stunnable; 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 Content.Shared.Weapons.Reflect; using Content.Shared.Whitelist; using Robust.Shared.Audio; using Robust.Shared.Audio.Systems; using Robust.Shared.Configuration; using Robust.Shared.Containers; using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Physics; using Robust.Shared.Physics.Components; using Robust.Shared.Physics.Systems; using Robust.Shared.Player; 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!; [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; [Dependency] private readonly StaminaSystem _stamina = default!; [Dependency] private readonly SharedStunSystem _stun = default!; [Dependency] private readonly SharedColorFlashEffectSystem _color = default!; [Dependency] private readonly SharedCameraRecoilSystem _recoil = default!; [Dependency] private readonly IConfigurationManager _config = 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 const float GunClumsyChance = 0.5f; private const float DamagePitchVariation = 0.05f; public bool GunPrediction { get; private set; } public override void Initialize() { SubscribeAllEvent(OnStopShootRequest); SubscribeLocalEvent(OnGunMelee); // Ammo providers InitializeBallistic(); InitializeBattery(); InitializeCartridge(); InitializeChamberMagazine(); InitializeMagazine(); InitializeRevolver(); InitializeBasicEntity(); InitializeClothing(); InitializeContainer(); InitializeSolution(); // Interactions SubscribeLocalEvent>(OnAltVerb); SubscribeLocalEvent(OnExamine); SubscribeLocalEvent(OnCycleMode); SubscribeLocalEvent(OnGunSelected); SubscribeLocalEvent(OnMapInit); Subs.CVar(_config, RMCCVars.RMCGunPrediction, v => GunPrediction = v, true); } private void OnMapInit(Entity 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(uid, out var melee)) return; if (melee.NextAttack > component.NextFire) { component.NextFire = melee.NextAttack; Dirty(uid, component); } } private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args) { var gunUid = GetEntity(ev.Gun); if (args.SenderSession.AttachedEntity == null || !TryComp(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; gun.ShotCounter = 0; gun.ShootCoordinates = null; gun.Target = null; Dirty(uid, gun); } /// /// Attempts to shoot at the target coordinates. Resets the shot counter after every shot. /// public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, EntityCoordinates toCoordinates) { gun.ShootCoordinates = toCoordinates; AttemptShoot(user, gunUid, gun); gun.ShotCounter = 0; } /// /// Shoots by assuming the gun is the user at default coordinates. /// 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 List? AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, List? predictedProjectiles = null, ICommonSession? userSession = null) { if (gun.FireRateModified <= 0f || !_actionBlockerSystem.CanAttack(user)) return null; var toCoordinates = gun.ShootCoordinates; if (toCoordinates == null) return null; var curTime = Timing.CurTime; // check if anything wants to prevent shooting var prevention = new ShotAttemptedEvent { User = user, Used = (gunUid, gun) }; RaiseLocalEvent(gunUid, ref prevention); if (prevention.Cancelled) return null; RaiseLocalEvent(user, ref prevention); if (prevention.Cancelled) return null; // 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 null; 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 null; } if (!Timing.IsFirstTimePredicted) return null; 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 null; } return null; } // Shoot confirmed - sounds also played here in case it's invalid (e.g. cartridge already spent). var projectiles = Shoot(gunUid, gun, ev.Ammo, fromCoordinates, toCoordinates.Value, out var userImpulse, user, throwItems: attemptEv.ThrowItems, predictedProjectiles, userSession); var shotEv = new GunShotEvent(user, ev.Ammo); RaiseLocalEvent(gunUid, ref shotEv); if (userImpulse && TryComp(user, out var userPhysics)) { if (_gravity.IsWeightless(user, userPhysics)) CauseImpulse(fromCoordinates, toCoordinates.Value, user, userPhysics); } Dirty(gunUid, gun); return projectiles; } 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); } /// /// Fires one or more projectiles or hitscan shots from a gun using the provided ammo, handling recoil, effects, and impact logic. /// /// The entity UID of the gun firing. /// The gun component associated with the firing gun. /// A list of ammo entities and their shootable components to be fired. /// The origin coordinates of the shot. /// The target coordinates of the shot. /// Set to true if the user should receive recoil impulse; false if not. /// The entity UID of the user firing the gun, if any. /// If true, items are thrown instead of shot as projectiles. /// Optional list of predicted projectile indices for client-side prediction. /// Optional session of the user for prediction purposes. /// A list of entity UIDs representing the fired projectiles, or null if none were fired. public List? Shoot( EntityUid gunUid, GunComponent gun, List<(EntityUid? Entity, IShootable Shootable)> ammo, EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, out bool userImpulse, EntityUid? user = null, bool throwItems = false, List? predictedProjectiles = null, ICommonSession? userSession = null) { userImpulse = true; var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem); var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem); 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 var grid) ? fromCoordinates.WithEntityId(gridUid, EntityManager) : new EntityCoordinates(MapManager.GetMapEntityId(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(ammo.Count); void MarkPredicted(EntityUid projectile, int index) { if (!GunPrediction) return; if (predictedProjectiles == null || userSession == null) return; if (predictedProjectiles.TryGetValue(index, out var predicted)) { var comp = new PredictedProjectileServerComponent { Shooter = userSession, ClientId = predicted, ClientEnt = user, }; AddComp(projectile, comp, true); Dirty(projectile, comp); } } foreach (var (ent, shootable) in ammo) { // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling if (throwItems && ent != null) { Recoil(user, mapDirection, gun.CameraRecoilScalarModified); ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user); continue; } switch (shootable) { // Cartridge shoots something else case CartridgeAmmoComponent cartridge: if (!cartridge.Spent) { if (_netManager.IsServer || GunPrediction) { var uid = Spawn(cartridge.Prototype, fromEnt); shotProjectiles.Add(uid); CreateAndFireProjectiles(uid, cartridge); RaiseLocalEvent(ent!.Value, new AmmoShotEvent() { FiredProjectiles = shotProjectiles, }); SetCartridgeSpent(ent.Value, cartridge, true); if (cartridge.DeleteOnSpawn && (_netManager.IsServer || IsClientSide(ent.Value))) { Del(ent.Value); } } else { MuzzleFlash(gunUid, cartridge, mapDirection.ToAngle(), user); Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); } Recoil(user, mapDirection, gun.CameraRecoilScalarModified); } 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); if (IsClientSide(ent!.Value)) Del(ent.Value); else Dirty(ent!.Value, cartridge); break; // Ammo shoots itself case AmmoComponent newAmmo: if (_netManager.IsServer || GunPrediction) { shotProjectiles.Add(ent!.Value); CreateAndFireProjectiles(ent.Value, newAmmo); } else { MuzzleFlash(gunUid, newAmmo, mapDirection.ToAngle(), user); Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); } Recoil(user, mapDirection, gun.CameraRecoilScalarModified); if (IsClientSide(ent!.Value)) Del(ent.Value); else if (_netManager.IsClient) RemoveShootable(ent.Value); MarkPredicted(ent!.Value, 0); break; case HitscanPrototype hitscan: EntityUid? lastHit = null; var from = fromMap; // can't use map coords above because funny FireEffects var fromEffect = fromCoordinates; var dir = mapDirection.Normalized(); //in the situation when user == null, means that the cannon fires on its own (via signals). And we need the gun to not fire by itself in this case var lastUser = user ?? gunUid; if (hitscan.Reflective != ReflectType.None) { for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++) { var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask); var rayCastResults = Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList(); if (!rayCastResults.Any()) break; var result = rayCastResults[0]; // Check if laser is shot from in a container if (!Containers.IsEntityOrParentInContainer(lastUser)) { // Checks if the laser should pass over unless targeted by its user foreach (var collide in rayCastResults) { if (collide.HitEntity != gun.Target && CompOrNull(collide.HitEntity)?.Active == true) { continue; } result = collide; break; } } var hit = result.HitEntity; lastHit = hit; FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit); var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false); RaiseLocalEvent(hit, ref ev); if (!ev.Reflected) break; fromEffect = Transform(hit).Coordinates; from = fromEffect.ToMap(EntityManager, TransformSystem); dir = ev.Direction; lastUser = hit; } } if (lastHit != null) { var hitEntity = lastHit.Value; if (hitscan.StaminaDamage > 0f) _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user, immediate: true); var dmg = hitscan.Damage; var hitName = ToPrettyString(hitEntity); if (dmg != null) dmg = Damageable.TryChangeDamage(hitEntity, dmg, origin: user); // check null again, as TryChangeDamage returns modified damage values if (dmg != null) { if (!Deleted(hitEntity)) { if (dmg.AnyPositive()) { _color.RaiseEffect(Color.Red, new List() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager)); } // TODO get fallback position for playing hit sound. PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound); } if (user != null) { Logs.Add(LogType.HitScanHit, $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage"); } else { Logs.Add(LogType.HitScanHit, $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage"); } } } else { FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan); } Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); Recoil(user, mapDirection, gun.CameraRecoilScalarModified); break; default: throw new ArgumentOutOfRangeException(); } } RaiseLocalEvent(gunUid, new AmmoShotEvent() { FiredProjectiles = shotProjectiles, }); void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp) { predictedProjectiles ??= new List(); MarkPredicted(ammoEnt, 0); if (TryComp(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); MarkPredicted(newuid, i + 1); } } else { ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user); shotProjectiles.Add(ammoEnt); } MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user); Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user); } return shotProjectiles; } 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. long tick = Timing.CurTick.Value; tick = tick << 32; tick = tick | (uint)GetNetEntity(component.Owner).Id; Logger.Info(Timing.CurTick.ToString()); var random = new Xoroshiro64S(tick).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; } 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(uid); targeted.Target = target; Dirty(uid, targeted); } // Do a throw if (!HasComp(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); } #region Hitscan effects private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle mapDirection, HitscanPrototype hitscan, EntityUid? hitEntity = null) { // Lord // Forgive me for the shitcode I am about to do // Effects tempt me not var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>(); var gridUid = fromCoordinates.GetGridUid(EntityManager); var angle = mapDirection; // We'll get the effects relative to the grid / map of the firer // Look you could probably optimise this a bit with redundant transforms at this point. var xformQuery = GetEntityQuery(); if (xformQuery.TryGetComponent(gridUid, out var gridXform)) { var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform, xformQuery); fromCoordinates = new EntityCoordinates(gridUid.Value, Vector2.Transform(fromCoordinates.ToMapPos(EntityManager, TransformSystem), gridInvMatrix)); // Use the fallback angle I guess? angle -= gridRot; } if (distance >= 1f) { if (hitscan.MuzzleFlash != null) { var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2); var netCoords = GetNetCoordinates(coords); sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f)); } if (hitscan.TravelFlash != null) { var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2); var netCoords = GetNetCoordinates(coords); sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f)); } } if (hitscan.ImpactFlash != null) { var coords = fromCoordinates.Offset(angle.ToVec() * distance); var netCoords = GetNetCoordinates(coords); sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f)); } if (_netManager.IsServer && sprites.Count > 0) { RaiseNetworkEvent(new HitscanEvent { Sprites = sprites, }, Filter.Pvs(fromCoordinates, entityMan: EntityManager)); } } #endregion /// /// Gets a linear spread of angles between start and end. /// /// Start angle in degrees /// End angle in degrees /// How many shots there are 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; } public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound, Filter? filter = null, EntityUid? projectile = null) { DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted"); // Like projectiles and melee, // 1. Entity specific sound // 2. Ammo's sound // 3. Nothing if (_netManager.IsClient && HasComp(projectile)) return; filter ??= Filter.Pvs(otherEntity); var playedSound = false; if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp(otherEntity, out var rangedSound)) { var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager); if (type != null && rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true && filter.Count > 0) { Audio.PlayEntity(damageSoundType, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation)); playedSound = true; } else if (type != null && rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true && filter.Count > 0) { Audio.PlayEntity(damageSoundGroup, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation)); playedSound = true; } } if (!playedSound && weaponSound != null && filter.Count > 0) { Audio.PlayEntity(weaponSound, filter, otherEntity, true); } } private void Recoil(EntityUid? user, Vector2 recoil, float recoilScalar) { if (_netManager.IsServer) return; if (!Timing.IsFirstTimePredicted || user == null || recoil == Vector2.Zero || recoilScalar == 0) return; _recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar); } public virtual void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid gunUid, EntityUid? user = null, float speed = 20f) { var physics = EnsureComp(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(uid); Projectiles.SetShooter(uid, projectile, user ?? gunUid); projectile.Weapon = gunUid; TransformSystem.SetWorldRotationNoLerp(uid, direction.ToWorldAngle() + projectile.Angle); } public List? ShootRequested(NetEntity netGun, NetCoordinates coordinates, NetEntity? target, List? projectiles, ICommonSession session) { var user = session.AttachedEntity; if (user == null || !_combatMode.IsInCombatMode(user) || !TryGetGun(user.Value, out var ent, out var gun)) { return null; } if (ent != GetEntity(netGun)) return null; gun.ShootCoordinates = GetCoordinates(coordinates); gun.Target = GetEntity(target); return AttemptShoot(user.Value, ent, gun, projectiles, session); } protected abstract void Popup(string message, EntityUid? uid, EntityUid? user); /// /// Call this whenever the ammo count for a gun changes. /// protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) { } protected virtual void SetCartridgeSpent(EntityUid uid, CartridgeAmmoComponent cartridge, bool spent) { if (cartridge.Spent != spent) Dirty(uid, cartridge); cartridge.Spent = spent; Appearance.SetData(uid, AmmoVisuals.Spent, spent); } /// /// Drops a single cartridge / shell /// 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(entity, out var cartridge)) { Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f)); } } protected IShootable EnsureShootable(EntityUid uid) { if (TryComp(uid, out var cartridge)) return cartridge; return EnsureComp(uid); } protected void RemoveShootable(EntityUid uid) { RemCompDeferred(uid); RemCompDeferred(uid); } protected void MuzzleFlash(EntityUid gun, AmmoComponent component, Angle worldAngle, 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, worldAngle); CreateEffect(gun, ev, gun, 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 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 gunUid, MuzzleFlashEvent message, EntityUid? user = null, EntityUid? player = null); /// /// Used for animated effects on the client. /// [Serializable, NetSerializable] public sealed class HitscanEvent : EntityEventArgs { public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new(); } } /// /// Raised directed on the gun before firing to see if the shot should go through. /// /// /// Handling this in server exclusively will lead to mispredicts. /// /// The user that attempted to fire this gun. /// Set this to true if the shot should be cancelled. /// Set this to true if the ammo shouldn't actually be fired, just thrown. [ByRefEvent] public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false); /// /// Raised directed on the gun after firing. /// /// The user that fired this gun. [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, }