using Content.Shared.CCVar; using Content.Shared.Construction.Components; using Robust.Shared.Configuration; using Robust.Shared.Map; namespace Content.Shared.Mapping; /// /// This system can fix the rotation of structures like doors and firelocks, based on the surrounding walls/windows/doors. /// See and . /// /// /// This only works for sprites that are symmetrical, so only need to worry about 2 rotation states. /// The correct rotation for sprites that (effectively) have 4 states is too fuzzy to be determined via scripted logic, /// and they are probably always placed deliberately anyway. So they are exempt from this system. /// This is primarily an assistive tool for fixing maps that were made before door sprites were directional. /// /// TODO But it could also be upgraded to help auto-align construction ghosts for doors, as a potential QOL feature public sealed partial class SharedStructureAlignerSystem : EntitySystem { [Dependency] private IConfigurationManager _cfg = default!; [Dependency] private EntityLookupSystem _lookup = default!; [Dependency] private SharedTransformSystem _trans = default!; /// /// If enabled, every will be Aligned when it spawns. /// private static bool _mapInitAlign; private const float ProximityMin = 0.45f; private const float ProximityMax = 1.1f; private const float SearchBoxScale = 1.25f; /// public override void Initialize() { Subs.CVar(_cfg, CCVars.MapInitAlign, (b) => { _mapInitAlign = b; }, true); } [SubscribeLocalEvent] private void OnAnchored(Entity entity, ref UserAnchoredEvent args) { if (!entity.Comp.AnchorAlign) return; Align(entity); } /// /// If the cvar is enabled, every will be Aligned when the map initializes. /// /// /// May be considered a stopgap measure when unupgraded maps are in rotation? /// [SubscribeLocalEvent] private void OnMapInit(Entity entity, ref MapInitEvent args) { if (!_mapInitAlign) return; Align(entity); } /// /// Aligns every on the target map, or on every map if not specified. /// /// The feedback to be displayed to the user, if the command was triggered from the console /// /// This can be expensive and lag the game for seconds. It should not be called after players are in the game. /// /// The map that should be targetted; if left null, all maps are targetted. /// If true, the command only checks for misalignment, without doing any rotation. /// If true, write a log message for each identified misalignment. public string AlignAll(MapId? map = null, bool dryRun = false, bool verbose = false) { // It needs to be an All Entity Query so it works on pre-init maps during Mapping. var query = AllEntityQuery(); var countAll = 0; var countFixed = 0; //TODO: check if map exists so we can quit early //TODO: rewrite the flow to check which entities should be aligned in one method, and then align those entities in a separate method foreach (var (ent, comp, trans) in query) { if (map is not null && trans.MapID != map) continue; if (Align((ent, comp), dryRun, verbose)) countFixed++; countAll++; } string message; if (countAll == 0) message = Loc.GetString("cmd-align-feedback-none", ("dry", dryRun)); else if (countFixed == 0) message = Loc.GetString("cmd-align-feedback-good", ("dry", dryRun)); else message = Loc.GetString("cmd-align-feedback", ("fixed", countFixed), ("dry", dryRun)); // Logging both alignable and misaligned entities, console only gets the latter. Log.Info($"AlignAll found {countAll} entities. {countFixed} were misaligned { (dryRun ? ". Dry run, no rotations were performed." : " and have been fixed.") }"); return message; } /// /// Aligns the target entity if there are any adjacent s with matching types. /// private bool Align(Entity entity, bool dryRun = false, bool verbose = false) { var trans = Transform(entity); // Do not align loose entities. if (!trans.Anchored || trans.GridUid is null) return false; // Get nearby entities, so we don't need to check through all entities // Searchbox is scaled up because adjacent airlocks would otherwise not be caught, due to smaller hitboxes var searchBox = _lookup.GetWorldAABB(entity, trans).Scale(SearchBoxScale); HashSet near = new(); _lookup.GetEntitiesIntersecting(trans.GridUid.Value, searchBox, near); var northSouth = 0d; var eastWest = 0d; foreach (var neighborEnt in near) { if (entity.Owner == neighborEnt) continue; if (!TryComp(neighborEnt, out var pylonComp)) continue; if (!pylonComp.AlignerPylonTypes.HasFlag(entity.Comp.AlignerType)) continue; var neighborTrans = Transform(neighborEnt); // Ignore space debris or docked grids. if (neighborTrans.ParentUid != trans.ParentUid) continue; // The searchbox catches diagonally adjacent tiles, but we don't want those. So we filter them out with a maximum distance. // Minimum range is here to ignore overlapping entities. trans.Coordinates.TryDistance(EntityManager, _trans, neighborTrans.Coordinates, out var dist); if (dist > ProximityMax || dist < ProximityMin) continue; // Anchored objects have enough weight in the calculation to make unanchored ones irrelevant, // but if only unanchored ones are present, they will still matter. // For example, if a line of firelock frames are being anchored, with no adjacent walls. var weight = neighborTrans.Anchored ? 10 : 1; var neighborDir = (trans.Coordinates.Position - neighborTrans.Coordinates.Position).GetDir(); switch (neighborDir) { case Direction.South or Direction.North: northSouth += weight; break; case Direction.East or Direction.West: eastWest += weight; break; } } // Determine correct orientation // A horizontal base sprite is assumed, with neighbors to the East or West being acceptable. // If the entity instead has N or S side neighbors, it will be rotated 90 degrees. // If ambiguous (neighbors on all sides, or in an L shape) then no rotation will occur. Direction targetDir; if (eastWest > northSouth) { targetDir = Direction.East; } else if (northSouth > eastWest) { targetDir = Direction.South; } else return false; // For our purposes the opposite of the target direction also works, as it's the same alignment axis. if (targetDir != trans.LocalRotation.GetDir() && targetDir != trans.LocalRotation.Opposite().GetDir()) return false; var name = MetaData(entity.Owner).EntityName; var pos = _trans.GetWorldPosition(trans); if (!dryRun) _trans.SetLocalRotation(entity, trans.LocalRotation + Angle.FromDegrees(90)); // Only generate individual logs if the user triggered alignment using the command. if (verbose) Log.Info($"Misaligned entity '{ entity.Owner }' on map { trans.MapID } at { pos } : { name }"); return true; } } /// /// Enum to use with to determine how incorrectly rotated entities should be aligned. /// [Flags] public enum StructureAlignerType : byte { /// /// Airlocks, doors, shutters, blast doors and everything that would be functionally /// considered a room boundary (doors, walls, windows, full tile rocks etc.). /// No firelocks - they are their own category to avoid interference in atypical placement locations. /// No thin walls/doors, or docking airlocks - directionality is too important for these and must be decided manually. /// Door = 1, /// /// Firelocks and everything that would be functionally considered a room boundary /// (doors, walls, windows, full tile rocks etc.). /// Firelock = 2, /// Go go gadget OverrideInheritance DoNotAlign = 4, }