From 4f997f2069c5625bf3b0de9e3cf6f0c71a3b9ac9 Mon Sep 17 00:00:00 2001 From: Fruitsalad <949631+Fruitsalad@users.noreply.github.com> Date: Thu, 15 Jan 2026 18:52:03 +0100 Subject: [PATCH] Cryo pod UI (#41850) * Add CryoPodWindow (placeholder) * Change HealthAnalyzerWindow: split off reusable HealthAnalyzerControl for cryo pod UI * Improve CryoPodWindow: add health analyzer * Improve CryoPodWindow: add eject button This wasn't requested in the issue but I implemented it as practice with the UI system. * Rewrote GasAnalyzerWindow, split off reusable gas mix viewer for cryo pod * Change GasAnalyzerWindow: change back to three columns With two rows you get a layouting bug when there's a lot of different gases, which looks somewhat bad. I didn't feel like fixing the layouting bug (it's an engine issue) so we're going back to three columns. That way you don't ever get two rows in practice. * Change GasAnalyzerWindow: simplify by disabling Resizable I added a lot of complexity to make resizable work nicely with a derived max & min size, but it's not necessary. * Change GasAnalyzerWindow: file-wide namespace * Change GasAnalyzerSystem: add GenerateGasMixEntry * Split HealthAnalyzerUiState from HealthAnalyzerScannedUserMessage * Rewrote CryoPodWindow, add atmos info * Improve CryoPodWindow: add loading placeholder * Improve CryoPodWindow: add internationalization support * Fix GasAnalyzerControl: add missing translation * Improve CryoPodWindow: add beaker info, high temperature warning * Improve CryoPodWindow/System: inject button in window + necessary system changes * Fix CryoPodWindow: Entering cryopod now closes window This way you can't heal yourself with a cryopod. * Change CryoPodWindow: add & update comments * Change HealthAnalyzerComponent: remove `uiKey` property (no longer necessary) * Tiny fixes * Improve CryoPodUiMessage: replace string with enum * Change GasAnalyzerWindow: simplify Measure code * Change CryoPodComponent: rename Injecting to InjectionBuffer * Change CryoPodBUI: tiny code simplification * Fix HealthAnalyzerComponent: Removed stray import * Improve CryoPodWindow: Prettier, concise atmos * Improve CryoPodWindow: Chemicals bar chart * Improve CryoPodWindow: Add Ruler to reagents * Change CryoPodWindow: More horizontal layout * Improve CryoPodWindow: Reduce height jiggling The health analyzer's height changes a lot, which can be annoying with the buttons (for example when the oxygen damage label is popping in and out) * Improve CryoPodWindow: Add setup checklist This is mostly here to fill vertical space in the new horizontal layout. * Improve CryoPodWindow: Eject beaker button * Improve CryoPodWindow: Localization * Improve CryoPodWindow: Add BeakerBarChart An animated version of the chemicals chart * Fix CryoPodSystem: Ejecting beaker no longer clears injection buffer * Improve BeakerBarChart: Not animated on first frame * Fix CryoPodWindow: Fix broken translation * Improve CryoPodWindow: Reorder sections * Fix BeakerBarChart: Tooltips now show up * Change BeakerBarChart: Reorder functions * Change CryoPodWindow: Reorder sections, change margins * Change CryoPodWindow: Edit flavor text * Revert changes to GasAnalyzerWindow Since GasAnalyzerControl is no longer used in CryoPodWindow, these changes are no longer relevant to this PR. * Tidy CryoPodWindow: Remove old workarounds These are old layouting bug workarounds from the older version of CryoPodWindow that had a ScrollContainer in it. They're no longer necessary. Less ScrollContainers less problems. * Tidy up: Remove unused imports * Remove LabelledSplitBar It was replaced by BeakerBarChart, which is a lot fancier. * Tidy up: Tiny code style fix * Change CryoPodSystem: Move code from server to shared This is still without adding UI prediction * move a ton of stuff to shared. * one last thing * Improve BeakerBarChart: Keep visual entry width when swapping beakers * Improve BeakerBarChart: Respect beaker order of reagents * Improve CryoPodWindow: Ensure space for injection buffer We need to keep space on the chart for the injection buffer after swapping to a full beaker. * Improve CryoPodWindow: Prettier ejection error * Improve CryoPodWindow: Add "Cooling patient" status * BeakerBarChart: Fix UI scale bug * BeakerBarChart: Fix bluespace beaker ugliness * BeakerBarChart: Add more pod status strings * HealthAnalyzerControl: Filewide namespace, sort imports * Style fix: Replace `bool x = y` with `var x = y` * CryoPodUiMessage: Split off separate class for inject * SharedCryoPodSystem: Move message-related code into Subs.BuiEvents --------- Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com> --- .../UI/HealthAnalyzerControl.xaml | 53 ++++ .../UI/HealthAnalyzerControl.xaml.cs | 241 +++++++++++++++ .../UI/HealthAnalyzerWindow.xaml | 55 +--- .../UI/HealthAnalyzerWindow.xaml.cs | 241 +-------------- .../Medical/Cryogenics/BeakerBarChart.cs | 285 ++++++++++++++++++ .../Cryogenics/CryoPodBoundUserInterface.cs | 53 ++++ .../Medical/Cryogenics/CryoPodSystem.cs | 10 +- .../Medical/Cryogenics/CryoPodWindow.xaml | 232 ++++++++++++++ .../Medical/Cryogenics/CryoPodWindow.xaml.cs | 260 ++++++++++++++++ .../Atmos/EntitySystems/GasAnalyzerSystem.cs | 35 ++- Content.Server/Medical/CryoPodSystem.cs | 73 ++--- .../Medical/HealthAnalyzerSystem.cs | 43 ++- .../Medical/Cryogenics/CryoPodComponent.cs | 79 ++++- .../Medical/Cryogenics/SharedCryoPodSystem.cs | 248 ++++++++++++--- .../HealthAnalyzerScannedUserMessage.cs | 21 +- .../medical/components/cryo-pod-component.ftl | 39 +++ .../Structures/Machines/Medical/cryo_pod.yml | 12 +- 17 files changed, 1582 insertions(+), 398 deletions(-) create mode 100644 Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml create mode 100644 Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs create mode 100644 Content.Client/Medical/Cryogenics/BeakerBarChart.cs create mode 100644 Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs create mode 100644 Content.Client/Medical/Cryogenics/CryoPodWindow.xaml create mode 100644 Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml new file mode 100644 index 0000000000..06c6528f59 --- /dev/null +++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml @@ -0,0 +1,53 @@ + + diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs new file mode 100644 index 0000000000..949b4770c4 --- /dev/null +++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs @@ -0,0 +1,241 @@ +using System.Linq; +using System.Numerics; +using Content.Shared.Atmos; +using Content.Shared.Damage.Components; +using Content.Shared.Damage.Prototypes; +using Content.Shared.FixedPoint; +using Content.Shared.Humanoid; +using Content.Shared.Humanoid.Prototypes; +using Content.Shared.IdentityManagement; +using Content.Shared.MedicalScanner; +using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; +using Robust.Client.AutoGenerated; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +namespace Content.Client.HealthAnalyzer.UI; + +// Health analyzer UI is split from its window because it's used by both the +// health analyzer item and the cryo pod UI. + +[GenerateTypedNameReferences] +public sealed partial class HealthAnalyzerControl : BoxContainer +{ + private readonly IEntityManager _entityManager; + private readonly SpriteSystem _spriteSystem; + private readonly IPrototypeManager _prototypes; + private readonly IResourceCache _cache; + + public HealthAnalyzerControl() + { + RobustXamlLoader.Load(this); + + var dependencies = IoCManager.Instance!; + _entityManager = dependencies.Resolve(); + _spriteSystem = _entityManager.System(); + _prototypes = dependencies.Resolve(); + _cache = dependencies.Resolve(); + } + + public void Populate(HealthAnalyzerUiState state) + { + var target = _entityManager.GetEntity(state.TargetEntity); + + if (target == null + || !_entityManager.TryGetComponent(target, out var damageable)) + { + NoPatientDataText.Visible = true; + return; + } + + NoPatientDataText.Visible = false; + + // Scan Mode + + ScanModeLabel.Text = state.ScanMode.HasValue + ? state.ScanMode.Value + ? Loc.GetString("health-analyzer-window-scan-mode-active") + : Loc.GetString("health-analyzer-window-scan-mode-inactive") + : Loc.GetString("health-analyzer-window-entity-unknown-text"); + + ScanModeLabel.FontColorOverride = state.ScanMode.HasValue && state.ScanMode.Value ? Color.Green : Color.Red; + + // Patient Information + + SpriteView.SetEntity(target.Value); + SpriteView.Visible = state.ScanMode.HasValue && state.ScanMode.Value; + NoDataTex.Visible = !SpriteView.Visible; + + var name = new FormattedMessage(); + name.PushColor(Color.White); + name.AddText(_entityManager.HasComponent(target.Value) + ? Identity.Name(target.Value, _entityManager) + : Loc.GetString("health-analyzer-window-entity-unknown-text")); + NameLabel.SetMessage(name); + + SpeciesLabel.Text = + _entityManager.TryGetComponent(target.Value, + out var humanoidAppearanceComponent) + ? Loc.GetString(_prototypes.Index(humanoidAppearanceComponent.Species).Name) + : Loc.GetString("health-analyzer-window-entity-unknown-species-text"); + + // Basic Diagnostic + + TemperatureLabel.Text = !float.IsNaN(state.Temperature) + ? $"{state.Temperature - Atmospherics.T0C:F1} °C ({state.Temperature:F1} K)" + : Loc.GetString("health-analyzer-window-entity-unknown-value-text"); + + BloodLabel.Text = !float.IsNaN(state.BloodLevel) + ? $"{state.BloodLevel * 100:F1} %" + : Loc.GetString("health-analyzer-window-entity-unknown-value-text"); + + StatusLabel.Text = + _entityManager.TryGetComponent(target.Value, out var mobStateComponent) + ? GetStatus(mobStateComponent.CurrentState) + : Loc.GetString("health-analyzer-window-entity-unknown-text"); + + // Total Damage + + DamageLabel.Text = damageable.TotalDamage.ToString(); + + // Alerts + + var showAlerts = state.Unrevivable == true || state.Bleeding == true; + + AlertsDivider.Visible = showAlerts; + AlertsContainer.Visible = showAlerts; + + if (showAlerts) + AlertsContainer.RemoveAllChildren(); + + if (state.Unrevivable == true) + AlertsContainer.AddChild(new RichTextLabel + { + Text = Loc.GetString("health-analyzer-window-entity-unrevivable-text"), + Margin = new Thickness(0, 4), + MaxWidth = 300 + }); + + if (state.Bleeding == true) + AlertsContainer.AddChild(new RichTextLabel + { + Text = Loc.GetString("health-analyzer-window-entity-bleeding-text"), + Margin = new Thickness(0, 4), + MaxWidth = 300 + }); + + // Damage Groups + + var damageSortedGroups = + damageable.DamagePerGroup.OrderByDescending(damage => damage.Value) + .ToDictionary(x => x.Key, x => x.Value); + + IReadOnlyDictionary damagePerType = damageable.Damage.DamageDict; + + DrawDiagnosticGroups(damageSortedGroups, damagePerType); + } + + private static string GetStatus(MobState mobState) + { + return mobState switch + { + MobState.Alive => Loc.GetString("health-analyzer-window-entity-alive-text"), + MobState.Critical => Loc.GetString("health-analyzer-window-entity-critical-text"), + MobState.Dead => Loc.GetString("health-analyzer-window-entity-dead-text"), + _ => Loc.GetString("health-analyzer-window-entity-unknown-text"), + }; + } + + private void DrawDiagnosticGroups( + Dictionary groups, + IReadOnlyDictionary damageDict) + { + GroupsContainer.RemoveAllChildren(); + + foreach (var (damageGroupId, damageAmount) in groups) + { + if (damageAmount == 0) + continue; + + var groupTitleText = $"{Loc.GetString( + "health-analyzer-window-damage-group-text", + ("damageGroup", _prototypes.Index(damageGroupId).LocalizedName), + ("amount", damageAmount) + )}"; + + var groupContainer = new BoxContainer + { + Align = AlignMode.Begin, + Orientation = LayoutOrientation.Vertical, + }; + + groupContainer.AddChild(CreateDiagnosticGroupTitle(groupTitleText, damageGroupId)); + + GroupsContainer.AddChild(groupContainer); + + // Show the damage for each type in that group. + var group = _prototypes.Index(damageGroupId); + + foreach (var type in group.DamageTypes) + { + if (!damageDict.TryGetValue(type, out var typeAmount) || typeAmount <= 0) + continue; + + var damageString = Loc.GetString( + "health-analyzer-window-damage-type-text", + ("damageType", _prototypes.Index(type).LocalizedName), + ("amount", typeAmount) + ); + + groupContainer.AddChild(CreateDiagnosticItemLabel(damageString.Insert(0, " · "))); + } + } + } + + private Texture GetTexture(string texture) + { + var rsiPath = new ResPath("/Textures/Objects/Devices/health_analyzer.rsi"); + var rsiSprite = new SpriteSpecifier.Rsi(rsiPath, texture); + + var rsi = _cache.GetResource(rsiSprite.RsiPath).RSI; + if (!rsi.TryGetState(rsiSprite.RsiState, out var state)) + { + rsiSprite = new SpriteSpecifier.Rsi(rsiPath, "unknown"); + } + + return _spriteSystem.Frame0(rsiSprite); + } + + private static Label CreateDiagnosticItemLabel(string text) + { + return new Label + { + Text = text, + }; + } + + private BoxContainer CreateDiagnosticGroupTitle(string text, string id) + { + var rootContainer = new BoxContainer + { + Margin = new Thickness(0, 6, 0, 0), + VerticalAlignment = VAlignment.Bottom, + Orientation = LayoutOrientation.Horizontal, + }; + + rootContainer.AddChild(new TextureRect + { + SetSize = new Vector2(30, 30), + Texture = GetTexture(id.ToLower()) + }); + + rootContainer.AddChild(CreateDiagnosticItemLabel(text)); + + return rootContainer; + } +} diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml index aae8785b1f..932592ed37 100644 --- a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml +++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml @@ -1,64 +1,15 @@ - - + diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs index 533a8b9f2c..6c0ed360b0 100644 --- a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs +++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs @@ -1,241 +1,20 @@ -using System.Linq; -using System.Numerics; -using Content.Shared.Atmos; using Content.Client.UserInterface.Controls; -using Content.Shared.Damage.Components; -using Content.Shared.Damage.Prototypes; -using Content.Shared.FixedPoint; -using Content.Shared.Humanoid; -using Content.Shared.Humanoid.Prototypes; -using Content.Shared.IdentityManagement; using Content.Shared.MedicalScanner; -using Content.Shared.Mobs; -using Content.Shared.Mobs.Components; using Robust.Client.AutoGenerated; using Robust.Client.UserInterface.XAML; -using Robust.Client.GameObjects; -using Robust.Client.Graphics; -using Robust.Client.UserInterface.Controls; -using Robust.Client.ResourceManagement; -using Robust.Shared.Prototypes; -using Robust.Shared.Utility; -namespace Content.Client.HealthAnalyzer.UI +namespace Content.Client.HealthAnalyzer.UI; + +[GenerateTypedNameReferences] +public sealed partial class HealthAnalyzerWindow : FancyWindow { - [GenerateTypedNameReferences] - public sealed partial class HealthAnalyzerWindow : FancyWindow + public HealthAnalyzerWindow() { - private readonly IEntityManager _entityManager; - private readonly SpriteSystem _spriteSystem; - private readonly IPrototypeManager _prototypes; - private readonly IResourceCache _cache; - - public HealthAnalyzerWindow() - { - RobustXamlLoader.Load(this); - - var dependencies = IoCManager.Instance!; - _entityManager = dependencies.Resolve(); - _spriteSystem = _entityManager.System(); - _prototypes = dependencies.Resolve(); - _cache = dependencies.Resolve(); - } - - public void Populate(HealthAnalyzerScannedUserMessage msg) - { - var target = _entityManager.GetEntity(msg.TargetEntity); - - if (target == null - || !_entityManager.TryGetComponent(target, out var damageable)) - { - NoPatientDataText.Visible = true; - return; - } - - NoPatientDataText.Visible = false; - - // Scan Mode - - ScanModeLabel.Text = msg.ScanMode.HasValue - ? msg.ScanMode.Value - ? Loc.GetString("health-analyzer-window-scan-mode-active") - : Loc.GetString("health-analyzer-window-scan-mode-inactive") - : Loc.GetString("health-analyzer-window-entity-unknown-text"); - - ScanModeLabel.FontColorOverride = msg.ScanMode.HasValue && msg.ScanMode.Value ? Color.Green : Color.Red; - - // Patient Information - - SpriteView.SetEntity(target.Value); - SpriteView.Visible = msg.ScanMode.HasValue && msg.ScanMode.Value; - NoDataTex.Visible = !SpriteView.Visible; - - var name = new FormattedMessage(); - name.PushColor(Color.White); - name.AddText(_entityManager.HasComponent(target.Value) - ? Identity.Name(target.Value, _entityManager) - : Loc.GetString("health-analyzer-window-entity-unknown-text")); - NameLabel.SetMessage(name); - - SpeciesLabel.Text = - _entityManager.TryGetComponent(target.Value, - out var humanoidAppearanceComponent) - ? Loc.GetString(_prototypes.Index(humanoidAppearanceComponent.Species).Name) - : Loc.GetString("health-analyzer-window-entity-unknown-species-text"); - - // Basic Diagnostic - - TemperatureLabel.Text = !float.IsNaN(msg.Temperature) - ? $"{msg.Temperature - Atmospherics.T0C:F1} °C ({msg.Temperature:F1} K)" - : Loc.GetString("health-analyzer-window-entity-unknown-value-text"); - - BloodLabel.Text = !float.IsNaN(msg.BloodLevel) - ? $"{msg.BloodLevel * 100:F1} %" - : Loc.GetString("health-analyzer-window-entity-unknown-value-text"); - - StatusLabel.Text = - _entityManager.TryGetComponent(target.Value, out var mobStateComponent) - ? GetStatus(mobStateComponent.CurrentState) - : Loc.GetString("health-analyzer-window-entity-unknown-text"); - - // Total Damage - - DamageLabel.Text = damageable.TotalDamage.ToString(); - - // Alerts - - var showAlerts = msg.Unrevivable == true || msg.Bleeding == true; - - AlertsDivider.Visible = showAlerts; - AlertsContainer.Visible = showAlerts; - - if (showAlerts) - AlertsContainer.RemoveAllChildren(); - - if (msg.Unrevivable == true) - AlertsContainer.AddChild(new RichTextLabel - { - Text = Loc.GetString("health-analyzer-window-entity-unrevivable-text"), - Margin = new Thickness(0, 4), - MaxWidth = 300 - }); - - if (msg.Bleeding == true) - AlertsContainer.AddChild(new RichTextLabel - { - Text = Loc.GetString("health-analyzer-window-entity-bleeding-text"), - Margin = new Thickness(0, 4), - MaxWidth = 300 - }); - - // Damage Groups - - var damageSortedGroups = - damageable.DamagePerGroup.OrderByDescending(damage => damage.Value) - .ToDictionary(x => x.Key, x => x.Value); - - IReadOnlyDictionary damagePerType = damageable.Damage.DamageDict; - - DrawDiagnosticGroups(damageSortedGroups, damagePerType); - } - - private static string GetStatus(MobState mobState) - { - return mobState switch - { - MobState.Alive => Loc.GetString("health-analyzer-window-entity-alive-text"), - MobState.Critical => Loc.GetString("health-analyzer-window-entity-critical-text"), - MobState.Dead => Loc.GetString("health-analyzer-window-entity-dead-text"), - _ => Loc.GetString("health-analyzer-window-entity-unknown-text"), - }; - } - - private void DrawDiagnosticGroups( - Dictionary groups, - IReadOnlyDictionary damageDict) - { - GroupsContainer.RemoveAllChildren(); - - foreach (var (damageGroupId, damageAmount) in groups) - { - if (damageAmount == 0) - continue; - - var groupTitleText = $"{Loc.GetString( - "health-analyzer-window-damage-group-text", - ("damageGroup", _prototypes.Index(damageGroupId).LocalizedName), - ("amount", damageAmount) - )}"; - - var groupContainer = new BoxContainer - { - Align = BoxContainer.AlignMode.Begin, - Orientation = BoxContainer.LayoutOrientation.Vertical, - }; - - groupContainer.AddChild(CreateDiagnosticGroupTitle(groupTitleText, damageGroupId)); - - GroupsContainer.AddChild(groupContainer); - - // Show the damage for each type in that group. - var group = _prototypes.Index(damageGroupId); - - foreach (var type in group.DamageTypes) - { - if (!damageDict.TryGetValue(type, out var typeAmount) || typeAmount <= 0) - continue; - - var damageString = Loc.GetString( - "health-analyzer-window-damage-type-text", - ("damageType", _prototypes.Index(type).LocalizedName), - ("amount", typeAmount) - ); - - groupContainer.AddChild(CreateDiagnosticItemLabel(damageString.Insert(0, " · "))); - } - } - } - - private Texture GetTexture(string texture) - { - var rsiPath = new ResPath("/Textures/Objects/Devices/health_analyzer.rsi"); - var rsiSprite = new SpriteSpecifier.Rsi(rsiPath, texture); - - var rsi = _cache.GetResource(rsiSprite.RsiPath).RSI; - if (!rsi.TryGetState(rsiSprite.RsiState, out var state)) - { - rsiSprite = new SpriteSpecifier.Rsi(rsiPath, "unknown"); - } - - return _spriteSystem.Frame0(rsiSprite); - } - - private static Label CreateDiagnosticItemLabel(string text) - { - return new Label - { - Text = text, - }; - } - - private BoxContainer CreateDiagnosticGroupTitle(string text, string id) - { - var rootContainer = new BoxContainer - { - Margin = new Thickness(0, 6, 0, 0), - VerticalAlignment = VAlignment.Bottom, - Orientation = BoxContainer.LayoutOrientation.Horizontal, - }; - - rootContainer.AddChild(new TextureRect - { - SetSize = new Vector2(30, 30), - Texture = GetTexture(id.ToLower()) - }); - - rootContainer.AddChild(CreateDiagnosticItemLabel(text)); + RobustXamlLoader.Load(this); + } - return rootContainer; - } + public void Populate(HealthAnalyzerScannedUserMessage msg) + { + HealthAnalyzer.Populate(msg.State); } } diff --git a/Content.Client/Medical/Cryogenics/BeakerBarChart.cs b/Content.Client/Medical/Cryogenics/BeakerBarChart.cs new file mode 100644 index 0000000000..25301b5268 --- /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/Medical/Cryogenics/CryoPodBoundUserInterface.cs b/Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs new file mode 100644 index 0000000000..5e64cea720 --- /dev/null +++ b/Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs @@ -0,0 +1,53 @@ +using Content.Shared.FixedPoint; +using Content.Shared.Medical.Cryogenics; +using JetBrains.Annotations; +using Robust.Client.UserInterface; +namespace Content.Client.Medical.Cryogenics; + +[UsedImplicitly] +public sealed class CryoPodBoundUserInterface : BoundUserInterface +{ + private CryoPodWindow? _window; + + public CryoPodBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + _window = this.CreateWindowCenteredLeft(); + _window.Title = EntMan.GetComponent(Owner).EntityName; + _window.OnEjectPatientPressed += EjectPatientPressed; + _window.OnEjectBeakerPressed += EjectBeakerPressed; + _window.OnInjectPressed += InjectPressed; + } + + private void EjectPatientPressed() + { + var isLocked = + EntMan.TryGetComponent(Owner, out var cryoComp) + && cryoComp.Locked; + + _window?.SetEjectErrorVisible(isLocked); + SendMessage(new CryoPodSimpleUiMessage(CryoPodSimpleUiMessage.MessageType.EjectPatient)); + } + + private void EjectBeakerPressed() + { + SendMessage(new CryoPodSimpleUiMessage(CryoPodSimpleUiMessage.MessageType.EjectBeaker)); + } + + private void InjectPressed(FixedPoint2 transferAmount) + { + SendMessage(new CryoPodInjectUiMessage(transferAmount)); + } + + protected override void ReceiveMessage(BoundUserInterfaceMessage message) + { + if (_window != null && message is CryoPodUserMessage cryoMsg) + { + _window.Populate(cryoMsg); + } + } +} diff --git a/Content.Client/Medical/Cryogenics/CryoPodSystem.cs b/Content.Client/Medical/Cryogenics/CryoPodSystem.cs index c1cbfc573e..63c95a63d8 100644 --- a/Content.Client/Medical/Cryogenics/CryoPodSystem.cs +++ b/Content.Client/Medical/Cryogenics/CryoPodSystem.cs @@ -6,7 +6,6 @@ namespace Content.Client.Medical.Cryogenics; public sealed class CryoPodSystem : SharedCryoPodSystem { - [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly SpriteSystem _sprite = default!; public override void Initialize() @@ -46,8 +45,8 @@ public sealed class CryoPodSystem : SharedCryoPodSystem return; } - if (!_appearance.TryGetData(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component) - || !_appearance.TryGetData(uid, CryoPodVisuals.IsOn, out var isOn, args.Component)) + if (!Appearance.TryGetData(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component) + || !Appearance.TryGetData(uid, CryoPodVisuals.IsOn, out var isOn, args.Component)) { return; } @@ -64,6 +63,11 @@ public sealed class CryoPodSystem : SharedCryoPodSystem _sprite.LayerSetVisible((uid, args.Sprite), CryoPodVisualLayers.Cover, true); } } + + protected override void UpdateUi(Entity cryoPod) + { + // Atmos and health scanner aren't predicted currently... + } } public enum CryoPodVisualLayers : byte diff --git a/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml new file mode 100644 index 0000000000..9bea37d582 --- /dev/null +++ b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml @@ -0,0 +1,232 @@ + + +