SharedGunSystem.cs 40 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049
  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. public List<EntityUid>? Shoot(
  333. EntityUid gunUid,
  334. GunComponent gun,
  335. List<(EntityUid? Entity, IShootable Shootable)> ammo,
  336. EntityCoordinates fromCoordinates,
  337. EntityCoordinates toCoordinates,
  338. out bool userImpulse,
  339. EntityUid? user = null,
  340. bool throwItems = false,
  341. List<int>? predictedProjectiles = null,
  342. ICommonSession? userSession = null)
  343. {
  344. userImpulse = true;
  345. var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem);
  346. var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
  347. var mapDirection = toMap - fromMap.Position;
  348. var mapAngle = mapDirection.ToAngle();
  349. var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle());
  350. // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
  351. var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out var grid)
  352. ? fromCoordinates.WithEntityId(gridUid, EntityManager)
  353. : new EntityCoordinates(MapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
  354. // Update shot based on the recoil
  355. toMap = fromMap.Position + angle.ToVec() * mapDirection.Length();
  356. mapDirection = toMap - fromMap.Position;
  357. var gunVelocity = Physics.GetMapLinearVelocity(fromEnt);
  358. // I must be high because this was getting tripped even when true.
  359. // DebugTools.Assert(direction != Vector2.Zero);
  360. var shotProjectiles = new List<EntityUid>(ammo.Count);
  361. void MarkPredicted(EntityUid projectile, int index)
  362. {
  363. if (!GunPrediction)
  364. return;
  365. if (predictedProjectiles == null || userSession == null)
  366. return;
  367. if (predictedProjectiles.TryGetValue(index, out var predicted))
  368. {
  369. var comp = new PredictedProjectileServerComponent
  370. {
  371. Shooter = userSession,
  372. ClientId = predicted,
  373. ClientEnt = user,
  374. };
  375. AddComp(projectile, comp, true);
  376. Dirty(projectile, comp);
  377. }
  378. }
  379. foreach (var (ent, shootable) in ammo)
  380. {
  381. // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling
  382. if (throwItems && ent != null)
  383. {
  384. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  385. ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user);
  386. continue;
  387. }
  388. switch (shootable)
  389. {
  390. // Cartridge shoots something else
  391. case CartridgeAmmoComponent cartridge:
  392. if (!cartridge.Spent)
  393. {
  394. if (_netManager.IsServer || GunPrediction)
  395. {
  396. var uid = Spawn(cartridge.Prototype, fromEnt);
  397. shotProjectiles.Add(uid);
  398. CreateAndFireProjectiles(uid, cartridge);
  399. RaiseLocalEvent(ent!.Value, new AmmoShotEvent()
  400. {
  401. FiredProjectiles = shotProjectiles,
  402. });
  403. SetCartridgeSpent(ent.Value, cartridge, true);
  404. if (cartridge.DeleteOnSpawn &&
  405. (_netManager.IsServer || IsClientSide(ent.Value)))
  406. {
  407. Del(ent.Value);
  408. }
  409. }
  410. else
  411. {
  412. MuzzleFlash(gunUid, cartridge, mapDirection.ToAngle(), user);
  413. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  414. }
  415. }
  416. else
  417. {
  418. userImpulse = false;
  419. Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
  420. }
  421. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  422. // Something like ballistic might want to leave it in the container still
  423. if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value))
  424. EjectCartridge(ent.Value, angle);
  425. if (IsClientSide(ent!.Value))
  426. Del(ent.Value);
  427. else
  428. Dirty(ent!.Value, cartridge);
  429. break;
  430. // Ammo shoots itself
  431. case AmmoComponent newAmmo:
  432. if (_netManager.IsServer || GunPrediction)
  433. {
  434. shotProjectiles.Add(ent!.Value);
  435. CreateAndFireProjectiles(ent.Value, newAmmo);
  436. }
  437. else
  438. {
  439. MuzzleFlash(gunUid, newAmmo, mapDirection.ToAngle(), user);
  440. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  441. }
  442. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  443. if (IsClientSide(ent!.Value))
  444. Del(ent.Value);
  445. else if (_netManager.IsClient)
  446. RemoveShootable(ent.Value);
  447. MarkPredicted(ent!.Value, 0);
  448. break;
  449. case HitscanPrototype hitscan:
  450. EntityUid? lastHit = null;
  451. var from = fromMap;
  452. // can't use map coords above because funny FireEffects
  453. var fromEffect = fromCoordinates;
  454. var dir = mapDirection.Normalized();
  455. //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
  456. var lastUser = user ?? gunUid;
  457. if (hitscan.Reflective != ReflectType.None)
  458. {
  459. for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
  460. {
  461. var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
  462. var rayCastResults =
  463. Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
  464. if (!rayCastResults.Any())
  465. break;
  466. var result = rayCastResults[0];
  467. // Check if laser is shot from in a container
  468. if (!Containers.IsEntityOrParentInContainer(lastUser))
  469. {
  470. // Checks if the laser should pass over unless targeted by its user
  471. foreach (var collide in rayCastResults)
  472. {
  473. if (collide.HitEntity != gun.Target &&
  474. CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
  475. {
  476. continue;
  477. }
  478. result = collide;
  479. break;
  480. }
  481. }
  482. var hit = result.HitEntity;
  483. lastHit = hit;
  484. FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
  485. var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
  486. RaiseLocalEvent(hit, ref ev);
  487. if (!ev.Reflected)
  488. break;
  489. fromEffect = Transform(hit).Coordinates;
  490. from = fromEffect.ToMap(EntityManager, TransformSystem);
  491. dir = ev.Direction;
  492. lastUser = hit;
  493. }
  494. }
  495. if (lastHit != null)
  496. {
  497. var hitEntity = lastHit.Value;
  498. if (hitscan.StaminaDamage > 0f)
  499. _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user);
  500. var dmg = hitscan.Damage;
  501. var hitName = ToPrettyString(hitEntity);
  502. if (dmg != null)
  503. dmg = Damageable.TryChangeDamage(hitEntity, dmg, origin: user);
  504. // check null again, as TryChangeDamage returns modified damage values
  505. if (dmg != null)
  506. {
  507. if (!Deleted(hitEntity))
  508. {
  509. if (dmg.AnyPositive())
  510. {
  511. _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
  512. }
  513. // TODO get fallback position for playing hit sound.
  514. PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
  515. }
  516. if (user != null)
  517. {
  518. Logs.Add(LogType.HitScanHit,
  519. $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
  520. }
  521. else
  522. {
  523. Logs.Add(LogType.HitScanHit,
  524. $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
  525. }
  526. }
  527. }
  528. else
  529. {
  530. FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
  531. }
  532. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  533. Recoil(user, mapDirection, gun.CameraRecoilScalarModified);
  534. break;
  535. default:
  536. throw new ArgumentOutOfRangeException();
  537. }
  538. }
  539. RaiseLocalEvent(gunUid, new AmmoShotEvent()
  540. {
  541. FiredProjectiles = shotProjectiles,
  542. });
  543. void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
  544. {
  545. predictedProjectiles ??= new List<int>();
  546. MarkPredicted(ammoEnt, 0);
  547. if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
  548. {
  549. var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
  550. RaiseLocalEvent(gunUid, ref spreadEvent);
  551. var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
  552. mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
  553. ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user);
  554. shotProjectiles.Add(ammoEnt);
  555. for (var i = 1; i < ammoSpreadComp.Count; i++)
  556. {
  557. var newuid = Spawn(ammoSpreadComp.Proto, fromEnt);
  558. ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user);
  559. shotProjectiles.Add(newuid);
  560. MarkPredicted(newuid, i + 1);
  561. }
  562. }
  563. else
  564. {
  565. ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user);
  566. shotProjectiles.Add(ammoEnt);
  567. }
  568. MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user);
  569. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  570. }
  571. return shotProjectiles;
  572. }
  573. private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction)
  574. {
  575. var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds;
  576. var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta);
  577. component.CurrentAngle = new Angle(newTheta);
  578. component.LastFire = component.NextFire;
  579. // Convert it so angle can go either side.
  580. long tick = Timing.CurTick.Value;
  581. tick = tick << 32;
  582. tick = tick | (uint)GetNetEntity(component.Owner).Id;
  583. Logger.Info(Timing.CurTick.ToString());
  584. var random = new Xoroshiro64S(tick).NextFloat(-0.5f, 0.5f);
  585. var spread = component.CurrentAngle.Theta * random;
  586. var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random);
  587. DebugTools.Assert(spread <= component.MaxAngleModified.Theta);
  588. return angle;
  589. }
  590. private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user)
  591. {
  592. if (gun.Target is { } target && !TerminatingOrDeleted(target))
  593. {
  594. var targeted = EnsureComp<TargetedProjectileComponent>(uid);
  595. targeted.Target = target;
  596. Dirty(uid, targeted);
  597. }
  598. // Do a throw
  599. if (!HasComp<ProjectileComponent>(uid))
  600. {
  601. RemoveShootable(uid);
  602. // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack.
  603. ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user);
  604. return;
  605. }
  606. ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified);
  607. }
  608. #region Hitscan effects
  609. private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle mapDirection, HitscanPrototype hitscan, EntityUid? hitEntity = null)
  610. {
  611. // Lord
  612. // Forgive me for the shitcode I am about to do
  613. // Effects tempt me not
  614. var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
  615. var gridUid = fromCoordinates.GetGridUid(EntityManager);
  616. var angle = mapDirection;
  617. // We'll get the effects relative to the grid / map of the firer
  618. // Look you could probably optimise this a bit with redundant transforms at this point.
  619. var xformQuery = GetEntityQuery<TransformComponent>();
  620. if (xformQuery.TryGetComponent(gridUid, out var gridXform))
  621. {
  622. var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform, xformQuery);
  623. fromCoordinates = new EntityCoordinates(gridUid.Value,
  624. Vector2.Transform(fromCoordinates.ToMapPos(EntityManager, TransformSystem), gridInvMatrix));
  625. // Use the fallback angle I guess?
  626. angle -= gridRot;
  627. }
  628. if (distance >= 1f)
  629. {
  630. if (hitscan.MuzzleFlash != null)
  631. {
  632. var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
  633. var netCoords = GetNetCoordinates(coords);
  634. sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
  635. }
  636. if (hitscan.TravelFlash != null)
  637. {
  638. var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
  639. var netCoords = GetNetCoordinates(coords);
  640. sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
  641. }
  642. }
  643. if (hitscan.ImpactFlash != null)
  644. {
  645. var coords = fromCoordinates.Offset(angle.ToVec() * distance);
  646. var netCoords = GetNetCoordinates(coords);
  647. sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
  648. }
  649. if (_netManager.IsServer && sprites.Count > 0)
  650. {
  651. RaiseNetworkEvent(new HitscanEvent
  652. {
  653. Sprites = sprites,
  654. }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
  655. }
  656. }
  657. #endregion
  658. /// <summary>
  659. /// Gets a linear spread of angles between start and end.
  660. /// </summary>
  661. /// <param name="start">Start angle in degrees</param>
  662. /// <param name="end">End angle in degrees</param>
  663. /// <param name="intervals">How many shots there are</param>
  664. private Angle[] LinearSpread(Angle start, Angle end, int intervals)
  665. {
  666. var angles = new Angle[intervals];
  667. DebugTools.Assert(intervals > 1);
  668. for (var i = 0; i <= intervals - 1; i++)
  669. {
  670. angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
  671. }
  672. return angles;
  673. }
  674. public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound, Filter? filter = null, EntityUid? projectile = null)
  675. {
  676. DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
  677. // Like projectiles and melee,
  678. // 1. Entity specific sound
  679. // 2. Ammo's sound
  680. // 3. Nothing
  681. if (_netManager.IsClient && HasComp<PredictedProjectileServerComponent>(projectile))
  682. return;
  683. filter ??= Filter.Pvs(otherEntity);
  684. var playedSound = false;
  685. if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
  686. {
  687. var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager);
  688. if (type != null &&
  689. rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true &&
  690. filter.Count > 0)
  691. {
  692. Audio.PlayEntity(damageSoundType, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
  693. playedSound = true;
  694. }
  695. else if (type != null &&
  696. rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true &&
  697. filter.Count > 0)
  698. {
  699. Audio.PlayEntity(damageSoundGroup, filter, otherEntity, true, AudioParams.Default.WithVariation(DamagePitchVariation));
  700. playedSound = true;
  701. }
  702. }
  703. if (!playedSound && weaponSound != null && filter.Count > 0)
  704. {
  705. Audio.PlayEntity(weaponSound, filter, otherEntity, true);
  706. }
  707. }
  708. private void Recoil(EntityUid? user, Vector2 recoil, float recoilScalar)
  709. {
  710. if (_netManager.IsServer)
  711. return;
  712. if (!Timing.IsFirstTimePredicted || user == null || recoil == Vector2.Zero || recoilScalar == 0)
  713. return;
  714. _recoil.KickCamera(user.Value, recoil.Normalized() * 0.5f * recoilScalar);
  715. }
  716. public virtual void ShootProjectile(EntityUid uid, Vector2 direction, Vector2 gunVelocity, EntityUid gunUid, EntityUid? user = null, float speed = 20f)
  717. {
  718. var physics = EnsureComp<PhysicsComponent>(uid);
  719. Physics.SetBodyStatus(uid, physics, BodyStatus.InAir);
  720. var targetMapVelocity = gunVelocity + direction.Normalized() * speed;
  721. var currentMapVelocity = Physics.GetMapLinearVelocity(uid, physics);
  722. var finalLinear = physics.LinearVelocity + targetMapVelocity - currentMapVelocity;
  723. Physics.SetLinearVelocity(uid, finalLinear, body: physics);
  724. var projectile = EnsureComp<ProjectileComponent>(uid);
  725. Projectiles.SetShooter(uid, projectile, user ?? gunUid);
  726. projectile.Weapon = gunUid;
  727. TransformSystem.SetWorldRotationNoLerp(uid, direction.ToWorldAngle());
  728. }
  729. public List<EntityUid>? ShootRequested(NetEntity netGun, NetCoordinates coordinates, NetEntity? target, List<int>? projectiles, ICommonSession session)
  730. {
  731. var user = session.AttachedEntity;
  732. if (user == null ||
  733. !_combatMode.IsInCombatMode(user) ||
  734. !TryGetGun(user.Value, out var ent, out var gun))
  735. {
  736. return null;
  737. }
  738. if (ent != GetEntity(netGun))
  739. return null;
  740. gun.ShootCoordinates = GetCoordinates(coordinates);
  741. gun.Target = GetEntity(target);
  742. return AttemptShoot(user.Value, ent, gun, projectiles, session);
  743. }
  744. protected abstract void Popup(string message, EntityUid? uid, EntityUid? user);
  745. /// <summary>
  746. /// Call this whenever the ammo count for a gun changes.
  747. /// </summary>
  748. protected virtual void UpdateAmmoCount(EntityUid uid, bool prediction = true) { }
  749. protected void SetCartridgeSpent(EntityUid uid, CartridgeAmmoComponent cartridge, bool spent)
  750. {
  751. if (cartridge.Spent != spent)
  752. Dirty(uid, cartridge);
  753. cartridge.Spent = spent;
  754. Appearance.SetData(uid, AmmoVisuals.Spent, spent);
  755. }
  756. /// <summary>
  757. /// Drops a single cartridge / shell
  758. /// </summary>
  759. protected void EjectCartridge(
  760. EntityUid entity,
  761. Angle? angle = null,
  762. bool playSound = true)
  763. {
  764. // TODO: Sound limit version.
  765. var offsetPos = Random.NextVector2(EjectOffset);
  766. var xform = Transform(entity);
  767. var coordinates = xform.Coordinates;
  768. coordinates = coordinates.Offset(offsetPos);
  769. TransformSystem.SetLocalRotation(xform, Random.NextAngle());
  770. TransformSystem.SetCoordinates(entity, xform, coordinates);
  771. // decides direction the casing ejects and only when not cycling
  772. if (angle != null)
  773. {
  774. Angle ejectAngle = angle.Value;
  775. ejectAngle += 3.7f; // 212 degrees; casings should eject slightly to the right and behind of a gun
  776. ThrowingSystem.TryThrow(entity, ejectAngle.ToVec().Normalized() / 100, 5f);
  777. }
  778. if (playSound && TryComp<CartridgeAmmoComponent>(entity, out var cartridge))
  779. {
  780. Audio.PlayPvs(cartridge.EjectSound, entity, AudioParams.Default.WithVariation(SharedContentAudioSystem.DefaultVariation).WithVolume(-1f));
  781. }
  782. }
  783. protected IShootable EnsureShootable(EntityUid uid)
  784. {
  785. if (TryComp<CartridgeAmmoComponent>(uid, out var cartridge))
  786. return cartridge;
  787. return EnsureComp<AmmoComponent>(uid);
  788. }
  789. protected void RemoveShootable(EntityUid uid)
  790. {
  791. RemCompDeferred<CartridgeAmmoComponent>(uid);
  792. RemCompDeferred<AmmoComponent>(uid);
  793. }
  794. protected void MuzzleFlash(EntityUid gun, AmmoComponent component, Angle worldAngle, EntityUid? user = null)
  795. {
  796. var attemptEv = new GunMuzzleFlashAttemptEvent();
  797. RaiseLocalEvent(gun, ref attemptEv);
  798. if (attemptEv.Cancelled)
  799. return;
  800. var sprite = component.MuzzleFlash;
  801. if (sprite == null)
  802. return;
  803. var ev = new MuzzleFlashEvent(GetNetEntity(gun), sprite, worldAngle);
  804. CreateEffect(gun, ev, gun, user);
  805. }
  806. public void CauseImpulse(EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, EntityUid user, PhysicsComponent userPhysics)
  807. {
  808. var fromMap = fromCoordinates.ToMapPos(EntityManager, TransformSystem);
  809. var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
  810. var shotDirection = (toMap - fromMap).Normalized();
  811. const float impulseStrength = 25.0f;
  812. var impulseVector = shotDirection * impulseStrength;
  813. Physics.ApplyLinearImpulse(user, -impulseVector, body: userPhysics);
  814. }
  815. public void RefreshModifiers(Entity<GunComponent?> gun)
  816. {
  817. if (!Resolve(gun, ref gun.Comp))
  818. return;
  819. var comp = gun.Comp;
  820. var ev = new GunRefreshModifiersEvent(
  821. (gun, comp),
  822. comp.SoundGunshot,
  823. comp.CameraRecoilScalar,
  824. comp.AngleIncrease,
  825. comp.AngleDecay,
  826. comp.MaxAngle,
  827. comp.MinAngle,
  828. comp.ShotsPerBurst,
  829. comp.FireRate,
  830. comp.ProjectileSpeed
  831. );
  832. RaiseLocalEvent(gun, ref ev);
  833. comp.SoundGunshotModified = ev.SoundGunshot;
  834. comp.CameraRecoilScalarModified = ev.CameraRecoilScalar;
  835. comp.AngleIncreaseModified = ev.AngleIncrease;
  836. comp.AngleDecayModified = ev.AngleDecay;
  837. comp.MaxAngleModified = ev.MaxAngle;
  838. comp.MinAngleModified = ev.MinAngle;
  839. comp.ShotsPerBurstModified = ev.ShotsPerBurst;
  840. comp.FireRateModified = ev.FireRate;
  841. comp.ProjectileSpeedModified = ev.ProjectileSpeed;
  842. Dirty(gun);
  843. }
  844. protected abstract void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null, EntityUid? player = null);
  845. /// <summary>
  846. /// Used for animated effects on the client.
  847. /// </summary>
  848. [Serializable, NetSerializable]
  849. public sealed class HitscanEvent : EntityEventArgs
  850. {
  851. public List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier Sprite, float Distance)> Sprites = new();
  852. }
  853. }
  854. /// <summary>
  855. /// Raised directed on the gun before firing to see if the shot should go through.
  856. /// </summary>
  857. /// <remarks>
  858. /// Handling this in server exclusively will lead to mispredicts.
  859. /// </remarks>
  860. /// <param name="User">The user that attempted to fire this gun.</param>
  861. /// <param name="Cancelled">Set this to true if the shot should be cancelled.</param>
  862. /// <param name="ThrowItems">Set this to true if the ammo shouldn't actually be fired, just thrown.</param>
  863. [ByRefEvent]
  864. public record struct AttemptShootEvent(EntityUid User, string? Message, bool Cancelled = false, bool ThrowItems = false);
  865. /// <summary>
  866. /// Raised directed on the gun after firing.
  867. /// </summary>
  868. /// <param name="User">The user that fired this gun.</param>
  869. [ByRefEvent]
  870. public record struct GunShotEvent(EntityUid User, List<(EntityUid? Uid, IShootable Shootable)> Ammo);
  871. public enum EffectLayers : byte
  872. {
  873. Unshaded,
  874. }
  875. [Serializable, NetSerializable]
  876. public enum AmmoVisuals : byte
  877. {
  878. Spent,
  879. AmmoCount,
  880. AmmoMax,
  881. HasAmmo, // used for generic visualizers. c# stuff can just check ammocount != 0
  882. MagLoaded,
  883. BoltClosed,
  884. }