SharedGunSystem.cs 42 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063
  1. using System.Diagnostics.CodeAnalysis;
  2. using System.Linq;
  3. using System.Numerics;
  4. using Content.Shared._RMC14.CCVar;
  5. using Content.Shared._RMC14.Random;
  6. using Content.Shared._RMC14.Weapons.Ranged.Prediction;
  7. using Content.Shared.ActionBlocker;
  8. using Content.Shared.Actions;
  9. using Content.Shared.Administration.Logs;
  10. using Content.Shared.Audio;
  11. using Content.Shared.Camera;
  12. using Content.Shared.CombatMode;
  13. using Content.Shared.Containers.ItemSlots;
  14. using Content.Shared.Damage;
  15. using Content.Shared.Damage.Components;
  16. using Content.Shared.Damage.Systems;
  17. using Content.Shared.Database;
  18. using Content.Shared.Effects;
  19. using Content.Shared.Examine;
  20. using Content.Shared.Gravity;
  21. using Content.Shared.Hands;
  22. using Content.Shared.Hands.Components;
  23. using Content.Shared.Interaction;
  24. using Content.Shared.Interaction.Components;
  25. using Content.Shared.Popups;
  26. using Content.Shared.Projectiles;
  27. using Content.Shared.Stunnable;
  28. using Content.Shared.Tag;
  29. using Content.Shared.Throwing;
  30. using Content.Shared.Timing;
  31. using Content.Shared.Verbs;
  32. using Content.Shared.Weapons.Melee;
  33. using Content.Shared.Weapons.Melee.Events;
  34. using Content.Shared.Weapons.Ranged.Components;
  35. using Content.Shared.Weapons.Ranged.Events;
  36. using Content.Shared.Weapons.Reflect;
  37. using Content.Shared.Whitelist;
  38. using Robust.Shared.Audio;
  39. using Robust.Shared.Audio.Systems;
  40. using Robust.Shared.Configuration;
  41. using Robust.Shared.Containers;
  42. using Robust.Shared.Map;
  43. using Robust.Shared.Network;
  44. using Robust.Shared.Physics;
  45. using Robust.Shared.Physics.Components;
  46. using Robust.Shared.Physics.Systems;
  47. using Robust.Shared.Player;
  48. using Robust.Shared.Prototypes;
  49. using Robust.Shared.Random;
  50. using Robust.Shared.Serialization;
  51. using Robust.Shared.Timing;
  52. using Robust.Shared.Utility;
  53. namespace Content.Shared.Weapons.Ranged.Systems;
  54. public abstract partial class SharedGunSystem : EntitySystem
  55. {
  56. [Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
  57. [Dependency] protected readonly IGameTiming Timing = default!;
  58. [Dependency] protected readonly IMapManager MapManager = default!;
  59. [Dependency] private readonly INetManager _netManager = default!;
  60. [Dependency] protected readonly IPrototypeManager ProtoManager = default!;
  61. [Dependency] protected readonly IRobustRandom Random = default!;
  62. [Dependency] protected readonly ISharedAdminLogManager Logs = default!;
  63. [Dependency] protected readonly DamageableSystem Damageable = default!;
  64. [Dependency] protected readonly ExamineSystemShared Examine = default!;
  65. [Dependency] private readonly ItemSlotsSystem _slots = default!;
  66. [Dependency] private readonly RechargeBasicEntityAmmoSystem _recharge = default!;
  67. [Dependency] protected readonly SharedActionsSystem Actions = default!;
  68. [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
  69. [Dependency] protected readonly SharedAudioSystem Audio = default!;
  70. [Dependency] private readonly SharedCombatModeSystem _combatMode = default!;
  71. [Dependency] protected readonly SharedContainerSystem Containers = default!;
  72. [Dependency] private readonly SharedGravitySystem _gravity = default!;
  73. [Dependency] protected readonly SharedPointLightSystem Lights = default!;
  74. [Dependency] protected readonly SharedPopupSystem PopupSystem = default!;
  75. [Dependency] protected readonly SharedPhysicsSystem Physics = default!;
  76. [Dependency] protected readonly SharedProjectileSystem Projectiles = default!;
  77. [Dependency] protected readonly SharedTransformSystem TransformSystem = default!;
  78. [Dependency] protected readonly TagSystem TagSystem = default!;
  79. [Dependency] protected readonly ThrowingSystem ThrowingSystem = default!;
  80. [Dependency] private readonly UseDelaySystem _useDelay = default!;
  81. [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
  82. [Dependency] private readonly StaminaSystem _stamina = default!;
  83. [Dependency] private readonly SharedStunSystem _stun = default!;
  84. [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
  85. [Dependency] private readonly SharedCameraRecoilSystem _recoil = default!;
  86. [Dependency] private readonly IConfigurationManager _config = default!;
  87. private const float InteractNextFire = 0.3f;
  88. private const double SafetyNextFire = 0.5;
  89. private const float EjectOffset = 0.4f;
  90. protected const string AmmoExamineColor = "yellow";
  91. protected const string FireRateExamineColor = "yellow";
  92. public const string ModeExamineColor = "cyan";
  93. public const float GunClumsyChance = 0.5f;
  94. private const float DamagePitchVariation = 0.05f;
  95. public bool GunPrediction { get; private set; }
  96. public override void Initialize()
  97. {
  98. SubscribeAllEvent<RequestStopShootEvent>(OnStopShootRequest);
  99. SubscribeLocalEvent<GunComponent, MeleeHitEvent>(OnGunMelee);
  100. // Ammo providers
  101. InitializeBallistic();
  102. InitializeBattery();
  103. InitializeCartridge();
  104. InitializeChamberMagazine();
  105. InitializeMagazine();
  106. InitializeRevolver();
  107. InitializeBasicEntity();
  108. InitializeClothing();
  109. InitializeContainer();
  110. InitializeSolution();
  111. // Interactions
  112. SubscribeLocalEvent<GunComponent, GetVerbsEvent<AlternativeVerb>>(OnAltVerb);
  113. SubscribeLocalEvent<GunComponent, ExaminedEvent>(OnExamine);
  114. SubscribeLocalEvent<GunComponent, CycleModeEvent>(OnCycleMode);
  115. SubscribeLocalEvent<GunComponent, HandSelectedEvent>(OnGunSelected);
  116. SubscribeLocalEvent<GunComponent, MapInitEvent>(OnMapInit);
  117. Subs.CVar(_config, RMCCVars.RMCGunPrediction, v => GunPrediction = v, true);
  118. }
  119. private void OnMapInit(Entity<GunComponent> gun, ref MapInitEvent args)
  120. {
  121. #if DEBUG
  122. if (gun.Comp.NextFire > Timing.CurTime)
  123. Log.Warning($"Initializing a map that contains an entity that is on cooldown. Entity: {ToPrettyString(gun)}");
  124. DebugTools.Assert((gun.Comp.AvailableModes & gun.Comp.SelectedMode) != 0x0);
  125. #endif
  126. RefreshModifiers((gun, gun));
  127. }
  128. private void OnGunMelee(EntityUid uid, GunComponent component, MeleeHitEvent args)
  129. {
  130. if (!TryComp<MeleeWeaponComponent>(uid, out var melee))
  131. return;
  132. if (melee.NextAttack > component.NextFire)
  133. {
  134. component.NextFire = melee.NextAttack;
  135. Dirty(uid, component);
  136. }
  137. }
  138. private void OnStopShootRequest(RequestStopShootEvent ev, EntitySessionEventArgs args)
  139. {
  140. var gunUid = GetEntity(ev.Gun);
  141. if (args.SenderSession.AttachedEntity == null ||
  142. !TryComp<GunComponent>(gunUid, out var gun) ||
  143. !TryGetGun(args.SenderSession.AttachedEntity.Value, out _, out var userGun))
  144. {
  145. return;
  146. }
  147. if (userGun != gun)
  148. return;
  149. StopShooting(gunUid, gun);
  150. }
  151. public bool CanShoot(GunComponent component)
  152. {
  153. if (component.NextFire > Timing.CurTime)
  154. return false;
  155. return true;
  156. }
  157. public bool TryGetGun(EntityUid entity, out EntityUid gunEntity, [NotNullWhen(true)] out GunComponent? gunComp)
  158. {
  159. gunEntity = default;
  160. gunComp = null;
  161. if (EntityManager.TryGetComponent(entity, out HandsComponent? hands) &&
  162. hands.ActiveHandEntity is { } held &&
  163. TryComp(held, out GunComponent? gun))
  164. {
  165. gunEntity = held;
  166. gunComp = gun;
  167. return true;
  168. }
  169. // Last resort is check if the entity itself is a gun.
  170. if (TryComp(entity, out gun))
  171. {
  172. gunEntity = entity;
  173. gunComp = gun;
  174. return true;
  175. }
  176. return false;
  177. }
  178. private void StopShooting(EntityUid uid, GunComponent gun)
  179. {
  180. if (gun.ShotCounter == 0)
  181. return;
  182. gun.ShotCounter = 0;
  183. gun.ShootCoordinates = null;
  184. gun.Target = null;
  185. Dirty(uid, gun);
  186. }
  187. /// <summary>
  188. /// Attempts to shoot at the target coordinates. Resets the shot counter after every shot.
  189. /// </summary>
  190. public void AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, EntityCoordinates toCoordinates)
  191. {
  192. gun.ShootCoordinates = toCoordinates;
  193. AttemptShoot(user, gunUid, gun);
  194. gun.ShotCounter = 0;
  195. }
  196. /// <summary>
  197. /// Shoots by assuming the gun is the user at default coordinates.
  198. /// </summary>
  199. public void AttemptShoot(EntityUid gunUid, GunComponent gun)
  200. {
  201. var coordinates = new EntityCoordinates(gunUid, new Vector2(0, -1));
  202. gun.ShootCoordinates = coordinates;
  203. AttemptShoot(gunUid, gunUid, gun);
  204. gun.ShotCounter = 0;
  205. }
  206. private List<EntityUid>? AttemptShoot(EntityUid user, EntityUid gunUid, GunComponent gun, List<int>? predictedProjectiles = null, ICommonSession? userSession = null)
  207. {
  208. if (gun.FireRateModified <= 0f ||
  209. !_actionBlockerSystem.CanAttack(user))
  210. return null;
  211. var toCoordinates = gun.ShootCoordinates;
  212. if (toCoordinates == null)
  213. return null;
  214. var curTime = Timing.CurTime;
  215. // check if anything wants to prevent shooting
  216. var prevention = new ShotAttemptedEvent
  217. {
  218. User = user,
  219. Used = (gunUid, gun)
  220. };
  221. RaiseLocalEvent(gunUid, ref prevention);
  222. if (prevention.Cancelled)
  223. return null;
  224. RaiseLocalEvent(user, ref prevention);
  225. if (prevention.Cancelled)
  226. return null;
  227. // Need to do this to play the clicking sound for empty automatic weapons
  228. // but not play anything for burst fire.
  229. if (gun.NextFire > curTime)
  230. return null;
  231. var fireRate = TimeSpan.FromSeconds(1f / gun.FireRateModified);
  232. // First shot
  233. // Previously we checked shotcounter but in some cases all the bullets got dumped at once
  234. // curTime - fireRate is insufficient because if you time it just right you can get a 3rd shot out slightly quicker.
  235. if (gun.NextFire < curTime - fireRate || gun.ShotCounter == 0 && gun.NextFire < curTime)
  236. gun.NextFire = curTime;
  237. var shots = 0;
  238. var lastFire = gun.NextFire;
  239. while (gun.NextFire <= curTime)
  240. {
  241. gun.NextFire += fireRate;
  242. shots++;
  243. }
  244. // NextFire has been touched regardless so need to dirty the gun.
  245. Dirty(gunUid, gun);
  246. // Get how many shots we're actually allowed to make, due to clip size or otherwise.
  247. // Don't do this in the loop so we still reset NextFire.
  248. switch (gun.SelectedMode)
  249. {
  250. case SelectiveFire.SemiAuto:
  251. shots = Math.Min(shots, 1 - gun.ShotCounter);
  252. break;
  253. case SelectiveFire.Burst:
  254. shots = Math.Min(shots, gun.ShotsPerBurstModified - gun.ShotCounter);
  255. break;
  256. case SelectiveFire.FullAuto:
  257. break;
  258. default:
  259. throw new ArgumentOutOfRangeException($"No implemented shooting behavior for {gun.SelectedMode}!");
  260. }
  261. var attemptEv = new AttemptShootEvent(user, null);
  262. RaiseLocalEvent(gunUid, ref attemptEv);
  263. if (attemptEv.Cancelled)
  264. {
  265. if (attemptEv.Message != null)
  266. {
  267. PopupSystem.PopupClient(attemptEv.Message, gunUid, user);
  268. }
  269. gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
  270. return null;
  271. }
  272. if (!Timing.IsFirstTimePredicted)
  273. return null;
  274. var fromCoordinates = Transform(user).Coordinates;
  275. // Remove ammo
  276. var ev = new TakeAmmoEvent(shots, new List<(EntityUid? Entity, IShootable Shootable)>(), fromCoordinates, user);
  277. // Listen it just makes the other code around it easier if shots == 0 to do this.
  278. if (shots > 0)
  279. RaiseLocalEvent(gunUid, ev);
  280. DebugTools.Assert(ev.Ammo.Count <= shots);
  281. DebugTools.Assert(shots >= 0);
  282. UpdateAmmoCount(gunUid);
  283. // Even if we don't actually shoot update the ShotCounter. This is to avoid spamming empty sounds
  284. // where the gun may be SemiAuto or Burst.
  285. gun.ShotCounter += shots;
  286. if (ev.Ammo.Count <= 0)
  287. {
  288. // triggers effects on the gun if it's empty
  289. var emptyGunShotEvent = new OnEmptyGunShotEvent();
  290. RaiseLocalEvent(gunUid, ref emptyGunShotEvent);
  291. // Play empty gun sounds if relevant
  292. // If they're firing an existing clip then don't play anything.
  293. if (shots > 0)
  294. {
  295. if (ev.Reason != null && Timing.IsFirstTimePredicted)
  296. {
  297. PopupSystem.PopupCursor(ev.Reason);
  298. }
  299. // Don't spam safety sounds at gun fire rate, play it at a reduced rate.
  300. // May cause prediction issues? Needs more tweaking
  301. gun.NextFire = TimeSpan.FromSeconds(Math.Max(lastFire.TotalSeconds + SafetyNextFire, gun.NextFire.TotalSeconds));
  302. Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
  303. return null;
  304. }
  305. return null;
  306. }
  307. // Shoot confirmed - sounds also played here in case it's invalid (e.g. cartridge already spent).
  308. var projectiles = Shoot(gunUid, gun, ev.Ammo, fromCoordinates, toCoordinates.Value, out var userImpulse, user, throwItems: attemptEv.ThrowItems, predictedProjectiles, userSession);
  309. var shotEv = new GunShotEvent(user, ev.Ammo);
  310. RaiseLocalEvent(gunUid, ref shotEv);
  311. if (userImpulse && TryComp<PhysicsComponent>(user, out var userPhysics))
  312. {
  313. if (_gravity.IsWeightless(user, userPhysics))
  314. CauseImpulse(fromCoordinates, toCoordinates.Value, user, userPhysics);
  315. }
  316. Dirty(gunUid, gun);
  317. return projectiles;
  318. }
  319. public void Shoot(
  320. EntityUid gunUid,
  321. GunComponent gun,
  322. EntityUid ammo,
  323. EntityCoordinates fromCoordinates,
  324. EntityCoordinates toCoordinates,
  325. out bool userImpulse,
  326. EntityUid? user = null,
  327. bool throwItems = false)
  328. {
  329. var shootable = EnsureShootable(ammo);
  330. Shoot(gunUid, gun, new List<(EntityUid? Entity, IShootable Shootable)>(1) { (ammo, shootable) }, fromCoordinates, toCoordinates, out userImpulse, user, throwItems);
  331. }
  332. /// <summary>
  333. /// Fires one or more projectiles or hitscan shots from a gun using the provided ammo, handling recoil, effects, and impact logic.
  334. /// </summary>
  335. /// <param name="gunUid">The entity UID of the gun firing.</param>
  336. /// <param name="gun">The gun component associated with the firing gun.</param>
  337. /// <param name="ammo">A list of ammo entities and their shootable components to be fired.</param>
  338. /// <param name="fromCoordinates">The origin coordinates of the shot.</param>
  339. /// <param name="toCoordinates">The target coordinates of the shot.</param>
  340. /// <param name="userImpulse">Set to true if the user should receive recoil impulse; false if not.</param>
  341. /// <param name="user">The entity UID of the user firing the gun, if any.</param>
  342. /// <param name="throwItems">If true, items are thrown instead of shot as projectiles.</param>
  343. /// <param name="predictedProjectiles">Optional list of predicted projectile indices for client-side prediction.</param>
  344. /// <param name="userSession">Optional session of the user for prediction purposes.</param>
  345. /// <returns>A list of entity UIDs representing the fired projectiles, or null if none were fired.</returns>
  346. public List<EntityUid>? Shoot(
  347. EntityUid gunUid,
  348. GunComponent gun,
  349. List<(EntityUid? Entity, IShootable Shootable)> ammo,
  350. EntityCoordinates fromCoordinates,
  351. EntityCoordinates toCoordinates,
  352. out bool userImpulse,
  353. EntityUid? user = null,
  354. bool throwItems = false,
  355. List<int>? predictedProjectiles = null,
  356. ICommonSession? userSession = null)
  357. {
  358. userImpulse = true;
  359. var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem);
  360. var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
  361. var mapDirection = toMap - fromMap.Position;
  362. var mapAngle = mapDirection.ToAngle();
  363. var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle());
  364. // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
  365. var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out var grid)
  366. ? fromCoordinates.WithEntityId(gridUid, EntityManager)
  367. : new EntityCoordinates(MapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
  368. // Update shot based on the recoil
  369. toMap = fromMap.Position + angle.ToVec() * mapDirection.Length();
  370. mapDirection = toMap - fromMap.Position;
  371. var gunVelocity = Physics.GetMapLinearVelocity(fromEnt);
  372. // I must be high because this was getting tripped even when true.
  373. // DebugTools.Assert(direction != Vector2.Zero);
  374. var shotProjectiles = new List<EntityUid>(ammo.Count);
  375. void MarkPredicted(EntityUid projectile, int index)
  376. {
  377. if (!GunPrediction)
  378. return;
  379. if (predictedProjectiles == null || userSession == null)
  380. return;
  381. if (predictedProjectiles.TryGetValue(index, out var predicted))
  382. {
  383. var comp = new PredictedProjectileServerComponent
  384. {
  385. Shooter = userSession,
  386. ClientId = predicted,
  387. ClientEnt = user,
  388. };
  389. AddComp(projectile, comp, true);
  390. Dirty(projectile, comp);
  391. }
  392. }
  393. foreach (var (ent, shootable) in ammo)
  394. {
  395. // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling
  396. if (throwItems && ent != null)
  397. {
  398. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  399. ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user);
  400. continue;
  401. }
  402. switch (shootable)
  403. {
  404. // Cartridge shoots something else
  405. case CartridgeAmmoComponent cartridge:
  406. if (!cartridge.Spent)
  407. {
  408. if (_netManager.IsServer || GunPrediction)
  409. {
  410. var uid = Spawn(cartridge.Prototype, fromEnt);
  411. shotProjectiles.Add(uid);
  412. CreateAndFireProjectiles(uid, cartridge);
  413. RaiseLocalEvent(ent!.Value, new AmmoShotEvent()
  414. {
  415. FiredProjectiles = shotProjectiles,
  416. });
  417. SetCartridgeSpent(ent.Value, cartridge, true);
  418. if (cartridge.DeleteOnSpawn &&
  419. (_netManager.IsServer || IsClientSide(ent.Value)))
  420. {
  421. Del(ent.Value);
  422. }
  423. }
  424. else
  425. {
  426. MuzzleFlash(gunUid, cartridge, mapDirection.ToAngle(), user);
  427. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  428. }
  429. }
  430. else
  431. {
  432. userImpulse = false;
  433. Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
  434. }
  435. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  436. // Something like ballistic might want to leave it in the container still
  437. if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value))
  438. EjectCartridge(ent.Value, angle);
  439. if (IsClientSide(ent!.Value))
  440. Del(ent.Value);
  441. else
  442. Dirty(ent!.Value, cartridge);
  443. break;
  444. // Ammo shoots itself
  445. case AmmoComponent newAmmo:
  446. if (_netManager.IsServer || GunPrediction)
  447. {
  448. shotProjectiles.Add(ent!.Value);
  449. CreateAndFireProjectiles(ent.Value, newAmmo);
  450. }
  451. else
  452. {
  453. MuzzleFlash(gunUid, newAmmo, mapDirection.ToAngle(), user);
  454. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  455. }
  456. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  457. if (IsClientSide(ent!.Value))
  458. Del(ent.Value);
  459. else if (_netManager.IsClient)
  460. RemoveShootable(ent.Value);
  461. MarkPredicted(ent!.Value, 0);
  462. break;
  463. case HitscanPrototype hitscan:
  464. EntityUid? lastHit = null;
  465. var from = fromMap;
  466. // can't use map coords above because funny FireEffects
  467. var fromEffect = fromCoordinates;
  468. var dir = mapDirection.Normalized();
  469. //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
  470. var lastUser = user ?? gunUid;
  471. if (hitscan.Reflective != ReflectType.None)
  472. {
  473. for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
  474. {
  475. var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
  476. var rayCastResults =
  477. Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
  478. if (!rayCastResults.Any())
  479. break;
  480. var result = rayCastResults[0];
  481. // Check if laser is shot from in a container
  482. if (!Containers.IsEntityOrParentInContainer(lastUser))
  483. {
  484. // Checks if the laser should pass over unless targeted by its user
  485. foreach (var collide in rayCastResults)
  486. {
  487. if (collide.HitEntity != gun.Target &&
  488. CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
  489. {
  490. continue;
  491. }
  492. result = collide;
  493. break;
  494. }
  495. }
  496. var hit = result.HitEntity;
  497. lastHit = hit;
  498. FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
  499. var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
  500. RaiseLocalEvent(hit, ref ev);
  501. if (!ev.Reflected)
  502. break;
  503. fromEffect = Transform(hit).Coordinates;
  504. from = fromEffect.ToMap(EntityManager, TransformSystem);
  505. dir = ev.Direction;
  506. lastUser = hit;
  507. }
  508. }
  509. if (lastHit != null)
  510. {
  511. var hitEntity = lastHit.Value;
  512. if (hitscan.StaminaDamage > 0f)
  513. _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user, immediate: true);
  514. var dmg = hitscan.Damage;
  515. var hitName = ToPrettyString(hitEntity);
  516. if (dmg != null)
  517. dmg = Damageable.TryChangeDamage(hitEntity, dmg, origin: user);
  518. // check null again, as TryChangeDamage returns modified damage values
  519. if (dmg != null)
  520. {
  521. if (!Deleted(hitEntity))
  522. {
  523. if (dmg.AnyPositive())
  524. {
  525. _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
  526. }
  527. // TODO get fallback position for playing hit sound.
  528. PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
  529. }
  530. if (user != null)
  531. {
  532. Logs.Add(LogType.HitScanHit,
  533. $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
  534. }
  535. else
  536. {
  537. Logs.Add(LogType.HitScanHit,
  538. $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
  539. }
  540. }
  541. }
  542. else
  543. {
  544. FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
  545. }
  546. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  547. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  548. break;
  549. default:
  550. throw new ArgumentOutOfRangeException();
  551. }
  552. }
  553. RaiseLocalEvent(gunUid, new AmmoShotEvent()
  554. {
  555. FiredProjectiles = shotProjectiles,
  556. });
  557. void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
  558. {
  559. predictedProjectiles ??= new List<int>();
  560. MarkPredicted(ammoEnt, 0);
  561. if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
  562. {
  563. var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
  564. RaiseLocalEvent(gunUid, ref spreadEvent);
  565. var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
  566. mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
  567. ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user);
  568. shotProjectiles.Add(ammoEnt);
  569. for (var i = 1; i < ammoSpreadComp.Count; i++)
  570. {
  571. var newuid = Spawn(ammoSpreadComp.Proto, fromEnt);
  572. ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user);
  573. shotProjectiles.Add(newuid);
  574. MarkPredicted(newuid, i + 1);
  575. }
  576. }
  577. else
  578. {
  579. ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user);
  580. shotProjectiles.Add(ammoEnt);
  581. }
  582. MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user);
  583. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  584. }
  585. return shotProjectiles;
  586. }
  587. private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction)
  588. {
  589. var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds;
  590. var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta);
  591. component.CurrentAngle = new Angle(newTheta);
  592. component.LastFire = component.NextFire;
  593. // Convert it so angle can go either side.
  594. long tick = Timing.CurTick.Value;
  595. tick = tick << 32;
  596. tick = tick | (uint)GetNetEntity(component.Owner).Id;
  597. Logger.Info(Timing.CurTick.ToString());
  598. var random = new Xoroshiro64S(tick).NextFloat(-0.5f, 0.5f);
  599. var spread = component.CurrentAngle.Theta * random;
  600. var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random);
  601. DebugTools.Assert(spread <= component.MaxAngleModified.Theta);
  602. return angle;
  603. }
  604. private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user)
  605. {
  606. if (gun.Target is { } target && !TerminatingOrDeleted(target))
  607. {
  608. var targeted = EnsureComp<TargetedProjectileComponent>(uid);
  609. targeted.Target = target;
  610. Dirty(uid, targeted);
  611. }
  612. // Do a throw
  613. if (!HasComp<ProjectileComponent>(uid))
  614. {
  615. RemoveShootable(uid);
  616. // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack.
  617. ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user);
  618. return;
  619. }
  620. ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified);
  621. }
  622. #region Hitscan effects
  623. private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle mapDirection, HitscanPrototype hitscan, EntityUid? hitEntity = null)
  624. {
  625. // Lord
  626. // Forgive me for the shitcode I am about to do
  627. // Effects tempt me not
  628. var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
  629. var gridUid = fromCoordinates.GetGridUid(EntityManager);
  630. var angle = mapDirection;
  631. // We'll get the effects relative to the grid / map of the firer
  632. // Look you could probably optimise this a bit with redundant transforms at this point.
  633. var xformQuery = GetEntityQuery<TransformComponent>();
  634. if (xformQuery.TryGetComponent(gridUid, out var gridXform))
  635. {
  636. var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform, xformQuery);
  637. fromCoordinates = new EntityCoordinates(gridUid.Value,
  638. Vector2.Transform(fromCoordinates.ToMapPos(EntityManager, TransformSystem), gridInvMatrix));
  639. // Use the fallback angle I guess?
  640. angle -= gridRot;
  641. }
  642. if (distance >= 1f)
  643. {
  644. if (hitscan.MuzzleFlash != null)
  645. {
  646. var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
  647. var netCoords = GetNetCoordinates(coords);
  648. sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
  649. }
  650. if (hitscan.TravelFlash != null)
  651. {
  652. var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
  653. var netCoords = GetNetCoordinates(coords);
  654. sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
  655. }
  656. }
  657. if (hitscan.ImpactFlash != null)
  658. {
  659. var coords = fromCoordinates.Offset(angle.ToVec() * distance);
  660. var netCoords = GetNetCoordinates(coords);
  661. sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
  662. }
  663. if (_netManager.IsServer && sprites.Count > 0)
  664. {
  665. RaiseNetworkEvent(new HitscanEvent
  666. {
  667. Sprites = sprites,
  668. }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
  669. }
  670. }
  671. #endregion
  672. /// <summary>
  673. /// Gets a linear spread of angles between start and end.
  674. /// </summary>
  675. /// <param name="start">Start angle in degrees</param>
  676. /// <param name="end">End angle in degrees</param>
  677. /// <param name="intervals">How many shots there are</param>
  678. private Angle[] LinearSpread(Angle start, Angle end, int intervals)
  679. {
  680. var angles = new Angle[intervals];
  681. DebugTools.Assert(intervals > 1);
  682. for (var i = 0; i <= intervals - 1; i++)
  683. {
  684. angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
  685. }
  686. return angles;
  687. }
  688. public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound, Filter? filter = null, EntityUid? projectile = null)
  689. {
  690. DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
  691. // Like projectiles and melee,
  692. // 1. Entity specific sound
  693. // 2. Ammo's sound
  694. // 3. Nothing
  695. if (_netManager.IsClient && HasComp<PredictedProjectileServerComponent>(projectile))
  696. return;
  697. filter ??= Filter.Pvs(otherEntity);
  698. var playedSound = false;
  699. if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
  700. {
  701. var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager);
  702. if (type != null &&
  703. rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true &&
  704. filter.Count > 0)
  705. {
  706. Audio.PlayEntity(damageSoundType, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
  707. playedSound = true;
  708. }
  709. else if (type != null &&
  710. rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true &&
  711. filter.Count > 0)
  712. {
  713. Audio.PlayEntity(damageSoundGroup, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
  714. playedSound = true;
  715. }
  716. }
  717. if (!playedSound && weaponSound != null && filter.Count > 0)
  718. {
  719. Audio.PlayEntity(weaponSound, filter, otherEntity, true);
  720. }
  721. }
  722. private void Recoil(EntityUid? user, Vector2 recoil, float recoilScalar)
  723. {
  724. if (_netManager.IsServer)
  725. return;
  726. if (!Timing.IsFirstTimePredicted || user == null || recoil == Vector2.Zero || recoilScalar == 0)
  727. return;
  728. _recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar);
  729. }
  730. public virtual void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid gunUid, EntityUid? user = null, float speed = 20f)
  731. {
  732. var physics = EnsureComp<PhysicsComponent>(uid);
  733. Physics.SetBodyStatus(uid, physics, BodyStatus.InAir);
  734. var targetMapVelocity = gunVelocity + direction.Normalized() * speed;
  735. var currentMapVelocity = Physics.GetMapLinearVelocity(uid, physics);
  736. var finalLinear = physics.LinearVelocity + targetMapVelocity - currentMapVelocity;
  737. Physics.SetLinearVelocity(uid, finalLinear, body: physics);
  738. var projectile = EnsureComp<ProjectileComponent>(uid);
  739. Projectiles.SetShooter(uid, projectile, user ?? gunUid);
  740. projectile.Weapon = gunUid;
  741. TransformSystem.SetWorldRotationNoLerp(uid, direction.ToWorldAngle());
  742. }
  743. public List<EntityUid>? ShootRequested(NetEntity netGun, NetCoordinates coordinates, NetEntity? target, List<int>? projectiles, ICommonSession session)
  744. {
  745. var user = session.AttachedEntity;
  746. if (user == null ||
  747. !_combatMode.IsInCombatMode(user) ||
  748. !TryGetGun(user.Value, out var ent, out var gun))
  749. {
  750. return null;
  751. }
  752. if (ent != GetEntity(netGun))
  753. return null;
  754. gun.ShootCoordinates = GetCoordinates(coordinates);
  755. gun.Target = GetEntity(target);
  756. return AttemptShoot(user.Value, ent, gun, projectiles, session);
  757. }
  758. protected abstract void Popup(string message, EntityUid? uid, EntityUid? user);
  759. /// <summary>
  760. /// Call this whenever the ammo count for a gun changes.
  761. /// </summary>
  762. protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) { }
  763. protected void SetCartridgeSpent(EntityUid uid, CartridgeAmmoComponent cartridge, bool spent)
  764. {
  765. if (cartridge.Spent != spent)
  766. Dirty(uid, cartridge);
  767. cartridge.Spent = spent;
  768. Appearance.SetData(uid, AmmoVisuals.Spent, spent);
  769. }
  770. /// <summary>
  771. /// Drops a single cartridge / shell
  772. /// </summary>
  773. protected void EjectCartridge(
  774. EntityUid entity,
  775. Angle? angle = null,
  776. bool playSound = true)
  777. {
  778. // TODO: Sound limit version.
  779. var offsetPos = Random.NextVector2(EjectOffset);
  780. var xform = Transform(entity);
  781. var coordinates = xform.Coordinates;
  782. coordinates = coordinates.Offset(offsetPos);
  783. TransformSystem.SetLocalRotation(xform, Random.NextAngle());
  784. TransformSystem.SetCoordinates(entity, xform, coordinates);
  785. // decides direction the casing ejects and only when not cycling
  786. if (angle != null)
  787. {
  788. Angle ejectAngle = angle.Value;
  789. ejectAngle += 3.7f; // 212 degrees; casings should eject slightly to the right and behind of a gun
  790. ThrowingSystem.TryThrow(entity, ejectAngle.ToVec().Normalized() / 100, 5f);
  791. }
  792. if (playSound && TryComp<CartridgeAmmoComponent>(entity, out var cartridge))
  793. {
  794. Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f));
  795. }
  796. }
  797. protected IShootable EnsureShootable(EntityUid uid)
  798. {
  799. if (TryComp<CartridgeAmmoComponent>(uid, out var cartridge))
  800. return cartridge;
  801. return EnsureComp<AmmoComponent>(uid);
  802. }
  803. protected void RemoveShootable(EntityUid uid)
  804. {
  805. RemCompDeferred<CartridgeAmmoComponent>(uid);
  806. RemCompDeferred<AmmoComponent>(uid);
  807. }
  808. protected void MuzzleFlash(EntityUid gun, AmmoComponent component, Angle worldAngle, EntityUid? user = null)
  809. {
  810. var attemptEv = new GunMuzzleFlashAttemptEvent();
  811. RaiseLocalEvent(gun, ref attemptEv);
  812. if (attemptEv.Cancelled)
  813. return;
  814. var sprite = component.MuzzleFlash;
  815. if (sprite == null)
  816. return;
  817. var ev = new MuzzleFlashEvent(GetNetEntity(gun), sprite, worldAngle);
  818. CreateEffect(gun, ev, gun, user);
  819. }
  820. public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, EntityUid user, PhysicsComponent userPhysics)
  821. {
  822. var fromMap = fromCoordinates.ToMapPos(EntityManager, TransformSystem);
  823. var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
  824. var shotDirection = (toMap - fromMap).Normalized();
  825. const float impulseStrength = 25.0f;
  826. var impulseVector = shotDirection * impulseStrength;
  827. Physics.ApplyLinearImpulse(user, -impulseVector, body: userPhysics);
  828. }
  829. public void RefreshModifiers(Entity<GunComponent?> gun)
  830. {
  831. if (!Resolve(gun, ref gun.Comp))
  832. return;
  833. var comp = gun.Comp;
  834. var ev = new GunRefreshModifiersEvent(
  835. (gun, comp),
  836. comp.SoundGunshot,
  837. comp.CameraRecoilScalar,
  838. comp.AngleIncrease,
  839. comp.AngleDecay,
  840. comp.MaxAngle,
  841. comp.MinAngle,
  842. comp.ShotsPerBurst,
  843. comp.FireRate,
  844. comp.ProjectileSpeed
  845. );
  846. RaiseLocalEvent(gun, ref ev);
  847. comp.SoundGunshotModified = ev.SoundGunshot;
  848. comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
  849. comp.AngleIncreaseModified = ev.AngleIncrease;
  850. comp.AngleDecayModified = ev.AngleDecay;
  851. comp.MaxAngleModified = ev.MaxAngle;
  852. comp.MinAngleModified = ev.MinAngle;
  853. comp.ShotsPerBurstModified = ev.ShotsPerBurst;
  854. comp.FireRateModified = ev.FireRate;
  855. comp.ProjectileSpeedModified = ev.ProjectileSpeed;
  856. Dirty(gun);
  857. }
  858. protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null, EntityUid? player = null);
  859. /// <summary>
  860. /// Used for animated effects on the client.
  861. /// </summary>
  862. [Serializable, NetSerializable]
  863. public sealed class HitscanEvent : EntityEventArgs
  864. {
  865. public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new();
  866. }
  867. }
  868. /// <summary>
  869. /// Raised directed on the gun before firing to see if the shot should go through.
  870. /// </summary>
  871. /// <remarks>
  872. /// Handling this in server exclusively will lead to mispredicts.
  873. /// </remarks>
  874. /// <param name="User">The user that attempted to fire this gun.</param>
  875. /// <param name="Cancelled">Set this to true if the shot should be cancelled.</param>
  876. /// <param name="ThrowItems">Set this to true if the ammo shouldn't actually be fired, just thrown.</param>
  877. [ByRefEvent]
  878. public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false);
  879. /// <summary>
  880. /// Raised directed on the gun after firing.
  881. /// </summary>
  882. /// <param name="User">The user that fired this gun.</param>
  883. [ByRefEvent]
  884. public record struct GunShotEvent(EntityUid User, List<(EntityUid? Uid, IShootable Shootable)> Ammo);
  885. public enum EffectLayers : byte
  886. {
  887. Unshaded,
  888. }
  889. [Serializable, NetSerializable]
  890. public enum AmmoVisuals : byte
  891. {
  892. Spent,
  893. AmmoCount,
  894. AmmoMax,
  895. HasAmmo, // used for generic visualizers. c# stuff can just check ammocount != 0
  896. MagLoaded,
  897. BoltClosed,
  898. }