diff --git a/Content.Client/Actions/ActionsSystem.cs b/Content.Client/Actions/ActionsSystem.cs index b67c1bd5b9..508f3404ba 100644 --- a/Content.Client/Actions/ActionsSystem.cs +++ b/Content.Client/Actions/ActionsSystem.cs @@ -87,6 +87,8 @@ namespace Content.Client.Actions component.Cooldown = state.Cooldown; component.UseDelay = state.UseDelay; component.Charges = state.Charges; + component.MaxCharges = state.MaxCharges; + component.RenewCharges = state.RenewCharges; component.Container = EnsureEntity(state.Container, uid); component.EntityIcon = EnsureEntity(state.EntityIcon, uid); component.CheckCanInteract = state.CheckCanInteract; diff --git a/Content.Client/Actions/UI/ActionAlertTooltip.cs b/Content.Client/Actions/UI/ActionAlertTooltip.cs index f48350d772..ddc498b6e9 100644 --- a/Content.Client/Actions/UI/ActionAlertTooltip.cs +++ b/Content.Client/Actions/UI/ActionAlertTooltip.cs @@ -21,7 +21,7 @@ namespace Content.Client.Actions.UI /// public (TimeSpan Start, TimeSpan End)? Cooldown { get; set; } - public ActionAlertTooltip(FormattedMessage name, FormattedMessage? desc, string? requires = null) + public ActionAlertTooltip(FormattedMessage name, FormattedMessage? desc, string? requires = null, FormattedMessage? charges = null) { _gameTiming = IoCManager.Resolve(); @@ -52,6 +52,17 @@ namespace Content.Client.Actions.UI vbox.AddChild(description); } + if (charges != null && !string.IsNullOrWhiteSpace(charges.ToString())) + { + var chargesLabel = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = { StyleNano.StyleClassTooltipActionCharges } + }; + chargesLabel.SetMessage(charges); + vbox.AddChild(chargesLabel); + } + vbox.AddChild(_cooldownLabel = new RichTextLabel { MaxWidth = TooltipTextMaxWidth, diff --git a/Content.Client/Stylesheets/StyleNano.cs b/Content.Client/Stylesheets/StyleNano.cs index 2ef5a63ac7..9f656297c8 100644 --- a/Content.Client/Stylesheets/StyleNano.cs +++ b/Content.Client/Stylesheets/StyleNano.cs @@ -56,6 +56,7 @@ namespace Content.Client.Stylesheets public const string StyleClassTooltipActionDescription = "tooltipActionDesc"; public const string StyleClassTooltipActionCooldown = "tooltipActionCooldown"; public const string StyleClassTooltipActionRequirements = "tooltipActionCooldown"; + public const string StyleClassTooltipActionCharges = "tooltipActionCharges"; public const string StyleClassHotbarSlotNumber = "hotbarSlotNumber"; public const string StyleClassActionSearchBox = "actionSearchBox"; public const string StyleClassActionMenuItemRevoked = "actionMenuItemRevoked"; @@ -940,6 +941,10 @@ namespace Content.Client.Stylesheets { new StyleProperty("font", notoSans15) }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipActionCharges}, null, null), new[] + { + new StyleProperty("font", notoSans15) + }), // small number for the entity counter in the entity menu new StyleRule(new SelectorElement(typeof(Label), new[] {ContextMenuElement.StyleClassEntityMenuIconLabel}, null, null), new[] diff --git a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs index 635647c890..aae06965ef 100644 --- a/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs +++ b/Content.Client/UserInterface/Systems/Actions/ActionUIController.cs @@ -171,7 +171,7 @@ public sealed class ActionUIController : UIController, IOnStateChanged _timing.CurTime) { // The user is targeting with this action, but it is not valid. Maybe mark this click as diff --git a/Content.Client/UserInterface/Systems/Actions/Controls/ActionButton.cs b/Content.Client/UserInterface/Systems/Actions/Controls/ActionButton.cs index aab160a162..3cd172b080 100644 --- a/Content.Client/UserInterface/Systems/Actions/Controls/ActionButton.cs +++ b/Content.Client/UserInterface/Systems/Actions/Controls/ActionButton.cs @@ -191,6 +191,12 @@ public sealed class ActionButton : Control, IEntityControl var name = FormattedMessage.FromMarkupPermissive(Loc.GetString(metadata.EntityName)); var decr = FormattedMessage.FromMarkupPermissive(Loc.GetString(metadata.EntityDescription)); + if (_action is { Charges: not null }) + { + var charges = FormattedMessage.FromMarkupPermissive(Loc.GetString($"Charges: {_action.Charges.Value.ToString()}/{_action.MaxCharges.ToString()}")); + return new ActionAlertTooltip(name, decr, charges: charges); + } + return new ActionAlertTooltip(name, decr); } diff --git a/Content.Client/UserInterface/Systems/Actions/Controls/ActionTooltip.xaml b/Content.Client/UserInterface/Systems/Actions/Controls/ActionTooltip.xaml index 338a297453..22893ccef4 100644 --- a/Content.Client/UserInterface/Systems/Actions/Controls/ActionTooltip.xaml +++ b/Content.Client/UserInterface/Systems/Actions/Controls/ActionTooltip.xaml @@ -5,5 +5,6 @@ + diff --git a/Content.Server/Commands/ActionCommands.cs b/Content.Server/Commands/ActionCommands.cs new file mode 100644 index 0000000000..280bf75a61 --- /dev/null +++ b/Content.Server/Commands/ActionCommands.cs @@ -0,0 +1,81 @@ +using Content.Server.Administration; +using Content.Shared.Actions; +using Content.Shared.Administration; +using Robust.Shared.Console; + +namespace Content.Server.Commands; + +[AdminCommand(AdminFlags.Fun)] +internal sealed class UpgradeActionCommand : IConsoleCommand +{ + [Dependency] private readonly IEntityManager _entMan = default!; + + public string Command => "upgradeaction"; + public string Description => Loc.GetString("upgradeaction-command-description"); + public string Help => Loc.GetString("upgradeaction-command-help"); + + public void Execute(IConsoleShell shell, string argStr, string[] args) + { + if (args.Length < 1) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-need-one-argument")); + return; + } + + if (args.Length > 2) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-max-two-arguments")); + return; + } + + var actionUpgrade = _entMan.EntitySysManager.GetEntitySystem(); + var id = args[0]; + + if (!EntityUid.TryParse(id, out var uid)) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-incorrect-entityuid-format")); + return; + } + + if (!_entMan.EntityExists(uid)) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-entity-does-not-exist")); + return; + } + + if (!_entMan.TryGetComponent(uid, out var actionUpgradeComponent)) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-entity-is-not-action")); + return; + } + + if (args.Length == 1) + { + if (!actionUpgrade.TryUpgradeAction(uid, actionUpgradeComponent)) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-cannot-level-up")); + return; + } + } + + if (args.Length == 2) + { + var levelArg = args[1]; + + if (!int.TryParse(levelArg, out var level)) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-second-argument-not-number")); + return; + } + + if (level <= 0) + { + shell.WriteLine(Loc.GetString("upgradeaction-command-less-than-required-level")); + return; + } + + if (!actionUpgrade.TryUpgradeAction(uid, actionUpgradeComponent, level)) + shell.WriteLine(Loc.GetString("upgradeaction-command-cannot-level-up")); + } + } +} diff --git a/Content.Shared/Actions/ActionUpgradeComponent.cs b/Content.Shared/Actions/ActionUpgradeComponent.cs new file mode 100644 index 0000000000..0d6a813526 --- /dev/null +++ b/Content.Shared/Actions/ActionUpgradeComponent.cs @@ -0,0 +1,24 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Actions; + +// For actions that can use basic upgrades +// Not all actions should be upgradable +[RegisterComponent, NetworkedComponent, Access(typeof(ActionUpgradeSystem))] +public sealed partial class ActionUpgradeComponent : Component +{ + /// + /// Current Level of the action. + /// + public int Level = 1; + + /// + /// What level(s) effect this action? + /// You can skip levels, so you can have this entity change at level 2 but then won't change again until level 5. + /// + [DataField("effectedLevels"), ViewVariables] + public Dictionary EffectedLevels = new(); + + // TODO: Branching level upgrades +} diff --git a/Content.Shared/Actions/ActionUpgradeSystem.cs b/Content.Shared/Actions/ActionUpgradeSystem.cs new file mode 100644 index 0000000000..f489779493 --- /dev/null +++ b/Content.Shared/Actions/ActionUpgradeSystem.cs @@ -0,0 +1,149 @@ +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Content.Shared.Actions.Events; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; + +namespace Content.Shared.Actions; + +public sealed class ActionUpgradeSystem : EntitySystem +{ + [Dependency] private readonly SharedActionsSystem _actions = default!; + [Dependency] private readonly ActionContainerSystem _actionContainer = default!; + [Dependency] private readonly EntityManager _entityManager = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnActionUpgradeEvent); + } + + private void OnActionUpgradeEvent(EntityUid uid, ActionUpgradeComponent component, ActionUpgradeEvent args) + { + if (!CanLevelUp(args.NewLevel, component.EffectedLevels, out var newActionProto) + || !_actions.TryGetActionData(uid, out var actionComp)) + return; + + var originalContainer = actionComp.Container; + var originalAttachedEntity = actionComp.AttachedEntity; + + _actionContainer.RemoveAction(uid, actionComp); + + EntityUid? upgradedActionId = null; + if (originalContainer != null + && TryComp(originalContainer.Value, out var actionContainerComp)) + { + upgradedActionId = _actionContainer.AddAction(originalContainer.Value, newActionProto, actionContainerComp); + + if (originalAttachedEntity != null) + _actions.GrantContainedActions(originalAttachedEntity.Value, originalContainer.Value); + else + _actions.GrantContainedActions(originalContainer.Value, originalContainer.Value); + } + else if (originalAttachedEntity != null) + { + upgradedActionId = _actionContainer.AddAction(originalAttachedEntity.Value, newActionProto); + } + + if (!TryComp(upgradedActionId, out var upgradeComp)) + return; + + upgradeComp.Level = args.NewLevel; + + // TODO: Preserve ordering of actions + + _entityManager.DeleteEntity(uid); + } + + public bool TryUpgradeAction(EntityUid? actionId, ActionUpgradeComponent? actionUpgradeComponent = null, int newLevel = 0) + { + if (!TryGetActionUpgrade(actionId, out var actionUpgradeComp)) + return false; + + actionUpgradeComponent ??= actionUpgradeComp; + DebugTools.AssertNotNull(actionUpgradeComponent); + DebugTools.AssertNotNull(actionId); + + if (newLevel < 1) + newLevel = actionUpgradeComponent.Level + 1; + + if (!CanLevelUp(newLevel, actionUpgradeComponent.EffectedLevels, out _)) + return false; + + UpgradeAction(actionId, actionUpgradeComp); + return true; + } + + // TODO: Add checks for branching upgrades + private bool CanLevelUp( + int newLevel, + Dictionary levelDict, + [NotNullWhen(true)]out EntProtoId? newLevelProto) + { + newLevelProto = null; + + if (levelDict.Count < 1) + return false; + + var canLevel = false; + var finalLevel = levelDict.Keys.ToList()[levelDict.Keys.Count - 1]; + + foreach (var (level, proto) in levelDict) + { + if (newLevel != level || newLevel > finalLevel) + continue; + + canLevel = true; + newLevelProto = proto; + DebugTools.AssertNotNull(newLevelProto); + break; + } + + return canLevel; + } + + /// + /// Raises a level by one + /// + public void UpgradeAction(EntityUid? actionId, ActionUpgradeComponent? actionUpgradeComponent = null, int newLevel = 0) + { + if (!TryGetActionUpgrade(actionId, out var actionUpgradeComp)) + return; + + actionUpgradeComponent ??= actionUpgradeComp; + DebugTools.AssertNotNull(actionUpgradeComponent); + DebugTools.AssertNotNull(actionId); + + if (newLevel < 1) + newLevel = actionUpgradeComponent.Level + 1; + + RaiseActionUpgradeEvent(newLevel, actionId.Value); + } + + private void RaiseActionUpgradeEvent(int level, EntityUid actionId) + { + var ev = new ActionUpgradeEvent(level, actionId); + RaiseLocalEvent(actionId, ev); + } + + public bool TryGetActionUpgrade( + [NotNullWhen(true)] EntityUid? uid, + [NotNullWhen(true)] out ActionUpgradeComponent? result, + bool logError = true) + { + result = null; + if (!Exists(uid)) + return false; + + if (!TryComp(uid, out var actionUpgradeComponent)) + { + Log.Error($"Failed to get action upgrade from action entity: {ToPrettyString(uid.Value)}"); + return false; + } + + result = actionUpgradeComponent; + DebugTools.AssertOwner(uid, result); + return true; + } +} diff --git a/Content.Shared/Actions/BaseActionComponent.cs b/Content.Shared/Actions/BaseActionComponent.cs index 0eccdddc4c..291d9a3ea2 100644 --- a/Content.Shared/Actions/BaseActionComponent.cs +++ b/Content.Shared/Actions/BaseActionComponent.cs @@ -66,9 +66,21 @@ public abstract partial class BaseActionComponent : Component /// /// 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 is enabled and the action will not disable + /// when charges reach zero. /// [DataField("charges")] public int? Charges; + /// + /// The max charges this action has, set automatically from + /// + public int MaxCharges; + + /// + /// If enabled, charges will regenerate after a is complete + /// + [DataField("renewCharges")]public bool RenewCharges; + /// /// The entity that contains this action. If the action is innate, this may be the user themselves. /// This should almost always be non-null. @@ -159,6 +171,8 @@ public abstract class BaseActionComponentState : ComponentState 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; @@ -186,6 +200,8 @@ public abstract class BaseActionComponentState : ComponentState Cooldown = component.Cooldown; UseDelay = component.UseDelay; Charges = component.Charges; + MaxCharges = component.MaxCharges; + RenewCharges = component.RenewCharges; CheckCanInteract = component.CheckCanInteract; ClientExclusive = component.ClientExclusive; Priority = component.Priority; diff --git a/Content.Shared/Actions/Events/ActionUpgradeEvent.cs b/Content.Shared/Actions/Events/ActionUpgradeEvent.cs new file mode 100644 index 0000000000..40ff716b29 --- /dev/null +++ b/Content.Shared/Actions/Events/ActionUpgradeEvent.cs @@ -0,0 +1,13 @@ +namespace Content.Shared.Actions.Events; + +public sealed class ActionUpgradeEvent : EntityEventArgs +{ + public int NewLevel; + public EntityUid? ActionId; + + public ActionUpgradeEvent(int newLevel, EntityUid? actionId) + { + NewLevel = newLevel; + ActionId = actionId; + } +} diff --git a/Content.Shared/Actions/SharedActionsSystem.cs b/Content.Shared/Actions/SharedActionsSystem.cs index 675727167e..4323a46114 100644 --- a/Content.Shared/Actions/SharedActionsSystem.cs +++ b/Content.Shared/Actions/SharedActionsSystem.cs @@ -30,11 +30,16 @@ public abstract class SharedActionsSystem : EntitySystem [Dependency] private readonly SharedAudioSystem _audio = default!; [Dependency] private readonly SharedTransformSystem _transformSystem = default!; [Dependency] private readonly ActionContainerSystem _actionContainer = default!; + [Dependency] private readonly MetaDataSystem _metaData = default!; public override void Initialize() { base.Initialize(); + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(OnDidEquip); SubscribeLocalEvent(OnHandEquipped); SubscribeLocalEvent(OnDidUnequip); @@ -56,6 +61,12 @@ public abstract class SharedActionsSystem : EntitySystem SubscribeAllEvent(OnActionRequest); } + private void OnInit(EntityUid uid, BaseActionComponent component, MapInitEvent args) + { + if (component.Charges != null) + component.MaxCharges = component.Charges.Value; + } + private void OnShutdown(EntityUid uid, ActionsComponent component, ComponentShutdown args) { foreach (var act in component.Actions) @@ -165,6 +176,31 @@ public abstract class SharedActionsSystem : EntitySystem Dirty(actionId.Value, action); } + public void SetUseDelay(EntityUid? actionId, TimeSpan? delay) + { + if (!TryGetActionData(actionId, out var action) || action.UseDelay == delay) + return; + + action.UseDelay = delay; + UpdateAction(actionId, action); + Dirty(actionId.Value, action); + } + + public void ReduceUseDelay(EntityUid? actionId, TimeSpan? lowerDelay) + { + if (!TryGetActionData(actionId, out var action)) + return; + + if (action.UseDelay != null && lowerDelay != null) + action.UseDelay = action.UseDelay - lowerDelay; + + if (action.UseDelay < TimeSpan.Zero) + action.UseDelay = null; + + UpdateAction(actionId, action); + Dirty(actionId.Value, action); + } + private void OnRejuventate(EntityUid uid, ActionsComponent component, RejuvenateEvent args) { foreach (var act in component.Actions) @@ -218,6 +254,51 @@ public abstract class SharedActionsSystem : EntitySystem 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) + { + if (!TryGetActionData(actionId, out var action)) + return; + + action.Charges = action.MaxCharges; + UpdateAction(actionId, action); + Dirty(actionId.Value, action); + } + private void OnActionsGetState(EntityUid uid, ActionsComponent component, ref ComponentGetState args) { args.State = new ActionsComponentState(GetNetEntitySet(component.Actions)); @@ -261,9 +342,14 @@ public abstract class SharedActionsSystem : EntitySystem return; var curTime = GameTiming.CurTime; + // TODO: Check for charge recovery timer if (action.Cooldown.HasValue && action.Cooldown.Value.End > 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); + BaseActionEvent? performEvent = null; // Validate request by checking action blockers and the like: @@ -438,12 +524,12 @@ public abstract class SharedActionsSystem : EntitySystem { dirty = true; action.Charges--; - if (action.Charges == 0) + if (action is { Charges: 0, RenewCharges: false }) action.Enabled = false; } action.Cooldown = null; - if (action.UseDelay != null) + if (action is { UseDelay: not null, Charges: null or < 1 }) { dirty = true; action.Cooldown = (curTime, curTime + action.UseDelay.Value); diff --git a/Resources/Locale/en-US/actions/actions/actions-commands.ftl b/Resources/Locale/en-US/actions/actions/actions-commands.ftl new file mode 100644 index 0000000000..c0cd59cdac --- /dev/null +++ b/Resources/Locale/en-US/actions/actions/actions-commands.ftl @@ -0,0 +1,12 @@ +## Actions Commands loc + +## Upgradeaction command loc +upgradeaction-command-need-one-argument = upgradeaction needs at least one argument, the action entity uid. The second optional argument is a specified level. +upgradeaction-command-max-two-arguments = upgradeaction has a maximum of two arguments, the action entity uid and the (optional) level to set. +upgradeaction-command-second-argument-not-number = upgradeaction's second argument can only be a number. +upgradeaction-command-less-than-required-level = upgradeaction cannot accept a level of 0 or lower. +upgradeaction-command-incorrect-entityuid-format = You must use a valid entityuid format for upgradeaction. +upgradeaction-command-entity-does-not-exist = This entity does not exist, a valid entity is required for upgradeaction. +upgradeaction-command-entity-is-not-action = This entity doesn't have the action upgrade component, so this action cannot be leveled. +upgradeaction-command-cannot-level-up = The action cannot be leveled up. +upgradeaction-command-description = Upgrades an action by one level, or to the specified level, if applicable. diff --git a/Resources/Prototypes/Magic/projectile_spells.yml b/Resources/Prototypes/Magic/projectile_spells.yml index 8d7b2ffff0..196472fe7b 100644 --- a/Resources/Prototypes/Magic/projectile_spells.yml +++ b/Resources/Prototypes/Magic/projectile_spells.yml @@ -5,7 +5,34 @@ noSpawn: true components: - type: WorldTargetAction - useDelay: 30 + useDelay: 15 + itemIconStyle: BigAction + checkCanAccess: false + range: 60 + sound: !type:SoundPathSpecifier + path: /Audio/Magic/fireball.ogg + icon: + sprite: Objects/Magic/magicactions.rsi + state: fireball + event: !type:ProjectileSpellEvent + prototype: ProjectileFireball + posData: !type:TargetCasterPos + speech: action-speech-spell-fireball + - type: ActionUpgrade + effectedLevels: + 2: ActionFireballII + +- type: entity + id: ActionFireballII + parent: ActionFireball + name: Fireball II + description: Fire three explosive fireball towards the clicked location. + noSpawn: true + components: + - type: WorldTargetAction + useDelay: 5 + charges: 3 + renewCharges: true itemIconStyle: BigAction checkCanAccess: false range: 60