ExplosionSystem.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  1. using System.Linq;
  2. using System.Numerics;
  3. using Content.Server.Administration.Logs;
  4. using Content.Server.Atmos.Components;
  5. using Content.Server.Chat.Managers;
  6. using Content.Server.NodeContainer.EntitySystems;
  7. using Content.Server.NPC.Pathfinding;
  8. using Content.Shared.Camera;
  9. using Content.Shared.CCVar;
  10. using Content.Shared.Damage;
  11. using Content.Shared.Database;
  12. using Content.Shared.Explosion;
  13. using Content.Shared.Explosion.Components;
  14. using Content.Shared.Explosion.EntitySystems;
  15. using Content.Shared.GameTicking;
  16. using Content.Shared.Inventory;
  17. using Content.Shared.Projectiles;
  18. using Content.Shared.Throwing;
  19. using Robust.Server.GameObjects;
  20. using Robust.Server.GameStates;
  21. using Robust.Server.Player;
  22. using Robust.Shared.Audio.Systems;
  23. using Robust.Shared.Configuration;
  24. using Robust.Shared.Map;
  25. using Robust.Shared.Physics.Components;
  26. using Robust.Shared.Player;
  27. using Robust.Shared.Prototypes;
  28. using Robust.Shared.Random;
  29. using Robust.Shared.Utility;
  30. namespace Content.Server.Explosion.EntitySystems;
  31. public sealed partial class ExplosionSystem : SharedExplosionSystem
  32. {
  33. [Dependency] private readonly IMapManager _mapManager = default!;
  34. [Dependency] private readonly IRobustRandom _robustRandom = default!;
  35. [Dependency] private readonly ITileDefinitionManager _tileDefinitionManager = default!;
  36. [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
  37. [Dependency] private readonly IConfigurationManager _cfg = default!;
  38. [Dependency] private readonly IPlayerManager _playerManager = default!;
  39. [Dependency] private readonly MapSystem _mapSystem = default!;
  40. [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
  41. [Dependency] private readonly DamageableSystem _damageableSystem = default!;
  42. [Dependency] private readonly NodeGroupSystem _nodeGroupSystem = default!;
  43. [Dependency] private readonly PathfindingSystem _pathfindingSystem = default!;
  44. [Dependency] private readonly SharedCameraRecoilSystem _recoilSystem = default!;
  45. [Dependency] private readonly IAdminLogManager _adminLogger = default!;
  46. [Dependency] private readonly IChatManager _chat = default!;
  47. [Dependency] private readonly ThrowingSystem _throwingSystem = default!;
  48. [Dependency] private readonly PvsOverrideSystem _pvsSys = default!;
  49. [Dependency] private readonly SharedAudioSystem _audio = default!;
  50. [Dependency] private readonly SharedTransformSystem _transformSystem = default!;
  51. [Dependency] private readonly SharedMapSystem _map = default!;
  52. private EntityQuery<FlammableComponent> _flammableQuery;
  53. private EntityQuery<PhysicsComponent> _physicsQuery;
  54. private EntityQuery<ProjectileComponent> _projectileQuery;
  55. /// <summary>
  56. /// "Tile-size" for space when there are no nearby grids to use as a reference.
  57. /// </summary>
  58. public const ushort DefaultTileSize = 1;
  59. public const int MaxExplosionAudioRange = 30;
  60. /// <summary>
  61. /// The "default" explosion prototype.
  62. /// </summary>
  63. /// <remarks>
  64. /// Generally components should specify an explosion prototype via a yaml datafield, so that the yaml-linter can
  65. /// find errors. However some components, like rogue arrows, or some commands like the admin-smite need to have
  66. /// a "default" option specified outside of yaml data-fields. Hence this const string.
  67. /// </remarks>
  68. [ValidatePrototypeId<ExplosionPrototype>]
  69. public const string DefaultExplosionPrototypeId = "Default";
  70. public override void Initialize()
  71. {
  72. base.Initialize();
  73. DebugTools.Assert(_prototypeManager.HasIndex<ExplosionPrototype>(DefaultExplosionPrototypeId));
  74. // handled in ExplosionSystem.GridMap.cs
  75. SubscribeLocalEvent<GridRemovalEvent>(OnGridRemoved);
  76. SubscribeLocalEvent<GridStartupEvent>(OnGridStartup);
  77. SubscribeLocalEvent<ExplosionResistanceComponent, GetExplosionResistanceEvent>(OnGetResistance);
  78. // as long as explosion-resistance mice are never added, this should be fine (otherwise a mouse-hat will transfer it's power to the wearer).
  79. SubscribeLocalEvent<ExplosionResistanceComponent, InventoryRelayedEvent<GetExplosionResistanceEvent>>(RelayedResistance);
  80. SubscribeLocalEvent<TileChangedEvent>(OnTileChanged);
  81. SubscribeLocalEvent<RoundRestartCleanupEvent>(OnReset);
  82. // Handled by ExplosionSystem.Processing.cs
  83. SubscribeLocalEvent<MapChangedEvent>(OnMapChanged);
  84. // handled in ExplosionSystemAirtight.cs
  85. SubscribeLocalEvent<AirtightComponent, DamageChangedEvent>(OnAirtightDamaged);
  86. SubscribeCvars();
  87. InitAirtightMap();
  88. InitVisuals();
  89. _flammableQuery = GetEntityQuery<FlammableComponent>();
  90. _physicsQuery = GetEntityQuery<PhysicsComponent>();
  91. _projectileQuery = GetEntityQuery<ProjectileComponent>();
  92. }
  93. private void OnReset(RoundRestartCleanupEvent ev)
  94. {
  95. _explosionQueue.Clear();
  96. _queuedExplosions.Clear();
  97. if (_activeExplosion != null)
  98. QueueDel(_activeExplosion.VisualEnt);
  99. _activeExplosion = null;
  100. _nodeGroupSystem.PauseUpdating = false;
  101. _pathfindingSystem.PauseUpdating = false;
  102. }
  103. public override void Shutdown()
  104. {
  105. base.Shutdown();
  106. _nodeGroupSystem.PauseUpdating = false;
  107. _pathfindingSystem.PauseUpdating = false;
  108. }
  109. private void RelayedResistance(EntityUid uid, ExplosionResistanceComponent component,
  110. InventoryRelayedEvent<GetExplosionResistanceEvent> args)
  111. {
  112. if (component.Worn)
  113. OnGetResistance(uid, component, ref args.Args);
  114. }
  115. private void OnGetResistance(EntityUid uid, ExplosionResistanceComponent component, ref GetExplosionResistanceEvent args)
  116. {
  117. args.DamageCoefficient *= component.DamageCoefficient;
  118. if (component.Modifiers.TryGetValue(args.ExplosionPrototype, out var modifier))
  119. args.DamageCoefficient *= modifier;
  120. }
  121. /// <inheritdoc/>
  122. public override void TriggerExplosive(EntityUid uid, ExplosiveComponent? explosive = null, bool delete = true, float? totalIntensity = null, float? radius = null, EntityUid? user = null)
  123. {
  124. // log missing: false, because some entities (e.g. liquid tanks) attempt to trigger explosions when damaged,
  125. // but may not actually be explosive.
  126. if (!Resolve(uid, ref explosive, logMissing: false))
  127. return;
  128. // No reusable explosions here.
  129. if (explosive.Exploded)
  130. return;
  131. explosive.Exploded = !explosive.Repeatable;
  132. // Override the explosion intensity if optional arguments were provided.
  133. if (radius != null)
  134. totalIntensity ??= RadiusToIntensity((float) radius, explosive.IntensitySlope, explosive.MaxIntensity);
  135. totalIntensity ??= explosive.TotalIntensity;
  136. QueueExplosion(uid,
  137. explosive.ExplosionType,
  138. (float) totalIntensity,
  139. explosive.IntensitySlope,
  140. explosive.MaxIntensity,
  141. explosive.TileBreakScale,
  142. explosive.MaxTileBreak,
  143. explosive.CanCreateVacuum,
  144. user);
  145. if (explosive.DeleteAfterExplosion ?? delete)
  146. EntityManager.QueueDeleteEntity(uid);
  147. }
  148. /// <summary>
  149. /// Find the strength needed to generate an explosion of a given radius. More useful for radii larger then 4, when the explosion becomes less "blocky".
  150. /// </summary>
  151. /// <remarks>
  152. /// This assumes the explosion is in a vacuum / unobstructed. Given that explosions are not perfectly
  153. /// circular, here radius actually means the sqrt(Area/pi), where the area is the total number of tiles
  154. /// covered by the explosion. Until you get to radius 30+, this is functionally equivalent to the
  155. /// actual radius.
  156. /// </remarks>
  157. public float RadiusToIntensity(float radius, float slope, float maxIntensity = 0)
  158. {
  159. // If you consider the intensity at each tile in an explosion to be a height. Then a circular explosion is
  160. // shaped like a cone. So total intensity is like the volume of a cone with height = slope * radius. Of
  161. // course, as the explosions are not perfectly circular, this formula isn't perfect, but the formula works
  162. // reasonably well.
  163. // This should actually use the formula for the volume of a distorted octagonal frustum. But this is good
  164. // enough.
  165. var coneVolume = slope * MathF.PI / 3 * MathF.Pow(radius, 3);
  166. if (maxIntensity <= 0 || slope * radius < maxIntensity)
  167. return coneVolume;
  168. // This explosion is limited by the maxIntensity.
  169. // Instead of a cone, we have a conical frustum.
  170. // Subtract the volume of the missing cone segment, with height:
  171. var h = slope * radius - maxIntensity;
  172. return coneVolume - h * MathF.PI / 3 * MathF.Pow(h / slope, 2);
  173. }
  174. /// <summary>
  175. /// Inverse formula for <see cref="RadiusToIntensity"/>
  176. /// </summary>
  177. public float IntensityToRadius(float totalIntensity, float slope, float maxIntensity)
  178. {
  179. // max radius to avoid being capped by max-intensity
  180. var r0 = maxIntensity / slope;
  181. // volume at r0
  182. var v0 = RadiusToIntensity(r0, slope);
  183. if (totalIntensity <= v0)
  184. {
  185. // maxIntensity is a non-issue, can use simple inverse formula
  186. return MathF.Cbrt(3 * totalIntensity / (slope * MathF.PI));
  187. }
  188. return r0 * (MathF.Sqrt(12 * totalIntensity / v0 - 3) / 6 + 0.5f);
  189. }
  190. /// <summary>
  191. /// Queue an explosions, centered on some entity.
  192. /// </summary>
  193. public void QueueExplosion(EntityUid uid,
  194. string typeId,
  195. float totalIntensity,
  196. float slope,
  197. float maxTileIntensity,
  198. float tileBreakScale = 1f,
  199. int maxTileBreak = int.MaxValue,
  200. bool canCreateVacuum = true,
  201. EntityUid? user = null,
  202. bool addLog = true)
  203. {
  204. var pos = Transform(uid);
  205. var mapPos = _transformSystem.GetMapCoordinates(pos);
  206. var posFound = _transformSystem.TryGetMapOrGridCoordinates(uid, out var gridPos, pos);
  207. QueueExplosion(mapPos, typeId, totalIntensity, slope, maxTileIntensity, uid, tileBreakScale, maxTileBreak, canCreateVacuum, addLog: false);
  208. if (!addLog)
  209. return;
  210. if (user == null)
  211. {
  212. _adminLogger.Add(LogType.Explosion, LogImpact.High,
  213. $"{ToPrettyString(uid):entity} exploded ({typeId}) at Pos:{(posFound ? $"{gridPos:coordinates}" : "[Grid or Map not found]")} with intensity {totalIntensity} slope {slope}");
  214. }
  215. else
  216. {
  217. _adminLogger.Add(LogType.Explosion, LogImpact.High,
  218. $"{ToPrettyString(user.Value):user} caused {ToPrettyString(uid):entity} to explode ({typeId}) at Pos:{(posFound ? $"{gridPos:coordinates}" : "[Grid or Map not found]")} with intensity {totalIntensity} slope {slope}");
  219. var alertMinExplosionIntensity = _cfg.GetCVar(CCVars.AdminAlertExplosionMinIntensity);
  220. if (alertMinExplosionIntensity > -1 && totalIntensity >= alertMinExplosionIntensity)
  221. _chat.SendAdminAlert(user.Value, $"caused {ToPrettyString(uid)} to explode ({typeId}:{totalIntensity}) at Pos:{(posFound ? $"{gridPos:coordinates}" : "[Grid or Map not found]")}");
  222. }
  223. }
  224. /// <summary>
  225. /// Queue an explosion, with a specified epicenter and set of starting tiles.
  226. /// </summary>
  227. public void QueueExplosion(MapCoordinates epicenter,
  228. string typeId,
  229. float totalIntensity,
  230. float slope,
  231. float maxTileIntensity,
  232. EntityUid? cause,
  233. float tileBreakScale = 1f,
  234. int maxTileBreak = int.MaxValue,
  235. bool canCreateVacuum = true,
  236. bool addLog = true)
  237. {
  238. if (totalIntensity <= 0 || slope <= 0)
  239. return;
  240. if (!_prototypeManager.TryIndex<ExplosionPrototype>(typeId, out var type))
  241. {
  242. Log.Error($"Attempted to spawn unknown explosion prototype: {type}");
  243. return;
  244. }
  245. if (addLog) // dont log if already created a separate, more detailed, log.
  246. _adminLogger.Add(LogType.Explosion, LogImpact.High, $"Explosion ({typeId}) spawned at {epicenter:coordinates} with intensity {totalIntensity} slope {slope}");
  247. // try to combine explosions on the same tile if they are the same type
  248. foreach (var queued in _queuedExplosions)
  249. {
  250. // ignore different types or those on different maps
  251. if (queued.Proto.ID != type.ID || queued.Epicenter.MapId != epicenter.MapId)
  252. continue;
  253. var dst2 = queued.Proto.MaxCombineDistance * queued.Proto.MaxCombineDistance;
  254. var direction = queued.Epicenter.Position - epicenter.Position;
  255. if (direction.LengthSquared() > dst2)
  256. continue;
  257. // they are close enough to combine so just add total intensity and prevent queuing another one
  258. queued.TotalIntensity += totalIntensity;
  259. return;
  260. }
  261. var boom = new QueuedExplosion()
  262. {
  263. Epicenter = epicenter,
  264. Proto = type,
  265. TotalIntensity = totalIntensity,
  266. Slope = slope,
  267. MaxTileIntensity = maxTileIntensity,
  268. TileBreakScale = tileBreakScale,
  269. MaxTileBreak = maxTileBreak,
  270. CanCreateVacuum = canCreateVacuum,
  271. Cause = cause
  272. };
  273. _explosionQueue.Enqueue(boom);
  274. _queuedExplosions.Add(boom);
  275. }
  276. /// <summary>
  277. /// This function actually spawns the explosion. It returns an <see cref="Explosion"/> instance with
  278. /// information about the affected tiles for the explosion system to process. It will also trigger the
  279. /// camera shake and sound effect.
  280. /// </summary>
  281. private Explosion? SpawnExplosion(QueuedExplosion queued)
  282. {
  283. var pos = queued.Epicenter;
  284. if (!_mapManager.MapExists(pos.MapId))
  285. return null;
  286. var results = GetExplosionTiles(pos, queued.Proto.ID, queued.TotalIntensity, queued.Slope, queued.MaxTileIntensity);
  287. if (results == null)
  288. return null;
  289. var (area, iterationIntensity, spaceData, gridData, spaceMatrix) = results.Value;
  290. var visualEnt = CreateExplosionVisualEntity(pos, queued.Proto.ID, spaceMatrix, spaceData, gridData.Values, iterationIntensity);
  291. // camera shake
  292. CameraShake(iterationIntensity.Count * 4f, pos, queued.TotalIntensity);
  293. //For whatever bloody reason, sound system requires ENTITY coordinates.
  294. var mapEntityCoords = _transformSystem.ToCoordinates(_mapSystem.GetMap(pos.MapId), pos);
  295. // play sound.
  296. // for the normal audio, we want everyone in pvs range
  297. // + if the bomb is big enough, people outside of it too
  298. // this is capped to 30 because otherwise really huge bombs
  299. // will attempt to play regular audio for people who can't hear it anyway because the epicenter is so far away
  300. //
  301. // TODO EXPLOSION redo this.
  302. // Use the Filter.Pvs range-multiplier option instead of AddInRange.
  303. // Also the default PVS range is 25*2 = 50. So capping it at 30 makes no sense here.
  304. // So actually maybe don't use Filter.Pvs at all and only use AddInRange?
  305. var audioRange = Math.Min(iterationIntensity.Count * 2, MaxExplosionAudioRange);
  306. var filter = Filter.Pvs(pos).AddInRange(pos, audioRange);
  307. var sound = iterationIntensity.Count < queued.Proto.SmallSoundIterationThreshold
  308. ? queued.Proto.SmallSound
  309. : queued.Proto.Sound;
  310. _audio.PlayStatic(sound, filter, mapEntityCoords, true, sound.Params);
  311. // play far sound
  312. // far sound should play for anyone who wasn't in range of any of the effects of the bomb
  313. var farAudioRange = iterationIntensity.Count * 5;
  314. var farFilter = Filter.Empty().AddInRange(pos, farAudioRange).RemoveInRange(pos, audioRange);
  315. var farSound = iterationIntensity.Count < queued.Proto.SmallSoundIterationThreshold
  316. ? queued.Proto.SmallSoundFar
  317. : queued.Proto.SoundFar;
  318. _audio.PlayGlobal(farSound, farFilter, true, farSound.Params);
  319. return new Explosion(this,
  320. queued.Proto,
  321. spaceData,
  322. gridData.Values.ToList(),
  323. iterationIntensity,
  324. pos,
  325. spaceMatrix,
  326. area,
  327. // TODO: instead of le copy paste fields refactor so it has QueuedExplosion as a field?
  328. queued.TileBreakScale,
  329. queued.MaxTileBreak,
  330. queued.CanCreateVacuum,
  331. EntityManager,
  332. _mapManager,
  333. visualEnt,
  334. queued.Cause,
  335. _map);
  336. }
  337. private void CameraShake(float range, MapCoordinates epicenter, float totalIntensity)
  338. {
  339. var players = Filter.Empty();
  340. players.AddInRange(epicenter, range, _playerManager, EntityManager);
  341. foreach (var player in players.Recipients)
  342. {
  343. if (player.AttachedEntity is not EntityUid uid)
  344. continue;
  345. var playerPos = _transformSystem.GetWorldPosition(player.AttachedEntity!.Value);
  346. var delta = epicenter.Position - playerPos;
  347. if (delta.EqualsApprox(Vector2.Zero))
  348. delta = new(0.01f, 0);
  349. var distance = delta.Length();
  350. var effect = 5 * MathF.Pow(totalIntensity, 0.5f) * (1 - distance / range);
  351. if (effect > 0.01f)
  352. _recoilSystem.KickCamera(uid, -delta.Normalized() * effect);
  353. }
  354. }
  355. }