Merge remote-tracking branch 'upstream/stable' into ed-12-05-2025-upstream

# Conflicts:
#	.github/CODEOWNERS
#	Content.Client/Construction/UI/ConstructionMenuPresenter.cs
#	Content.Shared/Construction/Prototypes/ConstructionPrototype.cs
#	Content.Shared/Damage/Systems/SharedStaminaSystem.cs
#	Content.Shared/Lock/LockSystem.cs
#	Resources/Prototypes/Entities/Mobs/Customization/Markings/human_hair.yml
#	Resources/Prototypes/Entities/Objects/Specific/chemistry.yml
#	Resources/Prototypes/Procedural/vgroid.yml
This commit is contained in:
Ed
2025-05-12 14:25:42 +03:00
1224 changed files with 155650 additions and 65383 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

@@ -264,12 +264,6 @@ public class ListContainer : Control
_updateChildren = false;
var toRemove = new Dictionary<ListData, ListContainerButton>(_buttons);
foreach (var child in Children.ToArray())
{
if (child == _vScrollBar)
continue;
RemoveChild(child);
}
if (_data.Count > 0)
{
@@ -292,8 +286,9 @@ public class ListContainer : Control
if (Toggle && data == _selected)
button.Pressed = true;
AddChild(button);
}
AddChild(button);
button.SetPositionInParent(i - _topIndex);
button.Measure(finalSize);
}
}

View File

@@ -0,0 +1,79 @@
using JetBrains.Annotations;
using Robust.Client.UserInterface.Controls;
using static Robust.Client.UserInterface.Controls.Label;
namespace Content.Client.UserInterface.Controls;
/// <summary>
/// A button intended for use with a monotone color palette
/// </summary>
public sealed class MonotoneButton : ContainerButton
{
/// <summary>
/// Specifies the color of the label text when the button is pressed.
/// </summary>
[ViewVariables]
public Color AltTextColor { set; get; } = new Color(0.2f, 0.2f, 0.2f);
/// <summary>
/// The label that holds the button text.
/// </summary>
public Label Label { get; }
/// <summary>
/// The text displayed by the button.
/// </summary>
[PublicAPI, ViewVariables]
public string? Text { get => Label.Text; set => Label.Text = value; }
/// <summary>
/// How to align the text inside the button.
/// </summary>
[PublicAPI, ViewVariables]
public AlignMode TextAlign { get => Label.Align; set => Label.Align = value; }
/// <summary>
/// If true, the button will allow shrinking and clip text
/// to prevent the text from going outside the bounds of the button.
/// If false, the minimum size will always fit the contained text.
/// </summary>
[PublicAPI, ViewVariables]
public bool ClipText
{
get => Label.ClipText;
set => Label.ClipText = value;
}
public MonotoneButton()
{
Label = new Label
{
StyleClasses = { StyleClassButton }
};
AddChild(Label);
UpdateAppearance();
}
private void UpdateAppearance()
{
// Recolor the label
if (Label != null)
Label.ModulateSelfOverride = DrawMode == DrawModeEnum.Pressed ? AltTextColor : null;
// Modulate the button if disabled
Modulate = Disabled ? Color.Gray : Color.White;
}
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
UpdateAppearance();
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
UpdateAppearance();
}
}

View File

@@ -0,0 +1,24 @@
using Robust.Client.UserInterface.Controls;
namespace Content.Client.UserInterface.Controls;
/// <summary>
/// A check box intended for use with a monotone color palette
/// </summary>
public sealed class MonotoneCheckBox : CheckBox
{
public const string StyleClassMonotoneCheckBox = "monotoneCheckBox";
public MonotoneCheckBox()
{
TextureRect.AddStyleClass(StyleClassMonotoneCheckBox);
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
// Appearance modulations
Modulate = Disabled ? Color.Gray : Color.White;
}
}

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;
}
}

View File

