GunSystem.cs 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. using System.Linq;
  2. using System.Numerics;
  3. using Content.Server.Cargo.Systems;
  4. using Content.Server.Power.EntitySystems;
  5. using Content.Server.Weapons.Ranged.Components;
  6. using Content.Shared.Damage;
  7. using Content.Shared.Damage.Systems;
  8. using Content.Shared.Database;
  9. using Content.Shared.Effects;
  10. using Content.Shared.Projectiles;
  11. using Content.Shared.Weapons.Melee;
  12. using Content.Shared.Weapons.Ranged;
  13. using Content.Shared.Weapons.Ranged.Components;
  14. using Content.Shared.Weapons.Ranged.Events;
  15. using Content.Shared.Weapons.Ranged.Systems;
  16. using Content.Shared.Weapons.Reflect;
  17. using Content.Shared.Damage.Components;
  18. using Robust.Shared.Audio;
  19. using Robust.Shared.Map;
  20. using Robust.Shared.Physics;
  21. using Robust.Shared.Player;
  22. using Robust.Shared.Prototypes;
  23. using Robust.Shared.Utility;
  24. using Robust.Shared.Containers;
  25. namespace Content.Server.Weapons.Ranged.Systems;
  26. public sealed partial class GunSystem : SharedGunSystem
  27. {
  28. [Dependency] private readonly IComponentFactory _factory = default!;
  29. [Dependency] private readonly BatterySystem _battery = default!;
  30. [Dependency] private readonly DamageExamineSystem _damageExamine = default!;
  31. [Dependency] private readonly PricingSystem _pricing = default!;
  32. [Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
  33. [Dependency] private readonly SharedTransformSystem _transform = default!;
  34. [Dependency] private readonly StaminaSystem _stamina = default!;
  35. [Dependency] private readonly SharedContainerSystem _container = default!;
  36. private const float DamagePitchVariation = 0.05f;
  37. public override void Initialize()
  38. {
  39. base.Initialize();
  40. SubscribeLocalEvent<BallisticAmmoProviderComponent, PriceCalculationEvent>(OnBallisticPrice);
  41. }
  42. private void OnBallisticPrice(EntityUid uid, BallisticAmmoProviderComponent component, ref PriceCalculationEvent args)
  43. {
  44. if (string.IsNullOrEmpty(component.Proto) || component.UnspawnedCount == 0)
  45. return;
  46. if (!ProtoManager.TryIndex<EntityPrototype>(component.Proto, out var proto))
  47. {
  48. Log.Error($"Unable to find fill prototype for price on {component.Proto} on {ToPrettyString(uid)}");
  49. return;
  50. }
  51. // Probably good enough for most.
  52. var price = _pricing.GetEstimatedPrice(proto);
  53. args.Price += price * component.UnspawnedCount;
  54. }
  55. public override void Shoot(EntityUid gunUid, GunComponent gun, List<(EntityUid? Entity, IShootable Shootable)> ammo,
  56. EntityCoordinates fromCoordinates, EntityCoordinates toCoordinates, out bool userImpulse, EntityUid? user = null, bool throwItems = false)
  57. {
  58. userImpulse = true;
  59. if (user != null)
  60. {
  61. var selfEvent = new SelfBeforeGunShotEvent(user.Value, (gunUid, gun), ammo);
  62. RaiseLocalEvent(user.Value, selfEvent);
  63. if (selfEvent.Cancelled)
  64. {
  65. userImpulse = false;
  66. return;
  67. }
  68. }
  69. var fromMap = fromCoordinates.ToMap(EntityManager, TransformSystem);
  70. var toMap = toCoordinates.ToMapPos(EntityManager, TransformSystem);
  71. var mapDirection = toMap - fromMap.Position;
  72. var mapAngle = mapDirection.ToAngle();
  73. var angle = GetRecoilAngle(Timing.CurTime, gun, mapDirection.ToAngle());
  74. // If applicable, this ensures the projectile is parented to grid on spawn, instead of the map.
  75. var fromEnt = MapManager.TryFindGridAt(fromMap, out var gridUid, out var grid)
  76. ? fromCoordinates.WithEntityId(gridUid, EntityManager)
  77. : new EntityCoordinates(MapManager.GetMapEntityId(fromMap.MapId), fromMap.Position);
  78. // Update shot based on the recoil
  79. toMap = fromMap.Position + angle.ToVec() * mapDirection.Length();
  80. mapDirection = toMap - fromMap.Position;
  81. var gunVelocity = Physics.GetMapLinearVelocity(fromEnt);
  82. // I must be high because this was getting tripped even when true.
  83. // DebugTools.Assert(direction != Vector2.Zero);
  84. var shotProjectiles = new List<EntityUid>(ammo.Count);
  85. foreach (var (ent, shootable) in ammo)
  86. {
  87. // pneumatic cannon doesn't shoot bullets it just throws them, ignore ammo handling
  88. if (throwItems && ent != null)
  89. {
  90. ShootOrThrow(ent.Value, mapDirection, gunVelocity, gun, gunUid, user);
  91. continue;
  92. }
  93. switch (shootable)
  94. {
  95. // Cartridge shoots something else
  96. case CartridgeAmmoComponent cartridge:
  97. if (!cartridge.Spent)
  98. {
  99. var uid = Spawn(cartridge.Prototype, fromEnt);
  100. CreateAndFireProjectiles(uid, cartridge);
  101. RaiseLocalEvent(ent!.Value, new AmmoShotEvent()
  102. {
  103. FiredProjectiles = shotProjectiles,
  104. });
  105. SetCartridgeSpent(ent.Value, cartridge, true);
  106. if (cartridge.DeleteOnSpawn)
  107. Del(ent.Value);
  108. }
  109. else
  110. {
  111. userImpulse = false;
  112. Audio.PlayPredicted(gun.SoundEmpty, gunUid, user);
  113. }
  114. // Something like ballistic might want to leave it in the container still
  115. if (!cartridge.DeleteOnSpawn && !Containers.IsEntityInContainer(ent!.Value))
  116. EjectCartridge(ent.Value, angle);
  117. Dirty(ent!.Value, cartridge);
  118. break;
  119. // Ammo shoots itself
  120. case AmmoComponent newAmmo:
  121. if (ent == null)
  122. break;
  123. CreateAndFireProjectiles(ent.Value, newAmmo);
  124. break;
  125. case HitscanPrototype hitscan:
  126. EntityUid? lastHit = null;
  127. var from = fromMap;
  128. // can't use map coords above because funny FireEffects
  129. var fromEffect = fromCoordinates;
  130. var dir = mapDirection.Normalized();
  131. //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
  132. var lastUser = user ?? gunUid;
  133. if (hitscan.Reflective != ReflectType.None)
  134. {
  135. for (var reflectAttempt = 0; reflectAttempt < 3; reflectAttempt++)
  136. {
  137. var ray = new CollisionRay(from.Position, dir, hitscan.CollisionMask);
  138. var rayCastResults =
  139. Physics.IntersectRay(from.MapId, ray, hitscan.MaxLength, lastUser, false).ToList();
  140. if (!rayCastResults.Any())
  141. break;
  142. var result = rayCastResults[0];
  143. // Check if laser is shot from in a container
  144. if (!_container.IsEntityOrParentInContainer(lastUser))
  145. {
  146. // Checks if the laser should pass over unless targeted by its user
  147. foreach (var collide in rayCastResults)
  148. {
  149. if (collide.HitEntity != gun.Target &&
  150. CompOrNull<RequireProjectileTargetComponent>(collide.HitEntity)?.Active == true)
  151. {
  152. continue;
  153. }
  154. result = collide;
  155. break;
  156. }
  157. }
  158. var hit = result.HitEntity;
  159. lastHit = hit;
  160. FireEffects(fromEffect, result.Distance, dir.Normalized().ToAngle(), hitscan, hit);
  161. var ev = new HitScanReflectAttemptEvent(user, gunUid, hitscan.Reflective, dir, false);
  162. RaiseLocalEvent(hit, ref ev);
  163. if (!ev.Reflected)
  164. break;
  165. fromEffect = Transform(hit).Coordinates;
  166. from = fromEffect.ToMap(EntityManager, _transform);
  167. dir = ev.Direction;
  168. lastUser = hit;
  169. }
  170. }
  171. if (lastHit != null)
  172. {
  173. var hitEntity = lastHit.Value;
  174. if (hitscan.StaminaDamage > 0f)
  175. _stamina.TakeStaminaDamage(hitEntity, hitscan.StaminaDamage, source: user);
  176. var dmg = hitscan.Damage;
  177. var hitName = ToPrettyString(hitEntity);
  178. if (dmg != null)
  179. dmg = Damageable.TryChangeDamage(hitEntity, dmg * Damageable.UniversalHitscanDamageModifier, origin: user);
  180. // check null again, as TryChangeDamage returns modified damage values
  181. if (dmg != null)
  182. {
  183. if (!Deleted(hitEntity))
  184. {
  185. if (dmg.AnyPositive())
  186. {
  187. _color.RaiseEffect(Color.Red, new List<EntityUid>() { hitEntity }, Filter.Pvs(hitEntity, entityManager: EntityManager));
  188. }
  189. // TODO get fallback position for playing hit sound.
  190. PlayImpactSound(hitEntity, dmg, hitscan.Sound, hitscan.ForceSound);
  191. }
  192. if (user != null)
  193. {
  194. Logs.Add(LogType.HitScanHit,
  195. $"{ToPrettyString(user.Value):user} hit {hitName:target} using hitscan and dealt {dmg.GetTotal():damage} damage");
  196. }
  197. else
  198. {
  199. Logs.Add(LogType.HitScanHit,
  200. $"{hitName:target} hit by hitscan dealing {dmg.GetTotal():damage} damage");
  201. }
  202. }
  203. }
  204. else
  205. {
  206. FireEffects(fromEffect, hitscan.MaxLength, dir.ToAngle(), hitscan);
  207. }
  208. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  209. break;
  210. default:
  211. throw new ArgumentOutOfRangeException();
  212. }
  213. }
  214. RaiseLocalEvent(gunUid, new AmmoShotEvent()
  215. {
  216. FiredProjectiles = shotProjectiles,
  217. });
  218. void CreateAndFireProjectiles(EntityUid ammoEnt, AmmoComponent ammoComp)
  219. {
  220. if (TryComp<ProjectileSpreadComponent>(ammoEnt, out var ammoSpreadComp))
  221. {
  222. var spreadEvent = new GunGetAmmoSpreadEvent(ammoSpreadComp.Spread);
  223. RaiseLocalEvent(gunUid, ref spreadEvent);
  224. var angles = LinearSpread(mapAngle - spreadEvent.Spread / 2,
  225. mapAngle + spreadEvent.Spread / 2, ammoSpreadComp.Count);
  226. ShootOrThrow(ammoEnt, angles[0].ToVec(), gunVelocity, gun, gunUid, user);
  227. shotProjectiles.Add(ammoEnt);
  228. for (var i = 1; i < ammoSpreadComp.Count; i++)
  229. {
  230. var newuid = Spawn(ammoSpreadComp.Proto, fromEnt);
  231. ShootOrThrow(newuid, angles[i].ToVec(), gunVelocity, gun, gunUid, user);
  232. shotProjectiles.Add(newuid);
  233. }
  234. }
  235. else
  236. {
  237. ShootOrThrow(ammoEnt, mapDirection, gunVelocity, gun, gunUid, user);
  238. shotProjectiles.Add(ammoEnt);
  239. }
  240. MuzzleFlash(gunUid, ammoComp, mapDirection.ToAngle(), user);
  241. Audio.PlayPredicted(gun.SoundGunshotModified, gunUid, user);
  242. }
  243. }
  244. private void ShootOrThrow(EntityUid uid, Vector2 mapDirection, Vector2 gunVelocity, GunComponent gun, EntityUid gunUid, EntityUid? user)
  245. {
  246. if (gun.Target is { } target && !TerminatingOrDeleted(target))
  247. {
  248. var targeted = EnsureComp<TargetedProjectileComponent>(uid);
  249. targeted.Target = target;
  250. Dirty(uid, targeted);
  251. }
  252. // Do a throw
  253. if (!HasComp<ProjectileComponent>(uid))
  254. {
  255. RemoveShootable(uid);
  256. // TODO: Someone can probably yeet this a billion miles so need to pre-validate input somewhere up the call stack.
  257. ThrowingSystem.TryThrow(uid, mapDirection, gun.ProjectileSpeedModified, user);
  258. return;
  259. }
  260. ShootProjectile(uid, mapDirection, gunVelocity, gunUid, user, gun.ProjectileSpeedModified);
  261. }
  262. /// <summary>
  263. /// Gets a linear spread of angles between start and end.
  264. /// </summary>
  265. /// <param name="start">Start angle in degrees</param>
  266. /// <param name="end">End angle in degrees</param>
  267. /// <param name="intervals">How many shots there are</param>
  268. private Angle[] LinearSpread(Angle start, Angle end, int intervals)
  269. {
  270. var angles = new Angle[intervals];
  271. DebugTools.Assert(intervals > 1);
  272. for (var i = 0; i <= intervals - 1; i++)
  273. {
  274. angles[i] = new Angle(start + (end - start) * i / (intervals - 1));
  275. }
  276. return angles;
  277. }
  278. private Angle GetRecoilAngle(TimeSpan curTime, GunComponent component, Angle direction)
  279. {
  280. var timeSinceLastFire = (curTime - component.LastFire).TotalSeconds;
  281. var newTheta = MathHelper.Clamp(component.CurrentAngle.Theta + component.AngleIncreaseModified.Theta - component.AngleDecayModified.Theta * timeSinceLastFire, component.MinAngleModified.Theta, component.MaxAngleModified.Theta);
  282. component.CurrentAngle = new Angle(newTheta);
  283. component.LastFire = component.NextFire;
  284. // Convert it so angle can go either side.
  285. var random = Random.NextFloat(-0.5f, 0.5f);
  286. var spread = component.CurrentAngle.Theta * random;
  287. var angle = new Angle(direction.Theta + component.CurrentAngle.Theta * random);
  288. DebugTools.Assert(spread <= component.MaxAngleModified.Theta);
  289. return angle;
  290. }
  291. protected override void Popup(string message, EntityUid? uid, EntityUid? user) { }
  292. protected override void CreateEffect(EntityUid gunUid, MuzzleFlashEvent message, EntityUid? user = null)
  293. {
  294. var filter = Filter.Pvs(gunUid, entityManager: EntityManager);
  295. if (TryComp<ActorComponent>(user, out var actor))
  296. filter.RemovePlayer(actor.PlayerSession);
  297. RaiseNetworkEvent(message, filter);
  298. }
  299. public void PlayImpactSound(EntityUid otherEntity, DamageSpecifier? modifiedDamage, SoundSpecifier? weaponSound, bool forceWeaponSound)
  300. {
  301. DebugTools.Assert(!Deleted(otherEntity), "Impact sound entity was deleted");
  302. // Like projectiles and melee,
  303. // 1. Entity specific sound
  304. // 2. Ammo's sound
  305. // 3. Nothing
  306. var playedSound = false;
  307. if (!forceWeaponSound && modifiedDamage != null && modifiedDamage.GetTotal() > 0 && TryComp<RangedDamageSoundComponent>(otherEntity, out var rangedSound))
  308. {
  309. var type = SharedMeleeWeaponSystem.GetHighestDamageSound(modifiedDamage, ProtoManager);
  310. if (type != null && rangedSound.SoundTypes?.TryGetValue(type, out var damageSoundType) == true)
  311. {
  312. Audio.PlayPvs(damageSoundType, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation));
  313. playedSound = true;
  314. }
  315. else if (type != null && rangedSound.SoundGroups?.TryGetValue(type, out var damageSoundGroup) == true)
  316. {
  317. Audio.PlayPvs(damageSoundGroup, otherEntity, AudioParams.Default.WithVariation(DamagePitchVariation));
  318. playedSound = true;
  319. }
  320. }
  321. if (!playedSound && weaponSound != null)
  322. {
  323. Audio.PlayPvs(weaponSound, otherEntity);
  324. }
  325. }
  326. // TODO: Pseudo RNG so the client can predict these.
  327. #region Hitscan effects
  328. private void FireEffects(EntityCoordinates fromCoordinates, float distance, Angle angle, HitscanPrototype hitscan, EntityUid? hitEntity = null)
  329. {
  330. // Lord
  331. // Forgive me for the shitcode I am about to do
  332. // Effects tempt me not
  333. var sprites = new List<(NetCoordinates coordinates, Angle angle, SpriteSpecifier sprite, float scale)>();
  334. var fromXform = Transform(fromCoordinates.EntityId);
  335. // We'll get the effects relative to the grid / map of the firer
  336. // Look you could probably optimise this a bit with redundant transforms at this point.
  337. var gridUid = fromXform.GridUid;
  338. if (gridUid != fromCoordinates.EntityId && TryComp(gridUid, out TransformComponent? gridXform))
  339. {
  340. var (_, gridRot, gridInvMatrix) = TransformSystem.GetWorldPositionRotationInvMatrix(gridXform);
  341. var map = _transform.ToMapCoordinates(fromCoordinates);
  342. fromCoordinates = new EntityCoordinates(gridUid.Value, Vector2.Transform(map.Position, gridInvMatrix));
  343. angle -= gridRot;
  344. }
  345. else
  346. {
  347. angle -= _transform.GetWorldRotation(fromXform);
  348. }
  349. if (distance >= 1f)
  350. {
  351. if (hitscan.MuzzleFlash != null)
  352. {
  353. var coords = fromCoordinates.Offset(angle.ToVec().Normalized() / 2);
  354. var netCoords = GetNetCoordinates(coords);
  355. sprites.Add((netCoords, angle, hitscan.MuzzleFlash, 1f));
  356. }
  357. if (hitscan.TravelFlash != null)
  358. {
  359. var coords = fromCoordinates.Offset(angle.ToVec() * (distance + 0.5f) / 2);
  360. var netCoords = GetNetCoordinates(coords);
  361. sprites.Add((netCoords, angle, hitscan.TravelFlash, distance - 1.5f));
  362. }
  363. }
  364. if (hitscan.ImpactFlash != null)
  365. {
  366. var coords = fromCoordinates.Offset(angle.ToVec() * distance);
  367. var netCoords = GetNetCoordinates(coords);
  368. sprites.Add((netCoords, angle.FlipPositive(), hitscan.ImpactFlash, 1f));
  369. }
  370. if (sprites.Count > 0)
  371. {
  372. RaiseNetworkEvent(new HitscanEvent
  373. {
  374. Sprites = sprites,
  375. }, Filter.Pvs(fromCoordinates, entityMan: EntityManager));
  376. }
  377. }
  378. #endregion
  379. }