Self-predicting popups (#44524)

* Popup prediction refactor

Co-authored-by: EmoGarbage404 <98561806+EmoGarbage404@users.noreply.github.com>

* fix popupcoordinates prediction

* review

* ProtoMan

---------

Co-authored-by: EmoGarbage404 <98561806+EmoGarbage404@users.noreply.github.com>
This commit is contained in:
slarticodefast 2026-07-07 22:42:08 +02:00 • committed by GitHub
parent 5dd2f3f43d
commit 964aebb031
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 770 additions and 739 deletions

View file

@ -12,358 +12,369 @@ using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Replays;
using Robust.Shared.Timing;
namespace Content.Client.Popups
namespace Content.Client.Popups;
public sealed partial class PopupSystem : SharedPopupSystem
{
public sealed partial class PopupSystem : SharedPopupSystem
[Dependency] private IConfigurationManager _configManager = default!;
[Dependency] private IInputManager _inputManager = default!;
[Dependency] private IOverlayManager _overlay = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IUserInterfaceManager _uiManager = default!;
[Dependency] private IReplayRecordingManager _replayRecording = default!;
[Dependency] private ExamineSystemShared _examine = default!;
[Dependency] private SharedTransformSystem _transform = default!;
public IReadOnlyCollection<WorldPopupLabel> WorldLabels => _aliveWorldLabels.Values;
public IReadOnlyCollection<CursorPopupLabel> CursorLabels => _aliveCursorLabels.Values;
private readonly Dictionary<WorldPopupData, WorldPopupLabel> _aliveWorldLabels = new();
private readonly Dictionary<CursorPopupData, CursorPopupLabel> _aliveCursorLabels = new();
/// <summary>
/// List of popups that have been predicted by the client.
/// If a popup is received from the server that matches one of these, it will be ignored to prevent duplicates.
/// </summary>
private readonly List<IPopupPredictionInstance> _predictionInstances = new();
public const float MinimumPopupLifetime = 0.7f;
public const float MaximumPopupLifetime = 5f;
public const float PopupLifetimePerCharacter = 0.04f;
public override void Initialize()
{
[Dependency] private IConfigurationManager _configManager = default!;
[Dependency] private IInputManager _inputManager = default!;
[Dependency] private IOverlayManager _overlay = default!;
[Dependency] private IPlayerManager _playerManager = default!;
[Dependency] private IGameTiming _timing = default!;
[Dependency] private IUserInterfaceManager _uiManager = default!;
[Dependency] private IReplayRecordingManager _replayRecording = default!;
[Dependency] private ExamineSystemShared _examine = default!;
[Dependency] private SharedTransformSystem _transform = default!;
base.Initialize();
public IReadOnlyCollection<WorldPopupLabel> WorldLabels => _aliveWorldLabels.Values;
public IReadOnlyCollection<CursorPopupLabel> CursorLabels => _aliveCursorLabels.Values;
private readonly Dictionary<WorldPopupData, WorldPopupLabel> _aliveWorldLabels = new();
private readonly Dictionary<CursorPopupData, CursorPopupLabel> _aliveCursorLabels = new();
public const float MinimumPopupLifetime = 0.7f;
public const float MaximumPopupLifetime = 5f;
public const float PopupLifetimePerCharacter = 0.04f;
public override void Initialize()
{
SubscribeNetworkEvent<PopupCursorEvent>(OnPopupCursorEvent);
SubscribeNetworkEvent<PopupCoordinatesEvent>(OnPopupCoordinatesEvent);
SubscribeNetworkEvent<PopupEntityEvent>(OnPopupEntityEvent);
SubscribeNetworkEvent<RoundRestartCleanupEvent>(OnRoundRestart);
_overlay
.AddOverlay(new PopupOverlay(
_configManager,
EntityManager,
_playerManager,
ProtoMan,
_uiManager,
_uiManager.GetUIController<PopupUIController>(),
_examine,
_transform,
this));
}
public override void Shutdown()
{
base.Shutdown();
_overlay
.RemoveOverlay<PopupOverlay>();
}
private void WrapAndRepeatPopup(PopupLabel existingLabel, string popupMessage)
{
existingLabel.TotalTime = 0;
existingLabel.Repeats += 1;
existingLabel.Text = Loc.GetString("popup-system-repeated-popup-stacking-wrap",
("popup-message", popupMessage),
("count", existingLabel.Repeats));
}
private void PopupMessage(string? message, PopupType type, EntityCoordinates coordinates, EntityUid? entity, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
{
if (entity != null)
_replayRecording.RecordClientMessage(new PopupEntityEvent(message, type, GetNetEntity(entity.Value)));
else
_replayRecording.RecordClientMessage(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)));
}
var popupData = new WorldPopupData(message, type, coordinates, entity);
if (_aliveWorldLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new WorldPopupLabel(coordinates)
{
Text = message,
Type = type,
};
_aliveWorldLabels.Add(popupData, label);
}
#region Abstract Method Implementations
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small)
{
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupMessage(message, type, coordinates, null, true);
}
public override void PopupPredictedCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupCoordinates(message, coordinates, recipient.Value, type);
}
private void PopupCursorInternal(string? message, PopupType type, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
_replayRecording.RecordClientMessage(new PopupCursorEvent(message, type));
var popupData = new CursorPopupData(message, type);
if (_aliveCursorLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new CursorPopupLabel(_inputManager.MouseScreenPosition)
{
Text = message,
Type = type,
};
_aliveCursorLabels.Add(popupData, label);
}
public override void PopupCursor(string? message, PopupType type = PopupType.Small)
{
if (!_timing.IsFirstTimePredicted)
return;
PopupCursorInternal(message, type, true);
}
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupCursor(message, type);
}
public override void PopupPredictedCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
public override void PopupPredictedCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool replayRecord, PopupType type = PopupType.Small)
{
PopupCoordinates(message, coordinates, type);
}
public override void PopupEntity(string? message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (!filter.Recipients.Contains(_playerManager.LocalSession))
return;
PopupEntity(message, uid, type);
}
public override void PopupClient(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupCursor(message, recipient.Value, type);
}
public override void PopupClient(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupClient(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
if (_timing.IsFirstTimePredicted)
PopupCoordinates(message, coordinates, recipient.Value, type);
}
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (TryComp(uid, out TransformComponent? transform))
PopupMessage(message, type, transform.Coordinates, uid, true);
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(message, uid, recipient.Value, type);
}
public override void PopupPredicted(string? recipientMessage, string? othersMessage, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient != null && _timing.IsFirstTimePredicted)
PopupEntity(recipientMessage, uid, recipient.Value, type);
}
#endregion
#region Network Event Handlers
private void OnPopupCursorEvent(PopupCursorEvent ev)
{
PopupCursorInternal(ev.Message, ev.Type, false);
}
private void OnPopupCoordinatesEvent(PopupCoordinatesEvent ev)
{
PopupMessage(ev.Message, ev.Type, GetCoordinates(ev.Coordinates), null, false);
}
private void OnPopupEntityEvent(PopupEntityEvent ev)
{
var entity = GetEntity(ev.Uid);
if (TryComp(entity, out TransformComponent? transform))
PopupMessage(ev.Message, ev.Type, transform.Coordinates, entity, false);
}
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_aliveCursorLabels.Clear();
_aliveWorldLabels.Clear();
}
#endregion
public static float GetPopupLifetime(PopupLabel label)
{
return Math.Clamp(PopupLifetimePerCharacter * label.Text.Length,
MinimumPopupLifetime,
MaximumPopupLifetime);
}
public override void FrameUpdate(float frameTime)
{
if (_aliveWorldLabels.Count == 0 && _aliveCursorLabels.Count == 0)
return;
if (_aliveWorldLabels.Count > 0)
{
var aliveWorldToRemove = new ValueList<WorldPopupData>();
foreach (var (data, label) in _aliveWorldLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label) || Deleted(label.InitialPos.EntityId))
{
aliveWorldToRemove.Add(data);
}
}
foreach (var data in aliveWorldToRemove)
{
_aliveWorldLabels.Remove(data);
}
}
if (_aliveCursorLabels.Count > 0)
{
var aliveCursorToRemove = new ValueList<CursorPopupData>();
foreach (var (data, label) in _aliveCursorLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label))
{
aliveCursorToRemove.Add(data);
}
}
foreach (var data in aliveCursorToRemove)
{
_aliveCursorLabels.Remove(data);
}
}
}
public abstract class PopupLabel
{
public PopupType Type = PopupType.Small;
public string Text { get; set; } = string.Empty;
public float TotalTime { get; set; }
public int Repeats = 1;
}
public sealed class WorldPopupLabel(EntityCoordinates coordinates) : PopupLabel
{
/// <summary>
/// The original EntityCoordinates of the label.
/// </summary>
public EntityCoordinates InitialPos = coordinates;
}
public sealed class CursorPopupLabel(ScreenCoordinates screenCoords) : PopupLabel
{
public ScreenCoordinates InitialPos = screenCoords;
}
[UsedImplicitly]
private record struct WorldPopupData(
string Message,
PopupType Type,
EntityCoordinates Coordinates,
EntityUid? Entity);
[UsedImplicitly]
private record struct CursorPopupData(
string Message,
PopupType Type);
_overlay.AddOverlay(new PopupOverlay(
_configManager,
EntityManager,
_playerManager,
ProtoMan,
_uiManager,
_uiManager.GetUIController<PopupUIController>(),
_examine,
_transform,
this));
}
public override void Shutdown()
{
base.Shutdown();
_overlay.RemoveOverlay<PopupOverlay>();
}
/// <summary>
/// If the same popup is repeated, this will make show x2, x3, x4, ... at the end of the message instead of creating a new, overlapping popup.
/// </summary>
private void WrapAndRepeatPopup(PopupLabel existingLabel, string popupMessage)
{
existingLabel.TotalTime = 0;
existingLabel.Repeats += 1;
existingLabel.Text = Loc.GetString("popup-system-repeated-popup-stacking-wrap",
("popup-message", popupMessage),
("count", existingLabel.Repeats));
}
/// <summary>
/// Interal implementation for both coordinates and entity popups.
/// </summary>
private void PopupInternal(string? message, PopupType type, EntityCoordinates coordinates, EntityUid? entity, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
{
if (entity != null)
_replayRecording.RecordClientMessage(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(entity.Value)));
else
_replayRecording.RecordClientMessage(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), 0));
}
var popupData = new WorldPopupData(message, type, coordinates, entity);
if (_aliveWorldLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new WorldPopupLabel(coordinates)
{
Text = message,
Type = type,
};
_aliveWorldLabels.Add(popupData, label);
}
/// <summary>
/// Internal implementation for cursor popups.
/// </summary>
private void PopupCursorInternal(string? message, PopupType type, bool recordReplay)
{
if (message == null)
return;
if (recordReplay && _replayRecording.IsRecording)
_replayRecording.RecordClientMessage(new PopupCursorEvent(message, type, Timing.CurTick));
var popupData = new CursorPopupData(message, type);
if (_aliveCursorLabels.TryGetValue(popupData, out var existingLabel))
{
WrapAndRepeatPopup(existingLabel, popupData.Message);
return;
}
var label = new CursorPopupLabel(_inputManager.MouseScreenPosition)
{
Text = message,
Type = type,
};
_aliveCursorLabels.Add(popupData, label);
}
#region Abstract Method Implementations
/// <summary>
/// Shows a popup at the local user's cursor.
/// </summary>
/// <remarks>
/// This overload only exists on the client. If you want to use this in Shared you should use the overload that takes a recipient, session or filter instead.
/// We do not add a virtual method to the shared system because that will cause problems if the shared code is run in a non-predicted way.
/// </remarks>
/// <param name="message">The message to display.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public void PopupCursor(string? message, PopupType type = PopupType.Small)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
_predictionInstances.Add(new PopupCursorEvent.PredictionInstance(message, type, Timing.CurTick));
PopupCursorInternal(message, type, true);
}
public override void PopupCursor(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupCursor(message, type);
}
public override void PopupCursor(string? message, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupCursor(message, type);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
_predictionInstances.Add(new PopupCoordinatesEvent.PredictionInstance(message, type, Timing.CurTick, predictionKey));
PopupInternal(message, type, coordinates, null, true);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (_playerManager.LocalSession == recipient)
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (_playerManager.LocalEntity == recipient)
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool replayRecord, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupCoordinates(message, coordinates, type, predictionKey);
}
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (!Timing.IsFirstTimePredicted || message is null)
return;
if (!TryComp(uid, out TransformComponent? transform))
return;
_predictionInstances.Add(new PopupEntityEvent.PredictionInstance(message, type, Timing.CurTick, GetNetEntity(uid)));
PopupInternal(message, type, transform.Coordinates, uid, true);
}
public override void PopupEntity(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalEntity == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (_playerManager.LocalSession == recipient)
PopupEntity(message, uid, type);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (filter.Recipients.Contains(_playerManager.LocalSession))
PopupEntity(message, uid, type);
}
#endregion
#region Network Event Handlers
[SubscribeNetworkEvent]
private void OnPopupCursorEvent(PopupCursorEvent ev)
{
var instance = new PopupCursorEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick);
if (_predictionInstances.Remove(instance))
return;
PopupCursorInternal(ev.Message, ev.Type, false);
}
[SubscribeNetworkEvent]
private void OnPopupCoordinatesEvent(PopupCoordinatesEvent ev)
{
var instance = new PopupCoordinatesEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick, ev.PredictionKey);
if (_predictionInstances.Remove(instance))
return;
PopupInternal(ev.Message, ev.Type, GetCoordinates(ev.Coordinates), null, false);
}
[SubscribeNetworkEvent]
private void OnPopupEntityEvent(PopupEntityEvent ev)
{
var instance = new PopupEntityEvent.PredictionInstance(ev.Message, ev.Type, ev.Tick, ev.Uid);
if (_predictionInstances.Remove(instance))
return;
var entity = GetEntity(ev.Uid);
if (TryComp(entity, out TransformComponent? transform))
PopupInternal(ev.Message, ev.Type, transform.Coordinates, entity, false);
}
[SubscribeNetworkEvent]
private void OnRoundRestart(RoundRestartCleanupEvent ev)
{
_aliveCursorLabels.Clear();
_aliveWorldLabels.Clear();
_predictionInstances.Clear();
}
#endregion
/// <summary>
/// Calculates the lifetime of a popup based on its text length.
/// </summary>
public static float GetPopupLifetime(PopupLabel label)
{
return Math.Clamp(PopupLifetimePerCharacter * label.Text.Length,
MinimumPopupLifetime,
MaximumPopupLifetime);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
if (!Timing.IsFirstTimePredicted)
return; // We only need to clean up once per tick.
// We only keep track of prediction instances for a short amount of time to prevent memory leaks.
// We can safely assume that if a popup hasn't been received from the server within a 10 seconds, it will never be received.
var deleteTickCount = Timing.TickRate * 10;
if (_predictionInstances.Count != 0)
{
_predictionInstances.RemoveAll(p => (int)Timing.CurTick.Value - (int)p.Tick.Value > deleteTickCount);
}
}
public override void FrameUpdate(float frameTime)
{
if (_aliveWorldLabels.Count == 0 && _aliveCursorLabels.Count == 0)
return;
if (_aliveWorldLabels.Count > 0)
{
var aliveWorldToRemove = new ValueList<WorldPopupData>();
foreach (var (data, label) in _aliveWorldLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label) || Deleted(label.InitialPos.EntityId))
{
aliveWorldToRemove.Add(data);
}
}
foreach (var data in aliveWorldToRemove)
{
_aliveWorldLabels.Remove(data);
}
}
if (_aliveCursorLabels.Count > 0)
{
var aliveCursorToRemove = new ValueList<CursorPopupData>();
foreach (var (data, label) in _aliveCursorLabels)
{
label.TotalTime += frameTime;
if (label.TotalTime > GetPopupLifetime(label))
{
aliveCursorToRemove.Add(data);
}
}
foreach (var data in aliveCursorToRemove)
{
_aliveCursorLabels.Remove(data);
}
}
}
public abstract class PopupLabel
{
public PopupType Type = PopupType.Small;
public string Text { get; set; } = string.Empty;
public float TotalTime { get; set; }
public int Repeats = 1;
}
public sealed class WorldPopupLabel(EntityCoordinates coordinates) : PopupLabel
{
/// <summary>
/// The original EntityCoordinates of the label.
/// </summary>
public EntityCoordinates InitialPos = coordinates;
}
public sealed class CursorPopupLabel(ScreenCoordinates screenCoords) : PopupLabel
{
public ScreenCoordinates InitialPos = screenCoords;
}
[UsedImplicitly]
private record struct WorldPopupData(
string Message,
PopupType Type,
EntityCoordinates Coordinates,
EntityUid? Entity);
[UsedImplicitly]
private record struct CursorPopupData(
string Message,
PopupType Type);
}

