diff --git a/Content.Client/ClientContentIoC.cs b/Content.Client/ClientContentIoC.cs index e7d8d90841..7c338bd292 100644 --- a/Content.Client/ClientContentIoC.cs +++ b/Content.Client/ClientContentIoC.cs @@ -11,6 +11,7 @@ using Content.Client.UserInterface.AdminMenu; using Content.Client.UserInterface.Stylesheets; using Content.Client.Utility; using Content.Shared.Interfaces; +using Content.Shared.Alert; using Robust.Shared.IoC; namespace Content.Client @@ -35,6 +36,7 @@ namespace Content.Client IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Client/EntryPoint.cs b/Content.Client/EntryPoint.cs index ffa05119bc..f34124b893 100644 --- a/Content.Client/EntryPoint.cs +++ b/Content.Client/EntryPoint.cs @@ -22,6 +22,7 @@ using Content.Shared.GameObjects.Components.Power.AME; using Content.Shared.GameObjects.Components.Research; using Content.Shared.GameObjects.Components.VendingMachines; using Content.Shared.Kitchen; +using Content.Shared.Alert; using Robust.Client; using Robust.Client.Interfaces; using Robust.Client.Interfaces.Graphics.Overlays; @@ -150,6 +151,7 @@ namespace Content.Client IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); _baseClient.RunLevelChanged += (sender, args) => { diff --git a/Content.Client/GameObjects/Components/Mobs/AlertControl.cs b/Content.Client/GameObjects/Components/Mobs/AlertControl.cs new file mode 100644 index 0000000000..b1a60f5fc9 --- /dev/null +++ b/Content.Client/GameObjects/Components/Mobs/AlertControl.cs @@ -0,0 +1,93 @@ +#nullable enable +using System; +using Content.Client.UserInterface; +using Content.Client.Utility; +using Content.Shared.Alert; +using OpenToolkit.Mathematics; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.UserInterface.Controls; + +namespace Content.Client.GameObjects.Components.Mobs +{ + public class AlertControl : BaseButton + { + public AlertPrototype Alert { get; } + + /// + /// Total duration of the cooldown in seconds. Null if no duration / cooldown. + /// + public int? TotalDuration { get; set; } + + private short? _severity; + private readonly TextureRect _icon; + private CooldownGraphic _cooldownGraphic; + + private readonly IResourceCache _resourceCache; + + + /// + /// Creates an alert control reflecting the indicated alert + state + /// + /// alert to display + /// severity of alert, null if alert doesn't have severity levels + /// resourceCache to use to load alert icon textures + public AlertControl(AlertPrototype alert, short? severity, IResourceCache resourceCache) + { + _resourceCache = resourceCache; + Alert = alert; + _severity = severity; + var texture = _resourceCache.GetTexture(alert.GetIconPath(_severity)); + _icon = new TextureRect + { + TextureScale = (2, 2), + Texture = texture + }; + + Children.Add(_icon); + _cooldownGraphic = new CooldownGraphic(); + Children.Add(_cooldownGraphic); + + } + + /// + /// Change the alert severity, changing the displayed icon + /// + public void SetSeverity(short? severity) + { + if (_severity != severity) + { + _severity = severity; + _icon.Texture = _resourceCache.GetTexture(Alert.GetIconPath(_severity)); + } + } + + /// + /// Updates the displayed cooldown amount, doing nothing if alertCooldown is null + /// + /// cooldown start and end + /// current game time + public void UpdateCooldown((TimeSpan Start, TimeSpan End)? alertCooldown, in TimeSpan curTime) + { + if (!alertCooldown.HasValue) + { + _cooldownGraphic.Progress = 0; + _cooldownGraphic.Visible = false; + TotalDuration = null; + } + else + { + + var start = alertCooldown.Value.Start; + var end = alertCooldown.Value.End; + + var length = (end - start).TotalSeconds; + var progress = (curTime - start).TotalSeconds / length; + var ratio = (progress <= 1 ? (1 - progress) : (curTime - end).TotalSeconds * -5); + + TotalDuration = (int?) Math.Round(length); + _cooldownGraphic.Progress = MathHelper.Clamp((float)ratio, -1, 1); + _cooldownGraphic.Visible = ratio > -1f; + } + } + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs b/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs new file mode 100644 index 0000000000..8dbbed08b5 --- /dev/null +++ b/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Content.Client.UserInterface; +using Content.Client.UserInterface.Stylesheets; +using Content.Client.Utility; +using Content.Shared.Alert; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Client.GameObjects; +using Robust.Client.Interfaces.Graphics; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.Player; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.GameObjects; +using Robust.Shared.Input; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using Robust.Shared.ViewVariables; +using Serilog; + +namespace Content.Client.GameObjects.Components.Mobs +{ + /// + [RegisterComponent] + [ComponentReference(typeof(SharedAlertsComponent))] + public sealed class ClientAlertsComponent : SharedAlertsComponent + { + private static readonly float TooltipTextMaxWidth = 265; + + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IResourceCache _resourceCache = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + + private AlertsUI _ui; + private PanelContainer _tooltip; + private RichTextLabel _stateName; + private RichTextLabel _stateDescription; + private RichTextLabel _stateCooldown; + private AlertOrderPrototype _alertOrder; + private bool _tooltipReady; + + [ViewVariables] + private Dictionary _alertControls + = new Dictionary(); + + /// + /// Allows calculating if we need to act due to this component being controlled by the current mob + /// TODO: should be revisited after space-wizards/RobustToolbox#1255 + /// + [ViewVariables] + private bool CurrentlyControlled => _playerManager.LocalPlayer != null && _playerManager.LocalPlayer.ControlledEntity == Owner; + + protected override void Shutdown() + { + base.Shutdown(); + PlayerDetached(); + } + + public override void HandleMessage(ComponentMessage message, IComponent component) + { + base.HandleMessage(message, component); + switch (message) + { + case PlayerAttachedMsg _: + PlayerAttached(); + break; + case PlayerDetachedMsg _: + PlayerDetached(); + break; + } + } + + public override void HandleComponentState(ComponentState curState, ComponentState nextState) + { + base.HandleComponentState(curState, nextState); + + if (!(curState is AlertsComponentState state)) + { + return; + } + + // update the dict of states based on the array we got in the message + SetAlerts(state.Alerts); + + UpdateAlertsControls(); + } + + private void PlayerAttached() + { + if (!CurrentlyControlled || _ui != null) + { + return; + } + + _alertOrder = IoCManager.Resolve().EnumeratePrototypes().FirstOrDefault(); + if (_alertOrder == null) + { + Logger.ErrorS("alert", "no alertOrder prototype found, alerts will be in random order"); + } + + _ui = new AlertsUI(IoCManager.Resolve()); + var uiManager = IoCManager.Resolve(); + uiManager.StateRoot.AddChild(_ui); + + _tooltip = new PanelContainer + { + Visible = false, + StyleClasses = { StyleNano.StyleClassTooltipPanel } + }; + var tooltipVBox = new VBoxContainer + { + RectClipContent = true + }; + _tooltip.AddChild(tooltipVBox); + _stateName = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = { StyleNano.StyleClassTooltipAlertTitle } + }; + tooltipVBox.AddChild(_stateName); + _stateDescription = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = { StyleNano.StyleClassTooltipAlertDescription } + }; + tooltipVBox.AddChild(_stateDescription); + _stateCooldown = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = { StyleNano.StyleClassTooltipAlertCooldown } + }; + tooltipVBox.AddChild(_stateCooldown); + + uiManager.PopupRoot.AddChild(_tooltip); + + UpdateAlertsControls(); + } + + private void PlayerDetached() + { + _ui?.Dispose(); + _ui = null; + _alertControls.Clear(); + } + + /// + /// Updates the displayed alerts based on current state of Alerts, performing + /// a diff to ensure we only change what's changed (this avoids active tooltips disappearing any + /// time state changes) + /// + private void UpdateAlertsControls() + { + if (!CurrentlyControlled || _ui == null) + { + return; + } + + // remove any controls with keys no longer present + var toRemove = new List(); + foreach (var existingKey in _alertControls.Keys) + { + if (!IsShowingAlert(existingKey)) + { + toRemove.Add(existingKey); + } + } + + foreach (var alertKeyToRemove in toRemove) + { + // remove and dispose the control + _alertControls.Remove(alertKeyToRemove, out var control); + control?.Dispose(); + } + + // now we know that alertControls contains alerts that should still exist but + // may need to updated, + // also there may be some new alerts we need to show. + // further, we need to ensure they are ordered w.r.t their configured order + foreach (var alertStatus in EnumerateAlertStates()) + { + if (!AlertManager.TryDecode(alertStatus.AlertEncoded, out var newAlert)) + { + Logger.ErrorS("alert", "Unable to decode alert {0}", alertStatus.AlertEncoded); + continue; + } + + if (_alertControls.TryGetValue(newAlert.AlertKey, out var existingAlertControl) && + existingAlertControl.Alert.AlertType == newAlert.AlertType) + { + // id is the same, simply update the existing control severity + existingAlertControl.SetSeverity(alertStatus.Severity); + } + else + { + existingAlertControl?.Dispose(); + + // this is a new alert + alert key or just a different alert with the same + // key, create the control and add it in the appropriate order + var newAlertControl = CreateAlertControl(newAlert, alertStatus); + if (_alertOrder != null) + { + var added = false; + foreach (var alertControl in _ui.Grid.Children) + { + if (_alertOrder.Compare(newAlert, ((AlertControl) alertControl).Alert) < 0) + { + var idx = alertControl.GetPositionInParent(); + _ui.Grid.Children.Add(newAlertControl); + newAlertControl.SetPositionInParent(idx); + added = true; + break; + } + } + + if (!added) + { + _ui.Grid.Children.Add(newAlertControl); + } + } + else + { + _ui.Grid.Children.Add(newAlertControl); + } + + _alertControls[newAlert.AlertKey] = newAlertControl; + } + } + } + + private AlertControl CreateAlertControl(AlertPrototype alert, AlertState alertState) + { + + var alertControl = new AlertControl(alert, alertState.Severity, _resourceCache); + // show custom tooltip for the status control + alertControl.OnShowTooltip += AlertOnOnShowTooltip; + alertControl.OnHideTooltip += AlertOnOnHideTooltip; + + alertControl.OnPressed += AlertControlOnPressed; + + return alertControl; + } + + private void AlertControlOnPressed(BaseButton.ButtonEventArgs args) + { + AlertPressed(args, args.Button as AlertControl); + } + + private void AlertOnOnHideTooltip(object sender, EventArgs e) + { + _tooltipReady = false; + _tooltip.Visible = false; + } + + private void AlertOnOnShowTooltip(object sender, EventArgs e) + { + var alertControl = (AlertControl) sender; + _stateName.SetMessage(alertControl.Alert.Name); + _stateDescription.SetMessage(alertControl.Alert.Description); + // check for a cooldown + if (alertControl.TotalDuration != null && alertControl.TotalDuration > 0) + { + _stateCooldown.SetMessage(FormattedMessage.FromMarkup("[color=#776a6a]" + + alertControl.TotalDuration + + " sec cooldown[/color]")); + _stateCooldown.Visible = true; + } + else + { + _stateCooldown.Visible = false; + } + // TODO: Text display of cooldown + Tooltips.PositionTooltip(_tooltip); + // if we set it visible here the size of the previous tooltip will flicker for a frame, + // so instead we wait until FrameUpdate to make it visible + _tooltipReady = true; + } + + private void AlertPressed(BaseButton.ButtonEventArgs args, AlertControl alert) + { + if (args.Event.Function != EngineKeyFunctions.UIClick) + { + return; + } + + if (AlertManager.TryEncode(alert.Alert, out var encoded)) + { + SendNetworkMessage(new ClickAlertMessage(encoded)); + } + else + { + Logger.ErrorS("alert", "unable to encode alert {0}", alert.Alert.AlertType); + } + + } + + public void FrameUpdate(float frameTime) + { + if (_tooltipReady) + { + _tooltipReady = false; + _tooltip.Visible = true; + } + foreach (var (alertKey, alertControl) in _alertControls) + { + // reconcile all alert controls with their current cooldowns + if (TryGetAlertState(alertKey, out var alertState)) + { + alertControl.UpdateCooldown(alertState.Cooldown, _gameTiming.CurTime); + } + else + { + Logger.WarningS("alert", "coding error - no alert state for alert {0} " + + "even though we had an AlertControl for it, this" + + " should never happen", alertControl.Alert.AlertType); + } + + } + } + + protected override void AfterClearAlert() + { + UpdateAlertsControls(); + } + + public override void OnRemove() + { + base.OnRemove(); + + foreach (var alertControl in _alertControls.Values) + { + alertControl.OnShowTooltip -= AlertOnOnShowTooltip; + alertControl.OnHideTooltip -= AlertOnOnHideTooltip; + alertControl.OnPressed -= AlertControlOnPressed; + } + + } + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/ClientStatusEffectsComponent.cs b/Content.Client/GameObjects/Components/Mobs/ClientStatusEffectsComponent.cs deleted file mode 100644 index bd292db23f..0000000000 --- a/Content.Client/GameObjects/Components/Mobs/ClientStatusEffectsComponent.cs +++ /dev/null @@ -1,200 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using Content.Client.UserInterface; -using Content.Client.Utility; -using Content.Shared.GameObjects.Components.Mobs; -using Robust.Client.GameObjects; -using Robust.Client.Interfaces.ResourceManagement; -using Robust.Client.Interfaces.UserInterface; -using Robust.Client.Player; -using Robust.Client.UserInterface.Controls; -using Robust.Shared.GameObjects; -using Robust.Shared.Input; -using Robust.Shared.Interfaces.GameObjects; -using Robust.Shared.Interfaces.Timing; -using Robust.Shared.IoC; -using Robust.Shared.Maths; -using Robust.Shared.ViewVariables; - -namespace Content.Client.GameObjects.Components.Mobs -{ - /// - [RegisterComponent] - [ComponentReference(typeof(SharedStatusEffectsComponent))] - public sealed class ClientStatusEffectsComponent : SharedStatusEffectsComponent - { - [Dependency] private readonly IPlayerManager _playerManager = default!; - [Dependency] private readonly IResourceCache _resourceCache = default!; - [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; - - private StatusEffectsUI _ui; - [ViewVariables] - private Dictionary _status = new Dictionary(); - [ViewVariables] - private Dictionary _cooldown = new Dictionary(); - - public override IReadOnlyDictionary Statuses => _status; - - /// - /// Allows calculating if we need to act due to this component being controlled by the current mob - /// - [ViewVariables] - private bool CurrentlyControlled => _playerManager.LocalPlayer != null && _playerManager.LocalPlayer.ControlledEntity == Owner; - - protected override void Shutdown() - { - base.Shutdown(); - PlayerDetached(); - } - - public override void HandleMessage(ComponentMessage message, IComponent component) - { - base.HandleMessage(message, component); - switch (message) - { - case PlayerAttachedMsg _: - PlayerAttached(); - break; - case PlayerDetachedMsg _: - PlayerDetached(); - break; - } - } - - public override void HandleComponentState(ComponentState curState, ComponentState nextState) - { - base.HandleComponentState(curState, nextState); - - if (!(curState is StatusEffectComponentState state) || _status == state.StatusEffects) - { - return; - } - - _status = state.StatusEffects; - UpdateStatusEffects(); - } - - private void PlayerAttached() - { - if (!CurrentlyControlled || _ui != null) - { - return; - } - _ui = new StatusEffectsUI(); - _userInterfaceManager.StateRoot.AddChild(_ui); - UpdateStatusEffects(); - } - - private void PlayerDetached() - { - _ui?.Dispose(); - _ui = null; - _cooldown.Clear(); - } - - public override void ChangeStatusEffectIcon(StatusEffect effect, string icon) - { - if (_status.TryGetValue(effect, out var value) && - value.Icon == icon) - { - return; - } - - _status[effect] = new StatusEffectStatus - { - Icon = icon, - Cooldown = value.Cooldown - }; - - Dirty(); - } - - public void UpdateStatusEffects() - { - if (!CurrentlyControlled || _ui == null) - { - return; - } - _cooldown.Clear(); - _ui.VBox.DisposeAllChildren(); - - foreach (var (key, effect) in _status.OrderBy(x => (int) x.Key)) - { - var texture = _resourceCache.GetTexture(effect.Icon); - var status = new StatusControl(key, texture) - { - ToolTip = key.ToString() - }; - - if (effect.Cooldown.HasValue) - { - var cooldown = new CooldownGraphic(); - status.Children.Add(cooldown); - _cooldown[key] = cooldown; - } - - status.OnPressed += args => StatusPressed(args, status); - - _ui.VBox.AddChild(status); - } - } - - private void StatusPressed(BaseButton.ButtonEventArgs args, StatusControl status) - { - if (args.Event.Function != EngineKeyFunctions.UIClick) - { - return; - } - - SendNetworkMessage(new ClickStatusMessage(status.Effect)); - } - - public override void RemoveStatusEffect(StatusEffect effect) - { - if (!_status.Remove(effect)) - { - return; - } - - UpdateStatusEffects(); - Dirty(); - } - - public void FrameUpdate(float frameTime) - { - foreach (var (effect, cooldownGraphic) in _cooldown) - { - var status = _status[effect]; - if (!status.Cooldown.HasValue) - { - cooldownGraphic.Progress = 0; - cooldownGraphic.Visible = false; - continue; - } - - var start = status.Cooldown.Value.Item1; - var end = status.Cooldown.Value.Item2; - - var length = (end - start).TotalSeconds; - var progress = (_gameTiming.CurTime - start).TotalSeconds / length; - var ratio = (progress <= 1 ? (1 - progress) : (_gameTiming.CurTime - end).TotalSeconds * -5); - - cooldownGraphic.Progress = MathHelper.Clamp((float)ratio, -1, 1); - cooldownGraphic.Visible = ratio > -1f; - } - } - - public override void ChangeStatusEffect(StatusEffect effect, string icon, (TimeSpan, TimeSpan)? cooldown) - { - _status[effect] = new StatusEffectStatus() - { - Icon = icon, - Cooldown = cooldown - }; - - Dirty(); - } - } -} diff --git a/Content.Client/GameObjects/Components/Mobs/StatusControl.cs b/Content.Client/GameObjects/Components/Mobs/StatusControl.cs deleted file mode 100644 index 7df0225a90..0000000000 --- a/Content.Client/GameObjects/Components/Mobs/StatusControl.cs +++ /dev/null @@ -1,25 +0,0 @@ -#nullable enable -using Content.Shared.GameObjects.Components.Mobs; -using Robust.Client.Graphics; -using Robust.Client.UserInterface.Controls; - -namespace Content.Client.GameObjects.Components.Mobs -{ - public class StatusControl : BaseButton - { - public readonly StatusEffect Effect; - - public StatusControl(StatusEffect effect, Texture? texture) - { - Effect = effect; - - var item = new TextureRect - { - TextureScale = (2, 2), - Texture = texture - }; - - Children.Add(item); - } - } -} diff --git a/Content.Client/GameObjects/EntitySystems/AI/ClientAiDebugSystem.cs b/Content.Client/GameObjects/EntitySystems/AI/ClientAiDebugSystem.cs index 70ab6690bb..943a057714 100644 --- a/Content.Client/GameObjects/EntitySystems/AI/ClientAiDebugSystem.cs +++ b/Content.Client/GameObjects/EntitySystems/AI/ClientAiDebugSystem.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Content.Client.UserInterface.Stylesheets; using Content.Shared.AI; using Robust.Client.Interfaces.Graphics.ClientEye; using Robust.Client.Interfaces.UserInterface; @@ -178,7 +179,7 @@ namespace Content.Client.GameObjects.EntitySystems.AI var panel = new PanelContainer { - StyleClasses = {"tooltipBox"}, + StyleClasses = { StyleNano.StyleClassTooltipPanel }, Children = {vBox}, MouseFilter = Control.MouseFilterMode.Ignore, ModulateSelfOverride = Color.White.WithAlpha(0.75f), diff --git a/Content.Client/GameObjects/EntitySystems/StatusEffectsSystem.cs b/Content.Client/GameObjects/EntitySystems/AlertsSystem.cs similarity index 67% rename from Content.Client/GameObjects/EntitySystems/StatusEffectsSystem.cs rename to Content.Client/GameObjects/EntitySystems/AlertsSystem.cs index f7a2388368..107451f9ea 100644 --- a/Content.Client/GameObjects/EntitySystems/StatusEffectsSystem.cs +++ b/Content.Client/GameObjects/EntitySystems/AlertsSystem.cs @@ -5,7 +5,7 @@ using Robust.Shared.IoC; namespace Content.Client.GameObjects.EntitySystems { - public class StatusEffectsSystem : EntitySystem + public class AlertsSystem : EntitySystem { [Dependency] private readonly IGameTiming _gameTiming = default!; @@ -16,9 +16,9 @@ namespace Content.Client.GameObjects.EntitySystems if (!_gameTiming.IsFirstTimePredicted) return; - foreach (var clientStatusEffectsComponent in EntityManager.ComponentManager.EntityQuery()) + foreach (var clientAlertsComponent in EntityManager.ComponentManager.EntityQuery()) { - clientStatusEffectsComponent.FrameUpdate(frameTime); + clientAlertsComponent.FrameUpdate(frameTime); } } } diff --git a/Content.Client/UserInterface/AlertsUI.cs b/Content.Client/UserInterface/AlertsUI.cs new file mode 100644 index 0000000000..f4c7effac9 --- /dev/null +++ b/Content.Client/UserInterface/AlertsUI.cs @@ -0,0 +1,73 @@ +using System; +using Content.Client.UserInterface.Stylesheets; +using Robust.Client.Graphics.Drawing; +using Robust.Client.Interfaces.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.Maths; + +namespace Content.Client.UserInterface +{ + /// + /// The status effects display on the right side of the screen. + /// + public sealed class AlertsUI : Control + { + public GridContainer Grid { get; } + + private readonly IClyde _clyde; + + public AlertsUI(IClyde clyde) + { + _clyde = clyde; + var panelContainer = new PanelContainer + { + StyleClasses = {StyleNano.StyleClassTransparentBorderedWindowPanel}, + SizeFlagsVertical = SizeFlags.FillExpand, + }; + AddChild(panelContainer); + + Grid = new GridContainer + { + MaxHeight = CalcMaxHeight(clyde.ScreenSize), + ExpandBackwards = true + }; + panelContainer.AddChild(Grid); + clyde.OnWindowResized += ClydeOnOnWindowResized; + + LayoutContainer.SetGrowHorizontal(this, LayoutContainer.GrowDirection.Begin); + LayoutContainer.SetAnchorAndMarginPreset(this, LayoutContainer.LayoutPreset.TopRight, margin: 10); + LayoutContainer.SetMarginTop(this, 250); + } + + protected override void UIScaleChanged() + { + Grid.MaxHeight = CalcMaxHeight(_clyde.ScreenSize); + base.UIScaleChanged(); + } + + private void ClydeOnOnWindowResized(WindowResizedEventArgs obj) + { + // TODO: Can rework this once https://github.com/space-wizards/RobustToolbox/issues/1392 is done, + // this is here because there isn't currently a good way to allow the grid to adjust its height based + // on constraints, otherwise we would use anchors to lay it out + Grid.MaxHeight = CalcMaxHeight(obj.NewSize);; + } + + private float CalcMaxHeight(Vector2i screenSize) + { + return Math.Max(((screenSize.Y) / UIScale) - 420, 1); + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + if (disposing) + { + _clyde.OnWindowResized -= ClydeOnOnWindowResized; + } + } + } +} diff --git a/Content.Client/UserInterface/StatusEffectsUI.cs b/Content.Client/UserInterface/StatusEffectsUI.cs deleted file mode 100644 index baaade0028..0000000000 --- a/Content.Client/UserInterface/StatusEffectsUI.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Robust.Client.UserInterface; -using Robust.Client.UserInterface.Controls; - -namespace Content.Client.UserInterface -{ - /// - /// The status effects display on the right side of the screen. - /// - public sealed class StatusEffectsUI : Control - { - public VBoxContainer VBox { get; } - - public StatusEffectsUI() - { - VBox = new VBoxContainer(); - AddChild(VBox); - - LayoutContainer.SetGrowHorizontal(this, LayoutContainer.GrowDirection.Begin); - LayoutContainer.SetAnchorAndMarginPreset(this, LayoutContainer.LayoutPreset.TopRight, margin: 10); - LayoutContainer.SetMarginTop(this, 250); - } - } -} diff --git a/Content.Client/UserInterface/Stylesheets/StyleNano.cs b/Content.Client/UserInterface/Stylesheets/StyleNano.cs index 092cb2c12f..a29b687b72 100644 --- a/Content.Client/UserInterface/Stylesheets/StyleNano.cs +++ b/Content.Client/UserInterface/Stylesheets/StyleNano.cs @@ -13,6 +13,13 @@ namespace Content.Client.UserInterface.Stylesheets { public sealed class StyleNano : StyleBase { + public const string StyleClassBorderedWindowPanel = "BorderedWindowPanel"; + public const string StyleClassTransparentBorderedWindowPanel = "TransparentBorderedWindowPanel"; + public const string StyleClassTooltipPanel = "tooltipBox"; + public const string StyleClassTooltipAlertTitle = "tooltipAlertTitle"; + public const string StyleClassTooltipAlertDescription = "tooltipAlertDesc"; + public const string StyleClassTooltipAlertCooldown = "tooltipAlertCooldown"; + public const string StyleClassSliderRed = "Red"; public const string StyleClassSliderGreen = "Green"; public const string StyleClassSliderBlue = "Blue"; @@ -55,6 +62,7 @@ namespace Content.Client.UserInterface.Stylesheets var notoSansDisplayBold14 = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 14); var notoSans16 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Regular.ttf", 16); var notoSansBold16 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 16); + var notoSansBold18 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 18); var notoSansBold20 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 20); var textureCloseButton = resCache.GetTexture("/Textures/Interface/Nano/cross.svg.png"); var windowHeaderTex = resCache.GetTexture("/Textures/Interface/Nano/window_header.png"); @@ -73,6 +81,20 @@ namespace Content.Client.UserInterface.Stylesheets windowBackground.SetPatchMargin(StyleBox.Margin.Horizontal | StyleBox.Margin.Bottom, 2); windowBackground.SetExpandMargin(StyleBox.Margin.Horizontal | StyleBox.Margin.Bottom, 2); + var borderedWindowBackgroundTex = resCache.GetTexture("/Textures/Interface/Nano/window_background_bordered.png"); + var borderedWindowBackground = new StyleBoxTexture + { + Texture = borderedWindowBackgroundTex, + }; + borderedWindowBackground.SetPatchMargin(StyleBox.Margin.All, 2); + + var borderedTransparentWindowBackgroundTex = resCache.GetTexture("/Textures/Interface/Nano/transparent_window_background_bordered.png"); + var borderedTransparentWindowBackground = new StyleBoxTexture + { + Texture = borderedTransparentWindowBackgroundTex, + }; + borderedTransparentWindowBackground.SetPatchMargin(StyleBox.Margin.All, 2); + var textureInvertedTriangle = resCache.GetTexture("/Textures/Interface/Nano/inverted_triangle.svg.png"); var lineEditTex = resCache.GetTexture("/Textures/Interface/Nano/lineedit.png"); @@ -147,7 +169,7 @@ namespace Content.Client.UserInterface.Stylesheets Texture = tooltipTexture, }; tooltipBox.SetPatchMargin(StyleBox.Margin.All, 2); - tooltipBox.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5); + tooltipBox.SetContentMarginOverride(StyleBox.Margin.Horizontal, 7); // Placeholder var placeholderTexture = resCache.GetTexture("/Textures/Interface/Nano/placeholder.png"); @@ -245,6 +267,19 @@ namespace Content.Client.UserInterface.Stylesheets { new StyleProperty(PanelContainer.StylePropertyPanel, windowBackground), }), + // bordered window background + new StyleRule( + new SelectorElement(null, new[] {StyleClassBorderedWindowPanel}, null, null), + new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, borderedWindowBackground), + }), + new StyleRule( + new SelectorElement(null, new[] {StyleClassTransparentBorderedWindowPanel}, null, null), + new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, borderedTransparentWindowBackground), + }), // Window header. new StyleRule( new SelectorElement(typeof(PanelContainer), new[] {SS14Window.StyleClassWindowHeader}, null, null), @@ -464,7 +499,7 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty(PanelContainer.StylePropertyPanel, tooltipBox) }), - new StyleRule(new SelectorElement(typeof(PanelContainer), new[] {"tooltipBox"}, null, null), new[] + new StyleRule(new SelectorElement(typeof(PanelContainer), new [] { StyleClassTooltipPanel }, null, null), new[] { new StyleProperty(PanelContainer.StylePropertyPanel, tooltipBox) }), @@ -482,6 +517,20 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty("font", notoSansItalic12), }), + // alert tooltip + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipAlertTitle}, null, null), new[] + { + new StyleProperty("font", notoSansBold18) + }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipAlertDescription}, null, null), new[] + { + new StyleProperty("font", notoSans16) + }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipAlertCooldown}, null, null), new[] + { + new StyleProperty("font", notoSans16) + }), + // Entity tooltip new StyleRule( new SelectorElement(typeof(PanelContainer), new[] {ExamineSystem.StyleClassEntityTooltip}, null, diff --git a/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs new file mode 100644 index 0000000000..e48a18daac --- /dev/null +++ b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs @@ -0,0 +1,110 @@ +using System.Linq; +using System.Threading.Tasks; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.UserInterface; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; +using NUnit.Framework; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.Player; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.IoC; +using Robust.Shared.Map; + +namespace Content.IntegrationTests.Tests.GameObjects.Components.Mobs +{ + [TestFixture] + [TestOf(typeof(ClientAlertsComponent))] + [TestOf(typeof(ServerAlertsComponent))] + public class AlertsComponentTests : ContentIntegrationTest + { + + [Test] + public async Task AlertsTest() + { + var (client, server) = await StartConnectedServerClientPair(); + + await server.WaitIdleAsync(); + await client.WaitIdleAsync(); + + var serverPlayerManager = server.ResolveDependency(); + + await server.WaitAssertion(() => + { + var player = serverPlayerManager.GetAllPlayers().Single(); + var playerEnt = player.AttachedEntity; + Assert.NotNull(playerEnt); + var alertsComponent = playerEnt.GetComponent(); + Assert.NotNull(alertsComponent); + + // show 2 alerts + alertsComponent.ShowAlert(AlertType.Debug1); + alertsComponent.ShowAlert(AlertType.Debug2); + }); + + await server.WaitRunTicks(5); + await client.WaitRunTicks(5); + + var clientPlayerMgr = client.ResolveDependency(); + var clientUIMgr = client.ResolveDependency(); + await client.WaitAssertion(() => + { + + var local = clientPlayerMgr.LocalPlayer; + Assert.NotNull(local); + var controlled = local.ControlledEntity; + Assert.NotNull(controlled); + var alertsComponent = controlled.GetComponent(); + Assert.NotNull(alertsComponent); + + // find the alertsui + var alertsUI = + clientUIMgr.StateRoot.Children.FirstOrDefault(c => c is AlertsUI) as AlertsUI; + Assert.NotNull(alertsUI); + + // we should be seeing 3 alerts - our health, and the 2 debug alerts, in a specific order. + Assert.That(alertsUI.Grid.ChildCount, Is.EqualTo(3)); + var alertControls = alertsUI.Grid.Children.Select(c => c as AlertControl); + var alertIDs = alertControls.Select(ac => ac.Alert.AlertType).ToArray(); + var expectedIDs = new [] {AlertType.HumanHealth, AlertType.Debug1, AlertType.Debug2}; + Assert.That(alertIDs, Is.EqualTo(expectedIDs)); + }); + + await server.WaitAssertion(() => + { + var player = serverPlayerManager.GetAllPlayers().Single(); + var playerEnt = player.AttachedEntity; + Assert.NotNull(playerEnt); + var alertsComponent = playerEnt.GetComponent(); + Assert.NotNull(alertsComponent); + + alertsComponent.ClearAlert(AlertType.Debug1); + }); + await server.WaitRunTicks(5); + await client.WaitRunTicks(5); + + await client.WaitAssertion(() => + { + + var local = clientPlayerMgr.LocalPlayer; + Assert.NotNull(local); + var controlled = local.ControlledEntity; + Assert.NotNull(controlled); + var alertsComponent = controlled.GetComponent(); + Assert.NotNull(alertsComponent); + + // find the alertsui + var alertsUI = + clientUIMgr.StateRoot.Children.FirstOrDefault(c => c is AlertsUI) as AlertsUI; + Assert.NotNull(alertsUI); + + // we should be seeing only 2 alerts now because one was cleared + Assert.That(alertsUI.Grid.ChildCount, Is.EqualTo(2)); + var alertControls = alertsUI.Grid.Children.Select(c => c as AlertControl); + var alertIDs = alertControls.Select(ac => ac.Alert.AlertType).ToArray(); + var expectedIDs = new [] {AlertType.HumanHealth, AlertType.Debug2}; + Assert.That(alertIDs, Is.EqualTo(expectedIDs)); + }); + } + } +} diff --git a/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs b/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs index 3e128bceee..5e0574c321 100644 --- a/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs +++ b/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs @@ -1,6 +1,7 @@ using System.Threading.Tasks; using Content.Server.GameObjects.Components.Gravity; using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Gravity; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.EntitySystems; @@ -32,7 +33,7 @@ namespace Content.IntegrationTests.Tests.Gravity var tileDefinitionManager = server.ResolveDependency(); IEntity human = null; - SharedStatusEffectsComponent statusEffects = null; + SharedAlertsComponent alerts = null; await server.WaitAssertion(() => { @@ -57,7 +58,7 @@ namespace Content.IntegrationTests.Tests.Gravity human = entityManager.SpawnEntity("HumanMob_Content", coordinates); - Assert.True(human.TryGetComponent(out statusEffects)); + Assert.True(human.TryGetComponent(out alerts)); }); // Let WeightlessSystem and GravitySystem tick @@ -68,7 +69,7 @@ namespace Content.IntegrationTests.Tests.Gravity await server.WaitAssertion(() => { // No gravity without a gravity generator - Assert.True(statusEffects.Statuses.ContainsKey(StatusEffect.Weightless)); + Assert.True(alerts.IsShowingAlert(AlertType.Weightless)); gravityGenerator = human.EnsureComponent(); }); @@ -78,7 +79,7 @@ namespace Content.IntegrationTests.Tests.Gravity await server.WaitAssertion(() => { - Assert.False(statusEffects.Statuses.ContainsKey(StatusEffect.Weightless)); + Assert.False(alerts.IsShowingAlert(AlertType.Weightless)); // Disable the gravity generator var args = new BreakageEventArgs {Owner = human}; @@ -89,7 +90,7 @@ namespace Content.IntegrationTests.Tests.Gravity await server.WaitAssertion(() => { - Assert.False(statusEffects.Statuses.ContainsKey(StatusEffect.Weightless)); + Assert.False(alerts.IsShowingAlert(AlertType.Weightless)); }); } } diff --git a/Content.Server/Commands/CommandUtils.cs b/Content.Server/Commands/CommandUtils.cs new file mode 100644 index 0000000000..b6535dc7d3 --- /dev/null +++ b/Content.Server/Commands/CommandUtils.cs @@ -0,0 +1,66 @@ +using System; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Network; + +namespace Content.Server.Commands +{ + /// + /// Utilities for writing commands + /// + public static class CommandUtils + { + /// + /// Gets the player session for the player with the indicated id, + /// sending a failure to the performer if unable to. + /// + public static bool TryGetSessionByUsernameOrId(IConsoleShell shell, + string usernameOrId, IPlayerSession performer, out IPlayerSession session) + { + var plyMgr = IoCManager.Resolve(); + if (plyMgr.TryGetSessionByUsername(usernameOrId, out session)) return true; + if (Guid.TryParse(usernameOrId, out var targetGuid)) + { + if (plyMgr.TryGetSessionById(new NetUserId(targetGuid), out session)) return true; + shell.SendText(performer, "Unable to find user with that name/id."); + return false; + } + + shell.SendText(performer, "Unable to find user with that name/id."); + return false; + } + + /// + /// Gets the attached entity for the player session with the indicated id, + /// sending a failure to the performer if unable to. + /// + public static bool TryGetAttachedEntityByUsernameOrId(IConsoleShell shell, + string usernameOrId, IPlayerSession performer, out IEntity attachedEntity) + { + attachedEntity = null; + if (!TryGetSessionByUsernameOrId(shell, usernameOrId, performer, out var session)) return false; + if (session.AttachedEntity == null) + { + shell.SendText(performer, "User has no attached entity."); + return false; + } + + attachedEntity = session.AttachedEntity; + return true; + } + + /// + /// Checks if attached entity is null, returning false and sending a message + /// to performer if not. + /// + public static bool ValidateAttachedEntity(IConsoleShell shell, IPlayerSession performer, IEntity attachedEntity) + { + if (attachedEntity != null) return true; + shell.SendText(performer, "User has no attached entity."); + return false; + } + + } +} diff --git a/Content.Server/EntryPoint.cs b/Content.Server/EntryPoint.cs index 9542e2b36a..ca684623f4 100644 --- a/Content.Server/EntryPoint.cs +++ b/Content.Server/EntryPoint.cs @@ -9,6 +9,7 @@ using Content.Server.Interfaces.GameTicking; using Content.Server.Interfaces.PDA; using Content.Server.Sandbox; using Content.Shared.Kitchen; +using Content.Shared.Alert; using Robust.Server.Interfaces.Player; using Robust.Shared.ContentPack; using Robust.Shared.Interfaces.GameObjects; @@ -74,6 +75,7 @@ namespace Content.Server _gameTicker.Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); diff --git a/Content.Server/GameObjects/Components/ActionBlocking/CuffableComponent.cs b/Content.Server/GameObjects/Components/ActionBlocking/CuffableComponent.cs index 35c1263cf0..510715b1e8 100644 --- a/Content.Server/GameObjects/Components/ActionBlocking/CuffableComponent.cs +++ b/Content.Server/GameObjects/Components/ActionBlocking/CuffableComponent.cs @@ -6,6 +6,7 @@ using Content.Server.GameObjects.Components.Items.Storage; using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.EntitySystems.DoAfter; using Content.Server.Interfaces.GameObjects.Components.Items; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.ActionBlocking; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.EntitySystems; @@ -115,7 +116,7 @@ namespace Content.Server.GameObjects.Components.ActionBlocking CanStillInteract = _hands.Hands.Count() > CuffedHandCount; OnCuffedStateChanged.Invoke(); - UpdateStatusEffect(); + UpdateAlert(); UpdateHeldItems(); Dirty(); } @@ -181,17 +182,17 @@ namespace Content.Server.GameObjects.Components.ActionBlocking /// /// Updates the status effect indicator on the HUD. /// - private void UpdateStatusEffect() + private void UpdateAlert() { - if (Owner.TryGetComponent(out ServerStatusEffectsComponent status)) + if (Owner.TryGetComponent(out ServerAlertsComponent status)) { if (CanStillInteract) { - status.RemoveStatusEffect(StatusEffect.Cuffed); + status.ClearAlert(AlertType.Handcuffed); } else { - status.ChangeStatusEffectIcon(StatusEffect.Cuffed, "/Textures/Interface/StatusEffects/Handcuffed/Handcuffed.png"); + status.ShowAlert(AlertType.Handcuffed); } } } @@ -282,7 +283,7 @@ namespace Content.Server.GameObjects.Components.ActionBlocking CanStillInteract = _hands.Hands.Count() > CuffedHandCount; OnCuffedStateChanged.Invoke(); - UpdateStatusEffect(); + UpdateAlert(); Dirty(); if (CuffedHandCount == 0) diff --git a/Content.Server/GameObjects/Components/Atmos/BarotraumaComponent.cs b/Content.Server/GameObjects/Components/Atmos/BarotraumaComponent.cs index 6710298441..f595aa4b13 100644 --- a/Content.Server/GameObjects/Components/Atmos/BarotraumaComponent.cs +++ b/Content.Server/GameObjects/Components/Atmos/BarotraumaComponent.cs @@ -2,6 +2,7 @@ using System.Runtime.CompilerServices; using Content.Server.GameObjects.Components.Mobs; using Content.Server.Interfaces.GameObjects; +using Content.Shared.Alert; using Content.Shared.Atmos; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; @@ -22,7 +23,7 @@ namespace Content.Server.GameObjects.Components.Atmos public void Update(float airPressure) { if (!Owner.TryGetComponent(out IDamageableComponent damageable)) return; - Owner.TryGetComponent(out ServerStatusEffectsComponent status); + Owner.TryGetComponent(out ServerAlertsComponent status); var highPressureMultiplier = 1f; var lowPressureMultiplier = 1f; @@ -50,11 +51,11 @@ namespace Content.Server.GameObjects.Components.Atmos if (pressure <= Atmospherics.HazardLowPressure) { - status.ChangeStatusEffect(StatusEffect.Pressure, "/Textures/Interface/StatusEffects/Pressure/lowpressure2.png", null); + status.ShowAlert(AlertType.LowPressure, 2); break; } - status.ChangeStatusEffect(StatusEffect.Pressure, "/Textures/Interface/StatusEffects/Pressure/lowpressure1.png", null); + status.ShowAlert(AlertType.LowPressure, 1); break; // High pressure. @@ -72,16 +73,16 @@ namespace Content.Server.GameObjects.Components.Atmos if (pressure >= Atmospherics.HazardHighPressure) { - status.ChangeStatusEffect(StatusEffect.Pressure, "/Textures/Interface/StatusEffects/Pressure/highpressure2.png", null); + status.ShowAlert(AlertType.HighPressure, 2); break; } - status.ChangeStatusEffect(StatusEffect.Pressure, "/Textures/Interface/StatusEffects/Pressure/highpressure1.png", null); + status.ShowAlert(AlertType.HighPressure, 1); break; // Normal pressure. default: - status?.RemoveStatusEffect(StatusEffect.Pressure); + status?.ClearAlertCategory(AlertCategory.Pressure); break; } diff --git a/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs b/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs index 6ca1388d84..137725f25d 100644 --- a/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs +++ b/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs @@ -4,6 +4,7 @@ using Content.Server.Atmos; using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Temperature; using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.Atmos; using Content.Shared.Chemistry; using Content.Shared.Damage; @@ -93,15 +94,15 @@ namespace Content.Server.GameObjects.Components.Atmos FireStacks = MathF.Min(0, FireStacks + 1); } - Owner.TryGetComponent(out ServerStatusEffectsComponent status); + Owner.TryGetComponent(out ServerAlertsComponent status); if (!OnFire) { - status?.RemoveStatusEffect(StatusEffect.Fire); + status?.ClearAlert(AlertType.Fire); return; } - status?.ChangeStatusEffect(StatusEffect.Fire, "/Textures/Interface/StatusEffects/Fire/fire.png", null); + status.ShowAlert(AlertType.Fire, onClickAlert: OnClickAlert); if (FireStacks > 0) { @@ -153,6 +154,14 @@ namespace Content.Server.GameObjects.Components.Atmos } } + private void OnClickAlert(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out FlammableComponent flammable)) + { + flammable.Resist(); + } + } + public void CollideWith(IEntity collidedWith) { if (!collidedWith.TryGetComponent(out FlammableComponent otherFlammable)) diff --git a/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs b/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs index 0eb0ffebea..1c4c0f55f8 100644 --- a/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs +++ b/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs @@ -7,6 +7,7 @@ using Content.Server.GameObjects.Components.Mobs.State; using Content.Server.GameObjects.Components.Pulling; using Content.Server.GameObjects.Components.Strap; using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Buckle; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Strap; @@ -37,7 +38,7 @@ namespace Content.Server.GameObjects.Components.Buckle [Dependency] private readonly IGameTiming _gameTiming = default!; [ComponentDependency] public readonly AppearanceComponent? AppearanceComponent = null; - [ComponentDependency] private readonly ServerStatusEffectsComponent? _serverStatusEffectsComponent = null; + [ComponentDependency] private readonly ServerAlertsComponent? _serverAlertsComponent = null; [ComponentDependency] private readonly StunnableComponent? _stunnableComponent = null; [ComponentDependency] private readonly MobStateManagerComponent? _mobStateManagerComponent = null; @@ -100,21 +101,31 @@ namespace Content.Server.GameObjects.Components.Buckle /// private void UpdateBuckleStatus() { - if (_serverStatusEffectsComponent == null) + if (_serverAlertsComponent == null) { return; } if (Buckled) { - _serverStatusEffectsComponent.ChangeStatusEffectIcon(StatusEffect.Buckled, BuckledTo!.BuckledIcon); + _serverAlertsComponent.ShowAlert(BuckledTo != null ? BuckledTo.BuckledAlertType : AlertType.Buckled, + onClickAlert: OnClickAlert); } else { - _serverStatusEffectsComponent.RemoveStatusEffect(StatusEffect.Buckled); + _serverAlertsComponent.ClearAlertCategory(AlertCategory.Buckled); } } + private void OnClickAlert(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out BuckleComponent? buckle)) + { + buckle.TryUnbuckle(args.Player); + } + } + + /// /// Reattaches this entity to the strap, modifying its position and rotation. /// diff --git a/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs b/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs new file mode 100644 index 0000000000..2587b491e4 --- /dev/null +++ b/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs @@ -0,0 +1,166 @@ +using System; +using Content.Server.Commands; +using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Network; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Players; + +namespace Content.Server.GameObjects.Components.Mobs +{ + [RegisterComponent] + [ComponentReference(typeof(SharedAlertsComponent))] + public sealed class ServerAlertsComponent : SharedAlertsComponent + { + + protected override void Startup() + { + base.Startup(); + + if (EntitySystem.TryGet(out var weightlessSystem)) + { + weightlessSystem.AddAlert(this); + } + else + { + Logger.WarningS("alert", "weightlesssystem not found"); + } + } + + public override void OnRemove() + { + if (EntitySystem.TryGet(out var weightlessSystem)) + { + weightlessSystem.RemoveAlert(this); + } + else + { + Logger.WarningS("alert", "weightlesssystem not found"); + } + + base.OnRemove(); + } + + public override ComponentState GetComponentState() + { + return new AlertsComponentState(CreateAlertStatesArray()); + } + + public override void HandleNetworkMessage(ComponentMessage message, INetChannel netChannel, ICommonSession session = null) + { + base.HandleNetworkMessage(message, netChannel, session); + + if (session == null) + { + throw new ArgumentNullException(nameof(session)); + } + + switch (message) + { + case ClickAlertMessage msg: + { + var player = session.AttachedEntity; + + if (player != Owner) + { + break; + } + + // TODO: Implement clicking other status effects in the HUD + if (AlertManager.TryDecode(msg.EncodedAlert, out var alert)) + { + PerformAlertClickCallback(alert, player); + } + else + { + Logger.WarningS("alert", "unrecognized encoded alert {0}", msg.EncodedAlert); + } + + break; + } + } + } + } + + public sealed class ShowAlert : IClientCommand + { + public string Command => "showalert"; + public string Description => "Shows an alert for a player, defaulting to current player"; + public string Help => "showalert "; + public void Execute(IConsoleShell shell, IPlayerSession player, string[] args) + { + var attachedEntity = player.AttachedEntity; + if (args.Length > 2) + { + var target = args[2]; + if (!Commands.CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; + } + + if (!CommandUtils.ValidateAttachedEntity(shell, player, attachedEntity)) return; + + + if (!attachedEntity.TryGetComponent(out ServerAlertsComponent alertsComponent)) + { + shell.SendText(player, "user has no alerts component"); + return; + } + + var alertType = args[0]; + var severity = args[1]; + var alertMgr = IoCManager.Resolve(); + if (!alertMgr.TryGet(Enum.Parse(alertType), out var alert)) + { + shell.SendText(player, "unrecognized alertType " + alertType); + return; + } + if (!short.TryParse(severity, out var sevint)) + { + shell.SendText(player, "invalid severity " + sevint); + return; + } + alertsComponent.ShowAlert(alert.AlertType, sevint == -1 ? (short?) null : sevint); + + } + } + + public sealed class ClearAlert : IClientCommand + { + public string Command => "clearalert"; + public string Description => "Clears an alert for a player, defaulting to current player"; + public string Help => "clearalert "; + + public void Execute(IConsoleShell shell, IPlayerSession player, string[] args) + { + var attachedEntity = player.AttachedEntity; + if (args.Length > 1) + { + var target = args[1]; + if (!CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; + } + + if (!CommandUtils.ValidateAttachedEntity(shell, player, attachedEntity)) return; + + if (!attachedEntity.TryGetComponent(out ServerAlertsComponent alertsComponent)) + { + shell.SendText(player, "user has no alerts component"); + return; + } + + var alertType = args[0]; + var alertMgr = IoCManager.Resolve(); + if (!alertMgr.TryGet(Enum.Parse(alertType), out var alert)) + { + shell.SendText(player, "unrecognized alertType " + alertType); + return; + } + + alertsComponent.ClearAlert(alert.AlertType); + } + } +} diff --git a/Content.Server/GameObjects/Components/Mobs/ServerStatusEffectsComponent.cs b/Content.Server/GameObjects/Components/Mobs/ServerStatusEffectsComponent.cs deleted file mode 100644 index 8715fdb3d7..0000000000 --- a/Content.Server/GameObjects/Components/Mobs/ServerStatusEffectsComponent.cs +++ /dev/null @@ -1,152 +0,0 @@ -using System; -using System.Collections.Generic; -using Content.Server.GameObjects.Components.Atmos; -using Content.Server.GameObjects.Components.Buckle; -using Content.Server.GameObjects.Components.Movement; -using Content.Server.GameObjects.EntitySystems; -using Content.Shared.GameObjects.Components.Mobs; -using Content.Shared.GameObjects.Components.Pulling; -using Content.Shared.GameObjects.EntitySystems; -using Content.Shared.Interfaces; -using Robust.Shared.GameObjects; -using Robust.Shared.GameObjects.Systems; -using Robust.Shared.Interfaces.Network; -using Robust.Shared.Players; -using Robust.Shared.ViewVariables; - -namespace Content.Server.GameObjects.Components.Mobs -{ - [RegisterComponent] - [ComponentReference(typeof(SharedStatusEffectsComponent))] - public sealed class ServerStatusEffectsComponent : SharedStatusEffectsComponent - { - [ViewVariables] - private readonly Dictionary _statusEffects = new Dictionary(); - - public override IReadOnlyDictionary Statuses => _statusEffects; - - protected override void Startup() - { - base.Startup(); - - EntitySystem.Get().AddStatus(this); - } - - public override void OnRemove() - { - EntitySystem.Get().RemoveStatus(this); - - base.OnRemove(); - } - - public override ComponentState GetComponentState() - { - return new StatusEffectComponentState(_statusEffects); - } - - public override void ChangeStatusEffectIcon(StatusEffect effect, string icon) - { - if (_statusEffects.TryGetValue(effect, out var value) && value.Icon == icon) - { - return; - } - - _statusEffects[effect] = new StatusEffectStatus() - {Icon = icon, Cooldown = value.Cooldown}; - Dirty(); - } - - public void ChangeStatusEffectCooldown(StatusEffect effect, ValueTuple cooldown) - { - if (_statusEffects.TryGetValue(effect, out var value) - && value.Cooldown == cooldown) - { - return; - } - - _statusEffects[effect] = new StatusEffectStatus() - { - Icon = value.Icon, Cooldown = cooldown - }; - Dirty(); - } - - public override void ChangeStatusEffect(StatusEffect effect, string icon, ValueTuple? cooldown) - { - _statusEffects[effect] = new StatusEffectStatus() - {Icon = icon, Cooldown = cooldown}; - - Dirty(); - } - - public override void RemoveStatusEffect(StatusEffect effect) - { - if (!_statusEffects.Remove(effect)) - { - return; - } - - Dirty(); - } - - public override void HandleNetworkMessage(ComponentMessage message, INetChannel netChannel, ICommonSession session = null) - { - base.HandleNetworkMessage(message, netChannel, session); - - if (session == null) - { - throw new ArgumentNullException(nameof(session)); - } - - switch (message) - { - case ClickStatusMessage msg: - { - var player = session.AttachedEntity; - - if (player != Owner) - { - break; - } - - // TODO: Implement clicking other status effects in the HUD - switch (msg.Effect) - { - case StatusEffect.Buckled: - if (!player.TryGetComponent(out BuckleComponent buckle)) - break; - - buckle.TryUnbuckle(player); - break; - case StatusEffect.Piloting: - if (!player.TryGetComponent(out ShuttleControllerComponent controller)) - break; - - controller.RemoveController(); - break; - case StatusEffect.Pulling: - EntitySystem - .Get() - .GetPulled(player)? - .GetComponentOrNull()? - .TryStopPull(); - - break; - case StatusEffect.Fire: - if (!player.TryGetComponent(out FlammableComponent flammable)) - break; - - flammable.Resist(); - break; - default: - player.PopupMessage(msg.Effect.ToString()); - break; - } - - break; - } - } - } - } - -} diff --git a/Content.Server/GameObjects/Components/Mobs/State/CriticalState.cs b/Content.Server/GameObjects/Components/Mobs/State/CriticalState.cs index 0adea20dde..9e6d92a0e0 100644 --- a/Content.Server/GameObjects/Components/Mobs/State/CriticalState.cs +++ b/Content.Server/GameObjects/Components/Mobs/State/CriticalState.cs @@ -1,4 +1,5 @@ using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Mobs.State; @@ -17,10 +18,9 @@ namespace Content.Server.GameObjects.Components.Mobs.State appearance.SetData(DamageStateVisuals.State, DamageState.Critical); } - if (entity.TryGetComponent(out ServerStatusEffectsComponent status)) + if (entity.TryGetComponent(out ServerAlertsComponent status)) { - status.ChangeStatusEffectIcon(StatusEffect.Health, - "/Textures/Interface/StatusEffects/Human/humancrit-0.png"); //Todo: combine humancrit-0 and humancrit-1 into a gif and display it + status.ShowAlert(AlertType.HumanCrit); //Todo: combine humancrit-0 and humancrit-1 into a gif and display it } if (entity.TryGetComponent(out ServerOverlayEffectsComponent overlay)) diff --git a/Content.Server/GameObjects/Components/Mobs/State/DeadState.cs b/Content.Server/GameObjects/Components/Mobs/State/DeadState.cs index 9fc2428975..cdd4673510 100644 --- a/Content.Server/GameObjects/Components/Mobs/State/DeadState.cs +++ b/Content.Server/GameObjects/Components/Mobs/State/DeadState.cs @@ -1,4 +1,5 @@ using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Mobs.State; @@ -18,10 +19,9 @@ namespace Content.Server.GameObjects.Components.Mobs.State appearance.SetData(DamageStateVisuals.State, DamageState.Dead); } - if (entity.TryGetComponent(out ServerStatusEffectsComponent status)) + if (entity.TryGetComponent(out ServerAlertsComponent status)) { - status.ChangeStatusEffectIcon(StatusEffect.Health, - "/Textures/Interface/StatusEffects/Human/humandead.png"); + status.ShowAlert(AlertType.HumanDead); } if (entity.TryGetComponent(out ServerOverlayEffectsComponent overlayComponent)) diff --git a/Content.Server/GameObjects/Components/Mobs/State/MobStateManager.cs b/Content.Server/GameObjects/Components/Mobs/State/MobStateManager.cs index c0e90dd9fa..d315ed1b0e 100644 --- a/Content.Server/GameObjects/Components/Mobs/State/MobStateManager.cs +++ b/Content.Server/GameObjects/Components/Mobs/State/MobStateManager.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Mobs.State; @@ -51,9 +52,9 @@ namespace Content.Server.GameObjects.Components.Mobs.State // TODO: Might want to add an OnRemove() to IMobState since those are where these components are being used base.OnRemove(); - if (Owner.TryGetComponent(out ServerStatusEffectsComponent status)) + if (Owner.TryGetComponent(out ServerAlertsComponent status)) { - status.RemoveStatusEffect(StatusEffect.Health); + status.ClearAlert(AlertType.HumanHealth); } if (Owner.TryGetComponent(out ServerOverlayEffectsComponent overlay)) diff --git a/Content.Server/GameObjects/Components/Mobs/State/NormalState.cs b/Content.Server/GameObjects/Components/Mobs/State/NormalState.cs index 6b8eb4aab3..5915ccebbe 100644 --- a/Content.Server/GameObjects/Components/Mobs/State/NormalState.cs +++ b/Content.Server/GameObjects/Components/Mobs/State/NormalState.cs @@ -1,5 +1,6 @@ using Content.Server.GameObjects.Components.Damage; using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Mobs.State; @@ -27,15 +28,14 @@ namespace Content.Server.GameObjects.Components.Mobs.State public override void UpdateState(IEntity entity) { - if (!entity.TryGetComponent(out ServerStatusEffectsComponent status)) + if (!entity.TryGetComponent(out ServerAlertsComponent status)) { return; } if (!entity.TryGetComponent(out IDamageableComponent damageable)) { - status.ChangeStatusEffectIcon(StatusEffect.Health, - "/Textures/Interface/StatusEffects/Human/human0.png"); + status.ShowAlert(AlertType.HumanHealth, 0); return; } @@ -49,10 +49,9 @@ namespace Content.Server.GameObjects.Components.Mobs.State return; } - var modifier = (int) (ruinable.TotalDamage / (threshold / 7f)); + var modifier = (short) (ruinable.TotalDamage / (threshold / 7f)); - status.ChangeStatusEffectIcon(StatusEffect.Health, - "/Textures/Interface/StatusEffects/Human/human" + modifier + ".png"); + status.ShowAlert(AlertType.HumanHealth, modifier); break; } @@ -63,10 +62,9 @@ namespace Content.Server.GameObjects.Components.Mobs.State return; } - var modifier = (int) (damageable.TotalDamage / (threshold / 7f)); + var modifier = (short) (damageable.TotalDamage / (threshold / 7f)); - status.ChangeStatusEffectIcon(StatusEffect.Health, - "/Textures/Interface/StatusEffects/Human/human" + modifier + ".png"); + status.ShowAlert(AlertType.HumanHealth, modifier); break; } } diff --git a/Content.Server/GameObjects/Components/Mobs/StunnableComponent.cs b/Content.Server/GameObjects/Components/Mobs/StunnableComponent.cs index 1af15ecce2..9f3ec4def6 100644 --- a/Content.Server/GameObjects/Components/Mobs/StunnableComponent.cs +++ b/Content.Server/GameObjects/Components/Mobs/StunnableComponent.cs @@ -1,4 +1,5 @@ using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.Chemistry; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Movement; @@ -89,7 +90,7 @@ namespace Content.Server.GameObjects.Components.Mobs } if (!StunStart.HasValue || !StunEnd.HasValue || - !Owner.TryGetComponent(out ServerStatusEffectsComponent status)) + !Owner.TryGetComponent(out ServerAlertsComponent status)) { return; } @@ -102,7 +103,7 @@ namespace Content.Server.GameObjects.Components.Mobs if (progress >= length) { - Owner.SpawnTimer(250, () => status.RemoveStatusEffect(StatusEffect.Stun), StatusRemoveCancellation.Token); + Owner.SpawnTimer(250, () => status.ClearAlert(AlertType.Stun), StatusRemoveCancellation.Token); LastStun = null; } } diff --git a/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs b/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs index 2def6aac43..db25892f5b 100644 --- a/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs +++ b/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs @@ -1,6 +1,7 @@ #nullable enable using Content.Server.GameObjects.Components.Buckle; using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.Components.Movement; using Content.Shared.GameObjects.Components.Strap; @@ -31,9 +32,9 @@ namespace Content.Server.GameObjects.Components.Movement private bool _movingRight; /// - /// The icon to be displayed when piloting from this chair. + /// ID of the alert to show when piloting /// - private string _pilotingIcon = default!; + private AlertType _pilotingAlertType; /// /// The entity that's currently controlling this component. @@ -137,7 +138,7 @@ namespace Content.Server.GameObjects.Components.Movement if (_controller != null || !entity.TryGetComponent(out MindComponent? mind) || mind.Mind == null || - !Owner.TryGetComponent(out ServerStatusEffectsComponent? status)) + !Owner.TryGetComponent(out ServerAlertsComponent? status)) { return; } @@ -145,7 +146,15 @@ namespace Content.Server.GameObjects.Components.Movement mind.Mind.Visit(Owner); _controller = entity; - status.ChangeStatusEffectIcon(StatusEffect.Piloting, _pilotingIcon); + status.ShowAlert(_pilotingAlertType, onClickAlert: OnClickAlert); + } + + private void OnClickAlert(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out ShuttleControllerComponent? controller)) + { + controller.RemoveController(); + } } /// @@ -177,9 +186,9 @@ namespace Content.Server.GameObjects.Components.Movement /// The entity to update private void UpdateRemovedEntity(IEntity entity) { - if (Owner.TryGetComponent(out ServerStatusEffectsComponent? status)) + if (Owner.TryGetComponent(out ServerAlertsComponent? status)) { - status.RemoveStatusEffect(StatusEffect.Piloting); + status.ClearAlert(_pilotingAlertType); } if (entity.TryGetComponent(out MindComponent? mind)) @@ -211,13 +220,13 @@ namespace Content.Server.GameObjects.Components.Movement { base.ExposeData(serializer); - serializer.DataField(ref _pilotingIcon, "pilotingIcon", "/Textures/Interface/StatusEffects/Buckle/buckled.png"); + serializer.DataField(ref _pilotingAlertType, "pilotingAlertType", AlertType.PilotingShuttle); } public override void Initialize() { base.Initialize(); - Owner.EnsureComponent(); + Owner.EnsureComponent(); } /// diff --git a/Content.Server/GameObjects/Components/Nutrition/HungerComponent.cs b/Content.Server/GameObjects/Components/Nutrition/HungerComponent.cs index 9ea95624cc..32e13a360a 100644 --- a/Content.Server/GameObjects/Components/Nutrition/HungerComponent.cs +++ b/Content.Server/GameObjects/Components/Nutrition/HungerComponent.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; @@ -70,11 +71,11 @@ namespace Content.Server.GameObjects.Components.Nutrition } - public static readonly Dictionary HungerThresholdImages = new Dictionary + public static readonly Dictionary HungerThresholdAlertTypes = new Dictionary { - { HungerThreshold.Overfed, "/Textures/Interface/StatusEffects/Hunger/Overfed.png" }, - { HungerThreshold.Peckish, "/Textures/Interface/StatusEffects/Hunger/Peckish.png" }, - { HungerThreshold.Starving, "/Textures/Interface/StatusEffects/Hunger/Starving.png" }, + { HungerThreshold.Overfed, AlertType.Overfed }, + { HungerThreshold.Peckish, AlertType.Peckish }, + { HungerThreshold.Starving, AlertType.Starving }, }; public void HungerThresholdEffect(bool force = false) @@ -89,15 +90,15 @@ namespace Content.Server.GameObjects.Components.Nutrition } // Update UI - Owner.TryGetComponent(out ServerStatusEffectsComponent statusEffectsComponent); + Owner.TryGetComponent(out ServerAlertsComponent alertsComponent); - if (HungerThresholdImages.TryGetValue(_currentHungerThreshold, out var statusTexture)) + if (HungerThresholdAlertTypes.TryGetValue(_currentHungerThreshold, out var alertId)) { - statusEffectsComponent?.ChangeStatusEffectIcon(StatusEffect.Hunger, statusTexture); + alertsComponent?.ShowAlert(alertId); } else { - statusEffectsComponent?.RemoveStatusEffect(StatusEffect.Hunger); + alertsComponent?.ClearAlertCategory(AlertCategory.Hunger); } switch (_currentHungerThreshold) diff --git a/Content.Server/GameObjects/Components/Nutrition/ThirstComponent.cs b/Content.Server/GameObjects/Components/Nutrition/ThirstComponent.cs index 2d294e8db6..6462141610 100644 --- a/Content.Server/GameObjects/Components/Nutrition/ThirstComponent.cs +++ b/Content.Server/GameObjects/Components/Nutrition/ThirstComponent.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; using Content.Shared.GameObjects.Components.Mobs; @@ -62,11 +63,11 @@ namespace Content.Server.GameObjects.Components.Nutrition {ThirstThreshold.Dead, 0.0f}, }; - public static readonly Dictionary ThirstThresholdImages = new Dictionary + public static readonly Dictionary ThirstThresholdAlertTypes = new Dictionary { - {ThirstThreshold.OverHydrated, "/Textures/Interface/StatusEffects/Thirst/OverHydrated.png"}, - {ThirstThreshold.Thirsty, "/Textures/Interface/StatusEffects/Thirst/Thirsty.png"}, - {ThirstThreshold.Parched, "/Textures/Interface/StatusEffects/Thirst/Parched.png"}, + {ThirstThreshold.OverHydrated, AlertType.Overhydrated}, + {ThirstThreshold.Thirsty, AlertType.Thirsty}, + {ThirstThreshold.Parched, AlertType.Parched}, }; public override void ExposeData(ObjectSerializer serializer) @@ -87,15 +88,15 @@ namespace Content.Server.GameObjects.Components.Nutrition } // Update UI - Owner.TryGetComponent(out ServerStatusEffectsComponent statusEffectsComponent); + Owner.TryGetComponent(out ServerAlertsComponent alertsComponent); - if (ThirstThresholdImages.TryGetValue(_currentThirstThreshold, out var statusTexture)) + if (ThirstThresholdAlertTypes.TryGetValue(_currentThirstThreshold, out var alertId)) { - statusEffectsComponent?.ChangeStatusEffectIcon(StatusEffect.Thirst, statusTexture); + alertsComponent?.ShowAlert(alertId); } else { - statusEffectsComponent?.RemoveStatusEffect(StatusEffect.Thirst); + alertsComponent?.ClearAlertCategory(AlertCategory.Thirst); } switch (_currentThirstThreshold) diff --git a/Content.Server/GameObjects/Components/Strap/StrapComponent.cs b/Content.Server/GameObjects/Components/Strap/StrapComponent.cs index 3bd7b5c4cb..a6ad9585b5 100644 --- a/Content.Server/GameObjects/Components/Strap/StrapComponent.cs +++ b/Content.Server/GameObjects/Components/Strap/StrapComponent.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Content.Server.GameObjects.Components.Buckle; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Strap; using Content.Shared.GameObjects.EntitySystems; using Content.Shared.GameObjects.Verbs; @@ -27,7 +28,7 @@ namespace Content.Server.GameObjects.Components.Strap private StrapPosition _position; private string _buckleSound = null!; private string _unbuckleSound = null!; - private string _buckledIcon = null!; + private AlertType _buckledAlertType; /// /// The angle in degrees to rotate the player by when they get strapped @@ -65,10 +66,10 @@ namespace Content.Server.GameObjects.Components.Strap public string UnbuckleSound => _unbuckleSound; /// - /// The icon to be displayed as a status when buckled + /// ID of the alert to show when buckled /// [ViewVariables] - public string BuckledIcon => _buckledIcon; + public AlertType BuckledAlertType => _buckledAlertType; /// /// The sum of the sizes of all the buckled entities in this strap @@ -137,7 +138,7 @@ namespace Content.Server.GameObjects.Components.Strap serializer.DataField(ref _position, "position", StrapPosition.None); serializer.DataField(ref _buckleSound, "buckleSound", "/Audio/Effects/buckle.ogg"); serializer.DataField(ref _unbuckleSound, "unbuckleSound", "/Audio/Effects/unbuckle.ogg"); - serializer.DataField(ref _buckledIcon, "buckledIcon", "/Textures/Interface/StatusEffects/Buckle/buckled.png"); + serializer.DataField(ref _buckledAlertType, "buckledAlertType", AlertType.Buckled); serializer.DataField(ref _rotation, "rotation", 0); var defaultSize = 100; diff --git a/Content.Server/GameObjects/Components/Temperature/TemperatureComponent.cs b/Content.Server/GameObjects/Components/Temperature/TemperatureComponent.cs index 72c318091b..8b59293975 100644 --- a/Content.Server/GameObjects/Components/Temperature/TemperatureComponent.cs +++ b/Content.Server/GameObjects/Components/Temperature/TemperatureComponent.cs @@ -1,6 +1,7 @@ using System; using System.Diagnostics; using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; using Content.Shared.Atmos; using Content.Shared.Damage; using Content.Shared.GameObjects.Components.Damage; @@ -74,43 +75,43 @@ namespace Content.Server.GameObjects.Components.Temperature damageType = DamageType.Cold; } - if (Owner.TryGetComponent(out ServerStatusEffectsComponent status)) + if (Owner.TryGetComponent(out ServerAlertsComponent status)) { switch(CurrentTemperature) { // Cold strong. case var t when t <= 260: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/cold3.png", null); + status.ShowAlert(AlertType.Cold, 3); break; // Cold mild. case var t when t <= 280 && t > 260: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/cold2.png", null); + status.ShowAlert(AlertType.Cold, 2); break; // Cold weak. case var t when t <= 292 && t > 280: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/cold1.png", null); + status.ShowAlert(AlertType.Cold, 1); break; // Safe. case var t when t <= 327 && t > 292: - status.RemoveStatusEffect(StatusEffect.Temperature); + status.ClearAlertCategory(AlertCategory.Temperature); break; // Heat weak. case var t when t <= 335 && t > 327: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/hot1.png", null); + status.ShowAlert(AlertType.Hot, 1); break; // Heat mild. case var t when t <= 345 && t > 335: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/hot2.png", null); + status.ShowAlert(AlertType.Hot, 2); break; // Heat strong. case var t when t > 345: - status.ChangeStatusEffect(StatusEffect.Temperature, "/Textures/Interface/StatusEffects/Temperature/hot3.png", null); + status.ShowAlert(AlertType.Hot, 3); break; } } diff --git a/Content.Server/GameObjects/EntitySystems/WeightlessSystem.cs b/Content.Server/GameObjects/EntitySystems/WeightlessSystem.cs index 1ceec26a0a..c18fb5c8a6 100644 --- a/Content.Server/GameObjects/EntitySystems/WeightlessSystem.cs +++ b/Content.Server/GameObjects/EntitySystems/WeightlessSystem.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.EntitySystemMessages.Gravity; using Content.Shared.GameTicking; @@ -19,7 +20,7 @@ namespace Content.Server.GameObjects.EntitySystems { [Dependency] private readonly IMapManager _mapManager = default!; - private readonly Dictionary> _statuses = new Dictionary>(); + private readonly Dictionary> _alerts = new Dictionary>(); public override void Initialize() { @@ -31,15 +32,15 @@ namespace Content.Server.GameObjects.EntitySystems public void Reset() { - _statuses.Clear(); + _alerts.Clear(); } - public void AddStatus(ServerStatusEffectsComponent status) + public void AddAlert(ServerAlertsComponent status) { var gridId = status.Owner.Transform.GridID; - var statuses = _statuses.GetOrNew(gridId); + var alerts = _alerts.GetOrNew(gridId); - statuses.Add(status); + alerts.Add(status); if (_mapManager.TryGetGrid(status.Owner.Transform.GridID, out var grid)) { @@ -54,10 +55,10 @@ namespace Content.Server.GameObjects.EntitySystems } } - public void RemoveStatus(ServerStatusEffectsComponent status) + public void RemoveAlert(ServerAlertsComponent status) { var grid = status.Owner.Transform.GridID; - if (!_statuses.TryGetValue(grid, out var statuses)) + if (!_alerts.TryGetValue(grid, out var statuses)) { return; } @@ -67,7 +68,7 @@ namespace Content.Server.GameObjects.EntitySystems private void GravityChanged(GravityChangedMessage ev) { - if (!_statuses.TryGetValue(ev.Grid.Index, out var statuses)) + if (!_alerts.TryGetValue(ev.Grid.Index, out var statuses)) { return; } @@ -88,19 +89,19 @@ namespace Content.Server.GameObjects.EntitySystems } } - private void AddWeightless(ServerStatusEffectsComponent status) + private void AddWeightless(ServerAlertsComponent status) { - status.ChangeStatusEffect(StatusEffect.Weightless, "/Textures/Interface/StatusEffects/Weightless/weightless.png", null); + status.ShowAlert(AlertType.Weightless); } - private void RemoveWeightless(ServerStatusEffectsComponent status) + private void RemoveWeightless(ServerAlertsComponent status) { - status.RemoveStatusEffect(StatusEffect.Weightless); + status.ClearAlert(AlertType.Weightless); } private void EntParentChanged(EntParentChangedMessage ev) { - if (!ev.Entity.TryGetComponent(out ServerStatusEffectsComponent status)) + if (!ev.Entity.TryGetComponent(out ServerAlertsComponent status)) { return; } @@ -110,14 +111,14 @@ namespace Content.Server.GameObjects.EntitySystems { var oldGrid = mapGrid.GridIndex; - if (_statuses.TryGetValue(oldGrid, out var oldStatuses)) + if (_alerts.TryGetValue(oldGrid, out var oldStatuses)) { oldStatuses.Remove(status); } } var newGrid = ev.Entity.Transform.GridID; - var newStatuses = _statuses.GetOrNew(newGrid); + var newStatuses = _alerts.GetOrNew(newGrid); newStatuses.Add(status); } diff --git a/Content.Server/ServerContentIoC.cs b/Content.Server/ServerContentIoC.cs index f9c86be44d..362b7ebe56 100644 --- a/Content.Server/ServerContentIoC.cs +++ b/Content.Server/ServerContentIoC.cs @@ -18,6 +18,7 @@ using Content.Server.Sandbox; using Content.Server.Utility; using Content.Shared.Interfaces; using Content.Shared.Kitchen; +using Content.Shared.Alert; using Robust.Shared.IoC; namespace Content.Server @@ -37,6 +38,7 @@ namespace Content.Server IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); diff --git a/Content.Shared/Alert/AlertManager.cs b/Content.Shared/Alert/AlertManager.cs new file mode 100644 index 0000000000..392b731a83 --- /dev/null +++ b/Content.Shared/Alert/AlertManager.cs @@ -0,0 +1,124 @@ +using System.Collections.Generic; +using System.Linq; +using Content.Shared.Prototypes.Kitchen; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Alert +{ + /// + /// Provides access to all configured alerts. Ability to encode/decode a given state + /// to an int. + /// + public class AlertManager + { + [Dependency] + private readonly IPrototypeManager _prototypeManager = default!; + + private AlertPrototype[] _orderedAlerts; + private Dictionary _typeToIndex; + + public void Initialize() + { + // order by type value so we can map between the id and an integer index and use + // the index for compact alert change messages + _orderedAlerts = + _prototypeManager.EnumeratePrototypes() + .OrderBy(prototype => prototype.AlertType).ToArray(); + _typeToIndex = new Dictionary(); + + for (var i = 0; i < _orderedAlerts.Length; i++) + { + if (i > byte.MaxValue) + { + Logger.ErrorS("alert", "too many alerts for byte encoding ({0})! encoding will need" + + " to be changed to use a ushort rather than byte", _typeToIndex.Count); + break; + } + if (!_typeToIndex.TryAdd(_orderedAlerts[i].AlertType, (byte) i)) + { + Logger.ErrorS("alert", + "Found alert with duplicate id {0}", _orderedAlerts[i].AlertType); + } + } + + } + + /// + /// Tries to get the alert of the indicated type + /// + /// true if found + public bool TryGet(AlertType alertType, out AlertPrototype alert) + { + if (_typeToIndex.TryGetValue(alertType, out var idx)) + { + alert = _orderedAlerts[idx]; + return true; + } + + alert = null; + return false; + } + + /// + /// Tries to get the alert of the indicated type along with its encoding + /// + /// true if found + public bool TryGetWithEncoded(AlertType alertType, out AlertPrototype alert, out byte encoded) + { + if (_typeToIndex.TryGetValue(alertType, out var idx)) + { + alert = _orderedAlerts[idx]; + encoded = (byte) idx; + return true; + } + + alert = null; + encoded = 0; + return false; + } + + /// + /// Tries to get the compact encoded representation of this alert + /// + /// true if successful + public bool TryEncode(AlertPrototype alert, out byte encoded) + { + return TryEncode(alert.AlertType, out encoded); + } + + /// + /// Tries to get the compact encoded representation of the alert with + /// the indicated id + /// + /// true if successful + public bool TryEncode(AlertType alertType, out byte encoded) + { + if (_typeToIndex.TryGetValue(alertType, out var idx)) + { + encoded = idx; + return true; + } + + encoded = 0; + return false; + } + + /// + /// Tries to get the alert from the encoded representation + /// + /// true if successful + public bool TryDecode(byte encodedAlert, out AlertPrototype alert) + { + if (encodedAlert >= _orderedAlerts.Length) + { + alert = null; + return false; + } + + alert = _orderedAlerts[encodedAlert]; + return true; + } + } +} diff --git a/Content.Shared/Alert/AlertOrderPrototype.cs b/Content.Shared/Alert/AlertOrderPrototype.cs new file mode 100644 index 0000000000..0fd5ee35c3 --- /dev/null +++ b/Content.Shared/Alert/AlertOrderPrototype.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; +using YamlDotNet.RepresentationModel; + +namespace Content.Shared.Alert +{ + /// + /// Defines the order of alerts so they show up in a consistent order. + /// + [Prototype("alertOrder")] + public class AlertOrderPrototype : IPrototype, IComparer + { + private Dictionary _typeToIdx = new Dictionary(); + private Dictionary _categoryToIdx = new Dictionary(); + + public void LoadFrom(YamlMappingNode mapping) + { + if (!mapping.TryGetNode("order", out YamlSequenceNode orderMapping)) return; + + int i = 0; + foreach (var entryYaml in orderMapping) + { + var orderEntry = (YamlMappingNode) entryYaml; + var serializer = YamlObjectSerializer.NewReader(orderEntry); + if (serializer.TryReadDataField("category", out AlertCategory alertCategory)) + { + _categoryToIdx[alertCategory] = i++; + } + else if (serializer.TryReadDataField("alertType", out AlertType alertType)) + { + _typeToIdx[alertType] = i++; + } + } + } + + private int GetOrderIndex(AlertPrototype alert) + { + if (_typeToIdx.TryGetValue(alert.AlertType, out var idx)) + { + return idx; + } + if (alert.Category != null && + _categoryToIdx.TryGetValue((AlertCategory) alert.Category, out idx)) + { + return idx; + } + + return -1; + } + + public int Compare(AlertPrototype x, AlertPrototype y) + { + if ((x == null) && (y == null)) return 0; + if (x == null) return 1; + if (y == null) return -1; + var idx = GetOrderIndex(x); + var idy = GetOrderIndex(y); + if (idx == -1 && idy == -1) + { + // break ties by type value + return x.AlertType - y.AlertType; + } + + if (idx == -1) return 1; + if (idy == -1) return -1; + var result = idx - idy; + // not strictly necessary (we don't care about ones that go at the same index) + // but it makes the sort stable + if (result == 0) + { + // break ties by type value + return x.AlertType - y.AlertType; + } + + return result; + } + } +} diff --git a/Content.Shared/Alert/AlertPrototype.cs b/Content.Shared/Alert/AlertPrototype.cs new file mode 100644 index 0000000000..431e0b6f3d --- /dev/null +++ b/Content.Shared/Alert/AlertPrototype.cs @@ -0,0 +1,189 @@ +using System; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; +using Robust.Shared.ViewVariables; +using YamlDotNet.RepresentationModel; + +namespace Content.Shared.Alert +{ + /// + /// An alert popup with associated icon, tooltip, and other data. + /// + [Prototype("alert")] + public class AlertPrototype : IPrototype + { + /// + /// Type of alert, no 2 alert prototypes should have the same one. + /// + public AlertType AlertType { get; private set; } + + /// + /// Path to the icon (png) to show in alert bar. If severity levels are supported, + /// this should be the path to the icon without the severity number + /// (i.e. hot.png if there is hot1.png and hot2.png). Use + /// to get the correct icon path for a particular severity level. + /// + [ViewVariables] + public string IconPath { get; private set; } + + /// + /// Name to show in tooltip window. Accepts formatting. + /// + public FormattedMessage Name { get; private set; } + + /// + /// Description to show in tooltip window. Accepts formatting. + /// + public FormattedMessage Description { get; private set; } + + /// + /// Category the alert belongs to. Only one alert of a given category + /// can be shown at a time. If one is shown while another is already being shown, + /// it will be replaced. This can be useful for categories of alerts which should naturally + /// replace each other and are mutually exclusive, for example lowpressure / highpressure, + /// hot / cold. If left unspecified, the alert will not replace or be replaced by any other alerts. + /// + public AlertCategory? Category { get; private set; } + + /// + /// Key which is unique w.r.t category semantics (alerts with same category have equal keys, + /// alerts with no category have different keys). + /// + public AlertKey AlertKey { get; private set; } + + /// + /// -1 (no effect) unless MaxSeverity is specified. Defaults to 1. Minimum severity level supported by this state. + /// + public short MinSeverity => MaxSeverity == -1 ? (short) -1 : _minSeverity; + private short _minSeverity; + + /// + /// Maximum severity level supported by this state. -1 (default) indicates + /// no severity levels are supported by the state. + /// + public short MaxSeverity { get; private set; } + + /// + /// Indicates whether this state support severity levels + /// + public bool SupportsSeverity => MaxSeverity != -1; + + public void LoadFrom(YamlMappingNode mapping) + { + var serializer = YamlObjectSerializer.NewReader(mapping); + + serializer.DataField(this, x => x.IconPath, "icon", string.Empty); + serializer.DataField(this, x => x.MaxSeverity, "maxSeverity", (short) -1); + serializer.DataField(ref _minSeverity, "minSeverity", (short) 1); + + serializer.DataReadFunction("name", string.Empty, + s => Name = FormattedMessage.FromMarkup(s)); + serializer.DataReadFunction("description", string.Empty, + s => Description = FormattedMessage.FromMarkup(s)); + + serializer.DataField(this, x => x.AlertType, "alertType", AlertType.Error); + if (AlertType == AlertType.Error) + { + Logger.ErrorS("alert", "missing or invalid alertType for alert with name {0}", Name); + } + + if (serializer.TryReadDataField("category", out AlertCategory alertCategory)) + { + Category = alertCategory; + } + AlertKey = new AlertKey(AlertType, Category); + } + + /// severity level, if supported by this alert + /// the icon path to the texture for the provided severity level + public string GetIconPath(short? severity = null) + { + if (!SupportsSeverity && severity != null) + { + Logger.WarningS("alert", "attempted to get icon path for severity level for alert {0}, but" + + " this alert does not support severity levels", AlertType); + } + if (!SupportsSeverity) return IconPath; + if (severity == null) + { + Logger.WarningS("alert", "attempted to get icon path without severity level for alert {0}," + + " but this alert requires a severity level. Using lowest" + + " valid severity level instead...", AlertType); + severity = MinSeverity; + } + + if (severity < MinSeverity) + { + Logger.WarningS("alert", "attempted to get icon path with severity level {0} for alert {1}," + + " but the minimum severity level for this alert is {2}. Using" + + " lowest valid severity level instead...", severity, AlertType, MinSeverity); + severity = MinSeverity; + } + if (severity > MaxSeverity) + { + Logger.WarningS("alert", "attempted to get icon path with severity level {0} for alert {1}," + + " but the max severity level for this alert is {2}. Using" + + " highest valid severity level instead...", severity, AlertType, MaxSeverity); + severity = MaxSeverity; + } + + // split and add the severity number to the path + var ext = IconPath.LastIndexOf('.'); + return IconPath.Substring(0, ext) + severity + IconPath.Substring(ext, IconPath.Length - ext); + } + } + + /// + /// Key for an alert which is unique (for equality and hashcode purposes) w.r.t category semantics. + /// I.e., entirely defined by the category, if a category was specified, otherwise + /// falls back to the id. + /// + [Serializable, NetSerializable] + public struct AlertKey + { + private readonly AlertType? _alertType; + private readonly AlertCategory? _alertCategory; + + /// NOTE: if the alert has a category you must pass the category for this to work + /// properly as a key. I.e. if the alert has a category and you pass only the ID, and you + /// compare this to another AlertKey that has both the category and the same ID, it will not consider them equal. + public AlertKey(AlertType? alertType, AlertCategory? alertCategory) + { + // if there is a category, ignore the alerttype. + if (alertCategory != null) + { + _alertCategory = alertCategory; + _alertType = null; + } + else + { + _alertCategory = null; + _alertType = alertType; + } + } + + public bool Equals(AlertKey other) + { + return _alertType == other._alertType && _alertCategory == other._alertCategory; + } + + public override bool Equals(object obj) + { + return obj is AlertKey other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(_alertType, _alertCategory); + } + + /// alert category, must not be null + /// An alert key for the provided alert category + public static AlertKey ForCategory(AlertCategory category) + { + return new AlertKey(null, category); + } + } +} diff --git a/Content.Shared/Alert/AlertType.cs b/Content.Shared/Alert/AlertType.cs new file mode 100644 index 0000000000..b4a3866e0c --- /dev/null +++ b/Content.Shared/Alert/AlertType.cs @@ -0,0 +1,52 @@ +namespace Content.Shared.Alert +{ + /// + /// Every category of alert. Corresponds to category field in alert prototypes defined in YML + /// + public enum AlertCategory + { + Pressure, + Temperature, + Buckled, + Health, + Piloting, + Hunger, + Thirst + } + + /// + /// Every kind of alert. Corresponds to alertType field in alert prototypes defined in YML + /// + public enum AlertType + { + Error, + LowPressure, + HighPressure, + Fire, + Cold, + Hot, + Weightless, + Stun, + Handcuffed, + Buckled, + HumanCrit, + HumanDead, + HumanHealth, + PilotingShuttle, + Overfed, + Peckish, + Starving, + Overhydrated, + Thirsty, + Parched, + Pulled, + Pulling, + Debug1, + Debug2, + Debug3, + Debug4, + Debug5, + Debug6 + } + +} diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs new file mode 100644 index 0000000000..dff93e22b7 --- /dev/null +++ b/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs @@ -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 +{ + /// + /// Handles the icons on the right side of the screen. + /// Should only be used for player-controlled entities. + /// + 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 _alerts = new Dictionary(); + + /// true iff an alert of the indicated alert category is currently showing + public bool IsShowingAlertCategory(AlertCategory alertCategory) + { + return IsShowingAlert(AlertKey.ForCategory(alertCategory)); + } + + /// true iff an alert of the indicated id is currently showing + 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; + + } + + /// true iff an alert of the indicated key is currently showing + protected bool IsShowingAlert(AlertKey alertKey) + { + return _alerts.ContainsKey(alertKey); + } + + protected IEnumerable EnumerateAlertStates() + { + return _alerts.Values.Select(alertData => alertData.AlertState); + } + + /// + /// Invokes the alert's specified callback if there is one. + /// Not intended to be used on clientside. + /// + 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); + } + } + + /// + /// Creates a new array containing all of the current alert states. + /// + /// + 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; + } + + /// + /// Replace the current active alerts with the specified alerts. Any + /// OnClickAlert callbacks on the active alerts will be erased. + /// + protected void SetAlerts(AlertState[] alerts) + { + var newAlerts = new Dictionary(); + 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; + } + + /// + /// 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. + /// + /// type of the alert to set + /// callback to invoke when ClickAlertMessage is received by the server + /// after being clicked by client. Has no effect when specified on the clientside. + /// severity, if supported by the alert + /// 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) + public void ShowAlert(AlertType alertType, short? severity = null, OnClickAlert onClickAlert = null, + ValueTuple? 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); + } + } + + /// + /// Clear the alert with the given category, if one is currently showing. + /// + public void ClearAlertCategory(AlertCategory category) + { + var key = AlertKey.ForCategory(category); + if (!_alerts.Remove(key)) + { + return; + } + + AfterClearAlert(); + + Dirty(); + } + + /// + /// Clear the alert of the given type if it is currently showing. + /// + 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); + } + + } + + /// + /// Invoked after clearing an alert prior to dirtying the control + /// + protected virtual void AfterClearAlert() { } + } + + [Serializable, NetSerializable] + public class AlertsComponentState : ComponentState + { + public AlertState[] Alerts; + + public AlertsComponentState(AlertState[] alerts) : base(ContentNetIDs.ALERTS) + { + Alerts = alerts; + } + } + + /// + /// A message that calls the click interaction on a alert + /// + [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? Cooldown; + } + + public struct ClickableAlertState + { + public AlertState AlertState; + public OnClickAlert OnClickAlert; + } + + public delegate void OnClickAlert(ClickAlertEventArgs args); + + public class ClickAlertEventArgs : EventArgs + { + /// + /// Player clicking the alert + /// + public readonly IEntity Player; + /// + /// Alert that was clicked + /// + public readonly AlertPrototype Alert; + + public ClickAlertEventArgs(IEntity player, AlertPrototype alert) + { + Player = player; + Alert = alert; + } + } +} diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedStatusEffectsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedStatusEffectsComponent.cs deleted file mode 100644 index 2c1cffeadd..0000000000 --- a/Content.Shared/GameObjects/Components/Mobs/SharedStatusEffectsComponent.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Collections.Generic; -using Robust.Shared.GameObjects; -using Robust.Shared.Serialization; - -namespace Content.Shared.GameObjects.Components.Mobs -{ - /// - /// Handles the icons on the right side of the screen. - /// Should only be used for player-controlled entities - /// - public abstract class SharedStatusEffectsComponent : Component - { - public override string Name => "StatusEffectsUI"; - public override uint? NetID => ContentNetIDs.STATUSEFFECTS; - - public abstract IReadOnlyDictionary Statuses { get; } - - public abstract void ChangeStatusEffectIcon(StatusEffect effect, string icon); - - public abstract void ChangeStatusEffect(StatusEffect effect, string icon, ValueTuple? cooldown); - - public abstract void RemoveStatusEffect(StatusEffect effect); - } - - [Serializable, NetSerializable] - public class StatusEffectComponentState : ComponentState - { - public Dictionary StatusEffects; - - public StatusEffectComponentState(Dictionary statusEffects) : base(ContentNetIDs.STATUSEFFECTS) - { - StatusEffects = statusEffects; - } - } - - /// - /// A message that calls the click interaction on a status effect - /// - [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? 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 - } -} diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedStunnableComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedStunnableComponent.cs index a347dbbccf..c168a9056e 100644 --- a/Content.Shared/GameObjects/Components/Mobs/SharedStunnableComponent.cs +++ b/Content.Shared/GameObjects/Components/Mobs/SharedStunnableComponent.cs @@ -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; diff --git a/Content.Shared/GameObjects/Components/Pulling/SharedPullableComponent.cs b/Content.Shared/GameObjects/Components/Pulling/SharedPullableComponent.cs index 1a76e085e3..114a7428e9 100644 --- a/Content.Shared/GameObjects/Components/Pulling/SharedPullableComponent.cs +++ b/Content.Shared/GameObjects/Components/Pulling/SharedPullableComponent.cs @@ -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() + .GetPulled(args.Player)? + .GetComponentOrNull()? + .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); } } diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index 8769dd85d4..a2d20277d0 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -29,7 +29,7 @@ public const uint RESEARCH_CONSOLE = 1023; public const uint WIRES = 1024; public const uint COMBATMODE = 1025; - public const uint STATUSEFFECTS = 1026; + public const uint ALERTS = 1026; public const uint OVERLAYEFFECTS = 1027; public const uint STOMACH = 1028; public const uint ITEMCOOLDOWN = 1029; diff --git a/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs b/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs new file mode 100644 index 0000000000..48062b1e62 --- /dev/null +++ b/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs @@ -0,0 +1,71 @@ +using System.IO; +using System.Linq; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Alert; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.Utility; +using NUnit.Framework; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Prototypes; + +namespace Content.Tests.Server.GameObjects.Components.Mobs +{ + [TestFixture] + [TestOf(typeof(ServerAlertsComponent))] + public class ServerAlertsComponentTests : ContentUnitTest + { + const string PROTOTYPES = @" +- type: alert + alertType: LowPressure + category: Pressure + icon: /Textures/Interface/Alerts/Pressure/lowpressure.png + +- type: alert + alertType: HighPressure + category: Pressure + icon: /Textures/Interface/Alerts/Pressure/highpressure.png +"; + + [Test] + public void ShowAlerts() + { + // this is kind of unnecessary because there's integration test coverage of Alert components + // but wanted to keep it anyway to see what's possible w.r.t. testing components + // in a unit test + + var prototypeManager = IoCManager.Resolve(); + prototypeManager.RegisterType(typeof(AlertPrototype)); + var factory = IoCManager.Resolve(); + factory.Register(); + prototypeManager.LoadFromStream(new StringReader(PROTOTYPES)); + prototypeManager.Resync(); + var alertManager = IoCManager.Resolve(); + alertManager.Initialize(); + + + var alertsComponent = new ServerAlertsComponent(); + alertsComponent = IoCManager.InjectDependencies(alertsComponent); + + Assert.That(alertManager.TryGetWithEncoded(AlertType.LowPressure, out var lowpressure, out var lpencoded)); + Assert.That(alertManager.TryGetWithEncoded(AlertType.HighPressure, out var highpressure, out var hpencoded)); + + alertsComponent.ShowAlert(AlertType.LowPressure); + var alertState = alertsComponent.GetComponentState() as AlertsComponentState; + Assert.NotNull(alertState); + Assert.That(alertState.Alerts.Length, Is.EqualTo(1)); + Assert.That(alertState.Alerts[0], Is.EqualTo(new AlertState{AlertEncoded = lpencoded})); + + alertsComponent.ShowAlert(AlertType.HighPressure); + alertState = alertsComponent.GetComponentState() as AlertsComponentState; + Assert.That(alertState.Alerts.Length, Is.EqualTo(1)); + Assert.That(alertState.Alerts[0], Is.EqualTo(new AlertState{AlertEncoded = hpencoded})); + + alertsComponent.ClearAlertCategory(AlertCategory.Pressure); + alertState = alertsComponent.GetComponentState() as AlertsComponentState; + Assert.That(alertState.Alerts.Length, Is.EqualTo(0)); + } + } +} diff --git a/Content.Tests/Shared/Alert/AlertManagerTests.cs b/Content.Tests/Shared/Alert/AlertManagerTests.cs new file mode 100644 index 0000000000..7601b04eb9 --- /dev/null +++ b/Content.Tests/Shared/Alert/AlertManagerTests.cs @@ -0,0 +1,63 @@ +using System.IO; +using Content.Shared.Alert; +using NUnit.Framework; +using Robust.Shared.Interfaces.Log; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using Robust.UnitTesting; +using YamlDotNet.RepresentationModel; + +namespace Content.Tests.Shared.Alert +{ + [TestFixture, TestOf(typeof(AlertManager))] + public class AlertManagerTests : RobustUnitTest + { + const string PROTOTYPES = @" +- type: alert + alertType: LowPressure + icon: /Textures/Interface/Alerts/Pressure/lowpressure.png + +- type: alert + alertType: HighPressure + icon: /Textures/Interface/Alerts/Pressure/highpressure.png +"; + + [Test] + public void TestAlertManager() + { + var prototypeManager = IoCManager.Resolve(); + prototypeManager.RegisterType(typeof(AlertPrototype)); + prototypeManager.LoadFromStream(new StringReader(PROTOTYPES)); + IoCManager.RegisterInstance(new AlertManager()); + var alertManager = IoCManager.Resolve(); + alertManager.Initialize(); + + Assert.That(alertManager.TryGet(AlertType.LowPressure, out var lowPressure)); + Assert.That(lowPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/lowpressure.png")); + Assert.That(alertManager.TryGet(AlertType.HighPressure, out var highPressure)); + Assert.That(highPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/highpressure.png")); + + Assert.That(alertManager.TryGetWithEncoded(AlertType.LowPressure, out lowPressure, out var encodedLowPressure)); + Assert.That(lowPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/lowpressure.png")); + Assert.That(alertManager.TryGetWithEncoded(AlertType.HighPressure, out highPressure, out var encodedHighPressure)); + Assert.That(highPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/highpressure.png")); + + Assert.That(alertManager.TryEncode(lowPressure, out var encodedLowPressure2)); + Assert.That(encodedLowPressure2, Is.EqualTo(encodedLowPressure)); + Assert.That(alertManager.TryEncode(highPressure, out var encodedHighPressure2)); + Assert.That(encodedHighPressure2, Is.EqualTo(encodedHighPressure)); + Assert.That(encodedLowPressure, Is.Not.EqualTo(encodedHighPressure)); + + Assert.That(alertManager.TryDecode(encodedLowPressure, out var decodedLowPressure)); + Assert.That(decodedLowPressure, Is.EqualTo(lowPressure)); + Assert.That(alertManager.TryDecode(encodedHighPressure, out var decodedHighPressure)); + Assert.That(decodedHighPressure, Is.EqualTo(highPressure)); + + Assert.False(alertManager.TryEncode(AlertType.Debug1, out _)); + Assert.False(alertManager.TryGetWithEncoded(AlertType.Debug1, out _, out _)); + + } + } +} diff --git a/Content.Tests/Shared/Alert/AlertOrderPrototypeTests.cs b/Content.Tests/Shared/Alert/AlertOrderPrototypeTests.cs new file mode 100644 index 0000000000..f67914ef6b --- /dev/null +++ b/Content.Tests/Shared/Alert/AlertOrderPrototypeTests.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Content.Shared.Alert; +using NUnit.Framework; +using Robust.Shared.IoC; +using Robust.Shared.Prototypes; +using Robust.UnitTesting; + +namespace Content.Tests.Shared.Alert +{ + [TestFixture, TestOf(typeof(AlertOrderPrototype))] + public class AlertOrderPrototypeTests : RobustUnitTest + { + const string PROTOTYPES = @" +- type: alertOrder + order: + - alertType: Handcuffed + - category: Pressure + - category: Hunger + - alertType: Hot + - alertType: Stun + - alertType: LowPressure + - category: Temperature + +- type: alert + category: Pressure + alertType: LowPressure + +- type: alert + category: Hunger + alertType: Overfed + +- type: alert + category: Pressure + alertType: HighPressure + +- type: alert + category: Hunger + alertType: Peckish + +- type: alert + alertType: Stun + +- type: alert + alertType: Handcuffed + +- type: alert + category: Temperature + alertType: Hot + +- type: alert + category: Temperature + alertType: Cold + +- type: alert + alertType: Weightless + +- type: alert + alertType: PilotingShuttle +"; + + [Test] + public void TestAlertOrderPrototype() + { + var prototypeManager = IoCManager.Resolve(); + prototypeManager.RegisterType(typeof(AlertPrototype)); + prototypeManager.RegisterType(typeof(AlertOrderPrototype)); + prototypeManager.LoadFromStream(new StringReader(PROTOTYPES)); + + var alertOrder = prototypeManager.EnumeratePrototypes().FirstOrDefault(); + + var alerts = prototypeManager.EnumeratePrototypes(); + + // ensure they sort according to our expected criteria + var expectedOrder = new List(); + expectedOrder.Add(AlertType.Handcuffed); + expectedOrder.Add(AlertType.HighPressure); + // stuff with only category + same category ordered by enum value + expectedOrder.Add(AlertType.Overfed); + expectedOrder.Add(AlertType.Peckish); + expectedOrder.Add(AlertType.Hot); + expectedOrder.Add(AlertType.Stun); + expectedOrder.Add(AlertType.LowPressure); + expectedOrder.Add(AlertType.Cold); + // stuff at end of list ordered by enum value + expectedOrder.Add(AlertType.Weightless); + expectedOrder.Add(AlertType.PilotingShuttle); + + var actual = alerts.ToList(); + actual.Sort(alertOrder); + + Assert.That(actual.Select(a => a.AlertType).ToList(), Is.EqualTo(expectedOrder)); + } + } +} diff --git a/Content.Tests/Shared/Alert/AlertPrototypeTests.cs b/Content.Tests/Shared/Alert/AlertPrototypeTests.cs new file mode 100644 index 0000000000..d4104a568f --- /dev/null +++ b/Content.Tests/Shared/Alert/AlertPrototypeTests.cs @@ -0,0 +1,62 @@ +using System.IO; +using Content.Shared.Alert; +using NUnit.Framework; +using Robust.Shared.Interfaces.Log; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Utility; +using Robust.UnitTesting; +using YamlDotNet.RepresentationModel; + +namespace Content.Tests.Shared.Alert +{ + [TestFixture, TestOf(typeof(AlertPrototype))] + public class AlertPrototypeTests : RobustUnitTest + { + private const string PROTOTYPE = @"- type: alert + alertType: HumanHealth + category: Health + icon: /Textures/Interface/Alerts/Human/human.rsi/human.png + name: Health + description: ""[color=green]Green[/color] good. [color=red]Red[/color] bad."" + minSeverity: 0 + maxSeverity: 6"; + + + [Test] + public void TestAlertKey() + { + Assert.That(new AlertKey(AlertType.HumanHealth, null), Is.Not.EqualTo(AlertKey.ForCategory(AlertCategory.Health))); + Assert.That((new AlertKey(null, AlertCategory.Health)), Is.EqualTo(AlertKey.ForCategory(AlertCategory.Health))); + Assert.That((new AlertKey(AlertType.Buckled, AlertCategory.Health)), Is.EqualTo(AlertKey.ForCategory(AlertCategory.Health))); + } + + + [TestCase(0, "/Textures/Interface/Alerts/Human/human.rsi/human0.png")] + [TestCase(null, "/Textures/Interface/Alerts/Human/human.rsi/human0.png")] + [TestCase(1, "/Textures/Interface/Alerts/Human/human.rsi/human1.png")] + [TestCase(6, "/Textures/Interface/Alerts/Human/human.rsi/human6.png")] + [TestCase(7, "/Textures/Interface/Alerts/Human/human.rsi/human6.png")] + public void GetsIconPath(short? severity, string expected) + { + + var alert = GetTestPrototype(); + Assert.That(alert.GetIconPath(severity), Is.EqualTo(expected)); + } + + private AlertPrototype GetTestPrototype() + { + using (TextReader stream = new StringReader(PROTOTYPE)) + { + var yamlStream = new YamlStream(); + yamlStream.Load(stream); + var document = yamlStream.Documents[0]; + var rootNode = (YamlSequenceNode) document.RootNode; + var proto = (YamlMappingNode) rootNode[0]; + var newReagent = new AlertPrototype(); + newReagent.LoadFrom(proto); + return newReagent; + } + } + } +} diff --git a/Resources/Groups/groups.yml b/Resources/Groups/groups.yml index b9f90c5639..640345c72d 100644 --- a/Resources/Groups/groups.yml +++ b/Resources/Groups/groups.yml @@ -232,6 +232,8 @@ - attachtogrid - attachtograndparent - inrangeunoccluded + - showalert + - clearalert - hungry CanViewVar: true CanAdminPlace: true diff --git a/Resources/Prototypes/Alerts/alerts.yml b/Resources/Prototypes/Alerts/alerts.yml new file mode 100644 index 0000000000..52e0d5a655 --- /dev/null +++ b/Resources/Prototypes/Alerts/alerts.yml @@ -0,0 +1,205 @@ +- type: alertOrder + # Defines ordering in alert tab, higher up = higher in tab. + # List below can contain alert type or category, if both are present the id will take precedence. + # If item is not in list it will go at the bottom (ties broken by alert type enum value) + order: + - category: Health + - alertType: Fire + - alertType: Handcuffed + - category: Buckled + - alertType: Pulling + - category: Piloting + - alertType: Stun + - category: Pressure + - category: Temperature + - category: Hunger + - category: Thirst + + + +- type: alert + alertType: LowPressure + category: Pressure + icon: /Textures/Interface/Alerts/Pressure/lowpressure.png + maxSeverity: 2 + name: "[color=red]Low Pressure[/color]" + description: "The air around you is [color=red]hazardously thin[/color]. A [color=green]space suit[/color] would protect you." + +- type: alert + alertType: HighPressure + category: Pressure + icon: /Textures/Interface/Alerts/Pressure/highpressure.png + maxSeverity: 2 + name: "[color=red]High Pressure[/color]" + description: "The air around you is [color=red]hazardously thick[/color]. A [color=green]fire suit[/color] would protect you." + +- type: alert + alertType: Fire + icon: /Textures/Interface/Alerts/Fire/fire.png + name: "[color=red]On Fire[/color]" + description: "You're [color=red]on fire[/color]. Click the alert to stop, drop and roll to put the fire out or move to a vacuum area." + +- type: alert + alertType: Cold + category: Temperature + icon: /Textures/Interface/Alerts/Temperature/cold.png + maxSeverity: 3 + name: "[color=cyan]Too Cold[/color]" + description: "You're [color=cyan]freezing cold![/color] Get somewhere warmer and take off any insulating clothing like a space suit." + +- type: alert + alertType: Hot + category: Temperature + icon: /Textures/Interface/Alerts/Temperature/hot.png + maxSeverity: 3 + name: "[color=red]Too Hot[/color]" + description: "It's [color=red]too hot![/color] Flee to space or at least away from the flames. Standing on weeds will heal you." + +- type: alert + alertType: Weightless + icon: /Textures/Interface/Alerts/Weightless/weightless.png + name: Weightless + description: >- + Gravity has ceased affecting you, and you're floating around aimlessly. You'll need something large and heavy, like a + wall or lattice, to push yourself off if you want to move. A jetpack would enable free range of motion. A pair of + magboots would let you walk around normally on the floor. Barring those, you can throw things, use a fire extinguisher, + or shoot a gun to move around via Newton's 3rd Law of Motion. + +- type: alert + alertType: Stun + icon: /Textures/Objects/Weapons/Melee/stunbaton.rsi/stunbaton_off.png + name: "[color=yellow]Stunned[/color]" + description: "You're [color=yellow]stunned[/color]! Wait for it to wear off." + +- type: alert + alertType: Handcuffed + icon: /Textures/Interface/Alerts/Handcuffed/Handcuffed.png + name: "[color=yellow]Handcuffed[/color]" + description: "You're [color=yellow]handcuffed[/color] and can't act. If anyone drags you, you won't be able to move.." + +- type: alert + alertType: Buckled + category: Buckled + icon: /Textures/Interface/Alerts/Buckle/buckled.png + name: "[color=yellow]Buckled[/color]" + description: "You've been [color=yellow]buckled[/color] to something. Click the alert to unbuckle unless you're [color=yellow]handcuffed.[/color]" + +- type: alert + alertType: HumanCrit + category: Health + icon: /Textures/Interface/Alerts/Human/humancrit-0.png + name: "[color=red]Critical Condition[/color]" + description: "You're severely injured and unconscious." + +- type: alert + alertType: HumanDead + category: Health + icon: /Textures/Interface/Alerts/Human/humandead.png + name: Dead + description: You're dead. + +- type: alert + alertType: HumanHealth + category: Health + icon: /Textures/Interface/Alerts/Human/human.png + name: Health + description: "[color=green]Green[/color] good. [color=red]Red[/color] bad." + minSeverity: 0 + maxSeverity: 6 + +- type: alert + alertType: PilotingShuttle + category: Piloting + icon: /Textures/Interface/Alerts/Buckle/buckled.png + name: Piloting Shuttle + description: You are piloting a shuttle. Click the alert to stop. + +- type: alert + alertType: Overfed + category: Hunger + icon: /Textures/Interface/Alerts/Hunger/Overfed.png + name: "[color=yellow]Overfed[/color]" + description: You ate too much food, lardass. Run around the station and lose some weight. + +- type: alert + alertType: Peckish + category: Hunger + icon: /Textures/Interface/Alerts/Hunger/Peckish.png + name: "[color=yellow]Peckish[/color]" + description: Some food would be good right about now. + +- type: alert + alertType: Starving + category: Hunger + icon: /Textures/Interface/Alerts/Hunger/Starving.png + name: "[color=red]Starving[/color]" + description: You're severely malnourished. The hunger pains make moving around a chore. + +- type: alert + alertType: Overhydrated + category: Thirst + icon: /Textures/Interface/Alerts/Thirst/OverHydrated.png + name: "[color=yellow]Overhydrated[/color]" + description: You drank too much. + +- type: alert + alertType: Thirsty + category: Thirst + icon: /Textures/Interface/Alerts/Thirst/Thirsty.png + name: "[color=yellow]Thirsty[/color]" + description: Something to drink would be good right about now. + +- type: alert + alertType: Parched + category: Thirst + icon: /Textures/Interface/Alerts/Thirst/Parched.png + name: "[color=red]Parched[/color]" + description: You're severely thirsty. The thirst makes moving around a chore. + +- type: alert + alertType: Pulled + icon: /Textures/Interface/Alerts/Pull/pulled.png + name: Pulled + description: You're being pulled. Move to break free. + +- type: alert + alertType: Pulling + icon: /Textures/Interface/Alerts/Pull/pulling.png + name: Pulling + description: You're pulling something. Click the alert to stop. + +- type: alert + alertType: Debug1 + icon: /Textures/Interface/Alerts/Human/human1.png + name: Debug + description: Debug + +- type: alert + alertType: Debug2 + icon: /Textures/Interface/Alerts/Human/human2.png + name: Debug + description: Debug + +- type: alert + alertType: Debug3 + icon: /Textures/Interface/Alerts/Human/human3.png + name: Debug + description: Debug + +- type: alert + alertType: Debug4 + icon: /Textures/Interface/Alerts/Human/human4.png + name: Debug + description: Debug + +- type: alert + alertType: Debug5 + icon: /Textures/Interface/Alerts/Human/human5.png + name: Debug + description: Debug + +- type: alert + alertType: Debug6 + icon: /Textures/Interface/Alerts/Human/human6.png + name: Debug + description: Debug diff --git a/Resources/Prototypes/Entities/Mobs/Player/human.yml b/Resources/Prototypes/Entities/Mobs/Player/human.yml index 7a5ddd085c..f6a5733558 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/human.yml @@ -10,7 +10,7 @@ show_examine_info: true - type: Input context: "human" - - type: StatusEffectsUI + - type: AlertsUI - type: OverlayEffectsUI - type: Eye zoom: 0.5, 0.5 diff --git a/Resources/Textures/Interface/StatusEffects/Buckle/buckled.png b/Resources/Textures/Interface/Alerts/Buckle/buckled.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Buckle/buckled.png rename to Resources/Textures/Interface/Alerts/Buckle/buckled.png diff --git a/Resources/Textures/Interface/StatusEffects/Fire/fire.png b/Resources/Textures/Interface/Alerts/Fire/fire.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Fire/fire.png rename to Resources/Textures/Interface/Alerts/Fire/fire.png diff --git a/Resources/Textures/Interface/StatusEffects/Handcuffed/Handcuffed.png b/Resources/Textures/Interface/Alerts/Handcuffed/Handcuffed.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Handcuffed/Handcuffed.png rename to Resources/Textures/Interface/Alerts/Handcuffed/Handcuffed.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human0.png b/Resources/Textures/Interface/Alerts/Human/human0.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human0.png rename to Resources/Textures/Interface/Alerts/Human/human0.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human1.png b/Resources/Textures/Interface/Alerts/Human/human1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human1.png rename to Resources/Textures/Interface/Alerts/Human/human1.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human2.png b/Resources/Textures/Interface/Alerts/Human/human2.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human2.png rename to Resources/Textures/Interface/Alerts/Human/human2.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human3.png b/Resources/Textures/Interface/Alerts/Human/human3.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human3.png rename to Resources/Textures/Interface/Alerts/Human/human3.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human4.png b/Resources/Textures/Interface/Alerts/Human/human4.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human4.png rename to Resources/Textures/Interface/Alerts/Human/human4.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human5.png b/Resources/Textures/Interface/Alerts/Human/human5.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human5.png rename to Resources/Textures/Interface/Alerts/Human/human5.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human6.png b/Resources/Textures/Interface/Alerts/Human/human6.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human6.png rename to Resources/Textures/Interface/Alerts/Human/human6.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/human7.png b/Resources/Textures/Interface/Alerts/Human/human7.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/human7.png rename to Resources/Textures/Interface/Alerts/Human/human7.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/humancrit-0.png b/Resources/Textures/Interface/Alerts/Human/humancrit-0.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/humancrit-0.png rename to Resources/Textures/Interface/Alerts/Human/humancrit-0.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/humancrit-1.png b/Resources/Textures/Interface/Alerts/Human/humancrit-1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/humancrit-1.png rename to Resources/Textures/Interface/Alerts/Human/humancrit-1.png diff --git a/Resources/Textures/Interface/StatusEffects/Human/humandead.png b/Resources/Textures/Interface/Alerts/Human/humandead.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Human/humandead.png rename to Resources/Textures/Interface/Alerts/Human/humandead.png diff --git a/Resources/Textures/Interface/StatusEffects/Hunger/Overfed.png b/Resources/Textures/Interface/Alerts/Hunger/Overfed.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Hunger/Overfed.png rename to Resources/Textures/Interface/Alerts/Hunger/Overfed.png diff --git a/Resources/Textures/Interface/StatusEffects/Hunger/Peckish.png b/Resources/Textures/Interface/Alerts/Hunger/Peckish.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Hunger/Peckish.png rename to Resources/Textures/Interface/Alerts/Hunger/Peckish.png diff --git a/Resources/Textures/Interface/StatusEffects/Hunger/Starving.png b/Resources/Textures/Interface/Alerts/Hunger/Starving.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Hunger/Starving.png rename to Resources/Textures/Interface/Alerts/Hunger/Starving.png diff --git a/Resources/Textures/Interface/StatusEffects/Pressure/highpressure1.png b/Resources/Textures/Interface/Alerts/Pressure/highpressure1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pressure/highpressure1.png rename to Resources/Textures/Interface/Alerts/Pressure/highpressure1.png diff --git a/Resources/Textures/Interface/StatusEffects/Pressure/highpressure2.png b/Resources/Textures/Interface/Alerts/Pressure/highpressure2.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pressure/highpressure2.png rename to Resources/Textures/Interface/Alerts/Pressure/highpressure2.png diff --git a/Resources/Textures/Interface/StatusEffects/Pressure/lowpressure1.png b/Resources/Textures/Interface/Alerts/Pressure/lowpressure1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pressure/lowpressure1.png rename to Resources/Textures/Interface/Alerts/Pressure/lowpressure1.png diff --git a/Resources/Textures/Interface/StatusEffects/Pressure/lowpressure2.png b/Resources/Textures/Interface/Alerts/Pressure/lowpressure2.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pressure/lowpressure2.png rename to Resources/Textures/Interface/Alerts/Pressure/lowpressure2.png diff --git a/Resources/Textures/Interface/StatusEffects/Pressure/meta.json b/Resources/Textures/Interface/Alerts/Pressure/meta.json similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pressure/meta.json rename to Resources/Textures/Interface/Alerts/Pressure/meta.json diff --git a/Resources/Textures/Interface/StatusEffects/Pull/pulled.png b/Resources/Textures/Interface/Alerts/Pull/pulled.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pull/pulled.png rename to Resources/Textures/Interface/Alerts/Pull/pulled.png diff --git a/Resources/Textures/Interface/StatusEffects/Pull/pulling.png b/Resources/Textures/Interface/Alerts/Pull/pulling.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Pull/pulling.png rename to Resources/Textures/Interface/Alerts/Pull/pulling.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/cold1.png b/Resources/Textures/Interface/Alerts/Temperature/cold1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/cold1.png rename to Resources/Textures/Interface/Alerts/Temperature/cold1.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/cold2.png b/Resources/Textures/Interface/Alerts/Temperature/cold2.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/cold2.png rename to Resources/Textures/Interface/Alerts/Temperature/cold2.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/cold3.png b/Resources/Textures/Interface/Alerts/Temperature/cold3.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/cold3.png rename to Resources/Textures/Interface/Alerts/Temperature/cold3.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/hot1.png b/Resources/Textures/Interface/Alerts/Temperature/hot1.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/hot1.png rename to Resources/Textures/Interface/Alerts/Temperature/hot1.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/hot2.png b/Resources/Textures/Interface/Alerts/Temperature/hot2.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/hot2.png rename to Resources/Textures/Interface/Alerts/Temperature/hot2.png diff --git a/Resources/Textures/Interface/StatusEffects/Temperature/hot3.png b/Resources/Textures/Interface/Alerts/Temperature/hot3.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Temperature/hot3.png rename to Resources/Textures/Interface/Alerts/Temperature/hot3.png diff --git a/Resources/Textures/Interface/StatusEffects/Thirst/OverHydrated.png b/Resources/Textures/Interface/Alerts/Thirst/OverHydrated.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Thirst/OverHydrated.png rename to Resources/Textures/Interface/Alerts/Thirst/OverHydrated.png diff --git a/Resources/Textures/Interface/StatusEffects/Thirst/Parched.png b/Resources/Textures/Interface/Alerts/Thirst/Parched.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Thirst/Parched.png rename to Resources/Textures/Interface/Alerts/Thirst/Parched.png diff --git a/Resources/Textures/Interface/StatusEffects/Thirst/Thirsty.png b/Resources/Textures/Interface/Alerts/Thirst/Thirsty.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Thirst/Thirsty.png rename to Resources/Textures/Interface/Alerts/Thirst/Thirsty.png diff --git a/Resources/Textures/Interface/StatusEffects/Weightless/weightless.png b/Resources/Textures/Interface/Alerts/Weightless/weightless.png similarity index 100% rename from Resources/Textures/Interface/StatusEffects/Weightless/weightless.png rename to Resources/Textures/Interface/Alerts/Weightless/weightless.png diff --git a/Resources/Textures/Interface/Nano/tooltip.png b/Resources/Textures/Interface/Nano/tooltip.png index eadfc72e37..bd6cefaa24 100644 Binary files a/Resources/Textures/Interface/Nano/tooltip.png and b/Resources/Textures/Interface/Nano/tooltip.png differ diff --git a/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png b/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png new file mode 100644 index 0000000000..987ad4cba6 Binary files /dev/null and b/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png differ diff --git a/Resources/Textures/Interface/Nano/window_background_bordered.png b/Resources/Textures/Interface/Nano/window_background_bordered.png new file mode 100644 index 0000000000..461fbf3114 Binary files /dev/null and b/Resources/Textures/Interface/Nano/window_background_bordered.png differ