@@ -12,6 +12,8 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
{
public sealed class AlertControl : BaseButton
{
[Dependency] private readonly IEntityManager _entityManager = default!;
public AlertPrototype Alert { get; }
/// <summary>
@@ -33,8 +35,7 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
private (TimeSpan Start, TimeSpan End)? _cooldown;
private short? _severity;
private readonly IGameTiming _gameTiming;
private readonly IEntityManager _entityManager;
private readonly SpriteView _icon;
private readonly CooldownGraphic _cooldownGraphic;
@@ -47,8 +48,10 @@ namespace Content.Client.UserInterface.Systems.Alerts.Controls
/// <param name="severity">severity of alert, null if alert doesn't have severity levels</param>
public AlertControl(AlertPrototype alert, short? severity)
{
_gameTiming = IoCManager.Resolve<IGameTiming>();
_entityManager = IoCManager.Resolve<IEntityManager>();
// Alerts will handle this.
MuteSounds = true;
IoCManager.InjectDependencies(this);
TooltipSupplier = SupplyTooltip;
Alert = alert;
_severity = severity;

View File

@@ -1,4 +1,5 @@
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
@@ -17,7 +18,7 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
public void SetOutputPressure(float value)
{
SendMessage(new GasTankSetPressureMessage
SendPredictedMessage(new GasTankSetPressureMessage
{
Pressure = value
});
@@ -25,13 +26,14 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
public void ToggleInternals()
{
SendMessage(new GasTankToggleInternalsMessage());
SendPredictedMessage(new GasTankToggleInternalsMessage());
}
protected override void Open()
{
base.Open();
_window = this.CreateWindow<GasTankWindow>();
_window.Entity = Owner;
_window.SetTitle(EntMan.GetComponent<MetaDataComponent>(Owner).EntityName);
_window.OnOutputPressure += SetOutputPressure;
_window.OnToggleInternals += ToggleInternals;
@@ -41,6 +43,12 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank
{
base.UpdateState(state);
if (EntMan.TryGetComponent(Owner, out GasTankComponent? component))
{
var canConnect = EntMan.System<SharedGasTankSystem>().CanConnectToInternals((Owner, component));
_window?.Update(canConnect, component.IsConnected, component.OutputPressure);
}
if (state is GasTankBoundUserInterfaceState cast)
_window?.UpdateState(cast);
}

View File

@@ -3,11 +3,14 @@ using Content.Client.Message;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared.Atmos.Components;
using Content.Shared.Atmos.EntitySystems;
using Content.Shared.Timing;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Shared.Timing;
using static Robust.Client.UserInterface.Controls.BoxContainer;
namespace Content.Client.UserInterface.Systems.Atmos.GasTank;
@@ -15,6 +18,7 @@ namespace Content.Client.UserInterface.Systems.Atmos.GasTank;
public sealed class GasTankWindow
: BaseWindow
{
[Dependency] private readonly IEntityManager _entManager = default!;
[Dependency] private readonly IResourceCache _cache = default!;
private readonly RichTextLabel _lblPressure;
@@ -23,6 +27,8 @@ public sealed class GasTankWindow
private readonly Button _btnInternals;
private readonly Label _topLabel;
public EntityUid Entity;
public event Action<float>? OnOutputPressure;
public event Action? OnToggleInternals;
@@ -194,12 +200,30 @@ public sealed class GasTankWindow
public void UpdateState(GasTankBoundUserInterfaceState state)
{
_lblPressure.SetMarkup(Loc.GetString("gas-tank-window-tank-pressure-text", ("tankPressure", $"{state.TankPressure:0.##}")));
_btnInternals.Disabled = !state.CanConnectInternals;
}
public void Update(bool canConnectInternals, bool internalsConnected, float outputPressure)
{
_btnInternals.Disabled = !canConnectInternals;
_lblInternals.SetMarkup(Loc.GetString("gas-tank-window-internal-text",
("status", Loc.GetString(state.InternalsConnected ? "gas-tank-window-internal-connected" : "gas-tank-window-internal-disconnected"))));
if (state.OutputPressure.HasValue)
("status", Loc.GetString(internalsConnected ? "gas-tank-window-internal-connected" : "gas-tank-window-internal-disconnected"))));
_spbPressure.Value = outputPressure;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// Easier than managing state on any ent changes. Previously this was just ticked on server's GasTankSystem.
if (_entManager.TryGetComponent(Entity, out GasTankComponent? tank))
{
_spbPressure.Value = state.OutputPressure.Value;
var canConnectInternals = _entManager.System<SharedGasTankSystem>().CanConnectToInternals((Entity, tank));
_btnInternals.Disabled = !canConnectInternals;
}
if (!_btnInternals.Disabled)
{
_btnInternals.Disabled = _entManager.System<UseDelaySystem>().IsDelayed(Entity, id: SharedGasTankSystem.GasTankDelay);
}
}

View File

@@ -918,6 +918,11 @@ public sealed class ChatUIController : UIController
_typingIndicator?.ClientChangedChatText();
}
public void NotifyChatFocus(bool isFocused)
{
_typingIndicator?.ClientChangedChatFocus(isFocused);
}
public void Repopulate()
{
foreach (var chat in _chats)

View File

@@ -34,6 +34,8 @@ public partial class ChatBox : UIWidget
ChatInput.Input.OnTextEntered += OnTextEntered;
ChatInput.Input.OnKeyBindDown += OnInputKeyBindDown;
ChatInput.Input.OnTextChanged += OnTextChanged;
ChatInput.Input.OnFocusEnter += OnFocusEnter;
ChatInput.Input.OnFocusExit += OnFocusExit;
ChatInput.ChannelSelector.OnChannelSelect += OnChannelSelect;
ChatInput.FilterButton.Popup.OnChannelFilter += OnChannelFilter;
@@ -174,6 +176,18 @@ public partial class ChatBox : UIWidget
_controller.NotifyChatTextChange();
}
private void OnFocusEnter(LineEditEventArgs args)
{
// Warn typing indicator about focus
_controller.NotifyChatFocus(true);
}
private void OnFocusExit(LineEditEventArgs args)
{
// Warn typing indicator about focus
_controller.NotifyChatFocus(false);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);