diff --git a/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs b/Content.Client/Chemistry/UI/ReagentDispenserBoundUserInterface.cs index 2ad1b718887..b0f2a77eed6 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 @@ protected override void Open() // 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 acf96e6d771..dec48d69ef9 100644 --- a/Content.Client/Entry/EntryPoint.cs +++ b/Content.Client/Entry/EntryPoint.cs @@ -20,6 +20,7 @@ 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; @@ -77,6 +78,7 @@ public sealed class EntryPoint : GameClient [Dependency] private readonly DebugMonitorManager _debugMonitorManager = default!; [Dependency] private readonly TitleWindowManager _titleWindowManager = default!; [Dependency] private readonly DiscordAuthManager _discordAuth = default!; // Floofstation + [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; public override void Init() { @@ -237,6 +239,15 @@ public override void Update(ModUpdateLevel level, FrameEventArgs frameEventArgs) { _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 00000000000..561fe90e408 --- /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 00000000000..83483a517e3 --- /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 00000000000..78cc669fd04 --- /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 cde97bc9ecb..be66c4add64 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -66,6 +66,7 @@ public sealed class StyleNano : StyleBase public const string StyleClassChatChannelSelectorButton = "chatSelectorOptionButton"; public const string StyleClassChatFilterOptionButton = "chatFilterOptionButton"; public const string StyleClassStorageButton = "storageButton"; + public const string StyleClassInset = "Inset"; public const string StyleClassConsoleHeading = "ConsoleHeading"; public const string StyleClassConsoleSubHeading = "ConsoleSubHeading"; @@ -2028,6 +2029,10 @@ public StyleNano(IResourceCache resCache) : base(resCache) { Modulate = ButtonColorGoodDefault }), + + Element() + .Class(StyleClassInset) + .Prop(PanelContainer.StylePropertyPanel, insetBack), }).ToList()); } } diff --git a/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs b/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs index 632894c2aaa..330cb51dcc8 100644 --- a/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs +++ b/Content.Client/UserInterface/BuiPreTickUpdateSystem.cs @@ -1,4 +1,4 @@ -using Robust.Client.GameObjects; +using Robust.Client.GameObjects; using Robust.Client.Player; using Robust.Shared.ContentPack; using Robust.Shared.Timing; diff --git a/Content.Client/UserInterface/BuiPredictionState.cs b/Content.Client/UserInterface/BuiPredictionState.cs index bd1342e1e0d..e3299e03f3e 100644 --- a/Content.Client/UserInterface/BuiPredictionState.cs +++ b/Content.Client/UserInterface/BuiPredictionState.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System.Linq; using Robust.Client.Timing; using Robust.Shared.Timing; diff --git a/Content.Client/UserInterface/Controls/OnOffButton.xaml b/Content.Client/UserInterface/Controls/OnOffButton.xaml new file mode 100644 index 00000000000..f642e709ec2 --- /dev/null +++ b/Content.Client/UserInterface/Controls/OnOffButton.xaml @@ -0,0 +1,6 @@ + + +