diff --git a/Content.Client/GameObjects/Components/DoAfterComponent.cs b/Content.Client/GameObjects/Components/DoAfterComponent.cs new file mode 100644 index 0000000000..1b665d2a31 --- /dev/null +++ b/Content.Client/GameObjects/Components/DoAfterComponent.cs @@ -0,0 +1,88 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Content.Client.GameObjects.EntitySystems.DoAfter; +using Content.Shared.GameObjects.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Network; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Players; + +namespace Content.Client.GameObjects.Components +{ + [RegisterComponent] + public sealed class DoAfterComponent : SharedDoAfterComponent + { + public override string Name => "DoAfter"; + + public IReadOnlyDictionary DoAfters => _doAfters; + private readonly Dictionary _doAfters = new Dictionary(); + + public readonly List<(TimeSpan CancelTime, DoAfterMessage Message)> CancelledDoAfters = + new List<(TimeSpan CancelTime, DoAfterMessage Message)>(); + + public override void HandleNetworkMessage(ComponentMessage message, INetChannel netChannel, ICommonSession? session = null) + { + base.HandleNetworkMessage(message, netChannel, session); + switch (message) + { + case DoAfterMessage msg: + _doAfters.Add(msg.ID, msg); + EntitySystem.Get().Gui?.AddDoAfter(msg); + break; + case CancelledDoAfterMessage msg: + Cancel(msg.ID); + break; + } + } + + /// + /// Remove a DoAfter without showing a cancellation graphic. + /// + /// + public void Remove(DoAfterMessage doAfter) + { + if (_doAfters.ContainsKey(doAfter.ID)) + { + _doAfters.Remove(doAfter.ID); + } + + for (var i = CancelledDoAfters.Count - 1; i >= 0; i--) + { + var cancelled = CancelledDoAfters[i]; + + if (cancelled.Message == doAfter) + { + CancelledDoAfters.RemoveAt(i); + break; + } + } + + EntitySystem.Get().Gui?.RemoveDoAfter(doAfter.ID); + } + + /// + /// Mark a DoAfter as cancelled and show a cancellation graphic. + /// + /// Actual removal is handled by DoAfterEntitySystem. + /// + /// + public void Cancel(byte id, TimeSpan? currentTime = null) + { + foreach (var (_, cancelled) in CancelledDoAfters) + { + if (cancelled.ID == id) + { + return; + } + } + + var doAfterMessage = _doAfters[id]; + currentTime ??= IoCManager.Resolve().CurTime; + CancelledDoAfters.Add((currentTime.Value, doAfterMessage)); + EntitySystem.Get().Gui?.CancelDoAfter(id); + } + } +} \ No newline at end of file diff --git a/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterBar.cs b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterBar.cs new file mode 100644 index 0000000000..c1c68a4cb3 --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterBar.cs @@ -0,0 +1,136 @@ +#nullable enable +using System; +using Robust.Client.Graphics.Drawing; +using Robust.Client.Graphics.Shaders; +using Robust.Client.UserInterface; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Maths; +using Robust.Shared.Prototypes; +using Robust.Shared.Timing; + +namespace Content.Client.GameObjects.EntitySystems.DoAfter +{ + public sealed class DoAfterBar : Control + { + private IGameTiming _gameTiming = default!; + + private ShaderInstance _shader; + + /// + /// Set from 0.0f to 1.0f to reflect bar progress + /// + public float Ratio + { + get => _ratio; + set => _ratio = value; + } + + private float _ratio = 1.0f; + + /// + /// Flash red until removed + /// + public bool Cancelled + { + get => _cancelled; + set + { + if (_cancelled == value) + { + return; + } + + _cancelled = value; + if (_cancelled) + { + _gameTiming = IoCManager.Resolve(); + _lastFlash = _gameTiming.CurTime; + } + } + } + + private bool _cancelled; + + /// + /// Is the cancellation bar red? + /// + private bool _flash = true; + + /// + /// Last time we swapped the flash. + /// + private TimeSpan _lastFlash; + + /// + /// How long each cancellation bar flash lasts in seconds. + /// + private const float FlashTime = 0.125f; + + private const int XPixelDiff = 20 * DoAfterBarScale; + + public const byte DoAfterBarScale = 2; + private static readonly Color StartColor = new Color(0.8f, 0.0f, 0.2f); + private static readonly Color EndColor = new Color(0.2f, 0.4f, 1.0f); + + public DoAfterBar() + { + IoCManager.InjectDependencies(this); + _shader = IoCManager.Resolve().Index("unshaded").Instance(); + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + if (Cancelled) + { + if ((_gameTiming.CurTime - _lastFlash).TotalSeconds > FlashTime) + { + _lastFlash = _gameTiming.CurTime; + _flash = !_flash; + } + } + } + + protected override void Draw(DrawingHandleScreen handle) + { + base.Draw(handle); + + Color color; + + if (Cancelled) + { + if ((_gameTiming.CurTime - _lastFlash).TotalSeconds > FlashTime) + { + _lastFlash = _gameTiming.CurTime; + _flash = !_flash; + } + + color = new Color(1.0f, 0.0f, 0.0f, _flash ? 1.0f : 0.0f); + } + else if (Ratio >= 1.0f) + { + color = new Color(0.92f, 0.77f, 0.34f); + } + else + { + // lerp + color = new Color( + StartColor.R + (EndColor.R - StartColor.R) * Ratio, + StartColor.G + (EndColor.G - StartColor.G) * Ratio, + StartColor.B + (EndColor.B - StartColor.B) * Ratio, + StartColor.A); + } + + handle.UseShader(_shader); + // If you want to make this less hard-coded be my guest + var leftOffset = 2 * DoAfterBarScale; + var box = new UIBox2i( + leftOffset, + -2 + 2 * DoAfterBarScale, + leftOffset + (int) (XPixelDiff * Ratio), + -2); + handle.DrawRect(box, color); + } + } +} \ No newline at end of file diff --git a/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterGui.cs b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterGui.cs new file mode 100644 index 0000000000..b785251aa9 --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterGui.cs @@ -0,0 +1,184 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Content.Client.GameObjects.Components; +using Content.Client.Utility; +using Content.Shared.GameObjects.Components; +using Robust.Client.Interfaces.Graphics.ClientEye; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Timing; + +namespace Content.Client.GameObjects.EntitySystems.DoAfter +{ + public sealed class DoAfterGui : VBoxContainer + { + [Dependency] private readonly IEyeManager _eyeManager = default!; + [Dependency] private readonly IGameTiming _gameTiming = default!; + + private Dictionary _doAfterControls = new Dictionary(); + private Dictionary _doAfterBars = new Dictionary(); + + // We'll store cancellations for a little bit just so we can flash the graphic to indicate it's cancelled + private Dictionary _cancelledDoAfters = new Dictionary(); + + public IEntity? AttachedEntity { get; set; } + private ScreenCoordinates _playerPosition; + + // This behavior probably shouldn't be happening; so for whatever reason the control position is set the frame after + // I got NFI why because I don't know the UI internals + private bool _firstDraw = true; + + public DoAfterGui() + { + IoCManager.InjectDependencies(this); + IoCManager.Resolve().StateRoot.AddChild(this); + SeparationOverride = 0; + + LayoutContainer.SetGrowVertical(this, LayoutContainer.GrowDirection.Begin); + } + + /// + /// Add the necessary control for a DoAfter progress bar. + /// + /// + public void AddDoAfter(DoAfterMessage message) + { + if (_doAfterControls.ContainsKey(message.ID)) + { + return; + } + + var doAfterBar = new DoAfterBar + { + SizeFlagsVertical = SizeFlags.ShrinkCenter + }; + + _doAfterBars[message.ID] = doAfterBar; + + var control = new PanelContainer + { + Children = + { + new TextureRect + { + Texture = StaticIoC.ResC.GetTexture("/Textures/Interface/Misc/progress_bar.rsi/icon.png"), + TextureScale = Vector2.One * DoAfterBar.DoAfterBarScale, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + }, + + doAfterBar + } + }; + + AddChild(control); + _doAfterControls.Add(message.ID, control); + } + + // NOTE THAT THE BELOW ONLY HANDLES THE UI SIDE + + /// + /// Removes a DoAfter without showing a cancel graphic. + /// + /// + public void RemoveDoAfter(byte id) + { + if (!_doAfterControls.ContainsKey(id)) + { + return; + } + + var control = _doAfterControls[id]; + RemoveChild(control); + _doAfterControls.Remove(id); + _doAfterBars.Remove(id); + if (_cancelledDoAfters.ContainsKey(id)) + { + _cancelledDoAfters.Remove(id); + } + } + + /// + /// Cancels a DoAfter and shows a graphic indicating it has been cancelled to the player. + /// + /// Can be called multiple times on the 1 DoAfter because of the client predicting the cancellation. + /// + public void CancelDoAfter(byte id) + { + if (_cancelledDoAfters.ContainsKey(id)) + { + return; + } + + var control = _doAfterControls[id]; + _doAfterBars[id].Cancelled = true; + _cancelledDoAfters.Add(id, _gameTiming.CurTime); + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + + if (AttachedEntity == null || !AttachedEntity.TryGetComponent(out DoAfterComponent doAfterComponent)) + { + return; + } + + var doAfters = doAfterComponent.DoAfters; + + // Nothing to render so we'll hide. + if (doAfters.Count == 0 && _cancelledDoAfters.Count == 0) + { + _firstDraw = true; + Visible = false; + return; + } + + // Set position ready for 2nd+ frames. + _playerPosition = _eyeManager.WorldToScreen(AttachedEntity.Transform.GridPosition); + LayoutContainer.SetPosition(this, new Vector2(_playerPosition.X - Width / 2, _playerPosition.Y - Height - 30.0f)); + + if (_firstDraw) + { + _firstDraw = false; + return; + } + + Visible = true; + var currentTime = _gameTiming.CurTime; + var toCancel = new List(); + + // Cleanup cancelled DoAfters + foreach (var (id, cancelTime) in _cancelledDoAfters) + { + if ((currentTime - cancelTime).TotalSeconds > DoAfterSystem.ExcessTime) + { + toCancel.Add(id); + } + } + + foreach (var id in toCancel) + { + RemoveDoAfter(id); + } + + // Update 0 -> 1.0f of the things + foreach (var (id, message) in doAfters) + { + if (_cancelledDoAfters.ContainsKey(id) || !_doAfterControls.ContainsKey(id)) + { + continue; + } + + var doAfterBar = _doAfterBars[id]; + doAfterBar.Ratio = MathF.Min(1.0f, + (float) (currentTime - message.StartTime).TotalSeconds / message.Delay); + } + } + } +} \ No newline at end of file diff --git a/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs new file mode 100644 index 0000000000..d04ac081f9 --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs @@ -0,0 +1,150 @@ +#nullable enable +using System.Linq; +using Content.Client.GameObjects.Components; +using JetBrains.Annotations; +using Robust.Client.GameObjects.EntitySystems; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; + +namespace Content.Client.GameObjects.EntitySystems.DoAfter +{ + /// + /// Handles events that need to happen after a certain amount of time where the event could be cancelled by factors + /// such as moving. + /// + [UsedImplicitly] + public sealed class DoAfterSystem : EntitySystem + { + /* + * How this is currently setup (client-side): + * DoAfterGui handles the actual bars displayed above heads. It also uses FrameUpdate to flash cancellations + * DoAfterEntitySystem handles checking predictions every tick as well as removing / cancelling DoAfters due to time elapsed. + * DoAfterComponent handles network messages inbound as well as storing the DoAfter data. + * It'll also handle overall cleanup when one is removed (i.e. removing it from DoAfterGui). + */ + [Dependency] private readonly IGameTiming _gameTiming = default!; + [Dependency] private readonly IEntityManager _entityManager = default!; + + /// + /// Rather than checking attached player every tick we'll just store it from the message. + /// + private IEntity? _player; + + /// + /// We'll use an excess time so stuff like finishing effects can show. + /// + public const float ExcessTime = 0.5f; + + public DoAfterGui? Gui { get; private set; } + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(message => HandlePlayerAttached(message.AttachedEntity)); + } + + public override void Shutdown() + { + base.Shutdown(); + Gui?.Dispose(); + Gui = null; + } + + private void HandlePlayerAttached(IEntity? entity) + { + _player = entity; + // Setup the GUI and pass the new data to it if applicable. + Gui?.Dispose(); + + if (entity == null) + { + return; + } + + Gui ??= new DoAfterGui(); + Gui.AttachedEntity = entity; + + if (entity.TryGetComponent(out DoAfterComponent doAfterComponent)) + { + foreach (var (_, doAfter) in doAfterComponent.DoAfters) + { + Gui.AddDoAfter(doAfter); + } + } + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var currentTime = _gameTiming.CurTime; + + if (_player == null || !_player.TryGetComponent(out DoAfterComponent doAfterComponent)) + { + return; + } + + var doAfters = doAfterComponent.DoAfters.ToList(); + if (doAfters.Count == 0) + { + return; + } + + var userGrid = _player.Transform.GridPosition; + + // Check cancellations / finishes + foreach (var (id, doAfter) in doAfters) + { + var elapsedTime = (currentTime - doAfter.StartTime).TotalSeconds; + + // If we've passed the final time (after the excess to show completion graphic) then remove. + if (elapsedTime > doAfter.Delay + ExcessTime) + { + Gui?.RemoveDoAfter(id); + doAfterComponent.Remove(doAfter); + continue; + } + + // Don't predict cancellation if it's already finished. + if (elapsedTime > doAfter.Delay) + { + continue; + } + + // Predictions + if (doAfter.BreakOnUserMove) + { + if (userGrid != doAfter.UserGrid) + { + doAfterComponent.Cancel(id, currentTime); + continue; + } + } + + if (doAfter.BreakOnTargetMove) + { + var targetEntity = _entityManager.GetEntity(doAfter.TargetUid); + + if (targetEntity.Transform.GridPosition != doAfter.TargetGrid) + { + doAfterComponent.Cancel(id, currentTime); + continue; + } + } + } + + var count = doAfterComponent.CancelledDoAfters.Count; + // Remove cancelled DoAfters after ExcessTime has elapsed + for (var i = count - 1; i >= 0; i--) + { + var cancelled = doAfterComponent.CancelledDoAfters[i]; + if ((currentTime - cancelled.CancelTime).TotalSeconds > ExcessTime) + { + doAfterComponent.Remove(cancelled.Message); + } + } + } + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs new file mode 100644 index 0000000000..439e10766a --- /dev/null +++ b/Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs @@ -0,0 +1,60 @@ +using System.Threading; +using System.Threading.Tasks; +using Content.Server.GameObjects.Components; +using Content.Server.GameObjects.EntitySystems; +using NUnit.Framework; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Map; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Maths; + +namespace Content.IntegrationTests.Tests.DoAfter +{ + [TestFixture] + [TestOf(typeof(DoAfterComponent))] + public class DoAfterServerTest : ContentIntegrationTest + { + [Test] + public async Task Test() + { + Task task = null; + var server = StartServerDummyTicker(); + float tickTime = 0.0f; + + // That it finishes successfully + server.Post(() => + { + tickTime = 1.0f / IoCManager.Resolve().TickRate; + var mapManager = IoCManager.Resolve(); + mapManager.CreateNewMapEntity(MapId.Nullspace); + var entityManager = IoCManager.Resolve(); + var mob = entityManager.SpawnEntity("HumanMob_Content", MapCoordinates.Nullspace); + var cancelToken = new CancellationTokenSource(); + var args = new DoAfterEventArgs(mob, tickTime / 2, cancelToken.Token); + task = EntitySystem.Get().DoAfter(args); + }); + + await server.WaitRunTicks(1); + Assert.That(task.Result == DoAfterStatus.Finished); + + // That cancel works on mob move + server.Post(() => + { + var mapManager = IoCManager.Resolve(); + mapManager.CreateNewMapEntity(MapId.Nullspace); + var entityManager = IoCManager.Resolve(); + var mob = entityManager.SpawnEntity("HumanMob_Content", MapCoordinates.Nullspace); + var cancelToken = new CancellationTokenSource(); + var args = new DoAfterEventArgs(mob, tickTime * 2, cancelToken.Token); + task = EntitySystem.Get().DoAfter(args); + mob.Transform.GridPosition = mob.Transform.GridPosition.Translated(new Vector2(0.1f, 0.1f)); + }); + + await server.WaitRunTicks(1); + Assert.That(task.Result == DoAfterStatus.Cancelled); + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/Components/DoAfterComponent.cs b/Content.Server/GameObjects/Components/DoAfterComponent.cs new file mode 100644 index 0000000000..4222363af8 --- /dev/null +++ b/Content.Server/GameObjects/Components/DoAfterComponent.cs @@ -0,0 +1,130 @@ +#nullable enable +using System.Collections.Generic; +using Content.Server.GameObjects.EntitySystems; +using Content.Shared.GameObjects.Components; +using Robust.Server.GameObjects; +using Robust.Server.Interfaces.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Network; + +namespace Content.Server.GameObjects.Components +{ + [RegisterComponent] + public sealed class DoAfterComponent : SharedDoAfterComponent + { + public IReadOnlyCollection DoAfters => _doAfters.Keys; + private readonly Dictionary _doAfters = new Dictionary(); + + // So the client knows which one to update (and so we don't send all of the do_afters every time 1 updates) + // we'll just send them the index. Doesn't matter if it wraps around. + private byte _runningIndex; + + public override void HandleMessage(ComponentMessage message, IComponent? component) + { + base.HandleMessage(message, component); + switch (message) + { + case PlayerAttachedMsg _: + UpdateClient(); + break; + } + } + + // Only sending data to the relevant client (at least, other clients don't need to know about do_after for now). + private void UpdateClient() + { + if (!TryGetConnectedClient(out var connectedClient)) + { + return; + } + + foreach (var (doAfter, id) in _doAfters) + { + // THE ALMIGHTY PYRAMID + var message = new DoAfterMessage( + id, + doAfter.UserGrid, + doAfter.TargetGrid, + doAfter.StartTime, + doAfter.EventArgs.Delay, + doAfter.EventArgs.BreakOnUserMove, + doAfter.EventArgs.BreakOnTargetMove, + doAfter.EventArgs.Target?.Uid ?? EntityUid.Invalid); + + SendNetworkMessage(message, connectedClient); + } + } + + private bool TryGetConnectedClient(out INetChannel? connectedClient) + { + connectedClient = null; + + if (!Owner.TryGetComponent(out IActorComponent actorComponent)) + { + return false; + } + + connectedClient = actorComponent.playerSession.ConnectedClient; + if (!connectedClient.IsConnected) + { + return false; + } + + return true; + } + + public void Add(DoAfter doAfter) + { + _doAfters.Add(doAfter, _runningIndex); + + if (TryGetConnectedClient(out var connectedClient)) + { + var message = new DoAfterMessage( + _runningIndex, + doAfter.UserGrid, + doAfter.TargetGrid, + doAfter.StartTime, + doAfter.EventArgs.Delay, + doAfter.EventArgs.BreakOnUserMove, + doAfter.EventArgs.BreakOnTargetMove, + doAfter.EventArgs.Target?.Uid ?? EntityUid.Invalid); + + SendNetworkMessage(message, connectedClient); + } + + _runningIndex++; + } + + public void Cancelled(DoAfter doAfter) + { + if (!_doAfters.TryGetValue(doAfter, out var index)) + { + return; + } + + if (TryGetConnectedClient(out var connectedClient)) + { + var message = new CancelledDoAfterMessage(index); + SendNetworkMessage(message, connectedClient); + } + + _doAfters.Remove(doAfter); + } + + /// + /// Call when the particular DoAfter is finished. + /// Client should be tracking this independently. + /// + /// + public void Finished(DoAfter doAfter) + { + if (!_doAfters.ContainsKey(doAfter)) + { + return; + } + + _doAfters.Remove(doAfter); + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfter.cs b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfter.cs new file mode 100644 index 0000000000..63eb8ee378 --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfter.cs @@ -0,0 +1,172 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Content.Server.GameObjects.Components; +using Content.Server.GameObjects.Components.GUI; +using Content.Server.GameObjects.Components.Mobs; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Map; + +namespace Content.Server.GameObjects.EntitySystems +{ + public sealed class DoAfter + { + public Task AsTask { get; } + + private TaskCompletionSource Tcs { get;} + + public DoAfterEventArgs EventArgs; + + public TimeSpan StartTime { get; } + + public float Elapsed { get; set; } + + public GridCoordinates UserGrid { get; } + + public GridCoordinates TargetGrid { get; } + + private bool _tookDamage; + + public DoAfterStatus Status => AsTask.IsCompletedSuccessfully ? AsTask.Result : DoAfterStatus.Running; + + // NeedHand + private string? _activeHand; + private ItemComponent? _activeItem; + + public DoAfter(DoAfterEventArgs eventArgs) + { + EventArgs = eventArgs; + StartTime = IoCManager.Resolve().CurTime; + + if (eventArgs.BreakOnUserMove) + { + UserGrid = eventArgs.User.Transform.GridPosition; + } + + if (eventArgs.BreakOnTargetMove) + { + // Target should never be null if the bool is set. + TargetGrid = eventArgs.Target!.Transform.GridPosition; + } + + // For this we need to stay on the same hand slot and need the same item in that hand slot + // (or if there is no item there we need to keep it free). + if (eventArgs.NeedHand && eventArgs.User.TryGetComponent(out HandsComponent handsComponent)) + { + _activeHand = handsComponent.ActiveHand; + _activeItem = handsComponent.GetActiveHand; + } + + Tcs = new TaskCompletionSource(); + AsTask = Tcs.Task; + } + + public void HandleDamage(object? sender, DamageEventArgs eventArgs) + { + _tookDamage = true; + } + + public void Run(float frameTime) + { + switch (Status) + { + case DoAfterStatus.Running: + break; + case DoAfterStatus.Cancelled: + case DoAfterStatus.Finished: + return; + default: + throw new ArgumentOutOfRangeException(); + } + + Elapsed += frameTime; + + if (IsFinished()) + { + Tcs.SetResult(DoAfterStatus.Finished); + return; + } + + if (IsCancelled()) + { + Tcs.SetResult(DoAfterStatus.Cancelled); + } + } + + private bool IsCancelled() + { + //https://github.com/tgstation/tgstation/blob/1aa293ea337283a0191140a878eeba319221e5df/code/__HELPERS/mobs.dm + if (EventArgs.CancelToken.IsCancellationRequested) + { + return true; + } + + // TODO :Handle inertia in space. + if (EventArgs.BreakOnUserMove && EventArgs.User.Transform.GridPosition != UserGrid) + { + return true; + } + + if (EventArgs.BreakOnTargetMove && EventArgs.Target!.Transform.GridPosition != TargetGrid) + { + return true; + } + + if (EventArgs.BreakOnDamage && _tookDamage) + { + return true; + } + + if (EventArgs.ExtraCheck != null && !EventArgs.ExtraCheck.Invoke()) + { + return true; + } + + if (EventArgs.BreakOnStun && + EventArgs.User.TryGetComponent(out StunnableComponent stunnableComponent) && + stunnableComponent.Stunned) + { + return true; + } + + if (EventArgs.NeedHand) + { + if (!EventArgs.User.TryGetComponent(out HandsComponent handsComponent)) + { + // If we had a hand but no longer have it that's still a paddlin' + if (_activeHand != null) + { + return true; + } + } + else + { + var currentActiveHand = handsComponent.ActiveHand; + if (_activeHand != currentActiveHand) + { + return true; + } + + var currentItem = handsComponent.GetActiveHand; + if (_activeItem != currentItem) + { + return true; + } + } + } + + return false; + } + + private bool IsFinished() + { + if (Elapsed <= EventArgs.Delay) + { + return false; + } + + return true; + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterEventArgs.cs b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterEventArgs.cs new file mode 100644 index 0000000000..dcada9d5cd --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterEventArgs.cs @@ -0,0 +1,84 @@ +#nullable enable +using System; +using System.Threading; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Server.GameObjects.EntitySystems +{ + public sealed class DoAfterEventArgs + { + /// + /// The entity invoking do_after + /// + public IEntity User { get; } + + /// + /// How long does the do_after require to complete + /// + public float Delay { get; } + + /// + /// Applicable target (if relevant) + /// + public IEntity? Target { get; } + + /// + /// Manually cancel the do_after so it no longer runs + /// + public CancellationToken CancelToken { get; } + + // Break the chains + /// + /// Whether we need to keep our active hand as is (i.e. can't change hand or change item). + /// This also covers requiring the hand to be free (if applicable). + /// + public bool NeedHand { get; } + + /// + /// If do_after stops when the user moves + /// + public bool BreakOnUserMove { get; } + + /// + /// If do_after stops when the target moves (if there is a target) + /// + public bool BreakOnTargetMove { get; } + public bool BreakOnDamage { get; } + public bool BreakOnStun { get; } + + /// + /// Additional conditions that need to be met. Return false to cancel. + /// + public Func? ExtraCheck { get; } + + public DoAfterEventArgs( + IEntity user, + float delay, + CancellationToken cancelToken, + IEntity? target = null, + bool needHand = true, + bool breakOnUserMove = true, + bool breakOnTargetMove = true, + bool breakOnDamage = true, + bool breakOnStun = true, + Func? extraCheck = null + ) + { + User = user; + Delay = delay; + CancelToken = cancelToken; + Target = target; + NeedHand = needHand; + BreakOnUserMove = breakOnUserMove; + BreakOnTargetMove = breakOnTargetMove; + BreakOnDamage = breakOnDamage; + BreakOnStun = breakOnStun; + ExtraCheck = extraCheck; + + if (Target == null) + { + BreakOnTargetMove = false; + } + } + } +} \ No newline at end of file diff --git a/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs new file mode 100644 index 0000000000..8aa394b25a --- /dev/null +++ b/Content.Server/GameObjects/EntitySystems/DoAfter/DoAfterSystem.cs @@ -0,0 +1,101 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Content.Server.GameObjects.Components; +using JetBrains.Annotations; +using Robust.Server.Interfaces.Timing; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.IoC; + +namespace Content.Server.GameObjects.EntitySystems +{ + [UsedImplicitly] + public sealed class DoAfterSystem : EntitySystem + { + [Dependency] private readonly IPauseManager _pauseManager = default!; + + public override void Update(float frameTime) + { + base.Update(frameTime); + + foreach (var comp in ComponentManager.EntityQuery()) + { + if (_pauseManager.IsGridPaused(comp.Owner.Transform.GridID)) continue; + + var cancelled = new List(0); + var finished = new List(0); + + foreach (var doAfter in comp.DoAfters) + { + doAfter.Run(frameTime); + + switch (doAfter.Status) + { + case DoAfterStatus.Running: + break; + case DoAfterStatus.Cancelled: + cancelled.Add(doAfter); + break; + case DoAfterStatus.Finished: + finished.Add(doAfter); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + foreach (var doAfter in cancelled) + { + comp.Cancelled(doAfter); + } + + foreach (var doAfter in finished) + { + comp.Finished(doAfter); + } + + finished.Clear(); + } + } + + /// + /// Tasks that are delayed until the specified time has passed + /// These can be potentially cancelled by the user moving or when other things happen. + /// + /// + /// + public async Task DoAfter(DoAfterEventArgs eventArgs) + { + // Setup + var doAfter = new DoAfter(eventArgs); + // Caller's gonna be responsible for this I guess + var doAfterComponent = eventArgs.User.GetComponent(); + doAfterComponent.Add(doAfter); + DamageableComponent? damageableComponent = null; + + // TODO: If the component's deleted this may not get unsubscribed? + if (eventArgs.BreakOnDamage && eventArgs.User.TryGetComponent(out damageableComponent)) + { + damageableComponent.Damaged += doAfter.HandleDamage; + } + + await doAfter.AsTask; + + if (damageableComponent != null) + { + damageableComponent.Damaged -= doAfter.HandleDamage; + } + + return doAfter.Status; + } + } + + public enum DoAfterStatus + { + Running, + Cancelled, + Finished, + } +} \ No newline at end of file diff --git a/Content.Shared/GameObjects/Components/SharedDoAfterComponent.cs b/Content.Shared/GameObjects/Components/SharedDoAfterComponent.cs new file mode 100644 index 0000000000..b84d238700 --- /dev/null +++ b/Content.Shared/GameObjects/Components/SharedDoAfterComponent.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components +{ + public abstract class SharedDoAfterComponent : Component + { + public override string Name => "DoAfter"; + + public override uint? NetID => ContentNetIDs.DO_AFTER; + } + + [Serializable, NetSerializable] + public sealed class CancelledDoAfterMessage : ComponentMessage + { + public byte ID { get; } + + public CancelledDoAfterMessage(byte id) + { + ID = id; + } + } + + /// + /// We send a trimmed-down version of the DoAfter for the client for it to use. + /// + [Serializable, NetSerializable] + public sealed class DoAfterMessage : ComponentMessage + { + // To see what these do look at DoAfter and DoAfterEventArgs + public byte ID { get; } + + public TimeSpan StartTime { get; } + + public GridCoordinates UserGrid { get; } + + public GridCoordinates TargetGrid { get; } + + public EntityUid TargetUid { get; } + + public float Delay { get; } + + // TODO: The other ones need predicting + public bool BreakOnUserMove { get; } + + public bool BreakOnTargetMove { get; } + + public DoAfterMessage(byte id, GridCoordinates userGrid, GridCoordinates targetGrid, TimeSpan startTime, float delay, bool breakOnUserMove, bool breakOnTargetMove, EntityUid targetUid = default) + { + ID = id; + UserGrid = userGrid; + TargetGrid = targetGrid; + StartTime = startTime; + Delay = delay; + BreakOnUserMove = breakOnUserMove; + BreakOnTargetMove = breakOnTargetMove; + TargetUid = targetUid; + } + } +} \ No newline at end of file diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index 625aeb0fd5..0a2854e429 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -61,6 +61,7 @@ public const uint THROWN_ITEM = 1054; public const uint STRAP = 1055; public const uint DISPOSABLE = 1056; + public const uint DO_AFTER = 1057; // Net IDs for integration tests. public const uint PREDICTION_TEST = 10001; diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml index a143640d1c..5118d9606e 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml @@ -144,6 +144,7 @@ proper: true - type: Pullable - type: CanSeeGases + - type: DoAfter - type: entity save: false diff --git a/Resources/Textures/Interface/Misc/progress_bar.rsi/icon.png b/Resources/Textures/Interface/Misc/progress_bar.rsi/icon.png new file mode 100644 index 0000000000..6038bb659d Binary files /dev/null and b/Resources/Textures/Interface/Misc/progress_bar.rsi/icon.png differ diff --git a/Resources/Textures/Interface/Misc/progress_bar.rsi/meta.json b/Resources/Textures/Interface/Misc/progress_bar.rsi/meta.json new file mode 100644 index 0000000000..01f0ac17b0 --- /dev/null +++ b/Resources/Textures/Interface/Misc/progress_bar.rsi/meta.json @@ -0,0 +1,15 @@ +{ + "version": 1, + "size": { + "x": 24, + "y": 7 + }, + "license": "CC-BY-SA-3.0", + "copyright": "https://github.com/tgstation/tgstation/blob/886ca0f8dddf83ecaf10c92ff106172722352192/icons/effects/progessbar.dmi", + "states": [ + { + "name": "icon", + "directions": 1 + } + ] +} \ No newline at end of file