using Content.Client.Guidebook.Components;
+using Content.Client.UserInterface.Controls;
using Content.Shared.Chemistry;
using Content.Shared.Containers.ItemSlots;
using JetBrains.Annotations;
// Setup window layout/elements
_window = this.CreateWindow<ReagentDispenserWindow>();
- _window.Title = EntMan.GetComponent<MetaDataComponent>(Owner).EntityName;
- _window.HelpGuidebookIds = EntMan.GetComponent<GuideHelpComponent>(Owner).Guides;
+ _window.SetInfoFromEntity(EntMan, Owner);
// Setup static button actions.
_window.EjectButton.OnPressed += _ => SendMessage(new ItemSlotButtonPressedEvent(SharedReagentDispenser.OutputSlotName));
using Content.Client.Screenshot;
using Content.Client.Singularity;
using Content.Client.Stylesheets;
+using Content.Client.UserInterface;
using Content.Client.Viewport;
using Content.Client.Voting;
using Content.Shared.Ame.Components;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly DebugMonitorManager _debugMonitorManager = default!;
[Dependency] private readonly TitleWindowManager _titleWindowManager = default!;
+ [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
public override void Init()
{
{
_debugMonitorManager.FrameUpdate();
}
+
+ if (level == ModUpdateLevel.PreEngine)
+ {
+ if (_baseClient.RunLevel is ClientRunLevel.InGame or ClientRunLevel.SinglePlayerGame)
+ {
+ var updateSystem = _entitySystemManager.GetEntitySystem<BuiPreTickUpdateSystem>();
+ updateSystem.RunUpdates();
+ }
+ }
}
}
}
--- /dev/null
+using Content.Client.UserInterface;
+using Content.Shared.Power;
+using JetBrains.Annotations;
+using Robust.Client.Timing;
+using Robust.Client.UserInterface;
+
+namespace Content.Client.Power.Battery;
+
+/// <summary>
+/// BUI for <see cref="BatteryUiKey.Key"/>.
+/// </summary>
+/// <seealso cref="BoundUserInterfaceState"/>
+/// <seealso cref="BatteryMenu"/>
+[UsedImplicitly]
+public sealed class BatteryBoundUserInterface : BoundUserInterface, IBuiPreTickUpdate
+{
+ [Dependency] private readonly IClientGameTiming _gameTiming = null!;
+
+ [ViewVariables]
+ private BatteryMenu? _menu;
+
+ private BuiPredictionState? _pred;
+ private InputCoalescer<float> _chargeRateCoalescer;
+ private InputCoalescer<float> _dischargeRateCoalescer;
+
+ public BatteryBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ IoCManager.InjectDependencies(this);
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _pred = new BuiPredictionState(this, _gameTiming);
+
+ _menu = this.CreateWindow<BatteryMenu>();
+ _menu.SetEntity(Owner);
+
+ _menu.OnInBreaker += val => _pred!.SendMessage(new BatterySetInputBreakerMessage(val));
+ _menu.OnOutBreaker += val => _pred!.SendMessage(new BatterySetOutputBreakerMessage(val));
+
+ _menu.OnChargeRate += val => _chargeRateCoalescer.Set(val);
+ _menu.OnDischargeRate += val => _dischargeRateCoalescer.Set(val);
+ }
+
+ void IBuiPreTickUpdate.PreTickUpdate()
+ {
+ if (_chargeRateCoalescer.CheckIsModified(out var chargeRateValue))
+ _pred!.SendMessage(new BatterySetChargeRateMessage(chargeRateValue));
+
+ if (_dischargeRateCoalescer.CheckIsModified(out var dischargeRateValue))
+ _pred!.SendMessage(new BatterySetDischargeRateMessage(dischargeRateValue));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ if (state is not BatteryBuiState batteryState)
+ return;
+
+ foreach (var replayMsg in _pred!.MessagesToReplay())
+ {
+ switch (replayMsg)
+ {
+ case BatterySetInputBreakerMessage setInputBreaker:
+ batteryState.CanCharge = setInputBreaker.On;
+ break;
+
+ case BatterySetOutputBreakerMessage setOutputBreaker:
+ batteryState.CanDischarge = setOutputBreaker.On;
+ break;
+
+ case BatterySetChargeRateMessage setChargeRate:
+ batteryState.MaxChargeRate = setChargeRate.Rate;
+ break;
+
+ case BatterySetDischargeRateMessage setDischargeRate:
+ batteryState.MaxSupply = setDischargeRate.Rate;
+ break;
+ }
+ }
+
+ _menu?.Update(batteryState);
+ }
+}
--- /dev/null
+<controls:FancyWindow xmlns="https://spacestation14.io"
+ xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+ SetSize="650 330"
+ Resizable="False">
+ <BoxContainer Orientation="Vertical">
+ <!-- Top row: main content -->
+ <BoxContainer Name="MainContent" Orientation="Horizontal" VerticalExpand="True" Margin="4">
+ <!-- Left pane: I/O, passthrough, sprite view -->
+ <BoxContainer Name="IOPane" Orientation="Vertical" HorizontalExpand="True">
+ <!-- Top row: input -->
+ <BoxContainer Name="InputRow" Orientation="Horizontal">
+ <!-- Input power line -->
+ <PanelContainer Name="InPowerLine" SetHeight="2" VerticalAlignment="Top" SetWidth="32"
+ Margin="2 16" />
+
+ <!-- Box with breaker, label, values -->
+ <PanelContainer HorizontalExpand="True" StyleClasses="Inset">
+ <BoxContainer Orientation="Vertical">
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-in'}" HorizontalExpand="True" VerticalAlignment="Top"
+ StyleClasses="LabelKeyText" />
+ <controls:OnOffButton Name="InBreaker" />
+ </BoxContainer>
+ <Label Name="InValue" />
+ </BoxContainer>
+ </PanelContainer>
+ </BoxContainer>
+
+ <!-- Middle row: Entity view & passthrough -->
+ <BoxContainer Name="MiddleRow" Orientation="Horizontal" VerticalExpand="True">
+ <SpriteView Name="EntityView" SetSize="64 64" Scale="2 2" OverrideDirection="South" Margin="15" />
+
+ <BoxContainer Orientation="Vertical" VerticalAlignment="Center" HorizontalExpand="True"
+ HorizontalAlignment="Right">
+ <Label HorizontalAlignment="Right" Text="{Loc 'battery-menu-passthrough'}" StyleClasses="StatusFieldTitle" />
+ <Label HorizontalAlignment="Right" Name="PassthroughValue" />
+ </BoxContainer>
+ </BoxContainer>
+
+ <!-- Bottom row: output -->
+ <BoxContainer Name="OutputRow" Orientation="Horizontal">
+ <!-- Output power line -->
+ <PanelContainer Name="OutPowerLine" SetHeight="2" VerticalAlignment="Bottom" SetWidth="32"
+ Margin="2 16" />
+
+ <!-- Box with breaker, label, values -->
+ <PanelContainer HorizontalExpand="True" StyleClasses="Inset">
+ <BoxContainer Orientation="Vertical">
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-out'}" HorizontalExpand="True" VerticalAlignment="Top"
+ StyleClasses="LabelKeyText" />
+ <controls:OnOffButton Name="OutBreaker" />
+ </BoxContainer>
+ <Label Name="OutValue" />
+ </BoxContainer>
+ </PanelContainer>
+ </BoxContainer>
+ </BoxContainer>
+
+ <!-- Separator connecting panes with some wires -->
+ <BoxContainer Orientation="Vertical" SetWidth="22" Margin="2 16">
+ <PanelContainer Name="InSecondPowerLine" SetHeight="2" />
+ <PanelContainer Name="PassthroughPowerLine" SetWidth="2" HorizontalAlignment="Center" VerticalExpand="True" />
+ <PanelContainer Name="OutSecondPowerLine" SetHeight="2" />
+ </BoxContainer>
+
+ <!-- Middle pane: charge/discharge -->
+ <BoxContainer Name="ChargeDischarge" Orientation="Vertical" HorizontalExpand="True">
+ <!-- Charge -->
+ <PanelContainer VerticalExpand="True" StyleClasses="Inset" Margin="0 0 0 8">
+ <BoxContainer Orientation="Vertical">
+ <Label Text="{Loc 'battery-menu-charge-header'}" StyleClasses="LabelKeyText" />
+ <BoxContainer Orientation="Vertical" VerticalExpand="True" VerticalAlignment="Center">
+ <Slider Name="ChargeRateSlider" />
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-max'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
+ <Label Name="ChargeMaxValue" />
+ </BoxContainer>
+ </BoxContainer>
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-current'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
+ <Label Name="ChargeCurrentValue" />
+ </BoxContainer>
+ </BoxContainer>
+ </PanelContainer>
+ <!-- Discharge -->
+ <PanelContainer VerticalExpand="True" StyleClasses="Inset">
+ <BoxContainer Orientation="Vertical">
+ <Label Text="{Loc 'battery-menu-discharge-header'}" StyleClasses="LabelKeyText" />
+ <BoxContainer Orientation="Vertical" VerticalExpand="True" VerticalAlignment="Center">
+ <Slider Name="DischargeRateSlider" />
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-max'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
+ <Label Name="DischargeMaxValue" />
+ </BoxContainer>
+ </BoxContainer>
+ <BoxContainer Orientation="Horizontal">
+ <Label Text="{Loc 'battery-menu-current'}" StyleClasses="StatusFieldTitle" HorizontalExpand="True" />
+ <Label Name="DischargeCurrentValue" />
+ </BoxContainer>
+ </BoxContainer>
+ </PanelContainer>
+ </BoxContainer>
+
+ <!-- Separator connecting panes with some wires -->
+ <BoxContainer Orientation="Vertical" SetWidth="22" Margin="2 16">
+ <PanelContainer Name="ChargePowerLine" SetHeight="2" VerticalAlignment="Top" VerticalExpand="True" />
+ <PanelContainer Name="DischargePowerLine" SetHeight="2" VerticalAlignment="Bottom" VerticalExpand="True" />
+ </BoxContainer>
+
+ <!-- Right pane: storage -->
+ <PanelContainer Name="Storage" StyleClasses="Inset" HorizontalExpand="True">
+ <BoxContainer Orientation="Vertical">
+ <Label Text="{Loc 'battery-menu-storage-header'}" StyleClasses="LabelKeyText" />
+ <GridContainer Columns="2">
+ <Label Text="{Loc 'battery-menu-stored'}" StyleClasses="StatusFieldTitle" />
+ <Label Name="StoredPercentageValue" HorizontalAlignment="Right" HorizontalExpand="True" />
+ <Label Text="{Loc 'battery-menu-energy'}" StyleClasses="StatusFieldTitle" />
+ <Label Name="StoredEnergyValue" HorizontalAlignment="Right" />
+ <Label Name="EtaLabel" StyleClasses="StatusFieldTitle" />
+ <Label Name="EtaValue" HorizontalAlignment="Right" />
+ </GridContainer>
+
+ <!-- Charge meter -->
+ <GridContainer Name="ChargeMeter" Columns="3" VerticalExpand="True" Margin="0 24 0 0">
+
+ </GridContainer>
+ </BoxContainer>
+ </PanelContainer>
+ </BoxContainer>
+
+ <!-- Footer -->
+ <BoxContainer Name="Footer" Orientation="Vertical">
+ <PanelContainer StyleClasses="LowDivider" />
+ <BoxContainer Orientation="Horizontal" Margin="10 2 5 0" VerticalAlignment="Bottom">
+ <Label Text="{Loc 'battery-menu-footer-left'}" StyleClasses="WindowFooterText" />
+ <Label Text="{Loc 'battery-menu-footer-right'}" StyleClasses="WindowFooterText"
+ HorizontalAlignment="Right" HorizontalExpand="True" Margin="0 0 5 0" />
+ <TextureRect StyleClasses="NTLogoDark" Stretch="KeepAspectCentered"
+ VerticalAlignment="Center" HorizontalAlignment="Right" SetSize="19 19" />
+ </BoxContainer>
+ </BoxContainer>
+ </BoxContainer>
+</controls:FancyWindow>
--- /dev/null
+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];
+ }
+ }
+}
public const string StyleClassChatChannelSelectorButton = "chatSelectorOptionButton";
public const string StyleClassChatFilterOptionButton = "chatFilterOptionButton";
public const string StyleClassStorageButton = "storageButton";
+ public const string StyleClassInset = "Inset";
public const string StyleClassSliderRed = "Red";
public const string StyleClassSliderGreen = "Green";
new[]
{
new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png"))
- })
+ }),
+
+ Element<PanelContainer>()
+ .Class(StyleClassInset)
+ .Prop(PanelContainer.StylePropertyPanel, insetBack),
}).ToList());
}
}
--- /dev/null
+using Robust.Client.GameObjects;
+using Robust.Client.Player;
+using Robust.Shared.ContentPack;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Client.UserInterface;
+
+/// <summary>
+/// Interface for <see cref="BoundUserInterface"/>s that need some updating logic
+/// ran in the <see cref="ModUpdateLevel.PreEngine"/> stage.
+/// </summary>
+/// <remarks>
+/// <para>
+/// This is called on all open <see cref="BoundUserInterface"/>s that implement this interface.
+/// </para>
+/// <para>
+/// One intended use case is coalescing input events (e.g. via <see cref="InputCoalescer{T}"/>) to send them to the
+/// server only once per tick.
+/// </para>
+/// </remarks>
+/// <seealso cref="BuiPreTickUpdateSystem"/>
+public interface IBuiPreTickUpdate
+{
+ void PreTickUpdate();
+}
+
+/// <summary>
+/// Implements <see cref="BuiPreTickUpdateSystem"/>.
+/// </summary>
+public sealed class BuiPreTickUpdateSystem : EntitySystem
+{
+ [Dependency] private readonly IPlayerManager _playerManager = null!;
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = null!;
+ [Dependency] private readonly IGameTiming _gameTiming = null!;
+
+ private EntityQuery<UserInterfaceUserComponent> _userQuery;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _userQuery = GetEntityQuery<UserInterfaceUserComponent>();
+ }
+
+ public void RunUpdates()
+ {
+ if (!_gameTiming.IsFirstTimePredicted)
+ return;
+
+ var localSession = _playerManager.LocalSession;
+ if (localSession?.AttachedEntity is not { } localEntity)
+ return;
+
+ if (!_userQuery.TryGetComponent(localEntity, out var userUIComp))
+ return;
+
+ foreach (var (entity, uis) in userUIComp.OpenInterfaces)
+ {
+ foreach (var key in uis)
+ {
+ if (!_uiSystem.TryGetOpenUi(entity, key, out var ui))
+ {
+ DebugTools.Assert("Unable to find UI that was in the open UIs list??");
+ continue;
+ }
+
+ if (ui is IBuiPreTickUpdate tickUpdate)
+ {
+ tickUpdate.PreTickUpdate();
+ }
+ }
+ }
+ }
+}
--- /dev/null
+using System.Linq;
+using Robust.Client.Timing;
+using Robust.Shared.Timing;
+
+namespace Content.Client.UserInterface;
+
+/// <summary>
+/// A local buffer for <see cref="BoundUserInterface"/>s to manually implement prediction.
+/// </summary>
+/// <remarks>
+/// <para>
+/// In many current (and future) cases, it is not practically possible to implement prediction for UIs
+/// by implementing the logic in shared. At the same time, we want to implement prediction for the best user experience
+/// (and it is sometimes the easiest way to make even a middling user experience).
+/// </para>
+/// <para>
+/// You can queue predicted messages into this class with <see cref="SendMessage"/>,
+/// and then call <see cref="MessagesToReplay"/> later from <see cref="BoundUserInterface.UpdateState"/>
+/// to get all messages that are still "ahead" of the latest server state.
+/// These messages can then manually be "applied" to the latest state received from the server.
+/// </para>
+/// <para>
+/// Note that this system only works if the server is guaranteed to send some kind of update in response to UI messages,
+/// or at a regular schedule. If it does not, there is no opportunity to error correct the prediction.
+/// </para>
+/// </remarks>
+public sealed class BuiPredictionState
+{
+ private readonly BoundUserInterface _parent;
+ private readonly IClientGameTiming _gameTiming;
+
+ private readonly Queue<MessageData> _queuedMessages = new();
+
+ public BuiPredictionState(BoundUserInterface parent, IClientGameTiming gameTiming)
+ {
+ _parent = parent;
+ _gameTiming = gameTiming;
+ }
+
+ public void SendMessage(BoundUserInterfaceMessage message)
+ {
+ if (_gameTiming.IsFirstTimePredicted)
+ {
+ var messageData = new MessageData
+ {
+ TickSent = _gameTiming.CurTick,
+ Message = message,
+ };
+
+ _queuedMessages.Enqueue(messageData);
+ }
+
+ _parent.SendPredictedMessage(message);
+ }
+
+ public IEnumerable<BoundUserInterfaceMessage> MessagesToReplay()
+ {
+ var curTick = _gameTiming.LastRealTick;
+ while (_queuedMessages.TryPeek(out var data) && data.TickSent <= curTick)
+ {
+ _queuedMessages.Dequeue();
+ }
+
+ if (_queuedMessages.Count == 0)
+ return [];
+
+ return _queuedMessages.Select(c => c.Message);
+ }
+
+ private struct MessageData
+ {
+ public GameTick TickSent;
+ public required BoundUserInterfaceMessage Message;
+
+ public override string ToString()
+ {
+ return $"{Message} @ {TickSent}";
+ }
+ }
+}
return mode;
}
}
+
+ /// <summary>
+ /// Helper functions for working with <see cref="FancyWindow"/>.
+ /// </summary>
+ public static class FancyWindowExt
+ {
+ /// <summary>
+ /// Sets information for a window (title and guidebooks) based on an entity.
+ /// </summary>
+ /// <param name="window">The window to modify.</param>
+ /// <param name="entityManager">Entity manager used to retrieve the information.</param>
+ /// <param name="entity">The entity that this window represents.</param>
+ /// <seealso cref="SetTitleFromEntity"/>
+ /// <seealso cref="SetGuidebookFromEntity"/>
+ public static void SetInfoFromEntity(this FancyWindow window, IEntityManager entityManager, EntityUid entity)
+ {
+ window.SetTitleFromEntity(entityManager, entity);
+ window.SetGuidebookFromEntity(entityManager, entity);
+ }
+
+ /// <summary>
+ /// Set a window's title to the name of an entity.
+ /// </summary>
+ /// <param name="window">The window to modify.</param>
+ /// <param name="entityManager">Entity manager used to retrieve the information.</param>
+ /// <param name="entity">The entity that this window represents.</param>
+ /// <seealso cref="SetInfoFromEntity"/>
+ public static void SetTitleFromEntity(
+ this FancyWindow window,
+ IEntityManager entityManager,
+ EntityUid entity)
+ {
+ window.Title = entityManager.GetComponent<MetaDataComponent>(entity).EntityName;
+ }
+
+ /// <summary>
+ /// Set a window's guidebook IDs to those of an entity.
+ /// </summary>
+ /// <param name="window">The window to modify.</param>
+ /// <param name="entityManager">Entity manager used to retrieve the information.</param>
+ /// <param name="entity">The entity that this window represents.</param>
+ /// <seealso cref="SetInfoFromEntity"/>
+ public static void SetGuidebookFromEntity(
+ this FancyWindow window,
+ IEntityManager entityManager,
+ EntityUid entity)
+ {
+ window.HelpGuidebookIds = entityManager.GetComponentOrNull<GuideHelpComponent>(entity)?.Guides;
+ }
+ }
}
--- /dev/null
+<Control xmlns="https://spacestation14.io">
+ <BoxContainer Orientation="Horizontal">
+ <Button Name="OffButton" StyleClasses="OpenRight" Text="{Loc 'ui-button-off'}" />
+ <Button Name="OnButton" StyleClasses="OpenLeft" Text="{Loc 'ui-button-on'}" />
+ </BoxContainer>
+</Control>
--- /dev/null
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.UserInterface.Controls;
+
+/// <summary>
+/// A simple control that displays a toggleable on/off button.
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class OnOffButton : Control
+{
+ /// <summary>
+ /// Whether the control is currently in the "on" state.
+ /// </summary>
+ public bool IsOn
+ {
+ get => OnButton.Pressed;
+ set
+ {
+ if (value)
+ OnButton.Pressed = true;
+ else
+ OffButton.Pressed = true;
+ }
+ }
+
+ /// <summary>
+ /// Raised when the user changes the state of the control.
+ /// </summary>
+ /// <remarks>
+ /// This does not get raised if state is changed with <see cref="set_IsOn"/>.
+ /// </remarks>
+ public event Action<bool>? StateChanged;
+
+ public OnOffButton()
+ {
+ RobustXamlLoader.Load(this);
+
+ var group = new ButtonGroup(isNoneSetAllowed: false);
+ OffButton.Group = group;
+ OnButton.Group = group;
+
+ OffButton.OnPressed += _ => StateChanged?.Invoke(false);
+ OnButton.OnPressed += _ => StateChanged?.Invoke(true);
+ }
+}
--- /dev/null
+using System.Diagnostics.CodeAnalysis;
+
+namespace Content.Client.UserInterface;
+
+/// <summary>
+/// A simple utility class to "coalesce" multiple input events into a single one, fired later.
+/// </summary>
+/// <typeparam name="T"></typeparam>
+public struct InputCoalescer<T>
+{
+ public bool IsModified;
+ public T LastValue;
+
+ /// <summary>
+ /// Replace the value in the <see cref="InputCoalescer{T}"/>. This sets <see cref="IsModified"/> to true.
+ /// </summary>
+ public void Set(T value)
+ {
+ LastValue = value;
+ IsModified = true;
+ }
+
+ /// <summary>
+ /// Check if the <see cref="InputCoalescer{T}"/> has been modified.
+ /// If it was, return the value and clear <see cref="IsModified"/>.
+ /// </summary>
+ /// <returns>True if the value was modified since the last check.</returns>
+ public bool CheckIsModified([MaybeNullWhen(false)] out T value)
+ {
+ if (IsModified)
+ {
+ value = LastValue;
+ IsModified = false;
+ return true;
+ }
+
+ value = default;
+ return IsModified;
+ }
+}
--- /dev/null
+using Content.Server.Power.EntitySystems;
+using Content.Shared.Power;
+
+namespace Content.Server.Power.Components;
+
+/// <summary>
+/// Necessary component for battery management UI for SMES/substations.
+/// </summary>
+/// <seealso cref="BatteryUiKey.Key"/>
+/// <seealso cref="BatteryInterfaceSystem"/>
+[RegisterComponent]
+public sealed partial class BatteryInterfaceComponent : Component
+{
+ /// <summary>
+ /// The maximum charge rate users can configure through the UI.
+ /// </summary>
+ [DataField]
+ public float MaxChargeRate;
+
+ /// <summary>
+ /// The minimum charge rate users can configure through the UI.
+ /// </summary>
+ [DataField]
+ public float MinChargeRate;
+
+ /// <summary>
+ /// The maximum discharge rate users can configure through the UI.
+ /// </summary>
+ [DataField]
+ public float MaxSupply;
+
+ /// <summary>
+ /// The minimum discharge rate users can configure through the UI.
+ /// </summary>
+ [DataField]
+ public float MinSupply;
+}
--- /dev/null
+using Content.Server.Power.Components;
+using Content.Shared.Power;
+using Robust.Server.GameObjects;
+
+namespace Content.Server.Power.EntitySystems;
+
+/// <summary>
+/// Handles logic for the battery interface on SMES/substations.
+/// </summary>
+/// <remarks>
+/// <para>
+/// These devices have interfaces that allow user to toggle input and output,
+/// and configure charge/discharge power limits.
+/// </para>
+/// <para>
+/// This system is not responsible for any power logic on its own,
+/// it merely reconfigures parameters on <see cref="PowerNetworkBatteryComponent"/> from the UI.
+/// </para>
+/// </remarks>
+public sealed class BatteryInterfaceSystem : EntitySystem
+{
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = null!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ UpdatesAfter.Add(typeof(PowerNetSystem));
+
+ Subs.BuiEvents<BatteryInterfaceComponent>(
+ BatteryUiKey.Key,
+ subs =>
+ {
+ subs.Event<BatterySetInputBreakerMessage>(HandleSetInputBreaker);
+ subs.Event<BatterySetOutputBreakerMessage>(HandleSetOutputBreaker);
+
+ subs.Event<BatterySetChargeRateMessage>(HandleSetChargeRate);
+ subs.Event<BatterySetDischargeRateMessage>(HandleSetDischargeRate);
+ });
+ }
+
+ private void HandleSetInputBreaker(Entity<BatteryInterfaceComponent> ent, ref BatterySetInputBreakerMessage args)
+ {
+ var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
+ netBattery.CanCharge = args.On;
+ }
+
+ private void HandleSetOutputBreaker(Entity<BatteryInterfaceComponent> ent, ref BatterySetOutputBreakerMessage args)
+ {
+ var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
+ netBattery.CanDischarge = args.On;
+ }
+
+ private void HandleSetChargeRate(Entity<BatteryInterfaceComponent> ent, ref BatterySetChargeRateMessage args)
+ {
+ var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
+ netBattery.MaxChargeRate = Math.Clamp(args.Rate, ent.Comp.MinChargeRate, ent.Comp.MaxChargeRate);
+ }
+
+ private void HandleSetDischargeRate(Entity<BatteryInterfaceComponent> ent, ref BatterySetDischargeRateMessage args)
+ {
+ var netBattery = Comp<PowerNetworkBatteryComponent>(ent);
+ netBattery.MaxSupply = Math.Clamp(args.Rate, ent.Comp.MinSupply, ent.Comp.MaxSupply);
+ }
+
+ public override void Update(float frameTime)
+ {
+ var query = EntityQueryEnumerator<BatteryInterfaceComponent, BatteryComponent, PowerNetworkBatteryComponent>();
+
+ while (query.MoveNext(out var uid, out var batteryInterface, out var battery, out var netBattery))
+ {
+ UpdateUI(uid, batteryInterface, battery, netBattery);
+ }
+ }
+
+ private void UpdateUI(
+ EntityUid uid,
+ BatteryInterfaceComponent batteryInterface,
+ BatteryComponent battery,
+ PowerNetworkBatteryComponent netBattery)
+ {
+ if (!_uiSystem.IsUiOpen(uid, BatteryUiKey.Key))
+ return;
+
+ _uiSystem.SetUiState(
+ uid,
+ BatteryUiKey.Key,
+ new BatteryBuiState
+ {
+ Capacity = battery.MaxCharge,
+ Charge = battery.CurrentCharge,
+ CanCharge = netBattery.CanCharge,
+ CanDischarge = netBattery.CanDischarge,
+ CurrentReceiving = netBattery.CurrentReceiving,
+ CurrentSupply = netBattery.CurrentSupply,
+ MaxSupply = netBattery.MaxSupply,
+ MaxChargeRate = netBattery.MaxChargeRate,
+ Efficiency = netBattery.Efficiency,
+ MaxMaxSupply = batteryInterface.MaxSupply,
+ MinMaxSupply = batteryInterface.MinSupply,
+ MaxMaxChargeRate = batteryInterface.MaxChargeRate,
+ MinMaxChargeRate = batteryInterface.MinChargeRate,
+ SupplyingNetworkHasPower = CheckHasPower<BatteryChargerComponent>(uid),
+ LoadingNetworkHasPower = CheckHasPower<BatteryDischargerComponent>(uid),
+ });
+
+ return;
+
+ bool CheckHasPower<TComp>(EntityUid entity) where TComp : BasePowerNetComponent
+ {
+ if (!TryComp(entity, out TComp? comp))
+ return false;
+
+ if (comp.Net == null)
+ return false;
+
+ return comp.Net.NetworkNode.LastCombinedMaxSupply > 0;
+ }
+ }
+}
_loc.AddFunction(culture, "PRESSURE", FormatPressure);
_loc.AddFunction(culture, "POWERWATTS", FormatPowerWatts);
_loc.AddFunction(culture, "POWERJOULES", FormatPowerJoules);
+ // NOTE: ENERGYWATTHOURS() still takes a value in joules, but formats as watt-hours.
+ _loc.AddFunction(culture, "ENERGYWATTHOURS", FormatEnergyWattHours);
_loc.AddFunction(culture, "UNITS", FormatUnits);
_loc.AddFunction(culture, "TOSTRING", args => FormatToString(culture, args));
_loc.AddFunction(culture, "LOC", FormatLoc);
return new LocValueString(obj?.ToString() ?? "");
}
- private static ILocValue FormatUnitsGeneric(LocArgs args, string mode)
+ private static ILocValue FormatUnitsGeneric(
+ LocArgs args,
+ string mode,
+ Func<double, double>? transformValue = null)
{
const int maxPlaces = 5; // Matches amount in _lib.ftl
var pressure = ((LocValueNumber) args.Args[0]).Value;
+ if (transformValue != null)
+ pressure = transformValue(pressure);
+
var places = 0;
while (pressure > 1000 && places < maxPlaces)
{
return FormatUnitsGeneric(args, "zzzz-fmt-power-joules");
}
+ private static ILocValue FormatEnergyWattHours(LocArgs args)
+ {
+ const double joulesToWattHours = 1.0 / 3600;
+
+ return FormatUnitsGeneric(args, "zzzz-fmt-energy-watt-hours", joules => joules * joulesToWattHours);
+ }
+
private static ILocValue FormatUnits(LocArgs args)
{
if (!Units.Types.TryGetValue(((LocValueString) args.Args[0]).Value, out var ut))
--- /dev/null
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Power;
+
+/// <summary>
+/// UI key for large battery (SMES/substation) UIs.
+/// </summary>
+[NetSerializable, Serializable]
+public enum BatteryUiKey : byte
+{
+ Key,
+}
+
+/// <summary>
+/// UI state for large battery (SMES/substation) UIs.
+/// </summary>
+/// <seealso cref="BatteryUiKey"/>
+[Serializable, NetSerializable]
+public sealed class BatteryBuiState : BoundUserInterfaceState
+{
+ // These are mostly just regular Pow3r parameters.
+
+ // I/O
+ public bool CanCharge;
+ public bool CanDischarge;
+ public bool SupplyingNetworkHasPower;
+ public bool LoadingNetworkHasPower;
+ public float CurrentReceiving;
+ public float CurrentSupply;
+
+ // Charge
+ public float MaxChargeRate;
+ public float MinMaxChargeRate;
+ public float MaxMaxChargeRate;
+ public float Efficiency;
+
+ // Discharge
+ public float MaxSupply;
+ public float MinMaxSupply;
+ public float MaxMaxSupply;
+
+ // Storage
+ public float Charge;
+ public float Capacity;
+}
+
+/// <summary>
+/// Sent client to server to change the input breaker state on a large battery.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class BatterySetInputBreakerMessage(bool on) : BoundUserInterfaceMessage
+{
+ public bool On = on;
+}
+
+/// <summary>
+/// Sent client to server to change the output breaker state on a large battery.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class BatterySetOutputBreakerMessage(bool on) : BoundUserInterfaceMessage
+{
+ public bool On = on;
+}
+
+/// <summary>
+/// Sent client to server to change the charge rate on a large battery.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class BatterySetChargeRateMessage(float rate) : BoundUserInterfaceMessage
+{
+ public float Rate = rate;
+}
+
+/// <summary>
+/// Sent client to server to change the discharge rate on a large battery.
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class BatterySetDischargeRateMessage(float rate) : BoundUserInterfaceMessage
+{
+ public float Rate = rate;
+}
+
*[5] ???
}
+# Used internally by the ENERGYWATTHOURS() function.
+zzzz-fmt-energy-watt-hours = { TOSTRING($divided, "F1") } { $places ->
+ [0] Wh
+ [1] kWh
+ [2] MWh
+ [3] GWh
+ [4] TWh
+ *[5] ???
+}
+
# Used internally by the PLAYTIME() function.
-zzzz-fmt-playtime = {$hours}H {$minutes}M
\ No newline at end of file
+zzzz-fmt-playtime = {$hours}H {$minutes}M
--- /dev/null
+## Strings for the battery (SMES/substation) menu
+
+battery-menu-footer-left = Danger: high voltage
+battery-menu-footer-right = 7.2 REV 6
+battery-menu-out = OUT
+battery-menu-in = IN
+battery-menu-charge-header = Charge Circuit
+battery-menu-discharge-header = Discharge Circuit
+battery-menu-storage-header = Storage Cells
+battery-menu-passthrough = Passthrough
+battery-menu-max = Max:
+battery-menu-current = Current:
+battery-menu-stored = Stored:
+battery-menu-energy = Energy:
+battery-menu-eta-full = ETA (full):
+battery-menu-eta-empty = ETA (empty):
+battery-menu-eta-value = ~{ $minutes } min
+battery-menu-eta-value-max = >{ $minutes } min
+battery-menu-eta-value-na = N/A
+battery-menu-power-value = { POWERWATTS($value) }
+battery-menu-stored-percent-value = { TOSTRING($value, "P1") }
+battery-menu-stored-energy-value = { ENERGYWATTHOURS($value) }
--- /dev/null
+## Loc strings for generic "on/off button" control.
+ui-button-off = Off
+ui-button-on = On
- VoltageNetworks
- Power
+ # Interface
+ - type: BatteryInterface
+ minChargeRate: 5000
+ maxChargeRate: 150000
+ minSupply: 5000
+ maxSupply: 150000
+ - type: UserInterface
+ interfaces:
+ enum.BatteryUiKey.Key:
+ type: BatteryBoundUserInterface
+ - type: ActivatableUI
+ key: enum.BatteryUiKey.Key
+
# SMES' in use
- type: entity
- VoltageNetworks
- Power
+ # Interface
+ - type: BatteryInterface
+ minChargeRate: 5000
+ maxChargeRate: 150000
+ minSupply: 5000
+ maxSupply: 150000
+ - type: UserInterface
+ interfaces:
+ enum.BatteryUiKey.Key:
+ type: BatteryBoundUserInterface
+ - type: ActivatableUI
+ key: enum.BatteryUiKey.Key
+
# Compact Wall Substation Base
- type: entity
id: BaseSubstationWall