Battery (SMES/substation) interface (#36386)

* Add ENERGYWATTHOURS() loc function

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

* Add simple OnOffButton control

* Re-add Inset style class

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

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

Reagent dispenser uses these, more in the next commits.

* Add BuiPredictionState helper

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

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

* Add input coalescing infrastructure

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

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

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

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

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

* Battery interface

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

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

* Oops, forgot to remove these style class defs.
This commit is contained in:
Pieter-Jan Briers
2025-04-27 13:08:34 +02:00
committed by GitHub
parent 791f7af5d4
commit ffe130b38d
21 changed files with 1146 additions and 5 deletions

View File

@@ -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;
/// <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();
}
}
}
}
}

View File

@@ -0,0 +1,80 @@
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}";
}
}
}

View File

@@ -81,4 +81,54 @@ namespace Content.Client.UserInterface.Controls
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;
}
}
}

View File

@@ -0,0 +1,6 @@
<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>

View File

@@ -0,0 +1,48 @@
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);
}
}

View File

@@ -0,0 +1,40 @@
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;
}
}