View file

@ -103,10 +103,10 @@ namespace Content.Server.Pointing.EntitySystems
// Someone pointing at YOU is slightly more important
var popupType = viewerEntity == pointed ? PopupType.Medium : PopupType.Small;
RaiseNetworkEvent(new PopupEntityEvent(message, popupType, netSource), viewerEntity);
RaiseNetworkEvent(new PopupEntityEvent(message, popupType, _gameTiming.CurTick, netSource), viewerEntity); // TODO: Make this use the popup system API
}
_replay.RecordServerMessage(new PopupEntityEvent(viewerMessage, PopupType.Small, netSource));
_replay.RecordServerMessage(new PopupEntityEvent(viewerMessage, PopupType.Small, _gameTiming.CurTick, netSource));
}
public bool InRange(EntityUid pointer, EntityCoordinates coordinates)

View file

@ -1,181 +1,108 @@
using Content.Shared.Popups;
using Robust.Server.GameObjects;
using Robust.Server.Player;
using Robust.Shared.Configuration;
using Robust.Shared.Map;
using Robust.Shared.Player;
namespace Content.Server.Popups
namespace Content.Server.Popups;
public sealed partial class PopupSystem : SharedPopupSystem
{
public sealed partial class PopupSystem : SharedPopupSystem
[Dependency] private IPlayerManager _player = default!;
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private SharedTransformSystem _transform = default!;
public override void PopupCursor(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
[Dependency] private IPlayerManager _player = default!;
[Dependency] private IConfigurationManager _cfg = default!;
[Dependency] private SharedTransformSystem _transform = default!;
if (message == null)
return;
public override void PopupCursor(string? message, PopupType type = PopupType.Small)
{
// No local user.
}
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupCursorEvent(message, type, Timing.CurTick), actor.PlayerSession);
}
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupCursorEvent(message, type), recipient);
}
RaiseNetworkEvent(new PopupCursorEvent(message, type, Timing.CurTick), recipient);
}
public override void PopupCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupCursor(string? message, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (message == null)
return;
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupCursorEvent(message, type), actor.PlayerSession);
}
RaiseNetworkEvent(new PopupCursorEvent(message, type, Timing.CurTick), filter, recordReplay);
}
public override void PopupPredictedCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
// Do nothing, since the client already predicted the popup.
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (message == null)
return;
public override void PopupPredictedCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
// Do nothing, since the client already predicted the popup.
}
var mapPos = _transform.ToMapCoordinates(coordinates);
var filter = Filter.Empty().AddPlayersByPvs(mapPos, entManager: EntityManager, playerMan: _player, cfgMan: _cfg);
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), predictionKey), filter);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool replayRecord, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)), filter, replayRecord);
}
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), predictionKey), actor.PlayerSession);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small)
{
if (message == null)
return;
var mapPos = _transform.ToMapCoordinates(coordinates);
var filter = Filter.Empty().AddPlayersByPvs(mapPos, entManager: EntityManager, playerMan: _player, cfgMan: _cfg);
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)), filter);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (message == null)
return;
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), predictionKey), recipient);
}
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)), recipient);
}
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool recordReplay, PopupType type = PopupType.Small, int predictionKey = 0)
{
if (message == null)
return;
public override void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, Timing.CurTick, GetNetCoordinates(coordinates), predictionKey), filter, recordReplay);
}
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)), actor.PlayerSession);
}
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupPredictedCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
var filter = Filter.Empty().AddPlayersByPvs(uid, entityManager: EntityManager, playerMan: _player, cfgMan: _cfg);
RaiseNetworkEvent(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(uid)), filter);
}
var mapPos = _transform.ToMapCoordinates(coordinates);
var filter = Filter.Empty().AddPlayersByPvs(mapPos, entManager: EntityManager, playerMan: _player, cfgMan: _cfg);
if (recipient != null)
{
// Don't send to recipient, since they predicted it locally
filter = filter.RemovePlayerByAttachedEntity(recipient.Value);
}
RaiseNetworkEvent(new PopupCoordinatesEvent(message, type, GetNetCoordinates(coordinates)), filter);
}
public override void PopupEntity(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small)
{
if (message == null)
return;
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(uid)), actor.PlayerSession);
}
var filter = Filter.Empty().AddPlayersByPvs(uid, entityManager: EntityManager, playerMan: _player, cfgMan: _cfg);
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), filter);
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupEntity(string? message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(uid)), recipient);
}
if (TryComp(recipient, out ActorComponent? actor))
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), actor.PlayerSession);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (message == null)
return;
public override void PopupClient(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
}
public override void PopupClient(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
// do nothing duh its for client only
}
public override void PopupClient(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
}
public override void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), recipient);
}
public override void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (message == null)
return;
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), filter, recordReplay);
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (message == null)
return;
if (recipient != null)
{
// Don't send to recipient, since they predicted it locally
var filter = Filter.PvsExcept(recipient.Value, entityManager: EntityManager);
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), filter);
}
else
{
// With no recipient, send to everyone (in PVS range)
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)));
}
}
public override void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
if (message == null)
return;
if (recipient != null)
{
// Don't send to recipient, since they predicted it locally
filter = filter.RemovePlayerByAttachedEntity(recipient.Value);
}
RaiseNetworkEvent(new PopupEntityEvent(message, type, GetNetEntity(uid)), filter, recordReplay);
}
public override void PopupPredicted(string? recipientMessage, string? othersMessage, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupPredicted(othersMessage, uid, recipient, type);
}
RaiseNetworkEvent(new PopupEntityEvent(message, type, Timing.CurTick, GetNetEntity(uid)), filter, recordReplay);
}
}

