do_after (#1616)
* do_after Ports (most of) do_after from SS13. Callers are expected to await the DoAfter task from the DoAfterSystem. I had a dummy component for in-game testing which I removed for the PR so nothing in game uses do_after at the moment. Currently only the movement cancellation is predicted client-side. * Minor do_after doc cleanup * do_the_shuffle Fix nullable build errors. * The last nullable * Implement NeedHand Thanks zum. * nullable dereference * Adjust the system query Co-authored-by: Metal Gear Sloth <metalgearsloth@gmail.com>
This commit is contained in:
88
Content.Client/GameObjects/Components/DoAfterComponent.cs
Normal file
88
Content.Client/GameObjects/Components/DoAfterComponent.cs
Normal file
@@ -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<byte, DoAfterMessage> DoAfters => _doAfters;
|
||||
private readonly Dictionary<byte, DoAfterMessage> _doAfters = new Dictionary<byte, DoAfterMessage>();
|
||||
|
||||
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<DoAfterSystem>().Gui?.AddDoAfter(msg);
|
||||
break;
|
||||
case CancelledDoAfterMessage msg:
|
||||
Cancel(msg.ID);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove a DoAfter without showing a cancellation graphic.
|
||||
/// </summary>
|
||||
/// <param name="doAfter"></param>
|
||||
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<DoAfterSystem>().Gui?.RemoveDoAfter(doAfter.ID);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mark a DoAfter as cancelled and show a cancellation graphic.
|
||||
/// </summary>
|
||||
/// Actual removal is handled by DoAfterEntitySystem.
|
||||
/// <param name="id"></param>
|
||||
/// <param name="currentTime"></param>
|
||||
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<IGameTiming>().CurTime;
|
||||
CancelledDoAfters.Add((currentTime.Value, doAfterMessage));
|
||||
EntitySystem.Get<DoAfterSystem>().Gui?.CancelDoAfter(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
136
Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterBar.cs
Normal file
136
Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterBar.cs
Normal file
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Set from 0.0f to 1.0f to reflect bar progress
|
||||
/// </summary>
|
||||
public float Ratio
|
||||
{
|
||||
get => _ratio;
|
||||
set => _ratio = value;
|
||||
}
|
||||
|
||||
private float _ratio = 1.0f;
|
||||
|
||||
/// <summary>
|
||||
/// Flash red until removed
|
||||
/// </summary>
|
||||
public bool Cancelled
|
||||
{
|
||||
get => _cancelled;
|
||||
set
|
||||
{
|
||||
if (_cancelled == value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_cancelled = value;
|
||||
if (_cancelled)
|
||||
{
|
||||
_gameTiming = IoCManager.Resolve<IGameTiming>();
|
||||
_lastFlash = _gameTiming.CurTime;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool _cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Is the cancellation bar red?
|
||||
/// </summary>
|
||||
private bool _flash = true;
|
||||
|
||||
/// <summary>
|
||||
/// Last time we swapped the flash.
|
||||
/// </summary>
|
||||
private TimeSpan _lastFlash;
|
||||
|
||||
/// <summary>
|
||||
/// How long each cancellation bar flash lasts in seconds.
|
||||
/// </summary>
|
||||
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<IPrototypeManager>().Index<ShaderPrototype>("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);
|
||||
}
|
||||
}
|
||||
}
|
||||
184
Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterGui.cs
Normal file
184
Content.Client/GameObjects/EntitySystems/DoAfter/DoAfterGui.cs
Normal file
@@ -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<byte, PanelContainer> _doAfterControls = new Dictionary<byte, PanelContainer>();
|
||||
private Dictionary<byte, DoAfterBar> _doAfterBars = new Dictionary<byte, DoAfterBar>();
|
||||
|
||||
// We'll store cancellations for a little bit just so we can flash the graphic to indicate it's cancelled
|
||||
private Dictionary<byte, TimeSpan> _cancelledDoAfters = new Dictionary<byte, TimeSpan>();
|
||||
|
||||
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<IUserInterfaceManager>().StateRoot.AddChild(this);
|
||||
SeparationOverride = 0;
|
||||
|
||||
LayoutContainer.SetGrowVertical(this, LayoutContainer.GrowDirection.Begin);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add the necessary control for a DoAfter progress bar.
|
||||
/// </summary>
|
||||
/// <param name="message"></param>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Removes a DoAfter without showing a cancel graphic.
|
||||
/// </summary>
|
||||
/// <param name="id"></param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a DoAfter and shows a graphic indicating it has been cancelled to the player.
|
||||
/// </summary>
|
||||
/// Can be called multiple times on the 1 DoAfter because of the client predicting the cancellation.
|
||||
/// <param name="id"></param>
|
||||
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<byte>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles events that need to happen after a certain amount of time where the event could be cancelled by factors
|
||||
/// such as moving.
|
||||
/// </summary>
|
||||
[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!;
|
||||
|
||||
/// <summary>
|
||||
/// Rather than checking attached player every tick we'll just store it from the message.
|
||||
/// </summary>
|
||||
private IEntity? _player;
|
||||
|
||||
/// <summary>
|
||||
/// We'll use an excess time so stuff like finishing effects can show.
|
||||
/// </summary>
|
||||
public const float ExcessTime = 0.5f;
|
||||
|
||||
public DoAfterGui? Gui { get; private set; }
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
SubscribeLocalEvent<PlayerAttachSysMessage>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs
Normal file
60
Content.IntegrationTests/Tests/DoAfter/DoAfterServerTest.cs
Normal file
@@ -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<DoAfterStatus> task = null;
|
||||
var server = StartServerDummyTicker();
|
||||
float tickTime = 0.0f;
|
||||
|
||||
// That it finishes successfully
|
||||
server.Post(() =>
|
||||
{
|
||||
tickTime = 1.0f / IoCManager.Resolve<IGameTiming>().TickRate;
|
||||
var mapManager = IoCManager.Resolve<IMapManager>();
|
||||
mapManager.CreateNewMapEntity(MapId.Nullspace);
|
||||
var entityManager = IoCManager.Resolve<IEntityManager>();
|
||||
var mob = entityManager.SpawnEntity("HumanMob_Content", MapCoordinates.Nullspace);
|
||||
var cancelToken = new CancellationTokenSource();
|
||||
var args = new DoAfterEventArgs(mob, tickTime / 2, cancelToken.Token);
|
||||
task = EntitySystem.Get<DoAfterSystem>().DoAfter(args);
|
||||
});
|
||||
|
||||
await server.WaitRunTicks(1);
|
||||
Assert.That(task.Result == DoAfterStatus.Finished);
|
||||
|
||||
// That cancel works on mob move
|
||||
server.Post(() =>
|
||||
{
|
||||
var mapManager = IoCManager.Resolve<IMapManager>();
|
||||
mapManager.CreateNewMapEntity(MapId.Nullspace);
|
||||
var entityManager = IoCManager.Resolve<IEntityManager>();
|
||||
var mob = entityManager.SpawnEntity("HumanMob_Content", MapCoordinates.Nullspace);
|
||||
var cancelToken = new CancellationTokenSource();
|
||||
var args = new DoAfterEventArgs(mob, tickTime * 2, cancelToken.Token);
|
||||
task = EntitySystem.Get<DoAfterSystem>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
130
Content.Server/GameObjects/Components/DoAfterComponent.cs
Normal file
130
Content.Server/GameObjects/Components/DoAfterComponent.cs
Normal file
@@ -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<DoAfter> DoAfters => _doAfters.Keys;
|
||||
private readonly Dictionary<DoAfter, byte> _doAfters = new Dictionary<DoAfter, byte>();
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call when the particular DoAfter is finished.
|
||||
/// Client should be tracking this independently.
|
||||
/// </summary>
|
||||
/// <param name="doAfter"></param>
|
||||
public void Finished(DoAfter doAfter)
|
||||
{
|
||||
if (!_doAfters.ContainsKey(doAfter))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_doAfters.Remove(doAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
172
Content.Server/GameObjects/EntitySystems/DoAfter/DoAfter.cs
Normal file
172
Content.Server/GameObjects/EntitySystems/DoAfter/DoAfter.cs
Normal file
@@ -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<DoAfterStatus> AsTask { get; }
|
||||
|
||||
private TaskCompletionSource<DoAfterStatus> 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<IGameTiming>().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<DoAfterStatus>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// The entity invoking do_after
|
||||
/// </summary>
|
||||
public IEntity User { get; }
|
||||
|
||||
/// <summary>
|
||||
/// How long does the do_after require to complete
|
||||
/// </summary>
|
||||
public float Delay { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Applicable target (if relevant)
|
||||
/// </summary>
|
||||
public IEntity? Target { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Manually cancel the do_after so it no longer runs
|
||||
/// </summary>
|
||||
public CancellationToken CancelToken { get; }
|
||||
|
||||
// Break the chains
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public bool NeedHand { get; }
|
||||
|
||||
/// <summary>
|
||||
/// If do_after stops when the user moves
|
||||
/// </summary>
|
||||
public bool BreakOnUserMove { get; }
|
||||
|
||||
/// <summary>
|
||||
/// If do_after stops when the target moves (if there is a target)
|
||||
/// </summary>
|
||||
public bool BreakOnTargetMove { get; }
|
||||
public bool BreakOnDamage { get; }
|
||||
public bool BreakOnStun { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Additional conditions that need to be met. Return false to cancel.
|
||||
/// </summary>
|
||||
public Func<bool>? 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<bool>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<DoAfterComponent>())
|
||||
{
|
||||
if (_pauseManager.IsGridPaused(comp.Owner.Transform.GridID)) continue;
|
||||
|
||||
var cancelled = new List<DoAfter>(0);
|
||||
var finished = new List<DoAfter>(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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tasks that are delayed until the specified time has passed
|
||||
/// These can be potentially cancelled by the user moving or when other things happen.
|
||||
/// </summary>
|
||||
/// <param name="eventArgs"></param>
|
||||
/// <returns></returns>
|
||||
public async Task<DoAfterStatus> DoAfter(DoAfterEventArgs eventArgs)
|
||||
{
|
||||
// Setup
|
||||
var doAfter = new DoAfter(eventArgs);
|
||||
// Caller's gonna be responsible for this I guess
|
||||
var doAfterComponent = eventArgs.User.GetComponent<DoAfterComponent>();
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// We send a trimmed-down version of the DoAfter for the client for it to use.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -144,6 +144,7 @@
|
||||
proper: true
|
||||
- type: Pullable
|
||||
- type: CanSeeGases
|
||||
- type: DoAfter
|
||||
|
||||
- type: entity
|
||||
save: false
|
||||
|
||||
BIN
Resources/Textures/Interface/Misc/progress_bar.rsi/icon.png
Normal file
BIN
Resources/Textures/Interface/Misc/progress_bar.rsi/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
15
Resources/Textures/Interface/Misc/progress_bar.rsi/meta.json
Normal file
15
Resources/Textures/Interface/Misc/progress_bar.rsi/meta.json
Normal file
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user