using System; using System.Collections.Generic; using System.Linq; using Content.Server.Administration.Logs; using Content.Server.Camera; using Content.Server.Explosion.Components; using Content.Shared.Acts; using Content.Shared.Administration.Logs; using Content.Shared.Database; using Content.Shared.Interaction.Helpers; using Content.Shared.Maps; using Content.Shared.Physics; using Content.Shared.Sound; using Content.Shared.Tag; using Robust.Server.GameObjects; using Robust.Shared.Audio; using Robust.Shared.Containers; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Map; using Robust.Shared.Maths; using Robust.Shared.Player; using Robust.Shared.Random; using Robust.Shared.Timing; namespace Content.Server.Explosion.EntitySystems { public class ExplosionSystem : EntitySystem { /// /// Distance used for camera shake when distance from explosion is (0.0, 0.0). /// Avoids getting NaN values down the line from doing math on (0.0, 0.0). /// private static readonly Vector2 EpicenterDistance = (0.1f, 0.1f); /// /// Chance of a tile breaking if the severity is Light and Heavy /// private const float LightBreakChance = 0.3f; private const float HeavyBreakChance = 0.8f; // TODO move this to the component private static readonly SoundSpecifier ExplosionSound = new SoundCollectionSpecifier("explosion"); [Dependency] private readonly IEntityLookup _entityLookup = default!; [Dependency] private readonly IGameTiming _timing = default!; [Dependency] private readonly IMapManager _maps = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly ITileDefinitionManager _tiles = default!; [Dependency] private readonly ActSystem _acts = default!; [Dependency] private readonly EffectSystem _effects = default!; [Dependency] private readonly TriggerSystem _triggers = default!; [Dependency] private readonly AdminLogSystem _logSystem = default!; private bool IgnoreExplosivePassable(IEntity e) { return e.HasTag("ExplosivePassable"); } private ExplosionSeverity CalculateSeverity(float distance, float devastationRange, float heavyRange) { if (distance < devastationRange) { return ExplosionSeverity.Destruction; } else if (distance < heavyRange) { return ExplosionSeverity.Heavy; } else { return ExplosionSeverity.Light; } } private void CameraShakeInRange(EntityCoordinates epicenter, float maxRange) { var players = Filter.Empty() .AddInRange(epicenter.ToMap(EntityManager), MathF.Ceiling(maxRange)) .Recipients; foreach (var player in players) { if (player.AttachedEntity == null || !IoCManager.Resolve().TryGetComponent(player.AttachedEntity, out CameraRecoilComponent? recoil)) { continue; } var playerPos = IoCManager.Resolve().GetComponent(player.AttachedEntity).WorldPosition; var delta = epicenter.ToMapPos(EntityManager) - playerPos; //Change if zero. Will result in a NaN later breaking camera shake if not changed if (delta.EqualsApprox((0.0f, 0.0f))) delta = EpicenterDistance; var distance = delta.LengthSquared; var effect = 10 * (1 / (1 + distance)); if (effect > 0.01f) { var kick = -delta.Normalized * effect; recoil.Kick(kick); } } } /// /// Damage entities inside the range. The damage depends on a discrete /// damage bracket [light, heavy, devastation] and the distance from the epicenter /// /// /// A dictionary of coordinates relative to the parents of every grid of entities that survived the explosion, /// have an airtight component and are currently blocking air. Like a wall. /// private void DamageEntitiesInRange( EntityCoordinates epicenter, Box2 boundingBox, float devastationRange, float heavyRange, float maxRange, MapId mapId) { var entitiesInRange = _entityLookup.GetEntitiesInRange(mapId, boundingBox, 0).ToList(); var impassableEntities = new List<(IEntity, float)>(); var nonImpassableEntities = new List<(IEntity, float)>(); // TODO: Given this seems to rely on physics it should just query directly like everything else. // The entities are paired with their distance to the epicenter // and splitted into two lists based on if they are Impassable or not foreach (var entity in entitiesInRange) { if ((!IoCManager.Resolve().EntityExists(entity) ? EntityLifeStage.Deleted : IoCManager.Resolve().GetComponent(entity).EntityLifeStage) >= EntityLifeStage.Deleted || entity.IsInContainer()) { continue; } if (!IoCManager.Resolve().GetComponent(entity).Coordinates.TryDistance(EntityManager, epicenter, out var distance) || distance > maxRange) { continue; } if (!IoCManager.Resolve().TryGetComponent(entity, out PhysicsComponent? body) || body.Fixtures.Count < 1) { continue; } if ((body.CollisionLayer & (int) CollisionGroup.Impassable) != 0) { impassableEntities.Add((entity, distance)); } else { nonImpassableEntities.Add((entity, distance)); } } // The Impassable entities are sorted in descending order // Entities closer to the epicenter are first impassableEntities.Sort((x, y) => x.Item2.CompareTo(y.Item2)); // Impassable entities are handled first. If they are damaged enough, they are destroyed and they may // be able to spawn a new entity. I.e Wall -> Girder. // Girder has a tag ExplosivePassable, and the predicate make it so the entities with this tag are ignored var epicenterMapPos = epicenter.ToMap(EntityManager); foreach (var (entity, distance) in impassableEntities) { if (!entity.InRangeUnobstructed(epicenterMapPos, maxRange, ignoreInsideBlocker: true, predicate: IgnoreExplosivePassable)) { continue; } _acts.HandleExplosion(epicenter, entity, CalculateSeverity(distance, devastationRange, heavyRange)); } // Impassable entities were handled first so NonImpassable entities have a bigger chance to get hit. As now // there are probably more ExplosivePassable entities around foreach (var (entity, distance) in nonImpassableEntities) { if (!entity.InRangeUnobstructed(epicenterMapPos, maxRange, ignoreInsideBlocker: true, predicate: IgnoreExplosivePassable)) { continue; } _acts.HandleExplosion(epicenter, entity, CalculateSeverity(distance, devastationRange, heavyRange)); } } /// /// Damage tiles inside the range. The type of tile can change depending on a discrete /// damage bracket [light, heavy, devastation], the distance from the epicenter and /// a probability bracket [, , 1.0]. /// /// private void DamageTilesInRange(EntityCoordinates epicenter, GridId gridId, Box2 boundingBox, float devastationRange, float heaveyRange, float maxRange) { if (!_maps.TryGetGrid(gridId, out var mapGrid)) { return; } if (!EntityManager.EntityExists(mapGrid.GridEntityId)) { return; } var tilesInGridAndCircle = mapGrid.GetTilesIntersecting(boundingBox); var epicenterMapPos = epicenter.ToMap(EntityManager); foreach (var tile in tilesInGridAndCircle) { var tileLoc = mapGrid.GridTileToLocal(tile.GridIndices); if (!tileLoc.TryDistance(EntityManager, epicenter, out var distance) || distance > maxRange) { continue; } if (tile.IsBlockedTurf(false)) { continue; } if (!tileLoc.ToMap(EntityManager).InRangeUnobstructed(epicenterMapPos, maxRange, ignoreInsideBlocker: false, predicate: IgnoreExplosivePassable)) { continue; } var tileDef = (ContentTileDefinition) _tiles[tile.Tile.TypeId]; var baseTurfs = tileDef.BaseTurfs; if (baseTurfs.Count == 0) { continue; } var zeroTile = new Tile(_tiles[baseTurfs[0]].TileId); var previousTile = new Tile(_tiles[baseTurfs[^1]].TileId); var severity = CalculateSeverity(distance, devastationRange, heaveyRange); switch (severity) { case ExplosionSeverity.Light: if (!previousTile.IsEmpty && _random.Prob(LightBreakChance)) { mapGrid.SetTile(tileLoc, previousTile); } break; case ExplosionSeverity.Heavy: if (!previousTile.IsEmpty && _random.Prob(HeavyBreakChance)) { mapGrid.SetTile(tileLoc, previousTile); } break; case ExplosionSeverity.Destruction: mapGrid.SetTile(tileLoc, zeroTile); break; } } } private void FlashInRange(EntityCoordinates epicenter, float flashRange) { if (flashRange > 0) { var time = _timing.CurTime; var message = new EffectSystemMessage { EffectSprite = "Effects/explosion.rsi", RsiState = "explosionfast", Born = time, DeathTime = time + TimeSpan.FromSeconds(5), Size = new Vector2(flashRange / 2, flashRange / 2), Coordinates = epicenter, Rotation = 0f, ColorDelta = new Vector4(0, 0, 0, -1500f), Color = Vector4.Multiply(new Vector4(255, 255, 255, 750), 0.5f), Shaded = false }; _effects.CreateParticle(message); } } public void SpawnExplosion( EntityUid entity, int devastationRange = 0, int heavyImpactRange = 0, int lightImpactRange = 0, int flashRange = 0, ExplosiveComponent? explosive = null, TransformComponent? transform = null) { if (!Resolve(entity, ref transform)) { return; } Resolve(entity, ref explosive, false); if (explosive is { Exploding: false }) { _triggers.Explode(entity, explosive); } else { while (EntityManager.TryGetEntity(entity, out var e) && e.TryGetContainer(out var container)) { entity = container.Owner; } if (!EntityManager.TryGetComponent(entity, out transform)) { return; } var epicenter = transform.Coordinates; SpawnExplosion(epicenter, devastationRange, heavyImpactRange, lightImpactRange, flashRange); } } public void SpawnExplosion( EntityCoordinates epicenter, int devastationRange = 0, int heavyImpactRange = 0, int lightImpactRange = 0, int flashRange = 0) { var mapId = epicenter.GetMapId(EntityManager); if (mapId == MapId.Nullspace) { return; } _logSystem.Add(LogType.Damaged, LogImpact.High , $"Spawned explosion at {epicenter} with range {devastationRange}/{heavyImpactRange}/{lightImpactRange}/{flashRange}"); var maxRange = MathHelper.Max(devastationRange, heavyImpactRange, lightImpactRange, 0); var epicenterMapPos = epicenter.ToMapPos(EntityManager); var boundingBox = new Box2(epicenterMapPos - new Vector2(maxRange, maxRange), epicenterMapPos + new Vector2(maxRange, maxRange)); SoundSystem.Play(Filter.Broadcast(), ExplosionSound.GetSound(), epicenter); DamageEntitiesInRange(epicenter, boundingBox, devastationRange, heavyImpactRange, maxRange, mapId); var mapGridsNear = _maps.FindGridsIntersecting(mapId, boundingBox); foreach (var gridId in mapGridsNear) { DamageTilesInRange(epicenter, gridId.Index, boundingBox, devastationRange, heavyImpactRange, maxRange); } CameraShakeInRange(epicenter, maxRange); FlashInRange(epicenter, flashRange); } } }