View file

@ -353,7 +353,7 @@ namespace Content.Server.Shuttles.Systems
if (!TryGetEntity(args.DockEntity, out var dockEnt) ||
!_dockingQuery.TryComp(dockEnt, out var dockComp))
{
_popup.PopupCursor(Loc.GetString("shuttle-console-undock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-undock-fail"), args.Actor);
return;
}
@ -361,7 +361,7 @@ namespace Content.Server.Shuttles.Systems
if (!CanUndock(dock))
{
_popup.PopupCursor(Loc.GetString("shuttle-console-undock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-undock-fail"), args.Actor);
return;
}
@ -374,7 +374,7 @@ namespace Content.Server.Shuttles.Systems
if (console == null)
{
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"), args.Actor);
return;
}
@ -382,7 +382,7 @@ namespace Content.Server.Shuttles.Systems
if (!CanShuttleDock(shuttleUid))
{
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"), args.Actor);
return;
}
@ -391,7 +391,7 @@ namespace Content.Server.Shuttles.Systems
!_dockingQuery.TryComp(ourDock, out var ourDockComp) ||
!_dockingQuery.TryComp(targetDock, out var targetDockComp))
{
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"), args.Actor);
return;
}
@ -399,7 +399,7 @@ namespace Content.Server.Shuttles.Systems
if (!TryComp(ourDock, out TransformComponent? xformA) ||
xformA.GridUid != shuttleUid)
{
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"), args.Actor);
return;
}
@ -407,7 +407,7 @@ namespace Content.Server.Shuttles.Systems
// Also need to check preventpilot + enabled / dockedwith
if (!CanDock((ourDock.Value, ourDockComp), (targetDock.Value, targetDockComp)))
{
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"));
_popup.PopupCursor(Loc.GetString("shuttle-console-dock-fail"), args.Actor);
return;
}

