Action charges refactor (#33993)

* Action charges refactor

- Fixes the slight godmoding of baseactioncomponent.
- Gets back 1ms of server time.

* chorg

* Remove FrameUpdate

* Fixes

* More fixes

* Combine

* Fixes

* Updates

* weh

* Last fixes

* weh

* Fix naughty

* YAML fixes

* This one too

* Merge conflicts

* This thing

* Review

* Fix this as well

* Icon fix

* weh

* Review

* Review

* seamless

* Review
This commit is contained in:
metalgearsloth
2025-04-18 13:45:48 +10:00
committed by GitHub
parent 424f153142
commit 7d2ef2bd47
30 changed files with 366 additions and 399 deletions

View File

@@ -1,6 +1,7 @@
using System.IO;
using System.Linq;
using Content.Shared.Actions;
using Content.Shared.Charges.Systems;
using JetBrains.Annotations;
using Robust.Client.Player;
using Robust.Shared.ContentPack;
@@ -22,6 +23,7 @@ namespace Content.Client.Actions
{
public delegate void OnActionReplaced(EntityUid actionId);
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IResourceManager _resources = default!;
[Dependency] private readonly ISerializationManager _serialization = default!;
@@ -51,29 +53,6 @@ namespace Content.Client.Actions
SubscribeLocalEvent<EntityWorldTargetActionComponent, ComponentHandleState>(OnEntityWorldTargetHandleState);
}
public override void FrameUpdate(float frameTime)
{
base.FrameUpdate(frameTime);
var worldActionQuery = EntityQueryEnumerator<WorldTargetActionComponent>();
while (worldActionQuery.MoveNext(out var uid, out var action))
{
UpdateAction(uid, action);
}
var instantActionQuery = EntityQueryEnumerator<InstantActionComponent>();
while (instantActionQuery.MoveNext(out var uid, out var action))
{
UpdateAction(uid, action);
}
var entityActionQuery = EntityQueryEnumerator<EntityTargetActionComponent>();
while (entityActionQuery.MoveNext(out var uid, out var action))
{
UpdateAction(uid, action);
}
}
private void OnInstantHandleState(EntityUid uid, InstantActionComponent component, ref ComponentHandleState args)
{
if (args.Current is not InstantActionComponentState state)
@@ -127,9 +106,6 @@ namespace Content.Client.Actions
component.Toggled = state.Toggled;
component.Cooldown = state.Cooldown;
component.UseDelay = state.UseDelay;
component.Charges = state.Charges;
component.MaxCharges = state.MaxCharges;
component.RenewCharges = state.RenewCharges;
component.Container = EnsureEntity<T>(state.Container, uid);
component.EntityIcon = EnsureEntity<T>(state.EntityIcon, uid);
component.CheckCanInteract = state.CheckCanInteract;
@@ -152,7 +128,8 @@ namespace Content.Client.Actions
if (!ResolveActionData(actionId, ref action))
return;
action.IconColor = action.Charges < 1 ? action.DisabledIconColor : action.OriginalIconColor;
// TODO: Decouple this.
action.IconColor = _sharedCharges.GetCurrentCharges(actionId.Value) == 0 ? action.DisabledIconColor : action.OriginalIconColor;
base.UpdateAction(actionId, action);
if (_playerManager.LocalEntity != action.AttachedEntity)

View File

@@ -0,0 +1,52 @@
using Content.Client.Actions;
using Content.Shared.Actions;
using Content.Shared.Charges.Components;
using Content.Shared.Charges.Systems;
namespace Content.Client.Charges;
public sealed class ChargesSystem : SharedChargesSystem
{
[Dependency] private readonly ActionsSystem _actions = default!;
private Dictionary<EntityUid, int> _lastCharges = new();
private Dictionary<EntityUid, int> _tempLastCharges = new();
public override void Update(float frameTime)
{
// Technically this should probably be in frameupdate but no one will ever notice a tick of delay on this.
base.Update(frameTime);
if (!_timing.IsFirstTimePredicted)
return;
// Update recharging actions. Server doesn't actually care about this and it's a waste of performance, actions are immediate.
var query = AllEntityQuery<AutoRechargeComponent, LimitedChargesComponent>();
while (query.MoveNext(out var uid, out var recharge, out var charges))
{
BaseActionComponent? actionComp = null;
if (!_actions.ResolveActionData(uid, ref actionComp, logError: false))
continue;
var current = GetCurrentCharges((uid, charges, recharge));
if (!_lastCharges.TryGetValue(uid, out var last) || current != last)
{
_actions.UpdateAction(uid, actionComp);
}
_tempLastCharges[uid] = current;
}
_lastCharges.Clear();
foreach (var (uid, value) in _tempLastCharges)
{
_lastCharges[uid] = value;
}
_tempLastCharges.Clear();
}
}

View File

@@ -1,5 +0,0 @@
using Content.Shared.Charges.Systems;
namespace Content.Client.Charges.Systems;
public sealed class ChargesSystem : SharedChargesSystem { }

View File

@@ -12,6 +12,7 @@ using Content.Client.UserInterface.Systems.Actions.Widgets;
using Content.Client.UserInterface.Systems.Actions.Windows;
using Content.Client.UserInterface.Systems.Gameplay;
using Content.Shared.Actions;
using Content.Shared.Charges.Systems;
using Content.Shared.Input;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
@@ -42,9 +43,9 @@ public sealed class ActionUIController : UIController, IOnStateChanged<GameplayS
[Dependency] private readonly IOverlayManager _overlays = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IEntityManager _entMan = default!;
[Dependency] private readonly IInputManager _input = default!;
[UISystemDependency] private readonly SharedChargesSystem _sharedCharges = default!;
[UISystemDependency] private readonly ActionsSystem? _actionsSystem = default;
[UISystemDependency] private readonly InteractionOutlineSystem? _interactionOutline = default;
[UISystemDependency] private readonly TargetOutlineSystem? _targetOutline = default;
@@ -173,7 +174,6 @@ public sealed class ActionUIController : UIController, IOnStateChanged<GameplayS
// Is the action currently valid?
if (!action.Enabled
|| action is { Charges: 0, RenewCharges: false }
|| action.Cooldown.HasValue && action.Cooldown.Value.End > _timing.CurTime)
{
// The user is targeting with this action, but it is not valid. Maybe mark this click as
@@ -483,7 +483,7 @@ public sealed class ActionUIController : UIController, IOnStateChanged<GameplayS
continue;
}
var button = new ActionButton(_entMan, _spriteSystem, this) {Locked = true};
var button = new ActionButton(EntityManager, _spriteSystem, this) {Locked = true};
button.ActionPressed += OnWindowActionPressed;
button.ActionUnpressed += OnWindowActionUnPressed;
button.ActionFocusExited += OnWindowActionFocusExisted;

View File

@@ -4,6 +4,8 @@ using Content.Client.Actions.UI;
using Content.Client.Cooldown;
using Content.Client.Stylesheets;
using Content.Shared.Actions;
using Content.Shared.Charges.Components;
using Content.Shared.Charges.Systems;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
@@ -22,10 +24,13 @@ public sealed class ActionButton : Control, IEntityControl
private IEntityManager _entities;
private SpriteSystem? _spriteSys;
private ActionUIController? _controller;
private SharedChargesSystem _sharedChargesSys;
private bool _beingHovered;
private bool _depressed;
private bool _toggled;
private int _lastCharges;
public BoundKeyFunction? KeyBind
{
set
@@ -65,6 +70,7 @@ public sealed class ActionButton : Control, IEntityControl
_entities = entities;
_spriteSys = spriteSys;
_sharedChargesSys = _entities.System<SharedChargesSystem>();
_controller = controller;
MouseFilter = MouseFilterMode.Pass;
@@ -194,14 +200,22 @@ public sealed class ActionButton : Control, IEntityControl
var name = FormattedMessage.FromMarkupPermissive(Loc.GetString(metadata.EntityName));
var decr = FormattedMessage.FromMarkupPermissive(Loc.GetString(metadata.EntityDescription));
FormattedMessage? chargesText = null;
if (_action is { Charges: not null })
// TODO: Don't touch this use an event make callers able to add their own shit for actions or I kill you.
if (_entities.TryGetComponent(ActionId, out LimitedChargesComponent? actionCharges))
{
var charges = FormattedMessage.FromMarkupPermissive(Loc.GetString($"Charges: {_action.Charges.Value.ToString()}/{_action.MaxCharges.ToString()}"));
return new ActionAlertTooltip(name, decr, charges: charges);
var charges = _sharedChargesSys.GetCurrentCharges((ActionId.Value, actionCharges, null));
chargesText = FormattedMessage.FromMarkupPermissive(Loc.GetString($"Charges: {charges.ToString()}/{actionCharges.MaxCharges}"));
if (_entities.TryGetComponent(ActionId, out AutoRechargeComponent? autoRecharge))
{
var chargeTimeRemaining = _sharedChargesSys.GetNextRechargeTime((ActionId.Value, actionCharges, autoRecharge));
chargesText.AddText(Loc.GetString($"{Environment.NewLine}Time Til Recharge: {chargeTimeRemaining}"));
}
}
return new ActionAlertTooltip(name, decr);
return new ActionAlertTooltip(name, decr, charges: chargesText);
}
protected override void ControlFocusExited()

View File

@@ -0,0 +1,8 @@
using Content.Shared.Charges.Systems;
namespace Content.Server.Charges;
public sealed class ChargesSystem : SharedChargesSystem
{
}

View File

@@ -1,27 +0,0 @@
using Content.Server.Charges.Systems;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Server.Charges.Components;
/// <summary>
/// Something with limited charges that can be recharged automatically.
/// Requires LimitedChargesComponent to function.
/// </summary>
// TODO: no reason this cant be predicted and server system deleted
[RegisterComponent, AutoGenerateComponentPause]
[Access(typeof(ChargesSystem))]
public sealed partial class AutoRechargeComponent : Component
{
/// <summary>
/// The time it takes to regain a single charge
/// </summary>
[DataField("rechargeDuration"), ViewVariables(VVAccess.ReadWrite)]
public TimeSpan RechargeDuration = TimeSpan.FromSeconds(90);
/// <summary>
/// The time when the next charge will be added
/// </summary>
[DataField("nextChargeTime", customTypeSerializer: typeof(TimeOffsetSerializer))]
[AutoPausedField]
public TimeSpan NextChargeTime;
}

View File

@@ -1,53 +0,0 @@
using Content.Server.Charges.Components;
using Content.Shared.Charges.Components;
using Content.Shared.Charges.Systems;
using Content.Shared.Examine;
using Robust.Shared.Timing;
namespace Content.Server.Charges.Systems;
public sealed class ChargesSystem : SharedChargesSystem
{
[Dependency] private readonly IGameTiming _timing = default!;
public override void Update(float frameTime)
{
base.Update(frameTime);
var query = EntityQueryEnumerator<LimitedChargesComponent, AutoRechargeComponent>();
while (query.MoveNext(out var uid, out var charges, out var recharge))
{
if (charges.Charges == charges.MaxCharges || _timing.CurTime < recharge.NextChargeTime)
continue;
AddCharges(uid, 1, charges);
recharge.NextChargeTime = _timing.CurTime + recharge.RechargeDuration;
}
}
protected override void OnExamine(EntityUid uid, LimitedChargesComponent comp, ExaminedEvent args)
{
base.OnExamine(uid, comp, args);
// only show the recharging info if it's not full
if (!args.IsInDetailsRange || comp.Charges == comp.MaxCharges || !TryComp<AutoRechargeComponent>(uid, out var recharge))
return;
var timeRemaining = Math.Round((recharge.NextChargeTime - _timing.CurTime).TotalSeconds);
args.PushMarkup(Loc.GetString("limited-charges-recharging", ("seconds", timeRemaining)));
}
public override void AddCharges(EntityUid uid, int change, LimitedChargesComponent? comp = null)
{
if (!Query.Resolve(uid, ref comp, false))
return;
var startRecharge = comp.Charges == comp.MaxCharges;
base.AddCharges(uid, change, comp);
// if a charge was just used from full, start the recharge timer
// TODO: probably make this an event instead of having le server system that just does this
if (change < 0 && startRecharge && TryComp<AutoRechargeComponent>(uid, out var recharge))
recharge.NextChargeTime = _timing.CurTime + recharge.RechargeDuration;
}
}

View File

@@ -29,7 +29,7 @@ namespace Content.Server.Flash
{
[Dependency] private readonly AppearanceSystem _appearance = default!;
[Dependency] private readonly AudioSystem _audio = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly EntityLookupSystem _entityLookup = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly ExamineSystemShared _examine = default!;
@@ -86,15 +86,15 @@ namespace Content.Server.Flash
return false;
TryComp<LimitedChargesComponent>(uid, out var charges);
if (_charges.IsEmpty(uid, charges))
if (_sharedCharges.IsEmpty((uid, charges)))
return false;
_charges.UseCharge(uid, charges);
_sharedCharges.TryUseCharge((uid, charges));
_audio.PlayPvs(comp.Sound, uid);
comp.Flashing = true;
_appearance.SetData(uid, FlashVisuals.Flashing, true);
if (_charges.IsEmpty(uid, charges))
if (_sharedCharges.IsEmpty((uid, charges)))
{
_appearance.SetData(uid, FlashVisuals.Burnt, true);
_tag.AddTag(uid, TrashTag);

View File

@@ -86,24 +86,6 @@ public abstract partial class BaseActionComponent : Component
/// </summary>
[DataField("useDelay")] public TimeSpan? UseDelay;
/// <summary>
/// Convenience tool for actions with limited number of charges. Automatically decremented on use, and the
/// action is disabled when it reaches zero. Does NOT automatically remove the action from the action bar.
/// However, charges will regenerate if <see cref="RenewCharges"/> is enabled and the action will not disable
/// when charges reach zero.
/// </summary>
[DataField("charges")] public int? Charges;
/// <summary>
/// The max charges this action has. If null, this is set automatically from <see cref="Charges"/> on mapinit.
/// </summary>
[DataField] public int? MaxCharges;
/// <summary>
/// If enabled, charges will regenerate after a <see cref="Cooldown"/> is complete
/// </summary>
[DataField("renewCharges")]public bool RenewCharges;
/// <summary>
/// The entity that contains this action. If the action is innate, this may be the user themselves.
/// This should almost always be non-null.
@@ -209,9 +191,6 @@ public abstract class BaseActionComponentState : ComponentState
public bool Toggled;
public (TimeSpan Start, TimeSpan End)? Cooldown;
public TimeSpan? UseDelay;
public int? Charges;
public int? MaxCharges;
public bool RenewCharges;
public NetEntity? Container;
public NetEntity? EntityIcon;
public bool CheckCanInteract;
@@ -243,9 +222,6 @@ public abstract class BaseActionComponentState : ComponentState
Toggled = component.Toggled;
Cooldown = component.Cooldown;
UseDelay = component.UseDelay;
Charges = component.Charges;
MaxCharges = component.MaxCharges;
RenewCharges = component.RenewCharges;
CheckCanInteract = component.CheckCanInteract;
CheckConsciousness = component.CheckConsciousness;
ClientExclusive = component.ClientExclusive;

View File

@@ -21,14 +21,14 @@ namespace Content.Shared.Actions;
public abstract class SharedActionsSystem : EntitySystem
{
[Dependency] protected readonly IGameTiming GameTiming = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly ActionBlockerSystem _actionBlockerSystem = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!;
[Dependency] private readonly RotateToFaceSystem _rotateToFaceSystem = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
public override void Initialize()
{
@@ -69,47 +69,9 @@ public abstract class SharedActionsSystem : EntitySystem
SubscribeAllEvent<RequestPerformActionEvent>(OnActionRequest);
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var worldActionQuery = EntityQueryEnumerator<WorldTargetActionComponent>();
while (worldActionQuery.MoveNext(out var uid, out var action))
{
if (IsCooldownActive(action) || !ShouldResetCharges(action))
continue;
ResetCharges(uid, dirty: true);
}
var instantActionQuery = EntityQueryEnumerator<InstantActionComponent>();
while (instantActionQuery.MoveNext(out var uid, out var action))
{
if (IsCooldownActive(action) || !ShouldResetCharges(action))
continue;
ResetCharges(uid, dirty: true);
}
var entityActionQuery = EntityQueryEnumerator<EntityTargetActionComponent>();
while (entityActionQuery.MoveNext(out var uid, out var action))
{
if (IsCooldownActive(action) || !ShouldResetCharges(action))
continue;
ResetCharges(uid, dirty: true);
}
}
private void OnActionMapInit(EntityUid uid, BaseActionComponent component, MapInitEvent args)
{
component.OriginalIconColor = component.IconColor;
if (component.Charges == null)
return;
component.MaxCharges ??= component.Charges.Value;
Dirty(uid, component);
}
private void OnActionShutdown(EntityUid uid, BaseActionComponent component, ComponentShutdown args)
@@ -312,68 +274,6 @@ public abstract class SharedActionsSystem : EntitySystem
Dirty(actionId.Value, action);
}
public void SetCharges(EntityUid? actionId, int? charges)
{
if (!TryGetActionData(actionId, out var action) ||
action.Charges == charges)
{
return;
}
action.Charges = charges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public int? GetCharges(EntityUid? actionId)
{
if (!TryGetActionData(actionId, out var action))
return null;
return action.Charges;
}
public void AddCharges(EntityUid? actionId, int addCharges)
{
if (!TryGetActionData(actionId, out var action) || action.Charges == null || addCharges < 1)
return;
action.Charges += addCharges;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void RemoveCharges(EntityUid? actionId, int? removeCharges)
{
if (!TryGetActionData(actionId, out var action) || action.Charges == null)
return;
if (removeCharges == null)
action.Charges = removeCharges;
else
action.Charges -= removeCharges;
if (action.Charges is < 0)
action.Charges = null;
UpdateAction(actionId, action);
Dirty(actionId.Value, action);
}
public void ResetCharges(EntityUid? actionId, bool update = false, bool dirty = false)
{
if (!TryGetActionData(actionId, out var action))
return;
action.Charges = action.MaxCharges;
if (update)
UpdateAction(actionId, action);
if (dirty)
Dirty(actionId.Value, action);
}
private void OnActionsGetState(EntityUid uid, ActionsComponent component, ref ComponentGetState args)
{
args.State = new ActionsComponentState(GetNetEntitySet(component.Actions));
@@ -416,6 +316,10 @@ public abstract class SharedActionsSystem : EntitySystem
if (!action.Enabled)
return;
var curTime = GameTiming.CurTime;
if (IsCooldownActive(action, curTime))
return;
// check for action use prevention
// TODO: make code below use this event with a dedicated component
var attemptEv = new ActionAttemptEvent(user);
@@ -423,14 +327,6 @@ public abstract class SharedActionsSystem : EntitySystem
if (attemptEv.Cancelled)
return;
var curTime = GameTiming.CurTime;
if (IsCooldownActive(action, curTime))
return;
// TODO: Replace with individual charge recovery when we have the visuals to aid it
if (action is { Charges: < 1, RenewCharges: true })
ResetCharges(actionEnt, true, true);
BaseActionEvent? performEvent = null;
if (action.CheckConsciousness && !_actionBlockerSystem.CanConsciouslyPerformAction(user))
@@ -705,16 +601,8 @@ public abstract class SharedActionsSystem : EntitySystem
var dirty = toggledBefore != action.Toggled;
if (action.Charges != null)
{
dirty = true;
action.Charges--;
if (action is { Charges: 0, RenewCharges: false })
action.Enabled = false;
}
action.Cooldown = null;
if (action is { UseDelay: not null, Charges: null or < 1 })
if (action is { UseDelay: not null})
{
dirty = true;
action.Cooldown = (curTime, curTime + action.UseDelay.Value);
@@ -1014,8 +902,6 @@ public abstract class SharedActionsSystem : EntitySystem
if (!action.Enabled)
return false;
if (action.Charges.HasValue && action.Charges <= 0)
return false;
var curTime = GameTiming.CurTime;
if (action.Cooldown.HasValue && action.Cooldown.Value.End > curTime)
@@ -1125,15 +1011,9 @@ public abstract class SharedActionsSystem : EntitySystem
/// <summary>
/// Checks if the action has a cooldown and if it's still active
/// </summary>
protected bool IsCooldownActive(BaseActionComponent action, TimeSpan? curTime = null)
public bool IsCooldownActive(BaseActionComponent action, TimeSpan? curTime = null)
{
curTime ??= GameTiming.CurTime;
// TODO: Check for charge recovery timer
return action.Cooldown.HasValue && action.Cooldown.Value.End > curTime;
}
protected bool ShouldResetCharges(BaseActionComponent action)
{
return action is { Charges: < 1, RenewCharges: true };
}
}

View File

@@ -0,0 +1,19 @@
using Content.Shared.Charges.Systems;
using Robust.Shared.GameStates;
namespace Content.Shared.Charges.Components;
/// <summary>
/// Something with limited charges that can be recharged automatically.
/// Requires LimitedChargesComponent to function.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(SharedChargesSystem))]
public sealed partial class AutoRechargeComponent : Component
{
/// <summary>
/// The time it takes to regain a single charge
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan RechargeDuration = TimeSpan.FromSeconds(90);
}

View File

@@ -1,24 +1,27 @@
using Content.Shared.Charges.Systems;
using Robust.Shared.GameStates;
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
namespace Content.Shared.Charges.Components;
[RegisterComponent, NetworkedComponent]
[Access(typeof(SharedChargesSystem))]
[AutoGenerateComponentState]
/// <summary>
/// Specifies the attached action has discrete charges, separate to a cooldown.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, Access(typeof(SharedChargesSystem))]
public sealed partial class LimitedChargesComponent : Component
{
/// <summary>
/// The maximum number of charges
/// </summary>
[DataField("maxCharges"), ViewVariables(VVAccess.ReadWrite)]
[AutoNetworkedField]
public int MaxCharges = 3;
[DataField, AutoNetworkedField]
public int LastCharges;
/// <summary>
/// The current number of charges
/// The max charges this action has.
/// </summary>
[DataField("charges"), ViewVariables(VVAccess.ReadWrite)]
[AutoNetworkedField]
public int Charges = 3;
[DataField, AutoNetworkedField, Access(Other = AccessPermissions.Read)]
public int MaxCharges = 1;
/// <summary>
/// Last time charges was changed. Used to derive current charges.
/// </summary>
[DataField(customTypeSerializer:typeof(TimeOffsetSerializer)), AutoNetworkedField]
public TimeSpan LastUpdate;
}

View File

@@ -1,103 +1,232 @@
using Content.Shared.Actions.Events;
using Content.Shared.Charges.Components;
using Content.Shared.Examine;
using JetBrains.Annotations;
using Robust.Shared.Timing;
namespace Content.Shared.Charges.Systems;
public abstract class SharedChargesSystem : EntitySystem
{
protected EntityQuery<LimitedChargesComponent> Query;
[Dependency] protected readonly IGameTiming _timing = default!;
/*
* Despite what a bunch of systems do you don't need to continuously tick linear number updates and can just derive it easily.
*/
public override void Initialize()
{
base.Initialize();
Query = GetEntityQuery<LimitedChargesComponent>();
SubscribeLocalEvent<LimitedChargesComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<LimitedChargesComponent, ActionAttemptEvent>(OnChargesAttempt);
SubscribeLocalEvent<LimitedChargesComponent, MapInitEvent>(OnChargesMapInit);
SubscribeLocalEvent<LimitedChargesComponent, ActionPerformedEvent>(OnChargesPerformed);
}
protected virtual void OnExamine(EntityUid uid, LimitedChargesComponent comp, ExaminedEvent args)
private void OnExamine(EntityUid uid, LimitedChargesComponent comp, ExaminedEvent args)
{
if (!args.IsInDetailsRange)
return;
using (args.PushGroup(nameof(LimitedChargesComponent)))
var rechargeEnt = new Entity<LimitedChargesComponent?, AutoRechargeComponent?>(uid, comp, null);
var charges = GetCurrentCharges(rechargeEnt);
using var _ = args.PushGroup(nameof(LimitedChargesComponent));
args.PushMarkup(Loc.GetString("limited-charges-charges-remaining", ("charges", charges)));
if (charges == comp.MaxCharges)
{
args.PushMarkup(Loc.GetString("limited-charges-charges-remaining", ("charges", comp.Charges)));
if (comp.Charges == comp.MaxCharges)
{
args.PushMarkup(Loc.GetString("limited-charges-max-charges"));
}
args.PushMarkup(Loc.GetString("limited-charges-max-charges"));
}
// only show the recharging info if it's not full
if (charges == comp.MaxCharges || !TryComp<AutoRechargeComponent>(uid, out var recharge))
return;
rechargeEnt.Comp2 = recharge;
var timeRemaining = GetNextRechargeTime(rechargeEnt);
args.PushMarkup(Loc.GetString("limited-charges-recharging", ("seconds", timeRemaining.TotalSeconds.ToString("F1"))));
}
private void OnChargesAttempt(Entity<LimitedChargesComponent> ent, ref ActionAttemptEvent args)
{
if (args.Cancelled)
return;
var charges = GetCurrentCharges((ent.Owner, ent.Comp, null));
if (charges <= 0)
{
args.Cancelled = true;
}
}
/// <summary>
/// Tries to add a number of charges. If it over or underflows it will be clamped, wasting the extra charges.
/// </summary>
public virtual void AddCharges(EntityUid uid, int change, LimitedChargesComponent? comp = null)
private void OnChargesPerformed(Entity<LimitedChargesComponent> ent, ref ActionPerformedEvent args)
{
if (!Query.Resolve(uid, ref comp, false))
AddCharges((ent.Owner, ent.Comp), -1);
}
private void OnChargesMapInit(Entity<LimitedChargesComponent> ent, ref MapInitEvent args)
{
// If nothing specified use max.
if (ent.Comp.LastCharges == 0)
{
ent.Comp.LastCharges = ent.Comp.MaxCharges;
}
// If -1 used then we don't want any.
else if (ent.Comp.LastCharges < 0)
{
ent.Comp.LastCharges = 0;
}
ent.Comp.LastUpdate = _timing.CurTime;
Dirty(ent);
}
[Pure]
public bool HasCharges(Entity<LimitedChargesComponent?> action, int charges)
{
var current = GetCurrentCharges(action);
return current >= charges;
}
/// <summary>
/// Adds the specified charges. Does not reset the accumulator.
/// </summary>
public void AddCharges(Entity<LimitedChargesComponent?> action, int addCharges)
{
if (addCharges == 0)
return;
var old = comp.Charges;
comp.Charges = Math.Clamp(comp.Charges + change, 0, comp.MaxCharges);
if (comp.Charges != old)
Dirty(uid, comp);
action.Comp ??= EnsureComp<LimitedChargesComponent>(action.Owner);
// 1. If we're going FROM max then set lastupdate to now (so it doesn't instantly recharge).
// 2. If we're going TO max then also set lastupdate to now.
// 3. Otherwise don't modify it.
// No idea if we go to 0 but future problem.
var lastCharges = GetCurrentCharges(action);
var charges = lastCharges + addCharges;
if (lastCharges == charges)
return;
if (charges == action.Comp.MaxCharges || lastCharges == action.Comp.MaxCharges)
{
action.Comp.LastUpdate = _timing.CurTime;
}
action.Comp.LastCharges = Math.Clamp(action.Comp.LastCharges + addCharges, 0, action.Comp.MaxCharges);
Dirty(action);
}
/// <summary>
/// Gets the limited charges component and returns true if there are no charges. Will return false if there is no limited charges component.
/// </summary>
public bool IsEmpty(EntityUid uid, LimitedChargesComponent? comp = null)
public bool TryUseCharge(Entity<LimitedChargesComponent?> entity)
{
// can't be empty if there are no limited charges
if (!Query.Resolve(uid, ref comp, false))
return TryUseCharges(entity, 1);
}
public bool TryUseCharges(Entity<LimitedChargesComponent?> entity, int amount)
{
var current = GetCurrentCharges(entity);
if (current < amount)
{
return false;
}
return comp.Charges <= 0;
}
/// <summary>
/// Uses a single charge. Must check IsEmpty beforehand to prevent using with 0 charge.
/// </summary>
public void UseCharge(EntityUid uid, LimitedChargesComponent? comp = null)
{
AddCharges(uid, -1, comp);
}
/// <summary>
/// Checks IsEmpty and uses a charge if it isn't empty.
/// </summary>
public bool TryUseCharge(Entity<LimitedChargesComponent?> ent)
{
if (!Query.Resolve(ent, ref ent.Comp, false))
return true;
if (IsEmpty(ent, ent.Comp))
return false;
UseCharge(ent, ent.Comp);
AddCharges(entity, -amount);
return true;
}
/// <summary>
/// Gets the limited charges component and returns true if the number of charges remaining is less than the specified value.
/// Will return false if there is no limited charges component.
/// </summary>
public bool HasInsufficientCharges(EntityUid uid, int requiredCharges, LimitedChargesComponent? comp = null)
[Pure]
public bool IsEmpty(Entity<LimitedChargesComponent?> entity)
{
// can't be empty if there are no limited charges
if (!Resolve(uid, ref comp, false))
return false;
return comp.Charges < requiredCharges;
return GetCurrentCharges(entity) == 0;
}
/// <summary>
/// Uses up a specified number of charges. Must check HasInsufficentCharges beforehand to prevent using with insufficient remaining charges.
/// Resets action charges to MaxCharges.
/// </summary>
public virtual void UseCharges(EntityUid uid, int chargesUsed, LimitedChargesComponent? comp = null)
public void ResetCharges(Entity<LimitedChargesComponent?> action)
{
AddCharges(uid, -chargesUsed, comp);
if (!Resolve(action.Owner, ref action.Comp, false))
return;
var charges = GetCurrentCharges((action.Owner, action.Comp, null));
if (charges == action.Comp.MaxCharges)
return;
action.Comp.LastCharges = action.Comp.MaxCharges;
action.Comp.LastUpdate = _timing.CurTime;
Dirty(action);
}
public void SetCharges(Entity<LimitedChargesComponent?> action, int value)
{
action.Comp ??= EnsureComp<LimitedChargesComponent>(action.Owner);
var adjusted = Math.Clamp(value, 0, action.Comp.MaxCharges);
if (action.Comp.LastCharges == adjusted)
{
return;
}
action.Comp.LastCharges = adjusted;
action.Comp.LastUpdate = _timing.CurTime;
Dirty(action);
}
/// <summary>
/// The next time a charge will be considered to be filled.
/// </summary>
/// <returns>0 timespan if invalid or no charges to generate.</returns>
[Pure]
public TimeSpan GetNextRechargeTime(Entity<LimitedChargesComponent?, AutoRechargeComponent?> entity)
{
if (!Resolve(entity.Owner, ref entity.Comp1, ref entity.Comp2, false))
{
return TimeSpan.Zero;
}
// Okay so essentially we need to get recharge time to full, then modulus that by the recharge timer which should be the next tick.
var fullTime = ((entity.Comp1.MaxCharges - entity.Comp1.LastCharges) * entity.Comp2.RechargeDuration) + entity.Comp1.LastUpdate;
var timeRemaining = fullTime - _timing.CurTime;
if (timeRemaining < TimeSpan.Zero)
{
return TimeSpan.Zero;
}
var nextChargeTime = timeRemaining.TotalSeconds % entity.Comp2.RechargeDuration.TotalSeconds;
return TimeSpan.FromSeconds(nextChargeTime);
}
/// <summary>
/// Derives the current charges of an entity.
/// </summary>
[Pure]
public int GetCurrentCharges(Entity<LimitedChargesComponent?, AutoRechargeComponent?> entity)
{
if (!Resolve(entity.Owner, ref entity.Comp1, false))
{
// I'm all in favor of nullable ints however null-checking return args against comp nullability is dodgy
// so we get this.
return -1;
}
var calculated = 0;
if (Resolve(entity.Owner, ref entity.Comp2, false) && entity.Comp2.RechargeDuration.TotalSeconds != 0.0)
{
calculated = (int)((_timing.CurTime - entity.Comp1.LastUpdate).TotalSeconds / entity.Comp2.RechargeDuration.TotalSeconds);
}
return Math.Clamp(entity.Comp1.LastCharges + calculated,
0,
entity.Comp1.MaxCharges);
}
}

View File

@@ -21,7 +21,7 @@ namespace Content.Shared.Emag.Systems;
public sealed class EmagSystem : EntitySystem
{
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly TagSystem _tag = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
@@ -61,8 +61,8 @@ public sealed class EmagSystem : EntitySystem
if (_tag.HasTag(target, ent.Comp.EmagImmuneTag))
return false;
TryComp<LimitedChargesComponent>(ent, out var charges);
if (_charges.IsEmpty(ent, charges))
Entity<LimitedChargesComponent?> chargesEnt = ent.Owner;
if (_sharedCharges.IsEmpty(chargesEnt))
{
_popup.PopupClient(Loc.GetString("emag-no-charges"), user, user);
return false;
@@ -80,8 +80,8 @@ public sealed class EmagSystem : EntitySystem
_adminLogger.Add(LogType.Emag, LogImpact.High, $"{ToPrettyString(user):player} emagged {ToPrettyString(target):target} with flag(s): {ent.Comp.EmagType}");
if (charges != null && emaggedEvent.Handled)
_charges.UseCharge(ent, charges);
if (emaggedEvent.Handled)
_sharedCharges.TryUseCharge(chargesEnt);
if (!emaggedEvent.Repeatable)
{

View File

@@ -1,4 +1,5 @@
using Content.Shared.Actions;
using Content.Shared.Charges.Systems;
using Content.Shared.DoAfter;
using Content.Shared.Interaction.Events;
using Content.Shared.Magic.Components;
@@ -9,6 +10,7 @@ namespace Content.Shared.Magic;
public sealed class SpellbookSystem : EntitySystem
{
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedActionsSystem _actions = default!;
@@ -30,11 +32,7 @@ public sealed class SpellbookSystem : EntitySystem
if (spell == null)
continue;
int? charge = charges;
if (_actions.GetCharges(spell) != null)
charge = _actions.GetCharges(spell);
_actions.SetCharges(spell, charge < 0 ? null : charge);
_sharedCharges.SetCharges(spell.Value, charges);
ent.Comp.Spells.Add(spell.Value);
}
}
@@ -75,7 +73,7 @@ public sealed class SpellbookSystem : EntitySystem
{
EntityUid? actionId = null;
if (_actions.AddAction(args.Args.User, ref actionId, id))
_actions.SetCharges(actionId, charges < 0 ? null : charges);
_sharedCharges.SetCharges(actionId.Value, charges);
}
}

View File

@@ -22,7 +22,7 @@ public sealed class DashAbilitySystem : EntitySystem
{
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedHandsSystem _hands = default!;
[Dependency] private readonly ExamineSystemShared _examine = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
@@ -79,7 +79,7 @@ public sealed class DashAbilitySystem : EntitySystem
return;
}
if (!_charges.TryUseCharge(uid))
if (!_sharedCharges.TryUseCharge(uid))
{
_popup.PopupClient(Loc.GetString("dash-ability-no-charges", ("item", uid)), user, user);
return;

View File

@@ -28,6 +28,7 @@ public sealed class EnergyKatanaSystem : EntitySystem
private void OnCheckDash(Entity<EnergyKatanaComponent> ent, ref CheckDashEvent args)
{
// Just use a whitelist fam
if (!_ninja.IsNinja(args.User))
args.Cancelled = true;
}

View File

@@ -11,6 +11,6 @@ public sealed partial class RCDAmmoComponent : Component
/// How many charges are contained in this ammo cartridge.
/// Can be partially transferred into an RCD, until it is empty then it gets deleted.
/// </summary>
[DataField("charges"), ViewVariables(VVAccess.ReadWrite), AutoNetworkedField]
[DataField, AutoNetworkedField]
public int Charges = 30;
}

View File

@@ -10,7 +10,7 @@ namespace Content.Shared.RCD.Systems;
public sealed class RCDAmmoSystem : EntitySystem
{
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly IGameTiming _timing = default!;
@@ -41,9 +41,10 @@ public sealed class RCDAmmoSystem : EntitySystem
!TryComp<LimitedChargesComponent>(target, out var charges))
return;
var current = _sharedCharges.GetCurrentCharges((target, charges));
var user = args.User;
args.Handled = true;
var count = Math.Min(charges.MaxCharges - charges.Charges, comp.Charges);
var count = Math.Min(charges.MaxCharges - current, comp.Charges);
if (count <= 0)
{
_popup.PopupClient(Loc.GetString("rcd-ammo-component-after-interact-full"), target, user);
@@ -51,7 +52,7 @@ public sealed class RCDAmmoSystem : EntitySystem
}
_popup.PopupClient(Loc.GetString("rcd-ammo-component-after-interact-refilled"), target, user);
_charges.AddCharges(target, count, charges);
_sharedCharges.AddCharges(target, count);
comp.Charges -= count;
Dirty(uid, comp);

View File

@@ -33,7 +33,7 @@ public sealed class RCDSystem : EntitySystem
[Dependency] private readonly ITileDefinitionManager _tileDefMan = default!;
[Dependency] private readonly FloorTileSystem _floors = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedChargesSystem _charges = default!;
[Dependency] private readonly SharedChargesSystem _sharedCharges = default!;
[Dependency] private readonly SharedDoAfterSystem _doAfter = default!;
[Dependency] private readonly SharedInteractionSystem _interaction = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
@@ -43,7 +43,6 @@ public sealed class RCDSystem : EntitySystem
[Dependency] private readonly SharedMapSystem _mapSystem = default!;
[Dependency] private readonly SharedTransformSystem _transform = default!;
[Dependency] private readonly TagSystem _tags = default!;
[Dependency] private readonly SharedTransformSystem _transformSystem = default!;
private readonly int _instantConstructionDelay = 0;
private readonly EntProtoId _instantConstructionFx = "EffectRCDConstruct0";
@@ -133,7 +132,7 @@ public sealed class RCDSystem : EntitySystem
if (!location.IsValid(EntityManager))
return;
var gridUid = _transformSystem.GetGrid(location);
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
{
@@ -239,7 +238,7 @@ public sealed class RCDSystem : EntitySystem
// Ensure the RCD operation is still valid
var location = GetCoordinates(args.Event.Location);
var gridUid = _transformSystem.GetGrid(location);
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
{
@@ -272,7 +271,7 @@ public sealed class RCDSystem : EntitySystem
var location = GetCoordinates(args.Location);
var gridUid = _transformSystem.GetGrid(location);
var gridUid = _transform.GetGrid(location);
if (!TryComp<MapGridComponent>(gridUid, out var mapGrid))
return;
@@ -289,7 +288,7 @@ public sealed class RCDSystem : EntitySystem
// Play audio and consume charges
_audio.PlayPredicted(component.SuccessSound, uid, args.User);
_charges.UseCharges(uid, args.Cost);
_sharedCharges.AddCharges(uid, -args.Cost);
}
private void OnRCDconstructionGhostRotationEvent(RCDConstructionGhostRotationEvent ev, EntitySessionEventArgs session)
@@ -321,10 +320,10 @@ public sealed class RCDSystem : EntitySystem
var prototype = _protoManager.Index(component.ProtoId);
// Check that the RCD has enough ammo to get the job done
TryComp<LimitedChargesComponent>(uid, out var charges);
var charges = _sharedCharges.GetCurrentCharges(uid);
// Both of these were messages were suppose to be predicted, but HasInsufficientCharges wasn't being checked on the client for some reason?
if (_charges.IsEmpty(uid, charges))
if (charges == 0)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-no-ammo-message"), uid, user);
@@ -332,7 +331,7 @@ public sealed class RCDSystem : EntitySystem
return false;
}
if (_charges.HasInsufficientCharges(uid, prototype.Cost, charges))
if (prototype.Cost > charges)
{
if (popMsgs)
_popup.PopupClient(Loc.GetString("rcd-component-insufficient-ammo-message"), uid, user);

View File

@@ -93,8 +93,9 @@
name: Break Free
description: Activating your freedom implant will free you from any hand restraints
components:
- type: LimitedCharges
maxCharges: 3
- type: InstantAction
charges: 3
checkCanInteract: false
itemIconStyle: BigAction
priority: -20
@@ -121,9 +122,10 @@
name: Activate EMP
description: Triggers a small EMP pulse around you
components:
- type: LimitedCharges
maxCharges: 3
- type: InstantAction
checkCanInteract: false
charges: 3
useDelay: 5
itemIconStyle: BigAction
priority: -20
@@ -137,9 +139,10 @@
name: SCRAM!
description: Randomly teleports you within a large distance.
components:
- type: LimitedCharges
maxCharges: 2
- type: InstantAction
checkCanInteract: false
charges: 2
useDelay: 5
itemIconStyle: BigAction
priority: -20
@@ -155,8 +158,9 @@
components:
- type: ConfirmableAction
popup: dna-scrambler-action-popup
- type: LimitedCharges
maxCharges: 1
- type: InstantAction
charges: 1
itemIconStyle: BigAction
priority: -20
icon:

View File

@@ -159,7 +159,6 @@
- type: Flash
- type: LimitedCharges
maxCharges: 3
charges: 3
- type: AutoRecharge
rechargeDuration: 30
- type: MeleeWeapon

View File

@@ -88,7 +88,6 @@
- type: Flash
- type: LimitedCharges
maxCharges: 15
charges: 15
- type: MeleeWeapon
damage:
types:

View File

@@ -320,7 +320,6 @@
- Deconstruct
- type: LimitedCharges
maxCharges: 30
charges: 30
- type: Sprite
sprite: Objects/Tools/rcd.rsi
state: icon
@@ -351,7 +350,7 @@
suffix: Empty
components:
- type: LimitedCharges
charges: 0
lastCharges: -1
- type: entity
id: RCDRecharging
@@ -362,7 +361,6 @@
components:
- type: LimitedCharges
maxCharges: 20
charges: 20
- type: AutoRecharge
rechargeDuration: 10

View File

@@ -89,7 +89,6 @@
- type: DashAbility
- type: LimitedCharges
maxCharges: 3
charges: 3
- type: AutoRecharge
rechargeDuration: 20
- type: Clothing

View File

@@ -147,7 +147,6 @@
- type: Flash
- type: LimitedCharges
maxCharges: 5
charges: 5
- type: MeleeWeapon
wideAnimationRotation: 180
damage:
@@ -183,7 +182,6 @@
components:
- type: LimitedCharges
maxCharges: 2
charges: 2
- type: entity
name: portable flasher

View File

@@ -3,9 +3,10 @@
name: Animate
description: Bring an inanimate object to life!
components:
- type: LimitedCharges
maxCharges: 5
- type: EntityTargetAction
useDelay: 0
charges: 5
itemIconStyle: BigAction
whitelist:
components:

View File

@@ -30,8 +30,6 @@
description: Fires a fireball, but faster!
components:
- type: WorldTargetAction
useDelay: 10
renewCharges: true
itemIconStyle: BigAction
checkCanAccess: false
raiseOnUser: true
@@ -52,8 +50,6 @@
description: The fastest fireball in the west!
components:
- type: WorldTargetAction
useDelay: 8
renewCharges: true
itemIconStyle: BigAction
checkCanAccess: false
raiseOnUser: true

View File

@@ -59,9 +59,10 @@
- type: entity
id: ActionRgbLight
components:
- type: LimitedCharges
maxCharges: 25
- type: EntityTargetAction
whitelist: { components: [ PointLight ] }
charges: 25
sound: /Audio/Magic/blink.ogg
event: !type:ChangeComponentsSpellEvent
toAdd: