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 @@ + + +