View file

@ -112,7 +112,7 @@ public abstract partial class InventorySystem
// before we drop the item, check that it can be equipped in the first place.
if (!CanEquip(actor, held.Value, ev.Slot, out var reason))
{
_popup.PopupCursor(Loc.GetString(reason));
_popup.PopupCursor(Loc.GetString(reason), actor);
return;
}
@ -134,7 +134,7 @@ public abstract partial class InventorySystem
if (!Resolve(target, ref inventory, false))
{
if (!silent)
_popup.PopupCursor(Loc.GetString("inventory-component-can-equip-cannot"));
_popup.PopupCursor(Loc.GetString("inventory-component-can-equip-cannot"), actor);
return false;
}
@ -145,14 +145,14 @@ public abstract partial class InventorySystem
if (!TryGetSlotContainer(target, slot, out var slotContainer, out var slotDefinition, inventory))
{
if (!silent)
_popup.PopupCursor(Loc.GetString("inventory-component-can-equip-cannot"));
_popup.PopupCursor(Loc.GetString("inventory-component-can-equip-cannot"), actor);
return false;
}
if (!force && !CanEquip(actor, target, itemUid, slot, out var reason, slotDefinition, inventory, clothing))
{
if (!silent)
_popup.PopupCursor(Loc.GetString(reason));
_popup.PopupCursor(Loc.GetString(reason), actor);
return false;
}
@ -190,7 +190,7 @@ public abstract partial class InventorySystem
if (!_containerSystem.Insert(itemUid, slotContainer))
{
if (!silent)
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"));
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"), actor);
return false;
}
@ -409,14 +409,14 @@ public abstract partial class InventorySystem
if (!Resolve(target, ref inventory, false))
{
if (!silent)
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"));
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"), actor);
return false;
}
if (!TryGetSlotContainer(target, slot, out var slotContainer, out var slotDefinition, inventory))
{
if (!silent)
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"));
_popup.PopupCursor(Loc.GetString("inventory-component-can-unequip-cannot"), actor);
return false;
}
@ -428,7 +428,7 @@ public abstract partial class InventorySystem
if (!force && !CanUnequip(actor, target, slot, out var reason, slotContainer, slotDefinition, inventory))
{
if (!silent)
_popup.PopupCursor(Loc.GetString(reason));
_popup.PopupCursor(Loc.GetString(reason), actor);
return false;
}

