diff --git a/Content.Client/Medical/Cryogenics/BeakerBarChart.cs b/Content.Client/Medical/Cryogenics/BeakerBarChart.cs new file mode 100644 index 00000000000..25301b52686 --- /dev/null +++ b/Content.Client/Medical/Cryogenics/BeakerBarChart.cs @@ -0,0 +1,285 @@ +using System.Diagnostics.CodeAnalysis; +using System.Numerics; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +// ReSharper disable CompareOfFloatsByEqualityOperator + +namespace Content.Client.Medical.Cryogenics; + + +public sealed class BeakerBarChart : Control +{ + private sealed class Entry + { + public float WidthFraction; // This entry's width as a fraction of the chart's total width (between 0 and 1) + public float TargetAmount; + public string Uid; // This UID is used to track entries between frames, for animation. + public string? Tooltip; + public Color Color; + public Label Label; + + public Entry(string uid, Label label) + { + Uid = uid; + Label = label; + } + } + + public float Capacity = 50; + + public Color NotchColor = new(1, 1, 1, 0.25f); + public Color BackgroundColor = new(0.1f, 0.1f, 0.1f); + + public int MediumNotchInterval = 5; + public int BigNotchInterval = 10; + + // When we have a very large beaker (i.e. bluespace beaker) we might need to increase the distance between notches. + // The distance between notches is increased by ScaleMultiplier when the distance between notches is less than + // MinSmallNotchScreenDistance in UI units. + public int MinSmallNotchScreenDistance = 2; + public int ScaleMultiplier = 10; + + public float SmallNotchHeight = 0.1f; + public float MediumNotchHeight = 0.25f; + public float BigNotchHeight = 1f; + + // We don't animate new entries until this control has been drawn at least once. + private bool _hasBeenDrawn = false; + + // This is used to keep the segments of the chart in the same order as the SetEntry calls. + // For example: In update 1 we might get cryox, alox, bic (in that order), and in update 2 we get alox, cryox, bic. + // To keep the order of the entries the same as the order of the SetEntry calls, we let the old cryox entry + // disappear and create a new cryox entry behind the alox entry. + private int _nextUpdateableEntry = 0; + + private readonly List _entries = new(); + + + public BeakerBarChart() + { + MouseFilter = MouseFilterMode.Pass; + TooltipSupplier = SupplyTooltip; + } + + public void Clear() + { + foreach (var entry in _entries) + { + entry.TargetAmount = 0; + } + + _nextUpdateableEntry = 0; + } + + /// + /// Either adds a new entry to the chart if the UID doesn't appear yet, or updates the amount of an existing entry. + /// + public void SetEntry( + string uid, + string label, + float amount, + Color color, + Color? textColor = null, + string? tooltip = null) + { + // If we can find an old entry we're allowed to update, update that one. + if (TryFindUpdateableEntry(uid, out var index)) + { + _entries[index].TargetAmount = amount; + _entries[index].Tooltip = tooltip; + _entries[index].Label.Text = label; + _nextUpdateableEntry = index + 1; + return; + } + + // Otherwise create a new entry. + if (amount <= 0) + return; + + // If no text color is provided, use either white or black depending on how dark the background is. + textColor ??= (color.R + color.G + color.B < 1.5f ? Color.White : Color.Black); + + var childLabel = new Label + { + Text = label, + ClipText = true, + FontColorOverride = textColor, + Margin = new Thickness(4, 0, 0, 0) + }; + AddChild(childLabel); + + _entries.Insert( + _nextUpdateableEntry, + new Entry(uid, childLabel) + { + WidthFraction = (_hasBeenDrawn ? 0 : amount / Capacity), + TargetAmount = amount, + Tooltip = tooltip, + Color = color + } + ); + + _nextUpdateableEntry += 1; + } + + private bool TryFindUpdateableEntry(string uid, out int index) + { + for (int i = _nextUpdateableEntry; i < _entries.Count; i++) + { + if (_entries[i].Uid == uid) + { + index = i; + return true; + } + } + + index = -1; + return false; + } + + private IEnumerable<(Entry, float xMin, float xMax)> EntryRanges(float? pixelWidth = null) + { + float chartWidth = pixelWidth ?? PixelWidth; + var xStart = 0f; + + foreach (var entry in _entries) + { + var entryWidth = entry.WidthFraction * chartWidth; + var xEnd = MathF.Min(xStart + entryWidth, chartWidth); + + yield return (entry, xStart, xEnd); + + xStart = xEnd; + } + } + + private bool TryFindEntry(float x, [NotNullWhen(true)] out Entry? entry) + { + foreach (var (currentEntry, xMin, xMax) in EntryRanges()) + { + if (xMin <= x && x < xMax) + { + entry = currentEntry; + return true; + } + } + + entry = null; + return false; + } + + protected override void FrameUpdate(FrameEventArgs args) + { + // Tween the amounts to their target amounts. + const float tweenInverseHalfLife = 8; // Half life of tween is 1/n + var hasChanged = false; + + foreach (var entry in _entries) + { + var targetWidthFraction = entry.TargetAmount / Capacity; + + if (entry.WidthFraction == targetWidthFraction) + continue; + + // Tween with lerp abuse interpolation + entry.WidthFraction = MathHelper.Lerp( + entry.WidthFraction, + targetWidthFraction, + MathHelper.Clamp01(tweenInverseHalfLife * args.DeltaSeconds) + ); + hasChanged = true; + + if (MathF.Abs(entry.WidthFraction - targetWidthFraction) < 0.0001f) + entry.WidthFraction = targetWidthFraction; + } + + if (!hasChanged) + return; + + InvalidateArrange(); + + // Remove old entries whose animations have finished. + foreach (var entry in _entries) + { + if (entry.WidthFraction == 0 && entry.TargetAmount == 0) + RemoveChild(entry.Label); + } + + _entries.RemoveAll(entry => entry.WidthFraction == 0 && entry.TargetAmount == 0); + } + + protected override void MouseMove(GUIMouseMoveEventArgs args) + { + HideTooltip(); + } + + protected override void Draw(DrawingHandleScreen handle) + { + handle.DrawRect(PixelSizeBox, BackgroundColor); + + // Draw the entry backgrounds + foreach (var (entry, xMin, xMax) in EntryRanges()) + { + if (xMin != xMax) + handle.DrawRect(new(xMin, 0, xMax, PixelHeight), entry.Color); + } + + // Draw notches + var unitWidth = PixelWidth / Capacity; + var unitsPerNotch = 1; + + while (unitWidth < MinSmallNotchScreenDistance) + { + // This is here for 1000u bluespace beakers. If the distance between small notches is so small that it would + // be very ugly, we reduce the amount of notches by ScaleMultiplier (currently a factor of 10). + // (I could use an analytical algorithm here, but it would be more difficult to read with pretty much no + // performance benefit, since it loops zero times normally and one time for the bluespace beaker) + unitWidth *= ScaleMultiplier; + unitsPerNotch *= ScaleMultiplier; + } + + for (int i = 0; i <= Capacity / unitsPerNotch; i++) + { + var x = i * unitWidth; + var height = (i % BigNotchInterval == 0 ? BigNotchHeight : + i % MediumNotchInterval == 0 ? MediumNotchHeight : + SmallNotchHeight) * PixelHeight; + var start = new Vector2(x, PixelHeight); + var end = new Vector2(x, PixelHeight - height); + handle.DrawLine(start, end, NotchColor); + } + + _hasBeenDrawn = true; + } + + protected override Vector2 ArrangeOverride(Vector2 finalSize) + { + foreach (var (entry, xMin, xMax) in EntryRanges(finalSize.X)) + { + entry.Label.Arrange(new((int)xMin, 0, (int)xMax, (int)finalSize.Y)); + } + + return finalSize; + } + + private Control? SupplyTooltip(Control sender) + { + var globalMousePos = UserInterfaceManager.MousePositionScaled.Position; + var mousePos = globalMousePos - GlobalPosition; + + if (!TryFindEntry(mousePos.X, out var entry) || entry.Tooltip == null) + return null; + + var msg = new FormattedMessage(); + msg.AddText(entry.Tooltip); + + var tooltip = new Tooltip(); + tooltip.SetMessage(msg); + return tooltip; + } +} diff --git a/Content.Client/_Starlight/Plumbing/PlumbingConnectorAppearanceSystem.cs b/Content.Client/_Starlight/Plumbing/PlumbingConnectorAppearanceSystem.cs new file mode 100644 index 00000000000..a165bdab176 --- /dev/null +++ b/Content.Client/_Starlight/Plumbing/PlumbingConnectorAppearanceSystem.cs @@ -0,0 +1,184 @@ +using System.Numerics; +using Content.Shared._Starlight.Plumbing; +using Content.Shared._Starlight.Plumbing.Components; +using Content.Client.SubFloor; +using Content.Shared.Atmos; +using Content.Shared.Atmos.Piping; +using JetBrains.Annotations; +using Robust.Client.GameObjects; + +namespace Content.Client._Starlight.Plumbing; + +/// +/// Client system that creates and updates plumbing connector sprite layers. +/// Single layer per direction that switches between disconnected/connected sprites +/// Layers hide when covered by floor tiles (server sends CoveredByFloor state) +/// +[UsedImplicitly] +public sealed class PlumbingConnectorAppearanceSystem : EntitySystem +{ + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly SpriteSystem _sprite = default!; + + private static readonly Color InletColor = new(1.0f, 0.35f, 0.35f); // Vibrant Red + private static readonly Color OutletColor = new(0.35f, 0.6f, 1.0f); // Vibrant Blue + private static readonly Color MixingInletColor = new(0.35f, 0.9f, 0.35f); // Vibrant Green + private static readonly PlumbingConnectionLayer[] ConnectionLayers = Enum.GetValues(); + + private EntityQuery _xformQuery; + + public override void Initialize() + { + base.Initialize(); + _xformQuery = GetEntityQuery(); + + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(OnAppearanceChanged, after: [typeof(SubFloorHideSystem)]); + } + + private void OnInit(EntityUid uid, PlumbingConnectorAppearanceComponent component, ComponentInit args) + { + if (!TryComp(uid, out SpriteComponent? sprite)) + return; + + // Create one layer for each cardinal direction + // The layer will swap between disconnected/connected sprites based on connection state + // Insert at layer 0 so connectors render UNDER the plumbing machine sprite + foreach (var layerKey in ConnectionLayers) + { + var offset = GetDirectionOffset(layerKey, component.Offset); + + // Each insertion at 0 pushes previous layers up, so we use index 0 for all operations + var layerName = layerKey.ToString(); + _sprite.AddBlankLayer((uid, sprite), 0); + _sprite.LayerMapSet((uid, sprite), layerName, 0); + + // Disconnected connectors are offset from center to show under big machine sprites. + _sprite.LayerSetRsi((uid, sprite), 0, component.Disconnected.RsiPath); + _sprite.LayerSetRsiState((uid, sprite), 0, component.Disconnected.RsiState); + _sprite.LayerSetDirOffset((uid, sprite), 0, ToOffset(layerKey)); + _sprite.LayerSetVisible((uid, sprite), 0, false); + if (offset != Vector2.Zero) + _sprite.LayerSetOffset((uid, sprite), 0, offset); + } + } + + private static Vector2 GetDirectionOffset(PlumbingConnectionLayer layer, float offset) + { + return ((PipeDirection)layer).ToDirection().ToVec() * offset; + } + + private void OnAppearanceChanged(EntityUid uid, PlumbingConnectorAppearanceComponent component, ref AppearanceChangeEvent args) + { + if (args.Sprite == null) + return; + + if (!args.Sprite.Visible) + { + return; + } + + // Hide if no nodes exists somehow + if (!_appearance.TryGetData(uid, PlumbingVisuals.NodeDirections, out var nodeDirectionsInt, args.Component)) + { + HideAllLayers(uid, args.Sprite); + return; + } + + if (!_appearance.TryGetData(uid, PlumbingVisuals.ConnectedDirections, out var connectedDirectionsInt, args.Component)) + connectedDirectionsInt = 0; + + if (!_appearance.TryGetData(uid, PlumbingVisuals.InletDirections, out var inletDirectionsInt, args.Component)) + inletDirectionsInt = 0; + if (!_appearance.TryGetData(uid, PlumbingVisuals.OutletDirections, out var outletDirectionsInt, args.Component)) + outletDirectionsInt = 0; + if (!_appearance.TryGetData(uid, PlumbingVisuals.MixingInletDirections, out var mixingInletDirectionsInt, args.Component)) + mixingInletDirectionsInt = 0; + + if (!_appearance.TryGetData(uid, PlumbingVisuals.CoveredByFloor, out var coveredByFloor, args.Component)) + coveredByFloor = false; + + var nodeDirections = (PipeDirection)nodeDirectionsInt; + var connectedDirections = (PipeDirection)connectedDirectionsInt; + var inletDirections = (PipeDirection)inletDirectionsInt; + var outletDirections = (PipeDirection)outletDirectionsInt; + var mixingInletDirections = (PipeDirection)mixingInletDirectionsInt; + + // Get the entity's local rotation to transform world directions to local + if (!_xformQuery.TryGetComponent(uid, out var xform)) + return; + var localRotation = xform.LocalRotation; + var connectedDirectionsLocal = connectedDirections.RotatePipeDirection(-localRotation); + var nodeDirectionsLocal = nodeDirections.RotatePipeDirection(-localRotation); + var inletDirectionsLocal = inletDirections.RotatePipeDirection(-localRotation); + var outletDirectionsLocal = outletDirections.RotatePipeDirection(-localRotation); + var mixingInletDirectionsLocal = mixingInletDirections.RotatePipeDirection(-localRotation); + + + foreach (var layerKey in ConnectionLayers) + { + var dir = (PipeDirection)layerKey; + var hasNode = nodeDirectionsLocal.HasDirection(dir); + var isConnected = connectedDirectionsLocal.HasDirection(dir); + var isInlet = inletDirectionsLocal.HasDirection(dir); + var isOutlet = outletDirectionsLocal.HasDirection(dir); + var isMixingInlet = mixingInletDirectionsLocal.HasDirection(dir); + + // Determine color based on inlet/outlet/mixing + var color = isMixingInlet ? MixingInletColor : isInlet ? InletColor : isOutlet ? OutletColor : Color.White; + + var layerName = layerKey.ToString(); + if (_sprite.LayerMapTryGet((uid, args.Sprite), layerName, out var layerKey2, false)) + { + var layer = args.Sprite[layerKey2]; + layer.Visible = hasNode && !coveredByFloor; + + if (layer.Visible) + { + // Swap sprite based on connection state + if (isConnected) + { + _sprite.LayerSetRsiState((uid, args.Sprite), layerKey2, component.Connected.RsiState); + _sprite.LayerSetOffset((uid, args.Sprite), layerKey2, Vector2.Zero); + } + else + { + _sprite.LayerSetRsiState((uid, args.Sprite), layerKey2, component.Disconnected.RsiState); + _sprite.LayerSetOffset((uid, args.Sprite), layerKey2, GetDirectionOffset(layerKey, component.Offset)); + } + layer.Color = color; + } + } + } + } + + private void HideAllLayers(EntityUid uid, SpriteComponent sprite) + { + foreach (var layerKey in ConnectionLayers) + { + var layerName = layerKey.ToString(); + if (_sprite.LayerMapTryGet((uid, sprite), layerName, out var key, false)) + sprite[key].Visible = false; + } + } + + + private SpriteComponent.DirectionOffset ToOffset(PlumbingConnectionLayer layer) + { + return layer switch + { + PlumbingConnectionLayer.NorthConnection => SpriteComponent.DirectionOffset.Flip, + PlumbingConnectionLayer.EastConnection => SpriteComponent.DirectionOffset.CounterClockwise, + PlumbingConnectionLayer.WestConnection => SpriteComponent.DirectionOffset.Clockwise, + _ => SpriteComponent.DirectionOffset.None, // SouthConnection + }; + } + + private enum PlumbingConnectionLayer : byte + { + NorthConnection = PipeDirection.North, + SouthConnection = PipeDirection.South, + EastConnection = PipeDirection.East, + WestConnection = PipeDirection.West, + } +} diff --git a/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterBoundUserInterface.cs b/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterBoundUserInterface.cs new file mode 100644 index 00000000000..c35ca54913a --- /dev/null +++ b/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterBoundUserInterface.cs @@ -0,0 +1,57 @@ +using Content.Shared._Starlight.Plumbing; +using JetBrains.Annotations; +using Robust.Client.UserInterface; + +namespace Content.Client._Starlight.Plumbing.UI; + +[UsedImplicitly] +public sealed class PlumbingFilterBoundUserInterface : BoundUserInterface +{ + private PlumbingFilterWindow? _window; + + public PlumbingFilterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + + _window.OnToggle += OnToggle; + _window.OnAddReagent += OnAddReagent; + _window.OnRemoveReagent += OnRemoveReagent; + _window.OnClear += OnClear; + } + + private void OnToggle(bool enabled) + { + SendMessage(new PlumbingFilterToggleMessage(enabled)); + } + + private void OnAddReagent(string reagentId) + { + SendMessage(new PlumbingFilterAddReagentMessage(reagentId)); + } + + private void OnRemoveReagent(string reagentId) + { + SendMessage(new PlumbingFilterRemoveReagentMessage(reagentId)); + } + + private void OnClear() + { + SendMessage(new PlumbingFilterClearMessage()); + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (_window == null || state is not PlumbingFilterBoundUserInterfaceState cast) + return; + + _window.UpdateState(cast); + } +} diff --git a/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterWindow.xaml b/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterWindow.xaml new file mode 100644 index 00000000000..9a47109fde3 --- /dev/null +++ b/Content.Client/_Starlight/Plumbing/UI/PlumbingFilterWindow.xaml @@ -0,0 +1,29 @@ + + + + +