diff --git a/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml b/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml index 80bf5a3f8b..b4bd76c328 100644 --- a/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml +++ b/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml @@ -2,15 +2,15 @@ xmlns:ui="clr-namespace:Content.Client.Medical.CrewMonitoring" xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls" Title="{Loc 'crew-monitoring-user-interface-title'}" - SetSize="1200 700" - MinSize="1200 700"> + SetSize="1210 700" + MinSize="1210 700"> - + - diff --git a/Content.Client/Pinpointer/UI/NavMapControl.cs b/Content.Client/Pinpointer/UI/NavMapControl.cs index 438c06f7f2..a748dc4a6d 100644 --- a/Content.Client/Pinpointer/UI/NavMapControl.cs +++ b/Content.Client/Pinpointer/UI/NavMapControl.cs @@ -27,10 +27,12 @@ public partial class NavMapControl : MapGridControl [Dependency] private readonly IEntityManager _entManager = default!; private readonly SharedTransformSystem _transformSystem = default!; + public EntityUid? Owner; public EntityUid? MapUid; // Actions public event Action? TrackedEntitySelectedAction; + public event Action? PostWallDrawingAction; // Tracked data public Dictionary TrackedCoordinates = new(); @@ -358,6 +360,9 @@ public partial class NavMapControl : MapGridControl } } + if (PostWallDrawingAction != null) + PostWallDrawingAction.Invoke(handle); + // Beacons if (_beacons.Pressed) { @@ -455,7 +460,7 @@ public partial class NavMapControl : MapGridControl } } - private void UpdateNavMap() + protected virtual void UpdateNavMap() { if (_navMap == null || _grid == null) return; diff --git a/Content.Client/Power/PowerMonitoringConsoleBoundUserInterface.cs b/Content.Client/Power/PowerMonitoringConsoleBoundUserInterface.cs new file mode 100644 index 0000000000..dc1dcd03ef --- /dev/null +++ b/Content.Client/Power/PowerMonitoringConsoleBoundUserInterface.cs @@ -0,0 +1,52 @@ +using Content.Shared.Power; + +namespace Content.Client.Power; + +public sealed class PowerMonitoringConsoleBoundUserInterface : BoundUserInterface +{ + [ViewVariables] + private PowerMonitoringWindow? _menu; + + public PowerMonitoringConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { } + + protected override void Open() + { + _menu = new PowerMonitoringWindow(this, Owner); + _menu.OpenCentered(); + _menu.OnClose += Close; + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + var castState = (PowerMonitoringConsoleBoundInterfaceState) state; + + if (castState == null) + return; + + EntMan.TryGetComponent(Owner, out var xform); + _menu?.ShowEntites + (castState.TotalSources, + castState.TotalBatteryUsage, + castState.TotalLoads, + castState.AllEntries, + castState.FocusSources, + castState.FocusLoads, + xform?.Coordinates); + } + + public void SendPowerMonitoringConsoleMessage(NetEntity? netEntity, PowerMonitoringConsoleGroup group) + { + SendMessage(new PowerMonitoringConsoleMessage(netEntity, group)); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + if (!disposing) + return; + + _menu?.Dispose(); + } +} diff --git a/Content.Client/Power/PowerMonitoringConsoleNavMapControl.cs b/Content.Client/Power/PowerMonitoringConsoleNavMapControl.cs new file mode 100644 index 0000000000..ac2700564d --- /dev/null +++ b/Content.Client/Power/PowerMonitoringConsoleNavMapControl.cs @@ -0,0 +1,300 @@ +using Content.Client.Pinpointer.UI; +using Content.Shared.Pinpointer; +using Content.Shared.Power; +using Robust.Client.Graphics; +using Robust.Shared.Collections; +using Robust.Shared.Map.Components; +using System.Numerics; + +namespace Content.Client.Power; + +public sealed partial class PowerMonitoringConsoleNavMapControl : NavMapControl +{ + [Dependency] private readonly IEntityManager _entManager = default!; + + // Cable indexing + // 0: CableType.HighVoltage + // 1: CableType.MediumVoltage + // 2: CableType.Apc + + private readonly Color[] _powerCableColors = { Color.OrangeRed, Color.Yellow, Color.LimeGreen }; + private readonly Vector2[] _powerCableOffsets = { new Vector2(-0.2f, -0.2f), Vector2.Zero, new Vector2(0.2f, 0.2f) }; + private Dictionary _sRGBLookUp = new Dictionary(); + + public PowerMonitoringCableNetworksComponent? PowerMonitoringCableNetworks; + public List HiddenLineGroups = new(); + public Dictionary>? PowerCableNetwork; + public Dictionary>? FocusCableNetwork; + + private MapGridComponent? _grid; + + public PowerMonitoringConsoleNavMapControl() : base() + { + // Set colors + TileColor = new Color(30, 57, 67); + WallColor = new Color(102, 164, 217); + + PostWallDrawingAction += DrawAllCableNetworks; + } + + protected override void UpdateNavMap() + { + base.UpdateNavMap(); + + if (Owner == null) + return; + + if (!_entManager.TryGetComponent(Owner, out var cableNetworks)) + return; + + if (!_entManager.TryGetComponent(MapUid, out _grid)) + return; + + PowerCableNetwork = GetDecodedPowerCableChunks(cableNetworks.AllChunks, _grid); + FocusCableNetwork = GetDecodedPowerCableChunks(cableNetworks.FocusChunks, _grid); + } + + public void DrawAllCableNetworks(DrawingHandleScreen handle) + { + // Draw full cable network + if (PowerCableNetwork != null && PowerCableNetwork.Count > 0) + { + var modulator = (FocusCableNetwork != null && FocusCableNetwork.Count > 0) ? Color.DimGray : Color.White; + DrawCableNetwork(handle, PowerCableNetwork, modulator); + } + + // Draw focus network + if (FocusCableNetwork != null && FocusCableNetwork.Count > 0) + DrawCableNetwork(handle, FocusCableNetwork, Color.White); + } + + public void DrawCableNetwork(DrawingHandleScreen handle, Dictionary> fullCableNetwork, Color modulator) + { + var offset = GetOffset(); + var area = new Box2(-WorldRange, -WorldRange, WorldRange + 1f, WorldRange + 1f).Translated(offset); + + if (WorldRange / WorldMaxRange > 0.5f) + { + var cableNetworks = new ValueList[3]; + + foreach ((var chunk, var chunkedLines) in fullCableNetwork) + { + var offsetChunk = new Vector2(chunk.X, chunk.Y) * SharedNavMapSystem.ChunkSize; + + if (offsetChunk.X < area.Left - SharedNavMapSystem.ChunkSize || offsetChunk.X > area.Right) + continue; + + if (offsetChunk.Y < area.Bottom - SharedNavMapSystem.ChunkSize || offsetChunk.Y > area.Top) + continue; + + foreach (var chunkedLine in chunkedLines) + { + if (HiddenLineGroups.Contains(chunkedLine.Group)) + continue; + + var start = Scale(chunkedLine.Origin - new Vector2(offset.X, -offset.Y)); + var end = Scale(chunkedLine.Terminus - new Vector2(offset.X, -offset.Y)); + + cableNetworks[(int) chunkedLine.Group].Add(start); + cableNetworks[(int) chunkedLine.Group].Add(end); + } + } + + for (int cableNetworkIdx = 0; cableNetworkIdx < cableNetworks.Length; cableNetworkIdx++) + { + var cableNetwork = cableNetworks[cableNetworkIdx]; + + if (cableNetwork.Count > 0) + { + var color = _powerCableColors[cableNetworkIdx] * modulator; + + if (!_sRGBLookUp.TryGetValue(color, out var sRGB)) + { + sRGB = Color.ToSrgb(color); + _sRGBLookUp[color] = sRGB; + } + + handle.DrawPrimitives(DrawPrimitiveTopology.LineList, cableNetwork.Span, sRGB); + } + } + } + + else + { + var cableVertexUVs = new ValueList[3]; + + foreach ((var chunk, var chunkedLines) in fullCableNetwork) + { + var offsetChunk = new Vector2(chunk.X, chunk.Y) * SharedNavMapSystem.ChunkSize; + + if (offsetChunk.X < area.Left - SharedNavMapSystem.ChunkSize || offsetChunk.X > area.Right) + continue; + + if (offsetChunk.Y < area.Bottom - SharedNavMapSystem.ChunkSize || offsetChunk.Y > area.Top) + continue; + + foreach (var chunkedLine in chunkedLines) + { + if (HiddenLineGroups.Contains(chunkedLine.Group)) + continue; + + var leftTop = Scale(new Vector2 + (Math.Min(chunkedLine.Origin.X, chunkedLine.Terminus.X) - 0.1f, + Math.Min(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) - 0.1f) + - new Vector2(offset.X, -offset.Y)); + + var rightTop = Scale(new Vector2 + (Math.Max(chunkedLine.Origin.X, chunkedLine.Terminus.X) + 0.1f, + Math.Min(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) - 0.1f) + - new Vector2(offset.X, -offset.Y)); + + var leftBottom = Scale(new Vector2 + (Math.Min(chunkedLine.Origin.X, chunkedLine.Terminus.X) - 0.1f, + Math.Max(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) + 0.1f) + - new Vector2(offset.X, -offset.Y)); + + var rightBottom = Scale(new Vector2 + (Math.Max(chunkedLine.Origin.X, chunkedLine.Terminus.X) + 0.1f, + Math.Max(chunkedLine.Origin.Y, chunkedLine.Terminus.Y) + 0.1f) + - new Vector2(offset.X, -offset.Y)); + + cableVertexUVs[(int) chunkedLine.Group].Add(leftBottom); + cableVertexUVs[(int) chunkedLine.Group].Add(leftTop); + cableVertexUVs[(int) chunkedLine.Group].Add(rightBottom); + cableVertexUVs[(int) chunkedLine.Group].Add(leftTop); + cableVertexUVs[(int) chunkedLine.Group].Add(rightBottom); + cableVertexUVs[(int) chunkedLine.Group].Add(rightTop); + } + } + + for (int cableNetworkIdx = 0; cableNetworkIdx < cableVertexUVs.Length; cableNetworkIdx++) + { + var cableVertexUV = cableVertexUVs[cableNetworkIdx]; + + if (cableVertexUV.Count > 0) + { + var color = _powerCableColors[cableNetworkIdx] * modulator; + + if (!_sRGBLookUp.TryGetValue(color, out var sRGB)) + { + sRGB = Color.ToSrgb(color); + _sRGBLookUp[color] = sRGB; + } + + handle.DrawPrimitives(DrawPrimitiveTopology.TriangleList, cableVertexUV.Span, sRGB); + } + } + } + } + + public Dictionary>? GetDecodedPowerCableChunks(Dictionary? chunks, MapGridComponent? grid) + { + if (chunks == null || grid == null) + return null; + + var decodedOutput = new Dictionary>(); + + foreach ((var chunkOrigin, var chunk) in chunks) + { + var list = new List(); + + for (int cableIdx = 0; cableIdx < chunk.PowerCableData.Length; cableIdx++) + { + var chunkMask = chunk.PowerCableData[cableIdx]; + + Vector2 offset = _powerCableOffsets[cableIdx]; + + for (var chunkIdx = 0; chunkIdx < SharedNavMapSystem.ChunkSize * SharedNavMapSystem.ChunkSize; chunkIdx++) + { + var value = (int) Math.Pow(2, chunkIdx); + var mask = chunkMask & value; + + if (mask == 0x0) + continue; + + var relativeTile = SharedNavMapSystem.GetTile(mask); + var tile = (chunk.Origin * SharedNavMapSystem.ChunkSize + relativeTile) * grid.TileSize; + var position = new Vector2(tile.X, -tile.Y); + + PowerCableChunk neighborChunk; + bool neighbor; + + // Note: we only check the north and east neighbors + + // East + if (relativeTile.X == SharedNavMapSystem.ChunkSize - 1) + { + neighbor = chunks.TryGetValue(chunkOrigin + new Vector2i(1, 0), out neighborChunk) && + (neighborChunk.PowerCableData[cableIdx] & SharedNavMapSystem.GetFlag(new Vector2i(0, relativeTile.Y))) != 0x0; + } + else + { + var flag = SharedNavMapSystem.GetFlag(relativeTile + new Vector2i(1, 0)); + neighbor = (chunkMask & flag) != 0x0; + } + + if (neighbor) + { + // Add points + var line = new PowerMonitoringConsoleLine + (position + offset + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f), + position + new Vector2(1f, 0f) + offset + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f), + (PowerMonitoringConsoleLineGroup) cableIdx); + + list.Add(line); + } + + // North + if (relativeTile.Y == SharedNavMapSystem.ChunkSize - 1) + { + neighbor = chunks.TryGetValue(chunkOrigin + new Vector2i(0, 1), out neighborChunk) && + (neighborChunk.PowerCableData[cableIdx] & SharedNavMapSystem.GetFlag(new Vector2i(relativeTile.X, 0))) != 0x0; + } + else + { + var flag = SharedNavMapSystem.GetFlag(relativeTile + new Vector2i(0, 1)); + neighbor = (chunkMask & flag) != 0x0; + } + + if (neighbor) + { + // Add points + var line = new PowerMonitoringConsoleLine + (position + offset + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f), + position + new Vector2(0f, -1f) + offset + new Vector2(grid.TileSize * 0.5f, -grid.TileSize * 0.5f), + (PowerMonitoringConsoleLineGroup) cableIdx); + + list.Add(line); + } + } + + } + + if (list.Count > 0) + decodedOutput.Add(chunkOrigin, list); + } + + return decodedOutput; + } +} + +public struct PowerMonitoringConsoleLine +{ + public readonly Vector2 Origin; + public readonly Vector2 Terminus; + public readonly PowerMonitoringConsoleLineGroup Group; + + public PowerMonitoringConsoleLine(Vector2 origin, Vector2 terminus, PowerMonitoringConsoleLineGroup group) + { + Origin = origin; + Terminus = terminus; + Group = group; + } +} + +public enum PowerMonitoringConsoleLineGroup : byte +{ + HighVoltage, + MediumVoltage, + Apc, +} diff --git a/Content.Client/Power/PowerMonitoringWindow.xaml b/Content.Client/Power/PowerMonitoringWindow.xaml index 826da19d90..cdd5b8f13f 100644 --- a/Content.Client/Power/PowerMonitoringWindow.xaml +++ b/Content.Client/Power/PowerMonitoringWindow.xaml @@ -1,20 +1,117 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + diff --git a/Content.Client/Power/PowerMonitoringWindow.xaml.Widgets.cs b/Content.Client/Power/PowerMonitoringWindow.xaml.Widgets.cs new file mode 100644 index 0000000000..25a586a75d --- /dev/null +++ b/Content.Client/Power/PowerMonitoringWindow.xaml.Widgets.cs @@ -0,0 +1,490 @@ +using Content.Client.Stylesheets; +using Content.Shared.Power; +using Robust.Client.Graphics; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Utility; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Numerics; + +namespace Content.Client.Power; + +public sealed partial class PowerMonitoringWindow +{ + private SpriteSpecifier.Texture _sourceIcon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/PowerMonitoring/source_arrow.png")); + private SpriteSpecifier.Texture _loadIconPath = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/PowerMonitoring/load_arrow.png")); + + private bool _autoScrollActive = false; + private bool _autoScrollAwaitsUpdate = false; + + private void UpdateWindowConsoleEntry + (BoxContainer masterContainer, + int index, + PowerMonitoringConsoleEntry entry, + PowerMonitoringConsoleEntry[] focusSources, + PowerMonitoringConsoleEntry[] focusLoads) + { + UpdateWindowConsoleEntry(masterContainer, index, entry); + + var windowEntry = masterContainer.GetChild(index) as PowerMonitoringWindowEntry; + + // If we exit here, something was added to the container that shouldn't have been added + if (windowEntry == null) + return; + + // Update sources and loads + UpdateEntrySourcesOrLoads(masterContainer, windowEntry.SourcesContainer, focusSources, _sourceIcon); + UpdateEntrySourcesOrLoads(masterContainer, windowEntry.LoadsContainer, focusLoads, _loadIconPath); + + windowEntry.MainContainer.Visible = true; + } + + private void UpdateWindowConsoleEntry(BoxContainer masterContainer, int index, PowerMonitoringConsoleEntry entry) + { + PowerMonitoringWindowEntry? windowEntry; + + // Add missing children + if (index >= masterContainer.ChildCount) + { + // Add basic entry + windowEntry = new PowerMonitoringWindowEntry(entry); + masterContainer.AddChild(windowEntry); + + // Selection action + windowEntry.Button.OnButtonUp += args => + { + windowEntry.SourcesContainer.DisposeAllChildren(); + windowEntry.LoadsContainer.DisposeAllChildren(); + ButtonAction(windowEntry, masterContainer); + }; + } + + else + { + windowEntry = masterContainer.GetChild(index) as PowerMonitoringWindowEntry; + } + + // If we exit here, something was added to the container that shouldn't have been added + if (windowEntry == null) + return; + + windowEntry.NetEntity = entry.NetEntity; + windowEntry.Entry = entry; + windowEntry.MainContainer.Visible = false; + + UpdateWindowEntryButton(entry.NetEntity, windowEntry.Button, entry); + } + + public void UpdateWindowEntryButton(NetEntity netEntity, PowerMonitoringButton button, PowerMonitoringConsoleEntry entry) + { + if (!netEntity.IsValid()) + return; + + if (entry.MetaData == null) + return; + + // Update button style + if (netEntity == _focusEntity) + button.AddStyleClass(StyleNano.StyleClassButtonColorGreen); + + else + button.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen); + + // Update sprite + if (entry.MetaData.Value.SpritePath != string.Empty && entry.MetaData.Value.SpriteState != string.Empty) + button.TextureRect.Texture = _spriteSystem.Frame0(new SpriteSpecifier.Rsi(new ResPath(entry.MetaData.Value.SpritePath), entry.MetaData.Value.SpriteState)); + + // Update name + var name = Loc.GetString(entry.MetaData.Value.EntityName); + button.NameLocalized.Text = name; + + // Update tool tip + button.ToolTip = Loc.GetString(name); + + // Update power value + button.PowerValue.Text = Loc.GetString("power-monitoring-window-value", ("value", entry.PowerValue)); + } + + private void UpdateEntrySourcesOrLoads(BoxContainer masterContainer, BoxContainer currentContainer, PowerMonitoringConsoleEntry[]? entries, SpriteSpecifier.Texture icon) + { + if (currentContainer == null) + return; + + if (entries == null || entries.Length == 0) + { + currentContainer.RemoveAllChildren(); + return; + } + + // Remove excess children + while (currentContainer.ChildCount > entries.Length) + { + currentContainer.RemoveChild(currentContainer.GetChild(currentContainer.ChildCount - 1)); + } + + // Add missing children + while (currentContainer.ChildCount < entries.Length) + { + var entry = entries[currentContainer.ChildCount]; + var subEntry = new PowerMonitoringWindowSubEntry(entry); + currentContainer.AddChild(subEntry); + + // Selection action + subEntry.Button.OnButtonUp += args => { ButtonAction(subEntry, masterContainer); }; + } + + if (!_entManager.TryGetComponent(_owner, out var console)) + return; + + // Update all children + foreach (var child in currentContainer.Children) + { + if (child is not PowerMonitoringWindowSubEntry) + continue; + + var castChild = (PowerMonitoringWindowSubEntry) child; + + if (castChild == null) + continue; + + if (castChild.Icon != null) + castChild.Icon.Texture = _spriteSystem.Frame0(icon); + + var entry = entries[child.GetPositionInParent()]; + + castChild.NetEntity = entry.NetEntity; + castChild.Entry = entry; + + UpdateWindowEntryButton(entry.NetEntity, castChild.Button, entries.ElementAt(child.GetPositionInParent())); + } + } + + private void ButtonAction(PowerMonitoringWindowBaseEntry entry, BoxContainer masterContainer) + { + // Toggle off button? + if (entry.NetEntity == _focusEntity) + { + entry.Button.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen); + _focusEntity = null; + + // Request an update from the power monitoring system + SendPowerMonitoringConsoleMessageAction?.Invoke(null, entry.Entry.Group); + + return; + } + + // Otherwise, toggle on + entry.Button.AddStyleClass(StyleNano.StyleClassButtonColorGreen); + + ActivateAutoScrollToFocus(); + + // Toggle off the old button (if applicable) + if (_focusEntity != null) + { + foreach (PowerMonitoringWindowEntry sibling in masterContainer.Children) + { + if (sibling.NetEntity == _focusEntity) + { + sibling.Button.RemoveStyleClass(StyleNano.StyleClassButtonColorGreen); + break; + } + } + } + + // Center the nav map on selected entity + _focusEntity = entry.NetEntity; + + if (!NavMap.TrackedEntities.TryGetValue(entry.NetEntity, out var blip)) + return; + + NavMap.CenterToCoordinates(blip.Coordinates); + + // Switch tabs + SwitchTabsBasedOnPowerMonitoringConsoleGroup(entry.Entry.Group); + + // Send an update from the power monitoring system + SendPowerMonitoringConsoleMessageAction?.Invoke(_focusEntity, entry.Entry.Group); + } + + private void ActivateAutoScrollToFocus() + { + _autoScrollActive = false; + _autoScrollAwaitsUpdate = true; + } + + private bool TryGetNextScrollPosition([NotNullWhen(true)] out float? nextScrollPosition) + { + nextScrollPosition = null; + + var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) 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 == null || control is not PowerMonitoringWindowEntry) + continue; + + if (((PowerMonitoringWindowEntry) control).NetEntity == _focusEntity) + return true; + + nextScrollPosition += control.Height; + } + + // Failed to find control + nextScrollPosition = null; + + return false; + } + + private bool TryGetVerticalScrollbar(ScrollContainer scroll, [NotNullWhen(true)] out VScrollBar? vScrollBar) + { + vScrollBar = null; + + foreach (var child in scroll.Children) + { + if (child is not VScrollBar) + continue; + + var castChild = child as VScrollBar; + + if (castChild != null) + { + vScrollBar = castChild; + return true; + } + } + + return false; + } + + private void AutoScrollToFocus() + { + if (!_autoScrollActive) + return; + + var scroll = MasterTabContainer.Children.ElementAt(MasterTabContainer.CurrentTab) 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 void UpdateWarningLabel(PowerMonitoringFlags flags) + { + if (flags == PowerMonitoringFlags.None) + { + SystemWarningPanel.Visible = false; + return; + } + + var msg = new FormattedMessage(); + + if ((flags & PowerMonitoringFlags.RoguePowerConsumer) != 0) + { + SystemWarningPanel.PanelOverride = new StyleBoxFlat + { + BackgroundColor = Color.Red, + BorderColor = Color.DarkRed, + BorderThickness = new Thickness(2), + }; + + msg.AddMarkup(Loc.GetString("power-monitoring-window-rogue-power-consumer")); + SystemWarningPanel.Visible = true; + } + + else if ((flags & PowerMonitoringFlags.PowerNetAbnormalities) != 0) + { + SystemWarningPanel.PanelOverride = new StyleBoxFlat + { + BackgroundColor = Color.Orange, + BorderColor = Color.DarkOrange, + BorderThickness = new Thickness(2), + }; + + msg.AddMarkup(Loc.GetString("power-monitoring-window-power-net-abnormalities")); + SystemWarningPanel.Visible = true; + } + + SystemWarningLabel.SetMessage(msg); + } + + private void SwitchTabsBasedOnPowerMonitoringConsoleGroup(PowerMonitoringConsoleGroup group) + { + switch (group) + { + case PowerMonitoringConsoleGroup.Generator: + MasterTabContainer.CurrentTab = 0; break; + case PowerMonitoringConsoleGroup.SMES: + MasterTabContainer.CurrentTab = 1; break; + case PowerMonitoringConsoleGroup.Substation: + MasterTabContainer.CurrentTab = 2; break; + case PowerMonitoringConsoleGroup.APC: + MasterTabContainer.CurrentTab = 3; break; + } + } + + private PowerMonitoringConsoleGroup GetCurrentPowerMonitoringConsoleGroup() + { + return (PowerMonitoringConsoleGroup) MasterTabContainer.CurrentTab; + } +} + +public sealed class PowerMonitoringWindowEntry : PowerMonitoringWindowBaseEntry +{ + public BoxContainer MainContainer; + public BoxContainer SourcesContainer; + public BoxContainer LoadsContainer; + + public PowerMonitoringWindowEntry(PowerMonitoringConsoleEntry entry) : base(entry) + { + Entry = entry; + + // Alignment + Orientation = LayoutOrientation.Vertical; + HorizontalExpand = true; + + // Update selection button + Button.StyleClasses.Add("OpenLeft"); + AddChild(Button); + + // Grid container to hold sub containers + MainContainer = new BoxContainer() + { + Orientation = LayoutOrientation.Vertical, + HorizontalExpand = true, + Margin = new Thickness(8, 0, 0, 0), + Visible = false, + }; + + AddChild(MainContainer); + + // Grid container to hold the list of sources when selected + SourcesContainer = new BoxContainer() + { + Orientation = LayoutOrientation.Vertical, + HorizontalExpand = true, + }; + + MainContainer.AddChild(SourcesContainer); + + // Grid container to hold the list of loads when selected + LoadsContainer = new BoxContainer() + { + Orientation = LayoutOrientation.Vertical, + HorizontalExpand = true, + }; + + MainContainer.AddChild(LoadsContainer); + } +} + +public sealed class PowerMonitoringWindowSubEntry : PowerMonitoringWindowBaseEntry +{ + public TextureRect? Icon; + + public PowerMonitoringWindowSubEntry(PowerMonitoringConsoleEntry entry) : base(entry) + { + Orientation = LayoutOrientation.Horizontal; + HorizontalExpand = true; + + // Source/load icon + Icon = new TextureRect() + { + VerticalAlignment = VAlignment.Center, + Margin = new Thickness(0, 0, 2, 0), + }; + + AddChild(Icon); + + // Selection button + Button.StyleClasses.Add("OpenBoth"); + AddChild(Button); + } +} + +public abstract class PowerMonitoringWindowBaseEntry : BoxContainer +{ + public NetEntity NetEntity; + public PowerMonitoringConsoleEntry Entry; + public PowerMonitoringButton Button; + + public PowerMonitoringWindowBaseEntry(PowerMonitoringConsoleEntry entry) + { + Entry = entry; + + // Add selection button (properties set by derivative classes) + Button = new PowerMonitoringButton(); + } +} + +public sealed class PowerMonitoringButton : Button +{ + public BoxContainer MainContainer; + public TextureRect TextureRect; + public Label NameLocalized; + public Label PowerValue; + + public PowerMonitoringButton() + { + HorizontalExpand = true; + VerticalExpand = true; + Margin = new Thickness(0f, 1f, 0f, 1f); + + MainContainer = new BoxContainer() + { + Orientation = BoxContainer.LayoutOrientation.Horizontal, + HorizontalExpand = true, + SetHeight = 32f, + }; + + AddChild(MainContainer); + + TextureRect = new TextureRect() + { + HorizontalAlignment = HAlignment.Center, + VerticalAlignment = VAlignment.Center, + SetSize = new Vector2(32f, 32f), + Margin = new Thickness(0f, 0f, 5f, 0f), + }; + + MainContainer.AddChild(TextureRect); + + NameLocalized = new Label() + { + HorizontalExpand = true, + ClipText = true, + }; + + MainContainer.AddChild(NameLocalized); + + PowerValue = new Label() + { + HorizontalAlignment = HAlignment.Right, + SetWidth = 72f, + Margin = new Thickness(10, 0, 0, 0), + ClipText = true, + }; + + MainContainer.AddChild(PowerValue); + } +} diff --git a/Content.Client/Power/PowerMonitoringWindow.xaml.cs b/Content.Client/Power/PowerMonitoringWindow.xaml.cs index 5cc48395a3..edc0eaa18a 100644 --- a/Content.Client/Power/PowerMonitoringWindow.xaml.cs +++ b/Content.Client/Power/PowerMonitoringWindow.xaml.cs @@ -1,84 +1,317 @@ -using System.Linq; -using System.Numerics; -using Content.Client.Computer; +using Content.Client.Pinpointer.UI; +using Content.Client.UserInterface.Controls; using Content.Shared.Power; -using JetBrains.Annotations; using Robust.Client.AutoGenerated; using Robust.Client.GameObjects; -using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.CustomControls; using Robust.Client.UserInterface.XAML; -using Robust.Shared.Graphics; -using Robust.Shared.Graphics.RSI; -using Robust.Shared.Prototypes; +using Robust.Shared.Map; +using Robust.Shared.Timing; +using Robust.Shared.Utility; +using System.Linq; namespace Content.Client.Power; [GenerateTypedNameReferences] -public sealed partial class PowerMonitoringWindow : DefaultWindow, IComputerWindow +public sealed partial class PowerMonitoringWindow : FancyWindow { - [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - private readonly SpriteSystem _spriteSystem = default!; + private readonly IEntityManager _entManager; + private readonly SpriteSystem _spriteSystem; + private readonly IGameTiming _gameTiming; - public PowerMonitoringWindow() + private const float BlinkFrequency = 1f; + + private EntityUid? _owner; + private NetEntity? _focusEntity; + + public event Action? SendPowerMonitoringConsoleMessageAction; + + private Dictionary _groupBlips = new() + { + { PowerMonitoringConsoleGroup.Generator, (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png")), Color.Purple) }, + { PowerMonitoringConsoleGroup.SMES, (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_hexagon.png")), Color.OrangeRed) }, + { PowerMonitoringConsoleGroup.Substation, (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_square.png")), Color.Yellow) }, + { PowerMonitoringConsoleGroup.APC, (new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_triangle.png")), Color.LimeGreen) }, + }; + + public PowerMonitoringWindow(PowerMonitoringConsoleBoundUserInterface userInterface, EntityUid? owner) { RobustXamlLoader.Load(this); - SetSize = MinSize = new Vector2(300, 450); - IoCManager.InjectDependencies(this); - _spriteSystem = IoCManager.Resolve().System(); - MasterTabContainer.SetTabTitle(0, Loc.GetString("power-monitoring-window-tab-sources")); - MasterTabContainer.SetTabTitle(1, Loc.GetString("power-monitoring-window-tab-loads")); + _entManager = IoCManager.Resolve(); + _gameTiming = IoCManager.Resolve(); + + _spriteSystem = _entManager.System(); + _owner = owner; + + // Pass owner to nav map + NavMap.Owner = _owner; + + // Set nav map grid uid + var stationName = Loc.GetString("power-monitoring-window-unknown-location"); + + if (_entManager.TryGetComponent(owner, out var xform)) + { + NavMap.MapUid = xform.GridUid; + + // Assign station name + if (_entManager.TryGetComponent(xform.GridUid, out var stationMetaData)) + stationName = stationMetaData.EntityName; + + var msg = new FormattedMessage(); + msg.AddMarkup(Loc.GetString("power-monitoring-window-station-name", ("stationName", stationName))); + + StationName.SetMessage(msg); + } + + else + { + StationName.SetMessage(stationName); + NavMap.Visible = false; + } + + // Set trackable entity selected action + NavMap.TrackedEntitySelectedAction += SetTrackedEntityFromNavMap; + + // Update nav map + NavMap.ForceNavMapUpdate(); + + // Set UI tab titles + MasterTabContainer.SetTabTitle(0, Loc.GetString("power-monitoring-window-label-sources")); + MasterTabContainer.SetTabTitle(1, Loc.GetString("power-monitoring-window-label-smes")); + MasterTabContainer.SetTabTitle(2, Loc.GetString("power-monitoring-window-label-substation")); + MasterTabContainer.SetTabTitle(3, Loc.GetString("power-monitoring-window-label-apc")); + + // Track when the MasterTabContainer changes its tab + MasterTabContainer.OnTabChanged += OnTabChanged; + + // Set UI toggles + ShowHVCable.OnToggled += _ => OnShowCableToggled(PowerMonitoringConsoleLineGroup.HighVoltage); + ShowMVCable.OnToggled += _ => OnShowCableToggled(PowerMonitoringConsoleLineGroup.MediumVoltage); + ShowLVCable.OnToggled += _ => OnShowCableToggled(PowerMonitoringConsoleLineGroup.Apc); + + // Set power monitoring message action + SendPowerMonitoringConsoleMessageAction += userInterface.SendPowerMonitoringConsoleMessage; } - public void UpdateState(PowerMonitoringConsoleBoundInterfaceState scc) + private void OnTabChanged(int tab) { - UpdateList(TotalSourcesNum, scc.TotalSources, SourcesList, scc.Sources); - var loads = scc.Loads; - if (!ShowInactiveConsumersCheckBox.Pressed) - { - // Not showing inactive consumers, so hiding them. - // This means filtering out loads that are not either: - // + Batteries (always important) - // + Meaningful (size above 0) - loads = loads.Where(a => a.IsBattery || a.Size > 0.0f).ToArray(); - } - UpdateList(TotalLoadsNum, scc.TotalLoads, LoadsList, loads); + SendPowerMonitoringConsoleMessageAction?.Invoke(_focusEntity, (PowerMonitoringConsoleGroup) tab); } - public void UpdateList(Label number, double numberVal, ItemList list, PowerMonitoringConsoleEntry[] listVal) + private void OnShowCableToggled(PowerMonitoringConsoleLineGroup lineGroup) { - number.Text = Loc.GetString("power-monitoring-window-value", ("value", numberVal)); - // This magic is important to prevent scrolling issues. - while (list.Count > listVal.Length) + if (!NavMap.HiddenLineGroups.Remove(lineGroup)) + NavMap.HiddenLineGroups.Add(lineGroup); + } + + public void ShowEntites + (double totalSources, + double totalBatteryUsage, + double totalLoads, + PowerMonitoringConsoleEntry[] allEntries, + PowerMonitoringConsoleEntry[] focusSources, + PowerMonitoringConsoleEntry[] focusLoads, + EntityCoordinates? monitorCoords) + { + if (_owner == null) + return; + + if (!_entManager.TryGetComponent(_owner.Value, out var console)) + return; + + // Update power status text + TotalSources.Text = Loc.GetString("power-monitoring-window-value", ("value", totalSources)); + TotalBatteryUsage.Text = Loc.GetString("power-monitoring-window-value", ("value", totalBatteryUsage)); + TotalLoads.Text = Loc.GetString("power-monitoring-window-value", ("value", totalLoads)); + + // 10+% of station power is being drawn from batteries + TotalBatteryUsage.FontColorOverride = (totalSources * 0.1111f) < totalBatteryUsage ? new Color(180, 0, 0) : Color.White; + + // Station generator and battery output is less than the current demand + TotalLoads.FontColorOverride = (totalSources + totalBatteryUsage) < totalLoads && + !MathHelper.CloseToPercent(totalSources + totalBatteryUsage, totalLoads, 0.1f) ? new Color(180, 0, 0) : Color.White; + + // Update system warnings + UpdateWarningLabel(console.Flags); + + // Reset nav map values + NavMap.TrackedCoordinates.Clear(); + NavMap.TrackedEntities.Clear(); + + // Draw entities on the nav map + var entitiesOfInterest = new List(); + + if (_focusEntity != null) { - list.RemoveAt(list.Count - 1); + entitiesOfInterest.Add(_focusEntity.Value); + + foreach (var entry in focusSources) + entitiesOfInterest.Add(entry.NetEntity); + + foreach (var entry in focusLoads) + entitiesOfInterest.Add(entry.NetEntity); } - while (list.Count < listVal.Length) + + focusSources.Concat(focusLoads); + + foreach ((var netEntity, var metaData) in console.PowerMonitoringDeviceMetaData) { - list.AddItem("YOU SHOULD NEVER SEE THIS (REALLY!)", null, false); + if (NavMap.Visible) + AddTrackedEntityToNavMap(netEntity, metaData, entitiesOfInterest); } - // Now overwrite the items properly... - for (var i = 0; i < listVal.Length; i++) + + // Show monitor location + var mon = _entManager.GetNetEntity(_owner); + + if (monitorCoords != null && mon != null) { - var ent = listVal[i]; - _prototypeManager.TryIndex(ent.IconEntityPrototypeId, out EntityPrototype? entityPrototype); - IRsiStateLike? iconState = null; - if (entityPrototype != null) - iconState = _spriteSystem.GetPrototypeIcon(entityPrototype); - var icon = iconState?.GetFrame(RsiDirection.South, 0); - var item = list[i]; - item.Text = $"{ent.NameLocalized} {Loc.GetString("power-monitoring-window-value", ("value", ent.Size))}"; - item.Icon = icon; + var texture = _spriteSystem.Frame0(new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/NavMap/beveled_circle.png"))); + var blip = new NavMapBlip(monitorCoords.Value, texture, Color.Cyan, true, false); + NavMap.TrackedEntities[mon.Value] = blip; } + + // Update nav map + NavMap.ForceNavMapUpdate(); + + // If the entry group doesn't match the current tab, the data is out dated, do not use it + if (allEntries.Length > 0 && allEntries[0].Group != GetCurrentPowerMonitoringConsoleGroup()) + return; + + // Assign meta data to the console entries and sort them + allEntries = GetUpdatedPowerMonitoringConsoleEntries(allEntries, console); + focusSources = GetUpdatedPowerMonitoringConsoleEntries(focusSources, console); + focusLoads = GetUpdatedPowerMonitoringConsoleEntries(focusLoads, console); + + // Get current console entry container + BoxContainer currentContainer = SourcesList; + switch (GetCurrentPowerMonitoringConsoleGroup()) + { + case PowerMonitoringConsoleGroup.SMES: + currentContainer = SMESList; break; + case PowerMonitoringConsoleGroup.Substation: + currentContainer = SubstationList; break; + case PowerMonitoringConsoleGroup.APC: + currentContainer = ApcList; break; + } + + // Clear excess children from the container + while (currentContainer.ChildCount > allEntries.Length) + currentContainer.RemoveChild(currentContainer.GetChild(currentContainer.ChildCount - 1)); + + // Update the remaining children + for (var index = 0; index < allEntries.Length; index++) + { + var entry = allEntries[index]; + + if (entry.NetEntity == _focusEntity) + UpdateWindowConsoleEntry(currentContainer, index, entry, focusSources, focusLoads); + + else + UpdateWindowConsoleEntry(currentContainer, index, entry); + } + + // Auto-scroll renable + if (_autoScrollAwaitsUpdate) + { + _autoScrollActive = true; + _autoScrollAwaitsUpdate = false; + } + } + + private void AddTrackedEntityToNavMap(NetEntity netEntity, PowerMonitoringDeviceMetaData metaData, List entitiesOfInterest) + { + if (!_groupBlips.TryGetValue(metaData.Group, out var data)) + return; + + var usedEntity = (metaData.CollectionMaster != null) ? metaData.CollectionMaster : netEntity; + var coords = _entManager.GetCoordinates(metaData.Coordinates); + var texture = data.Item1; + var color = data.Item2; + var blink = usedEntity == _focusEntity; + var modulator = Color.White; + + if (_focusEntity != null && usedEntity != _focusEntity && !entitiesOfInterest.Contains(usedEntity.Value)) + modulator = Color.DimGray; + + var blip = new NavMapBlip(coords, _spriteSystem.Frame0(texture), color * modulator, blink); + NavMap.TrackedEntities[netEntity] = blip; + } + + private void SetTrackedEntityFromNavMap(NetEntity? netEntity) + { + if (netEntity == null) + return; + + if (!_entManager.TryGetComponent(_owner, out var console)) + return; + + if (!console.PowerMonitoringDeviceMetaData.TryGetValue(netEntity.Value, out var metaData)) + return; + + // Switch entity for master, if applicable + // The master will always be in the same group as the entity + if (metaData.CollectionMaster != null) + netEntity = metaData.CollectionMaster; + + _focusEntity = netEntity; + + // Switch tabs + SwitchTabsBasedOnPowerMonitoringConsoleGroup(metaData.Group); + + // Get the scroll position of the selected entity on the selected button the UI + ActivateAutoScrollToFocus(); + + // Send message to console that the focus has changed + SendPowerMonitoringConsoleMessageAction?.Invoke(_focusEntity, metaData.Group); + } + + protected override void FrameUpdate(FrameEventArgs args) + { + AutoScrollToFocus(); + + // Warning sign pulse + var lit = _gameTiming.RealTime.TotalSeconds % BlinkFrequency > BlinkFrequency / 2f; + SystemWarningPanel.Modulate = lit ? Color.White : new Color(178, 178, 178); + } + + private PowerMonitoringConsoleEntry[] GetUpdatedPowerMonitoringConsoleEntries(PowerMonitoringConsoleEntry[] entries, PowerMonitoringConsoleComponent console) + { + for (int i = 0; i < entries.Length; i++) + { + var entry = entries[i]; + + if (!console.PowerMonitoringDeviceMetaData.TryGetValue(entry.NetEntity, out var metaData)) + continue; + + entries[i].MetaData = metaData; + } + + // Sort all devices alphabetically by their entity name (not by power usage; otherwise their position on the UI will shift) + Array.Sort(entries, AlphabeticalSort); + + return entries; + } + + private int AlphabeticalSort(PowerMonitoringConsoleEntry x, PowerMonitoringConsoleEntry y) + { + if (x.MetaData?.EntityName == null) + return -1; + + if (y.MetaData?.EntityName == null) + return 1; + + return x.MetaData.Value.EntityName.CompareTo(y.MetaData.Value.EntityName); } } -[UsedImplicitly] -public sealed class PowerMonitoringConsoleBoundUserInterface : ComputerBoundUserInterface +public struct PowerMonitoringConsoleTrackable { - public PowerMonitoringConsoleBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + public EntityUid EntityUid; + public PowerMonitoringConsoleGroup Group; + + public PowerMonitoringConsoleTrackable(EntityUid uid, PowerMonitoringConsoleGroup group) { + EntityUid = uid; + Group = group; } } - diff --git a/Content.Server/Pinpointer/NavMapSystem.cs b/Content.Server/Pinpointer/NavMapSystem.cs index a62c515520..a97e664d02 100644 --- a/Content.Server/Pinpointer/NavMapSystem.cs +++ b/Content.Server/Pinpointer/NavMapSystem.cs @@ -1,4 +1,3 @@ -using Content.Server.Station.Components; using Content.Server.Station.Systems; using Content.Server.Warps; using Content.Shared.Pinpointer; @@ -32,7 +31,7 @@ public sealed class NavMapSystem : SharedNavMapSystem SubscribeLocalEvent(OnStationInit); SubscribeLocalEvent(OnNavMapStartup); SubscribeLocalEvent(OnGetState); - SubscribeLocalEvent(OnNavMapSplit); + SubscribeLocalEvent(OnNavMapSplit); SubscribeLocalEvent(OnNavMapBeaconStartup); SubscribeLocalEvent(OnNavMapBeaconAnchor); @@ -84,7 +83,7 @@ public sealed class NavMapSystem : SharedNavMapSystem RefreshGrid(component, grid); } - private void OnNavMapSplit(EntityUid uid, NavMapComponent component, ref GridSplitEvent args) + private void OnNavMapSplit(ref GridSplitEvent args) { var gridQuery = GetEntityQuery(); @@ -94,7 +93,7 @@ public sealed class NavMapSystem : SharedNavMapSystem RefreshGrid(newComp, gridQuery.GetComponent(grid)); } - RefreshGrid(component, gridQuery.GetComponent(uid)); + RefreshGrid(Comp(args.Grid), gridQuery.GetComponent(args.Grid)); } private void RefreshGrid(NavMapComponent component, MapGridComponent grid) @@ -201,7 +200,6 @@ public sealed class NavMapSystem : SharedNavMapSystem private void RefreshTile(MapGridComponent grid, NavMapComponent component, NavMapChunk chunk, Vector2i tile) { var relative = SharedMapSystem.GetChunkRelative(tile, ChunkSize); - var existing = chunk.TileData; var flag = GetFlag(relative); diff --git a/Content.Server/Power/Components/CableComponent.cs b/Content.Server/Power/Components/CableComponent.cs index 306c9f732e..a2a02a60f6 100644 --- a/Content.Server/Power/Components/CableComponent.cs +++ b/Content.Server/Power/Components/CableComponent.cs @@ -1,38 +1,54 @@ using Content.Server.Power.EntitySystems; +using Content.Shared.Power; using Content.Shared.Tools; using Robust.Shared.Prototypes; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using System.Diagnostics.Tracing; -namespace Content.Server.Power.Components +namespace Content.Server.Power.Components; + +/// +/// Allows the attached entity to be destroyed by a cutting tool, dropping a piece of cable. +/// +[RegisterComponent] +[Access(typeof(CableSystem))] +public sealed partial class CableComponent : Component { + [DataField("cableDroppedOnCutPrototype", customTypeSerializer: typeof(PrototypeIdSerializer))] + public string CableDroppedOnCutPrototype = "CableHVStack1"; + + [DataField("cuttingQuality", customTypeSerializer:typeof(PrototypeIdSerializer))] + public string CuttingQuality = "Cutting"; + /// - /// Allows the attached entity to be destroyed by a cutting tool, dropping a piece of cable. + /// Checked by to determine if there is + /// already a cable of a type on a tile. /// - [RegisterComponent] - [Access(typeof(CableSystem))] - public sealed partial class CableComponent : Component + [DataField("cableType")] + public CableType CableType = CableType.HighVoltage; + + [DataField("cuttingDelay")] + public float CuttingDelay = 1f; +} + +/// +/// Event to be raised when a cable is anchored / unanchored +/// +[ByRefEvent] +public readonly struct CableAnchorStateChangedEvent +{ + public readonly TransformComponent Transform; + public EntityUid Entity => Transform.Owner; + public bool Anchored => Transform.Anchored; + + /// + /// If true, the entity is being detached to null-space + /// + public readonly bool Detaching; + + public CableAnchorStateChangedEvent(TransformComponent transform, bool detaching = false) { - [DataField("cableDroppedOnCutPrototype", customTypeSerializer: typeof(PrototypeIdSerializer))] - public string CableDroppedOnCutPrototype = "CableHVStack1"; - - [DataField("cuttingQuality", customTypeSerializer:typeof(PrototypeIdSerializer))] - public string CuttingQuality = "Cutting"; - - /// - /// Checked by to determine if there is - /// already a cable of a type on a tile. - /// - [DataField("cableType")] - public CableType CableType = CableType.HighVoltage; - - [DataField("cuttingDelay")] - public float CuttingDelay = 1f; - } - - public enum CableType - { - HighVoltage, - MediumVoltage, - Apc, + Detaching = detaching; + Transform = transform; } } diff --git a/Content.Server/Power/Components/CablePlacerComponent.cs b/Content.Server/Power/Components/CablePlacerComponent.cs index affe3c77a4..d52cfa118a 100644 --- a/Content.Server/Power/Components/CablePlacerComponent.cs +++ b/Content.Server/Power/Components/CablePlacerComponent.cs @@ -1,5 +1,6 @@ using Robust.Shared.Prototypes; using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Content.Shared.Power; namespace Content.Server.Power.Components { diff --git a/Content.Server/Power/Components/PowerMonitoringConsoleComponent.cs b/Content.Server/Power/Components/PowerMonitoringConsoleComponent.cs deleted file mode 100644 index 7bf8a3b414..0000000000 --- a/Content.Server/Power/Components/PowerMonitoringConsoleComponent.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Content.Server.Power.Components; - -[RegisterComponent] -public sealed partial class PowerMonitoringConsoleComponent : Component -{ -} - diff --git a/Content.Server/Power/Components/PowerMonitoringDeviceComponent.cs b/Content.Server/Power/Components/PowerMonitoringDeviceComponent.cs new file mode 100644 index 0000000000..c8f90d9ad5 --- /dev/null +++ b/Content.Server/Power/Components/PowerMonitoringDeviceComponent.cs @@ -0,0 +1,89 @@ +using Content.Server.NodeContainer; +using Content.Server.NodeContainer.NodeGroups; +using Content.Server.Power.EntitySystems; +using Content.Shared.Power; + +namespace Content.Server.Power.Components; + +/// +/// Used to flag any entities that should appear on a power monitoring console +/// +[RegisterComponent, Access(typeof(PowerMonitoringConsoleSystem))] +public sealed partial class PowerMonitoringDeviceComponent : Component +{ + /// + /// Name of the node that this device draws its power from (see ) + /// + [DataField("sourceNode"), ViewVariables] + public string SourceNode = string.Empty; + + /// + /// Name of the node that this device distributes power to (see ) + /// + [DataField("loadNode"), ViewVariables] + public string LoadNode = string.Empty; + + /// + /// Names of the nodes that this device can potentially distributes power to (see ) + /// + [DataField("loadNodes"), ViewVariables] + public List? LoadNodes; + + /// + /// This entity will be grouped with entities that have the same collection name + /// + [DataField("collectionName"), ViewVariables] + public string CollectionName = string.Empty; + + [ViewVariables] + public BaseNodeGroup? NodeGroup = null; + + /// + /// Indicates whether the entity is/should be part of a collection + /// + public bool IsCollectionMasterOrChild { get { return CollectionName != string.Empty; } } + + /// + /// Specifies the uid of the master that represents this entity + /// + /// + /// Used when grouping multiple entities into a single power monitoring console entry + /// + [ViewVariables] + public EntityUid CollectionMaster; + + /// + /// Indicates if this entity represents a group of entities + /// + /// + /// Used when grouping multiple entities into a single power monitoring console entry + /// + public bool IsCollectionMaster { get { return Owner == CollectionMaster; } } + + /// + /// A list of other entities that are to be represented by this entity + /// + /// /// + /// Used when grouping multiple entities into a single power monitoring console entry + /// + [ViewVariables] + public Dictionary ChildDevices = new(); + + /// + /// Path to the .rsi folder + /// + [DataField("sprite"), ViewVariables] + public string SpritePath = string.Empty; + + /// + /// The .rsi state + /// + [DataField("state"), ViewVariables] + public string SpriteState = string.Empty; + + /// + /// Determines what power monitoring group this entity should belong to + /// + [DataField("group", required: true), ViewVariables] + public PowerMonitoringConsoleGroup Group; +} diff --git a/Content.Server/Power/EntitySystems/CableSystem.cs b/Content.Server/Power/EntitySystems/CableSystem.cs index a5c9591d9a..dd478753be 100644 --- a/Content.Server/Power/EntitySystems/CableSystem.cs +++ b/Content.Server/Power/EntitySystems/CableSystem.cs @@ -8,6 +8,7 @@ using Content.Shared.Interaction; using Content.Shared.Tools; using Content.Shared.Tools.Components; using Robust.Shared.Map; +using System.Xml.Schema; using CableCuttingFinishedEvent = Content.Shared.Tools.Systems.CableCuttingFinishedEvent; using SharedToolSystem = Content.Shared.Tools.Systems.SharedToolSystem; @@ -21,6 +22,7 @@ public sealed partial class CableSystem : EntitySystem [Dependency] private readonly StackSystem _stack = default!; [Dependency] private readonly ElectrocutionSystem _electrocutionSystem = default!; [Dependency] private readonly IAdminLogManager _adminLogs = default!; + [Dependency] private readonly PowerMonitoringConsoleSystem _powerMonitoringSystem = default!; public override void Initialize() { @@ -47,17 +49,24 @@ public sealed partial class CableSystem : EntitySystem if (args.Cancelled) return; + var xform = Transform(uid); + var ev = new CableAnchorStateChangedEvent(xform); + RaiseLocalEvent(uid, ref ev); + if (_electrocutionSystem.TryDoElectrifiedAct(uid, args.User)) return; - _adminLogs.Add(LogType.CableCut, LogImpact.Medium, $"The {ToPrettyString(uid)} at {Transform(uid).Coordinates} was cut by {ToPrettyString(args.User)}."); + _adminLogs.Add(LogType.CableCut, LogImpact.Medium, $"The {ToPrettyString(uid)} at {xform.Coordinates} was cut by {ToPrettyString(args.User)}."); - Spawn(cable.CableDroppedOnCutPrototype, Transform(uid).Coordinates); + Spawn(cable.CableDroppedOnCutPrototype, xform.Coordinates); QueueDel(uid); } private void OnAnchorChanged(EntityUid uid, CableComponent cable, ref AnchorStateChangedEvent args) { + var ev = new CableAnchorStateChangedEvent(args.Transform, args.Detaching); + RaiseLocalEvent(uid, ref ev); + if (args.Anchored) return; // huh? it wasn't anchored? diff --git a/Content.Server/Power/EntitySystems/PowerMonitoringConsoleSystem.cs b/Content.Server/Power/EntitySystems/PowerMonitoringConsoleSystem.cs index 6bf4e69334..107d09c898 100644 --- a/Content.Server/Power/EntitySystems/PowerMonitoringConsoleSystem.cs +++ b/Content.Server/Power/EntitySystems/PowerMonitoringConsoleSystem.cs @@ -1,110 +1,999 @@ -using Content.Shared.Power; +using Content.Server.GameTicking.Rules.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.Server.Power.Nodes; using Content.Server.Power.NodeGroups; +using Content.Server.Station.Components; +using Content.Server.StationEvents.Components; +using Content.Shared.Pinpointer; +using Content.Shared.Power; using JetBrains.Annotations; using Robust.Server.GameObjects; +using Robust.Shared.Map.Components; +using Robust.Shared.Player; +using Robust.Shared.Utility; +using System.Linq; +using System.Diagnostics.CodeAnalysis; namespace Content.Server.Power.EntitySystems; [UsedImplicitly] -internal sealed class PowerMonitoringConsoleSystem : EntitySystem +internal sealed partial class PowerMonitoringConsoleSystem : SharedPowerMonitoringConsoleSystem { - private float _updateTimer = 0.0f; - private const float UpdateTime = 1.0f; - - [Dependency] private readonly NodeContainerSystem _nodeContainer = default!; [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!; + [Dependency] private readonly SharedMapSystem _sharedMapSystem = default!; + + // Note: this data does not need to be saved + private Dictionary> _gridPowerCableChunks = new(); + private float _updateTimer = 1.0f; + + private const float UpdateTime = 1.0f; + private const float RoguePowerConsumerThreshold = 100000; + + public override void Initialize() + { + base.Initialize(); + + // Console events + SubscribeLocalEvent(OnConsoleInit); + SubscribeLocalEvent(OnConsoleParentChanged); + SubscribeLocalEvent(OnCableNetworksInit); + SubscribeLocalEvent(OnCableNetworksParentChanged); + + // UI events + SubscribeLocalEvent(OnPowerMonitoringConsoleMessage); + SubscribeLocalEvent(OnBoundUIOpened); + + // Grid events + SubscribeLocalEvent(OnGridSplit); + SubscribeLocalEvent(OnCableAnchorStateChanged); + SubscribeLocalEvent(OnDeviceAnchoringChanged); + SubscribeLocalEvent(OnNodeGroupRebuilt); + + // Game rule events + SubscribeLocalEvent(OnPowerGridCheckStarted); + SubscribeLocalEvent(OnPowerGridCheckEnded); + } + + #region EventHandling + + private void OnConsoleInit(EntityUid uid, PowerMonitoringConsoleComponent component, ComponentInit args) + { + RefreshPowerMonitoringConsole(uid, component); + } + + private void OnConsoleParentChanged(EntityUid uid, PowerMonitoringConsoleComponent component, EntParentChangedMessage args) + { + RefreshPowerMonitoringConsole(uid, component); + } + + private void OnCableNetworksInit(EntityUid uid, PowerMonitoringCableNetworksComponent component, ComponentInit args) + { + RefreshPowerMonitoringCableNetworks(uid, component); + } + + private void OnCableNetworksParentChanged(EntityUid uid, PowerMonitoringCableNetworksComponent component, EntParentChangedMessage args) + { + RefreshPowerMonitoringCableNetworks(uid, component); + } + + private void OnPowerMonitoringConsoleMessage(EntityUid uid, PowerMonitoringConsoleComponent component, PowerMonitoringConsoleMessage args) + { + var focus = EntityManager.GetEntity(args.FocusDevice); + var group = args.FocusGroup; + + // Update this if the focus device has changed + if (component.Focus != focus) + { + component.Focus = focus; + + if (TryComp(uid, out var cableNetworks)) + { + cableNetworks.FocusChunks.Clear(); // Component will be dirtied when these chunks are rebuilt, unless the focus is null + + if (focus == null) + Dirty(uid, cableNetworks); + } + } + + // Update this if the focus group has changed + if (component.FocusGroup != group) + { + component.FocusGroup = args.FocusGroup; + Dirty(uid, component); + } + } + + private void OnBoundUIOpened(EntityUid uid, PowerMonitoringConsoleComponent component, BoundUIOpenedEvent args) + { + component.Focus = null; + component.FocusGroup = PowerMonitoringConsoleGroup.Generator; + + if (TryComp(uid, out var cableNetworks)) + { + cableNetworks.FocusChunks.Clear(); + Dirty(uid, cableNetworks); + } + } + + private void OnGridSplit(ref GridSplitEvent args) + { + // Collect grids + var allGrids = args.NewGrids.ToList(); + + if (!allGrids.Contains(args.Grid)) + allGrids.Add(args.Grid); + + // Refresh affected power cable grids + foreach (var grid in allGrids) + { + if (!TryComp(grid, out var map)) + continue; + + RefreshPowerCableGrid(grid, map); + } + + // Update power monitoring consoles that stand upon an updated grid + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entCableNetworks, out var entXform)) + { + if (entXform.GridUid == null) + continue; + + if (!allGrids.Contains(entXform.GridUid.Value)) + continue; + + RefreshPowerMonitoringConsole(ent, entConsole); + RefreshPowerMonitoringCableNetworks(ent, entCableNetworks); + } + } + + public void OnCableAnchorStateChanged(EntityUid uid, CableComponent component, CableAnchorStateChangedEvent args) + { + var xform = args.Transform; + + if (xform.GridUid == null || !TryComp(xform.GridUid, out var grid)) + return; + + if (!_gridPowerCableChunks.TryGetValue(xform.GridUid.Value, out var allChunks)) + allChunks = new(); + + var tile = _sharedMapSystem.LocalToTile(xform.GridUid.Value, grid, xform.Coordinates); + var chunkOrigin = SharedMapSystem.GetChunkIndices(tile, SharedNavMapSystem.ChunkSize); + + if (!allChunks.TryGetValue(chunkOrigin, out var chunk)) + { + chunk = new PowerCableChunk(chunkOrigin); + allChunks[chunkOrigin] = chunk; + } + + var relative = SharedMapSystem.GetChunkRelative(tile, SharedNavMapSystem.ChunkSize); + var flag = SharedNavMapSystem.GetFlag(relative); + + if (args.Anchored) + chunk.PowerCableData[(int) component.CableType] |= flag; + + else + chunk.PowerCableData[(int) component.CableType] &= ~flag; + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entCableNetworks, out var entXform)) + { + if (entXform.GridUid != xform.GridUid) + continue; + + entCableNetworks.AllChunks = allChunks; + Dirty(ent, entCableNetworks); + } + } + + private void OnDeviceAnchoringChanged(EntityUid uid, PowerMonitoringDeviceComponent component, AnchorStateChangedEvent args) + { + var xform = Transform(uid); + var gridUid = xform.GridUid; + + if (gridUid == null) + return; + + if (component.IsCollectionMasterOrChild) + AssignEntityAsCollectionMaster(uid, component, xform); + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (gridUid != entXform.GridUid) + continue; + + if (!args.Anchored) + { + entConsole.PowerMonitoringDeviceMetaData.Remove(EntityManager.GetNetEntity(uid)); + Dirty(ent, entConsole); + + continue; + } + + var name = MetaData(uid).EntityName; + var coords = EntityManager.GetNetCoordinates(xform.Coordinates); + + var metaData = new PowerMonitoringDeviceMetaData(name, coords, component.Group, component.SpritePath, component.SpriteState); + entConsole.PowerMonitoringDeviceMetaData.TryAdd(EntityManager.GetNetEntity(uid), metaData); + + Dirty(ent, entConsole); + } + } + + public void OnNodeGroupRebuilt(EntityUid uid, PowerMonitoringDeviceComponent component, NodeGroupsRebuilt args) + { + if (component.IsCollectionMasterOrChild) + AssignEntityAsCollectionMaster(uid, component); + + var query = AllEntityQuery(); + while (query.MoveNext(out var _, out var entConsole, out var entCableNetworks)) + { + if (entConsole.Focus == uid) + entCableNetworks.FocusChunks.Clear(); // Component is dirtied when these chunks are rebuilt + } + } + + private void OnPowerGridCheckStarted(ref GameRuleStartedEvent ev) + { + if (!TryComp(ev.RuleEntity, out var rule)) + return; + + var query = AllEntityQuery(); + while (query.MoveNext(out var uid, out var console, out var xform)) + { + if (CompOrNull(xform.GridUid)?.Station == rule.AffectedStation) + { + console.Flags |= PowerMonitoringFlags.PowerNetAbnormalities; + Dirty(uid, console); + } + } + } + + private void OnPowerGridCheckEnded(ref GameRuleEndedEvent ev) + { + if (!TryComp(ev.RuleEntity, out var rule)) + return; + + var query = AllEntityQuery(); + while (query.MoveNext(out var uid, out var console, out var xform)) + { + if (CompOrNull(xform.GridUid)?.Station == rule.AffectedStation) + { + console.Flags &= ~PowerMonitoringFlags.PowerNetAbnormalities; + Dirty(uid, console); + } + } + } + + #endregion public override void Update(float frameTime) { + base.Update(frameTime); + _updateTimer += frameTime; + if (_updateTimer >= UpdateTime) { _updateTimer -= UpdateTime; - var query = EntityQueryEnumerator(); - while (query.MoveNext(out var uid, out var component)) + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var console)) { - UpdateUIState(uid, component); + if (!_userInterfaceSystem.TryGetUi(ent, PowerMonitoringConsoleUiKey.Key, out var bui)) + continue; + + foreach (var session in bui.SubscribedSessions) + UpdateUIState(ent, console, session); } } } - public void UpdateUIState(EntityUid target, PowerMonitoringConsoleComponent? pmcComp = null, NodeContainerComponent? ncComp = null) + public void UpdateUIState(EntityUid uid, PowerMonitoringConsoleComponent component, ICommonSession session) { - if (!Resolve(target, ref pmcComp)) - return; - if (!Resolve(target, ref ncComp)) + if (!_userInterfaceSystem.TryGetUi(uid, PowerMonitoringConsoleUiKey.Key, out var bui)) return; - var totalSources = 0.0d; - var totalLoads = 0.0d; - var sources = new List(); - var loads = new List(); - PowerMonitoringConsoleEntry LoadOrSource(Component comp, double rate, bool isBattery) + var consoleXform = Transform(uid); + + if (consoleXform?.GridUid == null) + return; + + var gridUid = consoleXform.GridUid.Value; + + if (!TryComp(gridUid, out var mapGrid)) + return; + + // The grid must have a NavMapComponent to visualize the map in the UI + EnsureComp(gridUid); + + // Initializing data to be send to the client + var totalSources = 0d; + var totalBatteryUsage = 0d; + var totalLoads = 0d; + var allEntries = new List(); + var sourcesForFocus = new List(); + var loadsForFocus = new List(); + var flags = component.Flags; + + // Reset RoguePowerConsumer flag + component.Flags &= ~PowerMonitoringFlags.RoguePowerConsumer; + + // Record the load value of all non-tracked power consumers on the same grid as the console + var powerConsumerQuery = AllEntityQuery(); + while (powerConsumerQuery.MoveNext(out var ent, out var powerConsumer, out var xform)) { - var md = MetaData(comp.Owner); - var prototype = md.EntityPrototype?.ID ?? ""; - return new PowerMonitoringConsoleEntry(md.EntityName, prototype, rate, isBattery); + if (xform.Anchored == false || xform.GridUid != gridUid) + continue; + + if (TryComp(ent, out var device)) + continue; + + // Flag an alert if power consumption is ridiculous + if (powerConsumer.ReceivedPower >= RoguePowerConsumerThreshold) + component.Flags |= PowerMonitoringFlags.RoguePowerConsumer; + + totalLoads += powerConsumer.DrawRate; } - // Right, so, here's what needs to be considered here. - if (!_nodeContainer.TryGetNode(ncComp, "hv", out var node)) - return; - if (node.NodeGroup is PowerNet netQ) + if (component.Flags != flags) + Dirty(uid, component); + + // Loop over all tracked devices + var powerMonitoringDeviceQuery = AllEntityQuery(); + while (powerMonitoringDeviceQuery.MoveNext(out var ent, out var device, out var xform)) { - foreach (PowerConsumerComponent pcc in netQ.Consumers) - { - if (!pcc.ShowInMonitor) - continue; + // Ignore joint, non-master entities + if (device.IsCollectionMasterOrChild && !device.IsCollectionMaster) + continue; - loads.Add(LoadOrSource(pcc, pcc.DrawRate, false)); - totalLoads += pcc.DrawRate; - } - foreach (BatteryChargerComponent pcc in netQ.Chargers) - { - if (!TryComp(pcc.Owner, out PowerNetworkBatteryComponent? batteryComp)) - { - continue; - } - var rate = batteryComp.NetworkBattery.CurrentReceiving; - loads.Add(LoadOrSource(pcc, rate, true)); - totalLoads += rate; - } - foreach (PowerSupplierComponent pcc in netQ.Suppliers) - { - var supply = pcc.Enabled - ? pcc.MaxSupply - : 0f; + if (xform.Anchored == false || xform.GridUid != gridUid) + continue; - sources.Add(LoadOrSource(pcc, supply, false)); - totalSources += supply; - } - foreach (BatteryDischargerComponent pcc in netQ.Dischargers) + // Get the device power stats + var powerValue = GetPrimaryPowerValues(ent, device, out var powerSupplied, out var powerUsage, out var batteryUsage); + + // Update all running totals + totalSources += powerSupplied; + totalLoads += powerUsage; + totalBatteryUsage += batteryUsage; + + // Continue on if the device is not in the current focus group + if (device.Group != component.FocusGroup) + continue; + + // Generate a new console entry with which to populate the UI + var entry = new PowerMonitoringConsoleEntry(EntityManager.GetNetEntity(ent), device.Group, powerValue); + allEntries.Add(entry); + } + + // Update the UI focus data (if applicable) + if (component.Focus != null) + { + if (TryComp(component.Focus, out var nodeContainer) && + TryComp(component.Focus, out var device)) { - if (!TryComp(pcc.Owner, out PowerNetworkBatteryComponent? batteryComp)) + // Record the tracked sources powering the device + if (nodeContainer.Nodes.TryGetValue(device.SourceNode, out var sourceNode)) + GetSourcesForNode(component.Focus.Value, sourceNode, out sourcesForFocus); + + // Search for the enabled load node (required for portable generators) + var loadNodeName = device.LoadNode; + + if (device.LoadNodes != null) { - continue; + var foundNode = nodeContainer.Nodes.FirstOrNull(x => x.Value is CableDeviceNode && (x.Value as CableDeviceNode)?.Enabled == true); + + if (foundNode != null) + loadNodeName = foundNode.Value.Key; + } + + // Record the tracked loads on the device + if (nodeContainer.Nodes.TryGetValue(loadNodeName, out var loadNode)) + GetLoadsForNode(component.Focus.Value, loadNode, out loadsForFocus); + + // If the UI focus changed, update the highlighted power network + if (TryComp(uid, out var cableNetworks) && + cableNetworks.FocusChunks.Count == 0) + { + var reachableEntities = new List(); + + if (sourceNode?.NodeGroup != null) + { + foreach (var node in sourceNode.NodeGroup.Nodes) + reachableEntities.Add(node.Owner); + } + + if (loadNode?.NodeGroup != null) + { + foreach (var node in loadNode.NodeGroup.Nodes) + reachableEntities.Add(node.Owner); + } + + UpdateFocusNetwork(uid, cableNetworks, gridUid, mapGrid, reachableEntities); } - var rate = batteryComp.NetworkBattery.CurrentSupply; - sources.Add(LoadOrSource(pcc, rate, true)); - totalSources += rate; } } - // Sort - loads.Sort(CompareLoadOrSources); - sources.Sort(CompareLoadOrSources); - // Actually set state. - if (_userInterfaceSystem.TryGetUi(target, PowerMonitoringConsoleUiKey.Key, out var bui)) - _userInterfaceSystem.SetUiState(bui, new PowerMonitoringConsoleBoundInterfaceState(totalSources, totalLoads, sources.ToArray(), loads.ToArray())); + // Set the UI state + _userInterfaceSystem.SetUiState(bui, + new PowerMonitoringConsoleBoundInterfaceState + (totalSources, + totalBatteryUsage, + totalLoads, + allEntries.ToArray(), + sourcesForFocus.ToArray(), + loadsForFocus.ToArray()), + session); } - private int CompareLoadOrSources(PowerMonitoringConsoleEntry x, PowerMonitoringConsoleEntry y) + private double GetPrimaryPowerValues(EntityUid uid, PowerMonitoringDeviceComponent device, out double powerSupplied, out double powerUsage, out double batteryUsage) { - return -x.Size.CompareTo(y.Size); + var powerValue = 0d; + powerSupplied = 0d; + powerUsage = 0d; + batteryUsage = 0d; + + if (device.Group == PowerMonitoringConsoleGroup.Generator) + { + // This covers most power sources + if (TryComp(uid, out var supplier)) + { + powerValue = supplier.CurrentSupply; + powerSupplied += powerValue; + } + + // Edge case: radiation collectors + else if (TryComp(uid, out var _) && + TryComp(uid, out var battery)) + { + powerValue = battery.NetworkBattery.CurrentSupply; + powerSupplied += powerValue; + } + } + + else if (device.Group == PowerMonitoringConsoleGroup.SMES || + device.Group == PowerMonitoringConsoleGroup.Substation || + device.Group == PowerMonitoringConsoleGroup.APC) + { + + if (TryComp(uid, out var battery)) + { + powerValue = battery.CurrentSupply; + + // Load due to network battery recharging + powerUsage += Math.Max(battery.CurrentReceiving - battery.CurrentSupply, 0d); + + // Track battery usage + batteryUsage += Math.Max(battery.CurrentSupply - battery.CurrentReceiving, 0d); + + // Records loads attached to APCs + if (device.Group == PowerMonitoringConsoleGroup.APC && battery.Enabled) + { + powerUsage += battery.NetworkBattery.LoadingNetworkDemand; + } + } + } + + // Master devices add the power values from all entities they represent (if applicable) + if (device.IsCollectionMasterOrChild && device.IsCollectionMaster) + { + foreach ((var child, var childDevice) in device.ChildDevices) + { + if (child == uid) + continue; + + // Safeguard to prevent infinite loops + if (childDevice.IsCollectionMaster && childDevice.ChildDevices.ContainsKey(uid)) + continue; + + var childPowerValue = GetPrimaryPowerValues(child, childDevice, out var childPowerSupplied, out var childPowerUsage, out var childBatteryUsage); + + powerValue += childPowerValue; + powerSupplied += childPowerSupplied; + powerUsage += childPowerUsage; + batteryUsage += childBatteryUsage; + } + } + + return powerValue; + } + + private void GetSourcesForNode(EntityUid uid, Node node, out List sources) + { + sources = new List(); + + if (node.NodeGroup is not PowerNet netQ) + return; + + var indexedSources = new Dictionary(); + var currentSupply = 0f; + var currentDemand = 0f; + + foreach (var powerSupplier in netQ.Suppliers) + { + var ent = powerSupplier.Owner; + + if (uid == ent) + continue; + + currentSupply += powerSupplier.CurrentSupply; + + if (TryComp(ent, out var entDevice)) + { + // Combine entities represented by an master into a single entry + if (entDevice.IsCollectionMasterOrChild && !entDevice.IsCollectionMaster) + ent = entDevice.CollectionMaster; + + if (indexedSources.TryGetValue(ent, out var entry)) + { + entry.PowerValue += powerSupplier.CurrentSupply; + indexedSources[ent] = entry; + + continue; + } + + indexedSources.Add(ent, new PowerMonitoringConsoleEntry(EntityManager.GetNetEntity(ent), entDevice.Group, powerSupplier.CurrentSupply)); + } + } + + foreach (var batteryDischarger in netQ.Dischargers) + { + var ent = batteryDischarger.Owner; + + if (uid == ent) + continue; + + if (!TryComp(ent, out var entBattery)) + continue; + + currentSupply += entBattery.CurrentSupply; + + if (TryComp(ent, out var entDevice)) + { + // Combine entities represented by an master into a single entry + if (entDevice.IsCollectionMasterOrChild && !entDevice.IsCollectionMaster) + ent = entDevice.CollectionMaster; + + if (indexedSources.TryGetValue(ent, out var entry)) + { + entry.PowerValue += entBattery.CurrentSupply; + indexedSources[ent] = entry; + + continue; + } + + indexedSources.Add(ent, new PowerMonitoringConsoleEntry(EntityManager.GetNetEntity(ent), entDevice.Group, entBattery.CurrentSupply)); + } + } + + sources = indexedSources.Values.ToList(); + + // Get the total demand for the network + foreach (var powerConsumer in netQ.Consumers) + { + currentDemand += powerConsumer.ReceivedPower; + } + + foreach (var batteryCharger in netQ.Chargers) + { + var ent = batteryCharger.Owner; + + if (!TryComp(ent, out var entBattery)) + continue; + + currentDemand += entBattery.CurrentReceiving; + } + + // Exit if supply / demand is negligible + if (MathHelper.CloseTo(currentDemand, 0) || MathHelper.CloseTo(currentSupply, 0)) + return; + + // Work out how much power this device (and those it represents) is actually receiving + if (!TryComp(uid, out var battery)) + return; + + var powerUsage = battery.CurrentReceiving; + + if (TryComp(uid, out var device) && device.IsCollectionMaster) + { + foreach ((var child, var _) in device.ChildDevices) + { + if (TryComp(child, out var childBattery)) + powerUsage += childBattery.CurrentReceiving; + } + } + + // Update the power value for each source based on the fraction of power the entity is actually draining from each + var powerFraction = Math.Min(powerUsage / currentSupply, 1f) * Math.Min(currentSupply / currentDemand, 1f); + + for (int i = 0; i < sources.Count; i++) + { + var entry = sources[i]; + sources[i] = new PowerMonitoringConsoleEntry(entry.NetEntity, entry.Group, entry.PowerValue * powerFraction); + } + } + + private void GetLoadsForNode(EntityUid uid, Node node, out List loads, List? children = null) + { + loads = new List(); + + if (node.NodeGroup is not PowerNet netQ) + return; + + var indexedLoads = new Dictionary(); + var currentDemand = 0f; + + foreach (var powerConsumer in netQ.Consumers) + { + var ent = powerConsumer.Owner; + + if (uid == ent) + continue; + + currentDemand += powerConsumer.ReceivedPower; + + if (TryComp(ent, out var entDevice)) + { + // Combine entities represented by an master into a single entry + if (entDevice.IsCollectionMasterOrChild && !entDevice.IsCollectionMaster) + ent = entDevice.CollectionMaster; + + if (indexedLoads.TryGetValue(ent, out var entry)) + { + entry.PowerValue += powerConsumer.ReceivedPower; + indexedLoads[ent] = entry; + + continue; + } + + indexedLoads.Add(ent, new PowerMonitoringConsoleEntry(EntityManager.GetNetEntity(ent), entDevice.Group, powerConsumer.ReceivedPower)); + } + } + + foreach (var batteryCharger in netQ.Chargers) + { + var ent = batteryCharger.Owner; + + if (uid == ent) + continue; + + if (!TryComp(ent, out var battery)) + continue; + + currentDemand += battery.CurrentReceiving; + + if (TryComp(ent, out var entDevice)) + { + // Combine entities represented by an master into a single entry + if (entDevice.IsCollectionMasterOrChild && !entDevice.IsCollectionMaster) + ent = entDevice.CollectionMaster; + + if (indexedLoads.TryGetValue(ent, out var entry)) + { + entry.PowerValue += battery.CurrentReceiving; + indexedLoads[ent] = entry; + + continue; + } + + indexedLoads.Add(ent, new PowerMonitoringConsoleEntry(EntityManager.GetNetEntity(ent), entDevice.Group, battery.CurrentReceiving)); + } + } + + loads = indexedLoads.Values.ToList(); + + // Exit if demand is negligible + if (MathHelper.CloseTo(currentDemand, 0)) + return; + + var supplying = 0f; + + // Work out how much power this device (and those it represents) is actually supplying + if (TryComp(uid, out var entBattery)) + supplying = entBattery.CurrentSupply; + + else if (TryComp(uid, out var entSupplier)) + supplying = entSupplier.CurrentSupply; + + if (TryComp(uid, out var device) && device.IsCollectionMaster) + { + foreach ((var child, var _) in device.ChildDevices) + { + if (TryComp(child, out var childBattery)) + supplying += childBattery.CurrentSupply; + + else if (TryComp(child, out var childSupplier)) + supplying += childSupplier.CurrentSupply; + } + } + + // Update the power value for each load based on the fraction of power these entities are actually draining from this device + var powerFraction = Math.Min(supplying / currentDemand, 1f); + + for (int i = 0; i < indexedLoads.Values.Count; i++) + { + var entry = loads[i]; + loads[i] = new PowerMonitoringConsoleEntry(entry.NetEntity, entry.Group, entry.PowerValue * powerFraction); + } + } + + // Designates a supplied entity as a 'collection master'. Other entities which share this + // entities collection name and are attached on the same load network are assigned this entity + // as the master that represents them on the console UI. This way you can have one device + // represent multiple connected devices + private void AssignEntityAsCollectionMaster + (EntityUid uid, + PowerMonitoringDeviceComponent? device = null, + TransformComponent? xform = null, + NodeContainerComponent? nodeContainer = null) + { + if (!Resolve(uid, ref device, ref nodeContainer, ref xform, false)) + return; + + // If the device is not attached to a network, exit + var nodeName = device.SourceNode == string.Empty ? device.LoadNode : device.SourceNode; + + if (!nodeContainer.Nodes.TryGetValue(nodeName, out var node) || + node.ReachableNodes.Count == 0) + { + // Make a child the new master of the collection if necessary + if (device.ChildDevices.TryFirstOrNull(out var kvp)) + { + var newMaster = kvp.Value.Key; + var newMasterDevice = kvp.Value.Value; + + newMasterDevice.CollectionMaster = newMaster; + newMasterDevice.ChildDevices.Clear(); + + foreach ((var child, var childDevice) in device.ChildDevices) + { + newMasterDevice.ChildDevices.Add(child, childDevice); + + childDevice.CollectionMaster = newMaster; + UpdateCollectionChildMetaData(child, newMaster); + } + + UpdateCollectionMasterMetaData(newMaster, newMasterDevice.ChildDevices.Count); + } + + device.CollectionMaster = uid; + device.ChildDevices.Clear(); + UpdateCollectionMasterMetaData(uid, 0); + + return; + } + + // Check to see if the device has a valid existing master + if (!device.IsCollectionMaster && + device.CollectionMaster.IsValid() && + TryComp(device.CollectionMaster, out var masterNodeContainer) && + DevicesHaveMatchingNodes(nodeContainer, masterNodeContainer)) + return; + + // If not, make this a new master + device.CollectionMaster = uid; + device.ChildDevices.Clear(); + + // Search for children + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entDevice, out var entXform, out var entNodeContainer)) + { + if (entDevice.CollectionName != device.CollectionName) + continue; + + if (ent == uid) + continue; + + if (entXform.GridUid != xform.GridUid) + continue; + + if (!DevicesHaveMatchingNodes(nodeContainer, entNodeContainer)) + continue; + + device.ChildDevices.Add(ent, entDevice); + + entDevice.CollectionMaster = uid; + UpdateCollectionChildMetaData(ent, uid); + } + + UpdateCollectionMasterMetaData(uid, device.ChildDevices.Count); + } + + private bool DevicesHaveMatchingNodes(NodeContainerComponent nodeContainerA, NodeContainerComponent nodeContainerB) + { + foreach ((var key, var nodeA) in nodeContainerA.Nodes) + { + if (!nodeContainerB.Nodes.TryGetValue(key, out var nodeB)) + return false; + + if (nodeA.NodeGroup != nodeB.NodeGroup) + return false; + } + + return true; + } + + private void UpdateCollectionChildMetaData(EntityUid child, EntityUid master) + { + var netEntity = EntityManager.GetNetEntity(child); + var xform = Transform(child); + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (entXform.GridUid != xform.GridUid) + continue; + + if (!entConsole.PowerMonitoringDeviceMetaData.TryGetValue(netEntity, out var metaData)) + continue; + + metaData.CollectionMaster = EntityManager.GetNetEntity(master); + entConsole.PowerMonitoringDeviceMetaData[netEntity] = metaData; + + Dirty(ent, entConsole); + } + } + + private void UpdateCollectionMasterMetaData(EntityUid master, int childCount) + { + var netEntity = EntityManager.GetNetEntity(master); + var xform = Transform(master); + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entConsole, out var entXform)) + { + if (entXform.GridUid != xform.GridUid) + continue; + + if (!entConsole.PowerMonitoringDeviceMetaData.TryGetValue(netEntity, out var metaData)) + continue; + + if (childCount > 0) + { + var name = MetaData(master).EntityPrototype?.Name ?? MetaData(master).EntityName; + metaData.EntityName = Loc.GetString("power-monitoring-window-object-array", ("name", name), ("count", childCount + 1)); + } + + else + { + metaData.EntityName = MetaData(master).EntityName; + } + + metaData.CollectionMaster = null; + entConsole.PowerMonitoringDeviceMetaData[netEntity] = metaData; + + Dirty(ent, entConsole); + } + } + + private Dictionary RefreshPowerCableGrid(EntityUid gridUid, MapGridComponent grid) + { + // Clears all chunks for the associated grid + var allChunks = new Dictionary(); + _gridPowerCableChunks[gridUid] = allChunks; + + // Adds all power cables to the grid + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var cable, out var entXform)) + { + if (entXform.GridUid != gridUid) + continue; + + var tile = _sharedMapSystem.GetTileRef(gridUid, grid, entXform.Coordinates); + var chunkOrigin = SharedMapSystem.GetChunkIndices(tile.GridIndices, SharedNavMapSystem.ChunkSize); + + if (!allChunks.TryGetValue(chunkOrigin, out var chunk)) + { + chunk = new PowerCableChunk(chunkOrigin); + allChunks[chunkOrigin] = chunk; + } + + var relative = SharedMapSystem.GetChunkRelative(tile.GridIndices, SharedNavMapSystem.ChunkSize); + var flag = SharedNavMapSystem.GetFlag(relative); + + chunk.PowerCableData[(int) cable.CableType] |= flag; + } + + return allChunks; + } + + private void UpdateFocusNetwork(EntityUid uid, PowerMonitoringCableNetworksComponent component, EntityUid gridUid, MapGridComponent grid, List nodeList) + { + component.FocusChunks.Clear(); + + foreach (var ent in nodeList) + { + var xform = Transform(ent); + var tile = _sharedMapSystem.GetTileRef(gridUid, grid, xform.Coordinates); + var gridIndices = tile.GridIndices; + var chunkOrigin = SharedMapSystem.GetChunkIndices(gridIndices, SharedNavMapSystem.ChunkSize); + + if (!component.FocusChunks.TryGetValue(chunkOrigin, out var chunk)) + { + chunk = new PowerCableChunk(chunkOrigin); + component.FocusChunks[chunkOrigin] = chunk; + } + + var relative = SharedMapSystem.GetChunkRelative(gridIndices, SharedNavMapSystem.ChunkSize); + var flag = SharedNavMapSystem.GetFlag(relative); + + if (TryComp(ent, out var cable)) + chunk.PowerCableData[(int) cable.CableType] |= flag; + } + + Dirty(uid, component); + } + + private void RefreshPowerMonitoringConsole(EntityUid uid, PowerMonitoringConsoleComponent component) + { + component.Focus = null; + component.FocusGroup = PowerMonitoringConsoleGroup.Generator; + component.PowerMonitoringDeviceMetaData.Clear(); + component.Flags = 0; + + var xform = Transform(uid); + + if (xform.GridUid == null) + return; + + var grid = xform.GridUid.Value; + + var query = AllEntityQuery(); + while (query.MoveNext(out var ent, out var entDevice, out var entXform)) + { + if (grid != entXform.GridUid) + continue; + + var netEntity = EntityManager.GetNetEntity(ent); + var name = MetaData(ent).EntityName; + var netCoords = EntityManager.GetNetCoordinates(entXform.Coordinates); + + var metaData = new PowerMonitoringDeviceMetaData(name, netCoords, entDevice.Group, entDevice.SpritePath, entDevice.SpriteState); + + if (entDevice.IsCollectionMasterOrChild) + { + if (!entDevice.IsCollectionMaster) + { + metaData.CollectionMaster = EntityManager.GetNetEntity(entDevice.CollectionMaster); + } + + else if (entDevice.ChildDevices.Count > 0) + { + name = MetaData(ent).EntityPrototype?.Name ?? MetaData(ent).EntityName; + metaData.EntityName = Loc.GetString("power-monitoring-window-object-array", ("name", name), ("count", entDevice.ChildDevices.Count + 1)); + } + } + + component.PowerMonitoringDeviceMetaData.Add(netEntity, metaData); + } + + Dirty(uid, component); + } + + private void RefreshPowerMonitoringCableNetworks(EntityUid uid, PowerMonitoringCableNetworksComponent component) + { + var xform = Transform(uid); + + if (xform.GridUid == null) + return; + + var grid = xform.GridUid.Value; + + if (!TryComp(grid, out var map)) + return; + + if (!_gridPowerCableChunks.TryGetValue(grid, out var allChunks)) + allChunks = RefreshPowerCableGrid(grid, map); + + component.AllChunks = allChunks; + component.FocusChunks.Clear(); + + Dirty(uid, component); } } diff --git a/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs b/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs index 509c5a8ecf..98c60346c7 100644 --- a/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs +++ b/Content.Server/StationEvents/Components/PowerGridCheckRuleComponent.cs @@ -1,4 +1,4 @@ -using System.Threading; +using System.Threading; using Content.Server.StationEvents.Events; namespace Content.Server.StationEvents.Components; @@ -8,6 +8,7 @@ public sealed partial class PowerGridCheckRuleComponent : Component { public CancellationTokenSource? AnnounceCancelToken; + public EntityUid AffectedStation; public readonly List Powered = new(); public readonly List Unpowered = new(); diff --git a/Content.Server/StationEvents/Events/PowerGridCheckRule.cs b/Content.Server/StationEvents/Events/PowerGridCheckRule.cs index fdd3e30d43..5503438df8 100644 --- a/Content.Server/StationEvents/Events/PowerGridCheckRule.cs +++ b/Content.Server/StationEvents/Events/PowerGridCheckRule.cs @@ -24,6 +24,8 @@ namespace Content.Server.StationEvents.Events if (!TryGetRandomStation(out var chosenStation)) return; + component.AffectedStation = chosenStation.Value; + var query = AllEntityQuery(); while (query.MoveNext(out var apcUid ,out var apc, out var transform)) { diff --git a/Content.Shared/Pinpointer/NavMapBeaconComponent.cs b/Content.Shared/Pinpointer/NavMapBeaconComponent.cs index b9cb8d4488..2415f92f58 100644 --- a/Content.Shared/Pinpointer/NavMapBeaconComponent.cs +++ b/Content.Shared/Pinpointer/NavMapBeaconComponent.cs @@ -1,5 +1,3 @@ -using Robust.Shared.GameStates; - namespace Content.Shared.Pinpointer; /// diff --git a/Content.Shared/Pinpointer/NavMapComponent.cs b/Content.Shared/Pinpointer/NavMapComponent.cs index 86a0beef18..57f2d004d5 100644 --- a/Content.Shared/Pinpointer/NavMapComponent.cs +++ b/Content.Shared/Pinpointer/NavMapComponent.cs @@ -1,5 +1,4 @@ using Robust.Shared.GameStates; -using Robust.Shared.Timing; namespace Content.Shared.Pinpointer; diff --git a/Content.Shared/Power/PowerMonitoringCableNetworksComponent.cs b/Content.Shared/Power/PowerMonitoringCableNetworksComponent.cs new file mode 100644 index 0000000000..75ac8869ed --- /dev/null +++ b/Content.Shared/Power/PowerMonitoringCableNetworksComponent.cs @@ -0,0 +1,39 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Serialization; + +namespace Content.Shared.Power; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(SharedPowerMonitoringConsoleSystem))] +public sealed partial class PowerMonitoringCableNetworksComponent : Component +{ + /// + /// A dictionary of the all the nav map chunks that contain anchored power cables + /// + [ViewVariables, AutoNetworkedField] + public Dictionary AllChunks = new(); + + /// + /// A dictionary of the all the nav map chunks that contain anchored power cables + /// that are directly connected to the console's current focus + /// + [ViewVariables, AutoNetworkedField] + public Dictionary FocusChunks = new(); +} + +[Serializable, NetSerializable] +public struct PowerCableChunk +{ + public readonly Vector2i Origin; + + /// + /// Bitmask dictionary for power cables, 1 for occupied and 0 for empty. + /// + public int[] PowerCableData; + + public PowerCableChunk(Vector2i origin) + { + Origin = origin; + PowerCableData = new int[3]; + } +} diff --git a/Content.Shared/Power/SharedPower.cs b/Content.Shared/Power/SharedPower.cs index 5dd366fd68..da88198825 100644 --- a/Content.Shared/Power/SharedPower.cs +++ b/Content.Shared/Power/SharedPower.cs @@ -1,4 +1,4 @@ -using Robust.Shared.Serialization; +using Robust.Shared.Serialization; namespace Content.Shared.Power { @@ -23,4 +23,12 @@ namespace Content.Shared.Power WireCount, CutWires } + + [Serializable, NetSerializable] + public enum CableType + { + HighVoltage, + MediumVoltage, + Apc, + } } diff --git a/Content.Shared/Power/SharedPowerMonitoringConsoleComponent.cs b/Content.Shared/Power/SharedPowerMonitoringConsoleComponent.cs index 9fdedc5a38..4d404f209c 100644 --- a/Content.Shared/Power/SharedPowerMonitoringConsoleComponent.cs +++ b/Content.Shared/Power/SharedPowerMonitoringConsoleComponent.cs @@ -1,43 +1,159 @@ -#nullable enable +using Robust.Shared.GameStates; +using Robust.Shared.Map; using Robust.Shared.Serialization; namespace Content.Shared.Power; +/// +/// Flags an entity as being a power monitoring console +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(SharedPowerMonitoringConsoleSystem), Other = AccessPermissions.ReadExecute)] +public sealed partial class PowerMonitoringConsoleComponent : Component +{ + /// + /// The EntityUid of the device that is the console's current focus + /// + /// + /// Not-networked - set by the console UI + /// + [ViewVariables] + public EntityUid? Focus; + + /// + /// The group that the device that is the console's current focus belongs to + /// + /// /// + /// Not-networked - set by the console UI + /// + [ViewVariables] + public PowerMonitoringConsoleGroup FocusGroup = PowerMonitoringConsoleGroup.Generator; + + /// + /// A list of flags relating to currently active events of interest to the console. + /// E.g., power sinks, power net anomalies + /// + [ViewVariables, AutoNetworkedField] + public PowerMonitoringFlags Flags = PowerMonitoringFlags.None; + + /// + /// A dictionary containing all the meta data for tracked power monitoring devices + /// + [ViewVariables, AutoNetworkedField] + public Dictionary PowerMonitoringDeviceMetaData = new(); +} + +[Serializable, NetSerializable] +public struct PowerMonitoringDeviceMetaData +{ + public string EntityName; + public NetCoordinates Coordinates; + public PowerMonitoringConsoleGroup Group; + public string SpritePath; + public string SpriteState; + public NetEntity? CollectionMaster; + + public PowerMonitoringDeviceMetaData(string name, NetCoordinates coordinates, PowerMonitoringConsoleGroup group, string spritePath, string spriteState) + { + EntityName = name; + Coordinates = coordinates; + Group = group; + SpritePath = spritePath; + SpriteState = spriteState; + } +} + +/// +/// Data from by the server to the client for the power monitoring console UI +/// [Serializable, NetSerializable] public sealed class PowerMonitoringConsoleBoundInterfaceState : BoundUserInterfaceState { public double TotalSources; + public double TotalBatteryUsage; public double TotalLoads; - public PowerMonitoringConsoleEntry[] Sources; - public PowerMonitoringConsoleEntry[] Loads; - public PowerMonitoringConsoleBoundInterfaceState(double totalSources, double totalLoads, PowerMonitoringConsoleEntry[] sources, PowerMonitoringConsoleEntry[] loads) + public PowerMonitoringConsoleEntry[] AllEntries; + public PowerMonitoringConsoleEntry[] FocusSources; + public PowerMonitoringConsoleEntry[] FocusLoads; + + public PowerMonitoringConsoleBoundInterfaceState + (double totalSources, + double totalBatteryUsage, + double totalLoads, + PowerMonitoringConsoleEntry[] allEntries, + PowerMonitoringConsoleEntry[] focusSources, + PowerMonitoringConsoleEntry[] focusLoads) { TotalSources = totalSources; + TotalBatteryUsage = totalBatteryUsage; TotalLoads = totalLoads; - Sources = sources; - Loads = loads; + AllEntries = allEntries; + FocusSources = focusSources; + FocusLoads = focusLoads; } } +/// +/// Contains all the data needed to update a single device on the power monitoring UI +/// [Serializable, NetSerializable] -public sealed class PowerMonitoringConsoleEntry +public struct PowerMonitoringConsoleEntry { - public string NameLocalized; - public string IconEntityPrototypeId; - public double Size; - public bool IsBattery; - public PowerMonitoringConsoleEntry(string nl, string ipi, double size, bool isBattery) + public NetEntity NetEntity; + public PowerMonitoringConsoleGroup Group; + public double PowerValue; + + [NonSerialized] public PowerMonitoringDeviceMetaData? MetaData = null; + + public PowerMonitoringConsoleEntry(NetEntity netEntity, PowerMonitoringConsoleGroup group, double powerValue = 0d) { - NameLocalized = nl; - IconEntityPrototypeId = ipi; - Size = size; - IsBattery = isBattery; + NetEntity = netEntity; + Group = group; + PowerValue = powerValue; } } +/// +/// Triggers the server to send updated power monitoring console data to the client for the single player session +/// +[Serializable, NetSerializable] +public sealed class PowerMonitoringConsoleMessage : BoundUserInterfaceMessage +{ + public NetEntity? FocusDevice; + public PowerMonitoringConsoleGroup FocusGroup; + + public PowerMonitoringConsoleMessage(NetEntity? focusDevice, PowerMonitoringConsoleGroup focusGroup) + { + FocusDevice = focusDevice; + FocusGroup = focusGroup; + } +} + +/// +/// Determines how entities are grouped and color coded on the power monitor +/// +public enum PowerMonitoringConsoleGroup : byte +{ + Generator, + SMES, + Substation, + APC, + Consumer, +} + +[Flags] +public enum PowerMonitoringFlags : byte +{ + None = 0, + RoguePowerConsumer = 1, + PowerNetAbnormalities = 2, +} + +/// +/// UI key associated with the power monitoring console +/// [Serializable, NetSerializable] public enum PowerMonitoringConsoleUiKey { Key } - diff --git a/Content.Shared/Power/SharedPowerMonitoringConsoleSystem.cs b/Content.Shared/Power/SharedPowerMonitoringConsoleSystem.cs new file mode 100644 index 0000000000..dc4af23c23 --- /dev/null +++ b/Content.Shared/Power/SharedPowerMonitoringConsoleSystem.cs @@ -0,0 +1,8 @@ +using JetBrains.Annotations; + +namespace Content.Shared.Power; + +[UsedImplicitly] +public abstract class SharedPowerMonitoringConsoleSystem : EntitySystem +{ +} diff --git a/Resources/Locale/en-US/components/power-monitoring-component.ftl b/Resources/Locale/en-US/components/power-monitoring-component.ftl index ade9e2d933..e84c09e60d 100644 --- a/Resources/Locale/en-US/components/power-monitoring-component.ftl +++ b/Resources/Locale/en-US/components/power-monitoring-component.ftl @@ -1,8 +1,27 @@ power-monitoring-window-title = Power Monitoring Console -power-monitoring-window-tab-sources = Sources -power-monitoring-window-tab-loads = Loads -power-monitoring-window-total-sources = Total Sources: -power-monitoring-window-total-loads = Total Loads: + +power-monitoring-window-label-sources = Sources +power-monitoring-window-label-smes = SMES +power-monitoring-window-label-substation = Substation +power-monitoring-window-label-apc = APC +power-monitoring-window-label-misc = Misc + +power-monitoring-window-object-array = {$name} array [{$count}] + +power-monitoring-window-station-name = [color=white][font size=14]{$stationName}[/font][/color] +power-monitoring-window-unknown-location = Unknown location +power-monitoring-window-total-sources = Total generator output +power-monitoring-window-total-battery-usage = Total battery usage +power-monitoring-window-total-loads = Total network loads power-monitoring-window-value = { POWERWATTS($value) } power-monitoring-window-show-inactive-consumers = Show Inactive Consumers +power-monitoring-window-show-cable-networks = Toggle cable networks +power-monitoring-window-show-hv-cable = High voltage +power-monitoring-window-show-mv-cable = Medium voltage +power-monitoring-window-show-lv-cable = Low voltage + +power-monitoring-window-flavor-left = [user@nanotrasen] $run power_net_query +power-monitoring-window-flavor-right = v1.3 +power-monitoring-window-rogue-power-consumer = [color=white][font size=14][bold]! WARNING - ROGUE POWER CONSUMING DEVICE DETECTED ![/bold][/font][/color] +power-monitoring-window-power-net-abnormalities = [color=white][font size=14][bold]CAUTION - ABNORMAL ACTIVITY IN POWER NET[/bold][/font][/color] diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml index 24e828bf84..51bf12f8ef 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/computers.yml @@ -239,6 +239,7 @@ - type: Computer board: PowerComputerCircuitboard - type: PowerMonitoringConsole + - type: PowerMonitoringCableNetworks - type: NodeContainer examinable: true nodes: @@ -246,6 +247,7 @@ !type:CableDeviceNode nodeGroupID: HVPower - type: ActivatableUI + singleUser: true key: enum.PowerMonitoringConsoleUiKey.Key - type: UserInterface interfaces: diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/Singularity/collector.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/Singularity/collector.yml index acd1b80fb4..e43c80400e 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/Singularity/collector.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/Singularity/collector.yml @@ -50,6 +50,12 @@ input: !type:CableDeviceNode nodeGroupID: HVPower + - type: PowerMonitoringDevice + group: Generator + loadNode: input + collectionName: radiationCollector + sprite: Structures/Power/Generation/Singularity/collector.rsi + state: static - type: RadiationCollector chargeModifier: 7500 radiationReactiveGases: diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/ame.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/ame.yml index a356bfe285..d12301b3f3 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/ame.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/ame.yml @@ -78,8 +78,11 @@ input: !type:CableDeviceNode nodeGroupID: HVPower -# - type: ApcPowerReceiver -# - type: ExtensionCableReceiver + - type: PowerMonitoringDevice + group: Generator + loadNode: input + sprite: Structures/Power/Generation/ame.rsi + state: static - type: PowerSupplier supplyRate: 0 - type: ContainerContainer diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml index 65571f80ce..ad227956a7 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/generators.yml @@ -40,6 +40,11 @@ output: !type:CableDeviceNode nodeGroupID: HVPower + - type: PowerMonitoringDevice + group: Generator + loadNode: output + sprite: Structures/Power/power.rsi + state: generator - type: PowerSupplier supplyRate: 3000 supplyRampRate: 500 @@ -124,6 +129,11 @@ output: !type:CableDeviceNode nodeGroupID: HVPower + - type: PowerMonitoringDevice + group: Generator + loadNode: output + sprite: Structures/Power/Generation/wallmount_generator.rsi + state: static - type: PowerSupplier supplyRate: 3000 supplyRampRate: 500 @@ -229,6 +239,9 @@ - type: Sprite sprite: Structures/Power/Generation/rtg.rsi state: rtg + - type: PowerMonitoringDevice + sprite: Structures/Power/Generation/rtg.rsi + state: rtg - type: AmbientSound range: 5 sound: @@ -272,6 +285,9 @@ layers: - state: rtg_damaged - state: rtg_glow + - type: PowerMonitoringDevice + sprite: Structures/Power/Generation/rtg.rsi + state: rtg_damaged - type: RadiationSource # ideally only when opened. intensity: 2 - type: Destructible diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/portable_generator.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/portable_generator.yml index c49497b774..74fa0b2531 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/portable_generator.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/portable_generator.yml @@ -170,7 +170,14 @@ gasType: CarbonDioxide # 2 moles of gas for every sheet of plasma. moleRatio: 2 - + - type: PowerMonitoringDevice + group: Generator + loadNodes: + - output_hv + - output_mv + sprite: Structures/Power/Generation/portable_generator.rsi + state: portgen0 + - type: entity name: S.U.P.E.R.P.A.C.M.A.N.-type portable generator description: |- @@ -219,7 +226,14 @@ - type: UpgradePowerSupplier powerSupplyMultiplier: 1.25 scaling: Exponential - + - type: PowerMonitoringDevice + group: Generator + loadNodes: + - output_hv + - output_mv + sprite: Structures/Power/Generation/portable_generator.rsi + state: portgen1 + - type: entity name: J.R.P.A.C.M.A.N.-type portable generator description: |- @@ -293,7 +307,12 @@ nodes: output: !type:CableDeviceNode - nodeGroupID: Apc + nodeGroupID: Apc + - type: PowerMonitoringDevice + group: Generator + loadNode: output + sprite: Structures/Power/Generation/portable_generator.rsi + state: portgen3 - type: PowerSupplier # No ramping needed on this bugger. voltage: Apc diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/solar.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/solar.yml index 7fc240f2d4..750bdadf06 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/solar.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/solar.yml @@ -36,6 +36,12 @@ output: !type:CableDeviceNode nodeGroupID: HVPower + - type: PowerMonitoringDevice + group: Generator + loadNode: output + sprite: Structures/Power/Generation/solar_panel.rsi + state: static + collectionName: SolarPanel - type: Anchorable - type: Pullable - type: Electrified diff --git a/Resources/Prototypes/Entities/Structures/Power/Generation/teg.yml b/Resources/Prototypes/Entities/Structures/Power/Generation/teg.yml index 723a1480de..af482b2dbb 100644 --- a/Resources/Prototypes/Entities/Structures/Power/Generation/teg.yml +++ b/Resources/Prototypes/Entities/Structures/Power/Generation/teg.yml @@ -51,6 +51,11 @@ teg: !type:TegNodeGenerator nodeGroupID: Teg + - type: PowerMonitoringDevice + group: Generator + loadNode: output + sprite: Structures/Power/Generation/teg.rsi + state: static - type: Rotatable # Note that only the TEG center is an AtmosDevice. diff --git a/Resources/Prototypes/Entities/Structures/Power/apc.yml b/Resources/Prototypes/Entities/Structures/Power/apc.yml index 35a8f0f704..fb477c810c 100644 --- a/Resources/Prototypes/Entities/Structures/Power/apc.yml +++ b/Resources/Prototypes/Entities/Structures/Power/apc.yml @@ -65,6 +65,13 @@ output: !type:CableDeviceNode nodeGroupID: Apc + - type: PowerMonitoringDevice + group: APC + sourceNode: input + loadNode: output + collectionName: apc + sprite: Structures/Power/apc.rsi + state: static - type: BatteryCharger voltage: Medium - type: PowerProvider diff --git a/Resources/Prototypes/Entities/Structures/Power/smes.yml b/Resources/Prototypes/Entities/Structures/Power/smes.yml index 92451918c7..4a3b8e5d98 100644 --- a/Resources/Prototypes/Entities/Structures/Power/smes.yml +++ b/Resources/Prototypes/Entities/Structures/Power/smes.yml @@ -43,15 +43,22 @@ examinable: true nodes: input: - !type:CableDeviceNode - nodeGroupID: HVPower - output: !type:CableTerminalPortNode nodeGroupID: HVPower - - type: BatteryCharger + output: + !type:CableDeviceNode + nodeGroupID: HVPower + - type: PowerMonitoringDevice + group: SMES + sourceNode: input + loadNode: output + collectionName: smes + sprite: Structures/Power/smes.rsi + state: static + - type: BatteryDischarger voltage: High node: output - - type: BatteryDischarger + - type: BatteryCharger voltage: High node: input - type: PowerNetworkBattery diff --git a/Resources/Prototypes/Entities/Structures/Power/substation.yml b/Resources/Prototypes/Entities/Structures/Power/substation.yml index bd3dcc4b8c..fe6936d411 100644 --- a/Resources/Prototypes/Entities/Structures/Power/substation.yml +++ b/Resources/Prototypes/Entities/Structures/Power/substation.yml @@ -40,6 +40,13 @@ output: !type:CableDeviceNode nodeGroupID: MVPower + - type: PowerMonitoringDevice + group: Substation + sourceNode: input + loadNode: output + collectionName: substation + sprite: Structures/Power/substation.rsi + state: substation_static - type: BatteryCharger voltage: High - type: BatteryDischarger @@ -166,6 +173,12 @@ output: !type:CableDeviceNode nodeGroupID: MVPower + - type: PowerMonitoringDevice + group: Substation + sourceNode: input + loadNode: output + sprite: Structures/Power/substation.rsi + state: substation_wall_static - type: BatteryCharger voltage: High - type: BatteryDischarger diff --git a/Resources/Textures/Interface/NavMap/beveled_hexagon.png b/Resources/Textures/Interface/NavMap/beveled_hexagon.png new file mode 100644 index 0000000000..dc69a73480 Binary files /dev/null and b/Resources/Textures/Interface/NavMap/beveled_hexagon.png differ diff --git a/Resources/Textures/Interface/NavMap/beveled_square.png b/Resources/Textures/Interface/NavMap/beveled_square.png new file mode 100644 index 0000000000..5efa178932 Binary files /dev/null and b/Resources/Textures/Interface/NavMap/beveled_square.png differ diff --git a/Resources/Textures/Interface/NavMap/beveled_triangle.png b/Resources/Textures/Interface/NavMap/beveled_triangle.png new file mode 100644 index 0000000000..d0e90724d1 Binary files /dev/null and b/Resources/Textures/Interface/NavMap/beveled_triangle.png differ diff --git a/Resources/Textures/Interface/PowerMonitoring/load_arrow.png b/Resources/Textures/Interface/PowerMonitoring/load_arrow.png new file mode 100644 index 0000000000..fb112fecea Binary files /dev/null and b/Resources/Textures/Interface/PowerMonitoring/load_arrow.png differ diff --git a/Resources/Textures/Interface/PowerMonitoring/source_arrow.png b/Resources/Textures/Interface/PowerMonitoring/source_arrow.png new file mode 100644 index 0000000000..afe30cba70 Binary files /dev/null and b/Resources/Textures/Interface/PowerMonitoring/source_arrow.png differ diff --git a/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/meta.json b/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/meta.json index f111c8a64a..1da3a0a733 100644 --- a/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/meta.json +++ b/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/meta.json @@ -12,6 +12,9 @@ }, { "name": "ca_on" + }, + { + "name": "static" }, { "name": "ca_active", diff --git a/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/static.png b/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/static.png new file mode 100644 index 0000000000..6097982ee2 Binary files /dev/null and b/Resources/Textures/Structures/Power/Generation/Singularity/collector.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/Generation/ame.rsi/meta.json b/Resources/Textures/Structures/Power/Generation/ame.rsi/meta.json index fd88680d91..8f1a2b7a31 100644 --- a/Resources/Textures/Structures/Power/Generation/ame.rsi/meta.json +++ b/Resources/Textures/Structures/Power/Generation/ame.rsi/meta.json @@ -7,7 +7,7 @@ "y": 32 }, "states": [ - { + { "name": "shield_0" }, { @@ -86,6 +86,9 @@ }, { "name": "control" + }, + { + "name": "static" }, { "name": "control_critical", diff --git a/Resources/Textures/Structures/Power/Generation/ame.rsi/static.png b/Resources/Textures/Structures/Power/Generation/ame.rsi/static.png new file mode 100644 index 0000000000..de0e6db98d Binary files /dev/null and b/Resources/Textures/Structures/Power/Generation/ame.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/meta.json b/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/meta.json index 49c9b279ad..0046666656 100644 --- a/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/meta.json +++ b/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/meta.json @@ -1 +1,30 @@ -{"version":1,"license":"CC-BY-SA-3.0","copyright":"Taken from https://github.com/discordia-space/CEV-Eris/blob/d1e0161af146835f4fb79d21a6200caa9cc842d0/icons/obj/power.dmi and modified.","size":{"x":32,"y":32},"states":[{"name":"normal","select":[],"flags":{},"directions":8},{"name":"broken","select":[],"flags":{},"directions":1},{"name": "solar_assembly"},{"name": "solar_tracker"}]} +{ + "version":1, + "license":"CC-BY-SA-3.0", + "copyright":"Taken from https://github.com/discordia-space/CEV-Eris/blob/d1e0161af146835f4fb79d21a6200caa9cc842d0/icons/obj/power.dmi and modified.", + "size":{"x":32,"y":32}, + "states": + [ + { + "name": "normal", + "select": [], + "flags": {}, + "directions": 8 + }, + { + "name": "broken", + "select": [], + "flags": {}, + "directions": 1 + }, + { + "name": "static" + }, + { + "name": "solar_assembly" + }, + { + "name": "solar_tracker" + } + ] +} diff --git a/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/static.png b/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/static.png new file mode 100644 index 0000000000..455a061b31 Binary files /dev/null and b/Resources/Textures/Structures/Power/Generation/solar_panel.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/Generation/teg.rsi/meta.json b/Resources/Textures/Structures/Power/Generation/teg.rsi/meta.json index f0c0b58f05..b14f15b579 100644 --- a/Resources/Textures/Structures/Power/Generation/teg.rsi/meta.json +++ b/Resources/Textures/Structures/Power/Generation/teg.rsi/meta.json @@ -7,9 +7,12 @@ "y": 32 }, "states": [ - { + { "name": "teg", "directions": 4 + }, + { + "name": "static" }, { "name": "teg-op1", diff --git a/Resources/Textures/Structures/Power/Generation/teg.rsi/static.png b/Resources/Textures/Structures/Power/Generation/teg.rsi/static.png new file mode 100644 index 0000000000..f0c9303944 Binary files /dev/null and b/Resources/Textures/Structures/Power/Generation/teg.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/meta.json b/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/meta.json index 75571ab844..df1f633a18 100644 --- a/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/meta.json +++ b/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/meta.json @@ -9,6 +9,9 @@ "states": [ { "name": "panel" + }, + { + "name": "static" }, { "name": "on", diff --git a/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/static.png b/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/static.png new file mode 100644 index 0000000000..fa674a8960 Binary files /dev/null and b/Resources/Textures/Structures/Power/Generation/wallmount_generator.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/apc.rsi/meta.json b/Resources/Textures/Structures/Power/apc.rsi/meta.json index bcc12b2e55..82f78ccb87 100644 --- a/Resources/Textures/Structures/Power/apc.rsi/meta.json +++ b/Resources/Textures/Structures/Power/apc.rsi/meta.json @@ -9,6 +9,9 @@ "states": [ { "name": "base" + }, + { + "name": "static" }, { "name": "broken" diff --git a/Resources/Textures/Structures/Power/apc.rsi/static.png b/Resources/Textures/Structures/Power/apc.rsi/static.png new file mode 100644 index 0000000000..43089d0d81 Binary files /dev/null and b/Resources/Textures/Structures/Power/apc.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/smes.rsi/meta.json b/Resources/Textures/Structures/Power/smes.rsi/meta.json index e841421f7c..2ca8d1fb22 100644 --- a/Resources/Textures/Structures/Power/smes.rsi/meta.json +++ b/Resources/Textures/Structures/Power/smes.rsi/meta.json @@ -9,6 +9,9 @@ "states": [ { "name": "smes" + }, + { + "name": "static" }, { "name": "smes-open" diff --git a/Resources/Textures/Structures/Power/smes.rsi/static.png b/Resources/Textures/Structures/Power/smes.rsi/static.png new file mode 100644 index 0000000000..3e52b6c43e Binary files /dev/null and b/Resources/Textures/Structures/Power/smes.rsi/static.png differ diff --git a/Resources/Textures/Structures/Power/substation.rsi/meta.json b/Resources/Textures/Structures/Power/substation.rsi/meta.json index 83d05c9ff0..a2ade57116 100644 --- a/Resources/Textures/Structures/Power/substation.rsi/meta.json +++ b/Resources/Textures/Structures/Power/substation.rsi/meta.json @@ -10,9 +10,15 @@ { "name": "substation" }, - { + { + "name": "substation_static" + }, + { "name": "substation_wall" }, + { + "name": "substation_wall_static" + }, { "name": "full" }, diff --git a/Resources/Textures/Structures/Power/substation.rsi/substation_static.png b/Resources/Textures/Structures/Power/substation.rsi/substation_static.png new file mode 100644 index 0000000000..2a1de1a066 Binary files /dev/null and b/Resources/Textures/Structures/Power/substation.rsi/substation_static.png differ diff --git a/Resources/Textures/Structures/Power/substation.rsi/substation_wall_static.png b/Resources/Textures/Structures/Power/substation.rsi/substation_wall_static.png new file mode 100644 index 0000000000..ee24ff45e6 Binary files /dev/null and b/Resources/Textures/Structures/Power/substation.rsi/substation_wall_static.png differ