From 4f997f2069c5625bf3b0de9e3cf6f0c71a3b9ac9 Mon Sep 17 00:00:00 2001
From: Fruitsalad <949631+Fruitsalad@users.noreply.github.com>
Date: Thu, 15 Jan 2026 18:52:03 +0100
Subject: [PATCH] Cryo pod UI (#41850)
* Add CryoPodWindow (placeholder)
* Change HealthAnalyzerWindow: split off reusable HealthAnalyzerControl for cryo pod UI
* Improve CryoPodWindow: add health analyzer
* Improve CryoPodWindow: add eject button
This wasn't requested in the issue but I implemented it as practice with the UI system.
* Rewrote GasAnalyzerWindow, split off reusable gas mix viewer for cryo pod
* Change GasAnalyzerWindow: change back to three columns
With two rows you get a layouting bug when there's a lot of different gases, which looks somewhat bad. I didn't feel like fixing the layouting bug (it's an engine issue) so we're going back to three columns. That way you don't ever get two rows in practice.
* Change GasAnalyzerWindow: simplify by disabling Resizable
I added a lot of complexity to make resizable work nicely with a derived max & min size, but it's not necessary.
* Change GasAnalyzerWindow: file-wide namespace
* Change GasAnalyzerSystem: add GenerateGasMixEntry
* Split HealthAnalyzerUiState from HealthAnalyzerScannedUserMessage
* Rewrote CryoPodWindow, add atmos info
* Improve CryoPodWindow: add loading placeholder
* Improve CryoPodWindow: add internationalization support
* Fix GasAnalyzerControl: add missing translation
* Improve CryoPodWindow: add beaker info, high temperature warning
* Improve CryoPodWindow/System: inject button in window + necessary system changes
* Fix CryoPodWindow: Entering cryopod now closes window
This way you can't heal yourself with a cryopod.
* Change CryoPodWindow: add & update comments
* Change HealthAnalyzerComponent: remove `uiKey` property (no longer necessary)
* Tiny fixes
* Improve CryoPodUiMessage: replace string with enum
* Change GasAnalyzerWindow: simplify Measure code
* Change CryoPodComponent: rename Injecting to InjectionBuffer
* Change CryoPodBUI: tiny code simplification
* Fix HealthAnalyzerComponent: Removed stray import
* Improve CryoPodWindow: Prettier, concise atmos
* Improve CryoPodWindow: Chemicals bar chart
* Improve CryoPodWindow: Add Ruler to reagents
* Change CryoPodWindow: More horizontal layout
* Improve CryoPodWindow: Reduce height jiggling
The health analyzer's height changes a lot, which can be annoying with the buttons (for example when the oxygen damage label is popping in and out)
* Improve CryoPodWindow: Add setup checklist
This is mostly here to fill vertical space in the new horizontal layout.
* Improve CryoPodWindow: Eject beaker button
* Improve CryoPodWindow: Localization
* Improve CryoPodWindow: Add BeakerBarChart
An animated version of the chemicals chart
* Fix CryoPodSystem: Ejecting beaker no longer clears injection buffer
* Improve BeakerBarChart: Not animated on first frame
* Fix CryoPodWindow: Fix broken translation
* Improve CryoPodWindow: Reorder sections
* Fix BeakerBarChart: Tooltips now show up
* Change BeakerBarChart: Reorder functions
* Change CryoPodWindow: Reorder sections, change margins
* Change CryoPodWindow: Edit flavor text
* Revert changes to GasAnalyzerWindow
Since GasAnalyzerControl is no longer used in CryoPodWindow, these changes are no longer relevant to this PR.
* Tidy CryoPodWindow: Remove old workarounds
These are old layouting bug workarounds from the older version of CryoPodWindow that had a ScrollContainer in it. They're no longer necessary. Less ScrollContainers less problems.
* Tidy up: Remove unused imports
* Remove LabelledSplitBar
It was replaced by BeakerBarChart, which is a lot fancier.
* Tidy up: Tiny code style fix
* Change CryoPodSystem: Move code from server to shared
This is still without adding UI prediction
* move a ton of stuff to shared.
* one last thing
* Improve BeakerBarChart: Keep visual entry width when swapping beakers
* Improve BeakerBarChart: Respect beaker order of reagents
* Improve CryoPodWindow: Ensure space for injection buffer
We need to keep space on the chart for the injection buffer after swapping to a full beaker.
* Improve CryoPodWindow: Prettier ejection error
* Improve CryoPodWindow: Add "Cooling patient" status
* BeakerBarChart: Fix UI scale bug
* BeakerBarChart: Fix bluespace beaker ugliness
* BeakerBarChart: Add more pod status strings
* HealthAnalyzerControl: Filewide namespace, sort imports
* Style fix: Replace `bool x = y` with `var x = y`
* CryoPodUiMessage: Split off separate class for inject
* SharedCryoPodSystem: Move message-related code into Subs.BuiEvents
---------
Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
---
.../UI/HealthAnalyzerControl.xaml | 53 ++++
.../UI/HealthAnalyzerControl.xaml.cs | 241 +++++++++++++++
.../UI/HealthAnalyzerWindow.xaml | 55 +---
.../UI/HealthAnalyzerWindow.xaml.cs | 241 +--------------
.../Medical/Cryogenics/BeakerBarChart.cs | 285 ++++++++++++++++++
.../Cryogenics/CryoPodBoundUserInterface.cs | 53 ++++
.../Medical/Cryogenics/CryoPodSystem.cs | 10 +-
.../Medical/Cryogenics/CryoPodWindow.xaml | 232 ++++++++++++++
.../Medical/Cryogenics/CryoPodWindow.xaml.cs | 260 ++++++++++++++++
.../Atmos/EntitySystems/GasAnalyzerSystem.cs | 35 ++-
Content.Server/Medical/CryoPodSystem.cs | 73 ++---
.../Medical/HealthAnalyzerSystem.cs | 43 ++-
.../Medical/Cryogenics/CryoPodComponent.cs | 79 ++++-
.../Medical/Cryogenics/SharedCryoPodSystem.cs | 248 ++++++++++++---
.../HealthAnalyzerScannedUserMessage.cs | 21 +-
.../medical/components/cryo-pod-component.ftl | 39 +++
.../Structures/Machines/Medical/cryo_pod.yml | 12 +-
17 files changed, 1582 insertions(+), 398 deletions(-)
create mode 100644 Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml
create mode 100644 Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs
create mode 100644 Content.Client/Medical/Cryogenics/BeakerBarChart.cs
create mode 100644 Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs
create mode 100644 Content.Client/Medical/Cryogenics/CryoPodWindow.xaml
create mode 100644 Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs
diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml
new file mode 100644
index 0000000000..06c6528f59
--- /dev/null
+++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs
new file mode 100644
index 0000000000..949b4770c4
--- /dev/null
+++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerControl.xaml.cs
@@ -0,0 +1,241 @@
+using System.Linq;
+using System.Numerics;
+using Content.Shared.Atmos;
+using Content.Shared.Damage.Components;
+using Content.Shared.Damage.Prototypes;
+using Content.Shared.FixedPoint;
+using Content.Shared.Humanoid;
+using Content.Shared.Humanoid.Prototypes;
+using Content.Shared.IdentityManagement;
+using Content.Shared.MedicalScanner;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Components;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+namespace Content.Client.HealthAnalyzer.UI;
+
+// Health analyzer UI is split from its window because it's used by both the
+// health analyzer item and the cryo pod UI.
+
+[GenerateTypedNameReferences]
+public sealed partial class HealthAnalyzerControl : BoxContainer
+{
+ private readonly IEntityManager _entityManager;
+ private readonly SpriteSystem _spriteSystem;
+ private readonly IPrototypeManager _prototypes;
+ private readonly IResourceCache _cache;
+
+ public HealthAnalyzerControl()
+ {
+ RobustXamlLoader.Load(this);
+
+ var dependencies = IoCManager.Instance!;
+ _entityManager = dependencies.Resolve();
+ _spriteSystem = _entityManager.System();
+ _prototypes = dependencies.Resolve();
+ _cache = dependencies.Resolve();
+ }
+
+ public void Populate(HealthAnalyzerUiState state)
+ {
+ var target = _entityManager.GetEntity(state.TargetEntity);
+
+ if (target == null
+ || !_entityManager.TryGetComponent(target, out var damageable))
+ {
+ NoPatientDataText.Visible = true;
+ return;
+ }
+
+ NoPatientDataText.Visible = false;
+
+ // Scan Mode
+
+ ScanModeLabel.Text = state.ScanMode.HasValue
+ ? state.ScanMode.Value
+ ? Loc.GetString("health-analyzer-window-scan-mode-active")
+ : Loc.GetString("health-analyzer-window-scan-mode-inactive")
+ : Loc.GetString("health-analyzer-window-entity-unknown-text");
+
+ ScanModeLabel.FontColorOverride = state.ScanMode.HasValue && state.ScanMode.Value ? Color.Green : Color.Red;
+
+ // Patient Information
+
+ SpriteView.SetEntity(target.Value);
+ SpriteView.Visible = state.ScanMode.HasValue && state.ScanMode.Value;
+ NoDataTex.Visible = !SpriteView.Visible;
+
+ var name = new FormattedMessage();
+ name.PushColor(Color.White);
+ name.AddText(_entityManager.HasComponent(target.Value)
+ ? Identity.Name(target.Value, _entityManager)
+ : Loc.GetString("health-analyzer-window-entity-unknown-text"));
+ NameLabel.SetMessage(name);
+
+ SpeciesLabel.Text =
+ _entityManager.TryGetComponent(target.Value,
+ out var humanoidAppearanceComponent)
+ ? Loc.GetString(_prototypes.Index(humanoidAppearanceComponent.Species).Name)
+ : Loc.GetString("health-analyzer-window-entity-unknown-species-text");
+
+ // Basic Diagnostic
+
+ TemperatureLabel.Text = !float.IsNaN(state.Temperature)
+ ? $"{state.Temperature - Atmospherics.T0C:F1} °C ({state.Temperature:F1} K)"
+ : Loc.GetString("health-analyzer-window-entity-unknown-value-text");
+
+ BloodLabel.Text = !float.IsNaN(state.BloodLevel)
+ ? $"{state.BloodLevel * 100:F1} %"
+ : Loc.GetString("health-analyzer-window-entity-unknown-value-text");
+
+ StatusLabel.Text =
+ _entityManager.TryGetComponent(target.Value, out var mobStateComponent)
+ ? GetStatus(mobStateComponent.CurrentState)
+ : Loc.GetString("health-analyzer-window-entity-unknown-text");
+
+ // Total Damage
+
+ DamageLabel.Text = damageable.TotalDamage.ToString();
+
+ // Alerts
+
+ var showAlerts = state.Unrevivable == true || state.Bleeding == true;
+
+ AlertsDivider.Visible = showAlerts;
+ AlertsContainer.Visible = showAlerts;
+
+ if (showAlerts)
+ AlertsContainer.RemoveAllChildren();
+
+ if (state.Unrevivable == true)
+ AlertsContainer.AddChild(new RichTextLabel
+ {
+ Text = Loc.GetString("health-analyzer-window-entity-unrevivable-text"),
+ Margin = new Thickness(0, 4),
+ MaxWidth = 300
+ });
+
+ if (state.Bleeding == true)
+ AlertsContainer.AddChild(new RichTextLabel
+ {
+ Text = Loc.GetString("health-analyzer-window-entity-bleeding-text"),
+ Margin = new Thickness(0, 4),
+ MaxWidth = 300
+ });
+
+ // Damage Groups
+
+ var damageSortedGroups =
+ damageable.DamagePerGroup.OrderByDescending(damage => damage.Value)
+ .ToDictionary(x => x.Key, x => x.Value);
+
+ IReadOnlyDictionary damagePerType = damageable.Damage.DamageDict;
+
+ DrawDiagnosticGroups(damageSortedGroups, damagePerType);
+ }
+
+ private static string GetStatus(MobState mobState)
+ {
+ return mobState switch
+ {
+ MobState.Alive => Loc.GetString("health-analyzer-window-entity-alive-text"),
+ MobState.Critical => Loc.GetString("health-analyzer-window-entity-critical-text"),
+ MobState.Dead => Loc.GetString("health-analyzer-window-entity-dead-text"),
+ _ => Loc.GetString("health-analyzer-window-entity-unknown-text"),
+ };
+ }
+
+ private void DrawDiagnosticGroups(
+ Dictionary groups,
+ IReadOnlyDictionary damageDict)
+ {
+ GroupsContainer.RemoveAllChildren();
+
+ foreach (var (damageGroupId, damageAmount) in groups)
+ {
+ if (damageAmount == 0)
+ continue;
+
+ var groupTitleText = $"{Loc.GetString(
+ "health-analyzer-window-damage-group-text",
+ ("damageGroup", _prototypes.Index(damageGroupId).LocalizedName),
+ ("amount", damageAmount)
+ )}";
+
+ var groupContainer = new BoxContainer
+ {
+ Align = AlignMode.Begin,
+ Orientation = LayoutOrientation.Vertical,
+ };
+
+ groupContainer.AddChild(CreateDiagnosticGroupTitle(groupTitleText, damageGroupId));
+
+ GroupsContainer.AddChild(groupContainer);
+
+ // Show the damage for each type in that group.
+ var group = _prototypes.Index(damageGroupId);
+
+ foreach (var type in group.DamageTypes)
+ {
+ if (!damageDict.TryGetValue(type, out var typeAmount) || typeAmount <= 0)
+ continue;
+
+ var damageString = Loc.GetString(
+ "health-analyzer-window-damage-type-text",
+ ("damageType", _prototypes.Index(type).LocalizedName),
+ ("amount", typeAmount)
+ );
+
+ groupContainer.AddChild(CreateDiagnosticItemLabel(damageString.Insert(0, " · ")));
+ }
+ }
+ }
+
+ private Texture GetTexture(string texture)
+ {
+ var rsiPath = new ResPath("/Textures/Objects/Devices/health_analyzer.rsi");
+ var rsiSprite = new SpriteSpecifier.Rsi(rsiPath, texture);
+
+ var rsi = _cache.GetResource(rsiSprite.RsiPath).RSI;
+ if (!rsi.TryGetState(rsiSprite.RsiState, out var state))
+ {
+ rsiSprite = new SpriteSpecifier.Rsi(rsiPath, "unknown");
+ }
+
+ return _spriteSystem.Frame0(rsiSprite);
+ }
+
+ private static Label CreateDiagnosticItemLabel(string text)
+ {
+ return new Label
+ {
+ Text = text,
+ };
+ }
+
+ private BoxContainer CreateDiagnosticGroupTitle(string text, string id)
+ {
+ var rootContainer = new BoxContainer
+ {
+ Margin = new Thickness(0, 6, 0, 0),
+ VerticalAlignment = VAlignment.Bottom,
+ Orientation = LayoutOrientation.Horizontal,
+ };
+
+ rootContainer.AddChild(new TextureRect
+ {
+ SetSize = new Vector2(30, 30),
+ Texture = GetTexture(id.ToLower())
+ });
+
+ rootContainer.AddChild(CreateDiagnosticItemLabel(text));
+
+ return rootContainer;
+ }
+}
diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml
index aae8785b1f..932592ed37 100644
--- a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml
+++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml
@@ -1,64 +1,15 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
diff --git a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs
index 533a8b9f2c..6c0ed360b0 100644
--- a/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs
+++ b/Content.Client/HealthAnalyzer/UI/HealthAnalyzerWindow.xaml.cs
@@ -1,241 +1,20 @@
-using System.Linq;
-using System.Numerics;
-using Content.Shared.Atmos;
using Content.Client.UserInterface.Controls;
-using Content.Shared.Damage.Components;
-using Content.Shared.Damage.Prototypes;
-using Content.Shared.FixedPoint;
-using Content.Shared.Humanoid;
-using Content.Shared.Humanoid.Prototypes;
-using Content.Shared.IdentityManagement;
using Content.Shared.MedicalScanner;
-using Content.Shared.Mobs;
-using Content.Shared.Mobs.Components;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.XAML;
-using Robust.Client.GameObjects;
-using Robust.Client.Graphics;
-using Robust.Client.UserInterface.Controls;
-using Robust.Client.ResourceManagement;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Utility;
-namespace Content.Client.HealthAnalyzer.UI
+namespace Content.Client.HealthAnalyzer.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class HealthAnalyzerWindow : FancyWindow
{
- [GenerateTypedNameReferences]
- public sealed partial class HealthAnalyzerWindow : FancyWindow
+ public HealthAnalyzerWindow()
{
- private readonly IEntityManager _entityManager;
- private readonly SpriteSystem _spriteSystem;
- private readonly IPrototypeManager _prototypes;
- private readonly IResourceCache _cache;
-
- public HealthAnalyzerWindow()
- {
- RobustXamlLoader.Load(this);
-
- var dependencies = IoCManager.Instance!;
- _entityManager = dependencies.Resolve();
- _spriteSystem = _entityManager.System();
- _prototypes = dependencies.Resolve();
- _cache = dependencies.Resolve();
- }
-
- public void Populate(HealthAnalyzerScannedUserMessage msg)
- {
- var target = _entityManager.GetEntity(msg.TargetEntity);
-
- if (target == null
- || !_entityManager.TryGetComponent(target, out var damageable))
- {
- NoPatientDataText.Visible = true;
- return;
- }
-
- NoPatientDataText.Visible = false;
-
- // Scan Mode
-
- ScanModeLabel.Text = msg.ScanMode.HasValue
- ? msg.ScanMode.Value
- ? Loc.GetString("health-analyzer-window-scan-mode-active")
- : Loc.GetString("health-analyzer-window-scan-mode-inactive")
- : Loc.GetString("health-analyzer-window-entity-unknown-text");
-
- ScanModeLabel.FontColorOverride = msg.ScanMode.HasValue && msg.ScanMode.Value ? Color.Green : Color.Red;
-
- // Patient Information
-
- SpriteView.SetEntity(target.Value);
- SpriteView.Visible = msg.ScanMode.HasValue && msg.ScanMode.Value;
- NoDataTex.Visible = !SpriteView.Visible;
-
- var name = new FormattedMessage();
- name.PushColor(Color.White);
- name.AddText(_entityManager.HasComponent(target.Value)
- ? Identity.Name(target.Value, _entityManager)
- : Loc.GetString("health-analyzer-window-entity-unknown-text"));
- NameLabel.SetMessage(name);
-
- SpeciesLabel.Text =
- _entityManager.TryGetComponent(target.Value,
- out var humanoidAppearanceComponent)
- ? Loc.GetString(_prototypes.Index(humanoidAppearanceComponent.Species).Name)
- : Loc.GetString("health-analyzer-window-entity-unknown-species-text");
-
- // Basic Diagnostic
-
- TemperatureLabel.Text = !float.IsNaN(msg.Temperature)
- ? $"{msg.Temperature - Atmospherics.T0C:F1} °C ({msg.Temperature:F1} K)"
- : Loc.GetString("health-analyzer-window-entity-unknown-value-text");
-
- BloodLabel.Text = !float.IsNaN(msg.BloodLevel)
- ? $"{msg.BloodLevel * 100:F1} %"
- : Loc.GetString("health-analyzer-window-entity-unknown-value-text");
-
- StatusLabel.Text =
- _entityManager.TryGetComponent(target.Value, out var mobStateComponent)
- ? GetStatus(mobStateComponent.CurrentState)
- : Loc.GetString("health-analyzer-window-entity-unknown-text");
-
- // Total Damage
-
- DamageLabel.Text = damageable.TotalDamage.ToString();
-
- // Alerts
-
- var showAlerts = msg.Unrevivable == true || msg.Bleeding == true;
-
- AlertsDivider.Visible = showAlerts;
- AlertsContainer.Visible = showAlerts;
-
- if (showAlerts)
- AlertsContainer.RemoveAllChildren();
-
- if (msg.Unrevivable == true)
- AlertsContainer.AddChild(new RichTextLabel
- {
- Text = Loc.GetString("health-analyzer-window-entity-unrevivable-text"),
- Margin = new Thickness(0, 4),
- MaxWidth = 300
- });
-
- if (msg.Bleeding == true)
- AlertsContainer.AddChild(new RichTextLabel
- {
- Text = Loc.GetString("health-analyzer-window-entity-bleeding-text"),
- Margin = new Thickness(0, 4),
- MaxWidth = 300
- });
-
- // Damage Groups
-
- var damageSortedGroups =
- damageable.DamagePerGroup.OrderByDescending(damage => damage.Value)
- .ToDictionary(x => x.Key, x => x.Value);
-
- IReadOnlyDictionary damagePerType = damageable.Damage.DamageDict;
-
- DrawDiagnosticGroups(damageSortedGroups, damagePerType);
- }
-
- private static string GetStatus(MobState mobState)
- {
- return mobState switch
- {
- MobState.Alive => Loc.GetString("health-analyzer-window-entity-alive-text"),
- MobState.Critical => Loc.GetString("health-analyzer-window-entity-critical-text"),
- MobState.Dead => Loc.GetString("health-analyzer-window-entity-dead-text"),
- _ => Loc.GetString("health-analyzer-window-entity-unknown-text"),
- };
- }
-
- private void DrawDiagnosticGroups(
- Dictionary groups,
- IReadOnlyDictionary damageDict)
- {
- GroupsContainer.RemoveAllChildren();
-
- foreach (var (damageGroupId, damageAmount) in groups)
- {
- if (damageAmount == 0)
- continue;
-
- var groupTitleText = $"{Loc.GetString(
- "health-analyzer-window-damage-group-text",
- ("damageGroup", _prototypes.Index(damageGroupId).LocalizedName),
- ("amount", damageAmount)
- )}";
-
- var groupContainer = new BoxContainer
- {
- Align = BoxContainer.AlignMode.Begin,
- Orientation = BoxContainer.LayoutOrientation.Vertical,
- };
-
- groupContainer.AddChild(CreateDiagnosticGroupTitle(groupTitleText, damageGroupId));
-
- GroupsContainer.AddChild(groupContainer);
-
- // Show the damage for each type in that group.
- var group = _prototypes.Index(damageGroupId);
-
- foreach (var type in group.DamageTypes)
- {
- if (!damageDict.TryGetValue(type, out var typeAmount) || typeAmount <= 0)
- continue;
-
- var damageString = Loc.GetString(
- "health-analyzer-window-damage-type-text",
- ("damageType", _prototypes.Index(type).LocalizedName),
- ("amount", typeAmount)
- );
-
- groupContainer.AddChild(CreateDiagnosticItemLabel(damageString.Insert(0, " · ")));
- }
- }
- }
-
- private Texture GetTexture(string texture)
- {
- var rsiPath = new ResPath("/Textures/Objects/Devices/health_analyzer.rsi");
- var rsiSprite = new SpriteSpecifier.Rsi(rsiPath, texture);
-
- var rsi = _cache.GetResource(rsiSprite.RsiPath).RSI;
- if (!rsi.TryGetState(rsiSprite.RsiState, out var state))
- {
- rsiSprite = new SpriteSpecifier.Rsi(rsiPath, "unknown");
- }
-
- return _spriteSystem.Frame0(rsiSprite);
- }
-
- private static Label CreateDiagnosticItemLabel(string text)
- {
- return new Label
- {
- Text = text,
- };
- }
-
- private BoxContainer CreateDiagnosticGroupTitle(string text, string id)
- {
- var rootContainer = new BoxContainer
- {
- Margin = new Thickness(0, 6, 0, 0),
- VerticalAlignment = VAlignment.Bottom,
- Orientation = BoxContainer.LayoutOrientation.Horizontal,
- };
-
- rootContainer.AddChild(new TextureRect
- {
- SetSize = new Vector2(30, 30),
- Texture = GetTexture(id.ToLower())
- });
-
- rootContainer.AddChild(CreateDiagnosticItemLabel(text));
+ RobustXamlLoader.Load(this);
+ }
- return rootContainer;
- }
+ public void Populate(HealthAnalyzerScannedUserMessage msg)
+ {
+ HealthAnalyzer.Populate(msg.State);
}
}
diff --git a/Content.Client/Medical/Cryogenics/BeakerBarChart.cs b/Content.Client/Medical/Cryogenics/BeakerBarChart.cs
new file mode 100644
index 0000000000..25301b5268
--- /dev/null
+++ b/Content.Client/Medical/Cryogenics/BeakerBarChart.cs
@@ -0,0 +1,285 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Numerics;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+// ReSharper disable CompareOfFloatsByEqualityOperator
+
+namespace Content.Client.Medical.Cryogenics;
+
+
+public sealed class BeakerBarChart : Control
+{
+ private sealed class Entry
+ {
+ public float WidthFraction; // This entry's width as a fraction of the chart's total width (between 0 and 1)
+ public float TargetAmount;
+ public string Uid; // This UID is used to track entries between frames, for animation.
+ public string? Tooltip;
+ public Color Color;
+ public Label Label;
+
+ public Entry(string uid, Label label)
+ {
+ Uid = uid;
+ Label = label;
+ }
+ }
+
+ public float Capacity = 50;
+
+ public Color NotchColor = new(1, 1, 1, 0.25f);
+ public Color BackgroundColor = new(0.1f, 0.1f, 0.1f);
+
+ public int MediumNotchInterval = 5;
+ public int BigNotchInterval = 10;
+
+ // When we have a very large beaker (i.e. bluespace beaker) we might need to increase the distance between notches.
+ // The distance between notches is increased by ScaleMultiplier when the distance between notches is less than
+ // MinSmallNotchScreenDistance in UI units.
+ public int MinSmallNotchScreenDistance = 2;
+ public int ScaleMultiplier = 10;
+
+ public float SmallNotchHeight = 0.1f;
+ public float MediumNotchHeight = 0.25f;
+ public float BigNotchHeight = 1f;
+
+ // We don't animate new entries until this control has been drawn at least once.
+ private bool _hasBeenDrawn = false;
+
+ // This is used to keep the segments of the chart in the same order as the SetEntry calls.
+ // For example: In update 1 we might get cryox, alox, bic (in that order), and in update 2 we get alox, cryox, bic.
+ // To keep the order of the entries the same as the order of the SetEntry calls, we let the old cryox entry
+ // disappear and create a new cryox entry behind the alox entry.
+ private int _nextUpdateableEntry = 0;
+
+ private readonly List _entries = new();
+
+
+ public BeakerBarChart()
+ {
+ MouseFilter = MouseFilterMode.Pass;
+ TooltipSupplier = SupplyTooltip;
+ }
+
+ public void Clear()
+ {
+ foreach (var entry in _entries)
+ {
+ entry.TargetAmount = 0;
+ }
+
+ _nextUpdateableEntry = 0;
+ }
+
+ ///
+ /// Either adds a new entry to the chart if the UID doesn't appear yet, or updates the amount of an existing entry.
+ ///
+ public void SetEntry(
+ string uid,
+ string label,
+ float amount,
+ Color color,
+ Color? textColor = null,
+ string? tooltip = null)
+ {
+ // If we can find an old entry we're allowed to update, update that one.
+ if (TryFindUpdateableEntry(uid, out var index))
+ {
+ _entries[index].TargetAmount = amount;
+ _entries[index].Tooltip = tooltip;
+ _entries[index].Label.Text = label;
+ _nextUpdateableEntry = index + 1;
+ return;
+ }
+
+ // Otherwise create a new entry.
+ if (amount <= 0)
+ return;
+
+ // If no text color is provided, use either white or black depending on how dark the background is.
+ textColor ??= (color.R + color.G + color.B < 1.5f ? Color.White : Color.Black);
+
+ var childLabel = new Label
+ {
+ Text = label,
+ ClipText = true,
+ FontColorOverride = textColor,
+ Margin = new Thickness(4, 0, 0, 0)
+ };
+ AddChild(childLabel);
+
+ _entries.Insert(
+ _nextUpdateableEntry,
+ new Entry(uid, childLabel)
+ {
+ WidthFraction = (_hasBeenDrawn ? 0 : amount / Capacity),
+ TargetAmount = amount,
+ Tooltip = tooltip,
+ Color = color
+ }
+ );
+
+ _nextUpdateableEntry += 1;
+ }
+
+ private bool TryFindUpdateableEntry(string uid, out int index)
+ {
+ for (int i = _nextUpdateableEntry; i < _entries.Count; i++)
+ {
+ if (_entries[i].Uid == uid)
+ {
+ index = i;
+ return true;
+ }
+ }
+
+ index = -1;
+ return false;
+ }
+
+ private IEnumerable<(Entry, float xMin, float xMax)> EntryRanges(float? pixelWidth = null)
+ {
+ float chartWidth = pixelWidth ?? PixelWidth;
+ var xStart = 0f;
+
+ foreach (var entry in _entries)
+ {
+ var entryWidth = entry.WidthFraction * chartWidth;
+ var xEnd = MathF.Min(xStart + entryWidth, chartWidth);
+
+ yield return (entry, xStart, xEnd);
+
+ xStart = xEnd;
+ }
+ }
+
+ private bool TryFindEntry(float x, [NotNullWhen(true)] out Entry? entry)
+ {
+ foreach (var (currentEntry, xMin, xMax) in EntryRanges())
+ {
+ if (xMin <= x && x < xMax)
+ {
+ entry = currentEntry;
+ return true;
+ }
+ }
+
+ entry = null;
+ return false;
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ // Tween the amounts to their target amounts.
+ const float tweenInverseHalfLife = 8; // Half life of tween is 1/n
+ var hasChanged = false;
+
+ foreach (var entry in _entries)
+ {
+ var targetWidthFraction = entry.TargetAmount / Capacity;
+
+ if (entry.WidthFraction == targetWidthFraction)
+ continue;
+
+ // Tween with lerp abuse interpolation
+ entry.WidthFraction = MathHelper.Lerp(
+ entry.WidthFraction,
+ targetWidthFraction,
+ MathHelper.Clamp01(tweenInverseHalfLife * args.DeltaSeconds)
+ );
+ hasChanged = true;
+
+ if (MathF.Abs(entry.WidthFraction - targetWidthFraction) < 0.0001f)
+ entry.WidthFraction = targetWidthFraction;
+ }
+
+ if (!hasChanged)
+ return;
+
+ InvalidateArrange();
+
+ // Remove old entries whose animations have finished.
+ foreach (var entry in _entries)
+ {
+ if (entry.WidthFraction == 0 && entry.TargetAmount == 0)
+ RemoveChild(entry.Label);
+ }
+
+ _entries.RemoveAll(entry => entry.WidthFraction == 0 && entry.TargetAmount == 0);
+ }
+
+ protected override void MouseMove(GUIMouseMoveEventArgs args)
+ {
+ HideTooltip();
+ }
+
+ protected override void Draw(DrawingHandleScreen handle)
+ {
+ handle.DrawRect(PixelSizeBox, BackgroundColor);
+
+ // Draw the entry backgrounds
+ foreach (var (entry, xMin, xMax) in EntryRanges())
+ {
+ if (xMin != xMax)
+ handle.DrawRect(new(xMin, 0, xMax, PixelHeight), entry.Color);
+ }
+
+ // Draw notches
+ var unitWidth = PixelWidth / Capacity;
+ var unitsPerNotch = 1;
+
+ while (unitWidth < MinSmallNotchScreenDistance)
+ {
+ // This is here for 1000u bluespace beakers. If the distance between small notches is so small that it would
+ // be very ugly, we reduce the amount of notches by ScaleMultiplier (currently a factor of 10).
+ // (I could use an analytical algorithm here, but it would be more difficult to read with pretty much no
+ // performance benefit, since it loops zero times normally and one time for the bluespace beaker)
+ unitWidth *= ScaleMultiplier;
+ unitsPerNotch *= ScaleMultiplier;
+ }
+
+ for (int i = 0; i <= Capacity / unitsPerNotch; i++)
+ {
+ var x = i * unitWidth;
+ var height = (i % BigNotchInterval == 0 ? BigNotchHeight :
+ i % MediumNotchInterval == 0 ? MediumNotchHeight :
+ SmallNotchHeight) * PixelHeight;
+ var start = new Vector2(x, PixelHeight);
+ var end = new Vector2(x, PixelHeight - height);
+ handle.DrawLine(start, end, NotchColor);
+ }
+
+ _hasBeenDrawn = true;
+ }
+
+ protected override Vector2 ArrangeOverride(Vector2 finalSize)
+ {
+ foreach (var (entry, xMin, xMax) in EntryRanges(finalSize.X))
+ {
+ entry.Label.Arrange(new((int)xMin, 0, (int)xMax, (int)finalSize.Y));
+ }
+
+ return finalSize;
+ }
+
+ private Control? SupplyTooltip(Control sender)
+ {
+ var globalMousePos = UserInterfaceManager.MousePositionScaled.Position;
+ var mousePos = globalMousePos - GlobalPosition;
+
+ if (!TryFindEntry(mousePos.X, out var entry) || entry.Tooltip == null)
+ return null;
+
+ var msg = new FormattedMessage();
+ msg.AddText(entry.Tooltip);
+
+ var tooltip = new Tooltip();
+ tooltip.SetMessage(msg);
+ return tooltip;
+ }
+}
diff --git a/Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs b/Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs
new file mode 100644
index 0000000000..5e64cea720
--- /dev/null
+++ b/Content.Client/Medical/Cryogenics/CryoPodBoundUserInterface.cs
@@ -0,0 +1,53 @@
+using Content.Shared.FixedPoint;
+using Content.Shared.Medical.Cryogenics;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface;
+namespace Content.Client.Medical.Cryogenics;
+
+[UsedImplicitly]
+public sealed class CryoPodBoundUserInterface : BoundUserInterface
+{
+ private CryoPodWindow? _window;
+
+ public CryoPodBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+ _window = this.CreateWindowCenteredLeft();
+ _window.Title = EntMan.GetComponent(Owner).EntityName;
+ _window.OnEjectPatientPressed += EjectPatientPressed;
+ _window.OnEjectBeakerPressed += EjectBeakerPressed;
+ _window.OnInjectPressed += InjectPressed;
+ }
+
+ private void EjectPatientPressed()
+ {
+ var isLocked =
+ EntMan.TryGetComponent(Owner, out var cryoComp)
+ && cryoComp.Locked;
+
+ _window?.SetEjectErrorVisible(isLocked);
+ SendMessage(new CryoPodSimpleUiMessage(CryoPodSimpleUiMessage.MessageType.EjectPatient));
+ }
+
+ private void EjectBeakerPressed()
+ {
+ SendMessage(new CryoPodSimpleUiMessage(CryoPodSimpleUiMessage.MessageType.EjectBeaker));
+ }
+
+ private void InjectPressed(FixedPoint2 transferAmount)
+ {
+ SendMessage(new CryoPodInjectUiMessage(transferAmount));
+ }
+
+ protected override void ReceiveMessage(BoundUserInterfaceMessage message)
+ {
+ if (_window != null && message is CryoPodUserMessage cryoMsg)
+ {
+ _window.Populate(cryoMsg);
+ }
+ }
+}
diff --git a/Content.Client/Medical/Cryogenics/CryoPodSystem.cs b/Content.Client/Medical/Cryogenics/CryoPodSystem.cs
index c1cbfc573e..63c95a63d8 100644
--- a/Content.Client/Medical/Cryogenics/CryoPodSystem.cs
+++ b/Content.Client/Medical/Cryogenics/CryoPodSystem.cs
@@ -6,7 +6,6 @@ namespace Content.Client.Medical.Cryogenics;
public sealed class CryoPodSystem : SharedCryoPodSystem
{
- [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly SpriteSystem _sprite = default!;
public override void Initialize()
@@ -46,8 +45,8 @@ public sealed class CryoPodSystem : SharedCryoPodSystem
return;
}
- if (!_appearance.TryGetData(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component)
- || !_appearance.TryGetData(uid, CryoPodVisuals.IsOn, out var isOn, args.Component))
+ if (!Appearance.TryGetData(uid, CryoPodVisuals.ContainsEntity, out var isOpen, args.Component)
+ || !Appearance.TryGetData(uid, CryoPodVisuals.IsOn, out var isOn, args.Component))
{
return;
}
@@ -64,6 +63,11 @@ public sealed class CryoPodSystem : SharedCryoPodSystem
_sprite.LayerSetVisible((uid, args.Sprite), CryoPodVisualLayers.Cover, true);
}
}
+
+ protected override void UpdateUi(Entity cryoPod)
+ {
+ // Atmos and health scanner aren't predicted currently...
+ }
}
public enum CryoPodVisualLayers : byte
diff --git a/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml
new file mode 100644
index 0000000000..9bea37d582
--- /dev/null
+++ b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml
@@ -0,0 +1,232 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs
new file mode 100644
index 0000000000..ad5ab9d9ea
--- /dev/null
+++ b/Content.Client/Medical/Cryogenics/CryoPodWindow.xaml.cs
@@ -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? 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(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(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(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;
+ }
+}
diff --git a/Content.Server/Atmos/EntitySystems/GasAnalyzerSystem.cs b/Content.Server/Atmos/EntitySystems/GasAnalyzerSystem.cs
index 3cbe6575bf..a3fe2400d0 100644
--- a/Content.Server/Atmos/EntitySystems/GasAnalyzerSystem.cs
+++ b/Content.Server/Atmos/EntitySystems/GasAnalyzerSystem.cs
@@ -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;
}
+ ///
+ /// Generates a GasMixEntry for a given GasMixture
+ ///
+ 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)
+ );
+ }
+
///
/// Generates a GasEntry array for a given GasMixture
///
diff --git a/Content.Server/Medical/CryoPodSystem.cs b/Content.Server/Medical/CryoPodSystem.cs
index 8dab21902d..e54f80bca9 100644
--- a/Content.Server/Medical/CryoPodSystem.cs
+++ b/Content.Server/Medical/CryoPodSystem.cs
@@ -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(OnActivateUI);
SubscribeLocalEvent(OnCryoPodUpdateAtmosphere);
SubscribeLocalEvent(OnGasAnalyzed);
- SubscribeLocalEvent(OnEjected);
}
- private void OnActivateUI(Entity entity, ref AfterActivatableUIOpenEvent args)
+ public override void Update(float frameTime)
{
- if (!entity.Comp.BodyContainer.ContainedEntity.HasValue)
- return;
+ base.Update(frameTime);
- TryComp(entity.Comp.BodyContainer.ContainedEntity, out var temp);
- TryComp(entity.Comp.BodyContainer.ContainedEntity, out var bloodstream);
+ var query = EntityQueryEnumerator();
- if (TryComp(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 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 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 cryoPod, ref EntRemovedFromContainerMessage args)
- {
- if (TryComp(cryoPod.Owner, out var healthAnalyzer))
- {
- healthAnalyzer.ScannedEntity = null;
- }
-
- // if body is ejected - no need to display health-analyzer
- _uiSystem.CloseUi(cryoPod.Owner, HealthAnalyzerUiKey.Key);
- }
}
diff --git a/Content.Server/Medical/HealthAnalyzerSystem.cs b/Content.Server/Medical/HealthAnalyzerSystem.cs
index b7f48cade4..10da837141 100644
--- a/Content.Server/Medical/HealthAnalyzerSystem.cs
+++ b/Content.Server/Medical/HealthAnalyzerSystem.cs
@@ -187,39 +187,58 @@ public sealed class HealthAnalyzerSystem : EntitySystem
/// True makes the UI show ACTIVE, False makes the UI show INACTIVE
public void UpdateScannedUser(EntityUid healthAnalyzer, EntityUid target, bool scanMode)
{
- if (!_uiSystem.HasUi(healthAnalyzer, HealthAnalyzerUiKey.Key))
+ if (!_uiSystem.HasUi(healthAnalyzer, HealthAnalyzerUiKey.Key)
+ || !HasComp(target))
return;
- if (!HasComp(target))
- return;
+ var uiState = GetHealthAnalyzerUiState(target);
+ uiState.ScanMode = scanMode;
+
+ _uiSystem.ServerSendUiMessage(
+ healthAnalyzer,
+ HealthAnalyzerUiKey.Key,
+ new HealthAnalyzerScannedUserMessage(uiState)
+ );
+ }
+
+ ///
+ /// Creates a HealthAnalyzerState based on the current state of an entity.
+ ///
+ /// The entity being scanned
+ ///
+ public HealthAnalyzerUiState GetHealthAnalyzerUiState(EntityUid? target)
+ {
+ if (!target.HasValue || !HasComp(target))
+ return new HealthAnalyzerUiState();
+ var entity = target.Value;
var bodyTemperature = float.NaN;
- if (TryComp(target, out var temp))
+ if (TryComp(entity, out var temp))
bodyTemperature = temp.CurrentTemperature;
var bloodAmount = float.NaN;
var bleeding = false;
var unrevivable = false;
- if (TryComp(target, out var bloodstream) &&
- _solutionContainerSystem.ResolveSolution(target, bloodstream.BloodSolutionName,
+ if (TryComp(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(target, out var unrevivableComp) && unrevivableComp.Analyzable)
+ if (TryComp(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
- ));
+ );
}
}
diff --git a/Content.Shared/Medical/Cryogenics/CryoPodComponent.cs b/Content.Shared/Medical/Cryogenics/CryoPodComponent.cs
index ed9e9cb904..d75d22f60d 100644
--- a/Content.Shared/Medical/Cryogenics/CryoPodComponent.cs
+++ b/Content.Shared/Medical/Cryogenics/CryoPodComponent.cs
@@ -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;
///
@@ -21,6 +23,11 @@ public sealed partial class CryoPodComponent : Component
///
public const string BodyContainerName = "scanner-body";
+ ///
+ /// The name of the solution container for the injection chamber.
+ ///
+ public const string InjectionBufferSolutionName = "injectionBuffer";
+
///
/// Specifies the name of the atmospherics port to draw gas from.
///
@@ -38,7 +45,7 @@ public sealed partial class CryoPodComponent : Component
/// (injection interval)
///
[DataField]
- public TimeSpan BeakerTransferTime = TimeSpan.FromSeconds(1);
+ public TimeSpan BeakerTransferTime = TimeSpan.FromSeconds(2);
///
/// The timestamp for the next injection.
@@ -47,6 +54,20 @@ public sealed partial class CryoPodComponent : Component
[AutoNetworkedField, AutoPausedField]
public TimeSpan NextInjectionTime = TimeSpan.Zero;
+
+ ///
+ /// How often the UI is updated.
+ ///
+ [DataField]
+ public TimeSpan UiUpdateInterval = TimeSpan.FromSeconds(1);
+
+ ///
+ /// The timestamp for the next UI update.
+ ///
+ [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
+ [AutoNetworkedField, AutoPausedField]
+ public TimeSpan NextUiUpdateTime = TimeSpan.Zero;
+
///
/// How many units to transfer per injection from the beaker to the mob?
///
@@ -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? Beaker;
+ public List? Injecting;
+
+ public CryoPodUserMessage(
+ GasAnalyzerComponent.GasMixEntry gasMix,
+ HealthAnalyzerUiState health,
+ FixedPoint2? beakerCapacity,
+ List? beaker,
+ List? 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;
+ }
+}
diff --git a/Content.Shared/Medical/Cryogenics/SharedCryoPodSystem.cs b/Content.Shared/Medical/Cryogenics/SharedCryoPodSystem.cs
index a7bbe39c56..fe9db18960 100644
--- a/Content.Shared/Medical/Cryogenics/SharedCryoPodSystem.cs
+++ b/Content.Shared/Medical/Cryogenics/SharedCryoPodSystem.cs
@@ -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 _bloodstreamQuery;
private EntityQuery _itemSlotsQuery;
private EntityQuery _dispenserQuery;
private EntityQuery _solutionContainerQuery;
+
public override void Initialize()
{
base.Initialize();
@@ -69,6 +74,8 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
SubscribeLocalEvent(OnInteractUsing);
SubscribeLocalEvent(OnPowerChanged);
SubscribeLocalEvent(OnActivateUIAttempt);
+ SubscribeLocalEvent(OnEjected);
+ SubscribeLocalEvent(OnBodyInserted);
_bloodstreamQuery = GetEntityQuery();
_itemSlotsQuery = GetEntityQuery();
@@ -76,13 +83,20 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
_solutionContainerQuery = GetEntityQuery();
InitializeInsideCryoPod();
+
+ Subs.BuiEvents(CryoPodUiKey.Key, subs =>
+ {
+ subs.Event(OnBoundUiOpened);
+ subs.Event(OnSimpleUiMessage);
+ subs.Event(OnInjectUiMessage);
+ });
}
public override void Update(float frameTime)
{
base.Update(frameTime);
- var curTime = _timing.CurTime;
+ var curTime = Timing.CurTime;
var query = EntityQueryEnumerator();
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 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(ent))
+ if (containedEntity == args.User)
args.Cancel();
}
@@ -179,13 +201,13 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
if (args.Powered)
{
EnsureComp(ent);
- ent.Comp.NextInjectionTime = _timing.CurTime + ent.Comp.BeakerTransferTime;
+ ent.Comp.NextInjectionTime = Timing.CurTime + ent.Comp.BeakerTransferTime;
Dirty(ent);
}
else
{
RemComp(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 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);
+ }
+ }
+
+ ///
+ /// Transfers reagents from the cryopod beaker into the injection buffer.
+ ///
+ /// The cryopod entity
+ ///
+ /// The amount of reagents that will be transferred.
+ /// If less reagents are available, however much is available will be transferred.
+ ///
+ public void TryInject(Entity 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 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? reagents) GetBeakerInfo(Entity 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? GetInjectingReagents(Entity 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 args)
{
if (!args.CanAccess || !args.CanInteract)
@@ -340,6 +471,55 @@ public abstract partial class SharedCryoPodSystem : EntitySystem
args.Handled = true;
}
+ private void OnSimpleUiMessage(Entity 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 cryoPod, ref CryoPodInjectUiMessage msg)
+ {
+ TryInject(cryoPod, msg.Quantity);
+ UpdateUi(cryoPod);
+ }
+
+ private void OnBoundUiOpened(Entity cryoPod, ref BoundUIOpenedEvent args)
+ {
+ UpdateUi(cryoPod);
+ }
+
+ private void OnEjected(Entity cryoPod, ref EntRemovedFromContainerMessage args)
+ {
+ if (args.Container.ID == CryoPodComponent.BodyContainerName)
+ {
+ ClearInjectionBuffer(cryoPod);
+ }
+
+ UpdateUi(cryoPod);
+ }
+
+ private void OnBodyInserted(Entity 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 cryoPod);
+
[Serializable, NetSerializable]
public sealed partial class CryoPodPryFinished : SimpleDoAfterEvent;
diff --git a/Content.Shared/MedicalScanner/HealthAnalyzerScannedUserMessage.cs b/Content.Shared/MedicalScanner/HealthAnalyzerScannedUserMessage.cs
index 08af1a36a7..65c50cc9a9 100644
--- a/Content.Shared/MedicalScanner/HealthAnalyzerScannedUserMessage.cs
+++ b/Content.Shared/MedicalScanner/HealthAnalyzerScannedUserMessage.cs
@@ -3,10 +3,24 @@ using Robust.Shared.Serialization;
namespace Content.Shared.MedicalScanner;
///
-/// 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.
///
[Serializable, NetSerializable]
public sealed class HealthAnalyzerScannedUserMessage : BoundUserInterfaceMessage
+{
+ public HealthAnalyzerUiState State;
+
+ public HealthAnalyzerScannedUserMessage(HealthAnalyzerUiState state)
+ {
+ State = state;
+ }
+}
+
+///
+/// Contains the current state of a health analyzer control. Used for the health analyzer and cryo pod.
+///
+[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;
}
}
-
diff --git a/Resources/Locale/en-US/medical/components/cryo-pod-component.ftl b/Resources/Locale/en-US/medical/components/cryo-pod-component.ftl
index 53ee8301de..077bc26663 100644
--- a/Resources/Locale/en-US/medical/components/cryo-pod-component.ftl
+++ b/Resources/Locale/en-US/medical/components/cryo-pod-component.ftl
@@ -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
+
+
diff --git a/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml b/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml
index 4d50a8b6ed..af948741a1 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/Medical/cryo_pod.yml
@@ -87,16 +87,14 @@
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
@@ -107,6 +105,10 @@
- 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
--
2.52.0