Alerts System and UI (#2529)

* #272 add bordered panel for effects bar

* #272 avoid mouse overlapping tooltip when near edges,
change tooltip colors to match mockups

* #272 WIP defining status effect states as YML and
sending them as encoded integers

* #272 refactor to use new alert system

* #272 refactor to use new alert system

* #272 fix various bugs with new alert system and update
alerts to have color

* #272 WIP

* #272 rename status effects to alerts

* #272 WIP reworking alert internals to avoid code dup
and eliminate enum

* #272 refactor alerts to use
categories and fix various bugs

* #272 more alert bugfixes

* #272 alert ordering

* #272 callback-based approach for alert clicks

* #272 add debug commands for alerts

* #272 utilize new GridContainer capabilities for sizing of alerts tab

* #272 scale alerts height based on
window size

* #272 fix tooltip flicker

* #272 transparent alert panel

* #272 adjust styles to match injazz mockups more, add cooldown info in tooltip

* #272 adjust styles to match injazz mockups more, add cooldown info in tooltip

* #272 alert prototype tests

* #272 alert manager tests

* #272 alert order tests

* #272 simple unit test for alerts component

* #272 integration test for alerts

* #272 rework alerts to use enums instead
of id / category

* #272 various cleanups for PR

* #272 use byte for more compact alert messages

* #272 rename StatusEffects folder to Alerts,
add missing NetSerializable
This commit is contained in:
chairbender
2020-11-09 20:22:19 -08:00
committed by GitHub
parent c82199610d
commit 5f788c3318
86 changed files with 2305 additions and 598 deletions

View File

@@ -0,0 +1,279 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Shared.Alert;
using Robust.Shared.GameObjects;
using Robust.Shared.Interfaces.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Serialization;
using Robust.Shared.Utility;
using Robust.Shared.ViewVariables;
namespace Content.Shared.GameObjects.Components.Mobs
{
/// <summary>
/// Handles the icons on the right side of the screen.
/// Should only be used for player-controlled entities.
/// </summary>
public abstract class SharedAlertsComponent : Component
{
private static readonly AlertState[] NO_ALERTS = new AlertState[0];
[Dependency]
protected readonly AlertManager AlertManager = default!;
public override string Name => "AlertsUI";
public override uint? NetID => ContentNetIDs.ALERTS;
[ViewVariables]
private Dictionary<AlertKey, ClickableAlertState> _alerts = new Dictionary<AlertKey, ClickableAlertState>();
/// <returns>true iff an alert of the indicated alert category is currently showing</returns>
public bool IsShowingAlertCategory(AlertCategory alertCategory)
{
return IsShowingAlert(AlertKey.ForCategory(alertCategory));
}
/// <returns>true iff an alert of the indicated id is currently showing</returns>
public bool IsShowingAlert(AlertType alertType)
{
if (AlertManager.TryGet(alertType, out var alert))
{
return IsShowingAlert(alert.AlertKey);
}
Logger.DebugS("alert", "unknown alert type {0}", alertType);
return false;
}
/// <returns>true iff an alert of the indicated key is currently showing</returns>
protected bool IsShowingAlert(AlertKey alertKey)
{
return _alerts.ContainsKey(alertKey);
}
protected IEnumerable<AlertState> EnumerateAlertStates()
{
return _alerts.Values.Select(alertData => alertData.AlertState);
}
/// <summary>
/// Invokes the alert's specified callback if there is one.
/// Not intended to be used on clientside.
/// </summary>
protected void PerformAlertClickCallback(AlertPrototype alert, IEntity owner)
{
if (_alerts.TryGetValue(alert.AlertKey, out var alertStateCallback))
{
alertStateCallback.OnClickAlert?.Invoke(new ClickAlertEventArgs(owner, alert));
}
else
{
Logger.DebugS("alert", "player {0} attempted to invoke" +
" alert click for {1} but that alert is not currently" +
" showing", owner.Name, alert.AlertType);
}
}
/// <summary>
/// Creates a new array containing all of the current alert states.
/// </summary>
/// <returns></returns>
protected AlertState[] CreateAlertStatesArray()
{
if (_alerts.Count == 0) return NO_ALERTS;
var states = new AlertState[_alerts.Count];
// because I don't trust LINQ
var idx = 0;
foreach (var alertData in _alerts.Values)
{
states[idx++] = alertData.AlertState;
}
return states;
}
protected bool TryGetAlertState(AlertKey key, out AlertState alertState)
{
if (_alerts.TryGetValue(key, out var alertData))
{
alertState = alertData.AlertState;
return true;
}
alertState = default;
return false;
}
/// <summary>
/// Replace the current active alerts with the specified alerts. Any
/// OnClickAlert callbacks on the active alerts will be erased.
/// </summary>
protected void SetAlerts(AlertState[] alerts)
{
var newAlerts = new Dictionary<AlertKey, ClickableAlertState>();
foreach (var alertState in alerts)
{
if (AlertManager.TryDecode(alertState.AlertEncoded, out var alert))
{
newAlerts[alert.AlertKey] = new ClickableAlertState
{
AlertState = alertState
};
}
else
{
Logger.ErrorS("alert", "unrecognized encoded alert {0}", alertState.AlertEncoded);
}
}
_alerts = newAlerts;
}
/// <summary>
/// Shows the alert. If the alert or another alert of the same category is already showing,
/// it will be updated / replaced with the specified values.
/// </summary>
/// <param name="alertType">type of the alert to set</param>
/// <param name="onClickAlert">callback to invoke when ClickAlertMessage is received by the server
/// after being clicked by client. Has no effect when specified on the clientside.</param>
/// <param name="severity">severity, if supported by the alert</param>
/// <param name="cooldown">cooldown start and end, if null there will be no cooldown (and it will
/// be erased if there is currently a cooldown for the alert)</param>
public void ShowAlert(AlertType alertType, short? severity = null, OnClickAlert onClickAlert = null,
ValueTuple<TimeSpan, TimeSpan>? cooldown = null)
{
if (AlertManager.TryGetWithEncoded(alertType, out var alert, out var encoded))
{
if (_alerts.TryGetValue(alert.AlertKey, out var alertStateCallback) &&
alertStateCallback.AlertState.AlertEncoded == encoded &&
alertStateCallback.AlertState.Severity == severity && alertStateCallback.AlertState.Cooldown == cooldown)
{
alertStateCallback.OnClickAlert = onClickAlert;
return;
}
_alerts[alert.AlertKey] = new ClickableAlertState
{
AlertState = new AlertState
{Cooldown = cooldown, AlertEncoded = encoded, Severity = severity},
OnClickAlert = onClickAlert
};
Dirty();
}
else
{
Logger.ErrorS("alert", "Unable to show alert {0}, please ensure this alertType has" +
" a corresponding YML alert prototype",
alertType);
}
}
/// <summary>
/// Clear the alert with the given category, if one is currently showing.
/// </summary>
public void ClearAlertCategory(AlertCategory category)
{
var key = AlertKey.ForCategory(category);
if (!_alerts.Remove(key))
{
return;
}
AfterClearAlert();
Dirty();
}
/// <summary>
/// Clear the alert of the given type if it is currently showing.
/// </summary>
public void ClearAlert(AlertType alertType)
{
if (AlertManager.TryGet(alertType, out var alert))
{
if (!_alerts.Remove(alert.AlertKey))
{
return;
}
AfterClearAlert();
Dirty();
}
else
{
Logger.ErrorS("alert", "unable to clear alert, unknown alertType {0}", alertType);
}
}
/// <summary>
/// Invoked after clearing an alert prior to dirtying the control
/// </summary>
protected virtual void AfterClearAlert() { }
}
[Serializable, NetSerializable]
public class AlertsComponentState : ComponentState
{
public AlertState[] Alerts;
public AlertsComponentState(AlertState[] alerts) : base(ContentNetIDs.ALERTS)
{
Alerts = alerts;
}
}
/// <summary>
/// A message that calls the click interaction on a alert
/// </summary>
[Serializable, NetSerializable]
public class ClickAlertMessage : ComponentMessage
{
public readonly byte EncodedAlert;
public ClickAlertMessage(byte encodedAlert)
{
Directed = true;
EncodedAlert = encodedAlert;
}
}
[Serializable, NetSerializable]
public struct AlertState
{
public byte AlertEncoded;
public short? Severity;
public ValueTuple<TimeSpan, TimeSpan>? Cooldown;
}
public struct ClickableAlertState
{
public AlertState AlertState;
public OnClickAlert OnClickAlert;
}
public delegate void OnClickAlert(ClickAlertEventArgs args);
public class ClickAlertEventArgs : EventArgs
{
/// <summary>
/// Player clicking the alert
/// </summary>
public readonly IEntity Player;
/// <summary>
/// Alert that was clicked
/// </summary>
public readonly AlertPrototype Alert;
public ClickAlertEventArgs(IEntity player, AlertPrototype alert)
{
Player = player;
Alert = alert;
}
}
}

View File

@@ -1,76 +0,0 @@
using System;
using System.Collections.Generic;
using Robust.Shared.GameObjects;
using Robust.Shared.Serialization;
namespace Content.Shared.GameObjects.Components.Mobs
{
/// <summary>
/// Handles the icons on the right side of the screen.
/// Should only be used for player-controlled entities
/// </summary>
public abstract class SharedStatusEffectsComponent : Component
{
public override string Name => "StatusEffectsUI";
public override uint? NetID => ContentNetIDs.STATUSEFFECTS;
public abstract IReadOnlyDictionary<StatusEffect, StatusEffectStatus> Statuses { get; }
public abstract void ChangeStatusEffectIcon(StatusEffect effect, string icon);
public abstract void ChangeStatusEffect(StatusEffect effect, string icon, ValueTuple<TimeSpan, TimeSpan>? cooldown);
public abstract void RemoveStatusEffect(StatusEffect effect);
}
[Serializable, NetSerializable]
public class StatusEffectComponentState : ComponentState
{
public Dictionary<StatusEffect, StatusEffectStatus> StatusEffects;
public StatusEffectComponentState(Dictionary<StatusEffect, StatusEffectStatus> statusEffects) : base(ContentNetIDs.STATUSEFFECTS)
{
StatusEffects = statusEffects;
}
}
/// <summary>
/// A message that calls the click interaction on a status effect
/// </summary>
[Serializable, NetSerializable]
public class ClickStatusMessage : ComponentMessage
{
public readonly StatusEffect Effect;
public ClickStatusMessage(StatusEffect effect)
{
Directed = true;
Effect = effect;
}
}
[Serializable, NetSerializable]
public struct StatusEffectStatus
{
public string Icon;
public ValueTuple<TimeSpan, TimeSpan>? Cooldown;
}
// Each status effect is assumed to be unique
public enum StatusEffect
{
Health,
Hunger,
Thirst,
Pressure,
Fire,
Temperature,
Stun,
Cuffed,
Buckled,
Piloting,
Pulling,
Pulled,
Weightless
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Threading;
using Content.Shared.Alert;
using Content.Shared.GameObjects.Components.Movement;
using Content.Shared.GameObjects.EntitySystems;
using Content.Shared.Interfaces.GameObjects.Components;
@@ -41,7 +42,7 @@ namespace Content.Shared.GameObjects.Components.Mobs
protected float KnockdownTimer;
protected float SlowdownTimer;
private string _stunTexture;
private string _stunAlertId;
protected CancellationTokenSource StatusRemoveCancellation = new CancellationTokenSource();
@@ -117,7 +118,7 @@ namespace Content.Shared.GameObjects.Components.Mobs
StunnedTimer = seconds;
LastStun = _gameTiming.CurTime;
SetStatusEffect();
SetAlert();
OnStun();
Dirty();
@@ -144,7 +145,7 @@ namespace Content.Shared.GameObjects.Components.Mobs
KnockdownTimer = seconds;
LastStun = _gameTiming.CurTime;
SetStatusEffect();
SetAlert();
OnKnockdown();
Dirty();
@@ -186,18 +187,18 @@ namespace Content.Shared.GameObjects.Components.Mobs
if (Owner.TryGetComponent(out MovementSpeedModifierComponent movement))
movement.RefreshMovementSpeedModifiers();
SetStatusEffect();
SetAlert();
Dirty();
}
private void SetStatusEffect()
private void SetAlert()
{
if (!Owner.TryGetComponent(out SharedStatusEffectsComponent status))
if (!Owner.TryGetComponent(out SharedAlertsComponent status))
{
return;
}
status.ChangeStatusEffect(StatusEffect.Stun, _stunTexture,
status.ShowAlert(AlertType.Stun, cooldown:
(StunStart == null || StunEnd == null) ? default : (StunStart.Value, StunEnd.Value));
StatusRemoveCancellation.Cancel();
StatusRemoveCancellation = new CancellationTokenSource();
@@ -212,8 +213,8 @@ namespace Content.Shared.GameObjects.Components.Mobs
serializer.DataField(ref _slowdownCap, "slowdownCap", 20f);
serializer.DataField(ref _helpInterval, "helpInterval", 1f);
serializer.DataField(ref _helpKnockdownRemove, "helpKnockdownRemove", 1f);
serializer.DataField(ref _stunTexture, "stunTexture",
"/Textures/Objects/Weapons/Melee/stunbaton.rsi/stunbaton_off.png");
serializer.DataField(ref _stunAlertId, "stunAlertId",
"stun");
}
protected virtual void OnInteractHand() { }
@@ -230,7 +231,7 @@ namespace Content.Shared.GameObjects.Components.Mobs
KnockdownTimer -= _helpKnockdownRemove;
SetStatusEffect();
SetAlert();
Dirty();
return true;

View File

@@ -1,12 +1,15 @@
#nullable enable
using System;
using Content.Shared.Alert;
using Content.Shared.GameObjects.Components.Mobs;
using Content.Shared.GameObjects.EntitySystems;
using Content.Shared.Physics;
using Content.Shared.Physics.Pull;
using Robust.Shared.Containers;
using Robust.Shared.GameObjects;
using Robust.Shared.GameObjects.ComponentDependencies;
using Robust.Shared.GameObjects.Components;
using Robust.Shared.GameObjects.Systems;
using Robust.Shared.Interfaces.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Physics;
@@ -204,29 +207,36 @@ namespace Content.Shared.GameObjects.Components.Pulling
private void AddPullingStatuses(IEntity puller)
{
if (Owner.TryGetComponent(out SharedStatusEffectsComponent? pulledStatus))
if (Owner.TryGetComponent(out SharedAlertsComponent? pulledStatus))
{
pulledStatus.ChangeStatusEffectIcon(StatusEffect.Pulled,
"/Textures/Interface/StatusEffects/Pull/pulled.png");
pulledStatus.ShowAlert(AlertType.Pulled);
}
if (puller.TryGetComponent(out SharedStatusEffectsComponent? ownerStatus))
if (puller.TryGetComponent(out SharedAlertsComponent? ownerStatus))
{
ownerStatus.ChangeStatusEffectIcon(StatusEffect.Pulling,
"/Textures/Interface/StatusEffects/Pull/pulling.png");
ownerStatus.ShowAlert(AlertType.Pulling, onClickAlert: OnClickAlert);
}
}
private void OnClickAlert(ClickAlertEventArgs args)
{
EntitySystem
.Get<SharedPullingSystem>()
.GetPulled(args.Player)?
.GetComponentOrNull<SharedPullableComponent>()?
.TryStopPull();
}
private void RemovePullingStatuses(IEntity puller)
{
if (Owner.TryGetComponent(out SharedStatusEffectsComponent? pulledStatus))
if (Owner.TryGetComponent(out SharedAlertsComponent? pulledStatus))
{
pulledStatus.RemoveStatusEffect(StatusEffect.Pulled);
pulledStatus.ClearAlert(AlertType.Pulled);
}
if (puller.TryGetComponent(out SharedStatusEffectsComponent? ownerStatus))
if (puller.TryGetComponent(out SharedAlertsComponent? ownerStatus))
{
ownerStatus.RemoveStatusEffect(StatusEffect.Pulling);
ownerStatus.ClearAlert(AlertType.Pulling);
}
}