View file

@ -1,233 +1,326 @@
using Robust.Shared.Map;
using Robust.Shared.Player;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Popups
namespace Content.Shared.Popups;
/// <summary>
/// System for displaying small text popups on users' screens.
/// </summary>
public abstract partial class SharedPopupSystem : EntitySystem
{
/// <summary>
/// System for displaying small text popups on users' screens.
/// </summary>
public abstract class SharedPopupSystem : EntitySystem
{
/// <summary>
/// Shows a popup at the local users' cursor. Does nothing on the server.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, PopupType type = PopupType.Small);
/// <summary>
/// Shows a popup at a users' cursor.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="recipient">Client that will see this popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Shows a popup at a users' cursor.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="recipient">Client that will see this popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCursor(string?, ICommonSession, PopupType)"/> for use with prediction.
/// The local client will show the popup to the recipient. Does nothing on the server.
/// </summary>
public abstract void PopupPredictedCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCursor(string?, EntityUid, PopupType)"/> for use with prediction.
/// The local client will show the popup to the recipient. Does nothing on the server.
/// </summary>
public abstract void PopupPredictedCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small);
/// <summary>
/// Shows a popup at a world location to every entity in PVS range.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="coordinates">The coordinates where to display the message.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small);
/// <summary>
/// Filtered variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/>, which should only be used
/// if the filtering has to be more specific than simply PVS range based.
/// </summary>
/// <param name="filter">Filter for the players that will see the popup.</param>
/// <param name="recordReplay">If true, this pop-up will be considered as a globally visible pop-up that gets shown during replays.</param>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool recordReplay, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> that sends a pop-up to the player attached to some entity.
/// </summary>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> that sends a pop-up to a specific player.
/// </summary>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> for use with prediction. The local client will
/// the popup to the recipient, and the server will show it to every other player in PVS range. If recipient is null, the local
/// client will do nothing and the server will show the message to every player in PVS range.
/// </summary>
public abstract void PopupPredictedCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Shows a popup above an entity for every player in pvs range.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The UID of the entity.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupEntity(string? message, EntityUid uid, PopupType type=PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/> that shows the popup only to some specific client.
/// </summary>
public abstract void PopupEntity(string? message, EntityUid uid, EntityUid recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/> that shows the popup only to some specific client.
/// </summary>
public abstract void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Filtered variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/>, which should only be used
/// if the filtering has to be more specific than simply PVS range based.
/// </summary>
public abstract void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCursor(string, EntityUid, PopupType)"/> that only runs on the client, outside of prediction.
/// Useful for shared code that is always ran by both sides to avoid duplicate popups.
/// </summary>
public abstract void PopupClient(string? message, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, EntityUid, PopupType)"/> that only runs on the client, outside of prediction.
/// Useful for shared code that is always ran by both sides to avoid duplicate popups.
/// </summary>
public abstract void PopupClient(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> that only runs on the client, outside of prediction.
/// Useful for shared code that is always ran by both sides to avoid duplicate popups.
/// </summary>
public abstract void PopupClient(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, EntityUid, PopupType)"/> for use with prediction. The local client will show
/// the popup to the recipient, and the server will show it to every other player in PVS range. If recipient is null, the local client
/// will do nothing and the server will show the message to every player in PVS range.
/// </summary>
public abstract void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, Filter, bool, PopupType)"/> for use with prediction.
/// The local client will show the popup to the recipient, and the server will show it to players in the filter.
/// If recipient is null, the local client will do nothing and the server will show the message to players in the filter.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The entity to display the popup above.</param>
/// <param name="recipient">The client that will see this popup locally during prediction.</param>
/// <param name="filter">Filter for players that will see the popup from the server.</param>
/// <param name="recordReplay">If true, this pop-up will be considered as a globally visible pop-up that gets shown during replays.</param>
/// <param name="type">Used to customize how this popup should appear visually. See: <see cref="PopupType"/>.</param>
public abstract void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, Filter filter, bool recordReplay, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupPredicted(string?, EntityUid, EntityUid?, PopupType)"/> that displays <paramref name="recipientMessage"/>
/// to the recipient and <paramref name="othersMessage"/> to everyone else in PVS range.
/// </summary>
public abstract void PopupPredicted(string? recipientMessage, string? othersMessage, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small);
}
[Dependency] protected IGameTiming Timing = default!;
/// <summary>
/// Common base for all popup network events.
/// Shows a popup at a user's cursor.
/// </summary>
[Serializable, NetSerializable]
public abstract class PopupEvent : EntityEventArgs
{
public string Message { get; }
public PopupType Type { get; }
protected PopupEvent(string message, PopupType type)
{
Message = message;
Type = type;
}
}
/// <param name="message">The message to display.</param>
/// <param name="recipient">The entity whose attached player will see the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Network event for displaying a popup on the user's cursor.
/// Shows a popup at a user's cursor.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupCursorEvent : PopupEvent
{
public PopupCursorEvent(string message, PopupType type) : base(message, type)
{
}
}
/// <param name="message">The message to display.</param>
/// <param name="recipient">The player session that will see the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Network event for displaying a popup at a world location.
/// Shows a popup at some users' cursors.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupCoordinatesEvent : PopupEvent
{
public NetCoordinates Coordinates { get; }
public PopupCoordinatesEvent(string message, PopupType type, NetCoordinates coordinates) : base(message, type)
{
Coordinates = coordinates;
}
}
/// <param name="message">The message to display.</param>
/// <param name="filter">Filter for the clients that will see this popup.</param>
/// <param name="recordReplay">If true, this pop-up will be considered as a globally visible pop-up that gets shown during replays.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupCursor(string? message, Filter filter, bool recordReplay, PopupType type = PopupType.Small);
/// <summary>
/// Network event for displaying a popup above an entity.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupEntityEvent : PopupEvent
{
public NetEntity Uid { get; }
public PopupEntityEvent(string message, PopupType type, NetEntity uid) : base(message, type)
{
Uid = uid;
}
}
/// <summary>
/// Used to determine how a popup should appear visually to the client. Caution variants simply have a red color.
/// Shows a popup at a world location to every entity in PVS range.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="coordinates">The coordinates where to display the message.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
/// <param name="predictionKey">Additional key used to uniquely identify this popup event for prediction purposes.</param>
/// <remarks>
/// Actions which can fail or succeed should use a smaller popup for failure and a larger popup for success.
/// Actions which have different popups for the user vs. others should use a larger popup for the user and a smaller popup for others.
/// Actions which result in harm or are otherwise dangerous should always show as the caution variant.
/// In case your popup is predicted and may show up multiple times at different locations in a single tick, for each popup you should use a different prediction key,
/// which server and client have to agree on. Otherwise, the client may ignore some of the popups.
/// This is needed because the coordinates themselves cannot be part of the PredictionInstance, since they slightly differ between server and client due to
/// floating point precision issues and predictive movement, so two popups at different locations with the same message in the same tick would be considered identical.
/// The most common use case for this is when you delete an entity and spawn a popup at its location. For this you can use the NetEntity ID of the deleted entity as the prediction key.
/// </remarks>
[Serializable, NetSerializable]
public enum PopupType : byte
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, PopupType type = PopupType.Small, int predictionKey = 0);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> that sends a popup to the player attached to some entity.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="coordinates">The coordinates where to display the message.</param>
/// <param name="recipient">The entity whose attached player will see the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
/// <param name="predictionKey">Additional key used to uniquely identify this popup event for prediction purposes.</param>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small, int predictionKey = 0);
/// <summary>
/// Variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/> that sends a popup to a specific player.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="coordinates">The coordinates where to display the message.</param>
/// <param name="recipient">The player session that will see the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
/// <param name="predictionKey">Additional key used to uniquely identify this popup event for prediction purposes.</param>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, ICommonSession recipient, PopupType type = PopupType.Small, int predictionKey = 0);
/// <summary>
/// Filtered variant of <see cref="PopupCoordinates(string, EntityCoordinates, PopupType)"/>, which should only be used
/// if the filtering has to be more specific than simply PVS range based.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="coordinates">The coordinates where to display the message.</param>
/// <param name="filter">Filter for the clients that will see this popup.</param>
/// <param name="recordReplay">If true, this pop-up will be considered as a globally visible pop-up that gets shown during replays.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
/// <param name="predictionKey">Additional key used to uniquely identify this popup event for prediction purposes.</param>
public abstract void PopupCoordinates(string? message, EntityCoordinates coordinates, Filter filter, bool recordReplay, PopupType type = PopupType.Small, int predictionKey = 0);
/// <summary>
/// Shows a popup above an entity for every player in PVS range.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The entity above which to display the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupEntity(string? message, EntityUid uid, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/> that shows the popup only to some specific client.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The entity above which to display the popup.</param>
/// <param name="recipient">The entity whose attached player will see the popup.</param
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupEntity(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/> that shows the popup only to some specific client.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The entity above which to display the popup.</param>
/// <param name="recipient">The player session that will see the popup.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupEntity(string? message, EntityUid uid, ICommonSession recipient, PopupType type = PopupType.Small);
/// <summary>
/// Filtered variant of <see cref="PopupEntity(string, EntityUid, PopupType)"/>, which should only be used
/// if the filtering has to be more specific than simply PVS range based.
/// </summary>
/// <param name="message">The message to display.</param>
/// <param name="uid">The entity above which to display the popup.</param>
/// <param name="filter">Filter for the clients that will see this popup.</param>
/// <param name="recordReplay">If true, this pop-up will be considered as a globally visible pop-up that gets shown during replays.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param>
public abstract void PopupEntity(string? message, EntityUid uid, Filter filter, bool recordReplay, PopupType type = PopupType.Small);
/// <summary>
/// Variant of <see cref="PopupEntity(string?, EntityUid, PopupType)"/> that displays <paramref name="recipientMessage"/>
/// to the recipient and <paramref name="othersMessage"/> to everyone else in PVS range.
/// </summary>
/// <param name="recipientMessage">The message to display to the recipient.</param>
/// <param name="othersMessage">The message to display to everyone else in PVS range.</param>
/// <param name="uid">The entity above which to display the popup.</param>
/// <param name="recipient">The entity whose attached player will see the recipient message.</param>
/// <param name="type">Used to customize how this popup should appear visually.</param
public void PopupEntity(string? recipientMessage,
string? othersMessage,
EntityUid uid,
EntityUid? recipient,
PopupType type = PopupType.Small)
{
/// <summary>
/// Small popups are the default, and denote actions that may be spammable or are otherwise unimportant.
/// </summary>
Small,
SmallCaution,
/// <summary>
/// Medium popups should be used for actions which are not spammable but may not be particularly important.
/// </summary>
Medium,
MediumCaution,
/// <summary>
/// Large popups should be used for actions which may be important or very important to one or more users,
/// but is not life-threatening.
/// </summary>
Large,
LargeCaution
if (recipient.HasValue)
{
PopupEntity(othersMessage, uid, Filter.PvsExcept(recipient.Value), true, type);
PopupEntity(recipientMessage, uid, recipient.Value, type);
}
else
{
PopupEntity(othersMessage, uid, type);
}
}
[Obsolete("Popups are automatically predicted now, just call PopupCursor and the client will handle prediction.")]
public void PopupPredictedCursor(string? message, EntityUid recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
[Obsolete("Popups are automatically predicted now, just call PopupCursor and the client will handle prediction.")]
public void PopupPredictedCursor(string? message, ICommonSession recipient, PopupType type = PopupType.Small)
{
PopupCursor(message, recipient, type);
}
[Obsolete("Popups are automatically predicted now, just call PopupCoordinates and the client will handle prediction.")]
public void PopupPredictedCoordinates(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupCoordinates(message, coordinates, type); // The recipent was only used for prediction reasons, not as a filter, so we ignore it here.
}
[Obsolete("Popups are automatically predicted now, just call PopupEntity and the client will handle prediction.")]
public void PopupClient(string? message, EntityUid? recipient, PopupType type = PopupType.Small)
{
if (recipient == null)
return;
PopupEntity(message, recipient.Value, recipient, type); // Only show the popup to the recipient, since this was the original behavior.
}
[Obsolete("Popups are automatically predicted now, just call PopupEntity and the client will handle prediction.")]
public void PopupClient(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupEntity(message, uid, recipient, type); // Only show the popup to the recipient, since this was the original behavior.
}
[Obsolete("Popups are automatically predicted now, just call PopupCoordinates and the client will handle prediction.")]
public void PopupClient(string? message, EntityCoordinates coordinates, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupCoordinates(message, coordinates, recipient, type); // Only show the popup to the recipient, since this was the original behavior.
}
[Obsolete("Popups are automatically predicted now, just call PopupEntity and the client will handle prediction.")]
public void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupEntity(message, uid, type); // The recipent was only used for prediction reasons, not as a filter, so we ignore it here.
}
[Obsolete("Popups are automatically predicted now, just call PopupEntity and the client will handle prediction.")]
public void PopupPredicted(string? message, EntityUid uid, EntityUid? recipient, Filter filter, bool recordReplay, PopupType type = PopupType.Small)
{
PopupEntity(message, uid, filter, recordReplay, type); // The recipent was only used for prediction reasons, not as a filter, so we ignore it here.
}
[Obsolete("Popups are automatically predicted now, just call PopupEntity and the client will handle prediction.")]
public void PopupPredicted(string? recipientMessage, string? othersMessage, EntityUid uid, EntityUid? recipient, PopupType type = PopupType.Small)
{
PopupEntity(recipientMessage, othersMessage, uid, recipient, type);
}
}
/// <summary>
/// Common base for all popup network events.
/// </summary>
[Serializable, NetSerializable]
public abstract class PopupEvent(string message, PopupType type, GameTick tick) : EntityEventArgs
{
/// <summary>
/// The message to display.
/// </summary>
public string Message = message;
/// <summary>
/// The type of the popup.
/// </summary>
public PopupType Type = type;
/// <summary>
/// The game tick at which the popup was created.
/// </summary>
public GameTick Tick = tick;
}
/// <summary>
/// Interface for a prediction instance of a popup event.
/// Used to keep track if a popup has already been predicted and displayed on the client side, to avoid duplicate popups.
/// </summary>
public interface IPopupPredictionInstance
{
/// <summary>
/// The game tick at which the popup was created.
/// </summary>
GameTick Tick { get; }
}
/// <summary>
/// Network event for displaying a popup on the user's cursor.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupCursorEvent(string message, PopupType type, GameTick tick) : PopupEvent(message, type, tick)
{
/// <summary>
/// Creates a new prediction instance for this popup event.
/// </summary>
public readonly record struct PredictionInstance(string Message, PopupType Type, GameTick Tick) : IPopupPredictionInstance;
}
/// <summary>
/// Network event for displaying a popup at a world location.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupCoordinatesEvent(string message, PopupType type, GameTick tick, NetCoordinates coordinates, int predictionKey) : PopupEvent(message, type, tick)
{
/// <summary>
/// The coordinates where the popup should be displayed.
/// </summary>
public NetCoordinates Coordinates = coordinates;
/// <summary>
/// The key used to identify this popup event for prediction purposes.
/// </summary>
public int PredictionKey = predictionKey;
/// <summary>
/// Creates a new prediction instance for this popup event.
/// </summary>
/// <remarks>
/// TODO: remove coords, as they are not used for prediction.
/// </remarks>
public readonly record struct PredictionInstance(string Message, PopupType Type, GameTick Tick, int PredictionKey) : IPopupPredictionInstance;
}
/// <summary>
/// Network event for displaying a popup above an entity.
/// </summary>
[Serializable, NetSerializable]
public sealed class PopupEntityEvent(string message, PopupType type, GameTick tick, NetEntity uid) : PopupEvent(message, type, tick)
{
/// <summary>
/// The entity above which the popup should be displayed.
/// </summary>
public NetEntity Uid = uid;
/// <summary>
/// Creates a new prediction instance for this popup event.
/// </summary>
public readonly record struct PredictionInstance(string Message, PopupType Type, GameTick Tick, NetEntity Uid) : IPopupPredictionInstance;
}
/// <summary>
/// Used to determine how a popup should appear visually to the client. Caution variants simply have a red color.
/// </summary>
/// <remarks>
/// Actions which can fail or succeed should use a smaller popup for failure and a larger popup for success.
/// Actions which have different popups for the user vs. others should use a larger popup for the user and a smaller popup for others.
/// Actions which result in harm or are otherwise dangerous should always show as the caution variant.
/// </remarks>
[Serializable, NetSerializable]
public enum PopupType : byte
{
/// <summary>
/// Small popups are the default, and denote actions that may be spammable or are otherwise unimportant.
/// </summary>
Small,
SmallCaution,
/// <summary>
/// Medium popups should be used for actions which are not spammable but may not be particularly important.
/// </summary>
Medium,
MediumCaution,
/// <summary>
/// Large popups should be used for actions which may be important or very important to one or more users,
/// but is not life-threatening.
/// </summary>
Large,
LargeCaution
}

View file

@ -158,7 +158,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (!_handsSystem.CanDropHeld(user, user.Comp.ActiveHandId!))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop"));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop"), user);
return false;
}
@ -166,13 +166,13 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (_inventorySystem.TryGetSlotEntity(target, slot, out _))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-occupied", ("owner", targetIdentity)));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-occupied", ("owner", targetIdentity)), user);
return false;
}
if (!_inventorySystem.CanEquip(user, target, held, slot, out _))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-equip-message", ("owner", targetIdentity)));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-equip-message", ("owner", targetIdentity)), user);
return false;
}
@ -261,7 +261,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
{
if (!_inventorySystem.TryGetSlotEntity(target, slot, out var slotItem))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-free-message", ("owner", Identity.Entity(target, EntityManager))));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-free-message", ("owner", Identity.Entity(target, EntityManager))), user);
return false;
}
@ -270,7 +270,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (!_inventorySystem.CanUnequip(user, target, slot, out var reason))
{
_popupSystem.PopupCursor(Loc.GetString(reason));
_popupSystem.PopupCursor(Loc.GetString(reason), user);
return false;
}
@ -375,13 +375,13 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (!_handsSystem.CanDropHeld(user, user.Comp.ActiveHandId!))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop"));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop"), user);
return false;
}
if (!_handsSystem.CanPickupToHand(target, activeItem.Value, handName, checkActionBlocker: false, handsComp: target.Comp))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-put-message", ("owner", Identity.Entity(target, EntityManager))));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-put-message", ("owner", Identity.Entity(target, EntityManager))), user);
return false;
}
@ -476,7 +476,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (!_handsSystem.TryGetHand(target, handName, out _))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-free-message", ("owner", Identity.Entity(target, EntityManager))));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-item-slot-free-message", ("owner", Identity.Entity(target, EntityManager))), user);
return false;
}
@ -491,7 +491,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (!_handsSystem.CanDropHeld(target, handName, false))
{
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop-message", ("owner", Identity.Entity(target, EntityManager))));
_popupSystem.PopupCursor(Loc.GetString("strippable-component-cannot-drop-message", ("owner", Identity.Entity(target, EntityManager))), user);
return false;
}
@ -581,7 +581,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
if (ev.Event.InventoryOrHand)
{
if ( ev.Event.InsertOrRemove && !CanStripInsertInventory((entity.Owner, entity.Comp), args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName) ||
if (ev.Event.InsertOrRemove && !CanStripInsertInventory((entity.Owner, entity.Comp), args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName) ||
!ev.Event.InsertOrRemove && !CanStripRemoveInventory(entity.Owner, args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName))
{
ev.Cancel();
@ -589,7 +589,7 @@ public abstract partial class SharedStrippableSystem : EntitySystem
}
else
{
if ( ev.Event.InsertOrRemove && !CanStripInsertHand((entity.Owner, entity.Comp), args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName) ||
if (ev.Event.InsertOrRemove && !CanStripInsertHand((entity.Owner, entity.Comp), args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName) ||
!ev.Event.InsertOrRemove && !CanStripRemoveHand(entity.Owner, args.Target.Value, args.Used.Value, ev.Event.SlotOrHandName))
{
ev.Cancel();

View file

@ -376,7 +376,7 @@ public abstract partial class SharedGunSystem : EntitySystem
// If they're firing an existing clip then don't play anything.
if (shots > 0)
{
PopupSystem.PopupCursor(ev.Reason ?? Loc.GetString("gun-magazine-fired-empty"));
PopupSystem.PopupCursor(ev.Reason ?? Loc.GetString("gun-magazine-fired-empty"), user);
// Don't spam safety sounds at gun fire rate, play it at a reduced rate.
// May cause prediction issues? Needs more tweaking