From 27e59d35fb8a66607b2e866bccfcf3acd01b0738 Mon Sep 17 00:00:00 2001 From: chromiumboy <50505512+chromiumboy@users.noreply.github.com> Date: Mon, 16 Dec 2024 21:53:17 -0600 Subject: [PATCH] Atmospheric network monitor (#32294) * Updated to latest master version * Added gas pipe analyzer * Completed prototype * Playing with UI display * Refinement of the main UI * Renamed gas pipe analyzer to gas pipe sensor * Added focus network highlighting and map icons for gas pipe sensors * Added construction graph for gas pipe sensor * Improved efficiency of atmos pipe and focus pipe network data storage * Added gas pipe sensor variants * Fixed gas pipe sensor nav map icon not highlighting on focus * Rendered pipe lines now get merged together * Set up appearance handling for the gas pipe sensor, but setting the layers is bugged * Gas pipe sensor lights turn off when the device is unpowered * Renamed console * The gas pipe sensor is now a pipe. Redistributed components between it and its assembly * AtmosMonitors can now optionally monitor their internal pipe network instead of the surrounding atmosphere * Massive code clean up * Added delta states to handle pipe net updates, fixed entity deletion handling * Nav map blip data has been replaced with prototypes * Nav map blip fixes * Nav map colors are now set by the console component * Made the nav map more responsive to changes in focus * Updated nav map icons * Reverted unnecessary namespace changes * Code tidy up * Updated sprites and construction graph for gas pipe sensor * Updated localization files * Misc bug fixes * Added missing comment * Fixed issue with the circuit board for the monitor * Embellished the background of the console network entries * Updated console to account for PR #32273 * Removed gas pipe sensor * Fixing merge conflict * Update * Addressing reviews part 1 * Addressing review part 2 * Addressing reviews part 3 * Removed unnecessary references * Side panel values will be grayed out if there is no gas present in the pipe network * Declaring colors at the start of some files * Added a colored stripe to the side of the atmos network entries * Fixed an issue with pipe sensor blip coloration * Fixed delay that occurs when toggling gas sensors on/off --- .../Consoles/AtmosAlarmEntryContainer.xaml.cs | 18 +- ...tmosMonitoringConsoleBoundUserInterface.cs | 40 ++ .../AtmosMonitoringConsoleNavMapControl.cs | 295 ++++++++++ .../Consoles/AtmosMonitoringConsoleSystem.cs | 69 +++ .../AtmosMonitoringConsoleWindow.xaml | 99 ++++ .../AtmosMonitoringConsoleWindow.xaml.cs | 455 +++++++++++++++ .../AtmosMonitoringEntryContainer.xaml | 74 +++ .../AtmosMonitoringEntryContainer.xaml.cs | 166 ++++++ Content.Client/Pinpointer/UI/NavMapControl.cs | 49 +- .../Consoles/AtmosMonitoringConsoleSystem.cs | 542 ++++++++++++++++++ .../Components/AtmosPipeColorComponent.cs | 31 +- .../EntitySystems/AtmosPipeColorSystem.cs | 3 + Content.Shared/Atmos/Atmospherics.cs | 16 + .../Components/GasPipeSensorComponent.cs | 10 + .../AtmosMonitoringConsoleComponent.cs | 235 ++++++++ .../AtmosMonitoringConsoleDeviceComponent.cs | 21 + .../SharedAtmosMonitoringConsoleSystem.cs | 115 ++++ .../Prototypes/NavMapBlipPrototype.cs | 42 ++ .../en-US/atmos/atmos-alerts-console.ftl | 3 + Resources/Locale/en-US/atmos/gases.ftl | 10 + .../components/atmos-monitoring-component.ftl | 14 + .../Devices/Circuitboards/computer.yml | 9 + .../Machines/Computers/computers.yml | 33 ++ .../Machines/Computers/nav_map_blips.yml | 56 ++ .../Structures/Machines/Medical/cryo_pod.yml | 2 + .../Structures/Piping/Atmospherics/binary.yml | 34 +- .../Piping/Atmospherics/gas_pipe_sensor.yml | 3 + .../Structures/Piping/Atmospherics/pipes.yml | 1 + .../Piping/Atmospherics/trinary.yml | 10 +- .../Structures/Piping/Atmospherics/unary.yml | 22 +- .../Interface/NavMap/attributions.yml | 59 ++ .../Interface/NavMap/beveled_arrow_east.png | Bin 0 -> 1135 bytes .../NavMap/beveled_arrow_east.png.yml | 2 + .../Interface/NavMap/beveled_arrow_north.png | Bin 0 -> 1034 bytes .../NavMap/beveled_arrow_north.png.yml | 2 + .../Interface/NavMap/beveled_arrow_south.png | Bin 0 -> 1151 bytes .../NavMap/beveled_arrow_south.png.yml | 2 + .../Interface/NavMap/beveled_arrow_west.png | Bin 0 -> 1203 bytes .../NavMap/beveled_arrow_west.png.yml | 2 + .../Interface/NavMap/beveled_diamond.png | Bin 0 -> 2481 bytes .../Interface/NavMap/beveled_diamond.png.yml | 2 + .../NavMap/beveled_diamond_east_west.png | Bin 0 -> 1779 bytes .../NavMap/beveled_diamond_east_west.png.yml | 2 + .../NavMap/beveled_diamond_north_south.png | Bin 0 -> 1928 bytes .../beveled_diamond_north_south.png.yml | 2 + .../Interface/NavMap/beveled_star.png | Bin 0 -> 1920 bytes .../Interface/NavMap/beveled_star.png.yml | 2 + 47 files changed, 2486 insertions(+), 66 deletions(-) create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringConsoleBoundUserInterface.cs create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringConsoleNavMapControl.cs create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml.cs create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml create mode 100644 Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml.cs create mode 100644 Content.Server/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs create mode 100644 Content.Shared/Atmos/Components/GasPipeSensorComponent.cs create mode 100644 Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleComponent.cs create mode 100644 Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleDeviceComponent.cs create mode 100644 Content.Shared/Atmos/Consoles/SharedAtmosMonitoringConsoleSystem.cs create mode 100644 Content.Shared/Prototypes/NavMapBlipPrototype.cs create mode 100644 Resources/Locale/en-US/atmos/gases.ftl create mode 100644 Resources/Locale/en-US/components/atmos-monitoring-component.ftl create mode 100644 Resources/Prototypes/Entities/Structures/Machines/Computers/nav_map_blips.yml create mode 100644 Resources/Textures/Interface/NavMap/attributions.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_east.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_east.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_north.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_north.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_south.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_south.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_west.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_arrow_west.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond_north_south.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_diamond_north_south.png.yml create mode 100644 Resources/Textures/Interface/NavMap/beveled_star.png create mode 100644 Resources/Textures/Interface/NavMap/beveled_star.png.yml diff --git a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs index e533ef2dce..f0e4b13356 100644 --- a/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs +++ b/Content.Client/Atmos/Consoles/AtmosAlarmEntryContainer.xaml.cs @@ -31,19 +31,6 @@ public sealed partial class AtmosAlarmEntryContainer : BoxContainer [AtmosAlarmType.Danger] = "atmos-alerts-window-danger-state", }; - private Dictionary _gasShorthands = new Dictionary() - { - [Gas.Ammonia] = "NH₃", - [Gas.CarbonDioxide] = "CO₂", - [Gas.Frezon] = "F", - [Gas.Nitrogen] = "N₂", - [Gas.NitrousOxide] = "N₂O", - [Gas.Oxygen] = "O₂", - [Gas.Plasma] = "P", - [Gas.Tritium] = "T", - [Gas.WaterVapor] = "H₂O", - }; - public AtmosAlarmEntryContainer(NetEntity uid, EntityCoordinates? coordinates) { RobustXamlLoader.Load(this); @@ -162,12 +149,11 @@ public sealed partial class AtmosAlarmEntryContainer : BoxContainer foreach ((var gas, (var mol, var percent, var alert)) in keyValuePairs) { FixedPoint2 gasPercent = percent * 100f; - - var gasShorthand = _gasShorthands.GetValueOrDefault(gas, "X"); + var gasAbbreviation = Atmospherics.GasAbbreviations.GetValueOrDefault(gas, Loc.GetString("gas-unknown-abbreviation")); var gasLabel = new Label() { - Text = Loc.GetString("atmos-alerts-window-other-gases-value", ("shorthand", gasShorthand), ("value", gasPercent)), + Text = Loc.GetString("atmos-alerts-window-other-gases-value", ("shorthand", gasAbbreviation), ("value", gasPercent)), FontOverride = normalFont, FontColorOverride = GetAlarmStateColor(alert), HorizontalAlignment = HAlignment.Center, diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleBoundUserInterface.cs b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleBoundUserInterface.cs new file mode 100644 index 0000000000..563122f962 --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleBoundUserInterface.cs @@ -0,0 +1,40 @@ +using Content.Shared.Atmos.Components; + +namespace Content.Client.Atmos.Consoles; + +public sealed class AtmosMonitoringConsoleBoundUserInterface : BoundUserInterface +{ + [ViewVariables] + private AtmosMonitoringConsoleWindow? _menu; + + public AtmosMonitoringConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { } + + protected override void Open() + { + base.Open(); + + _menu = new AtmosMonitoringConsoleWindow(this, Owner); + _menu.OpenCentered(); + _menu.OnClose += Close; + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (state is not AtmosMonitoringConsoleBoundInterfaceState castState) + return; + + EntMan.TryGetComponent(Owner, out var xform); + _menu?.UpdateUI(xform?.Coordinates, castState.AtmosNetworks); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing) + return; + + _menu?.Dispose(); + } +} diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleNavMapControl.cs b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleNavMapControl.cs new file mode 100644 index 0000000000..c23ebb6435 --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleNavMapControl.cs @@ -0,0 +1,295 @@ +using Content.Client.Pinpointer.UI; +using Content.Shared.Atmos.Components; +using Content.Shared.Pinpointer; +using Robust.Client.Graphics; +using Robust.Shared.Collections; +using Robust.Shared.Map.Components; +using System.Linq; +using System.Numerics; + +namespace Content.Client.Atmos.Consoles; + +public sealed partial class AtmosMonitoringConsoleNavMapControl : NavMapControl +{ + [Dependency] private readonly IEntityManager _entManager = default!; + + public bool ShowPipeNetwork = true; + public int? FocusNetId = null; + + private const int ChunkSize = 4; + + private readonly Color _basePipeNetColor = Color.LightGray; + private readonly Color _unfocusedPipeNetColor = Color.DimGray; + + private List _atmosPipeNetwork = new(); + private Dictionary _sRGBLookUp = new Dictionary(); + + // Look up tables for merging continuous lines. Indexed by line color + private Dictionary> _horizLines = new(); + private Dictionary> _horizLinesReversed = new(); + private Dictionary> _vertLines = new(); + private Dictionary> _vertLinesReversed = new(); + + public AtmosMonitoringConsoleNavMapControl() : base() + { + PostWallDrawingAction += DrawAllPipeNetworks; + } + + protected override void UpdateNavMap() + { + base.UpdateNavMap(); + + if (!_entManager.TryGetComponent(Owner, out var console)) + return; + + if (!_entManager.TryGetComponent(MapUid, out var grid)) + return; + + _atmosPipeNetwork = GetDecodedAtmosPipeChunks(console.AtmosPipeChunks, grid); + } + + private void DrawAllPipeNetworks(DrawingHandleScreen handle) + { + if (!ShowPipeNetwork) + return; + + // Draw networks + if (_atmosPipeNetwork != null && _atmosPipeNetwork.Any()) + DrawPipeNetwork(handle, _atmosPipeNetwork); + } + + private void DrawPipeNetwork(DrawingHandleScreen handle, List atmosPipeNetwork) + { + var offset = GetOffset(); + offset = offset with { Y = -offset.Y }; + + if (WorldRange / WorldMaxRange > 0.5f) + { + var pipeNetworks = new Dictionary>(); + + foreach (var chunkedLine in atmosPipeNetwork) + { + var start = ScalePosition(chunkedLine.Origin - offset); + var end = ScalePosition(chunkedLine.Terminus - offset); + + if (!pipeNetworks.TryGetValue(chunkedLine.Color, out var subNetwork)) + subNetwork = new ValueList(); + + subNetwork.Add(start); + subNetwork.Add(end); + + pipeNetworks[chunkedLine.Color] = subNetwork; + } + + foreach ((var color, var subNetwork) in pipeNetworks) + { + if (subNetwork.Count > 0) + handle.DrawPrimitives(DrawPrimitiveTopology.LineList, subNetwork.Span, color); + } + } + + else + { + var pipeVertexUVs = new Dictionary>(); + + foreach (var chunkedLine in atmosPipeNetwork) + { + var leftTop = ScalePosition(new Vector2 + (Math.Min(chunkedLine.Origin.X, chunkedLine.Terminus.X) - 0.1f, + Math.Min(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) - 0.1f) + - offset); + + var rightTop = ScalePosition(new Vector2 + (Math.Max(chunkedLine.Origin.X, chunkedLine.Terminus.X) + 0.1f, + Math.Min(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) - 0.1f) + - offset); + + var leftBottom = ScalePosition(new Vector2 + (Math.Min(chunkedLine.Origin.X, chunkedLine.Terminus.X) - 0.1f, + Math.Max(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) + 0.1f) + - offset); + + var rightBottom = ScalePosition(new Vector2 + (Math.Max(chunkedLine.Origin.X, chunkedLine.Terminus.X) + 0.1f, + Math.Max(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) + 0.1f) + - offset); + + if (!pipeVertexUVs.TryGetValue(chunkedLine.Color, out var pipeVertexUV)) + pipeVertexUV = new ValueList(); + + pipeVertexUV.Add(leftBottom); + pipeVertexUV.Add(leftTop); + pipeVertexUV.Add(rightBottom); + pipeVertexUV.Add(leftTop); + pipeVertexUV.Add(rightBottom); + pipeVertexUV.Add(rightTop); + + pipeVertexUVs[chunkedLine.Color] = pipeVertexUV; + } + + foreach ((var color, var pipeVertexUV) in pipeVertexUVs) + { + if (pipeVertexUV.Count > 0) + handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, pipeVertexUV.Span, color); + } + } + } + + private List GetDecodedAtmosPipeChunks(Dictionary? chunks, MapGridComponent? grid) + { + var decodedOutput = new List(); + + if (chunks == null || grid == null) + return decodedOutput; + + // Clear stale look up table values + _horizLines.Clear(); + _horizLinesReversed.Clear(); + _vertLines.Clear(); + _vertLinesReversed.Clear(); + + // Generate masks + var northMask = (ulong)1 << 0; + var southMask = (ulong)1 << 1; + var westMask = (ulong)1 << 2; + var eastMask = (ulong)1 << 3; + + foreach ((var chunkOrigin, var chunk) in chunks) + { + var list = new List(); + + foreach (var ((netId, hexColor), atmosPipeData) in chunk.AtmosPipeData) + { + // Determine the correct coloration for the pipe + var color = Color.FromHex(hexColor) * _basePipeNetColor; + + if (FocusNetId != null && FocusNetId != netId) + color *= _unfocusedPipeNetColor; + + // Get the associated line look up tables + if (!_horizLines.TryGetValue(color, out var horizLines)) + { + horizLines = new(); + _horizLines[color] = horizLines; + } + + if (!_horizLinesReversed.TryGetValue(color, out var horizLinesReversed)) + { + horizLinesReversed = new(); + _horizLinesReversed[color] = horizLinesReversed; + } + + if (!_vertLines.TryGetValue(color, out var vertLines)) + { + vertLines = new(); + _vertLines[color] = vertLines; + } + + if (!_vertLinesReversed.TryGetValue(color, out var vertLinesReversed)) + { + vertLinesReversed = new(); + _vertLinesReversed[color] = vertLinesReversed; + } + + // Loop over the chunk + for (var tileIdx = 0; tileIdx < ChunkSize * ChunkSize; tileIdx++) + { + if (atmosPipeData == 0) + continue; + + var mask = (ulong)SharedNavMapSystem.AllDirMask << tileIdx * SharedNavMapSystem.Directions; + + if ((atmosPipeData & mask) == 0) + continue; + + var relativeTile = GetTileFromIndex(tileIdx); + var tile = (chunk.Origin * ChunkSize + relativeTile) * grid.TileSize; + tile = tile with { Y = -tile.Y }; + + // Calculate the draw point offsets + var vertLineOrigin = (atmosPipeData & northMask << tileIdx * SharedNavMapSystem.Directions) > 0 ? + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 1f) : new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f); + + var vertLineTerminus = (atmosPipeData & southMask << tileIdx * SharedNavMapSystem.Directions) > 0 ? + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0f) : new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f); + + var horizLineOrigin = (atmosPipeData & eastMask << tileIdx * SharedNavMapSystem.Directions) > 0 ? + new Vector2(grid.TileSize * 1f, -grid.TileSize * 0.5f) : new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f); + + var horizLineTerminus = (atmosPipeData & westMask << tileIdx * SharedNavMapSystem.Directions) > 0 ? + new Vector2(grid.TileSize * 0f, -grid.TileSize * 0.5f) : new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f); + + // Since we can have pipe lines that have a length of a half tile, + // double the vectors and convert to vector2i so we can merge them + AddOrUpdateNavMapLine(ConvertVector2ToVector2i(tile + horizLineOrigin, 2), ConvertVector2ToVector2i(tile + horizLineTerminus, 2), horizLines, horizLinesReversed); + AddOrUpdateNavMapLine(ConvertVector2ToVector2i(tile + vertLineOrigin, 2), ConvertVector2ToVector2i(tile + vertLineTerminus, 2), vertLines, vertLinesReversed); + } + } + } + + // Scale the vector2is back down and convert to vector2 + foreach (var (color, horizLines) in _horizLines) + { + // Get the corresponding sRBG color + var sRGB = GetsRGBColor(color); + + foreach (var (origin, terminal) in horizLines) + decodedOutput.Add(new AtmosMonitoringConsoleLine + (ConvertVector2iToVector2(origin, 0.5f), ConvertVector2iToVector2(terminal, 0.5f), sRGB)); + } + + foreach (var (color, vertLines) in _vertLines) + { + // Get the corresponding sRBG color + var sRGB = GetsRGBColor(color); + + foreach (var (origin, terminal) in vertLines) + decodedOutput.Add(new AtmosMonitoringConsoleLine + (ConvertVector2iToVector2(origin, 0.5f), ConvertVector2iToVector2(terminal, 0.5f), sRGB)); + } + + return decodedOutput; + } + + private Vector2 ConvertVector2iToVector2(Vector2i vector, float scale = 1f) + { + return new Vector2(vector.X * scale, vector.Y * scale); + } + + private Vector2i ConvertVector2ToVector2i(Vector2 vector, float scale = 1f) + { + return new Vector2i((int)MathF.Round(vector.X * scale), (int)MathF.Round(vector.Y * scale)); + } + + private Vector2i GetTileFromIndex(int index) + { + var x = index / ChunkSize; + var y = index % ChunkSize; + return new Vector2i(x, y); + } + + private Color GetsRGBColor(Color color) + { + if (!_sRGBLookUp.TryGetValue(color, out var sRGB)) + { + sRGB = Color.ToSrgb(color); + _sRGBLookUp[color] = sRGB; + } + + return sRGB; + } +} + +public struct AtmosMonitoringConsoleLine +{ + public readonly Vector2 Origin; + public readonly Vector2 Terminus; + public readonly Color Color; + + public AtmosMonitoringConsoleLine(Vector2 origin, Vector2 terminus, Color color) + { + Origin = origin; + Terminus = terminus; + Color = color; + } +} diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs new file mode 100644 index 0000000000..bfbb05d2ab --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs @@ -0,0 +1,69 @@ +using Content.Shared.Atmos.Components; +using Content.Shared.Atmos.Consoles; +using Robust.Shared.GameStates; + +namespace Content.Client.Atmos.Consoles; + +public sealed class AtmosMonitoringConsoleSystem : SharedAtmosMonitoringConsoleSystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnHandleState); + } + + private void OnHandleState(EntityUid uid, AtmosMonitoringConsoleComponent component, ref ComponentHandleState args) + { + Dictionary> modifiedChunks; + Dictionary atmosDevices; + + switch (args.Current) + { + case AtmosMonitoringConsoleDeltaState delta: + { + modifiedChunks = delta.ModifiedChunks; + atmosDevices = delta.AtmosDevices; + + foreach (var index in component.AtmosPipeChunks.Keys) + { + if (!delta.AllChunks!.Contains(index)) + component.AtmosPipeChunks.Remove(index); + } + + break; + } + + case AtmosMonitoringConsoleState state: + { + modifiedChunks = state.Chunks; + atmosDevices = state.AtmosDevices; + + foreach (var index in component.AtmosPipeChunks.Keys) + { + if (!state.Chunks.ContainsKey(index)) + component.AtmosPipeChunks.Remove(index); + } + + break; + } + default: + return; + } + + foreach (var (origin, chunk) in modifiedChunks) + { + var newChunk = new AtmosPipeChunk(origin); + newChunk.AtmosPipeData = new Dictionary<(int, string), ulong>(chunk); + + component.AtmosPipeChunks[origin] = newChunk; + } + + component.AtmosDevices.Clear(); + + foreach (var (nuid, atmosDevice) in atmosDevices) + { + component.AtmosDevices[nuid] = atmosDevice; + } + } +} diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml new file mode 100644 index 0000000000..b6fde7592f --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml.cs b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml.cs new file mode 100644 index 0000000000..515f91790f --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringConsoleWindow.xaml.cs @@ -0,0 +1,455 @@ +using Content.Client.Pinpointer.UI; +using Content.Client.UserInterface.Controls; +using Content.Shared.Atmos.Components; +using Content.Shared.Prototypes; +using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Content.Client.Atmos.Consoles; + +[GenerateTypedNameReferences] +public sealed partial class AtmosMonitoringConsoleWindow : FancyWindow +{ + private readonly IEntityManager _entManager; + private readonly IPrototypeManager _protoManager; + private readonly SpriteSystem _spriteSystem; + + private EntityUid? _owner; + private NetEntity? _focusEntity; + private int? _focusNetId; + + private bool _autoScrollActive = false; + + private readonly Color _unfocusedDeviceColor = Color.DimGray; + private ProtoId _navMapConsoleProtoId = "NavMapConsole"; + private ProtoId _gasPipeSensorProtoId = "GasPipeSensor"; + + public AtmosMonitoringConsoleWindow(AtmosMonitoringConsoleBoundUserInterface userInterface, EntityUid? owner) + { + RobustXamlLoader.Load(this); + _entManager = IoCManager.Resolve(); + _protoManager = IoCManager.Resolve(); + _spriteSystem = _entManager.System(); + + // Pass the owner to nav map + _owner = owner; + NavMap.Owner = _owner; + + // Set nav map grid uid + var stationName = Loc.GetString("atmos-monitoring-window-unknown-location"); + EntityCoordinates? consoleCoords = null; + + if (_entManager.TryGetComponent(owner, out var xform)) + { + consoleCoords = xform.Coordinates; + NavMap.MapUid = xform.GridUid; + + // Assign station name + if (_entManager.TryGetComponent(xform.GridUid, out var stationMetaData)) + stationName = stationMetaData.EntityName; + + var msg = new FormattedMessage(); + msg.TryAddMarkup(Loc.GetString("atmos-monitoring-window-station-name", ("stationName", stationName)), out _); + + StationName.SetMessage(msg); + } + + else + { + StationName.SetMessage(stationName); + NavMap.Visible = false; + } + + // Set trackable entity selected action + NavMap.TrackedEntitySelectedAction += SetTrackedEntityFromNavMap; + + // Update nav map + NavMap.ForceNavMapUpdate(); + + // Set tab container headers + MasterTabContainer.SetTabTitle(0, Loc.GetString("atmos-monitoring-window-tab-networks")); + + // Set UI toggles + ShowPipeNetwork.OnToggled += _ => OnShowPipeNetworkToggled(); + ShowGasPipeSensors.OnToggled += _ => OnShowGasPipeSensors(); + + // Set nav map colors + if (!_entManager.TryGetComponent(_owner, out var console)) + return; + + NavMap.TileColor = console.NavMapTileColor; + NavMap.WallColor = console.NavMapWallColor; + + // Initalize + UpdateUI(consoleCoords, Array.Empty()); + } + + #region Toggle handling + + private void OnShowPipeNetworkToggled() + { + if (_owner == null) + return; + + if (!_entManager.TryGetComponent(_owner.Value, out var console)) + return; + + NavMap.ShowPipeNetwork = ShowPipeNetwork.Pressed; + + foreach (var (netEnt, device) in console.AtmosDevices) + { + if (device.NavMapBlip == _gasPipeSensorProtoId) + continue; + + if (ShowPipeNetwork.Pressed) + AddTrackedEntityToNavMap(device); + + else + NavMap.TrackedEntities.Remove(netEnt); + } + } + + private void OnShowGasPipeSensors() + { + if (_owner == null) + return; + + if (!_entManager.TryGetComponent(_owner.Value, out var console)) + return; + + foreach (var (netEnt, device) in console.AtmosDevices) + { + if (device.NavMapBlip != _gasPipeSensorProtoId) + continue; + + if (ShowGasPipeSensors.Pressed) + AddTrackedEntityToNavMap(device, true); + + else + NavMap.TrackedEntities.Remove(netEnt); + } + } + + #endregion + + public void UpdateUI + (EntityCoordinates? consoleCoords, + AtmosMonitoringConsoleEntry[] atmosNetworks) + { + if (_owner == null) + return; + + if (!_entManager.TryGetComponent(_owner.Value, out var console)) + return; + + // Reset nav map values + NavMap.TrackedCoordinates.Clear(); + NavMap.TrackedEntities.Clear(); + + if (_focusEntity != null && !console.AtmosDevices.Any(x => x.Key == _focusEntity)) + ClearFocus(); + + // Add tracked entities to the nav map + UpdateNavMapBlips(); + + // Show the monitor location + var consoleNetEnt = _entManager.GetNetEntity(_owner); + + if (consoleCoords != null && consoleNetEnt != null) + { + var proto = _protoManager.Index(_navMapConsoleProtoId); + + if (proto.TexturePaths != null && proto.TexturePaths.Length != 0) + { + var texture = _spriteSystem.Frame0(new SpriteSpecifier.Texture(proto.TexturePaths[0])); + var blip = new NavMapBlip(consoleCoords.Value, texture, proto.Color, proto.Blinks, proto.Selectable); + NavMap.TrackedEntities[consoleNetEnt.Value] = blip; + } + } + + // Update the nav map + NavMap.ForceNavMapUpdate(); + + // Clear excess children from the tables + while (AtmosNetworksTable.ChildCount > atmosNetworks.Length) + AtmosNetworksTable.RemoveChild(AtmosNetworksTable.GetChild(AtmosNetworksTable.ChildCount - 1)); + + // Update all entries in each table + for (int index = 0; index < atmosNetworks.Length; index++) + { + var entry = atmosNetworks.ElementAt(index); + UpdateUIEntry(entry, index, AtmosNetworksTable, console); + } + } + + private void UpdateNavMapBlips() + { + if (_owner == null || !_entManager.TryGetComponent(_owner.Value, out var console)) + return; + + if (NavMap.Visible) + { + foreach (var (netEnt, device) in console.AtmosDevices) + { + // Update the focus network ID, incase it has changed + if (_focusEntity == netEnt) + { + _focusNetId = device.NetId; + NavMap.FocusNetId = _focusNetId; + } + + var isSensor = device.NavMapBlip == _gasPipeSensorProtoId; + + // Skip network devices if the toggled is off + if (!ShowPipeNetwork.Pressed && !isSensor) + continue; + + // Skip gas pipe sensors if the toggle is off + if (!ShowGasPipeSensors.Pressed && isSensor) + continue; + + AddTrackedEntityToNavMap(device, isSensor); + } + } + } + + private void AddTrackedEntityToNavMap(AtmosDeviceNavMapData metaData, bool isSensor = false) + { + var proto = _protoManager.Index(metaData.NavMapBlip); + + if (proto.TexturePaths == null || proto.TexturePaths.Length == 0) + return; + + var idx = Math.Clamp((int)metaData.Direction / 2, 0, proto.TexturePaths.Length - 1); + var texture = proto.TexturePaths.Length > 0 ? proto.TexturePaths[idx] : proto.TexturePaths[0]; + var color = isSensor ? proto.Color : proto.Color * metaData.PipeColor; + + if (_focusNetId != null && metaData.NetId != _focusNetId) + color *= _unfocusedDeviceColor; + + var blinks = proto.Blinks || _focusEntity == metaData.NetEntity; + var coords = _entManager.GetCoordinates(metaData.NetCoordinates); + var blip = new NavMapBlip(coords, _spriteSystem.Frame0(new SpriteSpecifier.Texture(texture)), color, blinks, proto.Selectable, proto.Scale); + NavMap.TrackedEntities[metaData.NetEntity] = blip; + } + + private void UpdateUIEntry(AtmosMonitoringConsoleEntry data, int index, Control table, AtmosMonitoringConsoleComponent console) + { + // Make new UI entry if required + if (index >= table.ChildCount) + { + var newEntryContainer = new AtmosMonitoringEntryContainer(data); + + // On click + newEntryContainer.FocusButton.OnButtonUp += args => + { + if (_focusEntity == newEntryContainer.Data.NetEntity) + { + ClearFocus(); + } + + else + { + SetFocus(newEntryContainer.Data.NetEntity, newEntryContainer.Data.NetId); + + var coords = _entManager.GetCoordinates(newEntryContainer.Data.Coordinates); + NavMap.CenterToCoordinates(coords); + } + + // Update affected UI elements across all tables + UpdateConsoleTable(console, AtmosNetworksTable, _focusEntity); + }; + + // Add the entry to the current table + table.AddChild(newEntryContainer); + } + + // Update values and UI elements + var tableChild = table.GetChild(index); + + if (tableChild is not AtmosMonitoringEntryContainer) + { + table.RemoveChild(tableChild); + UpdateUIEntry(data, index, table, console); + + return; + } + + var entryContainer = (AtmosMonitoringEntryContainer)tableChild; + entryContainer.UpdateEntry(data, data.NetEntity == _focusEntity); + } + + private void UpdateConsoleTable(AtmosMonitoringConsoleComponent console, Control table, NetEntity? currTrackedEntity) + { + foreach (var tableChild in table.Children) + { + if (tableChild is not AtmosAlarmEntryContainer) + continue; + + var entryContainer = (AtmosAlarmEntryContainer)tableChild; + + if (entryContainer.NetEntity != currTrackedEntity) + entryContainer.RemoveAsFocus(); + + else if (entryContainer.NetEntity == currTrackedEntity) + entryContainer.SetAsFocus(); + } + } + + private void SetTrackedEntityFromNavMap(NetEntity? focusEntity) + { + if (focusEntity == null) + return; + + if (!_entManager.TryGetComponent(_owner, out var console)) + return; + + foreach (var (netEnt, device) in console.AtmosDevices) + { + if (netEnt != focusEntity) + continue; + + if (device.NavMapBlip != _gasPipeSensorProtoId) + return; + + // Set new focus + SetFocus(focusEntity.Value, device.NetId); + + // Get the scroll position of the selected entity on the selected button the UI + ActivateAutoScrollToFocus(); + + break; + } + } + + protected override void FrameUpdate(FrameEventArgs args) + { + AutoScrollToFocus(); + } + + private void ActivateAutoScrollToFocus() + { + _autoScrollActive = true; + } + + private void AutoScrollToFocus() + { + if (!_autoScrollActive) + return; + + var scroll = AtmosNetworksTable.Parent as ScrollContainer; + if (scroll == null) + return; + + if (!TryGetVerticalScrollbar(scroll, out var vScrollbar)) + return; + + if (!TryGetNextScrollPosition(out float? nextScrollPosition)) + return; + + vScrollbar.ValueTarget = nextScrollPosition.Value; + + if (MathHelper.CloseToPercent(vScrollbar.Value, vScrollbar.ValueTarget)) + _autoScrollActive = false; + } + + private bool TryGetVerticalScrollbar(ScrollContainer scroll, [NotNullWhen(true)] out VScrollBar? vScrollBar) + { + vScrollBar = null; + + foreach (var control in scroll.Children) + { + if (control is not VScrollBar) + continue; + + vScrollBar = (VScrollBar)control; + + return true; + } + + return false; + } + + private bool TryGetNextScrollPosition([NotNullWhen(true)] out float? nextScrollPosition) + { + nextScrollPosition = null; + + var scroll = AtmosNetworksTable.Parent as ScrollContainer; + if (scroll == null) + return false; + + var container = scroll.Children.ElementAt(0) as BoxContainer; + if (container == null || container.Children.Count() == 0) + return false; + + // Exit if the heights of the children haven't been initialized yet + if (!container.Children.Any(x => x.Height > 0)) + return false; + + nextScrollPosition = 0; + + foreach (var control in container.Children) + { + if (control is not AtmosMonitoringEntryContainer) + continue; + + var entry = (AtmosMonitoringEntryContainer)control; + + if (entry.Data.NetEntity == _focusEntity) + return true; + + nextScrollPosition += control.Height; + } + + // Failed to find control + nextScrollPosition = null; + + return false; + } + + private void SetFocus(NetEntity focusEntity, int focusNetId) + { + _focusEntity = focusEntity; + _focusNetId = focusNetId; + NavMap.FocusNetId = focusNetId; + + OnFocusChanged(); + } + + private void ClearFocus() + { + _focusEntity = null; + _focusNetId = null; + NavMap.FocusNetId = null; + + OnFocusChanged(); + } + + private void OnFocusChanged() + { + UpdateNavMapBlips(); + NavMap.ForceNavMapUpdate(); + + if (!_entManager.TryGetComponent(_owner, out var console)) + return; + + for (int index = 0; index < AtmosNetworksTable.ChildCount; index++) + { + var entry = (AtmosMonitoringEntryContainer)AtmosNetworksTable.GetChild(index); + + if (entry == null) + continue; + + UpdateUIEntry(entry.Data, index, AtmosNetworksTable, console); + } + } +} diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml b/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml new file mode 100644 index 0000000000..6a19f0775f --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml @@ -0,0 +1,74 @@ + + + + + diff --git a/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml.cs b/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml.cs new file mode 100644 index 0000000000..0ce0c9c880 --- /dev/null +++ b/Content.Client/Atmos/Consoles/AtmosMonitoringEntryContainer.xaml.cs @@ -0,0 +1,166 @@ +using Content.Client.Stylesheets; +using Content.Shared.Atmos; +using Content.Shared.Atmos.Components; +using Content.Shared.FixedPoint; +using Content.Shared.Temperature; +using Robust.Client.AutoGenerated; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using System.Linq; + +namespace Content.Client.Atmos.Consoles; + +[GenerateTypedNameReferences] +public sealed partial class AtmosMonitoringEntryContainer : BoxContainer +{ + public AtmosMonitoringConsoleEntry Data; + + private readonly IEntityManager _entManager; + private readonly IResourceCache _cache; + + public AtmosMonitoringEntryContainer(AtmosMonitoringConsoleEntry data) + { + RobustXamlLoader.Load(this); + _entManager = IoCManager.Resolve(); + _cache = IoCManager.Resolve(); + + Data = data; + + // Modulate colored stripe + NetworkColorStripe.Modulate = data.Color; + + // Load fonts + var headerFont = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Bold.ttf"), 11); + var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11); + + // Set fonts + TemperatureHeaderLabel.FontOverride = headerFont; + PressureHeaderLabel.FontOverride = headerFont; + TotalMolHeaderLabel.FontOverride = headerFont; + GasesHeaderLabel.FontOverride = headerFont; + + TemperatureLabel.FontOverride = normalFont; + PressureLabel.FontOverride = normalFont; + TotalMolLabel.FontOverride = normalFont; + + NoDataLabel.FontOverride = headerFont; + } + + public void UpdateEntry(AtmosMonitoringConsoleEntry updatedData, bool isFocus) + { + // Load fonts + var normalFont = new VectorFont(_cache.GetResource("/Fonts/NotoSansDisplay/NotoSansDisplay-Regular.ttf"), 11); + + // Update name and values + if (!string.IsNullOrEmpty(updatedData.Address)) + NetworkNameLabel.Text = Loc.GetString("atmos-alerts-window-alarm-label", ("name", updatedData.EntityName), ("address", updatedData.Address)); + + else + NetworkNameLabel.Text = Loc.GetString(updatedData.EntityName); + + Data = updatedData; + + // Modulate colored stripe + NetworkColorStripe.Modulate = Data.Color; + + // Focus updates + if (isFocus) + SetAsFocus(); + else + RemoveAsFocus(); + + // Check if powered + if (!updatedData.IsPowered) + { + MainDataContainer.Visible = false; + NoDataLabel.Visible = true; + + return; + } + + // Set container visibility + MainDataContainer.Visible = true; + NoDataLabel.Visible = false; + + // Update temperature + var isNotVacuum = updatedData.TotalMolData > 1e-6f; + var tempK = (FixedPoint2)updatedData.TemperatureData; + var tempC = (FixedPoint2)TemperatureHelpers.KelvinToCelsius(tempK.Float()); + + TemperatureLabel.Text = isNotVacuum ? + Loc.GetString("atmos-alerts-window-temperature-value", ("valueInC", tempC), ("valueInK", tempK)) : + Loc.GetString("atmos-alerts-window-invalid-value"); + + TemperatureLabel.FontColorOverride = isNotVacuum ? Color.DarkGray : StyleNano.DisabledFore; + + // Update pressure + PressureLabel.Text = Loc.GetString("atmos-alerts-window-pressure-value", ("value", (FixedPoint2)updatedData.PressureData)); + PressureLabel.FontColorOverride = isNotVacuum ? Color.DarkGray : StyleNano.DisabledFore; + + // Update total mol + TotalMolLabel.Text = Loc.GetString("atmos-alerts-window-total-mol-value", ("value", (FixedPoint2)updatedData.TotalMolData)); + TotalMolLabel.FontColorOverride = isNotVacuum ? Color.DarkGray : StyleNano.DisabledFore; + + // Update other present gases + GasGridContainer.RemoveAllChildren(); + + if (updatedData.GasData.Count() == 0) + { + // No gases + var gasLabel = new Label() + { + Text = Loc.GetString("atmos-alerts-window-other-gases-value-nil"), + FontOverride = normalFont, + FontColorOverride = StyleNano.DisabledFore, + HorizontalAlignment = HAlignment.Center, + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + Margin = new Thickness(0, 2, 0, 0), + SetHeight = 24f, + }; + + GasGridContainer.AddChild(gasLabel); + } + + else + { + // Add an entry for each gas + foreach (var (gas, percent) in updatedData.GasData) + { + var gasPercent = (FixedPoint2)0f; + gasPercent = percent * 100f; + + var gasAbbreviation = Atmospherics.GasAbbreviations.GetValueOrDefault(gas, Loc.GetString("gas-unknown-abbreviation")); + + var gasLabel = new Label() + { + Text = Loc.GetString("atmos-alerts-window-other-gases-value", ("shorthand", gasAbbreviation), ("value", gasPercent)), + FontOverride = normalFont, + HorizontalAlignment = HAlignment.Center, + VerticalAlignment = VAlignment.Center, + HorizontalExpand = true, + Margin = new Thickness(0, 2, 0, 0), + SetHeight = 24f, + }; + + GasGridContainer.AddChild(gasLabel); + } + } + } + + public void SetAsFocus() + { + FocusButton.AddStyleClass(StyleNano.StyleClassButtonColorGreen); + ArrowTexture.TexturePath = "/Textures/Interface/Nano/inverted_triangle.svg.png"; + FocusContainer.Visible = true; + } + + public void RemoveAsFocus() + { + FocusButton.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen); + ArrowTexture.TexturePath = "/Textures/Interface/Nano/triangle_right.png"; + FocusContainer.Visible = false; + } +} diff --git a/Content.Client/Pinpointer/UI/NavMapControl.cs b/Content.Client/Pinpointer/UI/NavMapControl.cs index 90c2680c4a..b774b7d8b5 100644 --- a/Content.Client/Pinpointer/UI/NavMapControl.cs +++ b/Content.Client/Pinpointer/UI/NavMapControl.cs @@ -385,26 +385,6 @@ public partial class NavMapControl : MapGridControl if (PostWallDrawingAction != null) PostWallDrawingAction.Invoke(handle); - // Beacons - if (_beacons.Pressed) - { - var rectBuffer = new Vector2(5f, 3f); - - // Calculate font size for current zoom level - var fontSize = (int) Math.Round(1 / WorldRange * DefaultDisplayedRange * UIScale * _targetFontsize, 0); - var font = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Bold.ttf"), fontSize); - - foreach (var beacon in _navMap.Beacons.Values) - { - var position = beacon.Position - offset; - position = ScalePosition(position with { Y = -position.Y }); - - var textDimensions = handle.GetDimensions(font, beacon.Text, 1f); - handle.DrawRect(new UIBox2(position - textDimensions / 2 - rectBuffer, position + textDimensions / 2 + rectBuffer), BackgroundColor); - handle.DrawString(font, position - textDimensions / 2, beacon.Text, beacon.Color); - } - } - var curTime = Timing.RealTime; var blinkFrequency = 1f / 1f; var lit = curTime.TotalSeconds % blinkFrequency > blinkFrequency / 2f; @@ -443,11 +423,31 @@ public partial class NavMapControl : MapGridControl position = ScalePosition(new Vector2(position.X, -position.Y)); var scalingCoefficient = MinmapScaleModifier * float.Sqrt(MinimapScale); - var positionOffset = new Vector2(scalingCoefficient * blip.Texture.Width, scalingCoefficient * blip.Texture.Height); + var positionOffset = new Vector2(scalingCoefficient * blip.Scale * blip.Texture.Width, scalingCoefficient * blip.Scale * blip.Texture.Height); handle.DrawTextureRect(blip.Texture, new UIBox2(position - positionOffset, position + positionOffset), blip.Color); } } + + // Beacons + if (_beacons.Pressed) + { + var rectBuffer = new Vector2(5f, 3f); + + // Calculate font size for current zoom level + var fontSize = (int)Math.Round(1 / WorldRange * DefaultDisplayedRange * UIScale * _targetFontsize, 0); + var font = new VectorFont(_cache.GetResource("/Fonts/NotoSans/NotoSans-Bold.ttf"), fontSize); + + foreach (var beacon in _navMap.Beacons.Values) + { + var position = beacon.Position - offset; + position = ScalePosition(position with { Y = -position.Y }); + + var textDimensions = handle.GetDimensions(font, beacon.Text, 1f); + handle.DrawRect(new UIBox2(position - textDimensions / 2 - rectBuffer, position + textDimensions / 2 + rectBuffer), BackgroundColor); + handle.DrawString(font, position - textDimensions / 2, beacon.Text, beacon.Color); + } + } } protected override void FrameUpdate(FrameEventArgs args) @@ -689,6 +689,9 @@ public partial class NavMapControl : MapGridControl Vector2i foundTermius; Vector2i foundOrigin; + if (origin == terminus) + return; + // Does our new line end at the beginning of an existing line? if (lookup.Remove(terminus, out foundTermius)) { @@ -739,13 +742,15 @@ public struct NavMapBlip public Color Color; public bool Blinks; public bool Selectable; + public float Scale; - public NavMapBlip(EntityCoordinates coordinates, Texture texture, Color color, bool blinks, bool selectable = true) + public NavMapBlip(EntityCoordinates coordinates, Texture texture, Color color, bool blinks, bool selectable = true, float scale = 1f) { Coordinates = coordinates; Texture = texture; Color = color; Blinks = blinks; Selectable = selectable; + Scale = scale; } } diff --git a/Content.Server/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs b/Content.Server/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs new file mode 100644 index 0000000000..5ecadc7154 --- /dev/null +++ b/Content.Server/Atmos/Consoles/AtmosMonitoringConsoleSystem.cs @@ -0,0 +1,542 @@ +using Content.Server.Atmos.Components; +using Content.Server.Atmos.Piping.Components; +using Content.Server.DeviceNetwork.Components; +using Content.Server.NodeContainer; +using Content.Server.NodeContainer.EntitySystems; +using Content.Server.NodeContainer.NodeGroups; +using Content.Server.NodeContainer.Nodes; +using Content.Server.Power.Components; +using Content.Shared.Atmos; +using Content.Shared.Atmos.Components; +using Content.Shared.Atmos.Consoles; +using Content.Shared.Labels.Components; +using Content.Shared.Pinpointer; +using Robust.Server.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; +using Robust.Shared.Timing; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace Content.Server.Atmos.Consoles; + +public sealed class AtmosMonitoringConsoleSystem : SharedAtmosMonitoringConsoleSystem +{ + [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!; + [Dependency] private readonly SharedMapSystem _sharedMapSystem = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + + // Private variables + // Note: this data does not need to be saved + private Dictionary> _gridAtmosPipeChunks = new(); + private float _updateTimer = 1.0f; + + // Constants + private const float UpdateTime = 1.0f; + private const int ChunkSize = 4; + + public override void Initialize() + { + base.Initialize(); + + // Console events + SubscribeLocalEvent(OnConsoleInit); + SubscribeLocalEvent(OnConsoleAnchorChanged); + SubscribeLocalEvent(OnConsoleParentChanged); + + // Tracked device events + SubscribeLocalEvent(OnEntityNodeGroupsRebuilt); + SubscribeLocalEvent(OnEntityPipeColorChanged); + SubscribeLocalEvent(OnEntityShutdown); + + // Grid events + SubscribeLocalEvent(OnGridSplit); + } + + #region Event handling + + private void OnConsoleInit(EntityUid uid, AtmosMonitoringConsoleComponent component, ComponentInit args) + { + InitializeAtmosMonitoringConsole(uid, component); + } + + private void OnConsoleAnchorChanged(EntityUid uid, AtmosMonitoringConsoleComponent component, AnchorStateChangedEvent args) + { + InitializeAtmosMonitoringConsole(uid, component); + } + + private void OnConsoleParentChanged(EntityUid uid, AtmosMonitoringConsoleComponent component, EntParentChangedMessage args) + { + component.ForceFullUpdate = true; + InitializeAtmosMonitoringConsole(uid, component); + } + + private void OnEntityNodeGroupsRebuilt(EntityUid uid, AtmosMonitoringConsoleDeviceComponent component, NodeGroupsRebuilt args) + { + InitializeAtmosMonitoringDevice(uid, component); + } + + private void OnEntityPipeColorChanged(EntityUid uid, AtmosMonitoringConsoleDeviceComponent component, AtmosPipeColorChangedEvent args) + { + InitializeAtmosMonitoringDevice(uid, component); + } + + private void OnEntityShutdown(EntityUid uid, AtmosMonitoringConsoleDeviceComponent component, EntityTerminatingEvent args) + { + ShutDownAtmosMonitoringEntity(uid, component); + } + + private void OnGridSplit(ref GridSplitEvent args) + { + // Collect grids + var allGrids = args.NewGrids.ToList(); + + if (!allGrids.Contains(args.Grid)) + allGrids.Add(args.Grid); + + // Rebuild the pipe networks on the affected grids + foreach (var ent in allGrids) + { + if (!TryComp(ent, out var grid)) + continue; + + RebuildAtmosPipeGrid(ent, grid); + } + + // Update atmos monitoring consoles that stand upon an updated grid + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (entXform.GridUid == null) + continue; + + if (!allGrids.Contains(entXform.GridUid.Value)) + continue; + + InitializeAtmosMonitoringConsole(ent, entConsole); + } + } + + #endregion + + #region UI updates + + public override void Update(float frameTime) + { + base.Update(frameTime); + + _updateTimer += frameTime; + + if (_updateTimer >= UpdateTime) + { + _updateTimer -= UpdateTime; + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (entXform?.GridUid == null) + continue; + + UpdateUIState(ent, entConsole, entXform); + } + } + } + + public void UpdateUIState + (EntityUid uid, + AtmosMonitoringConsoleComponent component, + TransformComponent xform) + { + if (!_userInterfaceSystem.IsUiOpen(uid, AtmosMonitoringConsoleUiKey.Key)) + return; + + var gridUid = xform.GridUid!.Value; + + if (!TryComp(gridUid, out var mapGrid)) + return; + + if (!TryComp(gridUid, out var atmosphere)) + return; + + // The grid must have a NavMapComponent to visualize the map in the UI + EnsureComp(gridUid); + + // Gathering data to be send to the client + var atmosNetworks = new List(); + var query = AllEntityQuery(); + + while (query.MoveNext(out var ent, out var entSensor, out var entXform)) + { + if (entXform?.GridUid != xform.GridUid) + continue; + + if (!entXform.Anchored) + continue; + + var entry = CreateAtmosMonitoringConsoleEntry(ent, entXform); + + if (entry != null) + atmosNetworks.Add(entry.Value); + } + + // Set the UI state + _userInterfaceSystem.SetUiState(uid, AtmosMonitoringConsoleUiKey.Key, + new AtmosMonitoringConsoleBoundInterfaceState(atmosNetworks.ToArray())); + } + + private AtmosMonitoringConsoleEntry? CreateAtmosMonitoringConsoleEntry(EntityUid uid, TransformComponent xform) + { + AtmosMonitoringConsoleEntry? entry = null; + + var netEnt = GetNetEntity(uid); + var name = MetaData(uid).EntityName; + var address = string.Empty; + + if (xform.GridUid == null) + return null; + + if (!TryGettingFirstPipeNode(uid, out var pipeNode, out var netId) || + pipeNode == null || + netId == null) + return null; + + var pipeColor = TryComp(uid, out var colorComponent) ? colorComponent.Color : Color.White; + + // Name the entity based on its label, if available + if (TryComp(uid, out var label) && label.CurrentLabel != null) + name = label.CurrentLabel; + + // Otherwise use its base name and network address + else if (TryComp(uid, out var deviceNet)) + address = deviceNet.Address; + + // Entry for unpowered devices + if (TryComp(uid, out var apcPowerReceiver) && !apcPowerReceiver.Powered) + { + entry = new AtmosMonitoringConsoleEntry(netEnt, GetNetCoordinates(xform.Coordinates), netId.Value, name, address) + { + IsPowered = false, + Color = pipeColor + }; + + return entry; + } + + // Entry for powered devices + var gasData = new Dictionary(); + var isAirPresent = pipeNode.Air.TotalMoles > 0; + + if (isAirPresent) + { + foreach (var gas in Enum.GetValues()) + { + if (pipeNode.Air[(int)gas] > 0) + gasData.Add(gas, pipeNode.Air[(int)gas] / pipeNode.Air.TotalMoles); + } + } + + entry = new AtmosMonitoringConsoleEntry(netEnt, GetNetCoordinates(xform.Coordinates), netId.Value, name, address) + { + TemperatureData = isAirPresent ? pipeNode.Air.Temperature : 0f, + PressureData = pipeNode.Air.Pressure, + TotalMolData = pipeNode.Air.TotalMoles, + GasData = gasData, + Color = pipeColor + }; + + return entry; + } + + private Dictionary GetAllAtmosDeviceNavMapData(EntityUid gridUid) + { + var atmosDeviceNavMapData = new Dictionary(); + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entComponent, out var entXform)) + { + if (TryGetAtmosDeviceNavMapData(ent, entComponent, entXform, gridUid, out var data)) + atmosDeviceNavMapData.Add(data.Value.NetEntity, data.Value); + } + + return atmosDeviceNavMapData; + } + + private bool TryGetAtmosDeviceNavMapData + (EntityUid uid, + AtmosMonitoringConsoleDeviceComponent component, + TransformComponent xform, + EntityUid gridUid, + [NotNullWhen(true)] out AtmosDeviceNavMapData? device) + { + device = null; + + if (component.NavMapBlip == null) + return false; + + if (xform.GridUid != gridUid) + return false; + + if (!xform.Anchored) + return false; + + var direction = xform.LocalRotation.GetCardinalDir(); + + if (!TryGettingFirstPipeNode(uid, out var _, out var netId)) + netId = -1; + + var color = Color.White; + + if (TryComp(uid, out var atmosPipeColor)) + color = atmosPipeColor.Color; + + device = new AtmosDeviceNavMapData(GetNetEntity(uid), GetNetCoordinates(xform.Coordinates), netId.Value, component.NavMapBlip.Value, direction, color); + + return true; + } + + #endregion + + #region Pipe net functions + + private void RebuildAtmosPipeGrid(EntityUid gridUid, MapGridComponent grid) + { + var allChunks = new Dictionary(); + + // Adds all atmos pipes to the nav map via bit mask chunks + var queryPipes = AllEntityQuery(); + while (queryPipes.MoveNext(out var ent, out var entAtmosPipeColor, out var entNodeContainer, out var entXform)) + { + if (entXform.GridUid != gridUid) + continue; + + if (!entXform.Anchored) + continue; + + var tile = _sharedMapSystem.GetTileRef(gridUid, grid, entXform.Coordinates); + var chunkOrigin = SharedMapSystem.GetChunkIndices(tile.GridIndices, ChunkSize); + var relative = SharedMapSystem.GetChunkRelative(tile.GridIndices, ChunkSize); + + if (!allChunks.TryGetValue(chunkOrigin, out var chunk)) + { + chunk = new AtmosPipeChunk(chunkOrigin); + allChunks[chunkOrigin] = chunk; + } + + UpdateAtmosPipeChunk(ent, entNodeContainer, entAtmosPipeColor, GetTileIndex(relative), ref chunk); + } + + // Add or update the chunks on the associated grid + _gridAtmosPipeChunks[gridUid] = allChunks; + + // Update the consoles that are on the same grid + var queryConsoles = AllEntityQuery(); + while (queryConsoles.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (gridUid != entXform.GridUid) + continue; + + entConsole.AtmosPipeChunks = allChunks; + Dirty(ent, entConsole); + } + } + + private void RebuildSingleTileOfPipeNetwork(EntityUid gridUid, MapGridComponent grid, EntityCoordinates coords) + { + if (!_gridAtmosPipeChunks.TryGetValue(gridUid, out var allChunks)) + allChunks = new Dictionary(); + + var tile = _sharedMapSystem.GetTileRef(gridUid, grid, coords); + var chunkOrigin = SharedMapSystem.GetChunkIndices(tile.GridIndices, ChunkSize); + var relative = SharedMapSystem.GetChunkRelative(tile.GridIndices, ChunkSize); + var tileIdx = GetTileIndex(relative); + + if (!allChunks.TryGetValue(chunkOrigin, out var chunk)) + chunk = new AtmosPipeChunk(chunkOrigin); + + // Remove all stale values for the tile + foreach (var (index, atmosPipeData) in chunk.AtmosPipeData) + { + var mask = (ulong)SharedNavMapSystem.AllDirMask << tileIdx * SharedNavMapSystem.Directions; + chunk.AtmosPipeData[index] = atmosPipeData & ~mask; + } + + // Rebuild the tile's pipe data + foreach (var ent in _sharedMapSystem.GetAnchoredEntities(gridUid, grid, coords)) + { + if (!TryComp(ent, out var entAtmosPipeColor)) + continue; + + if (!TryComp(ent, out var entNodeContainer)) + continue; + + UpdateAtmosPipeChunk(ent, entNodeContainer, entAtmosPipeColor, tileIdx, ref chunk); + } + + // Add or update the chunk on the associated grid + // Only the modified chunk will be sent to the client + chunk.LastUpdate = _gameTiming.CurTick; + allChunks[chunkOrigin] = chunk; + _gridAtmosPipeChunks[gridUid] = allChunks; + + // Update the components of the monitoring consoles that are attached to the same grid + var query = AllEntityQuery(); + + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (gridUid != entXform.GridUid) + continue; + + entConsole.AtmosPipeChunks = allChunks; + Dirty(ent, entConsole); + } + } + + private void UpdateAtmosPipeChunk(EntityUid uid, NodeContainerComponent nodeContainer, AtmosPipeColorComponent pipeColor, int tileIdx, ref AtmosPipeChunk chunk) + { + // Entities that are actively being deleted are not to be drawn + if (MetaData(uid).EntityLifeStage >= EntityLifeStage.Terminating) + return; + + foreach ((var id, var node) in nodeContainer.Nodes) + { + if (node is not PipeNode) + continue; + + var pipeNode = (PipeNode)node; + var netId = GetPipeNodeNetId(pipeNode); + var pipeDirection = pipeNode.CurrentPipeDirection; + + chunk.AtmosPipeData.TryGetValue((netId, pipeColor.Color.ToHex()), out var atmosPipeData); + atmosPipeData |= (ulong)pipeDirection << tileIdx * SharedNavMapSystem.Directions; + chunk.AtmosPipeData[(netId, pipeColor.Color.ToHex())] = atmosPipeData; + } + } + + private bool TryGettingFirstPipeNode(EntityUid uid, [NotNullWhen(true)] out PipeNode? pipeNode, [NotNullWhen(true)] out int? netId) + { + pipeNode = null; + netId = null; + + if (!TryComp(uid, out var nodeContainer)) + return false; + + foreach (var node in nodeContainer.Nodes.Values) + { + if (node is PipeNode) + { + pipeNode = (PipeNode)node; + netId = GetPipeNodeNetId(pipeNode); + + return true; + } + } + + return false; + } + + private int GetPipeNodeNetId(PipeNode pipeNode) + { + if (pipeNode.NodeGroup is BaseNodeGroup) + { + var nodeGroup = (BaseNodeGroup)pipeNode.NodeGroup; + + return nodeGroup.NetId; + } + + return -1; + } + + #endregion + + #region Initialization functions + + private void InitializeAtmosMonitoringConsole(EntityUid uid, AtmosMonitoringConsoleComponent component) + { + var xform = Transform(uid); + + if (xform.GridUid == null) + return; + + var grid = xform.GridUid.Value; + + if (!TryComp(grid, out var map)) + return; + + component.AtmosDevices = GetAllAtmosDeviceNavMapData(grid); + + if (!_gridAtmosPipeChunks.TryGetValue(grid, out var chunks)) + { + RebuildAtmosPipeGrid(grid, map); + } + + else + { + component.AtmosPipeChunks = chunks; + Dirty(uid, component); + } + } + + private void InitializeAtmosMonitoringDevice(EntityUid uid, AtmosMonitoringConsoleDeviceComponent component) + { + // Rebuild tile + var xform = Transform(uid); + var gridUid = xform.GridUid; + + if (gridUid != null && TryComp(gridUid, out var grid)) + RebuildSingleTileOfPipeNetwork(gridUid.Value, grid, xform.Coordinates); + + // Update blips on affected consoles + if (component.NavMapBlip == null) + return; + + var netEntity = EntityManager.GetNetEntity(uid); + var query = AllEntityQuery(); + + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + var isDirty = entConsole.AtmosDevices.Remove(netEntity); + + if (gridUid != null && + gridUid == entXform.GridUid && + xform.Anchored && + TryGetAtmosDeviceNavMapData(uid, component, xform, gridUid.Value, out var data)) + { + entConsole.AtmosDevices.Add(netEntity, data.Value); + isDirty = true; + } + + if (isDirty) + Dirty(ent, entConsole); + } + } + + private void ShutDownAtmosMonitoringEntity(EntityUid uid, AtmosMonitoringConsoleDeviceComponent component) + { + // Rebuild tile + var xform = Transform(uid); + var gridUid = xform.GridUid; + + if (gridUid != null && TryComp(gridUid, out var grid)) + RebuildSingleTileOfPipeNetwork(gridUid.Value, grid, xform.Coordinates); + + // Update blips on affected consoles + if (component.NavMapBlip == null) + return; + + var netEntity = EntityManager.GetNetEntity(uid); + var query = AllEntityQuery(); + + while (query.MoveNext(out var ent, out var entConsole)) + { + if (entConsole.AtmosDevices.Remove(netEntity)) + Dirty(ent, entConsole); + } + } + + #endregion + + private int GetTileIndex(Vector2i relativeTile) + { + return relativeTile.X * ChunkSize + relativeTile.Y; + } +} diff --git a/Content.Server/Atmos/Piping/Components/AtmosPipeColorComponent.cs b/Content.Server/Atmos/Piping/Components/AtmosPipeColorComponent.cs index 5b05668ad5..a8edb07d31 100644 --- a/Content.Server/Atmos/Piping/Components/AtmosPipeColorComponent.cs +++ b/Content.Server/Atmos/Piping/Components/AtmosPipeColorComponent.cs @@ -1,19 +1,24 @@ using Content.Server.Atmos.Piping.EntitySystems; using JetBrains.Annotations; -namespace Content.Server.Atmos.Piping.Components -{ - [RegisterComponent] - public sealed partial class AtmosPipeColorComponent : Component - { - [DataField("color")] - public Color Color { get; set; } = Color.White; +namespace Content.Server.Atmos.Piping.Components; - [ViewVariables(VVAccess.ReadWrite), UsedImplicitly] - public Color ColorVV - { - get => Color; - set => IoCManager.Resolve().System().SetColor(Owner, this, value); - } +[RegisterComponent] +public sealed partial class AtmosPipeColorComponent : Component +{ + [DataField] + public Color Color { get; set; } = Color.White; + + [ViewVariables(VVAccess.ReadWrite), UsedImplicitly] + public Color ColorVV + { + get => Color; + set => IoCManager.Resolve().System().SetColor(Owner, this, value); } } + +[ByRefEvent] +public record struct AtmosPipeColorChangedEvent(Color color) +{ + public Color Color = color; +} diff --git a/Content.Server/Atmos/Piping/EntitySystems/AtmosPipeColorSystem.cs b/Content.Server/Atmos/Piping/EntitySystems/AtmosPipeColorSystem.cs index b9ee668032..dcb08dcd57 100644 --- a/Content.Server/Atmos/Piping/EntitySystems/AtmosPipeColorSystem.cs +++ b/Content.Server/Atmos/Piping/EntitySystems/AtmosPipeColorSystem.cs @@ -40,6 +40,9 @@ namespace Content.Server.Atmos.Piping.EntitySystems return; _appearance.SetData(uid, PipeColorVisuals.Color, color, appearance); + + var ev = new AtmosPipeColorChangedEvent(color); + RaiseLocalEvent(uid, ref ev); } } } diff --git a/Content.Shared/Atmos/Atmospherics.cs b/Content.Shared/Atmos/Atmospherics.cs index 19766d92e6..cb89f6c199 100644 --- a/Content.Shared/Atmos/Atmospherics.cs +++ b/Content.Shared/Atmos/Atmospherics.cs @@ -145,6 +145,22 @@ namespace Content.Shared.Atmos /// public const float SpaceHeatCapacity = 7000f; + /// + /// Dictionary of chemical abbreviations for + /// + public static Dictionary GasAbbreviations = new Dictionary() + { + [Gas.Ammonia] = Loc.GetString("gas-ammonia-abbreviation"), + [Gas.CarbonDioxide] = Loc.GetString("gas-carbon-dioxide-abbreviation"), + [Gas.Frezon] = Loc.GetString("gas-frezon-abbreviation"), + [Gas.Nitrogen] = Loc.GetString("gas-nitrogen-abbreviation"), + [Gas.NitrousOxide] = Loc.GetString("gas-nitrous-oxide-abbreviation"), + [Gas.Oxygen] = Loc.GetString("gas-oxygen-abbreviation"), + [Gas.Plasma] = Loc.GetString("gas-plasma-abbreviation"), + [Gas.Tritium] = Loc.GetString("gas-tritium-abbreviation"), + [Gas.WaterVapor] = Loc.GetString("gas-water-vapor-abbreviation"), + }; + #region Excited Groups /// diff --git a/Content.Shared/Atmos/Components/GasPipeSensorComponent.cs b/Content.Shared/Atmos/Components/GasPipeSensorComponent.cs new file mode 100644 index 0000000000..3393948f4f --- /dev/null +++ b/Content.Shared/Atmos/Components/GasPipeSensorComponent.cs @@ -0,0 +1,10 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Atmos.Components; + +/// +/// Entities with component will be queried against for their +/// atmos monitoring data on atmos monitoring consoles +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class GasPipeSensorComponent : Component; diff --git a/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleComponent.cs b/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleComponent.cs new file mode 100644 index 0000000000..2ac0d2a9af --- /dev/null +++ b/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleComponent.cs @@ -0,0 +1,235 @@ +using Content.Shared.Atmos.Consoles; +using Content.Shared.Pinpointer; +using Content.Shared.Prototypes; +using Robust.Shared.GameStates; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Timing; + +namespace Content.Shared.Atmos.Components; + +/// +/// Entities capable of opening the atmos monitoring console UI +/// require this component to function correctly +/// +[RegisterComponent, NetworkedComponent] +[Access(typeof(SharedAtmosMonitoringConsoleSystem))] +public sealed partial class AtmosMonitoringConsoleComponent : Component +{ + /* + * Don't need DataFields as this can be reconstructed + */ + + /// + /// A dictionary of the all the nav map chunks that contain anchored atmos pipes + /// + [ViewVariables] + public Dictionary AtmosPipeChunks = new(); + + /// + /// A list of all the atmos devices that will be used to populate the nav map + /// + [ViewVariables] + public Dictionary AtmosDevices = new(); + + /// + /// Color of the floor tiles on the nav map screen + /// + [DataField, ViewVariables] + public Color NavMapTileColor; + + /// + /// Color of the wall lines on the nav map screen + /// + [DataField, ViewVariables] + public Color NavMapWallColor; + + /// + /// The next time this component is dirtied, it will force the full state + /// to be sent to the client, instead of just the delta state + /// + [ViewVariables] + public bool ForceFullUpdate = false; +} + +[Serializable, NetSerializable] +public struct AtmosPipeChunk(Vector2i origin) +{ + /// + /// Chunk position + /// + [ViewVariables] + public readonly Vector2i Origin = origin; + + /// + /// Bitmask look up for atmos pipes, 1 for occupied and 0 for empty. + /// Indexed by the color hexcode of the pipe + /// + [ViewVariables] + public Dictionary<(int, string), ulong> AtmosPipeData = new(); + + /// + /// The last game tick that the chunk was updated + /// + [NonSerialized] + public GameTick LastUpdate; +} + +[Serializable, NetSerializable] +public struct AtmosDeviceNavMapData +{ + /// + /// The entity in question + /// + public NetEntity NetEntity; + + /// + /// Location of the entity + /// + public NetCoordinates NetCoordinates; + + /// + /// The associated pipe network ID + /// + public int NetId = -1; + + /// + /// Prototype ID for the nav map blip + /// + public ProtoId NavMapBlip; + + /// + /// Direction of the entity + /// + public Direction Direction; + + /// + /// Color of the attached pipe + /// + public Color PipeColor; + + /// + /// Populate the atmos monitoring console nav map with a single entity + /// + public AtmosDeviceNavMapData(NetEntity netEntity, NetCoordinates netCoordinates, int netId, ProtoId navMapBlip, Direction direction, Color pipeColor) + { + NetEntity = netEntity; + NetCoordinates = netCoordinates; + NetId = netId; + NavMapBlip = navMapBlip; + Direction = direction; + PipeColor = pipeColor; + } +} + +[Serializable, NetSerializable] +public sealed class AtmosMonitoringConsoleBoundInterfaceState : BoundUserInterfaceState +{ + /// + /// A list of all entries to populate the UI with + /// + public AtmosMonitoringConsoleEntry[] AtmosNetworks; + + /// + /// Sends data from the server to the client to populate the atmos monitoring console UI + /// + public AtmosMonitoringConsoleBoundInterfaceState(AtmosMonitoringConsoleEntry[] atmosNetworks) + { + AtmosNetworks = atmosNetworks; + } +} + +[Serializable, NetSerializable] +public struct AtmosMonitoringConsoleEntry +{ + /// + /// The entity in question + /// + public NetEntity NetEntity; + + /// + /// Location of the entity + /// + public NetCoordinates Coordinates; + + /// + /// The associated pipe network ID + /// + public int NetId = -1; + + /// + /// Localised device name + /// + public string EntityName; + + /// + /// Device network address + /// + public string Address; + + /// + /// Temperature (K) + /// + public float TemperatureData; + + /// + /// Pressure (kPA) + /// + public float PressureData; + + /// + /// Total number of mols of gas + /// + public float TotalMolData; + + /// + /// Mol and percentage for all detected gases + /// + public Dictionary GasData = new(); + + /// + /// The color to be associated with the pipe network + /// + public Color Color; + + /// + /// Indicates whether the entity is powered + /// + public bool IsPowered = true; + + /// + /// Used to populate the atmos monitoring console UI with data from a single air alarm + /// + public AtmosMonitoringConsoleEntry + (NetEntity entity, + NetCoordinates coordinates, + int netId, + string entityName, + string address) + { + NetEntity = entity; + Coordinates = coordinates; + NetId = netId; + EntityName = entityName; + Address = address; + } +} + +public enum AtmosPipeChunkDataFacing : byte +{ + // Values represent bit shift offsets when retrieving data in the tile array. + North = 0, + South = SharedNavMapSystem.ArraySize, + East = SharedNavMapSystem.ArraySize * 2, + West = SharedNavMapSystem.ArraySize * 3, +} + +/// +/// UI key associated with the atmos monitoring console +/// +[Serializable, NetSerializable] +public enum AtmosMonitoringConsoleUiKey +{ + Key +} diff --git a/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleDeviceComponent.cs b/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleDeviceComponent.cs new file mode 100644 index 0000000000..50c3abcfca --- /dev/null +++ b/Content.Shared/Atmos/Consoles/Components/AtmosMonitoringConsoleDeviceComponent.cs @@ -0,0 +1,21 @@ +using Content.Shared.Prototypes; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Atmos.Components; + +/// +/// Entities with this component appear on the +/// nav maps of atmos monitoring consoles +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class AtmosMonitoringConsoleDeviceComponent : Component +{ + /// + /// Prototype ID for the blip used to represent this + /// entity on the atmos monitoring console nav map. + /// If null, no blip is drawn (i.e., null for pipes) + /// + [DataField, ViewVariables] + public ProtoId? NavMapBlip = null; +} diff --git a/Content.Shared/Atmos/Consoles/SharedAtmosMonitoringConsoleSystem.cs b/Content.Shared/Atmos/Consoles/SharedAtmosMonitoringConsoleSystem.cs new file mode 100644 index 0000000000..e6dd455be7 --- /dev/null +++ b/Content.Shared/Atmos/Consoles/SharedAtmosMonitoringConsoleSystem.cs @@ -0,0 +1,115 @@ +using Content.Shared.Atmos.Components; +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared.Atmos.Consoles; + +public abstract class SharedAtmosMonitoringConsoleSystem : EntitySystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnGetState); + } + + private void OnGetState(EntityUid uid, AtmosMonitoringConsoleComponent component, ref ComponentGetState args) + { + Dictionary> chunks; + + // Should this be a full component state or a delta-state? + if (args.FromTick <= component.CreationTick || component.ForceFullUpdate) + { + component.ForceFullUpdate = false; + + // Full state + chunks = new(component.AtmosPipeChunks.Count); + + foreach (var (origin, chunk) in component.AtmosPipeChunks) + { + chunks.Add(origin, chunk.AtmosPipeData); + } + + args.State = new AtmosMonitoringConsoleState(chunks, component.AtmosDevices); + + return; + } + + chunks = new(); + + foreach (var (origin, chunk) in component.AtmosPipeChunks) + { + if (chunk.LastUpdate < args.FromTick) + continue; + + chunks.Add(origin, chunk.AtmosPipeData); + } + + args.State = new AtmosMonitoringConsoleDeltaState(chunks, component.AtmosDevices, new(component.AtmosPipeChunks.Keys)); + } + + #region: System messages + + [Serializable, NetSerializable] + protected sealed class AtmosMonitoringConsoleState( + Dictionary> chunks, + Dictionary atmosDevices) + : ComponentState + { + public Dictionary> Chunks = chunks; + public Dictionary AtmosDevices = atmosDevices; + } + + [Serializable, NetSerializable] + protected sealed class AtmosMonitoringConsoleDeltaState( + Dictionary> modifiedChunks, + Dictionary atmosDevices, + HashSet allChunks) + : ComponentState, IComponentDeltaState + { + public Dictionary> ModifiedChunks = modifiedChunks; + public Dictionary AtmosDevices = atmosDevices; + public HashSet AllChunks = allChunks; + + public void ApplyToFullState(AtmosMonitoringConsoleState state) + { + foreach (var key in state.Chunks.Keys) + { + if (!AllChunks!.Contains(key)) + state.Chunks.Remove(key); + } + + foreach (var (index, data) in ModifiedChunks) + { + state.Chunks[index] = new Dictionary<(int, string), ulong>(data); + } + + state.AtmosDevices.Clear(); + foreach (var (nuid, atmosDevice) in AtmosDevices) + { + state.AtmosDevices.Add(nuid, atmosDevice); + } + } + + public AtmosMonitoringConsoleState CreateNewFullState(AtmosMonitoringConsoleState state) + { + var chunks = new Dictionary>(state.Chunks.Count); + + foreach (var (index, data) in state.Chunks) + { + if (!AllChunks!.Contains(index)) + continue; + + if (ModifiedChunks.ContainsKey(index)) + chunks[index] = new Dictionary<(int, string), ulong>(ModifiedChunks[index]); + + else + chunks[index] = new Dictionary<(int, string), ulong>(state.Chunks[index]); + } + + return new AtmosMonitoringConsoleState(chunks, new(AtmosDevices)); + } + } + + #endregion +} diff --git a/Content.Shared/Prototypes/NavMapBlipPrototype.cs b/Content.Shared/Prototypes/NavMapBlipPrototype.cs new file mode 100644 index 0000000000..ede82d8e04 --- /dev/null +++ b/Content.Shared/Prototypes/NavMapBlipPrototype.cs @@ -0,0 +1,42 @@ +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Shared.Prototypes; + +[Prototype("navMapBlip")] +public sealed partial class NavMapBlipPrototype : IPrototype +{ + [ViewVariables] + [IdDataField] + public string ID { get; private set; } = default!; + + /// + /// Sets whether the associated entity can be selected when the blip is clicked + /// + [DataField] + public bool Selectable = false; + + /// + /// Sets whether the blips is always blinking + /// + [DataField] + public bool Blinks = false; + + /// + /// Sets the color of the blip + /// + [DataField] + public Color Color { get; private set; } = Color.LightGray; + + /// + /// Texture paths associated with the blip + /// + [DataField] + public ResPath[]? TexturePaths { get; private set; } + + /// + /// Sets the UI scaling of the blip + /// + [DataField] + public float Scale { get; private set; } = 1f; +} diff --git a/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl b/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl index 470a8f8695..dd9b6a0128 100644 --- a/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl +++ b/Resources/Locale/en-US/atmos/atmos-alerts-console.ftl @@ -10,6 +10,9 @@ atmos-alerts-window-tab-fire-alarms = Fire alarms atmos-alerts-window-alarm-label = {CAPITALIZE($name)} ({$address}) atmos-alerts-window-temperature-label = Temperature atmos-alerts-window-temperature-value = {$valueInC} °C ({$valueInK} K) +atmos-alerts-window-invalid-value = N/A +atmos-alerts-window-total-mol-label = Total moles +atmos-alerts-window-total-mol-value = {$value} mol atmos-alerts-window-pressure-label = Pressure atmos-alerts-window-pressure-value = {$value} kPa atmos-alerts-window-oxygenation-label = Oxygenation diff --git a/Resources/Locale/en-US/atmos/gases.ftl b/Resources/Locale/en-US/atmos/gases.ftl new file mode 100644 index 0000000000..5c540c46df --- /dev/null +++ b/Resources/Locale/en-US/atmos/gases.ftl @@ -0,0 +1,10 @@ +gas-ammonia-abbreviation = NH₃ +gas-carbon-dioxide-abbreviation = CO₂ +gas-frezon-abbreviation = F +gas-nitrogen-abbreviation = N₂ +gas-nitrous-oxide-abbreviation = N₂O +gas-oxygen-abbreviation = O₂ +gas-plasma-abbreviation = P +gas-tritium-abbreviation = T +gas-water-vapor-abbreviation = H₂O +gas-unknown-abbreviation = X diff --git a/Resources/Locale/en-US/components/atmos-monitoring-component.ftl b/Resources/Locale/en-US/components/atmos-monitoring-component.ftl new file mode 100644 index 0000000000..eab6f50c60 --- /dev/null +++ b/Resources/Locale/en-US/components/atmos-monitoring-component.ftl @@ -0,0 +1,14 @@ +atmos-monitoring-window-title = Atmospheric Network Monitor +atmos-monitoring-window-station-name = [color=white][font size=14]{$stationName}[/font][/color] +atmos-monitoring-window-unknown-location = Unknown location +atmos-monitoring-window-label-gas-opening = Network opening +atmos-monitoring-window-label-gas-scrubber = Air scrubber +atmos-monitoring-window-label-gas-flow-regulator = Flow regulator +atmos-monitoring-window-label-thermoregulator = Thermoregulator +atmos-monitoring-window-tab-networks = Atmospheric networks +atmos-monitoring-window-toggle-overlays = Toggle map overlays +atmos-monitoring-window-show-pipe-network = Pipe network +atmos-monitoring-window-show-gas-pipe-sensors = Gas pipe sensors +atmos-monitoring-window-label-gases = Present gases +atmos-monitoring-window-flavor-left = Contact an atmospheric technician for assistance +atmos-monitoring-window-flavor-right = v1.1 \ No newline at end of file diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml index 54616724fb..26f2881ae8 100644 --- a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml +++ b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/computer.yml @@ -26,6 +26,15 @@ components: - type: ComputerBoard prototype: ComputerAlert + +- type: entity + parent: BaseComputerCircuitboard + id: AtmosMonitoringComputerCircuitboard + name: atmospheric network monitor board + description: A computer printed circuit board for an atmospheric network monitor. + components: + - type: ComputerBoard + prototype: ComputerAtmosMonitoring - type: entity parent: BaseComputerCircuitboard diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml index 4cd596e9b4..8167229ae5 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml @@ -49,6 +49,39 @@ enum.WiresUiKey.Key: type: WiresBoundUserInterface +- type: entity + parent: BaseComputerAiAccess + id: ComputerAtmosMonitoring + name: atmospheric network monitor + description: Used to monitor the station's atmospheric networks. + components: + - type: Computer + board: AtmosMonitoringComputerCircuitboard + - type: Sprite + layers: + - map: ["computerLayerBody"] + state: computer + - map: ["computerLayerKeyboard"] + state: generic_keyboard + - map: ["computerLayerScreen"] + state: tank + - map: ["computerLayerKeys"] + state: atmos_key + - map: [ "enum.WiresVisualLayers.MaintenancePanel" ] + state: generic_panel_open + - type: AtmosMonitoringConsole + navMapTileColor: "#1a1a1a" + navMapWallColor: "#404040" + - type: ActivatableUI + singleUser: true + key: enum.AtmosMonitoringConsoleUiKey.Key + - type: UserInterface + interfaces: + enum.AtmosMonitoringConsoleUiKey.Key: + type: AtmosMonitoringConsoleBoundUserInterface + enum.WiresUiKey.Key: + type: WiresBoundUserInterface + - type: entity parent: BaseComputer id: ComputerEmergencyShuttle diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/nav_map_blips.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/nav_map_blips.yml new file mode 100644 index 0000000000..bc51557186 --- /dev/null +++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/nav_map_blips.yml @@ -0,0 +1,56 @@ +# All consoles +- type: navMapBlip + id: NavMapConsole + blinks: true + color: Cyan + texturePaths: + - "/Textures/Interface/NavMap/beveled_circle.png" + +# Atmos monitoring console +- type: navMapBlip + id: GasPipeSensor + selectable: true + color: "#ffcd00" + texturePaths: + - "/Textures/Interface/NavMap/beveled_star.png" + +- type: navMapBlip + id: GasVentOpening + scale: 0.6667 + color: LightGray + texturePaths: + - "/Textures/Interface/NavMap/beveled_square.png" + +- type: navMapBlip + id: GasVentScrubber + scale: 0.6667 + color: LightGray + texturePaths: + - "/Textures/Interface/NavMap/beveled_circle.png" + +- type: navMapBlip + id: GasFlowRegulator + scale: 0.75 + color: LightGray + texturePaths: + - "/Textures/Interface/NavMap/beveled_arrow_south.png" + - "/Textures/Interface/NavMap/beveled_arrow_east.png" + - "/Textures/Interface/NavMap/beveled_arrow_north.png" + - "/Textures/Interface/NavMap/beveled_arrow_west.png" + +- type: navMapBlip + id: GasValve + scale: 0.6667 + color: LightGray + texturePaths: + - "/Textures/Interface/NavMap/beveled_diamond_north_south.png" + - "/Textures/Interface/NavMap/beveled_diamond_east_west.png" + - "/Textures/Interface/NavMap/beveled_diamond_north_south.png" + - "/Textures/Interface/NavMap/beveled_diamond_east_west.png" + +- type: navMapBlip + id: Thermoregulator + scale: 0.6667 + color: LightGray + texturePaths: + - "/Textures/Interface/NavMap/beveled_hexagon.png" \ No newline at end of file diff --git a/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml b/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml index ed5137c28f..3bc00fb1b6 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml @@ -79,6 +79,8 @@ !type:PortablePipeNode nodeGroupID: Pipe pipeDirection: South + - type: AtmosMonitoringConsoleDevice + navMapBlip: Thermoregulator - type: ItemSlots slots: beakerSlot: diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/binary.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/binary.yml index 90e48d8be6..8327937ba8 100644 --- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/binary.yml +++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/binary.yml @@ -73,7 +73,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_pump.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasFlowRegulator + - type: entity parent: GasBinaryBase id: GasVolumePump @@ -130,7 +132,9 @@ examinableAddress: true prefix: device-address-prefix-volume-pump - type: WiredNetworkConnection - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasFlowRegulator + - type: entity parent: GasBinaryBase id: GasPassiveGate @@ -159,7 +163,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_hiss.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasValve + - type: entity parent: GasBinaryBase id: GasValve @@ -207,7 +213,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_hiss.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasValve + - type: entity parent: GasBinaryBase id: SignalControlledValve @@ -266,7 +274,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_hiss.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasValve + - type: entity parent: GasBinaryBase id: GasPort @@ -295,7 +305,9 @@ - type: Construction graph: GasBinary node: port - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentOpening + - type: entity parent: GasVentPump id: GasDualPortVentPump @@ -351,7 +363,9 @@ pipeDirection: South - type: AmbientSound enabled: true - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentOpening + - type: entity parent: [ BaseMachine, ConstructibleMachine ] id: GasRecycler @@ -413,7 +427,9 @@ acts: ["Destruction"] - type: Machine board: GasRecyclerMachineCircuitboard - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasValve + - type: entity parent: GasBinaryBase id: HeatExchanger @@ -453,3 +469,5 @@ - type: Construction graph: GasBinary node: radiator + - type: AtmosMonitoringConsoleDevice + navMapBlip: Thermoregulator diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/gas_pipe_sensor.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/gas_pipe_sensor.yml index 08015abe7d..22b56908ea 100644 --- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/gas_pipe_sensor.yml +++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/gas_pipe_sensor.yml @@ -27,6 +27,9 @@ True: { state: lights } - type: AtmosMonitor monitorsPipeNet: true + - type: GasPipeSensor + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasPipeSensor - type: ApcPowerReceiver - type: ExtensionCableReceiver - type: Construction diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/pipes.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/pipes.yml index 4fe5463bff..ef436b4299 100644 --- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/pipes.yml +++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/pipes.yml @@ -54,6 +54,7 @@ - type: PipeRestrictOverlap - type: AtmosUnsafeUnanchor - type: AtmosPipeColor + - type: AtmosMonitoringConsoleDevice - type: Tag tags: - Pipe diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/trinary.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/trinary.yml index e8025556aa..bde7136850 100644 --- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/trinary.yml +++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/trinary.yml @@ -70,7 +70,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_hiss.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasFlowRegulator + - type: entity parent: GasFilter id: GasFilterFlipped @@ -158,7 +160,9 @@ range: 5 sound: path: /Audio/Ambience/Objects/gas_hiss.ogg - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasFlowRegulator + - type: entity parent: GasMixer id: GasMixerFlipped @@ -257,3 +261,5 @@ - type: Construction graph: GasTrinary node: pneumaticvalve + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasFlowRegulator \ No newline at end of file diff --git a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/unary.yml b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/unary.yml index d0ec74dd40..5da85544fc 100644 --- a/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/unary.yml +++ b/Resources/Prototypes/Entities/Structures/Piping/Atmospherics/unary.yml @@ -68,7 +68,9 @@ sound: path: /Audio/Ambience/Objects/gas_vent.ogg - type: Weldable - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentOpening + - type: entity parent: GasUnaryBase id: GasPassiveVent @@ -92,7 +94,9 @@ - type: Construction graph: GasUnary node: passivevent - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentOpening + - type: entity parent: [GasUnaryBase, AirSensorBase] id: GasVentScrubber @@ -141,7 +145,9 @@ sound: path: /Audio/Ambience/Objects/gas_vent.ogg - type: Weldable - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentScrubber + - type: entity parent: GasUnaryBase id: GasOutletInjector @@ -180,7 +186,9 @@ visibleLayers: - enum.SubfloorLayers.FirstLayer - enum.LightLayers.Unshaded - + - type: AtmosMonitoringConsoleDevice + navMapBlip: GasVentOpening + - type: entity parent: [ BaseMachinePowered, ConstructibleMachine ] id: BaseGasThermoMachine @@ -224,7 +232,9 @@ examinableAddress: true - type: WiredNetworkConnection - type: PowerSwitch - + - type: AtmosMonitoringConsoleDevice + navMapBlip: Thermoregulator + - type: entity parent: BaseGasThermoMachine id: GasThermoMachineFreezer @@ -429,3 +439,5 @@ - type: ExaminableSolution solution: tank - type: PowerSwitch + - type: AtmosMonitoringConsoleDevice + navMapBlip: Thermoregulator diff --git a/Resources/Textures/Interface/NavMap/attributions.yml b/Resources/Textures/Interface/NavMap/attributions.yml new file mode 100644 index 0000000000..f624c0f448 --- /dev/null +++ b/Resources/Textures/Interface/NavMap/attributions.yml @@ -0,0 +1,59 @@ +- files: ["beveled_arrow_east.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_arrow_north.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_arrow_south.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_arrow_west.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_circle.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_diamond.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_diamond_east_west.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_diamond_north_south.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_hexagon.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_square.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_star.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" + +- files: ["beveled_triangle.png"] + license: "CC-BY-SA-3.0" + copyright: "Created by chromiumboy" + source: "https://github.com/chromiumboy" diff --git a/Resources/Textures/Interface/NavMap/beveled_arrow_east.png b/Resources/Textures/Interface/NavMap/beveled_arrow_east.png new file mode 100644 index 0000000000000000000000000000000000000000..156685fe146b4c9262d335dd30565fa4895522b6 GIT binary patch literal 1135 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 literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/NavMap/beveled_diamond.png.yml b/Resources/Textures/Interface/NavMap/beveled_diamond.png.yml new file mode 100644 index 0000000000..dabd6601f7 --- /dev/null +++ b/Resources/Textures/Interface/NavMap/beveled_diamond.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true diff --git a/Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png b/Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png new file mode 100644 index 0000000000000000000000000000000000000000..9c88e7c51d2fd1f57febc161c521d36e5366d48d GIT binary patch literal 1779 zcmVY`0e=R#BpL5>^4b{GY7FY5vtmNqAX3Vur^X#G+3m;hPJ^6Uc?6^`Ul*9 zz$Ey93KYRMu|Xv^Bnl!eja)a2B1oqSC9ObB>qcw*{ubM@<74}r!-vLJwlyJX<1`uP z_tAQM&U0VBulu?0$AE^0hK7cQhK7cQhK7cQ#{V1aX}pw(&H?!Gp+kp04*<7s-;M$J z84gC?vUa!ey;6+bZ6HKKtvn>CxCDC_xFEuU|`@YcDvp1B+ty{a@FbS>3_`3%=`hs`v6izr0gmI zRs(MZaKh{Le%a2?PS~CX>m(0=P*;%TGlBA~FMb2Ed7vCr^H-udlDyVzF@8 z0ZOHkGCMmvcjd~J->zoET_Rf95dnzE2w*>eFCRH_xfnWIOKKFjkw!ybj_dFJ!^o&|7by)_&Cvc+n(D(}U>Wm!h0Qb8t@K_Zcm0Tdrc zfcJyJ;3-iQUl0UAC(ANq`Jo;h$3d^xgX1`G90$f2{@0Y(EEGjSwOU1`QbDm;M5$E5 z^71mW*(??p7v;Nm?@p}F5L*@?S1cBPa^b>-|8#eE|EROGv(;cQfaiHwEEbr}X7D@@ zqtOV1!2q332ae-*G;mp#AxRQSr4kB-0+yDRkjZ3_PNxwLhnEV4!p|9FzXp(7?>Xxp z=D}bP!^6XQS(b05QmLyC9y~BelGFo0uP6#sRfR0eP}Pr*>dnrE`g zYh;AUWP-t9XxL*rg-}(sk?!Sk8OzJd$mjFOX0u4A(}=}lay%ZNQWWJpfE$}_i?_7H z)yc0Lz=_t@)*pzX=xb|hV=XN$uv)DRTbN8H7>!2gbh>TH2t`p)tJP30mr*Ph8|j`- zr;$u1)o?gGFGsOd^p;mdoWY==FL_y-3z= zQMX1t_{R}JRaMk#HAs?#LZN_cHj7j$g=jR2Xf%pYD3prDV&@e_`6*-UDu9Ki4MUH} z1|5LU0T?hC3@>(cbe#2gJZ8Jy4x7yev)TNqLb+L6)O}v9R3_A}i^1 z`VxTm089gj6VYQG+1qg;VgS$y;Hx&9?KM#pkGfngogfIXTCK2HET6K)dP3BFUQhQ= z`aF?H$gx=L!&W0NF;KoOns;X}S_%jg|nz7HO_pe0M31G}-v%T*1 zdcWv&I=Qv=fiZ?cq0k6Ckw_@fX!PH;TJ22$eqvWjSwRM2te=ZM{^H9rU__hXlQ6?XlQ6?XlQ6?XlOK<{{qsv VG{K?_#%%xq002ovPDHLkV1l^oWqkku literal 0 HcmV?d00001 diff --git a/Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png.yml b/Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png.yml new file mode 100644 index 0000000000..dabd6601f7 --- /dev/null +++ b/Resources/Textures/Interface/NavMap/beveled_diamond_east_west.png.yml @@ -0,0 +1,2 @@ +sample: + filter: true diff --git a/Resources/Textures/Interface/NavMap/beveled_diamond_north_south.png b/Resources/Textures/Interface/NavMap/beveled_diamond_north_south.png new file mode 100644 index 0000000000000000000000000000000000000000..af27998af8740ef9211b92fb9c5487f75b9b86cf GIT binary patch literal 1928 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