Files
tbd-station-14/Content.Client/Power/Battery/BatteryMenu.xaml.cs
Pieter-Jan Briers ffe130b38d Battery (SMES/substation) interface (#36386)
* Add ENERGYWATTHOURS() loc function

Takes in joules (energy), displays as watt-hours.

* Add simple OnOffButton control

* Re-add Inset style class

This was sloppily removed at some point?? Whatever, I need it.

* Add helper functions for setting title/guidebook IDs on FancyWindow

Reagent dispenser uses these, more in the next commits.

* Add BuiPredictionState helper

This enables me to implement coarse prediction manually in the battery UI.

Basically it's a local buffer of predicted inputs that can easily be replayed against future BUI states from the server.

* Add input coalescing infrastructure

I ran into the following problem: Robust's Slider control absolutely *spams* input events, to such a degree that it actually causes issues for the networking layer if directly passed through. For something like a slider, we just need to send the most recent value.

There is no good way for us to handle this in the control itself, as it *really* needs to happen in PreEngine. For simplicity reasons (for BUIs) I came to the conclusion it's best if it's there, as it's *before* any new states from the server can be applied. We can't just do this in Update() or something on the control as the timing just doesn't line up.

I made a content system, BuiPreTickUpdateSystem, that runs in the ModRunLevel.PreEngine phase to achieve this. It runs a method on a new IBuiPreTickUpdate interface on all open BUIs. They can then implement their own coalescing logic.

In the simplest case, this coalescing logic can just be "save the last value, and if we have any new value since the last update, send an input event." This is what the new InputCoalescer<T> type is for.

Adding new coalescing logic should be possible in the future, of course. It's all just small helpers.

* Battery interface

This adds a proper interface to batteries (SMES/substation). Players can turn IO on and off, and they can change charge and discharge rate. There's also a ton of numbers and stuff. It looks great.

This actually enables charge and discharge rates to be changed for these devices. The settings for both have been set between 5kW and 150kW.

* Oops, forgot to remove these style class defs.
2025-04-27 21:08:34 +10:00

281 lines
10 KiB
C#

using System.Diagnostics.CodeAnalysis;
using Content.Client.Stylesheets;
using Content.Client.UserInterface.Controls;
using Content.Shared.Power;
using Content.Shared.Rounding;
using Robust.Client.AutoGenerated;
using Robust.Client.Graphics;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Timing;
namespace Content.Client.Power.Battery;
/// <summary>
/// Interface control for batteries.
/// </summary>
/// <seealso cref="BatteryBoundUserInterface"/>
[GenerateTypedNameReferences]
public sealed partial class BatteryMenu : FancyWindow
{
// Cutoff for the ETA time to switch from "~" to ">" and cap out.
private const float MaxEtaValueMinutes = 60;
// Cutoff where ETA times likely don't make sense and it's better to just say "N/A".
private const float NotApplicableEtaHighCutoffMinutes = 1000;
private const float NotApplicableEtaLowCutoffMinutes = 0.01f;
// Fudge factor to ignore small charge/discharge values, that are likely caused by floating point rounding errors.
private const float PrecisionRoundFactor = 100_000;
// Colors used for the storage cell bar graphic.
private static readonly Color[] StorageColors =
[
StyleNano.DangerousRedFore,
Color.FromHex("#C49438"),
Color.FromHex("#B3BF28"),
StyleNano.GoodGreenFore,
];
// StorageColors but dimmed for "off" bars.
private static readonly Color[] DimStorageColors =
[
DimStorageColor(StorageColors[0]),
DimStorageColor(StorageColors[1]),
DimStorageColor(StorageColors[2]),
DimStorageColor(StorageColors[3]),
];
// Parameters for the sine wave pulsing animations for active power lines in the UI.
private static readonly Color ActivePowerLineHighColor = Color.FromHex("#CCC");
private static readonly Color ActivePowerLineLowColor = Color.FromHex("#888");
private const float PowerPulseFactor = 4;
// Dependencies
[Dependency] private readonly IEntityManager _entityManager = null!;
[Dependency] private readonly ILocalizationManager _loc = null!;
// Active and inactive style boxes for power lines.
// We modify _activePowerLineStyleBox's properties programmatically to implement the pulsing animation.
private readonly StyleBoxFlat _activePowerLineStyleBox = new();
private readonly StyleBoxFlat _inactivePowerLineStyleBox = new() { BackgroundColor = Color.FromHex("#555") };
// Style boxes for the storage cell bar graphic.
// We modify the properties of these to change the bars' colors.
private StyleBoxFlat[] _chargeMeterBoxes;
// State for the powerline pulsing animation.
private float _powerPulseValue;
// State for the storage cell bar graphic and its blinking effect.
private float _blinkPulseValue;
private bool _blinkPulse;
private int _storageLevel;
private bool _hasStorageDelta;
// The entity that this UI is for.
private EntityUid _entity;
// Used to avoid sending input events when updating slider values.
private bool _suppressSliderEvents;
// Events for the BUI to subscribe to.
public event Action<bool>? OnInBreaker;
public event Action<bool>? OnOutBreaker;
public event Action<float>? OnChargeRate;
public event Action<float>? OnDischargeRate;
public BatteryMenu()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
InitChargeMeter();
InBreaker.StateChanged += val => OnInBreaker?.Invoke(val);
OutBreaker.StateChanged += val => OnOutBreaker?.Invoke(val);
ChargeRateSlider.OnValueChanged += _ =>
{
if (!_suppressSliderEvents)
OnChargeRate?.Invoke(ChargeRateSlider.Value);
};
DischargeRateSlider.OnValueChanged += _ =>
{
if (!_suppressSliderEvents)
OnDischargeRate?.Invoke(DischargeRateSlider.Value);
};
}
public void SetEntity(EntityUid entity)
{
_entity = entity;
this.SetInfoFromEntity(_entityManager, _entity);
EntityView.SetEntity(entity);
}
[MemberNotNull(nameof(_chargeMeterBoxes))]
public void InitChargeMeter()
{
_chargeMeterBoxes = new StyleBoxFlat[StorageColors.Length];
for (var i = StorageColors.Length - 1; i >= 0; i--)
{
var styleBox = new StyleBoxFlat();
_chargeMeterBoxes[i] = styleBox;
for (var j = 0; j < ChargeMeter.Columns; j++)
{
var control = new PanelContainer
{
Margin = new Thickness(2),
PanelOverride = styleBox,
HorizontalExpand = true,
VerticalExpand = true,
};
ChargeMeter.AddChild(control);
}
}
}
public void Update(BatteryBuiState msg)
{
var inValue = msg.CurrentReceiving;
var outValue = msg.CurrentSupply;
var storageDelta = inValue - outValue;
// Mask rounding errors in power code.
if (Math.Abs(storageDelta) < msg.Capacity / PrecisionRoundFactor)
storageDelta = 0;
// Update power lines based on a ton of parameters.
SetPowerLineState(InPowerLine, msg.SupplyingNetworkHasPower);
SetPowerLineState(OutPowerLine, msg.LoadingNetworkHasPower);
SetPowerLineState(InSecondPowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge);
SetPowerLineState(ChargePowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge && storageDelta > 0);
SetPowerLineState(PassthroughPowerLine, msg.SupplyingNetworkHasPower && msg.CanCharge && msg.CanDischarge);
SetPowerLineState(OutSecondPowerLine,
msg.CanDischarge && (msg.Charge > 0 || msg.SupplyingNetworkHasPower && msg.CanCharge));
SetPowerLineState(DischargePowerLine, storageDelta < 0);
// Update breakers.
InBreaker.IsOn = msg.CanCharge;
OutBreaker.IsOn = msg.CanDischarge;
// Update various power values.
InValue.Text = FormatPower(inValue);
OutValue.Text = FormatPower(outValue);
PassthroughValue.Text = FormatPower(Math.Min(msg.CurrentReceiving, msg.CurrentSupply));
ChargeMaxValue.Text = FormatPower(msg.MaxChargeRate);
DischargeMaxValue.Text = FormatPower(msg.MaxSupply);
ChargeCurrentValue.Text = FormatPower(Math.Max(0, storageDelta));
DischargeCurrentValue.Text = FormatPower(Math.Max(0, -storageDelta));
// Update charge/discharge rate sliders.
_suppressSliderEvents = true;
ChargeRateSlider.MaxValue = msg.MaxMaxChargeRate;
ChargeRateSlider.MinValue = msg.MinMaxChargeRate;
ChargeRateSlider.Value = msg.MaxChargeRate;
DischargeRateSlider.MaxValue = msg.MaxMaxSupply;
DischargeRateSlider.MinValue = msg.MinMaxSupply;
DischargeRateSlider.Value = msg.MaxSupply;
_suppressSliderEvents = false;
// Update ETA display.
var storageEtaDiff = storageDelta > 0 ? (msg.Capacity - msg.Charge) * (1 / msg.Efficiency) : -msg.Charge;
var etaTimeSeconds = storageEtaDiff / storageDelta;
var etaTimeMinutes = etaTimeSeconds / 60.0;
EtaLabel.Text = _loc.GetString(
storageDelta > 0 ? "battery-menu-eta-full" : "battery-menu-eta-empty");
if (!double.IsFinite(etaTimeMinutes)
|| Math.Abs(etaTimeMinutes) > NotApplicableEtaHighCutoffMinutes
|| Math.Abs(etaTimeMinutes) < NotApplicableEtaLowCutoffMinutes)
{
EtaValue.Text = _loc.GetString("battery-menu-eta-value-na");
}
else
{
EtaValue.Text = _loc.GetString(
etaTimeMinutes > MaxEtaValueMinutes ? "battery-menu-eta-value-max" : "battery-menu-eta-value",
("minutes", Math.Min(Math.Ceiling(etaTimeMinutes), MaxEtaValueMinutes)));
}
// Update storage display.
StoredPercentageValue.Text = _loc.GetString(
"battery-menu-stored-percent-value",
("value", msg.Charge / msg.Capacity));
StoredEnergyValue.Text = _loc.GetString(
"battery-menu-stored-energy-value",
("value", msg.Charge));
// Update charge meter.
_storageLevel = ContentHelpers.RoundToNearestLevels(msg.Charge, msg.Capacity, _chargeMeterBoxes.Length);
_hasStorageDelta = Math.Abs(storageDelta) > 0;
}
private static Color DimStorageColor(Color color)
{
var hsv = Color.ToHsv(color);
hsv.Z /= 5;
return Color.FromHsv(hsv);
}
private void SetPowerLineState(PanelContainer control, bool value)
{
control.PanelOverride = value ? _activePowerLineStyleBox : _inactivePowerLineStyleBox;
}
private string FormatPower(float value)
{
return _loc.GetString("battery-menu-power-value", ("value", value));
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// Pulse power lines.
_powerPulseValue += args.DeltaSeconds * PowerPulseFactor;
var color = Color.InterpolateBetween(
ActivePowerLineLowColor,
ActivePowerLineHighColor,
MathF.Sin(_powerPulseValue) / 2 + 1);
_activePowerLineStyleBox.BackgroundColor = color;
// Update storage indicator and blink it.
for (var i = 0; i < _chargeMeterBoxes.Length; i++)
{
var box = _chargeMeterBoxes[i];
if (_storageLevel > i)
{
// On
box.BackgroundColor = StorageColors[i];
}
else
{
box.BackgroundColor = DimStorageColors[i];
}
}
_blinkPulseValue += args.DeltaSeconds;
if (_blinkPulseValue > 1)
{
_blinkPulseValue -= 1;
_blinkPulse ^= true;
}
// If there is a storage delta (charging or discharging), we want to blink the highest bar.
if (_hasStorageDelta)
{
// If there is no highest bar (UI completely at 0), then blink bar 0.
var toBlink = Math.Max(0, _storageLevel - 1);
_chargeMeterBoxes[toBlink].BackgroundColor =
_blinkPulse ? StorageColors[toBlink] : DimStorageColors[toBlink];
}
}
}