]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Cryo pod UI (#41850)
authorFruitsalad <949631+Fruitsalad@users.noreply.github.com>
Thu, 15 Jan 2026 17:52:03 +0000 (18:52 +0100)
committerGitHub <noreply@github.com>
Thu, 15 Jan 2026 17:52:03 +0000 (17:52 +0000)
* 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>
17 files changed:
Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml [new file with mode: 0644]
Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs [new file with mode: 0644]
Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml
Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs
Content.Client/Medical/Cryogenics/BeakerBarChart.cs [new file with mode: 0644]
Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs [new file with mode: 0644]
Content.Client/Medical/Cryogenics/CryoPodSystem.cs
Content.Client/Medical/Cryogenics/CryoPodWindow.xaml [new file with mode: 0644]
Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs [new file with mode: 0644]
Content.Server/Atmos/EntitySystems/GasAnalyzerSystem.cs
Content.Server/Medical/CryoPodSystem.cs
Content.Server/Medical/HealthAnalyzerSystem.cs
Content.Shared/Medical/Cryogenics/CryoPodComponent.cs
Content.Shared/Medical/Cryogenics/SharedCryoPodSystem.cs
Content.Shared/MedicalScanner/HealthAnalyzerScannedUserMessage.cs
Resources/Locale/en-US/medical/components/cryo-pod-component.ftl
Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml

diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml
new file mode 100644 (file)
index 0000000..06c6528
--- /dev/null
@@ -0,0 +1,53 @@
+<BoxContainer
+    xmlns="https://spacestation14.io"
+    VerticalExpand="True"
+    Orientation="Vertical">
+    <Label
+        Name="NoPatientDataText"
+        Text="{Loc health-analyzer-window-no-patient-data-text}" />
+
+    <BoxContainer
+        Name="PatientDataContainer"
+        Margin="0 0 0 5"
+        Orientation="Vertical">
+        <BoxContainer Orientation="Horizontal" Margin="0 0 0 5">
+            <SpriteView OverrideDirection="South" Scale="2 2" Name="SpriteView" Access="Public" SetSize="64 64" />
+            <TextureRect Name="NoDataTex" Access="Public" SetSize="64 64" Visible="false" Stretch="KeepAspectCentered" TexturePath="/Textures/Interface/Misc/health_analyzer_out_of_range.png"/>
+            <BoxContainer Margin="5 0 0 0" Orientation="Vertical" VerticalAlignment="Top">
+                <RichTextLabel Name="NameLabel" SetWidth="150" />
+                <Label Name="SpeciesLabel" VerticalAlignment="Top" StyleClasses="LabelSubText" />
+            </BoxContainer>
+            <Label Margin="0 0 5 0" HorizontalExpand="True" HorizontalAlignment="Right" VerticalExpand="True"
+                   VerticalAlignment="Top" Name="ScanModeLabel"
+                   Text="{Loc 'health-analyzer-window-entity-unknown-text'}" />
+        </BoxContainer>
+
+        <PanelContainer StyleClasses="LowDivider" />
+
+        <GridContainer Margin="0 5 0 0" Columns="2">
+            <Label Text="{Loc 'health-analyzer-window-entity-status-text'}" />
+            <Label Name="StatusLabel" />
+            <Label Text="{Loc 'health-analyzer-window-entity-temperature-text'}" />
+            <Label Name="TemperatureLabel" />
+            <Label Text="{Loc 'health-analyzer-window-entity-blood-level-text'}" />
+            <Label Name="BloodLabel" />
+            <Label Text="{Loc 'health-analyzer-window-entity-damage-total-text'}" />
+            <Label Name="DamageLabel" />
+        </GridContainer>
+    </BoxContainer>
+
+    <PanelContainer Name="AlertsDivider" Visible="False" StyleClasses="LowDivider" />
+
+    <BoxContainer Name="AlertsContainer" Visible="False" Margin="0 5" Orientation="Vertical" HorizontalAlignment="Center">
+
+    </BoxContainer>
+
+    <PanelContainer StyleClasses="LowDivider" />
+
+    <BoxContainer
+        Name="GroupsContainer"
+        Margin="0 5 0 5"
+        Orientation="Vertical">
+    </BoxContainer>
+
+</BoxContainer>
diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs
new file mode 100644 (file)
index 0000000..949b477
--- /dev/null
@@ -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<IEntityManager>();
+        _spriteSystem = _entityManager.System<SpriteSystem>();
+        _prototypes = dependencies.Resolve<IPrototypeManager>();
+        _cache = dependencies.Resolve<IResourceCache>();
+    }
+
+    public void Populate(HealthAnalyzerUiState state)
+    {
+        var target = _entityManager.GetEntity(state.TargetEntity);
+
+        if (target == null
+            || !_entityManager.TryGetComponent<DamageableComponent>(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<MetaDataComponent>(target.Value)
+            ? Identity.Name(target.Value, _entityManager)
+            : Loc.GetString("health-analyzer-window-entity-unknown-text"));
+        NameLabel.SetMessage(name);
+
+        SpeciesLabel.Text =
+            _entityManager.TryGetComponent<HumanoidAppearanceComponent>(target.Value,
+                out var humanoidAppearanceComponent)
+                ? Loc.GetString(_prototypes.Index<SpeciesPrototype>(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<MobStateComponent>(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<string, FixedPoint2> 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<string, FixedPoint2> groups,
+        IReadOnlyDictionary<string, FixedPoint2> 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<DamageGroupPrototype>(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<DamageGroupPrototype>(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<DamageTypePrototype>(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<RSIResource>(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;
+    }
+}
index aae8785b1fe1080360549b68ae4a4615c0cdcf01..932592ed373db2d8609175fb230aba66a41bf26c 100644 (file)
@@ -1,64 +1,15 @@
 <controls:FancyWindow
     xmlns="https://spacestation14.io"
     xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+    xmlns:ui="clr-namespace:Content.Client.HealthAnalyzer.UI"
     MaxHeight="525"
     MinWidth="300">
     <ScrollContainer
         Margin="5 5 5 5"
         ReturnMeasure="True"
         VerticalExpand="True">
-        <BoxContainer
-            Name="RootContainer"
-            VerticalExpand="True"
-            Orientation="Vertical">
-            <Label
-                Name="NoPatientDataText"
-                Text="{Loc health-analyzer-window-no-patient-data-text}" />
 
-            <BoxContainer
-                Name="PatientDataContainer"
-                Margin="0 0 0 5"
-                Orientation="Vertical">
-                <BoxContainer Orientation="Horizontal" Margin="0 0 0 5">
-                    <SpriteView OverrideDirection="South" Scale="2 2" Name="SpriteView" Access="Public" SetSize="64 64" />
-                    <TextureRect Name="NoDataTex" Access="Public" SetSize="64 64" Visible="false" Stretch="KeepAspectCentered" TexturePath="/Textures/Interface/Misc/health_analyzer_out_of_range.png"/>
-                    <BoxContainer Margin="5 0 0 0" Orientation="Vertical" VerticalAlignment="Top">
-                        <RichTextLabel Name="NameLabel" SetWidth="150" />
-                        <Label Name="SpeciesLabel" VerticalAlignment="Top" StyleClasses="LabelSubText" />
-                    </BoxContainer>
-                    <Label Margin="0 0 5 0" HorizontalExpand="True" HorizontalAlignment="Right" VerticalExpand="True"
-                           VerticalAlignment="Top" Name="ScanModeLabel"
-                           Text="{Loc 'health-analyzer-window-entity-unknown-text'}" />
-                </BoxContainer>
-
-                <PanelContainer StyleClasses="LowDivider" />
-
-                <GridContainer Margin="0 5 0 0" Columns="2">
-                    <Label Text="{Loc 'health-analyzer-window-entity-status-text'}" />
-                    <Label Name="StatusLabel" />
-                    <Label Text="{Loc 'health-analyzer-window-entity-temperature-text'}" />
-                    <Label Name="TemperatureLabel" />
-                    <Label Text="{Loc 'health-analyzer-window-entity-blood-level-text'}" />
-                    <Label Name="BloodLabel" />
-                    <Label Text="{Loc 'health-analyzer-window-entity-damage-total-text'}" />
-                    <Label Name="DamageLabel" />
-                </GridContainer>
-            </BoxContainer>
-
-            <PanelContainer Name="AlertsDivider" Visible="False" StyleClasses="LowDivider" />
-
-            <BoxContainer Name="AlertsContainer" Visible="False" Margin="0 5" Orientation="Vertical" HorizontalAlignment="Center">
-
-            </BoxContainer>
-
-            <PanelContainer StyleClasses="LowDivider" />
-
-            <BoxContainer
-                Name="GroupsContainer"
-                Margin="0 5 0 5"
-                Orientation="Vertical">
-            </BoxContainer>
-
-        </BoxContainer>
+        <ui:HealthAnalyzerControl
+            Name="HealthAnalyzer"/>
     </ScrollContainer>
 </controls:FancyWindow>
index 533a8b9f2cc072c26f23f44651b818624738c36c..6c0ed360b037ca28e177cf99d098c44c3368c7b5 100644 (file)
-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<IEntityManager>();
-            _spriteSystem = _entityManager.System<SpriteSystem>();
-            _prototypes = dependencies.Resolve<IPrototypeManager>();
-            _cache = dependencies.Resolve<IResourceCache>();
-        }
-
-        public void Populate(HealthAnalyzerScannedUserMessage msg)
-        {
-            var target = _entityManager.GetEntity(msg.TargetEntity);
-
-            if (target == null
-                || !_entityManager.TryGetComponent<DamageableComponent>(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<MetaDataComponent>(target.Value)
-                ? Identity.Name(target.Value, _entityManager)
-                : Loc.GetString("health-analyzer-window-entity-unknown-text"));
-            NameLabel.SetMessage(name);
-
-            SpeciesLabel.Text =
-                _entityManager.TryGetComponent<HumanoidAppearanceComponent>(target.Value,
-                    out var humanoidAppearanceComponent)
-                    ? Loc.GetString(_prototypes.Index<SpeciesPrototype>(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<MobStateComponent>(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<string, FixedPoint2> 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<string, FixedPoint2> groups,
-            IReadOnlyDictionary<string, FixedPoint2> 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<DamageGroupPrototype>(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<DamageGroupPrototype>(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<DamageTypePrototype>(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<RSIResource>(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 (file)
index 0000000..25301b5
--- /dev/null
@@ -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<Entry> _entries = new();
+
+
+    public BeakerBarChart()
+    {
+        MouseFilter = MouseFilterMode.Pass;
+        TooltipSupplier = SupplyTooltip;
+    }
+
+    public void Clear()
+    {
+        foreach (var entry in _entries)
+        {
+            entry.TargetAmount = 0;
+        }
+
+        _nextUpdateableEntry = 0;
+    }
+
+    /// <summary>
+    /// Either adds a new entry to the chart if the UID doesn't appear yet, or updates the amount of an existing entry.
+    /// </summary>
+    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 (file)
index 0000000..5e64cea
--- /dev/null
@@ -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<CryoPodWindow>();
+        _window.Title = EntMan.GetComponent<MetaDataComponent>(Owner).EntityName;
+        _window.OnEjectPatientPressed += EjectPatientPressed;
+        _window.OnEjectBeakerPressed += EjectBeakerPressed;
+        _window.OnInjectPressed += InjectPressed;
+    }
+
+    private void EjectPatientPressed()
+    {
+        var isLocked =
+            EntMan.TryGetComponent<CryoPodComponent>(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);
+        }
+    }
+}
index c1cbfc573eeab16c8d87f68840ccfdfeef58bf37..63c95a63d8cf5e9738ac6fe691d1f8e5516fe9a8 100644 (file)
@@ -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<bool>(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component)
-            || !_appearance.TryGetData<bool>(uid, CryoPodVisuals.IsOn, out var isOn, args.Component))
+        if (!Appearance.TryGetData<bool>(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component)
+            || !Appearance.TryGetData<bool>(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<CryoPodComponent> 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 (file)
index 0000000..9bea37d
--- /dev/null
@@ -0,0 +1,232 @@
+<controls:FancyWindow xmlns="https://spacestation14.io"
+               xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+               xmlns:health="clr-namespace:Content.Client.HealthAnalyzer.UI"
+               xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+               xmlns:cryogenics="clr-namespace:Content.Client.Medical.Cryogenics"
+               MinSize="250 300"
+               Resizable="False">
+
+    <Label Name="LoadingPlaceHolder"
+           Text="{Loc 'cryo-pod-window-loading'}"
+           Align="Center"
+           HorizontalExpand="True"
+           VerticalExpand="True"/>
+
+    <BoxContainer Name="Sections"
+                  Orientation="Horizontal"
+                  Visible="False"
+                  Margin="10"
+                  SeparationOverride="16">
+        <BoxContainer Name="CryoSection"
+                      VerticalExpand="True"
+                      Orientation="Vertical"
+                      MinWidth="250"
+                      MaxWidth="250">
+
+            <!-- Flavor text -->
+            <BoxContainer Orientation="Horizontal"
+                          SeparationOverride="10"
+                          Margin="8 0 0 8">
+                <TextureRect StyleClasses="NTLogoDark"
+                             VerticalExpand="True"
+                             Stretch="KeepAspectCentered"
+                             SetSize="32 32"/>
+                <BoxContainer Orientation="Vertical"
+                              SeparationOverride="-4">
+                    <Label Text="{Loc 'cryo-pod-window-product-name'}"
+                           StyleClasses="FontLarge"/>
+                    <Label Text="{Loc 'cryo-pod-window-product-subtitle'}"
+                           StyleClasses="LabelSubText"/>
+
+                </BoxContainer>
+            </BoxContainer>
+
+            <!-- Atmos info -->
+            <BoxContainer Orientation="Horizontal"
+                          SeparationOverride="20"
+                          Margin="0 0 0 4">
+                <!-- Pressure -->
+                <BoxContainer Orientation="Vertical">
+                    <Label Text="{Loc 'gas-analyzer-window-pressure-text'}"
+                           StyleClasses="LabelSubText"/>
+                    <Label Name="Pressure"/>
+                </BoxContainer>
+
+                <!-- Temperature -->
+                <BoxContainer Orientation="Vertical">
+                    <Label Text="{Loc 'gas-analyzer-window-temperature-text'}"
+                           StyleClasses="LabelSubText"/>
+                    <Label Name="Temperature"/>
+                </BoxContainer>
+            </BoxContainer>
+
+            <!-- Gas mix -->
+            <Control Margin="0 0 0 22">
+                <controls:SplitBar Name="GasMixChart"
+                                   MinHeight="8"
+                                   MaxHeight="8"/>
+            </Control>
+
+            <!-- Warnings & status -->
+            <BoxContainer Orientation="Vertical"
+                          HorizontalExpand="True"
+                          VerticalExpand="True"
+                          Align="Center"
+                          Margin="0 0 0 14"
+                          SeparationOverride="20">
+
+                <!-- Ejection error (if the pod is locked) -->
+                <PanelContainer Name="EjectError"
+                                Visible="False"
+                                HorizontalExpand="True">
+                    <PanelContainer.PanelOverride>
+                        <gfx:StyleBoxFlat BorderThickness="1" BorderColor="orange"/>
+                    </PanelContainer.PanelOverride>
+
+                    <BoxContainer Orientation="Vertical"
+                                  Margin="6">
+                        <Label Text="{Loc 'cryo-pod-window-error-header'}"
+                               FontColorOverride="orange"
+                               Align="Center"/>
+                        <RichTextLabel Text="{Loc 'cryo-pod-window-eject-error'}"/>
+                    </BoxContainer>
+                </PanelContainer>
+
+                <!-- Pressure warning -->
+                <PanelContainer Name="LowPressureWarning"
+                                Visible="False"
+                                HorizontalExpand="True">
+                    <PanelContainer.PanelOverride>
+                        <gfx:StyleBoxFlat BorderThickness="1" BorderColor="orange"/>
+                    </PanelContainer.PanelOverride>
+
+                    <BoxContainer Orientation="Vertical"
+                                  Margin="6">
+                        <Label Text="{Loc 'cryo-pod-window-warning-header'}"
+                               FontColorOverride="orange"
+                               Align="Center"/>
+                        <RichTextLabel Text="{Loc 'cryo-pod-window-low-pressure-warning'}"/>
+                    </BoxContainer>
+                </PanelContainer>
+
+                <!-- Temperature warning -->
+                <PanelContainer Name="HighTemperatureWarning"
+                                Visible="False"
+                                HorizontalExpand="True">
+                    <PanelContainer.PanelOverride>
+                        <gfx:StyleBoxFlat BorderThickness="1" BorderColor="orange"/>
+                    </PanelContainer.PanelOverride>
+
+                    <BoxContainer Orientation="Vertical"
+                                  Margin="6">
+                        <Label Text="{Loc 'cryo-pod-window-warning-header'}"
+                               FontColorOverride="orange"
+                               Align="Center"/>
+                        <!-- Note: This placeholder text should never be visible. -->
+                        <RichTextLabel Name="HighTemperatureWarningText"
+                                       Text="Temperature too high."/>
+                    </BoxContainer>
+                </PanelContainer>
+
+                <!-- Status checklist -->
+                <BoxContainer Orientation="Vertical">
+
+                    <BoxContainer Orientation="Horizontal"
+                                  SeparationOverride="8">
+                        <Label Text="{Loc 'cryo-pod-window-status'}"/>
+                        <Label Name="StatusLabel"
+                               Text="{Loc 'cryo-pod-window-status-not-ready'}"
+                               FontColorOverride="Orange"/>
+                    </BoxContainer>
+
+                    <GridContainer Columns="2"
+                                   HSeparationOverride="0"
+                                   VSeparationOverride="6"
+                                   Margin="6 3 0 0">
+                        <Label Text="⋄"
+                               StyleClasses="LabelSubText"/>
+                        <Label Name="PressureCheck"
+                               Text="{Loc 'cryo-pod-window-checklist-pressure'}"
+                               StyleClasses="LabelSubText"/>
+                        <Label Text="⋄"
+                               StyleClasses="LabelSubText"/>
+                        <Label Name="ChemicalsCheck"
+                               Text="{Loc 'cryo-pod-window-checklist-chemicals'}"
+                               StyleClasses="LabelSubText"
+                               FontColorOverride="Orange"/>
+                        <Label Text="⋄"
+                               StyleClasses="LabelSubText"/>
+                        <Label Name="TemperatureCheck"
+                               Text="{Loc 'cryo-pod-window-checklist-temperature'}"
+                               StyleClasses="LabelSubText"/>
+                    </GridContainer>
+
+                </BoxContainer>
+
+            </BoxContainer>
+
+            <!-- Reagents -->
+            <Control HorizontalExpand="True"
+                     MinHeight="30">
+                <Label Name="NoBeakerText"
+                       Text="{Loc 'cryo-pod-window-chems-no-beaker'}"
+                       FontColorOverride="Gray"
+                       VerticalExpand="True"
+                       VAlign="Center"/>
+                <cryogenics:BeakerBarChart Name="ChemicalsChart"
+                                  HorizontalExpand="True"
+                                  VerticalExpand="True"/>
+            </Control>
+
+            <!-- Buttons -->
+            <BoxContainer Orientation="Vertical"
+                          Margin="-2 2 -2 0">
+                <BoxContainer Orientation="Horizontal">
+                    <Button Name="Inject1"
+                            Text="{Loc 'cryo-pod-window-inject-1u'}"
+                            Disabled="True"
+                            HorizontalExpand="True"
+                            StyleClasses="OpenBoth"/>
+                    <Button Name="Inject5"
+                            Text="{Loc 'cryo-pod-window-inject-5u'}"
+                            Disabled="True"
+                            HorizontalExpand="True"
+                            StyleClasses="OpenBoth"/>
+                    <Button Name="Inject10"
+                            Text="{Loc 'cryo-pod-window-inject-10u'}"
+                            Disabled="True"
+                            HorizontalExpand="True"
+                            StyleClasses="OpenBoth"/>
+                    <Button Name="Inject20"
+                            Text="{Loc 'cryo-pod-window-inject-20u'}"
+                            Disabled="True"
+                            HorizontalExpand="True"
+                            StyleClasses="OpenBoth"/>
+                    <Button Name="EjectBeakerButton"
+                            Text="{Loc 'cryo-pod-window-eject-beaker'}"
+                            Disabled="True"
+                            StyleClasses="OpenBoth"/>
+                </BoxContainer>
+                <Button Name="EjectPatientButton"
+                        Text="{Loc 'cryo-pod-window-eject-patient'}"
+                        Disabled="True"
+                        HorizontalExpand="True"
+                        StyleClasses="OpenRight"/>
+            </BoxContainer>
+
+        </BoxContainer>
+        <BoxContainer Name="HealthSection"
+                      VerticalExpand="True"
+                      Orientation="Vertical">
+
+            <health:HealthAnalyzerControl Name="HealthAnalyzer"/>
+
+            <!-- This label is used to deal with a stray hline at the end of the health analyzer UI -->
+            <Label Name="NoDamageText"
+                   Text="{Loc 'cryo-pod-window-health-no-damage'}"
+                   FontColorOverride="DeepSkyBlue"/>
+            <Control VerticalExpand="True"/>
+
+        </BoxContainer>
+    </BoxContainer>
+</controls:FancyWindow>
diff --git a/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs
new file mode 100644 (file)
index 0000000..ad5ab9d
--- /dev/null
@@ -0,0 +1,260 @@
+using System.Linq;
+using System.Numerics;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Atmos;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Damage.Components;
+using Content.Shared.EntityConditions.Conditions;
+using Content.Shared.FixedPoint;
+using Content.Shared.Medical.Cryogenics;
+using Content.Shared.Temperature;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+namespace Content.Client.Medical.Cryogenics;
+
+[GenerateTypedNameReferences]
+public sealed partial class CryoPodWindow : FancyWindow
+{
+    [Dependency] private readonly IEntityManager _entityManager = default!;
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+    public event Action? OnEjectPatientPressed;
+    public event Action? OnEjectBeakerPressed;
+    public event Action<FixedPoint2>? OnInjectPressed;
+
+    public CryoPodWindow()
+    {
+        IoCManager.InjectDependencies(this);
+        RobustXamlLoader.Load(this);
+        EjectPatientButton.OnPressed += _ => OnEjectPatientPressed?.Invoke();
+        EjectBeakerButton.OnPressed += _ => OnEjectBeakerPressed?.Invoke();
+        Inject1.OnPressed += _ => OnInjectPressed?.Invoke(1);
+        Inject5.OnPressed += _ => OnInjectPressed?.Invoke(5);
+        Inject10.OnPressed += _ => OnInjectPressed?.Invoke(10);
+        Inject20.OnPressed += _ => OnInjectPressed?.Invoke(20);
+    }
+
+    public void Populate(CryoPodUserMessage msg)
+    {
+        // Loading screen
+        if (LoadingPlaceHolder.Visible)
+        {
+            LoadingPlaceHolder.Visible = false;
+            Sections.Visible = true;
+        }
+
+        // Atmosphere
+        var hasCorrectPressure = (msg.GasMix.Pressure > Atmospherics.WarningLowPressure);
+        var hasGas = (msg.GasMix.Pressure > Atmospherics.GasMinMoles);
+        var showsPressureWarning = !hasCorrectPressure;
+        LowPressureWarning.Visible = showsPressureWarning;
+        Pressure.Text = Loc.GetString("gas-analyzer-window-pressure-val-text",
+                                      ("pressure", $"{msg.GasMix.Pressure:0.00}"));
+        Temperature.Text = Loc.GetString("generic-not-available-shorthand");
+
+        if (hasGas)
+        {
+            var celsius = TemperatureHelpers.KelvinToCelsius(msg.GasMix.Temperature);
+            Temperature.Text = Loc.GetString("gas-analyzer-window-temperature-val-text",
+                                             ("tempK", $"{msg.GasMix.Temperature:0.0}"),
+                                             ("tempC", $"{celsius:0.0}"));
+        }
+
+        // Gas mix segmented bar chart
+        GasMixChart.Clear();
+        GasMixChart.Visible = hasGas;
+
+        if (msg.GasMix.Gases != null)
+        {
+            var totalGasAmount = msg.GasMix.Gases.Sum(gas => gas.Amount);
+
+            foreach (var gas in msg.GasMix.Gases)
+            {
+                var color = Color.FromHex($"#{gas.Color}", Color.White);
+                var percent = gas.Amount / totalGasAmount * 100;
+                var localizedName = Loc.GetString(gas.Name);
+                var tooltip = Loc.GetString("gas-analyzer-window-molarity-percentage-text",
+                                            ("gasName", localizedName),
+                                            ("amount", $"{gas.Amount:0.##}"),
+                                            ("percentage", $"{percent:0.#}"));
+                GasMixChart.AddEntry(gas.Amount, color, tooltip: tooltip);
+            }
+        }
+
+        // Health analyzer
+        var maybePatient = _entityManager.GetEntity(msg.Health.TargetEntity);
+        var hasPatient = msg.Health.TargetEntity.HasValue;
+        var hasDamage = (hasPatient
+             && _entityManager.TryGetComponent(maybePatient, out DamageableComponent? damageable)
+             && damageable.TotalDamage > 0);
+
+        NoDamageText.Visible = (hasPatient && !hasDamage);
+        HealthSection.Visible = hasPatient;
+        EjectPatientButton.Disabled = !hasPatient;
+
+        if (hasPatient)
+            HealthAnalyzer.Populate(msg.Health);
+
+        // Reagents
+        float? lowestTempRequirement = null;
+        ReagentId? lowestTempReagent = null;
+        var totalBeakerCapacity = msg.BeakerCapacity ?? 0;
+        var availableQuantity = new FixedPoint2();
+        var injectingQuantity =
+            msg.Injecting?.Aggregate(new FixedPoint2(), (sum, r) => sum + r.Quantity)
+            ?? new FixedPoint2(); // Either the sum of the reagent quantities in `msg.Injecting` or zero.
+        var hasBeaker = (msg.Beaker != null);
+
+        ChemicalsChart.Clear();
+        ChemicalsChart.Capacity = (totalBeakerCapacity < 1 ? 50 : (int)totalBeakerCapacity);
+
+        var chartMaxChemsQuantity = ChemicalsChart.Capacity - injectingQuantity; // Ensure space for injection buffer
+
+        if (hasBeaker)
+        {
+            foreach (var (reagent, quantity) in msg.Beaker!)
+            {
+                availableQuantity += quantity;
+
+                // Make sure we don't add too many chemicals to the chart, so that there's still enough space to
+                // visualize the injection buffer.
+                var chemsQuantityOvershoot = FixedPoint2.Max(0, availableQuantity - chartMaxChemsQuantity);
+                var chartQuantity = FixedPoint2.Max(0, quantity - chemsQuantityOvershoot);
+
+                var reagentProto = _prototypeManager.Index<ReagentPrototype>(reagent.Prototype);
+                ChemicalsChart.SetEntry(
+                    reagent.Prototype,
+                    reagentProto.LocalizedName,
+                    (float)chartQuantity,
+                    reagentProto.SubstanceColor,
+                    tooltip: $"{quantity}u {reagentProto.LocalizedName}"
+                );
+
+                var temp = TryFindMaxTemperatureRequirement(reagent);
+                if (lowestTempRequirement == null
+                    || temp < lowestTempRequirement)
+                {
+                    lowestTempRequirement = temp;
+                    lowestTempReagent = reagent;
+                }
+            }
+        }
+
+        if (injectingQuantity != 0)
+        {
+            var injectingText = (injectingQuantity > 1 ? $"{injectingQuantity}u" : "");
+            ChemicalsChart.SetEntry(
+                "injecting",
+                injectingText,
+                (float)injectingQuantity,
+                Color.MediumSpringGreen,
+                tooltip: Loc.GetString("cryo-pod-window-chems-injecting-tooltip",
+                                       ("quantity", injectingQuantity))
+            );
+        }
+
+        var isBeakerEmpty = (injectingQuantity + availableQuantity == 0);
+        var isChemicalsChartVisible = (hasBeaker || injectingQuantity != 0);
+        NoBeakerText.Visible = !isChemicalsChartVisible;
+        ChemicalsChart.Visible = isChemicalsChartVisible;
+        Inject1.Disabled = (!hasPatient || availableQuantity < 0.1f);
+        Inject5.Disabled = (!hasPatient || availableQuantity <= 1);
+        Inject10.Disabled = (!hasPatient || availableQuantity <= 5);
+        Inject20.Disabled = (!hasPatient || availableQuantity <= 10);
+        EjectBeakerButton.Disabled = !hasBeaker;
+
+        // Temperature warning
+        var hasCorrectTemperature = (lowestTempRequirement == null || lowestTempRequirement > msg.GasMix.Temperature);
+        var showsTemperatureWarning = (!showsPressureWarning && !hasCorrectTemperature);
+
+        HighTemperatureWarning.Visible = showsTemperatureWarning;
+
+        if (showsTemperatureWarning)
+        {
+            var reagentName = _prototypeManager.Index<ReagentPrototype>(lowestTempReagent!.Value.Prototype)
+                                               .LocalizedName;
+            HighTemperatureWarningText.Text = Loc.GetString("cryo-pod-window-high-temperature-warning",
+                ("reagent", reagentName),
+                ("temperature", lowestTempRequirement!));
+        }
+
+        // Status checklist
+        const float fallbackTemperatureRequirement = 213;
+        var hasTemperatureCheck = (hasGas && hasCorrectTemperature
+                && (lowestTempRequirement != null || msg.GasMix.Temperature < fallbackTemperatureRequirement));
+        var hasChemicals = (hasBeaker && !isBeakerEmpty);
+
+        UpdateChecklistItem(PressureCheck, Loc.GetString("cryo-pod-window-checklist-pressure"), hasCorrectPressure);
+        UpdateChecklistItem(ChemicalsCheck, Loc.GetString("cryo-pod-window-checklist-chemicals"), hasChemicals);
+        UpdateChecklistItem(TemperatureCheck, Loc.GetString("cryo-pod-window-checklist-temperature"), hasTemperatureCheck);
+
+        var isReady = (hasCorrectPressure && hasChemicals && hasTemperatureCheck);
+        var isCooling = (lowestTempRequirement != null && hasPatient
+                          && msg.Health.Temperature > lowestTempRequirement);
+        var isInjecting = (injectingQuantity > 0);
+        StatusLabel.Text = (!isReady    ? Loc.GetString("cryo-pod-window-status-not-ready") :
+                            isCooling   ? Loc.GetString("cryo-pod-window-status-cooling") :
+                            isInjecting ? Loc.GetString("cryo-pod-window-status-injecting") :
+                            hasPatient  ? Loc.GetString("cryo-pod-window-status-ready-to-inject") :
+                                          Loc.GetString("cryo-pod-window-status-ready-for-patient"));
+        StatusLabel.FontColorOverride = (isReady ? Color.DeepSkyBlue : Color.Orange);
+    }
+
+    private void UpdateChecklistItem(Label label, string text, bool isOkay)
+    {
+        label.Text = (isOkay ? text : Loc.GetString("cryo-pod-window-checklist-fail", ("item", text)));
+        label.FontColorOverride = (isOkay ? null : Color.Orange);
+    }
+
+    private float? TryFindMaxTemperatureRequirement(ReagentId reagent)
+    {
+        var reagentProto = _prototypeManager.Index<ReagentPrototype>(reagent.Prototype);
+        if (reagentProto.Metabolisms == null)
+            return null;
+
+        float? result = null;
+
+        foreach (var (_, metabolism) in reagentProto.Metabolisms)
+        {
+            foreach (var effect in metabolism.Effects)
+            {
+                if (effect.Conditions == null)
+                    continue;
+
+                foreach (var condition in effect.Conditions)
+                {
+                    // If there are multiple temperature conditions in the same reagent (which could hypothetically
+                    // happen, although it currently doesn't), we return the lowest max temperature.
+                    if (condition is TemperatureCondition tempCondition
+                        && float.IsFinite(tempCondition.Max)
+                        && (result == null || tempCondition.Max < result))
+                    {
+                        result = tempCondition.Max;
+                    }
+                }
+            }
+        }
+
+        return result;
+    }
+
+    public void SetEjectErrorVisible(bool isVisible)
+    {
+        EjectError.Visible = isVisible;
+    }
+
+    protected override Vector2 MeasureOverride(Vector2 availableSize)
+    {
+        const float antiJiggleSlackSpace = 80;
+        var oldSize = DesiredSize;
+        var newSize = base.MeasureOverride(availableSize);
+
+        // Reduce how often the height of the window jiggles
+        if (newSize.Y < oldSize.Y && newSize.Y + antiJiggleSlackSpace > oldSize.Y)
+            newSize.Y = oldSize.Y;
+
+        return newSize;
+    }
+}
index 3cbe6575bf2c2e39428d52768bd55a99c2863a37..a3fe2400d0d7ecc2475901c46e2788d1d095fc57 100644 (file)
@@ -1,12 +1,10 @@
 using System.Linq;
 using Content.Server.Atmos.Components;
-using Content.Server.NodeContainer;
 using Content.Server.NodeContainer.Nodes;
 using Content.Server.Popups;
 using Content.Shared.Atmos;
 using Content.Shared.Atmos.Components;
 using Content.Shared.Interaction;
-using Content.Shared.Interaction.Events;
 using Content.Shared.NodeContainer;
 using JetBrains.Annotations;
 using Robust.Server.GameObjects;
@@ -159,16 +157,8 @@ public sealed class GasAnalyzerSystem : EntitySystem
 
         // Fetch the environmental atmosphere around the scanner. This must be the first entry
         var tileMixture = _atmo.GetContainingMixture(uid, true);
-        if (tileMixture != null)
-        {
-            gasMixList.Add(new GasMixEntry(Loc.GetString("gas-analyzer-window-environment-tab-label"), tileMixture.Volume, tileMixture.Pressure, tileMixture.Temperature,
-                GenerateGasEntryArray(tileMixture)));
-        }
-        else
-        {
-            // No gases were found
-            gasMixList.Add(new GasMixEntry(Loc.GetString("gas-analyzer-window-environment-tab-label"), 0f, 0f, 0f));
-        }
+        var tileMixtureName = Loc.GetString("gas-analyzer-window-environment-tab-label");
+        gasMixList.Add(GenerateGasMixEntry(tileMixtureName, tileMixture));
 
         var deviceFlipped = false;
         if (component.Target != null)
@@ -192,7 +182,7 @@ public sealed class GasAnalyzerSystem : EntitySystem
                 {
                     if (mixes.Item2 != null)
                     {
-                        gasMixList.Add(new GasMixEntry(mixes.Item1, mixes.Item2.Volume, mixes.Item2.Pressure, mixes.Item2.Temperature, GenerateGasEntryArray(mixes.Item2)));
+                        gasMixList.Add(GenerateGasMixEntry(mixes.Item1, mixes.Item2));
                         validTarget = true;
                     }
                 }
@@ -215,7 +205,7 @@ public sealed class GasAnalyzerSystem : EntitySystem
                             var pipeAir = pipeNode.Air.Clone();
                             pipeAir.Multiply(pipeNode.Volume / pipeNode.Air.Volume);
                             pipeAir.Volume = pipeNode.Volume;
-                            gasMixList.Add(new GasMixEntry(pair.Key, pipeAir.Volume, pipeAir.Pressure, pipeAir.Temperature, GenerateGasEntryArray(pipeAir)));
+                            gasMixList.Add(GenerateGasMixEntry(pair.Key, pipeAir));
                             validTarget = true;
                         }
                     }
@@ -242,6 +232,23 @@ public sealed class GasAnalyzerSystem : EntitySystem
         return true;
     }
 
+    /// <summary>
+    /// Generates a GasMixEntry for a given GasMixture
+    /// </summary>
+    public GasMixEntry GenerateGasMixEntry(string name, GasMixture? mixture)
+    {
+        if (mixture == null)
+            return new GasMixEntry(name, 0, 0, 0);
+
+        return new GasMixEntry(
+            name,
+            mixture.Volume,
+            mixture.Pressure,
+            mixture.Temperature,
+            GenerateGasEntryArray(mixture)
+        );
+    }
+
     /// <summary>
     /// Generates a GasEntry array for a given GasMixture
     /// </summary>
index 8dab21902d936e0667a62c7a198669e2eafdaf8f..e54f80bca9d9d56560a578b1ef9797616cd127fd 100644 (file)
@@ -6,61 +6,61 @@ using Content.Server.NodeContainer.EntitySystems;
 using Content.Server.NodeContainer.NodeGroups;
 using Content.Server.NodeContainer.Nodes;
 using Content.Shared.Atmos;
-using Content.Shared.Body.Components;
-using Content.Shared.Chemistry.EntitySystems;
 using Content.Shared.Medical.Cryogenics;
-using Content.Shared.MedicalScanner;
-using Content.Shared.Temperature.Components;
-using Content.Shared.UserInterface;
-using Robust.Shared.Containers;
-
 namespace Content.Server.Medical;
 
 public sealed partial class CryoPodSystem : SharedCryoPodSystem
 {
     [Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
     [Dependency] private readonly GasCanisterSystem _gasCanisterSystem = default!;
+    [Dependency] private readonly GasAnalyzerSystem _gasAnalyzerSystem = default!;
+    [Dependency] private readonly HealthAnalyzerSystem _healthAnalyzerSystem = default!;
     [Dependency] private readonly NodeContainerSystem _nodeContainer = default!;
-    [Dependency] private readonly SharedUserInterfaceSystem _uiSystem = default!;
-    [Dependency] private readonly SharedSolutionContainerSystem _solutionContainerSystem = default!;
+
 
     public override void Initialize()
     {
         base.Initialize();
 
-        SubscribeLocalEvent<CryoPodComponent, AfterActivatableUIOpenEvent>(OnActivateUI);
         SubscribeLocalEvent<CryoPodComponent, AtmosDeviceUpdateEvent>(OnCryoPodUpdateAtmosphere);
         SubscribeLocalEvent<CryoPodComponent, GasAnalyzerScanEvent>(OnGasAnalyzed);
-        SubscribeLocalEvent<CryoPodComponent, EntRemovedFromContainerMessage>(OnEjected);
     }
 
-    private void OnActivateUI(Entity<CryoPodComponent> entity, ref AfterActivatableUIOpenEvent args)
+    public override void Update(float frameTime)
     {
-        if (!entity.Comp.BodyContainer.ContainedEntity.HasValue)
-            return;
+        base.Update(frameTime);
 
-        TryComp<TemperatureComponent>(entity.Comp.BodyContainer.ContainedEntity, out var temp);
-        TryComp<BloodstreamComponent>(entity.Comp.BodyContainer.ContainedEntity, out var bloodstream);
+        var query = EntityQueryEnumerator<ActiveCryoPodComponent, CryoPodComponent>();
 
-        if (TryComp<HealthAnalyzerComponent>(entity, out var healthAnalyzer))
+        while (query.MoveNext(out var uid, out _, out var cryoPod))
         {
-            healthAnalyzer.ScannedEntity = entity.Comp.BodyContainer.ContainedEntity;
+            if (Timing.CurTime < cryoPod.NextUiUpdateTime)
+                continue;
+
+            cryoPod.NextUiUpdateTime += cryoPod.UiUpdateInterval;
+            Dirty(uid, cryoPod);
+            UpdateUi((uid, cryoPod));
         }
+    }
+
+    protected override void UpdateUi(Entity<CryoPodComponent> entity)
+    {
+        if (!UI.IsUiOpen(entity.Owner, CryoPodUiKey.Key)
+            || !TryComp(entity, out CryoPodAirComponent? air))
+            return;
 
-        // TODO: This should be a state my dude
-        _uiSystem.ServerSendUiMessage(
+        var patient = entity.Comp.BodyContainer.ContainedEntity;
+        var gasMix = _gasAnalyzerSystem.GenerateGasMixEntry("Cryo pod", air.Air);
+        var (beakerCapacity, beaker) = GetBeakerInfo(entity);
+        var injecting = GetInjectingReagents(entity);
+        var health = _healthAnalyzerSystem.GetHealthAnalyzerUiState(patient);
+        health.ScanMode = true;
+
+        UI.ServerSendUiMessage(
             entity.Owner,
-            HealthAnalyzerUiKey.Key,
-            new HealthAnalyzerScannedUserMessage(GetNetEntity(entity.Comp.BodyContainer.ContainedEntity),
-            temp?.CurrentTemperature ?? 0,
-            (bloodstream != null && _solutionContainerSystem.ResolveSolution(entity.Comp.BodyContainer.ContainedEntity.Value,
-                bloodstream.BloodSolutionName, ref bloodstream.BloodSolution, out var bloodSolution))
-                ? bloodSolution.FillFraction
-                : 0,
-            null,
-            null,
-            null
-        ));
+            CryoPodUiKey.Key,
+            new CryoPodUserMessage(gasMix, health, beakerCapacity, beaker, injecting)
+        );
     }
 
     private void OnCryoPodUpdateAtmosphere(Entity<CryoPodComponent> entity, ref AtmosDeviceUpdateEvent args)
@@ -96,15 +96,4 @@ public sealed partial class CryoPodSystem : SharedCryoPodSystem
             args.GasMixtures.Add((entity.Comp.PortName, portAirLocal));
         }
     }
-
-    private void OnEjected(Entity<CryoPodComponent> cryoPod, ref EntRemovedFromContainerMessage args)
-    {
-        if (TryComp<HealthAnalyzerComponent>(cryoPod.Owner, out var healthAnalyzer))
-        {
-            healthAnalyzer.ScannedEntity = null;
-        }
-
-        // if body is ejected - no need to display health-analyzer
-        _uiSystem.CloseUi(cryoPod.Owner, HealthAnalyzerUiKey.Key);
-    }
 }
index b7f48cade49d3dda181da6980d99066d6c8449ec..10da837141d0bb84d660b42a52ae2dcf84b0e034 100644 (file)
@@ -187,39 +187,58 @@ public sealed class HealthAnalyzerSystem : EntitySystem
     /// <param name="scanMode">True makes the UI show ACTIVE, False makes the UI show INACTIVE</param>
     public void UpdateScannedUser(EntityUid healthAnalyzer, EntityUid target, bool scanMode)
     {
-        if (!_uiSystem.HasUi(healthAnalyzer, HealthAnalyzerUiKey.Key))
+        if (!_uiSystem.HasUi(healthAnalyzer, HealthAnalyzerUiKey.Key)
+            || !HasComp<DamageableComponent>(target))
             return;
 
-        if (!HasComp<DamageableComponent>(target))
-            return;
+        var uiState = GetHealthAnalyzerUiState(target);
+        uiState.ScanMode = scanMode;
+
+        _uiSystem.ServerSendUiMessage(
+            healthAnalyzer,
+            HealthAnalyzerUiKey.Key,
+            new HealthAnalyzerScannedUserMessage(uiState)
+        );
+    }
+
+    /// <summary>
+    /// Creates a HealthAnalyzerState based on the current state of an entity.
+    /// </summary>
+    /// <param name="target">The entity being scanned</param>
+    /// <returns></returns>
+    public HealthAnalyzerUiState GetHealthAnalyzerUiState(EntityUid? target)
+    {
+        if (!target.HasValue || !HasComp<DamageableComponent>(target))
+            return new HealthAnalyzerUiState();
 
+        var entity = target.Value;
         var bodyTemperature = float.NaN;
 
-        if (TryComp<TemperatureComponent>(target, out var temp))
+        if (TryComp<TemperatureComponent>(entity, out var temp))
             bodyTemperature = temp.CurrentTemperature;
 
         var bloodAmount = float.NaN;
         var bleeding = false;
         var unrevivable = false;
 
-        if (TryComp<BloodstreamComponent>(target, out var bloodstream) &&
-            _solutionContainerSystem.ResolveSolution(target, bloodstream.BloodSolutionName,
+        if (TryComp<BloodstreamComponent>(entity, out var bloodstream) &&
+            _solutionContainerSystem.ResolveSolution(entity, bloodstream.BloodSolutionName,
                 ref bloodstream.BloodSolution, out var bloodSolution))
         {
-            bloodAmount = _bloodstreamSystem.GetBloodLevel(target);
+            bloodAmount = _bloodstreamSystem.GetBloodLevel(entity);
             bleeding = bloodstream.BleedAmount > 0;
         }
 
-        if (TryComp<UnrevivableComponent>(target, out var unrevivableComp) && unrevivableComp.Analyzable)
+        if (TryComp<UnrevivableComponent>(entity, out var unrevivableComp) && unrevivableComp.Analyzable)
             unrevivable = true;
 
-        _uiSystem.ServerSendUiMessage(healthAnalyzer, HealthAnalyzerUiKey.Key, new HealthAnalyzerScannedUserMessage(
-            GetNetEntity(target),
+        return new HealthAnalyzerUiState(
+            GetNetEntity(entity),
             bodyTemperature,
             bloodAmount,
-            scanMode,
+            null,
             bleeding,
             unrevivable
-        ));
+        );
     }
 }
index ed9e9cb904fec37cbad488ddcc8e19a380f97bbf..d75d22f60d7aa9f7fc7e0fa28bed98fe76924ed2 100644 (file)
@@ -1,11 +1,13 @@
+using Content.Shared.Atmos.Components;
+using Content.Shared.Chemistry.Reagent;
 using Content.Shared.FixedPoint;
+using Content.Shared.MedicalScanner;
 using Content.Shared.Tools;
 using Robust.Shared.Containers;
 using Robust.Shared.GameStates;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Serialization;
 using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
-
 namespace Content.Shared.Medical.Cryogenics;
 
 /// <summary>
@@ -21,6 +23,11 @@ public sealed partial class CryoPodComponent : Component
     /// </summary>
     public const string BodyContainerName = "scanner-body";
 
+    /// <summary>
+    /// The name of the solution container for the injection chamber.
+    /// </summary>
+    public const string InjectionBufferSolutionName = "injectionBuffer";
+
     /// <summary>
     /// Specifies the name of the atmospherics port to draw gas from.
     /// </summary>
@@ -38,7 +45,7 @@ public sealed partial class CryoPodComponent : Component
     /// (injection interval)
     /// </summary>
     [DataField]
-    public TimeSpan BeakerTransferTime = TimeSpan.FromSeconds(1);
+    public TimeSpan BeakerTransferTime = TimeSpan.FromSeconds(2);
 
     /// <summary>
     /// The timestamp for the next injection.
@@ -47,6 +54,20 @@ public sealed partial class CryoPodComponent : Component
     [AutoNetworkedField, AutoPausedField]
     public TimeSpan NextInjectionTime = TimeSpan.Zero;
 
+
+    /// <summary>
+    /// How often the UI is updated.
+    /// </summary>
+    [DataField]
+    public TimeSpan UiUpdateInterval = TimeSpan.FromSeconds(1);
+
+    /// <summary>
+    /// The timestamp for the next UI update.
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+    [AutoNetworkedField, AutoPausedField]
+    public TimeSpan NextUiUpdateTime = TimeSpan.Zero;
+
     /// <summary>
     /// How many units to transfer per injection from the beaker to the mob?
     /// </summary>
@@ -96,3 +117,57 @@ public enum CryoPodVisuals : byte
     ContainsEntity,
     IsOn
 }
+
+[Serializable, NetSerializable]
+public enum CryoPodUiKey : byte
+{
+    Key
+}
+
+[Serializable, NetSerializable]
+public sealed class CryoPodUserMessage : BoundUserInterfaceMessage
+{
+    public GasAnalyzerComponent.GasMixEntry GasMix;
+    public HealthAnalyzerUiState Health;
+    public FixedPoint2? BeakerCapacity;
+    public List<ReagentQuantity>? Beaker;
+    public List<ReagentQuantity>? Injecting;
+
+    public CryoPodUserMessage(
+        GasAnalyzerComponent.GasMixEntry gasMix,
+        HealthAnalyzerUiState health,
+        FixedPoint2? beakerCapacity,
+        List<ReagentQuantity>? beaker,
+        List<ReagentQuantity>? injecting)
+    {
+        GasMix = gasMix;
+        Health = health;
+        BeakerCapacity = beakerCapacity;
+        Beaker = beaker;
+        Injecting = injecting;
+    }
+}
+
+[Serializable, NetSerializable]
+public sealed class CryoPodSimpleUiMessage : BoundUserInterfaceMessage
+{
+    public enum MessageType { EjectPatient, EjectBeaker }
+
+    public readonly MessageType Type;
+
+    public CryoPodSimpleUiMessage(MessageType type)
+    {
+        Type = type;
+    }
+}
+
+[Serializable, NetSerializable]
+public sealed class CryoPodInjectUiMessage : BoundUserInterfaceMessage
+{
+    public readonly FixedPoint2 Quantity;
+
+    public CryoPodInjectUiMessage(FixedPoint2 quantity)
+    {
+        Quantity = quantity;
+    }
+}
index a7bbe39c56ff11754a73368ecee9a3689bf843d2..fe9db1896082b6cae9e715b8f6656868f4bf118c 100644 (file)
@@ -1,3 +1,4 @@
+using System.Linq;
 using Content.Shared.Administration.Logs;
 using Content.Shared.Body.Components;
 using Content.Shared.Body.Systems;
@@ -5,6 +6,7 @@ using Content.Shared.Chemistry;
 using Content.Shared.Chemistry.Components;
 using Content.Shared.Chemistry.Components.SolutionManager;
 using Content.Shared.Chemistry.EntitySystems;
+using Content.Shared.Chemistry.Reagent;
 using Content.Shared.Climbing.Systems;
 using Content.Shared.Containers.ItemSlots;
 using Content.Shared.Database;
@@ -12,6 +14,8 @@ using Content.Shared.DoAfter;
 using Content.Shared.DragDrop;
 using Content.Shared.Emag.Systems;
 using Content.Shared.Examine;
+using Content.Shared.FixedPoint;
+using Content.Shared.Hands.EntitySystems;
 using Content.Shared.Interaction;
 using Content.Shared.MedicalScanner;
 using Content.Shared.Mobs.Components;
@@ -26,34 +30,35 @@ using Content.Shared.Verbs;
 using Robust.Shared.Containers;
 using Robust.Shared.Serialization;
 using Robust.Shared.Timing;
-
 namespace Content.Shared.Medical.Cryogenics;
 
 public abstract partial class SharedCryoPodSystem : EntitySystem
 {
-    [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
-    [Dependency] private readonly StandingStateSystem _standingState = default!;
+    [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
+    [Dependency] protected readonly IGameTiming Timing = default!;
+    [Dependency] private readonly ClimbSystem _climb = default!;
     [Dependency] private readonly EmagSystem _emag = default!;
     [Dependency] private readonly ItemSlotsSystem _itemSlots = default!;
     [Dependency] private readonly MobStateSystem _mobState = default!;
-    [Dependency] private readonly SharedPopupSystem _popup = default!;
+    [Dependency] private readonly ReactiveSystem _reactive = default!;
+    [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
+    [Dependency] private readonly SharedBloodstreamSystem _bloodstream = default!;
     [Dependency] private readonly SharedContainerSystem _container = default!;
+    [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
+    [Dependency] private readonly SharedHandsSystem _hands = default!;
     [Dependency] private readonly SharedPointLightSystem _light = default!;
+    [Dependency] private readonly SharedPopupSystem _popup = default!;
     [Dependency] private readonly SharedSolutionContainerSystem _solutionContainer = default!;
-    [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
-    [Dependency] private readonly ClimbSystem _climb = default!;
-    [Dependency] private readonly SharedBloodstreamSystem _bloodstream = default!;
-    [Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
-    [Dependency] private readonly SharedUserInterfaceSystem _ui = default!;
     [Dependency] private readonly SharedToolSystem _tool = default!;
-    [Dependency] private readonly IGameTiming _timing = default!;
-    [Dependency] private readonly ReactiveSystem _reactive = default!;
+    [Dependency] protected readonly SharedUserInterfaceSystem UI = default!;
+    [Dependency] private readonly StandingStateSystem _standingState = default!;
 
     private EntityQuery<BloodstreamComponent> _bloodstreamQuery;
     private EntityQuery<ItemSlotsComponent> _itemSlotsQuery;
     private EntityQuery<FitsInDispenserComponent> _dispenserQuery;
     private EntityQuery<SolutionContainerManagerComponent> _solutionContainerQuery;
 
+
     public override void Initialize()
     {
         base.Initialize();
@@ -69,6 +74,8 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         SubscribeLocalEvent<CryoPodComponent, InteractUsingEvent>(OnInteractUsing);
         SubscribeLocalEvent<CryoPodComponent, PowerChangedEvent>(OnPowerChanged);
         SubscribeLocalEvent<CryoPodComponent, ActivatableUIOpenAttemptEvent>(OnActivateUIAttempt);
+        SubscribeLocalEvent<CryoPodComponent, EntRemovedFromContainerMessage>(OnEjected);
+        SubscribeLocalEvent<CryoPodComponent, EntInsertedIntoContainerMessage>(OnBodyInserted);
 
         _bloodstreamQuery = GetEntityQuery<BloodstreamComponent>();
         _itemSlotsQuery = GetEntityQuery<ItemSlotsComponent>();
@@ -76,13 +83,20 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         _solutionContainerQuery = GetEntityQuery<SolutionContainerManagerComponent>();
 
         InitializeInsideCryoPod();
+
+        Subs.BuiEvents<CryoPodComponent>(CryoPodUiKey.Key, subs =>
+        {
+            subs.Event<BoundUIOpenedEvent>(OnBoundUiOpened);
+            subs.Event<CryoPodSimpleUiMessage>(OnSimpleUiMessage);
+            subs.Event<CryoPodInjectUiMessage>(OnInjectUiMessage);
+        });
     }
 
     public override void Update(float frameTime)
     {
         base.Update(frameTime);
 
-        var curTime = _timing.CurTime;
+        var curTime = Timing.CurTime;
         var query = EntityQueryEnumerator<ActiveCryoPodComponent, CryoPodComponent>();
 
         while (query.MoveNext(out var uid, out _, out var cryoPod))
@@ -92,25 +106,33 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
 
             cryoPod.NextInjectionTime += cryoPod.BeakerTransferTime;
             Dirty(uid, cryoPod);
+            UpdateInjection((uid, cryoPod));
+        }
+    }
 
-            if (!_itemSlotsQuery.TryComp(uid, out var itemSlotsComponent))
-                continue;
+    private void UpdateInjection(Entity<CryoPodComponent> entity)
+    {
+        var patient = entity.Comp.BodyContainer.ContainedEntity;
+
+        if (patient == null
+            || !_solutionContainerQuery.TryComp(entity, out var podSolutionManager)
+            || !_solutionContainer.TryGetSolution(
+                    (entity.Owner, podSolutionManager),
+                    CryoPodComponent.InjectionBufferSolutionName,
+                    out var injectingSolution,
+                    out _)
+            || !_bloodstreamQuery.TryComp(patient, out var bloodstream))
+        {
+            return;
+        }
 
-            var container = _itemSlots.GetItemOrNull(uid, cryoPod.SolutionContainerName, itemSlotsComponent);
-            var patient = cryoPod.BodyContainer.ContainedEntity;
-            if (container != null
-                && container.Value.Valid
-                && patient != null
-                && _dispenserQuery.TryComp(container, out var fitsInDispenserComponent)
-                && _solutionContainerQuery.TryComp(container, out var solutionContainerManagerComponent)
-                && _solutionContainer.TryGetFitsInDispenser((container.Value, fitsInDispenserComponent, solutionContainerManagerComponent),
-                    out var containerSolution, out _)
-                && _bloodstreamQuery.TryComp(patient, out var bloodstream))
-            {
-                var solutionToInject = _solutionContainer.SplitSolution(containerSolution.Value, cryoPod.BeakerTransferAmount);
-                _bloodstream.TryAddToBloodstream((patient.Value, bloodstream), solutionToInject);
-                _reactive.DoEntityReaction(patient.Value, solutionToInject, ReactionMethod.Injection);
-            }
+        var solutionToInject =
+            _solutionContainer.SplitSolution(injectingSolution.Value, entity.Comp.BeakerTransferAmount);
+
+        if (solutionToInject.Volume > 0)
+        {
+            _bloodstream.TryAddToBloodstream((patient.Value, bloodstream), solutionToInject);
+            _reactive.DoEntityReaction(patient.Value, solutionToInject, ReactionMethod.Injection);
         }
     }
 
@@ -148,7 +170,7 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
             return;
 
         var containedEntity = ent.Comp.BodyContainer.ContainedEntity;
-        if (containedEntity == null || containedEntity == args.User || !HasComp<ActiveCryoPodComponent>(ent))
+        if (containedEntity == args.User)
             args.Cancel();
     }
 
@@ -179,13 +201,13 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         if (args.Powered)
         {
             EnsureComp<ActiveCryoPodComponent>(ent);
-            ent.Comp.NextInjectionTime = _timing.CurTime + ent.Comp.BeakerTransferTime;
+            ent.Comp.NextInjectionTime = Timing.CurTime + ent.Comp.BeakerTransferTime;
             Dirty(ent);
         }
         else
         {
             RemComp<ActiveCryoPodComponent>(ent);
-            _ui.CloseUi(ent.Owner, HealthAnalyzerUiKey.Key);
+            UI.CloseUi(ent.Owner, HealthAnalyzerUiKey.Key);
         }
 
         UpdateAppearance(ent.Owner, ent.Comp);
@@ -236,8 +258,8 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         if (!Resolve(uid, ref appearance))
             return;
 
-        _appearance.SetData(uid, CryoPodVisuals.ContainsEntity, cryoPod.BodyContainer?.ContainedEntity == null, appearance);
-        _appearance.SetData(uid, CryoPodVisuals.IsOn, cryoPodEnabled, appearance);
+        Appearance.SetData(uid, CryoPodVisuals.ContainsEntity, cryoPod.BodyContainer?.ContainedEntity == null, appearance);
+        Appearance.SetData(uid, CryoPodVisuals.IsOn, cryoPodEnabled, appearance);
     }
 
     public bool InsertBody(EntityUid uid, EntityUid target, CryoPodComponent cryoPodComponent)
@@ -305,6 +327,115 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         return contained;
     }
 
+    public void TryEjectBeaker(Entity<CryoPodComponent> cryoPod, EntityUid? user)
+    {
+        if (_itemSlots.TryEject(cryoPod.Owner, cryoPod.Comp.SolutionContainerName, user, out var beaker)
+            && user != null)
+        {
+            // Eject the beaker to the user's hands if possible.
+            _hands.PickupOrDrop(user.Value, beaker.Value);
+        }
+    }
+
+    /// <summary>
+    /// Transfers reagents from the cryopod beaker into the injection buffer.
+    /// </summary>
+    /// <param name="cryoPod">The cryopod entity</param>
+    /// <param name="transferAmount">
+    /// The amount of reagents that will be transferred.
+    /// If less reagents are available, however much is available will be transferred.
+    /// </param>
+    public void TryInject(Entity<CryoPodComponent> cryoPod, FixedPoint2 transferAmount)
+    {
+        var patient = cryoPod.Comp.BodyContainer.ContainedEntity;
+        if (patient == null)
+            return; // Refuse to inject if there is no patient.
+
+        var beaker = _itemSlots.GetItemOrNull(cryoPod, cryoPod.Comp.SolutionContainerName);
+
+        if (beaker == null
+            || !beaker.Value.Valid
+            || !_dispenserQuery.TryComp(beaker, out var fitsInDispenserComponent)
+            || !_solutionContainerQuery.TryComp(beaker, out var beakerSolutionManager)
+            || !_solutionContainerQuery.TryComp(cryoPod, out var podSolutionManager)
+            || !_solutionContainer.TryGetFitsInDispenser(
+                    (beaker.Value, fitsInDispenserComponent, beakerSolutionManager),
+                    out var beakerSolution,
+                    out _)
+            || !_solutionContainer.TryGetSolution(
+                    (cryoPod.Owner, podSolutionManager),
+                    CryoPodComponent.InjectionBufferSolutionName,
+                    out var injectionSolutionComp,
+                    out var injectionSolution))
+        {
+            return;
+        }
+
+        if (injectionSolution.AvailableVolume == 0)
+            return;
+
+        var amountToTransfer = FixedPoint2.Min(transferAmount, injectionSolution.AvailableVolume);
+        var solution = _solutionContainer.SplitSolution(beakerSolution.Value, amountToTransfer);
+        _solutionContainer.TryAddSolution(injectionSolutionComp.Value, solution);
+    }
+
+    public void ClearInjectionBuffer(Entity<CryoPodComponent> cryoPod)
+    {
+        if (_solutionContainerQuery.TryComp(cryoPod, out var podSolutionManager)
+            && _solutionContainer.TryGetSolution(
+                    (cryoPod.Owner, podSolutionManager),
+                    CryoPodComponent.InjectionBufferSolutionName,
+                    out var injectingSolution,
+                    out _))
+        {
+            _solutionContainer.RemoveAllSolution(injectingSolution.Value);
+        }
+    }
+
+    protected (FixedPoint2? capacity, List<ReagentQuantity>? reagents) GetBeakerInfo(Entity<CryoPodComponent> entity)
+    {
+        if (!_itemSlotsQuery.TryComp(entity, out var itemSlotsComponent))
+            return (null, null);
+
+        var beaker = _itemSlots.GetItemOrNull(
+            entity.Owner,
+            entity.Comp.SolutionContainerName,
+            itemSlotsComponent
+        );
+
+        if (beaker == null
+            || !beaker.Value.Valid
+            || !_dispenserQuery.TryComp(beaker, out var fitsInDispenserComponent)
+            || !_solutionContainerQuery.TryComp(beaker, out var solutionContainerManagerComponent)
+            || !_solutionContainer.TryGetFitsInDispenser(
+                    (beaker.Value, fitsInDispenserComponent, solutionContainerManagerComponent),
+                    out var containerSolution,
+                    out _))
+            return (null, null);
+
+        var capacity = containerSolution.Value.Comp.Solution.MaxVolume;
+        var reagents = containerSolution.Value.Comp.Solution.Contents
+            .Select(reagent => new ReagentQuantity(reagent.Reagent, reagent.Quantity))
+            .ToList();
+
+        return (capacity, reagents);
+    }
+
+    protected List<ReagentQuantity>? GetInjectingReagents(Entity<CryoPodComponent> entity)
+    {
+        if (!_solutionContainerQuery.TryComp(entity, out var solutionManager)
+            || !_solutionContainer.TryGetSolution(
+                    (entity.Owner, solutionManager),
+                    CryoPodComponent.InjectionBufferSolutionName,
+                    out var injectingSolution,
+                    out _))
+            return null;
+
+        return injectingSolution.Value.Comp.Solution.Contents
+            .Select(reagent => new ReagentQuantity(reagent.Reagent, reagent.Quantity))
+            .ToList();
+    }
+
     protected void AddAlternativeVerbs(EntityUid uid, CryoPodComponent cryoPodComponent, GetVerbsEvent<AlternativeVerb> args)
     {
         if (!args.CanAccess || !args.CanInteract)
@@ -340,6 +471,55 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
         args.Handled = true;
     }
 
+    private void OnSimpleUiMessage(Entity<CryoPodComponent> cryoPod, ref CryoPodSimpleUiMessage msg)
+    {
+        switch (msg.Type)
+        {
+            case CryoPodSimpleUiMessage.MessageType.EjectPatient:
+                TryEjectBody(cryoPod.Owner, msg.Actor, cryoPod.Comp);
+                break;
+            case CryoPodSimpleUiMessage.MessageType.EjectBeaker:
+                TryEjectBeaker(cryoPod, msg.Actor);
+                break;
+        }
+
+        UpdateUi(cryoPod);
+    }
+
+    private void OnInjectUiMessage(Entity<CryoPodComponent> cryoPod, ref CryoPodInjectUiMessage msg)
+    {
+        TryInject(cryoPod, msg.Quantity);
+        UpdateUi(cryoPod);
+    }
+
+    private void OnBoundUiOpened(Entity<CryoPodComponent> cryoPod, ref BoundUIOpenedEvent args)
+    {
+        UpdateUi(cryoPod);
+    }
+
+    private void OnEjected(Entity<CryoPodComponent> cryoPod, ref EntRemovedFromContainerMessage args)
+    {
+        if (args.Container.ID == CryoPodComponent.BodyContainerName)
+        {
+            ClearInjectionBuffer(cryoPod);
+        }
+
+        UpdateUi(cryoPod);
+    }
+
+    private void OnBodyInserted(Entity<CryoPodComponent> cryoPod, ref EntInsertedIntoContainerMessage args)
+    {
+        if (args.Container.ID == CryoPodComponent.BodyContainerName)
+        {
+            UI.CloseUi(cryoPod.Owner, CryoPodUiKey.Key, args.Entity);
+            ClearInjectionBuffer(cryoPod);
+        }
+
+        UpdateUi(cryoPod);
+    }
+
+    protected abstract void UpdateUi(Entity<CryoPodComponent> cryoPod);
+
     [Serializable, NetSerializable]
     public sealed partial class CryoPodPryFinished : SimpleDoAfterEvent;
 
index 08af1a36a7b9ac8ee1078d2c39b4f039bd664dca..65c50cc9a9d7ab067e99e3ae4958c2aef9ee2c77 100644 (file)
@@ -3,10 +3,24 @@ using Robust.Shared.Serialization;
 namespace Content.Shared.MedicalScanner;
 
 /// <summary>
-///     On interacting with an entity retrieves the entity UID for use with getting the current damage of the mob.
+/// On interacting with an entity retrieves the entity UID for use with getting the current damage of the mob.
 /// </summary>
 [Serializable, NetSerializable]
 public sealed class HealthAnalyzerScannedUserMessage : BoundUserInterfaceMessage
+{
+    public HealthAnalyzerUiState State;
+
+    public HealthAnalyzerScannedUserMessage(HealthAnalyzerUiState state)
+    {
+        State = state;
+    }
+}
+
+/// <summary>
+/// Contains the current state of a health analyzer control. Used for the health analyzer and cryo pod.
+/// </summary>
+[Serializable, NetSerializable]
+public struct HealthAnalyzerUiState
 {
     public readonly NetEntity? TargetEntity;
     public float Temperature;
@@ -15,7 +29,9 @@ public sealed class HealthAnalyzerScannedUserMessage : BoundUserInterfaceMessage
     public bool? Bleeding;
     public bool? Unrevivable;
 
-    public HealthAnalyzerScannedUserMessage(NetEntity? targetEntity, float temperature, float bloodLevel, bool? scanMode, bool? bleeding, bool? unrevivable)
+    public HealthAnalyzerUiState() {}
+
+    public HealthAnalyzerUiState(NetEntity? targetEntity, float temperature, float bloodLevel, bool? scanMode, bool? bleeding, bool? unrevivable)
     {
         TargetEntity = targetEntity;
         Temperature = temperature;
@@ -25,4 +41,3 @@ public sealed class HealthAnalyzerScannedUserMessage : BoundUserInterfaceMessage
         Unrevivable = unrevivable;
     }
 }
-
index 53ee8301dea8400e54012b1cb7e78b9f210fda67..077bc26663a6ac7e7ce56d9b6a400e63b2044775 100644 (file)
@@ -5,3 +5,42 @@ cryo-pod-examine = There's {INDEFINITE($beaker)} {$beaker} in here.
 cryo-pod-empty-beaker = It is empty!
 # Shown when a normal ejection through the eject verb is attempted on a locked pod.
 cryo-pod-locked = The ejection mechanism is unresponsive!
+
+cryo-pod-window-product-name = Nanotrasen CRPX-229
+cryo-pod-window-product-subtitle = Cryogenic Restoration Pod
+cryo-pod-window-loading = Loading
+cryo-pod-window-atmos-pressure = Pressure
+cryo-pod-window-atmos-temperature = Temperature
+cryo-pod-window-status = Pod status:
+cryo-pod-window-status-ready-for-patient = Ready for patient
+cryo-pod-window-status-ready-to-inject = Ready to inject
+cryo-pod-window-status-injecting = Injecting...
+cryo-pod-window-status-not-ready = NOT READY
+cryo-pod-window-status-cooling = Cooling patient...
+cryo-pod-window-checklist-pressure = Pressurized
+cryo-pod-window-checklist-chemicals = Chemicals available
+cryo-pod-window-checklist-temperature = Cryogenic temperature
+cryo-pod-window-checklist-fail = {$item} — NO
+
+cryo-pod-window-warning-header = WARNING
+cryo-pod-window-low-pressure-warning = Dangerously low pressure. Gas pressure must be approximately 100 kPa for safe operation.
+cryo-pod-window-high-temperature-warning = Temperature too high. {CAPITALIZE($reagent)} requires a temperature below {$temperature} K.
+
+cryo-pod-window-error-header = ERROR
+# Shown when the eject button is pressed on a locked pod.
+cryo-pod-window-eject-error = Ejection mechanism failed. Contact a Nanotrasen-certified engineer for support.
+
+cryo-pod-window-chems-no-beaker = No beaker inserted
+cryo-pod-window-chems-empty-beaker = Beaker is empty
+cryo-pod-window-chems-injecting-tooltip = Injecting {$quantity}u
+cryo-pod-window-inject-1u = 1u
+cryo-pod-window-inject-5u = 5u
+cryo-pod-window-inject-10u = 10u
+cryo-pod-window-inject-20u = 20u
+# The eject beaker button has very little horizontal space, which is why it only says "eject"
+cryo-pod-window-eject-beaker = Eject
+cryo-pod-window-eject-patient = Eject patient
+
+cryo-pod-window-health-no-damage = No damage detected
+
+
index 4d50a8b6ed01974228bd613de5588bc1de61ae36..af948741a1c543fac4ab54999d8737550896dc00 100644 (file)
         whitelist:
           components:
             - FitsInDispenser
-  - type: HealthAnalyzer
-    scanDelay: 0
   - type: UserInterface
     interfaces:
-      enum.HealthAnalyzerUiKey.Key:
-        type: HealthAnalyzerBoundUserInterface
+      enum.CryoPodUiKey.Key:
+        type: CryoPodBoundUserInterface
       enum.WiresUiKey.Key:
         type: WiresBoundUserInterface
   - type: ActivatableUI
-    key: enum.HealthAnalyzerUiKey.Key
+    key: enum.CryoPodUiKey.Key
     requiresComplex: false
   - type: ActivatableUIRequiresPower
   - type: PointLight
   - type: EmptyOnMachineDeconstruct
     containers:
       - scanner-body
+  - type: SolutionContainerManager
+    solutions:
+      injectionBuffer:
+        maxVol: 50
   - type: CryoPod
   - type: CryoPodAir
   - type: Climbable # so that ejected bodies don't get stuck