From ffe130b38dfa2d7c6597803f5e8a79d8c95f3e14 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Sun, 27 Apr 2025 13:08:34 +0200 Subject: [PATCH] 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 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. --- .../UI/ReagentDispenserBoundUserInterface.cs | 4 +- Content.Client/Entry/EntryPoint.cs | 11 + .../Battery/BatteryBoundUserInterface.cs | 85 ++++++ Content.Client/Power/Battery/BatteryMenu.xaml | 146 +++++++++ .../Power/Battery/BatteryMenu.xaml.cs | 280 ++++++++++++++++++ Content.Client/Stylesheets/StyleNano.cs | 7 +- .../UserInterface/BuiPreTickUpdateSystem.cs | 75 +++++ .../UserInterface/BuiPredictionState.cs | 80 +++++ .../Controls/FancyWindow.xaml.cs | 50 ++++ .../UserInterface/Controls/OnOffButton.xaml | 6 + .../Controls/OnOffButton.xaml.cs | 48 +++ .../UserInterface/InputCoalescer.cs | 40 +++ .../Components/BatteryInterfaceComponent.cs | 37 +++ .../EntitySystems/BatteryInterfaceSystem.cs | 120 ++++++++ .../ContentLocalizationManager.cs | 17 +- Content.Shared/Power/SharedBattery.cs | 82 +++++ Resources/Locale/en-US/_lib.ftl | 12 +- Resources/Locale/en-US/power/battery.ftl | 22 ++ Resources/Locale/en-US/ui/controls.ftl | 3 + .../Entities/Structures/Power/smes.yml | 13 + .../Entities/Structures/Power/substation.yml | 13 + 21 files changed, 1146 insertions(+), 5 deletions(-) create mode 100644 Content.Client/Power/Battery/BatteryBoundUserInterface.cs create mode 100644 Content.Client/Power/Battery/BatteryMenu.xaml create mode 100644 Content.Client/Power/Battery/BatteryMenu.xaml.cs create mode 100644 Content.Client/UserInterface/BuiPreTickUpdateSystem.cs create mode 100644 Content.Client/UserInterface/BuiPredictionState.cs create mode 100644 Content.Client/UserInterface/Controls/OnOffButton.xaml create mode 100644 Content.Client/UserInterface/Controls/OnOffButton.xaml.cs create mode 100644 Content.Client/UserInterface/InputCoalescer.cs create mode 100644 Content.Server/Power/Components/BatteryInterfaceComponent.cs create mode 100644 Content.Server/Power/EntitySystems/BatteryInterfaceSystem.cs create mode 100644 Content.Shared/Power/SharedBattery.cs create mode 100644 Resources/Locale/en-US/power/battery.ftl create mode 100644 Resources/Locale/en-US/ui/controls.ftl diff --git a/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs b/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs index 2ad1b71888..b0f2a77eed 100644 --- a/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs +++ b/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs @@ -1,4 +1,5 @@ using Content.Client.Guidebook.Components; +using Content.Client.UserInterface.Controls; using Content.Shared.Chemistry; using Content.Shared.Containers.ItemSlots; using JetBrains.Annotations; @@ -31,8 +32,7 @@ namespace Content.Client.Chemistry.UI // Setup window layout/elements _window = this.CreateWindow(); - _window.Title = EntMan.GetComponent(Owner).EntityName; - _window.HelpGuidebookIds = EntMan.GetComponent(Owner).Guides; + _window.SetInfoFromEntity(EntMan, Owner); // Setup static button actions. _window.EjectButton.OnPressed += _ => SendMessage(new ItemSlotButtonPressedEvent(SharedReagentDispenser.OutputSlotName)); diff --git a/Content.Client/Entry/EntryPoint.cs b/Content.Client/Entry/EntryPoint.cs index 5c1f94f333..ebed5269f8 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -19,6 +19,7 @@ using Content.Client.Replay; 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; @@ -72,6 +73,7 @@ namespace Content.Client.Entry [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() { @@ -224,6 +226,15 @@ namespace Content.Client.Entry { _debugMonitorManager.FrameUpdate(); } + + if (level == ModUpdateLevel.PreEngine) + { + if (_baseClient.RunLevel is ClientRunLevel.InGame or ClientRunLevel.SinglePlayerGame) + { + var updateSystem = _entitySystemManager.GetEntitySystem(); + updateSystem.RunUpdates(); + } + } } } } diff --git a/Content.Client/Power/Battery/BatteryBoundUserInterface.cs b/Content.Client/Power/Battery/BatteryBoundUserInterface.cs new file mode 100644 index 0000000000..561fe90e40 --- /dev/null +++ b/Content.Client/Power/Battery/BatteryBoundUserInterface.cs @@ -0,0 +1,85 @@ +using Content.Client.UserInterface; +using Content.Shared.Power; +using JetBrains.Annotations; +using Robust.Client.Timing; +using Robust.Client.UserInterface; + +namespace Content.Client.Power.Battery; + +/// +/// BUI for . +/// +/// +/// +[UsedImplicitly] +public sealed class BatteryBoundUserInterface : BoundUserInterface, IBuiPreTickUpdate +{ + [Dependency] private readonly IClientGameTiming _gameTiming = null!; + + [ViewVariables] + private BatteryMenu? _menu; + + private BuiPredictionState? _pred; + private InputCoalescer _chargeRateCoalescer; + private InputCoalescer _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(); + _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); + } +} diff --git a/Content.Client/Power/Battery/BatteryMenu.xaml b/Content.Client/Power/Battery/BatteryMenu.xaml new file mode 100644 index 0000000000..83483a517e --- /dev/null +++ b/Content.Client/Power/Battery/BatteryMenu.xaml @@ -0,0 +1,146 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Power/Battery/BatteryMenu.xaml.cs b/Content.Client/Power/Battery/BatteryMenu.xaml.cs new file mode 100644 index 0000000000..78cc669fd0 --- /dev/null +++ b/Content.Client/Power/Battery/BatteryMenu.xaml.cs @@ -0,0 +1,280 @@ +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; + +/// +/// Interface control for batteries. +/// +/// +[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? OnInBreaker; + public event Action? OnOutBreaker; + + public event Action? OnChargeRate; + public event Action? 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]; + } + } +} diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index 40d256813e..30e00b3c1c 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -66,6 +66,7 @@ namespace Content.Client.Stylesheets 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"; @@ -1675,7 +1676,11 @@ namespace Content.Client.Stylesheets new[] { new StyleProperty(TextureButton.StylePropertyTexture, resCache.GetTexture("/Textures/Interface/Bwoink/un_pinned.png")) - }) + }), + + Element() + .Class(StyleClassInset) + .Prop(PanelContainer.StylePropertyPanel, insetBack), }).ToList()); } } diff --git a/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs b/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs new file mode 100644 index 0000000000..330cb51dcc --- /dev/null +++ b/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs @@ -0,0 +1,75 @@ +using Robust.Client.GameObjects; +using Robust.Client.Player; +using Robust.Shared.ContentPack; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface; + +/// +/// Interface for s that need some updating logic +/// ran in the stage. +/// +/// +/// +/// This is called on all open s that implement this interface. +/// +/// +/// One intended use case is coalescing input events (e.g. via ) to send them to the +/// server only once per tick. +/// +/// +/// +public interface IBuiPreTickUpdate +{ + void PreTickUpdate(); +} + +/// +/// Implements . +/// +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 _userQuery; + + public override void Initialize() + { + base.Initialize(); + + _userQuery = GetEntityQuery(); + } + + 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(); + } + } + } + } +} diff --git a/Content.Client/UserInterface/BuiPredictionState.cs b/Content.Client/UserInterface/BuiPredictionState.cs new file mode 100644 index 0000000000..e3299e03f3 --- /dev/null +++ b/Content.Client/UserInterface/BuiPredictionState.cs @@ -0,0 +1,80 @@ +using System.Linq; +using Robust.Client.Timing; +using Robust.Shared.Timing; + +namespace Content.Client.UserInterface; + +/// +/// A local buffer for s to manually implement prediction. +/// +/// +/// +/// 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). +/// +/// +/// You can queue predicted messages into this class with , +/// and then call later from +/// 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. +/// +/// +/// 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. +/// +/// +public sealed class BuiPredictionState +{ + private readonly BoundUserInterface _parent; + private readonly IClientGameTiming _gameTiming; + + private readonly Queue _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 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}"; + } + } +} diff --git a/Content.Client/UserInterface/Controls/FancyWindow.xaml.cs b/Content.Client/UserInterface/Controls/FancyWindow.xaml.cs index df6501dcb8..71d0066a79 100644 --- a/Content.Client/UserInterface/Controls/FancyWindow.xaml.cs +++ b/Content.Client/UserInterface/Controls/FancyWindow.xaml.cs @@ -81,4 +81,54 @@ namespace Content.Client.UserInterface.Controls return mode; } } + + /// + /// Helper functions for working with . + /// + public static class FancyWindowExt + { + /// + /// Sets information for a window (title and guidebooks) based on an entity. + /// + /// The window to modify. + /// Entity manager used to retrieve the information. + /// The entity that this window represents. + /// + /// + public static void SetInfoFromEntity(this FancyWindow window, IEntityManager entityManager, EntityUid entity) + { + window.SetTitleFromEntity(entityManager, entity); + window.SetGuidebookFromEntity(entityManager, entity); + } + + /// + /// Set a window's title to the name of an entity. + /// + /// The window to modify. + /// Entity manager used to retrieve the information. + /// The entity that this window represents. + /// + public static void SetTitleFromEntity( + this FancyWindow window, + IEntityManager entityManager, + EntityUid entity) + { + window.Title = entityManager.GetComponent(entity).EntityName; + } + + /// + /// Set a window's guidebook IDs to those of an entity. + /// + /// The window to modify. + /// Entity manager used to retrieve the information. + /// The entity that this window represents. + /// + public static void SetGuidebookFromEntity( + this FancyWindow window, + IEntityManager entityManager, + EntityUid entity) + { + window.HelpGuidebookIds = entityManager.GetComponentOrNull(entity)?.Guides; + } + } } diff --git a/Content.Client/UserInterface/Controls/OnOffButton.xaml b/Content.Client/UserInterface/Controls/OnOffButton.xaml new file mode 100644 index 0000000000..f642e709ec --- /dev/null +++ b/Content.Client/UserInterface/Controls/OnOffButton.xaml @@ -0,0 +1,6 @@ + + +