diff --git a/Content.Client/ClientContentIoC.cs b/Content.Client/ClientContentIoC.cs index d63ad75a32..0faab676a3 100644 --- a/Content.Client/ClientContentIoC.cs +++ b/Content.Client/ClientContentIoC.cs @@ -12,6 +12,7 @@ using Content.Client.UserInterface; using Content.Client.UserInterface.AdminMenu; using Content.Client.UserInterface.Stylesheets; using Content.Client.Utility; +using Content.Shared.Actions; using Content.Shared.Interfaces; using Content.Shared.Alert; using Robust.Shared.IoC; @@ -39,6 +40,7 @@ namespace Content.Client IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); } diff --git a/Content.Client/EntryPoint.cs b/Content.Client/EntryPoint.cs index e4e7cdd425..0f9752f4d5 100644 --- a/Content.Client/EntryPoint.cs +++ b/Content.Client/EntryPoint.cs @@ -13,6 +13,7 @@ using Content.Client.StationEvents; using Content.Client.UserInterface; using Content.Client.UserInterface.AdminMenu; using Content.Client.UserInterface.Stylesheets; +using Content.Shared.Actions; using Content.Shared.GameObjects.Components; using Content.Shared.GameObjects.Components.Cargo; using Content.Shared.GameObjects.Components.Chemistry; @@ -157,6 +158,7 @@ namespace Content.Client IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); _baseClient.RunLevelChanged += (sender, args) => { diff --git a/Content.Client/GameObjects/Components/HUD/Inventory/ClientInventoryComponent.cs b/Content.Client/GameObjects/Components/HUD/Inventory/ClientInventoryComponent.cs index 9fabec2678..97ff7454e5 100644 --- a/Content.Client/GameObjects/Components/HUD/Inventory/ClientInventoryComponent.cs +++ b/Content.Client/GameObjects/Components/HUD/Inventory/ClientInventoryComponent.cs @@ -20,10 +20,13 @@ namespace Content.Client.GameObjects.Components.HUD.Inventory /// A character UI which shows items the user has equipped within his inventory /// [RegisterComponent] + [ComponentReference(typeof(SharedInventoryComponent))] public class ClientInventoryComponent : SharedInventoryComponent { private readonly Dictionary _slots = new(); + public IReadOnlyDictionary AllSlots => _slots; + [ViewVariables] public InventoryInterfaceController InterfaceController { get; private set; } = default!; private ISpriteComponent? _sprite; @@ -70,6 +73,11 @@ namespace Content.Client.GameObjects.Components.HUD.Inventory } } + public override bool IsEquipped(IEntity item) + { + return item != null && _slots.Values.Any(e => e == item); + } + public override void HandleComponentState(ComponentState? curState, ComponentState? nextState) { base.HandleComponentState(curState, nextState); diff --git a/Content.Client/GameObjects/Components/HUD/Inventory/HumanInventoryInterfaceController.cs b/Content.Client/GameObjects/Components/HUD/Inventory/HumanInventoryInterfaceController.cs index 05b2a6a696..135b9ca531 100644 --- a/Content.Client/GameObjects/Components/HUD/Inventory/HumanInventoryInterfaceController.cs +++ b/Content.Client/GameObjects/Components/HUD/Inventory/HumanInventoryInterfaceController.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using Content.Client.UserInterface; using Content.Client.Utility; using JetBrains.Annotations; @@ -84,6 +85,16 @@ namespace Content.Client.GameObjects.Components.HUD.Inventory public override SS14Window Window => _window; private HumanInventoryWindow _window; + public override IEnumerable GetItemSlotButtons(Slots slot) + { + if (!_inventoryButtons.TryGetValue(slot, out var buttons)) + { + return Enumerable.Empty(); + } + + return buttons; + } + public override void AddToSlot(Slots slot, IEntity entity) { base.AddToSlot(slot, entity); diff --git a/Content.Client/GameObjects/Components/HUD/Inventory/InventoryInterfaceController.cs b/Content.Client/GameObjects/Components/HUD/Inventory/InventoryInterfaceController.cs index 73c18852f5..789d303c30 100644 --- a/Content.Client/GameObjects/Components/HUD/Inventory/InventoryInterfaceController.cs +++ b/Content.Client/GameObjects/Components/HUD/Inventory/InventoryInterfaceController.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Content.Client.UserInterface; using Content.Shared.GameObjects.Components.Inventory; using Content.Shared.Input; @@ -53,6 +54,10 @@ namespace Content.Client.GameObjects.Components.HUD.Inventory { } + /// the button controls associated with the + /// specified slot, if any. Empty if none. + public abstract IEnumerable GetItemSlotButtons(EquipmentSlotDefines.Slots slot); + public virtual void AddToSlot(EquipmentSlotDefines.Slots slot, IEntity entity) { } diff --git a/Content.Client/GameObjects/Components/Items/HandsComponent.cs b/Content.Client/GameObjects/Components/Items/HandsComponent.cs index 8bf8bf6c9d..654f7e6c0c 100644 --- a/Content.Client/GameObjects/Components/Items/HandsComponent.cs +++ b/Content.Client/GameObjects/Components/Items/HandsComponent.cs @@ -15,6 +15,7 @@ namespace Content.Client.GameObjects.Components.Items { [RegisterComponent] [ComponentReference(typeof(ISharedHandsComponent))] + [ComponentReference(typeof(SharedHandsComponent))] public class HandsComponent : SharedHandsComponent { [Dependency] private readonly IGameHud _gameHud = default!; @@ -31,6 +32,18 @@ namespace Content.Client.GameObjects.Components.Items [ViewVariables] public IEntity? ActiveHand => GetEntity(ActiveIndex); + public override bool IsHolding(IEntity entity) + { + foreach (var hand in _hands) + { + if (hand.Entity == entity) + { + return true; + } + } + return false; + } + private void AddHand(Hand hand) { _sprite?.LayerMapReserveBlank($"hand-{hand.Name}"); diff --git a/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignment.cs b/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignment.cs new file mode 100644 index 0000000000..2146d5e90b --- /dev/null +++ b/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignment.cs @@ -0,0 +1,90 @@ +using System; +using Content.Shared.Actions; +using Robust.Shared.GameObjects; + +namespace Content.Client.GameObjects.Components.Mobs.Actions +{ + public struct ActionAssignment : IEquatable + { + private readonly ActionType _actionType; + private readonly ItemActionType _itemActionType; + private readonly EntityUid _item; + public Assignment Assignment { get; private init; } + + private ActionAssignment(Assignment assignment, ActionType actionType, ItemActionType itemActionType, EntityUid item) + { + Assignment = assignment; + _actionType = actionType; + _itemActionType = itemActionType; + _item = item; + } + + /// the action type, if our Assignment is Assignment.Action + /// true only if our Assignment is Assignment.Action + public bool TryGetAction(out ActionType actionType) + { + actionType = _actionType; + return Assignment == Assignment.Action; + } + + /// the item action type, if our Assignment is Assignment.ItemActionWithoutItem + /// true only if our Assignment is Assignment.ItemActionWithoutItem + public bool TryGetItemActionWithoutItem(out ItemActionType itemActionType) + { + itemActionType = _itemActionType; + return Assignment == Assignment.ItemActionWithoutItem; + } + + /// the item action type, if our Assignment is Assignment.ItemActionWithItem + /// the item UID providing the action, if our Assignment is Assignment.ItemActionWithItem + /// true only if our Assignment is Assignment.ItemActionWithItem + public bool TryGetItemActionWithItem(out ItemActionType itemActionType, out EntityUid item) + { + itemActionType = _itemActionType; + item = _item; + return Assignment == Assignment.ItemActionWithItem; + } + + public static ActionAssignment For(ActionType actionType) + { + return new(Assignment.Action, actionType, default, default); + } + + public static ActionAssignment For(ItemActionType actionType) + { + return new(Assignment.ItemActionWithoutItem, default, actionType, default); + } + + public static ActionAssignment For(ItemActionType actionType, EntityUid item) + { + return new(Assignment.ItemActionWithItem, default, actionType, item); + } + + public bool Equals(ActionAssignment other) + { + return _actionType == other._actionType && _itemActionType == other._itemActionType && Equals(_item, other._item); + } + + public override bool Equals(object obj) + { + return obj is ActionAssignment other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(_actionType, _itemActionType, _item); + } + + public override string ToString() + { + return $"{nameof(_actionType)}: {_actionType}, {nameof(_itemActionType)}: {_itemActionType}, {nameof(_item)}: {_item}, {nameof(Assignment)}: {Assignment}"; + } + } + + public enum Assignment : byte + { + Action, + ItemActionWithoutItem, + ItemActionWithItem + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignments.cs b/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignments.cs new file mode 100644 index 0000000000..9afe2e27e6 --- /dev/null +++ b/Content.Client/GameObjects/Components/Mobs/Actions/ActionAssignments.cs @@ -0,0 +1,304 @@ +using System.Collections.Generic; +using System.Linq; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Shared.GameObjects; + +namespace Content.Client.GameObjects.Components.Mobs.Actions +{ + /// + /// Tracks and manages the hotbar assignments for actions. + /// + public class ActionAssignments + { + // the slots and assignments fields hold client's assignments (what action goes in what slot), + // which are completely client side and independent of what actions they've actually been granted and + // what item the action is actually for. + + /// + /// x = hotbar number, y = slot of that hotbar (index 0 corresponds to the one labeled "1", + /// index 9 corresponds to the one labeled "0"). Essentially the inverse of _assignments. + /// + private readonly ActionAssignment?[,] _slots; + + /// + /// Hotbar and slot assignment for each action type (slot index 0 corresponds to the one labeled "1", + /// slot index 9 corresponds to the one labeled "0"). The key corresponds to an index in the _slots array. + /// The value is a list because actions can be assigned to multiple slots. Even if an action type has not been granted, + /// it can still be assigned to a slot. Essentially the inverse of _slots. + /// There will be no entry if there is no assignment (no empty lists in this dict) + /// + private readonly Dictionary> _assignments; + + /// + /// Actions which have been manually cleared by the user, thus should not + /// auto-populate. + /// + private readonly HashSet _preventAutoPopulate = new(); + private readonly Dictionary> _preventAutoPopulateItem = new(); + + private readonly byte _numHotbars; + private readonly byte _numSlots; + + public ActionAssignments(byte numHotbars, byte numSlots) + { + _numHotbars = numHotbars; + _numSlots = numSlots; + _assignments = new Dictionary>(); + _slots = new ActionAssignment?[numHotbars,numSlots]; + } + + /// + /// Updates the assignments based on the current states of all the actions. + /// Newly-granted actions or item actions which don't have an assignment will be assigned a slot + /// automatically (unless they've been manually cleared). Item-based actions + /// which no longer have an associated state will be decoupled from their item. + /// + public void Reconcile(byte currentHotbar, IReadOnlyDictionary actionStates, + IReadOnlyDictionary> itemActionStates) + { + // if we've been granted any actions which have no assignment to any hotbar, we must auto-populate them + // into the hotbar so the user knows about them. + // We fill their current hotbar first, rolling over to the next open slot on the next hotbar. + foreach (var actionState in actionStates) + { + var assignment = ActionAssignment.For(actionState.Key); + if (actionState.Value.Enabled && !_assignments.ContainsKey(assignment)) + { + AutoPopulate(assignment, currentHotbar, false); + } + } + + + + foreach (var (item, itemStates) in itemActionStates) + { + foreach (var itemActionState in itemStates) + { + // unlike regular actions, we DO actually show user their new item action even when it's disabled. + // this allows them to instantly see when an action may be possible that is provided by an item but + // something is preventing it + // Note that we are checking if there is an explicit assignment for this item action + item, + // we will determine during auto-population if we should tie the item to an existing "item action only" + // assignment + var assignment = ActionAssignment.For(itemActionState.Key, item); + if (!_assignments.ContainsKey(assignment)) + { + AutoPopulate(assignment, currentHotbar, false); + } + } + } + + // We need to figure out which current item action assignments we had + // which once had an associated item but have been revoked (based on our newly provided action states) + // so we can dissociate them from the item. If the provided action states do not + // have a state for this action type + item, we can assume that the action has been revoked for that item. + var assignmentsWithoutItem = new List>>(); + foreach (var assignmentEntry in _assignments) + { + if (!assignmentEntry.Key.TryGetItemActionWithItem(out var actionType, out var item)) continue; + + // we have this assignment currently tied to an item, + // check if it no longer has an associated item in our dict of states + if (itemActionStates.TryGetValue(item, out var states)) + { + if (states.ContainsKey(actionType)) + { + // we have a state for this item + action type so we won't + // remove the item from the assignment + continue; + } + } + assignmentsWithoutItem.Add(assignmentEntry); + } + // reassign without the item for each assignment we found that no longer has an associated item + foreach (var (assignment, slots) in assignmentsWithoutItem) + { + foreach (var (hotbar, slot) in slots) + { + if (!assignment.TryGetItemActionWithItem(out var actionType, out _)) continue; + AssignSlot(hotbar, slot, + ActionAssignment.For(actionType)); + } + } + + // Additionally, we must find items which have no action states at all in our newly provided states so + // we can assume their item was unequipped and reset them to allow auto-population. + var itemsWithoutState = _preventAutoPopulateItem.Keys.Where(item => !itemActionStates.ContainsKey(item)); + foreach (var toRemove in itemsWithoutState) + { + _preventAutoPopulateItem.Remove(toRemove); + } + } + + /// + /// Assigns the indicated hotbar slot to the specified action type. + /// + /// hotbar whose slot is being assigned + /// slot of the hotbar to assign to (0 = the slot labeled 1, 9 = the slot labeled 0) + /// action to assign to the slot + public void AssignSlot(byte hotbar, byte slot, ActionAssignment actionType) + { + ClearSlot(hotbar, slot, false); + _slots[hotbar, slot] = actionType; + if (_assignments.TryGetValue(actionType, out var slotList)) + { + slotList.Add((hotbar, slot)); + } + else + { + var newList = new List<(byte Hotbar, byte Slot)> {(hotbar, slot)}; + _assignments[actionType] = newList; + } + } + + /// + /// Clear the assignment from the indicated slot. + /// + /// hotbar whose slot is being cleared + /// slot of the hotbar to clear (0 = the slot labeled 1, 9 = the slot labeled 0) + /// if true, the action assigned to this slot + /// will be prevented from being auto-populated in the future when it is newly granted. + /// Item actions will automatically be allowed to auto populate again + /// when their associated item are unequipped. This ensures that items that are newly + /// picked up will always present their actions to the user even if they had earlier been cleared. + /// + public void ClearSlot(byte hotbar, byte slot, bool preventAutoPopulate) + { + // remove this particular assignment from our data structures + // (keeping in mind something can be assigned multiple slots) + var currentAction = _slots[hotbar, slot]; + if (!currentAction.HasValue) return; + if (preventAutoPopulate) + { + var assignment = currentAction.Value; + + if (assignment.TryGetAction(out var actionType)) + { + _preventAutoPopulate.Add(actionType); + } + else if (assignment.TryGetItemActionWithItem(out var itemActionType, out var item)) + { + if (!_preventAutoPopulateItem.TryGetValue(item, out var actionTypes)) + { + actionTypes = new HashSet(); + _preventAutoPopulateItem[item] = actionTypes; + } + + actionTypes.Add(itemActionType); + } + } + var assignmentList = _assignments[currentAction.Value]; + assignmentList = assignmentList.Where(a => a.Hotbar != hotbar || a.Slot != slot).ToList(); + if (assignmentList.Count == 0) + { + _assignments.Remove(currentAction.Value); + } + else + { + _assignments[currentAction.Value] = assignmentList; + } + _slots[hotbar, slot] = null; + } + + /// + /// Finds the next open slot the action can go in and assigns it there, + /// starting from the currently selected hotbar. + /// Does not update any UI elements, only updates the assignment data structures. + /// + /// if true, will force the assignment to occur + /// regardless of whether this assignment has been prevented from auto population + /// via ClearSlot's preventAutoPopulate parameter. If false, will have no effect + /// if this assignment has been prevented from auto population. + public void AutoPopulate(ActionAssignment toAssign, byte currentHotbar, bool force = true) + { + if (ShouldPreventAutoPopulate(toAssign, force)) return; + // if the assignment to make is an item action with an associated item, + // then first look for currently assigned item actions without an item, to replace with this + // assignment + if (toAssign.TryGetItemActionWithItem(out var actionType, out var _)) + { + if (_assignments.TryGetValue(ActionAssignment.For(actionType), + out var possibilities)) + { + // use the closest assignment to current hotbar + byte hotbar = 0; + byte slot = 0; + var minCost = int.MaxValue; + foreach (var possibility in possibilities) + { + var cost = possibility.Slot + _numSlots * (currentHotbar >= possibility.Hotbar + ? currentHotbar - possibility.Hotbar + : (_numHotbars - currentHotbar) + possibility.Hotbar); + if (cost < minCost) + { + hotbar = possibility.Hotbar; + slot = possibility.Slot; + minCost = cost; + } + } + + if (minCost != int.MaxValue) + { + AssignSlot(hotbar, slot, toAssign); + return; + } + } + } + + for (byte hotbarOffset = 0; hotbarOffset < _numHotbars; hotbarOffset++) + { + for (byte slot = 0; slot < _numSlots; slot++) + { + var hotbar = (byte) ((currentHotbar + hotbarOffset) % _numHotbars); + var slotAssignment = _slots[hotbar, slot]; + if (slotAssignment.HasValue) + { + // if the assignment in this slot is an item action without an associated item, + // then tie it to the current item if we are trying to auto populate an item action. + if (toAssign.Assignment == Assignment.ItemActionWithItem && + slotAssignment.Value.Assignment == Assignment.ItemActionWithoutItem) + { + AssignSlot(hotbar, slot, toAssign); + return; + } + continue; + } + // slot's empty, assign + AssignSlot(hotbar, slot, toAssign); + return; + } + } + // there was no empty slot + } + + private bool ShouldPreventAutoPopulate(ActionAssignment assignment, bool force) + { + if (force) return false; + + if (assignment.TryGetAction(out var actionType)) + { + return _preventAutoPopulate.Contains(actionType); + } + + if (assignment.TryGetItemActionWithItem(out var itemActionType, out var item)) + { + return _preventAutoPopulateItem.TryGetValue(item, + out var itemActionTypes) && itemActionTypes.Contains(itemActionType); + } + + return false; + } + + /// + /// Gets the assignment to the indicated slot if there is one. + /// + public ActionAssignment? this[in byte hotbar, in byte slot] => _slots[hotbar, slot]; + + /// true if we have the assignment assigned to some slot + public bool HasAssignment(ActionAssignment assignment) + { + return _assignments.ContainsKey(assignment); + } + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/ClientActionsComponent.cs b/Content.Client/GameObjects/Components/Mobs/ClientActionsComponent.cs new file mode 100644 index 0000000000..bbcf23ff36 --- /dev/null +++ b/Content.Client/GameObjects/Components/Mobs/ClientActionsComponent.cs @@ -0,0 +1,273 @@ +#nullable enable +using System.Collections.Generic; +using Content.Client.GameObjects.Components.HUD.Inventory; +using Content.Client.GameObjects.Components.Items; +using Content.Client.GameObjects.Components.Mobs.Actions; +using Content.Client.UserInterface; +using Content.Client.UserInterface.Controls; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Client.GameObjects; +using Robust.Client.GameObjects.EntitySystems; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.Player; +using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.ComponentDependencies; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Input.Binding; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.ViewVariables; + +namespace Content.Client.GameObjects.Components.Mobs +{ + /// + [RegisterComponent] + [ComponentReference(typeof(SharedActionsComponent))] + public sealed class ClientActionsComponent : SharedActionsComponent + { + public const byte Hotbars = 10; + public const byte Slots = 10; + + [Dependency] private readonly IPlayerManager _playerManager = default!; + + [ComponentDependency] private readonly HandsComponent? _handsComponent = null; + [ComponentDependency] private readonly ClientInventoryComponent? _inventoryComponent = null; + + private ActionsUI? _ui; + private readonly List _highlightingItemSlots = new(); + + /// + /// Current assignments for all hotbars / slots for this entity. + /// + public ActionAssignments Assignments { get; } = new(Hotbars, Slots); + + /// + /// Allows calculating if we need to act due to this component being controlled by the current mob + /// + [ViewVariables] + private bool CurrentlyControlled => _playerManager.LocalPlayer != null && _playerManager.LocalPlayer.ControlledEntity == Owner; + + + protected override void Shutdown() + { + base.Shutdown(); + PlayerDetached(); + } + + public override void HandleMessage(ComponentMessage message, IComponent? component) + { + base.HandleMessage(message, component); + switch (message) + { + case PlayerAttachedMsg _: + PlayerAttached(); + break; + case PlayerDetachedMsg _: + PlayerDetached(); + break; + } + } + + public override void HandleComponentState(ComponentState? curState, ComponentState? nextState) + { + base.HandleComponentState(curState, nextState); + + if (curState is not ActionComponentState) + { + return; + } + + UpdateUI(); + } + + private void PlayerAttached() + { + if (!CurrentlyControlled || _ui != null) + { + return; + } + + _ui = new ActionsUI(this); + IoCManager.Resolve().StateRoot.AddChild(_ui); + UpdateUI(); + } + + private void PlayerDetached() + { + if (_ui == null) return; + IoCManager.Resolve().StateRoot.RemoveChild(_ui); + _ui = null; + } + + public void HandleHotbarKeybind(byte slot, in PointerInputCmdHandler.PointerInputCmdArgs args) + { + _ui?.HandleHotbarKeybind(slot, args); + } + + /// + /// Updates the displayed hotbar (and menu) based on current state of actions. + /// + private void UpdateUI() + { + if (!CurrentlyControlled || _ui == null) + { + return; + } + + Assignments.Reconcile(_ui.SelectedHotbar, ActionStates(), ItemActionStates()); + + _ui.UpdateUI(); + } + + public void AttemptAction(ActionSlot slot) + { + + var attempt = slot.ActionAttempt(); + if (attempt == null) return; + + switch (attempt.Action.BehaviorType) + { + case BehaviorType.Instant: + // for instant actions, we immediately tell the server we're doing it + SendNetworkMessage(attempt.PerformInstantActionMessage()); + break; + case BehaviorType.Toggle: + // for toggle actions, we immediately tell the server we're toggling it. + if (attempt.TryGetActionState(this, out var actionState)) + { + // TODO: At the moment we always predict that the toggle will work clientside, + // even if it sometimes may not (it will be reset by the server if wrong). + attempt.ToggleAction(this, !actionState.ToggledOn); + slot.ToggledOn = !actionState.ToggledOn; + SendNetworkMessage(attempt.PerformToggleActionMessage(!actionState.ToggledOn)); + } + else + { + Logger.ErrorS("action", "attempted to toggle action {0} which has" + + " unknown state", attempt); + } + + break; + case BehaviorType.TargetPoint: + case BehaviorType.TargetEntity: + // for target actions, we go into "select target" mode, we don't + // message the server until we actually pick our target. + + // if we're clicking the same thing we're already targeting for, then we simply cancel + // targeting + _ui?.ToggleTargeting(slot); + break; + case BehaviorType.None: + break; + default: + Logger.ErrorS("action", "unhandled action press for action {0}", + attempt); + break; + } + } + + /// + /// Handles clicks when selecting the target for an action. Only has an effect when currently + /// selecting a target. + /// + public bool TargetingOnUse(in PointerInputCmdHandler.PointerInputCmdArgs args) + { + // not currently predicted + if (EntitySystem.Get().Predicted) return false; + + // only do something for actual target-based actions + if (_ui?.SelectingTargetFor?.Action == null || + (_ui.SelectingTargetFor.Action.BehaviorType != BehaviorType.TargetEntity && + _ui.SelectingTargetFor.Action.BehaviorType != BehaviorType.TargetPoint)) return false; + + var attempt = _ui.SelectingTargetFor.ActionAttempt(); + if (attempt == null) + { + _ui.StopTargeting(); + return false; + } + + switch (_ui.SelectingTargetFor.Action.BehaviorType) + { + case BehaviorType.TargetPoint: + { + // send our action to the server, we chose our target + SendNetworkMessage(attempt.PerformTargetPointActionMessage(args)); + if (!attempt.Action.Repeat) + { + _ui.StopTargeting(); + } + return true; + } + // target the currently hovered entity, if there is one + case BehaviorType.TargetEntity when args.EntityUid != EntityUid.Invalid: + { + // send our action to the server, we chose our target + SendNetworkMessage(attempt.PerformTargetEntityActionMessage(args)); + if (!attempt.Action.Repeat) + { + _ui.StopTargeting(); + } + return true; + } + default: + _ui.StopTargeting(); + return false; + } + } + + protected override void AfterActionChanged() + { + UpdateUI(); + } + + /// + /// Highlights the item slot (inventory or hand) that contains this item + /// + /// + public void HighlightItemSlot(IEntity item) + { + StopHighlightingItemSlots(); + + // figure out if it's in hand or inventory and highlight it + foreach (var hand in _handsComponent!.Hands) + { + if (hand.Entity != item || hand.Button == null) continue; + _highlightingItemSlots.Add(hand.Button); + hand.Button.Highlight(true); + return; + } + + foreach (var (slot, slotItem) in _inventoryComponent!.AllSlots) + { + if (slotItem != item) continue; + foreach (var itemSlotButton in + _inventoryComponent.InterfaceController.GetItemSlotButtons(slot)) + { + _highlightingItemSlots.Add(itemSlotButton); + itemSlotButton.Highlight(true); + } + return; + } + } + + /// + /// Stops highlighting any item slots we are currently highlighting. + /// + public void StopHighlightingItemSlots() + { + foreach (var itemSlot in _highlightingItemSlots) + { + itemSlot.Highlight(false); + } + _highlightingItemSlots.Clear(); + } + + public void ToggleActionsMenu() + { + _ui?.ToggleActionsMenu(); + } + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs b/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs index 4f2a1bbb6c..ae92f09ee3 100644 --- a/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs +++ b/Content.Client/GameObjects/Components/Mobs/ClientAlertsComponent.cs @@ -1,25 +1,20 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Content.Client.UserInterface; -using Content.Client.UserInterface.Stylesheets; +using Content.Client.UserInterface.Controls; using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Mobs; using Robust.Client.GameObjects; -using Robust.Client.Interfaces.Graphics; using Robust.Client.Interfaces.ResourceManagement; using Robust.Client.Interfaces.UserInterface; using Robust.Client.Player; -using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared.GameObjects; using Robust.Shared.Input; using Robust.Shared.Interfaces.GameObjects; -using Robust.Shared.Interfaces.Timing; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Prototypes; -using Robust.Shared.Utility; using Robust.Shared.ViewVariables; namespace Content.Client.GameObjects.Components.Mobs @@ -29,19 +24,11 @@ namespace Content.Client.GameObjects.Components.Mobs [ComponentReference(typeof(SharedAlertsComponent))] public sealed class ClientAlertsComponent : SharedAlertsComponent { - private static readonly float TooltipTextMaxWidth = 265; - [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IResourceCache _resourceCache = default!; - [Dependency] private readonly IGameTiming _gameTiming = default!; private AlertsUI _ui; - private PanelContainer _tooltip; - private RichTextLabel _stateName; - private RichTextLabel _stateDescription; - private RichTextLabel _stateCooldown; private AlertOrderPrototype _alertOrder; - private bool _tooltipReady; [ViewVariables] private readonly Dictionary _alertControls @@ -49,7 +36,6 @@ namespace Content.Client.GameObjects.Components.Mobs /// /// Allows calculating if we need to act due to this component being controlled by the current mob - /// TODO: should be revisited after space-wizards/RobustToolbox#1255 /// [ViewVariables] private bool CurrentlyControlled => _playerManager.LocalPlayer != null && _playerManager.LocalPlayer.ControlledEntity == Owner; @@ -78,14 +64,11 @@ namespace Content.Client.GameObjects.Components.Mobs { base.HandleComponentState(curState, nextState); - if (curState is not AlertsComponentState state) + if (curState is not AlertsComponentState) { return; } - // update the dict of states based on the array we got in the message - SetAlerts(state.Alerts); - UpdateAlertsControls(); } @@ -102,48 +85,24 @@ namespace Content.Client.GameObjects.Components.Mobs Logger.ErrorS("alert", "no alertOrder prototype found, alerts will be in random order"); } - _ui = new AlertsUI(IoCManager.Resolve()); - var uiManager = IoCManager.Resolve(); - uiManager.StateRoot.AddChild(_ui); - - _tooltip = new PanelContainer - { - Visible = false, - StyleClasses = { StyleNano.StyleClassTooltipPanel } - }; - var tooltipVBox = new VBoxContainer - { - RectClipContent = true - }; - _tooltip.AddChild(tooltipVBox); - _stateName = new RichTextLabel - { - MaxWidth = TooltipTextMaxWidth, - StyleClasses = { StyleNano.StyleClassTooltipAlertTitle } - }; - tooltipVBox.AddChild(_stateName); - _stateDescription = new RichTextLabel - { - MaxWidth = TooltipTextMaxWidth, - StyleClasses = { StyleNano.StyleClassTooltipAlertDescription } - }; - tooltipVBox.AddChild(_stateDescription); - _stateCooldown = new RichTextLabel - { - MaxWidth = TooltipTextMaxWidth, - StyleClasses = { StyleNano.StyleClassTooltipAlertCooldown } - }; - tooltipVBox.AddChild(_stateCooldown); - - uiManager.PopupRoot.AddChild(_tooltip); + _ui = new AlertsUI(); + IoCManager.Resolve().StateRoot.AddChild(_ui); UpdateAlertsControls(); } private void PlayerDetached() { - _ui?.Dispose(); - _ui = null; + foreach (var alertControl in _alertControls.Values) + { + alertControl.OnPressed -= AlertControlOnPressed; + } + + if (_ui != null) + { + IoCManager.Resolve().StateRoot.RemoveChild(_ui); + _ui = null; + } _alertControls.Clear(); } @@ -168,39 +127,49 @@ namespace Content.Client.GameObjects.Components.Mobs toRemove.Add(existingKey); } } - foreach (var alertKeyToRemove in toRemove) { - // remove and dispose the control _alertControls.Remove(alertKeyToRemove, out var control); - control?.Dispose(); + if (control == null) return; + _ui.Grid.Children.Remove(control); } // now we know that alertControls contains alerts that should still exist but // may need to updated, // also there may be some new alerts we need to show. // further, we need to ensure they are ordered w.r.t their configured order - foreach (var alertStatus in EnumerateAlertStates()) + foreach (var (alertKey, alertState) in EnumerateAlertStates()) { - if (!AlertManager.TryDecode(alertStatus.AlertEncoded, out var newAlert)) + if (!alertKey.AlertType.HasValue) { - Logger.ErrorS("alert", "Unable to decode alert {0}", alertStatus.AlertEncoded); + Logger.WarningS("alert", "found alertkey without alerttype," + + " alert keys should never be stored without an alerttype set: {0}", alertKey); + continue; + } + var alertType = alertKey.AlertType.Value; + if (!AlertManager.TryGet(alertType, out var newAlert)) + { + Logger.ErrorS("alert", "Unrecognized alertType {0}", alertType); continue; } if (_alertControls.TryGetValue(newAlert.AlertKey, out var existingAlertControl) && existingAlertControl.Alert.AlertType == newAlert.AlertType) { - // id is the same, simply update the existing control severity - existingAlertControl.SetSeverity(alertStatus.Severity); + // key is the same, simply update the existing control severity / cooldown + existingAlertControl.SetSeverity(alertState.Severity); + existingAlertControl.Cooldown = alertState.Cooldown; } else { - existingAlertControl?.Dispose(); + if (existingAlertControl != null) + { + _ui.Grid.Children.Remove(existingAlertControl); + } // this is a new alert + alert key or just a different alert with the same // key, create the control and add it in the appropriate order - var newAlertControl = CreateAlertControl(newAlert, alertStatus); + var newAlertControl = CreateAlertControl(newAlert, alertState); if (_alertOrder != null) { var added = false; @@ -233,14 +202,11 @@ namespace Content.Client.GameObjects.Components.Mobs private AlertControl CreateAlertControl(AlertPrototype alert, AlertState alertState) { - - var alertControl = new AlertControl(alert, alertState.Severity, _resourceCache); - // show custom tooltip for the status control - alertControl.OnShowTooltip += AlertOnOnShowTooltip; - alertControl.OnHideTooltip += AlertOnOnHideTooltip; - + var alertControl = new AlertControl(alert, alertState.Severity, _resourceCache) + { + Cooldown = alertState.Cooldown + }; alertControl.OnPressed += AlertControlOnPressed; - return alertControl; } @@ -249,36 +215,6 @@ namespace Content.Client.GameObjects.Components.Mobs AlertPressed(args, args.Button as AlertControl); } - private void AlertOnOnHideTooltip(object sender, EventArgs e) - { - _tooltipReady = false; - _tooltip.Visible = false; - } - - private void AlertOnOnShowTooltip(object sender, EventArgs e) - { - var alertControl = (AlertControl) sender; - _stateName.SetMessage(alertControl.Alert.Name); - _stateDescription.SetMessage(alertControl.Alert.Description); - // check for a cooldown - if (alertControl.TotalDuration != null && alertControl.TotalDuration > 0) - { - _stateCooldown.SetMessage(FormattedMessage.FromMarkup("[color=#776a6a]" + - alertControl.TotalDuration + - " sec cooldown[/color]")); - _stateCooldown.Visible = true; - } - else - { - _stateCooldown.Visible = false; - } - // TODO: Text display of cooldown - Tooltips.PositionTooltip(_tooltip); - // if we set it visible here the size of the previous tooltip will flicker for a frame, - // so instead we wait until FrameUpdate to make it visible - _tooltipReady = true; - } - private void AlertPressed(BaseButton.ButtonEventArgs args, AlertControl alert) { if (args.Event.Function != EngineKeyFunctions.UIClick) @@ -286,57 +222,17 @@ namespace Content.Client.GameObjects.Components.Mobs return; } - if (AlertManager.TryEncode(alert.Alert, out var encoded)) - { - SendNetworkMessage(new ClickAlertMessage(encoded)); - } - else - { - Logger.ErrorS("alert", "unable to encode alert {0}", alert.Alert.AlertType); - } - + SendNetworkMessage(new ClickAlertMessage(alert.Alert.AlertType)); } - public void FrameUpdate(float frameTime) + protected override void AfterShowAlert() { - if (_tooltipReady) - { - _tooltipReady = false; - _tooltip.Visible = true; - } - foreach (var (alertKey, alertControl) in _alertControls) - { - // reconcile all alert controls with their current cooldowns - if (TryGetAlertState(alertKey, out var alertState)) - { - alertControl.UpdateCooldown(alertState.Cooldown, _gameTiming.CurTime); - } - else - { - Logger.WarningS("alert", "coding error - no alert state for alert {0} " + - "even though we had an AlertControl for it, this" + - " should never happen", alertControl.Alert.AlertType); - } - - } + UpdateAlertsControls(); } protected override void AfterClearAlert() { UpdateAlertsControls(); } - - public override void OnRemove() - { - base.OnRemove(); - - foreach (var alertControl in _alertControls.Values) - { - alertControl.OnShowTooltip -= AlertOnOnShowTooltip; - alertControl.OnHideTooltip -= AlertOnOnHideTooltip; - alertControl.OnPressed -= AlertControlOnPressed; - } - - } } } diff --git a/Content.Client/GameObjects/EntitySystems/ActionsSystem.cs b/Content.Client/GameObjects/EntitySystems/ActionsSystem.cs new file mode 100644 index 0000000000..b30d6c0cff --- /dev/null +++ b/Content.Client/GameObjects/EntitySystems/ActionsSystem.cs @@ -0,0 +1,91 @@ +using Content.Client.GameObjects.Components.Mobs; +using Content.Shared.Input; +using JetBrains.Annotations; +using Robust.Client.Player; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Robust.Shared.IoC; + +namespace Content.Client.GameObjects.EntitySystems +{ + [UsedImplicitly] + public class ActionsSystem : EntitySystem + { + [Dependency] private readonly IPlayerManager _playerManager = default!; + + public override void Initialize() + { + base.Initialize(); + + // set up hotkeys for hotbar + CommandBinds.Builder + .Bind(ContentKeyFunctions.OpenActionsMenu, + InputCmdHandler.FromDelegate(_ => ToggleActionsMenu())) + .Bind(ContentKeyFunctions.Hotbar1, + HandleHotbarKeybind(0)) + .Bind(ContentKeyFunctions.Hotbar2, + HandleHotbarKeybind(1)) + .Bind(ContentKeyFunctions.Hotbar3, + HandleHotbarKeybind(2)) + .Bind(ContentKeyFunctions.Hotbar4, + HandleHotbarKeybind(3)) + .Bind(ContentKeyFunctions.Hotbar5, + HandleHotbarKeybind(4)) + .Bind(ContentKeyFunctions.Hotbar6, + HandleHotbarKeybind(5)) + .Bind(ContentKeyFunctions.Hotbar7, + HandleHotbarKeybind(6)) + .Bind(ContentKeyFunctions.Hotbar8, + HandleHotbarKeybind(7)) + .Bind(ContentKeyFunctions.Hotbar9, + HandleHotbarKeybind(8)) + .Bind(ContentKeyFunctions.Hotbar0, + HandleHotbarKeybind(9)) + // when selecting a target, we intercept clicks in the game world, treating them as our target selection. We want to + // take priority before any other systems handle the click. + .BindBefore(EngineKeyFunctions.Use, new PointerInputCmdHandler(TargetingOnUse), + typeof(ConstructionSystem), typeof(DragDropSystem)) + .Register(); + } + + public override void Shutdown() + { + base.Shutdown(); + CommandBinds.Unregister(); + } + + private PointerInputCmdHandler HandleHotbarKeybind(byte slot) + { + // delegate to the ActionsUI, simulating a click on it + return new((in PointerInputCmdHandler.PointerInputCmdArgs args) => + { + var playerEntity = _playerManager.LocalPlayer.ControlledEntity; + if (playerEntity == null || + !playerEntity.TryGetComponent( out var actionsComponent)) return false; + + actionsComponent.HandleHotbarKeybind(slot, args); + return true; + }, + false); + } + + private bool TargetingOnUse(in PointerInputCmdHandler.PointerInputCmdArgs args) + { + var playerEntity = _playerManager.LocalPlayer.ControlledEntity; + if (playerEntity == null || + !playerEntity.TryGetComponent( out var actionsComponent)) return false; + + return actionsComponent.TargetingOnUse(args); + } + + private void ToggleActionsMenu() + { + var playerEntity = _playerManager.LocalPlayer.ControlledEntity; + if (playerEntity == null || + !playerEntity.TryGetComponent( out var actionsComponent)) return; + + actionsComponent.ToggleActionsMenu(); + } + } +} diff --git a/Content.Client/GameObjects/EntitySystems/AlertsSystem.cs b/Content.Client/GameObjects/EntitySystems/AlertsSystem.cs deleted file mode 100644 index e7c267329c..0000000000 --- a/Content.Client/GameObjects/EntitySystems/AlertsSystem.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Content.Client.GameObjects.Components.Mobs; -using JetBrains.Annotations; -using Robust.Shared.GameObjects.Systems; -using Robust.Shared.Interfaces.Timing; -using Robust.Shared.IoC; - -namespace Content.Client.GameObjects.EntitySystems -{ - [UsedImplicitly] - public class AlertsSystem : EntitySystem - { - [Dependency] private readonly IGameTiming _gameTiming = default!; - - public override void FrameUpdate(float frameTime) - { - base.FrameUpdate(frameTime); - - if (!_gameTiming.IsFirstTimePredicted) - return; - - foreach (var clientAlertsComponent in EntityManager.ComponentManager.EntityQuery()) - { - clientAlertsComponent.FrameUpdate(frameTime); - } - } - } -} diff --git a/Content.Client/GameObjects/EntitySystems/DragDropSystem.cs b/Content.Client/GameObjects/EntitySystems/DragDropSystem.cs index 8cb69b8e2d..97c8aa37dd 100644 --- a/Content.Client/GameObjects/EntitySystems/DragDropSystem.cs +++ b/Content.Client/GameObjects/EntitySystems/DragDropSystem.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Linq; using Content.Client.State; +using Content.Client.Utility; using Content.Shared.GameObjects; using Content.Shared.GameObjects.EntitySystemMessages; using Content.Shared.GameObjects.EntitySystems; @@ -10,7 +11,6 @@ using Robust.Client.GameObjects; using Robust.Client.GameObjects.EntitySystems; using Robust.Client.Graphics.Shaders; using Robust.Client.Interfaces.Graphics.ClientEye; -using Robust.Client.Interfaces.Input; using Robust.Client.Interfaces.State; using Robust.Shared.GameObjects.Systems; using Robust.Shared.Input; @@ -18,7 +18,6 @@ using Robust.Shared.Input.Binding; using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Log; -using Robust.Shared.Maths; using Robust.Shared.Prototypes; namespace Content.Client.GameObjects.EntitySystems @@ -30,12 +29,9 @@ namespace Content.Client.GameObjects.EntitySystems public class DragDropSystem : EntitySystem { [Dependency] private readonly IStateManager _stateManager = default!; - [Dependency] private readonly IInputManager _inputManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - // drag will be triggered when mouse leaves this deadzone around the click position. - private const float DragDeadzone = 2f; // how often to recheck possible targets (prevents calling expensive // check logic each update) private const float TargetRecheckInterval = 0.25f; @@ -50,14 +46,10 @@ namespace Content.Client.GameObjects.EntitySystems // entity performing the drag action private IEntity _dragger; - private IEntity _draggedEntity; private readonly List _draggables = new(); private IEntity _dragShadow; - private DragState _state; // time since mouse down over the dragged entity private float _mouseDownTime; - // screen pos where the mouse down began - private Vector2 _mouseDownScreenPos; // how much time since last recheck of all possible targets private float _targetRecheckTime; // reserved initial mousedown event so we can replay it if no drag ends up being performed @@ -66,6 +58,8 @@ namespace Content.Client.GameObjects.EntitySystems // can ignore any events sent to this system private bool _isReplaying; + private DragDropHelper _dragDropHelper; + private ShaderInstance _dropTargetInRangeShader; private ShaderInstance _dropTargetOutOfRangeShader; private SharedInteractionSystem _interactionSystem; @@ -73,20 +67,9 @@ namespace Content.Client.GameObjects.EntitySystems private readonly List _highlightedSprites = new(); - private enum DragState : byte - { - NotDragging, - // not dragging yet, waiting to see - // if they hold for long enough - MouseDown, - // currently dragging something - Dragging, - } - - public override void Initialize() { - _state = DragState.NotDragging; + _dragDropHelper = new DragDropHelper(OnBeginDrag, OnContinueDrag, OnEndDrag); _dropTargetInRangeShader = _prototypeManager.Index(ShaderDropTargetInRange).Instance(); _dropTargetOutOfRangeShader = _prototypeManager.Index(ShaderDropTargetOutOfRange).Instance(); @@ -101,7 +84,7 @@ namespace Content.Client.GameObjects.EntitySystems public override void Shutdown() { - CancelDrag(false, null); + _dragDropHelper.EndDrag(); CommandBinds.Unregister(); base.Shutdown(); } @@ -132,7 +115,7 @@ namespace Content.Client.GameObjects.EntitySystems var dragger = args.Session.AttachedEntity; // cancel any current dragging if there is one (shouldn't be because they would've had to have lifted // the mouse, canceling the drag, but just being cautious) - CancelDrag(false, null); + _dragDropHelper.EndDrag(); // possibly initiating a drag // check if the clicked entity is draggable @@ -150,96 +133,43 @@ namespace Content.Client.GameObjects.EntitySystems var dragEventArgs = new StartDragDropEventArgs(args.Session.AttachedEntity, entity); if (draggable.CanStartDrag(dragEventArgs)) { - // wait to initiate a drag - _dragger = dragger; - _draggedEntity = entity; _draggables.Add(draggable); - _mouseDownTime = 0; - _state = DragState.MouseDown; - _mouseDownScreenPos = _inputManager.MouseScreenPosition; - // don't want anything else to process the click, - // but we will save the event so we can "re-play" it if this drag does - // not turn into an actual drag so the click can be handled normally - _savedMouseDown = args; canDrag = true; } } + if (canDrag) + { + // wait to initiate a drag + _dragDropHelper.MouseDown(entity); + _dragger = dragger; + _mouseDownTime = 0; + // don't want anything else to process the click, + // but we will save the event so we can "re-play" it if this drag does + // not turn into an actual drag so the click can be handled normally + _savedMouseDown = args; + } + return canDrag; } return false; } - private bool OnUseMouseUp(in PointerInputCmdHandler.PointerInputCmdArgs args) + + private bool OnBeginDrag() { - if (_state == DragState.MouseDown) + if (_dragDropHelper.Dragged == null || _dragDropHelper.Dragged.Deleted) { - // quick mouseup, definitely treat it as a normal click by - // replaying the original - CancelDrag(true, args.OriginalMessage); - return false; - } - if (_state != DragState.Dragging) return false; - - // remaining CancelDrag calls will not replay the click because - // by this time we've determined the input was actually a drag attempt - - - // tell the server we are dropping if we are over a valid drop target in range. - // We don't use args.EntityUid here because drag interactions generally should - // work even if there's something "on top" of the drop target - if (!_interactionSystem.InRangeUnobstructed(_dragger, - args.Coordinates, ignoreInsideBlocker: true)) - { - CancelDrag(false, null); + // something happened to the clicked entity or we moved the mouse off the target so + // we shouldn't replay the original click return false; } - var entities = GameScreenBase.GetEntitiesUnderPosition(_stateManager, args.Coordinates); - - foreach (var entity in entities) + if (_dragDropHelper.Dragged.TryGetComponent(out var draggedSprite)) { - // check if it's able to be dropped on by current dragged entity - var dropArgs = new DragDropEventArgs(_dragger, args.Coordinates, _draggedEntity, entity); - - foreach (var draggable in _draggables) - { - if (!draggable.CanDrop(dropArgs)) - { - continue; - } - - // tell the server about the drop attempt - RaiseNetworkEvent(new DragDropMessage(args.Coordinates, _draggedEntity.Uid, - entity.Uid)); - - draggable.Drop(dropArgs); - - CancelDrag(false, null); - return true; - } - } - - CancelDrag(false, null); - return false; - } - - private void StartDragging() - { - // this is checked elsewhere but adding this as a failsafe - if (_draggedEntity == null || _draggedEntity.Deleted) - { - Logger.Error("Programming error. Cannot initiate drag, no dragged entity or entity" + - " was deleted."); - return; - } - - if (_draggedEntity.TryGetComponent(out var draggedSprite)) - { - _state = DragState.Dragging; // pop up drag shadow under mouse - var mousePos = _eyeManager.ScreenToMap(_inputManager.MouseScreenPosition); + var mousePos = _eyeManager.ScreenToMap(_dragDropHelper.MouseScreenPosition); _dragShadow = EntityManager.SpawnEntity("dragshadow", mousePos); var dragSprite = _dragShadow.GetComponent(); dragSprite.CopyFrom(draggedSprite); @@ -249,22 +179,132 @@ namespace Content.Client.GameObjects.EntitySystems dragSprite.DrawDepth = (int) DrawDepth.Overlays; if (dragSprite.Directional) { - _dragShadow.Transform.WorldRotation = _draggedEntity.Transform.WorldRotation; + _dragShadow.Transform.WorldRotation = _dragDropHelper.Dragged.Transform.WorldRotation; } HighlightTargets(); + + // drag initiated + return true; } - else + + Logger.Warning("Unable to display drag shadow for {0} because it" + + " has no sprite component.", _dragDropHelper.Dragged.Name); + return false; + } + + private bool OnContinueDrag(float frameTime) + { + if (_dragDropHelper.Dragged == null || _dragDropHelper.Dragged.Deleted) { - Logger.Warning("Unable to display drag shadow for {0} because it" + - " has no sprite component.", _draggedEntity.Name); + return false; } + // still in range of the thing we are dragging? + if (!_interactionSystem.InRangeUnobstructed(_dragger, _dragDropHelper.Dragged)) + { + return false; + } + + // keep dragged entity under mouse + var mousePos = _eyeManager.ScreenToMap(_dragDropHelper.MouseScreenPosition); + // TODO: would use MapPosition instead if it had a setter, but it has no setter. + // is that intentional, or should we add a setter for Transform.MapPosition? + _dragShadow.Transform.WorldPosition = mousePos.Position; + + _targetRecheckTime += frameTime; + if (_targetRecheckTime > TargetRecheckInterval) + { + HighlightTargets(); + _targetRecheckTime = 0; + } + + return true; + } + + private void OnEndDrag() + { + RemoveHighlights(); + if (_dragShadow != null) + { + EntityManager.DeleteEntity(_dragShadow); + } + + _dragShadow = null; + _draggables.Clear(); + _dragger = null; + _mouseDownTime = 0; + _savedMouseDown = null; + } + + private bool OnUseMouseUp(in PointerInputCmdHandler.PointerInputCmdArgs args) + { + if (!_dragDropHelper.IsDragging) + { + // haven't started the drag yet, quick mouseup, definitely treat it as a normal click by + // replaying the original cmd + if (_savedMouseDown.HasValue && _mouseDownTime < MaxMouseDownTimeForReplayingClick) + { + var savedValue = _savedMouseDown.Value; + _isReplaying = true; + // adjust the timing info based on the current tick so it appears as if it happened now + var replayMsg = savedValue.OriginalMessage; + var adjustedInputMsg = new FullInputCmdMessage(args.OriginalMessage.Tick, args.OriginalMessage.SubTick, + replayMsg.InputFunctionId, replayMsg.State, replayMsg.Coordinates, replayMsg.ScreenCoordinates, replayMsg.Uid); + + _inputSystem.HandleInputCommand(savedValue.Session, EngineKeyFunctions.Use, + adjustedInputMsg, true); + _isReplaying = false; + } + _dragDropHelper.EndDrag(); + return false; + } + + // now when ending the drag, we will not replay the click because + // by this time we've determined the input was actually a drag attempt + + // tell the server we are dropping if we are over a valid drop target in range. + // We don't use args.EntityUid here because drag interactions generally should + // work even if there's something "on top" of the drop target + if (!_interactionSystem.InRangeUnobstructed(_dragger, + args.Coordinates, ignoreInsideBlocker: true)) + { + _dragDropHelper.EndDrag(); + return false; + } + + var entities = GameScreenBase.GetEntitiesUnderPosition(_stateManager, args.Coordinates); + + foreach (var entity in entities) + { + // check if it's able to be dropped on by current dragged entity + var dropArgs = new DragDropEventArgs(_dragger, args.Coordinates, _dragDropHelper.Dragged, entity); + + foreach (var draggable in _draggables) + { + if (!draggable.CanDrop(dropArgs)) + { + continue; + } + + // tell the server about the drop attempt + RaiseNetworkEvent(new DragDropMessage(args.Coordinates, _dragDropHelper.Dragged.Uid, + entity.Uid)); + + draggable.Drop(dropArgs); + + _dragDropHelper.EndDrag(); + return true; + } + } + + _dragDropHelper.EndDrag(); + return false; } private void HighlightTargets() { - if (_state != DragState.Dragging || _draggedEntity == null || - _draggedEntity.Deleted || _dragShadow == null || _dragShadow.Deleted) + if (_dragDropHelper.Dragged == null || + _dragDropHelper.Dragged.Deleted || _dragShadow == null || _dragShadow.Deleted) { Logger.Warning("Programming error. Can't highlight drag and drop targets, not currently " + "dragging anything or dragged entity / shadow was deleted."); @@ -289,7 +329,7 @@ namespace Content.Client.GameObjects.EntitySystems if (inRangeSprite.Visible == false) continue; // check if it's able to be dropped on by current dragged entity - var canDropArgs = new CanDropEventArgs(_dragger, _draggedEntity, pvsEntity); + var canDropArgs = new CanDropEventArgs(_dragger, _dragDropHelper.Dragged, pvsEntity); var anyValidDraggable = _draggables.Any(draggable => draggable.CanDrop(canDropArgs)); if (anyValidDraggable) @@ -314,95 +354,10 @@ namespace Content.Client.GameObjects.EntitySystems _highlightedSprites.Clear(); } - /// - /// Cancels the drag, firing our saved drag event if instructed to do so and - /// we are within the threshold for replaying the click - /// (essentially reverting the drag attempt and allowing the original click - /// to proceed as if no drag was performed) - /// - /// if fireSavedCmd is true, this should be passed with the value of - /// the pointer cmd that caused the drag to be cancelled - private void CancelDrag(bool fireSavedCmd, FullInputCmdMessage cause) - { - RemoveHighlights(); - if (_dragShadow != null) - { - EntityManager.DeleteEntity(_dragShadow); - } - - _dragShadow = null; - _draggedEntity = null; - _draggables.Clear(); - _dragger = null; - _state = DragState.NotDragging; - - _mouseDownTime = 0; - - if (fireSavedCmd && _savedMouseDown.HasValue && _mouseDownTime < MaxMouseDownTimeForReplayingClick) - { - var savedValue = _savedMouseDown.Value; - _isReplaying = true; - // adjust the timing info based on the current tick so it appears as if it happened now - var replayMsg = savedValue.OriginalMessage; - var adjustedInputMsg = new FullInputCmdMessage(cause.Tick, cause.SubTick, replayMsg.InputFunctionId, replayMsg.State, replayMsg.Coordinates, replayMsg.ScreenCoordinates, replayMsg.Uid); - - _inputSystem.HandleInputCommand(savedValue.Session, EngineKeyFunctions.Use, - adjustedInputMsg, true); - _isReplaying = false; - } - - _savedMouseDown = null; - - } - public override void Update(float frameTime) { base.Update(frameTime); - if (_state == DragState.MouseDown) - { - var screenPos = _inputManager.MouseScreenPosition; - if (_draggedEntity == null || _draggedEntity.Deleted) - { - // something happened to the clicked entity or we moved the mouse off the target so - // we shouldn't replay the original click - CancelDrag(false, null); - return; - } - else if ((_mouseDownScreenPos - screenPos).Length > DragDeadzone) - { - // initiate actual drag - StartDragging(); - _mouseDownTime = 0; - } - } - else if (_state == DragState.Dragging) - { - if (_draggedEntity == null || _draggedEntity.Deleted) - { - CancelDrag(false, null); - return; - } - // still in range of the thing we are dragging? - if (!_interactionSystem.InRangeUnobstructed(_dragger, _draggedEntity)) - { - CancelDrag(false, null); - return; - } - - // keep dragged entity under mouse - var mousePos = _eyeManager.ScreenToMap(_inputManager.MouseScreenPosition); - // TODO: would use MapPosition instead if it had a setter, but it has no setter. - // is that intentional, or should we add a setter for Transform.MapPosition? - _dragShadow.Transform.WorldPosition = mousePos.Position; - - _targetRecheckTime += frameTime; - if (_targetRecheckTime > TargetRecheckInterval) - { - HighlightTargets(); - _targetRecheckTime = 0; - } - - } + _dragDropHelper.Update(frameTime); } } } diff --git a/Content.Client/IgnoredComponents.cs b/Content.Client/IgnoredComponents.cs index 0cb7efa887..f61fac1c5f 100644 --- a/Content.Client/IgnoredComponents.cs +++ b/Content.Client/IgnoredComponents.cs @@ -210,6 +210,8 @@ "CrematoriumEntityStorage", "RandomArcade", "RandomSpriteState", + "DebugEquip", + "InnateActions", "ReagentGrinder", "Grindable", "Juiceable", diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index fcd0be1519..c10d16857f 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -46,6 +46,17 @@ namespace Content.Client.Input human.AddFunction(ContentKeyFunctions.Arcade1); human.AddFunction(ContentKeyFunctions.Arcade2); human.AddFunction(ContentKeyFunctions.Arcade3); + human.AddFunction(ContentKeyFunctions.OpenActionsMenu); + human.AddFunction(ContentKeyFunctions.Hotbar0); + human.AddFunction(ContentKeyFunctions.Hotbar1); + human.AddFunction(ContentKeyFunctions.Hotbar2); + human.AddFunction(ContentKeyFunctions.Hotbar3); + human.AddFunction(ContentKeyFunctions.Hotbar4); + human.AddFunction(ContentKeyFunctions.Hotbar5); + human.AddFunction(ContentKeyFunctions.Hotbar6); + human.AddFunction(ContentKeyFunctions.Hotbar7); + human.AddFunction(ContentKeyFunctions.Hotbar8); + human.AddFunction(ContentKeyFunctions.Hotbar9); var ghost = contexts.New("ghost", "common"); ghost.AddFunction(EngineKeyFunctions.MoveUp); diff --git a/Content.Client/UserInterface/ActionAlertTooltip.cs b/Content.Client/UserInterface/ActionAlertTooltip.cs new file mode 100644 index 0000000000..07d081eee4 --- /dev/null +++ b/Content.Client/UserInterface/ActionAlertTooltip.cs @@ -0,0 +1,100 @@ +#nullable enable + +using System; +using Content.Client.UserInterface.Stylesheets; +using Content.Shared.Actions; +using Robust.Client.UserInterface.Controls; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface +{ + /// + /// Tooltip for actions or alerts because they are very similar. + /// + public class ActionAlertTooltip : PanelContainer + { + private const float TooltipTextMaxWidth = 350; + + private readonly RichTextLabel _cooldownLabel; + private readonly IGameTiming _gameTiming; + + /// + /// Current cooldown displayed in this tooltip. Set to null to show no cooldown. + /// + public (TimeSpan Start, TimeSpan End)? Cooldown { get; set; } + + public ActionAlertTooltip(FormattedMessage name, FormattedMessage? desc, string? requires = null) + { + _gameTiming = IoCManager.Resolve(); + + SetOnlyStyleClass(StyleNano.StyleClassTooltipPanel); + + VBoxContainer vbox; + AddChild(vbox = new VBoxContainer {RectClipContent = true}); + var nameLabel = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = {StyleNano.StyleClassTooltipActionTitle} + }; + nameLabel.SetMessage(name); + vbox.AddChild(nameLabel); + + if (desc != null && !string.IsNullOrWhiteSpace(desc.ToString())) + { + var description = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = {StyleNano.StyleClassTooltipActionDescription} + }; + description.SetMessage(desc); + vbox.AddChild(description); + } + + vbox.AddChild(_cooldownLabel = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = {StyleNano.StyleClassTooltipActionCooldown}, + Visible = false + }); + + if (!string.IsNullOrWhiteSpace(requires)) + { + var requiresLabel = new RichTextLabel + { + MaxWidth = TooltipTextMaxWidth, + StyleClasses = {StyleNano.StyleClassTooltipActionRequirements} + }; + requiresLabel.SetMessage(FormattedMessage.FromMarkup("[color=#635c5c]" + + requires + + "[/color]")); + vbox.AddChild(requiresLabel); + } + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + if (!Cooldown.HasValue) + { + _cooldownLabel.Visible = false; + return; + } + + var timeLeft = Cooldown.Value.End - _gameTiming.CurTime; + if (timeLeft > TimeSpan.Zero) + { + var duration = Cooldown.Value.End - Cooldown.Value.Start; + _cooldownLabel.SetMessage(FormattedMessage.FromMarkup( + $"[color=#a10505]{duration.Seconds} sec cooldown ({timeLeft.Seconds + 1} sec remaining)[/color]")); + _cooldownLabel.Visible = true; + } + else + { + _cooldownLabel.Visible = false; + } + } + } +} diff --git a/Content.Client/UserInterface/ActionMenu.cs b/Content.Client/UserInterface/ActionMenu.cs new file mode 100644 index 0000000000..07973f9470 --- /dev/null +++ b/Content.Client/UserInterface/ActionMenu.cs @@ -0,0 +1,499 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.GameObjects.Components.Mobs.Actions; +using Content.Client.UserInterface.Controls; +using Content.Client.UserInterface.Stylesheets; +using Content.Client.Utility; +using Content.Shared.Actions; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Client.Utility; +using Robust.Shared.Input; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Log; +using Robust.Shared.Timing; + +namespace Content.Client.UserInterface +{ + /// + /// Action selection menu, allows filtering and searching over all possible + /// actions and populating those actions into the hotbar. + /// + public class ActionMenu : SS14Window + { + private const string ItemTag = "item"; + private const string NotItemTag = "not item"; + private const string InstantActionTag = "instant"; + private const string ToggleActionTag = "toggle"; + private const string TargetActionTag = "target"; + private const string AllActionsTag = "all"; + private const string GrantedActionsTag = "granted"; + private const int MinSearchLength = 3; + private static readonly Regex NonAlphanumeric = new Regex(@"\W", RegexOptions.Compiled); + private static readonly Regex Whitespace = new Regex(@"\s+", RegexOptions.Compiled); + private static readonly BaseActionPrototype[] EmptyActionList = Array.Empty(); + + // parallel list of actions currently selectable in itemList + private BaseActionPrototype[] _actionList; + + private readonly ActionManager _actionManager; + private readonly ClientActionsComponent _actionsComponent; + private readonly ActionsUI _actionsUI; + private readonly LineEdit _searchBar; + private readonly MultiselectOptionButton _filterButton; + private readonly Label _filterLabel; + private readonly Button _clearButton; + private readonly GridContainer _resultsGrid; + private readonly TextureRect _dragShadow; + private readonly DragDropHelper _dragDropHelper; + + + public ActionMenu(ClientActionsComponent actionsComponent, ActionsUI actionsUI) + { + _actionsComponent = actionsComponent; + _actionsUI = actionsUI; + _actionManager = IoCManager.Resolve(); + Title = Loc.GetString("Actions"); + CustomMinimumSize = (300, 300); + + Contents.AddChild(new VBoxContainer + { + Children = + { + new HBoxContainer + { + Children = + { + (_searchBar = new LineEdit + { + StyleClasses = { StyleNano.StyleClassActionSearchBox }, + SizeFlagsHorizontal = SizeFlags.FillExpand, + PlaceHolder = Loc.GetString("Search") + }), + (_filterButton = new MultiselectOptionButton() + { + Label = Loc.GetString("Filter") + }), + } + }, + (_clearButton = new Button + { + Text = Loc.GetString("Clear"), + }), + (_filterLabel = new Label()), + new ScrollContainer + { + //TODO: needed? CustomMinimumSize = new Vector2(200.0f, 0.0f), + SizeFlagsVertical = SizeFlags.FillExpand, + SizeFlagsHorizontal = SizeFlags.FillExpand, + Children = + { + (_resultsGrid = new GridContainer + { + MaxWidth = 300 + }) + } + } + } + }); + + // populate filters from search tags + var filterTags = new List(); + foreach (var action in _actionManager.EnumerateActions()) + { + filterTags.AddRange(action.Filters); + } + + // special one to filter to only include item actions + filterTags.Add(ItemTag); + filterTags.Add(NotItemTag); + filterTags.Add(InstantActionTag); + filterTags.Add(ToggleActionTag); + filterTags.Add(TargetActionTag); + filterTags.Add(AllActionsTag); + filterTags.Add(GrantedActionsTag); + + foreach (var tag in filterTags.Distinct().OrderBy(tag => tag)) + { + _filterButton.AddItem( CultureInfo.CurrentCulture.TextInfo.ToTitleCase(tag), tag); + } + + UpdateFilterLabel(); + + _dragShadow = new TextureRect + { + CustomMinimumSize = (64, 64), + Stretch = TextureRect.StretchMode.Scale, + Visible = false + }; + UserInterfaceManager.PopupRoot.AddChild(_dragShadow); + LayoutContainer.SetSize(_dragShadow, (64, 64)); + + _dragDropHelper = new DragDropHelper(OnBeginActionDrag, OnContinueActionDrag, OnEndActionDrag); + } + + + protected override void EnteredTree() + { + base.EnteredTree(); + _clearButton.OnPressed += OnClearButtonPressed; + _searchBar.OnTextChanged += OnSearchTextChanged; + _filterButton.OnItemSelected += OnFilterItemSelected; + + foreach (var actionMenuControl in _resultsGrid.Children) + { + var actionMenuItem = (actionMenuControl as ActionMenuItem); + actionMenuItem.OnButtonDown += OnItemButtonDown; + actionMenuItem.OnButtonUp += OnItemButtonUp; + actionMenuItem.OnPressed += OnItemPressed; + } + } + + protected override void ExitedTree() + { + base.ExitedTree(); + _clearButton.OnPressed -= OnClearButtonPressed; + _searchBar.OnTextChanged -= OnSearchTextChanged; + _filterButton.OnItemSelected -= OnFilterItemSelected; + + foreach (var actionMenuControl in _resultsGrid.Children) + { + var actionMenuItem = (actionMenuControl as ActionMenuItem); + actionMenuItem.OnButtonDown -= OnItemButtonDown; + actionMenuItem.OnButtonUp -= OnItemButtonUp; + actionMenuItem.OnPressed -= OnItemPressed; + } + } + + private void OnFilterItemSelected(MultiselectOptionButton.ItemPressedEventArgs args) + { + UpdateFilterLabel(); + SearchAndDisplay(); + } + + protected override void Resized() + { + base.Resized(); + // TODO: Can rework this once https://github.com/space-wizards/RobustToolbox/issues/1392 is done, + // currently no good way to let the grid know what size it has to "work with", so must manually resize + _resultsGrid.MaxWidth = Width; + } + + private bool OnBeginActionDrag() + { + _dragShadow.Texture = _dragDropHelper.Dragged.Action.Icon.Frame0(); + // don't make visible until frameupdate, otherwise it'll flicker + LayoutContainer.SetPosition(_dragShadow, UserInterfaceManager.MousePositionScaled - (32, 32)); + return true; + } + + private bool OnContinueActionDrag(float frameTime) + { + // keep dragged entity centered under mouse + LayoutContainer.SetPosition(_dragShadow, UserInterfaceManager.MousePositionScaled - (32, 32)); + // we don't set this visible until frameupdate, otherwise it flickers + _dragShadow.Visible = true; + return true; + } + + private void OnEndActionDrag() + { + _dragShadow.Visible = false; + } + + private void OnItemButtonDown(BaseButton.ButtonEventArgs args) + { + if (args.Event.Function != EngineKeyFunctions.UIClick) return; + _dragDropHelper.MouseDown(args.Button as ActionMenuItem); + } + + private void OnItemButtonUp(BaseButton.ButtonEventArgs args) + { + // note the buttonup only fires on the control that was originally + // pressed to initiate the drag, NOT the one we are currently hovering + if (args.Event.Function != EngineKeyFunctions.UIClick) return; + + if (UserInterfaceManager.CurrentlyHovered is ActionSlot targetSlot) + { + if (!_dragDropHelper.IsDragging || _dragDropHelper.Dragged?.Action == null) + { + _dragDropHelper.EndDrag(); + return; + } + + // drag and drop + switch (_dragDropHelper.Dragged.Action) + { + // assign the dragged action to the target slot + case ActionPrototype actionPrototype: + _actionsComponent.Assignments.AssignSlot(_actionsUI.SelectedHotbar, targetSlot.SlotIndex, ActionAssignment.For(actionPrototype.ActionType)); + break; + case ItemActionPrototype itemActionPrototype: + // the action menu doesn't show us if the action has an associated item, + // so when we perform the assignment, we should check if we currently have an unassigned state + // for this item and assign it tied to that item if so, otherwise assign it "itemless" + + // this is not particularly efficient but we don't maintain an index from + // item action type to its action states, and this method should be pretty infrequent so it's probably fine + var assigned = false; + foreach (var (item, itemStates) in _actionsComponent.ItemActionStates()) + { + foreach (var (actionType, _) in itemStates) + { + if (actionType != itemActionPrototype.ActionType) continue; + var assignment = ActionAssignment.For(actionType, item); + if (_actionsComponent.Assignments.HasAssignment(assignment)) continue; + // no assignment for this state, assign tied to the item + assigned = true; + _actionsComponent.Assignments.AssignSlot(_actionsUI.SelectedHotbar, targetSlot.SlotIndex, assignment); + break; + } + + if (assigned) + { + break; + } + } + + if (!assigned) + { + _actionsComponent.Assignments.AssignSlot(_actionsUI.SelectedHotbar, targetSlot.SlotIndex, ActionAssignment.For(itemActionPrototype.ActionType)); + } + break; + } + + _actionsUI.UpdateUI(); + } + + _dragDropHelper.EndDrag(); + } + + private void OnItemPressed(BaseButton.ButtonEventArgs args) + { + if (args.Button is not ActionMenuItem actionMenuItem) return; + switch (actionMenuItem.Action) + { + case ActionPrototype actionPrototype: + _actionsComponent.Assignments.AutoPopulate(ActionAssignment.For(actionPrototype.ActionType), _actionsUI.SelectedHotbar); + break; + case ItemActionPrototype itemActionPrototype: + _actionsComponent.Assignments.AutoPopulate(ActionAssignment.For(itemActionPrototype.ActionType), _actionsUI.SelectedHotbar); + break; + default: + Logger.ErrorS("action", "unexpected action prototype {0}", actionMenuItem.Action); + break; + } + + _actionsUI.UpdateUI(); + } + + private void OnClearButtonPressed(BaseButton.ButtonEventArgs args) + { + _searchBar.Clear(); + _filterButton.DeselectAll(); + UpdateFilterLabel(); + SearchAndDisplay(); + } + + private void OnSearchTextChanged(LineEdit.LineEditEventArgs obj) + { + SearchAndDisplay(); + } + + private void SearchAndDisplay() + { + var search = Standardize(_searchBar.Text); + // only display nothing if there are no filters selected and text is not long enough. + // otherwise we will search if even one filter is applied, regardless of length of search string + if (_filterButton.SelectedKeys.Count == 0 && + (string.IsNullOrWhiteSpace(search) || search.Length < MinSearchLength)) + { + ClearList(); + return; + } + + var matchingActions = _actionManager.EnumerateActions() + .Where(a => MatchesSearchCriteria(a, search, _filterButton.SelectedKeys)); + + PopulateActions(matchingActions); + } + + private void UpdateFilterLabel() + { + if (_filterButton.SelectedKeys.Count == 0) + { + _filterLabel.Visible = false; + } + else + { + _filterLabel.Visible = true; + _filterLabel.Text = Loc.GetString("Filters: {0}", string.Join(", ", _filterButton.SelectedLabels)); + } + } + + private bool MatchesSearchCriteria(BaseActionPrototype action, string standardizedSearch, + IReadOnlyList selectedFilterTags) + { + // check filter tag match first - each action must contain all filter tags currently selected. + // if no tags selected, don't check tags + if (selectedFilterTags.Count > 0 && selectedFilterTags.Any(filterTag => !ActionMatchesFilterTag(action, filterTag))) + { + return false; + } + + // check search tag match against the search query + if (action.Keywords.Any(standardizedSearch.Contains)) + { + return true; + } + + if (Standardize(ActionTypeString(action)).Contains(standardizedSearch)) + { + return true; + } + + // allows matching by typing spaces between the enum case changes, like "xeno spit" if the + // actiontype is "XenoSpit" + if (Standardize(ActionTypeString(action), true).Contains(standardizedSearch)) + { + return true; + } + + if (Standardize(action.Name.ToString()).Contains(standardizedSearch)) + { + return true; + } + + return false; + + } + + private string ActionTypeString(BaseActionPrototype baseActionPrototype) + { + if (baseActionPrototype is ActionPrototype actionPrototype) + { + return actionPrototype.ActionType.ToString(); + } + if (baseActionPrototype is ItemActionPrototype itemActionPrototype) + { + return itemActionPrototype.ActionType.ToString(); + } + throw new InvalidOperationException(); + } + + private bool ActionMatchesFilterTag(BaseActionPrototype action, string tag) + { + return tag switch + { + AllActionsTag => true, + GrantedActionsTag => _actionsComponent.IsGranted(action), + ItemTag => action is ItemActionPrototype, + NotItemTag => action is ActionPrototype, + InstantActionTag => action.BehaviorType == BehaviorType.Instant, + TargetActionTag => action.BehaviorType == BehaviorType.TargetEntity || + action.BehaviorType == BehaviorType.TargetPoint, + ToggleActionTag => action.BehaviorType == BehaviorType.Toggle, + _ => action.Filters.Contains(tag) + }; + } + + + /// + /// Standardized form is all lowercase, no non-alphanumeric characters (converted to whitespace), + /// trimmed, 1 space max per whitespace gap, + /// and optional spaces between case change + /// + private static string Standardize(string rawText, bool splitOnCaseChange = false) + { + rawText ??= ""; + + // treat non-alphanumeric characters as whitespace + rawText = NonAlphanumeric.Replace(rawText, " "); + + // trim spaces and reduce internal whitespaces to 1 max + rawText = Whitespace.Replace(rawText, " ").Trim(); + if (splitOnCaseChange) + { + // insert a space when case switches from lower to upper + rawText = AddSpaces(rawText, true); + } + + return rawText.ToLowerInvariant().Trim(); + } + + // taken from https://stackoverflow.com/a/272929 (CC BY-SA 3.0) + private static string AddSpaces(string text, bool preserveAcronyms) + { + if (string.IsNullOrWhiteSpace(text)) + return string.Empty; + var newText = new StringBuilder(text.Length * 2); + newText.Append(text[0]); + for (var i = 1; i < text.Length; i++) + { + if (char.IsUpper(text[i])) + { + if ((text[i - 1] != ' ' && !char.IsUpper(text[i - 1])) || + (preserveAcronyms && char.IsUpper(text[i - 1]) && + i < text.Length - 1 && !char.IsUpper(text[i + 1]))) + newText.Append(' '); + } + + newText.Append(text[i]); + } + return newText.ToString(); + } + + private void PopulateActions(IEnumerable actions) + { + ClearList(); + + _actionList = actions.ToArray(); + foreach (var action in _actionList.OrderBy(act => act.Name.ToString())) + { + var actionItem = new ActionMenuItem(action); + _resultsGrid.Children.Add(actionItem); + actionItem.SetActionState(_actionsComponent.IsGranted(action)); + + actionItem.OnButtonDown += OnItemButtonDown; + actionItem.OnButtonUp += OnItemButtonUp; + actionItem.OnPressed += OnItemPressed; + } + } + + private void ClearList() + { + // TODO: Not sure if this unsub is needed if children are all being cleared + foreach (var actionItem in _resultsGrid.Children) + { + ((ActionMenuItem) actionItem).OnPressed -= OnItemPressed; + } + _resultsGrid.Children.Clear(); + _actionList = EmptyActionList; + } + + /// + /// Should be invoked when action states change, ensures + /// currently displayed actions are properly showing their revoked / granted status + /// + public void UpdateUI() + { + foreach (var actionItem in _resultsGrid.Children) + { + var actionMenuItem = ((ActionMenuItem) actionItem); + actionMenuItem.SetActionState(_actionsComponent.IsGranted(actionMenuItem.Action)); + } + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.Update(args); + _dragDropHelper.Update(args.DeltaSeconds); + } + } +} diff --git a/Content.Client/UserInterface/ActionMenuItem.cs b/Content.Client/UserInterface/ActionMenuItem.cs new file mode 100644 index 0000000000..d678847c5e --- /dev/null +++ b/Content.Client/UserInterface/ActionMenuItem.cs @@ -0,0 +1,68 @@ +#nullable enable + +using Content.Client.UserInterface.Stylesheets; +using Content.Shared.Actions; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.Utility; + +namespace Content.Client.UserInterface +{ + /// + /// An individual action visible in the action menu. + /// + public class ActionMenuItem : ContainerButton + { + // shorter than default tooltip delay so user can + // quickly explore what each action is + private const float CustomTooltipDelay = 0.2f; + + public BaseActionPrototype Action { get; private set; } + + public ActionMenuItem(BaseActionPrototype action) + { + Action = action; + + CustomMinimumSize = (64, 64); + SizeFlagsVertical = SizeFlags.None; + + AddChild(new TextureRect + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + SizeFlagsVertical = SizeFlags.FillExpand, + Stretch = TextureRect.StretchMode.Scale, + Texture = action.Icon.Frame0() + }); + + TooltipDelay = CustomTooltipDelay; + TooltipSupplier = SupplyTooltip; + } + + private Control SupplyTooltip(Control? sender) + { + return new ActionAlertTooltip(Action.Name, Action.Description, Action.Requires); + } + + /// + /// Change how this is displayed depending on if it is granted or revoked + /// + public void SetActionState(bool granted) + { + if (granted) + { + if (HasStyleClass(StyleNano.StyleClassActionMenuItemRevoked)) + { + RemoveStyleClass(StyleNano.StyleClassActionMenuItemRevoked); + } + } + else + { + if (!HasStyleClass(StyleNano.StyleClassActionMenuItemRevoked)) + { + AddStyleClass(StyleNano.StyleClassActionMenuItemRevoked); + } + } + } + + } +} diff --git a/Content.Client/UserInterface/ActionsUI.cs b/Content.Client/UserInterface/ActionsUI.cs new file mode 100644 index 0000000000..7f5698a16d --- /dev/null +++ b/Content.Client/UserInterface/ActionsUI.cs @@ -0,0 +1,556 @@ +#nullable enable +using System.Collections.Generic; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.GameObjects.Components.Mobs.Actions; +using Content.Client.UserInterface.Controls; +using Content.Client.UserInterface.Stylesheets; +using Content.Client.Utility; +using Content.Shared.Actions; +using Robust.Client.Graphics; +using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.Utility; +using Robust.Shared.GameObjects; +using Robust.Shared.Input; +using Robust.Shared.Input.Binding; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Maths; +using Robust.Shared.Timing; + +namespace Content.Client.UserInterface +{ + /// + /// The action hotbar on the left side of the screen. + /// + public sealed class ActionsUI : Container + { + private readonly ClientActionsComponent _actionsComponent; + private readonly ActionManager _actionManager; + private readonly IEntityManager _entityManager; + private readonly IGameTiming _gameTiming; + + private readonly ActionSlot[] _slots; + + private readonly GridContainer _slotContainer; + + private readonly TextureButton _lockButton; + private readonly TextureButton _settingsButton; + private readonly TextureButton _previousHotbarButton; + private readonly Label _loadoutNumber; + private readonly TextureButton _nextHotbarButton; + private readonly Texture _lockTexture; + private readonly Texture _unlockTexture; + + private readonly TextureRect _dragShadow; + + private readonly ActionMenu _menu; + + /// + /// Index of currently selected hotbar + /// + public byte SelectedHotbar { get; private set; } + + /// + /// Action slot we are currently selecting a target for. + /// + public ActionSlot? SelectingTargetFor { get; private set; } + + /// + /// Drag drop helper for coordinating drag drops between action slots + /// + public DragDropHelper DragDropHelper { get; } + + /// + /// Whether the bar is currently locked by the user. This is intended to prevent drag / drop + /// and right click clearing slots. Anything else is still doable. + /// + public bool Locked { get; private set; } + + /// + /// All the action slots in order. + /// + public IEnumerable Slots => _slots; + + public ActionsUI(ClientActionsComponent actionsComponent) + { + _actionsComponent = actionsComponent; + _actionManager = IoCManager.Resolve(); + _entityManager = IoCManager.Resolve(); + _gameTiming = IoCManager.Resolve(); + _menu = new ActionMenu(_actionsComponent, this); + LayoutContainer.SetGrowHorizontal(this, LayoutContainer.GrowDirection.End); + LayoutContainer.SetGrowVertical(this, LayoutContainer.GrowDirection.End); + LayoutContainer.SetAnchorTop(this, 0f); + LayoutContainer.SetAnchorBottom(this, 0.8f); + LayoutContainer.SetMarginLeft(this, 10); + LayoutContainer.SetMarginTop(this, 100); + + SizeFlagsHorizontal = SizeFlags.None; + SizeFlagsVertical = SizeFlags.FillExpand; + + var resourceCache = IoCManager.Resolve(); + + // everything needs to go within an inner panel container so the panel resizes to fit the elements. + // Because ActionsUI is being anchored by layoutcontainer, the hotbar backing would appear too tall + // if ActionsUI was the panel container + + var panelContainer = new PanelContainer() + { + StyleClasses = {StyleNano.StyleClassHotbarPanel}, + SizeFlagsHorizontal = SizeFlags.None, + SizeFlagsVertical = SizeFlags.None + }; + AddChild(panelContainer); + + var hotbarContainer = new VBoxContainer + { + SeparationOverride = 3, + SizeFlagsHorizontal = SizeFlags.None + }; + panelContainer.AddChild(hotbarContainer); + + var settingsContainer = new HBoxContainer + { + SizeFlagsHorizontal = SizeFlags.FillExpand + }; + hotbarContainer.AddChild(settingsContainer); + + settingsContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 1 }); + _lockTexture = resourceCache.GetTexture("/Textures/Interface/Nano/lock.svg.png"); + _unlockTexture = resourceCache.GetTexture("/Textures/Interface/Nano/lock_open.svg.png"); + _lockButton = new TextureButton + { + TextureNormal = _unlockTexture, + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + SizeFlagsStretchRatio = 1 + }; + settingsContainer.AddChild(_lockButton); + settingsContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 2 }); + _settingsButton = new TextureButton + { + TextureNormal = resourceCache.GetTexture("/Textures/Interface/Nano/gear.svg.png"), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + SizeFlagsStretchRatio = 1 + }; + settingsContainer.AddChild(_settingsButton); + settingsContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 1 }); + + // this allows a 2 column layout if window gets too small + _slotContainer = new GridContainer + { + MaxHeight = CalcMaxHeight() + }; + hotbarContainer.AddChild(_slotContainer); + + var loadoutContainer = new HBoxContainer + { + SizeFlagsHorizontal = SizeFlags.FillExpand + }; + hotbarContainer.AddChild(loadoutContainer); + + loadoutContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 1 }); + _previousHotbarButton = new TextureButton + { + TextureNormal = resourceCache.GetTexture("/Textures/Interface/Nano/left_arrow.svg.png"), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + SizeFlagsStretchRatio = 1 + }; + loadoutContainer.AddChild(_previousHotbarButton); + loadoutContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 2 }); + _loadoutNumber = new Label + { + Text = "1", + SizeFlagsStretchRatio = 1 + }; + loadoutContainer.AddChild(_loadoutNumber); + loadoutContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 2 }); + _nextHotbarButton = new TextureButton + { + TextureNormal = resourceCache.GetTexture("/Textures/Interface/Nano/right_arrow.svg.png"), + SizeFlagsHorizontal = SizeFlags.ShrinkCenter, + SizeFlagsVertical = SizeFlags.ShrinkCenter, + SizeFlagsStretchRatio = 1 + }; + loadoutContainer.AddChild(_nextHotbarButton); + loadoutContainer.AddChild(new Control { SizeFlagsHorizontal = SizeFlags.FillExpand, SizeFlagsStretchRatio = 1 }); + + _slots = new ActionSlot[ClientActionsComponent.Slots]; + + _dragShadow = new TextureRect + { + CustomMinimumSize = (64, 64), + Stretch = TextureRect.StretchMode.Scale, + Visible = false + }; + UserInterfaceManager.PopupRoot.AddChild(_dragShadow); + LayoutContainer.SetSize(_dragShadow, (64, 64)); + + for (byte i = 0; i < ClientActionsComponent.Slots; i++) + { + var slot = new ActionSlot(this, actionsComponent, i); + _slotContainer.AddChild(slot); + _slots[i] = slot; + } + + DragDropHelper = new DragDropHelper(OnBeginActionDrag, OnContinueActionDrag, OnEndActionDrag); + } + + protected override void EnteredTree() + { + base.EnteredTree(); + _lockButton.OnPressed += OnLockPressed; + _nextHotbarButton.OnPressed += NextHotbar; + _previousHotbarButton.OnPressed += PreviousHotbar; + _settingsButton.OnPressed += OnToggleActionsMenu; + } + + protected override void ExitedTree() + { + base.ExitedTree(); + StopTargeting(); + _menu.Close(); + _lockButton.OnPressed -= OnLockPressed; + _nextHotbarButton.OnPressed -= NextHotbar; + _previousHotbarButton.OnPressed -= PreviousHotbar; + _settingsButton.OnPressed -= OnToggleActionsMenu; + } + + protected override Vector2 CalculateMinimumSize() + { + // allows us to shrink down to a 2-column layout minimum + return (10, 400); + } + + protected override void Resized() + { + base.Resized(); + _slotContainer.MaxHeight = CalcMaxHeight(); + } + + private float CalcMaxHeight() + { + // TODO: Can rework this once https://github.com/space-wizards/RobustToolbox/issues/1392 is done, + // this is here because there isn't currently a good way to allow the grid to adjust its height based + // on constraints, otherwise we would use anchors to lay it out + + // it looks bad to have an uneven number of slots in the columns, + // so we either do a single column or 2 equal sized columns + if (Height < 650) + { + // 2 column + return 400; + } + else + { + // 1 column + return 900; + } + } + + protected override void UIScaleChanged() + { + _slotContainer.MaxHeight = CalcMaxHeight(); + base.UIScaleChanged(); + } + + /// + /// Refresh the display of all the slots in the currently displayed hotbar, + /// to reflect the current component state and assignments of actions component. + /// + public void UpdateUI() + { + _menu.UpdateUI(); + + foreach (var actionSlot in Slots) + { + var assignedActionType = _actionsComponent.Assignments[SelectedHotbar, actionSlot.SlotIndex]; + if (!assignedActionType.HasValue) + { + actionSlot.Clear(); + continue; + } + + if (assignedActionType.Value.TryGetAction(out var actionType)) + { + UpdateActionSlot(actionType, actionSlot, assignedActionType); + } + else if (assignedActionType.Value.TryGetItemActionWithoutItem(out var itemlessActionType)) + { + UpdateActionSlot(itemlessActionType, actionSlot, assignedActionType); + } + else if (assignedActionType.Value.TryGetItemActionWithItem(out var itemActionType, out var item)) + { + UpdateActionSlot(item, itemActionType, actionSlot, assignedActionType); + } + else + { + Logger.ErrorS("action", "unexpected Assignment type {0}", + assignedActionType.Value.Assignment); + actionSlot.Clear(); + } + } + } + + private void UpdateActionSlot(ActionType actionType, ActionSlot actionSlot, ActionAssignment? assignedActionType) + { + if (_actionManager.TryGet(actionType, out var action)) + { + actionSlot.Assign(action, true); + } + else + { + Logger.ErrorS("action", "unrecognized actionType {0}", assignedActionType); + actionSlot.Clear(); + return; + } + + if (!_actionsComponent.TryGetActionState(actionType, out var actionState) || !actionState.Enabled) + { + // action is currently disabled + + // just revoked an action we were trying to target with, stop targeting + if (SelectingTargetFor?.Action != null && SelectingTargetFor.Action == action) + { + StopTargeting(); + } + + actionSlot.DisableAction(); + actionSlot.Cooldown = null; + } + else + { + // action is currently granted + actionSlot.EnableAction(); + actionSlot.Cooldown = actionState.Cooldown; + + // if we are targeting with an action now on cooldown, stop targeting + if (SelectingTargetFor?.Action != null && SelectingTargetFor.Action == action && + actionState.IsOnCooldown(_gameTiming)) + { + StopTargeting(); + } + } + + // check if we need to toggle it + if (action.BehaviorType == BehaviorType.Toggle) + { + actionSlot.ToggledOn = actionState.ToggledOn; + } + } + + private void UpdateActionSlot(ItemActionType itemlessActionType, ActionSlot actionSlot, + ActionAssignment? assignedActionType) + { + if (_actionManager.TryGet(itemlessActionType, out var action)) + { + actionSlot.Assign(action); + } + else + { + Logger.ErrorS("action", "unrecognized actionType {0}", assignedActionType); + actionSlot.Clear(); + } + actionSlot.Cooldown = null; + } + + private void UpdateActionSlot(EntityUid item, ItemActionType itemActionType, ActionSlot actionSlot, + ActionAssignment? assignedActionType) + { + if (!_entityManager.TryGetEntity(item, out var itemEntity)) return; + if (_actionManager.TryGet(itemActionType, out var action)) + { + actionSlot.Assign(action, itemEntity, true); + } + else + { + Logger.ErrorS("action", "unrecognized actionType {0}", assignedActionType); + actionSlot.Clear(); + return; + } + + if (!_actionsComponent.TryGetItemActionState(itemActionType, item, out var actionState)) + { + // action is no longer tied to an item, this should never happen as we + // check this at the start of this method. But just to be safe + // we will restore our assignment here to the correct state + Logger.ErrorS("action", "coding error, expected actionType {0} to have" + + " a state but it didn't", assignedActionType); + _actionsComponent.Assignments.AssignSlot(SelectedHotbar, actionSlot.SlotIndex, + ActionAssignment.For(itemActionType)); + actionSlot.Assign(action); + return; + } + + if (!actionState.Enabled) + { + // just disabled an action we were trying to target with, stop targeting + if (SelectingTargetFor?.Action != null && SelectingTargetFor.Action == action) + { + StopTargeting(); + } + + actionSlot.DisableAction(); + } + else + { + // action is currently granted + actionSlot.EnableAction(); + + // if we are targeting with an action now on cooldown, stop targeting + if (SelectingTargetFor?.Action != null && SelectingTargetFor.Action == action && + SelectingTargetFor.Item == itemEntity && + actionState.IsOnCooldown(_gameTiming)) + { + StopTargeting(); + } + } + actionSlot.Cooldown = actionState.Cooldown; + + // check if we need to toggle it + if (action.BehaviorType == BehaviorType.Toggle) + { + actionSlot.ToggledOn = actionState.ToggledOn; + } + } + + private void NextHotbar(BaseButton.ButtonEventArgs args) + { + ChangeHotbar((byte) ((SelectedHotbar + 1) % ClientActionsComponent.Hotbars)); + } + + private void PreviousHotbar(BaseButton.ButtonEventArgs args) + { + var newBar = SelectedHotbar == 0 ? ClientActionsComponent.Hotbars - 1 : SelectedHotbar - 1; + ChangeHotbar((byte) newBar); + } + + + private void ChangeHotbar(byte hotbar) + { + StopTargeting(); + SelectedHotbar = hotbar; + _loadoutNumber.Text = (hotbar + 1).ToString(); + UpdateUI(); + } + + /// + /// If currently targeting with this slot, stops targeting. + /// If currently targeting with no slot or a different slot, switches to + /// targeting with the specified slot. + /// + /// + public void ToggleTargeting(ActionSlot slot) + { + if (SelectingTargetFor == slot) + { + StopTargeting(); + return; + } + StartTargeting(slot); + } + + /// + /// Puts us in targeting mode, where we need to pick either a target point or entity + /// + private void StartTargeting(ActionSlot actionSlot) + { + // If we were targeting something else we should stop + StopTargeting(); + + SelectingTargetFor = actionSlot; + + // show it as toggled on to indicate we are currently selecting a target for it + if (!actionSlot.ToggledOn) + { + actionSlot.ToggledOn = true; + } + } + + /// + /// Switch out of targeting mode if currently selecting target for an action + /// + public void StopTargeting() + { + if (SelectingTargetFor == null) return; + if (SelectingTargetFor.ToggledOn) + { + SelectingTargetFor.ToggledOn = false; + } + SelectingTargetFor = null; + } + + private void OnToggleActionsMenu(BaseButton.ButtonEventArgs args) + { + ToggleActionsMenu(); + } + + public void ToggleActionsMenu() + { + if (_menu.IsOpen) + { + _menu.Close(); + } + else + { + _menu.OpenCentered(); + } + } + + private void OnLockPressed(BaseButton.ButtonEventArgs obj) + { + Locked = !Locked; + _lockButton.TextureNormal = Locked ? _lockTexture : _unlockTexture; + } + + private bool OnBeginActionDrag() + { + // only initiate the drag if the slot has an action in it + if (Locked || DragDropHelper.Dragged.Action == null) return false; + + _dragShadow.Texture = DragDropHelper.Dragged.Action.Icon.Frame0(); + LayoutContainer.SetPosition(_dragShadow, UserInterfaceManager.MousePositionScaled - (32, 32)); + DragDropHelper.Dragged.CancelPress(); + return true; + } + + private bool OnContinueActionDrag(float frameTime) + { + // stop if there's no action in the slot + if (Locked || DragDropHelper.Dragged.Action == null) return false; + + // keep dragged entity centered under mouse + LayoutContainer.SetPosition(_dragShadow, UserInterfaceManager.MousePositionScaled - (32, 32)); + // we don't set this visible until frameupdate, otherwise it flickers + _dragShadow.Visible = true; + return true; + } + + private void OnEndActionDrag() + { + _dragShadow.Visible = false; + } + + /// + /// Handle keydown / keyup for one of the slots via a keybinding, simulates mousedown/mouseup on it. + /// + /// slot index to to receive the press (0 corresponds to the one labeled 1, 9 corresponds to the one labeled 0) + public void HandleHotbarKeybind(byte slot, PointerInputCmdHandler.PointerInputCmdArgs args) + { + var actionSlot = _slots[slot]; + actionSlot.Depress(args.State == BoundKeyState.Down); + } + + protected override void FrameUpdate(FrameEventArgs args) + { + base.Update(args); + DragDropHelper.Update(args.DeltaSeconds); + } + } +} diff --git a/Content.Client/UserInterface/AlertsUI.cs b/Content.Client/UserInterface/AlertsUI.cs index f4c7effac9..656f9f18c8 100644 --- a/Content.Client/UserInterface/AlertsUI.cs +++ b/Content.Client/UserInterface/AlertsUI.cs @@ -1,10 +1,8 @@ using System; using Content.Client.UserInterface.Stylesheets; -using Robust.Client.Graphics.Drawing; using Robust.Client.Interfaces.Graphics; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.CustomControls; using Robust.Shared.Maths; namespace Content.Client.UserInterface @@ -16,58 +14,51 @@ namespace Content.Client.UserInterface { public GridContainer Grid { get; } - private readonly IClyde _clyde; - - public AlertsUI(IClyde clyde) + public AlertsUI() { - _clyde = clyde; + LayoutContainer.SetGrowHorizontal(this, LayoutContainer.GrowDirection.Begin); + LayoutContainer.SetGrowVertical(this, LayoutContainer.GrowDirection.End); + LayoutContainer.SetAnchorTop(this, 0f); + LayoutContainer.SetAnchorRight(this, 1f); + LayoutContainer.SetAnchorBottom(this, 1f); + LayoutContainer.SetMarginBottom(this, -180); + LayoutContainer.SetMarginTop(this, 250); + LayoutContainer.SetMarginRight(this, -10); var panelContainer = new PanelContainer { StyleClasses = {StyleNano.StyleClassTransparentBorderedWindowPanel}, - SizeFlagsVertical = SizeFlags.FillExpand, + SizeFlagsHorizontal = SizeFlags.ShrinkEnd, + SizeFlagsVertical = SizeFlags.None }; AddChild(panelContainer); Grid = new GridContainer { - MaxHeight = CalcMaxHeight(clyde.ScreenSize), + MaxHeight = 64, ExpandBackwards = true }; panelContainer.AddChild(Grid); - clyde.OnWindowResized += ClydeOnOnWindowResized; - - LayoutContainer.SetGrowHorizontal(this, LayoutContainer.GrowDirection.Begin); - LayoutContainer.SetAnchorAndMarginPreset(this, LayoutContainer.LayoutPreset.TopRight, margin: 10); - LayoutContainer.SetMarginTop(this, 250); } - protected override void UIScaleChanged() - { - Grid.MaxHeight = CalcMaxHeight(_clyde.ScreenSize); - base.UIScaleChanged(); - } - - private void ClydeOnOnWindowResized(WindowResizedEventArgs obj) + protected override void Resized() { // TODO: Can rework this once https://github.com/space-wizards/RobustToolbox/issues/1392 is done, // this is here because there isn't currently a good way to allow the grid to adjust its height based // on constraints, otherwise we would use anchors to lay it out - Grid.MaxHeight = CalcMaxHeight(obj.NewSize);; + base.Resized(); + Grid.MaxHeight = Height; } - private float CalcMaxHeight(Vector2i screenSize) + protected override Vector2 CalculateMinimumSize() { - return Math.Max(((screenSize.Y) / UIScale) - 420, 1); + // allows us to shrink down to a single row + return (64, 64); } - protected override void Dispose(bool disposing) + protected override void UIScaleChanged() { - base.Dispose(disposing); - - if (disposing) - { - _clyde.OnWindowResized -= ClydeOnOnWindowResized; - } + Grid.MaxHeight = Height; + base.UIScaleChanged(); } } } diff --git a/Content.Client/UserInterface/Controls/ActionSlot.cs b/Content.Client/UserInterface/Controls/ActionSlot.cs new file mode 100644 index 0000000000..0e1f24a86a --- /dev/null +++ b/Content.Client/UserInterface/Controls/ActionSlot.cs @@ -0,0 +1,652 @@ +#nullable enable +using System; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.UserInterface.Stylesheets; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Client.Graphics; +using Robust.Client.Interfaces.GameObjects.Components; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.Utility; +using Robust.Shared.Input; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Localization; +using Robust.Shared.Maths; +using Robust.Shared.Timing; +using Robust.Shared.Utility; + +namespace Content.Client.UserInterface.Controls +{ + /// + /// A slot in the action hotbar. Not extending BaseButton because + /// its needs diverged too much. + /// + public class ActionSlot : PanelContainer + { + // shorter than default tooltip delay so user can more easily + // see what actions they've been given + private const float CustomTooltipDelay = 0.5f; + + private static readonly string EnabledColor = "#7b7e9e"; + private static readonly string DisabledColor = "#950000"; + + /// + /// Current action in this slot. + /// + public BaseActionPrototype? Action { get; private set; } + + /// + /// true if there is an action assigned to the slot + /// + public bool HasAssignment => Action != null; + + private bool HasToggleSprite => Action != null && Action.IconOn != SpriteSpecifier.Invalid; + + /// + /// Only applicable when an action is in this slot. + /// True if the action is currently shown as enabled, false if action disabled. + /// + public bool ActionEnabled { get; private set; } + + /// + /// Is there an action in the slot that can currently be used? + /// + public bool CanUseAction => HasAssignment && ActionEnabled && !IsOnCooldown; + + /// + /// Item the action is provided by, only valid if Action is an ItemActionPrototype. May be null + /// if the item action is not yet tied to an item. + /// + public IEntity? Item { get; private set; } + + /// + /// Whether the action in this slot should be shown as toggled on. Separate from Depressed. + /// + public bool ToggledOn + { + get => _toggledOn; + set + { + if (_toggledOn == value) return; + _toggledOn = value; + UpdateIcons(); + DrawModeChanged(); + } + } + + /// + /// 1-10 corresponding to the number label on the slot (10 is labeled as 0) + /// + private byte SlotNumber => (byte) (SlotIndex + 1); + public byte SlotIndex { get; } + + /// + /// Current cooldown displayed in this slot. Set to null to show no cooldown. + /// + public (TimeSpan Start, TimeSpan End)? Cooldown + { + get => _cooldown; + set + { + _cooldown = value; + if (SuppliedTooltip is ActionAlertTooltip actionAlertTooltip) + { + actionAlertTooltip.Cooldown = value; + } + } + } + private (TimeSpan Start, TimeSpan End)? _cooldown; + + public bool IsOnCooldown => Cooldown.HasValue && _gameTiming.CurTime < Cooldown.Value.End; + + private readonly IGameTiming _gameTiming; + private readonly RichTextLabel _number; + private readonly TextureRect _bigActionIcon; + private readonly TextureRect _smallActionIcon; + private readonly SpriteView _smallItemSpriteView; + private readonly SpriteView _bigItemSpriteView; + private readonly CooldownGraphic _cooldownGraphic; + private readonly ActionsUI _actionsUI; + private readonly ClientActionsComponent _actionsComponent; + private bool _toggledOn; + // whether button is currently pressed down by mouse or keybind down. + private bool _depressed; + private bool _beingHovered; + + /// + /// Creates an action slot for the specified number + /// + /// slot index this corresponds to, 0-9 (0 labeled as 1, 8, labeled "9", 9 labeled as "0". + public ActionSlot(ActionsUI actionsUI, ClientActionsComponent actionsComponent, byte slotIndex) + { + _actionsComponent = actionsComponent; + _actionsUI = actionsUI; + _gameTiming = IoCManager.Resolve(); + SlotIndex = slotIndex; + MouseFilter = MouseFilterMode.Stop; + + CustomMinimumSize = (64, 64); + SizeFlagsVertical = SizeFlags.None; + TooltipDelay = CustomTooltipDelay; + TooltipSupplier = SupplyTooltip; + + _number = new RichTextLabel + { + StyleClasses = {StyleNano.StyleClassHotbarSlotNumber} + }; + _number.SetMessage(SlotNumberLabel()); + + _bigActionIcon = new TextureRect + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + SizeFlagsVertical = SizeFlags.FillExpand, + Stretch = TextureRect.StretchMode.Scale, + Visible = false + }; + _bigItemSpriteView = new SpriteView + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + SizeFlagsVertical = SizeFlags.FillExpand, + Scale = (2,2), + Visible = false + }; + _smallActionIcon = new TextureRect + { + SizeFlagsHorizontal = SizeFlags.ShrinkEnd, + SizeFlagsVertical = SizeFlags.ShrinkEnd, + Stretch = TextureRect.StretchMode.Scale, + Visible = false + }; + _smallItemSpriteView = new SpriteView + { + SizeFlagsHorizontal = SizeFlags.ShrinkEnd, + SizeFlagsVertical = SizeFlags.ShrinkEnd, + Visible = false + }; + + _cooldownGraphic = new CooldownGraphic {Progress = 0, Visible = false}; + + // padding to the left of the number to shift it right + var paddingBox = new HBoxContainer() + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + SizeFlagsVertical = SizeFlags.FillExpand, + CustomMinimumSize = (64, 64) + }; + paddingBox.AddChild(new Control() + { + CustomMinimumSize = (4, 4), + SizeFlagsVertical = SizeFlags.Fill + }); + paddingBox.AddChild(_number); + + // padding to the left of the small icon + var paddingBoxItemIcon = new HBoxContainer() + { + SizeFlagsHorizontal = SizeFlags.FillExpand, + SizeFlagsVertical = SizeFlags.FillExpand, + CustomMinimumSize = (64, 64) + }; + paddingBoxItemIcon.AddChild(new Control() + { + CustomMinimumSize = (32, 32), + SizeFlagsVertical = SizeFlags.Fill + }); + paddingBoxItemIcon.AddChild(new Control + { + Children = + { + _smallActionIcon, + _smallItemSpriteView + } + }); + AddChild(_bigActionIcon); + AddChild(_bigItemSpriteView); + AddChild(_cooldownGraphic); + AddChild(paddingBox); + AddChild(paddingBoxItemIcon); + DrawModeChanged(); + } + + private Control? SupplyTooltip(Control sender) + { + return Action == null ? null : + new ActionAlertTooltip(Action.Name, Action.Description, Action.Requires) {Cooldown = Cooldown}; + } + + /// + /// Action attempt for performing the action in the slot + /// + public IActionAttempt? ActionAttempt() + { + IActionAttempt? attempt = Action switch + { + ActionPrototype actionPrototype => new ActionAttempt(actionPrototype), + ItemActionPrototype itemActionPrototype => + (Item != null && Item.TryGetComponent(out var itemActions)) ? + new ItemActionAttempt(itemActionPrototype, Item, itemActions) : null, + _ => null + }; + return attempt; + } + + protected override void MouseEntered() + { + base.MouseEntered(); + + _beingHovered = true; + DrawModeChanged(); + if (Action is not ItemActionPrototype) return; + if (Item == null) return; + _actionsComponent.HighlightItemSlot(Item); + } + + protected override void MouseExited() + { + base.MouseExited(); + _beingHovered = false; + CancelPress(); + DrawModeChanged(); + _actionsComponent.StopHighlightingItemSlots(); + } + + protected override void KeyBindDown(GUIBoundKeyEventArgs args) + { + base.KeyBindDown(args); + + if (args.Function == EngineKeyFunctions.UIRightClick) + { + if (!_actionsUI.Locked && !_actionsUI.DragDropHelper.IsDragging) + { + _actionsComponent.Assignments.ClearSlot(_actionsUI.SelectedHotbar, SlotIndex, true); + _actionsUI.StopTargeting(); + _actionsUI.UpdateUI(); + } + return; + } + + // only handle clicks, and can't do anything to this if no assignment + if (args.Function != EngineKeyFunctions.UIClick || !HasAssignment) + return; + + // might turn into a drag or a full press if released + Depress(true); + _actionsUI.DragDropHelper.MouseDown(this); + DrawModeChanged(); + } + + protected override void KeyBindUp(GUIBoundKeyEventArgs args) + { + base.KeyBindUp(args); + + if (args.Function != EngineKeyFunctions.UIClick) + return; + + // might be finishing a drag or using the action + if (_actionsUI.DragDropHelper.IsDragging && + _actionsUI.DragDropHelper.Dragged == this && + UserInterfaceManager.CurrentlyHovered is ActionSlot targetSlot && + targetSlot != this) + { + // finish the drag, swap the 2 slots + var fromIdx = SlotIndex; + var fromAssignment = _actionsComponent.Assignments[_actionsUI.SelectedHotbar, fromIdx]; + var toIdx = targetSlot.SlotIndex; + var toAssignment = _actionsComponent.Assignments[_actionsUI.SelectedHotbar, toIdx]; + + if (fromIdx == toIdx) return; + if (!fromAssignment.HasValue) return; + + _actionsComponent.Assignments.AssignSlot(_actionsUI.SelectedHotbar, toIdx, fromAssignment.Value); + if (toAssignment.HasValue) + { + _actionsComponent.Assignments.AssignSlot(_actionsUI.SelectedHotbar, fromIdx, toAssignment.Value); + } + else + { + _actionsComponent.Assignments.ClearSlot(_actionsUI.SelectedHotbar, fromIdx, false); + } + _actionsUI.UpdateUI(); + } + else + { + // perform the action + if (UserInterfaceManager.CurrentlyHovered == this) + { + Depress(false); + } + } + _actionsUI.DragDropHelper.EndDrag(); + DrawModeChanged(); + } + + /// + /// Cancel current press without triggering the action + /// + public void CancelPress() + { + _depressed = false; + DrawModeChanged(); + } + + /// + /// Press this button down. If it was depressed and now set to not depressed, will + /// trigger the action. Only has an effect if CanUseAction. + /// + public void Depress(bool depress) + { + if (!CanUseAction) return; + + if (_depressed && !depress) + { + // fire the action + // no left-click interaction with it on cooldown or revoked + _actionsComponent.AttemptAction(this); + } + _depressed = depress; + DrawModeChanged(); + } + + /// + /// Updates the action assigned to this slot. + /// + /// action to assign + /// whether action should initially appear enable or disabled + public void Assign(ActionPrototype action, bool actionEnabled) + { + // already assigned + if (Action != null && Action == action) return; + + Action = action; + Item = null; + _depressed = false; + ToggledOn = false; + ActionEnabled = actionEnabled; + Cooldown = null; + HideTooltip(); + UpdateIcons(); + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + /// + /// Updates the item action assigned to this slot. The action will always be shown as disabled + /// until it is tied to a specific item. + /// + /// action to assign + public void Assign(ItemActionPrototype action) + { + // already assigned + if (Action != null && Action == action && Item == null) return; + + Action = action; + Item = null; + _depressed = false; + ToggledOn = false; + ActionEnabled = false; + Cooldown = null; + HideTooltip(); + UpdateIcons(); + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + /// + /// Updates the item action assigned to this slot, tied to a specific item. + /// + /// action to assign + /// item the action is provided by + /// whether action should initially appear enable or disabled + public void Assign(ItemActionPrototype action, IEntity item, bool actionEnabled) + { + // already assigned + if (Action != null && Action == action && Item == item) return; + + Action = action; + Item = item; + _depressed = false; + ToggledOn = false; + ActionEnabled = false; + Cooldown = null; + HideTooltip(); + UpdateIcons(); + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + /// + /// Clears the action assigned to this slot + /// + public void Clear() + { + if (!HasAssignment) return; + Action = null; + Item = null; + ToggledOn = false; + _depressed = false; + Cooldown = null; + HideTooltip(); + UpdateIcons(); + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + /// + /// Display the action in this slot (if there is one) as enabled + /// + public void EnableAction() + { + if (ActionEnabled || !HasAssignment) return; + + ActionEnabled = true; + _depressed = false; + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + /// + /// Display the action in this slot (if there is one) as disabled. + /// The slot is still clickable. + /// + public void DisableAction() + { + if (!ActionEnabled || !HasAssignment) return; + + ActionEnabled = false; + _depressed = false; + DrawModeChanged(); + _number.SetMessage(SlotNumberLabel()); + } + + private FormattedMessage SlotNumberLabel() + { + if (SlotNumber > 10) return FormattedMessage.FromMarkup(""); + var number = Loc.GetString(SlotNumber == 10 ? "0" : SlotNumber.ToString()); + var color = (ActionEnabled || !HasAssignment) ? EnabledColor : DisabledColor; + return FormattedMessage.FromMarkup("[color=" + color + "]" + number + "[/color]"); + } + + private void UpdateIcons() + { + if (!HasAssignment) + { + SetActionIcon(null); + SetItemIcon(null); + return; + } + + if (HasToggleSprite && ToggledOn && Action != null) + { + SetActionIcon(Action.IconOn.Frame0()); + } + else if (Action != null) + { + SetActionIcon(Action.Icon.Frame0()); + } + + if (Item != null) + { + SetItemIcon(Item.TryGetComponent(out var spriteComponent) ? spriteComponent : null); + } + else + { + SetItemIcon(null); + } + } + + private void SetActionIcon(Texture? texture) + { + if (texture == null || !HasAssignment) + { + _bigActionIcon.Texture = null; + _bigActionIcon.Visible = false; + _smallActionIcon.Texture = null; + _smallActionIcon.Visible = false; + } + else + { + if (Action is ItemActionPrototype {IconStyle: ItemActionIconStyle.BigItem}) + { + _bigActionIcon.Texture = null; + _bigActionIcon.Visible = false; + _smallActionIcon.Texture = texture; + _smallActionIcon.Visible = true; + } + else + { + _bigActionIcon.Texture = texture; + _bigActionIcon.Visible = true; + _smallActionIcon.Texture = null; + _smallActionIcon.Visible = false; + } + + } + } + + private void SetItemIcon(ISpriteComponent? sprite) + { + if (sprite == null || !HasAssignment) + { + _bigItemSpriteView.Visible = false; + _bigItemSpriteView.Sprite = null; + _smallItemSpriteView.Visible = false; + _smallItemSpriteView.Sprite = null; + } + else + { + if (Action is ItemActionPrototype actionPrototype) + { + switch (actionPrototype.IconStyle) + { + case ItemActionIconStyle.BigItem: + { + _bigItemSpriteView.Visible = true; + _bigItemSpriteView.Sprite = sprite; + _smallItemSpriteView.Visible = false; + _smallItemSpriteView.Sprite = null; + break; + } + case ItemActionIconStyle.BigAction: + { + _bigItemSpriteView.Visible = false; + _bigItemSpriteView.Sprite = null; + _smallItemSpriteView.Visible = true; + _smallItemSpriteView.Sprite = sprite; + break; + } + case ItemActionIconStyle.NoItem: + { + _bigItemSpriteView.Visible = false; + _bigItemSpriteView.Sprite = null; + _smallItemSpriteView.Visible = false; + _smallItemSpriteView.Sprite = null; + break; + } + } + + } + else + { + _bigItemSpriteView.Visible = false; + _bigItemSpriteView.Sprite = null; + _smallItemSpriteView.Visible = false; + _smallItemSpriteView.Sprite = null; + } + + } + } + + + private void DrawModeChanged() + { + // always show the normal empty button style if no action in this slot + if (!HasAssignment) + { + SetOnlyStylePseudoClass(ContainerButton.StylePseudoClassNormal); + return; + } + + // it's only depress-able if it's usable, so if we're depressed + // show the depressed style + if (_depressed) + { + SetOnlyStylePseudoClass(ContainerButton.StylePseudoClassPressed); + return; + } + + // show a hover only if the action is usable + if (_beingHovered) + { + if (ActionEnabled && !IsOnCooldown) + { + SetOnlyStylePseudoClass(ContainerButton.StylePseudoClassHover); + return; + } + } + + // if it's toggled on, always show the toggled on style (currently same as depressed style) + if (ToggledOn) + { + // when there's a toggle sprite, we're showing that sprite instead of highlighting this slot + SetOnlyStylePseudoClass(HasToggleSprite ? ContainerButton.StylePseudoClassNormal : + ContainerButton.StylePseudoClassPressed); + return; + } + + + if (!ActionEnabled) + { + SetOnlyStylePseudoClass(ContainerButton.StylePseudoClassDisabled); + return; + } + + + SetOnlyStylePseudoClass(ContainerButton.StylePseudoClassNormal); + } + + + + protected override void FrameUpdate(FrameEventArgs args) + { + base.FrameUpdate(args); + if (!Cooldown.HasValue) + { + _cooldownGraphic.Visible = false; + _cooldownGraphic.Progress = 0; + return; + } + + var duration = Cooldown.Value.End - Cooldown.Value.Start; + var curTime = _gameTiming.CurTime; + var length = duration.TotalSeconds; + var progress = (curTime - Cooldown.Value.Start).TotalSeconds / length; + var ratio = (progress <= 1 ? (1 - progress) : (curTime - Cooldown.Value.End).TotalSeconds * -5); + + _cooldownGraphic.Progress = MathHelper.Clamp((float)ratio, -1, 1); + _cooldownGraphic.Visible = ratio > -1f; + } + } +} diff --git a/Content.Client/GameObjects/Components/Mobs/AlertControl.cs b/Content.Client/UserInterface/Controls/AlertControl.cs similarity index 53% rename from Content.Client/GameObjects/Components/Mobs/AlertControl.cs rename to Content.Client/UserInterface/Controls/AlertControl.cs index b723550d33..b7925e7b38 100644 --- a/Content.Client/GameObjects/Components/Mobs/AlertControl.cs +++ b/Content.Client/UserInterface/Controls/AlertControl.cs @@ -1,30 +1,48 @@ #nullable enable using System; -using Content.Client.UserInterface; using Content.Client.Utility; using Content.Shared.Alert; using Robust.Client.Interfaces.ResourceManagement; +using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; using Robust.Shared.Maths; +using Robust.Shared.Timing; -namespace Content.Client.GameObjects.Components.Mobs +namespace Content.Client.UserInterface.Controls { public class AlertControl : BaseButton { + // shorter than default tooltip delay so user can more easily + // see what alerts they have + private const float CustomTooltipDelay = 0.5f; + public AlertPrototype Alert { get; } /// - /// Total duration of the cooldown in seconds. Null if no duration / cooldown. + /// Current cooldown displayed in this slot. Set to null to show no cooldown. /// - public int? TotalDuration { get; set; } + public (TimeSpan Start, TimeSpan End)? Cooldown + { + get => _cooldown; + set + { + _cooldown = value; + if (SuppliedTooltip is ActionAlertTooltip actionAlertTooltip) + { + actionAlertTooltip.Cooldown = value; + } + } + } + private (TimeSpan Start, TimeSpan End)? _cooldown; private short? _severity; + private readonly IGameTiming _gameTiming; private readonly TextureRect _icon; private readonly CooldownGraphic _cooldownGraphic; - private readonly IResourceCache _resourceCache; - /// /// Creates an alert control reflecting the indicated alert + state /// @@ -33,6 +51,9 @@ namespace Content.Client.GameObjects.Components.Mobs /// resourceCache to use to load alert icon textures public AlertControl(AlertPrototype alert, short? severity, IResourceCache resourceCache) { + _gameTiming = IoCManager.Resolve(); + TooltipDelay = CustomTooltipDelay; + TooltipSupplier = SupplyTooltip; _resourceCache = resourceCache; Alert = alert; _severity = severity; @@ -49,6 +70,11 @@ namespace Content.Client.GameObjects.Components.Mobs } + private Control SupplyTooltip(Control? sender) + { + return new ActionAlertTooltip(Alert.Name, Alert.Description) {Cooldown = Cooldown}; + } + /// /// Change the alert severity, changing the displayed icon /// @@ -61,33 +87,24 @@ namespace Content.Client.GameObjects.Components.Mobs } } - /// - /// Updates the displayed cooldown amount, doing nothing if alertCooldown is null - /// - /// cooldown start and end - /// current game time - public void UpdateCooldown((TimeSpan Start, TimeSpan End)? alertCooldown, in TimeSpan curTime) + protected override void FrameUpdate(FrameEventArgs args) { - if (!alertCooldown.HasValue) + base.FrameUpdate(args); + if (!Cooldown.HasValue) { - _cooldownGraphic.Progress = 0; _cooldownGraphic.Visible = false; - TotalDuration = null; + _cooldownGraphic.Progress = 0; + return; } - else - { - var start = alertCooldown.Value.Start; - var end = alertCooldown.Value.End; + var duration = Cooldown.Value.End - Cooldown.Value.Start; + var curTime = _gameTiming.CurTime; + var length = duration.TotalSeconds; + var progress = (curTime - Cooldown.Value.Start).TotalSeconds / length; + var ratio = (progress <= 1 ? (1 - progress) : (curTime - Cooldown.Value.End).TotalSeconds * -5); - var length = (end - start).TotalSeconds; - var progress = (curTime - start).TotalSeconds / length; - var ratio = (progress <= 1 ? (1 - progress) : (curTime - end).TotalSeconds * -5); - - TotalDuration = (int?) Math.Round(length); - _cooldownGraphic.Progress = MathHelper.Clamp((float)ratio, -1, 1); - _cooldownGraphic.Visible = ratio > -1f; - } + _cooldownGraphic.Progress = MathHelper.Clamp((float)ratio, -1, 1); + _cooldownGraphic.Visible = ratio > -1f; } } } diff --git a/Content.Client/UserInterface/ItemSlotButton.cs b/Content.Client/UserInterface/ItemSlotButton.cs index 24ed144d4c..7715c565d9 100644 --- a/Content.Client/UserInterface/ItemSlotButton.cs +++ b/Content.Client/UserInterface/ItemSlotButton.cs @@ -1,14 +1,19 @@ using System; using Robust.Client.Graphics; +using Robust.Client.Graphics.Shaders; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; using Robust.Shared.Input; +using Robust.Shared.IoC; using Robust.Shared.Maths; +using Robust.Shared.Prototypes; namespace Content.Client.UserInterface { public class ItemSlotButton : MarginContainer { + private const string HighlightShader = "SelectionOutlineInrange"; + public TextureRect Button { get; } public SpriteView SpriteView { get; } public SpriteView HoverSpriteView { get; } @@ -21,9 +26,11 @@ namespace Content.Client.UserInterface public bool EntityHover => HoverSpriteView.Sprite != null; public bool MouseIsHovering = false; + private readonly ShaderInstance _highlightShader; public ItemSlotButton(Texture texture, Texture storageTexture) { + _highlightShader = IoCManager.Resolve().Index(HighlightShader).Instance(); CustomMinimumSize = (64, 64); AddChild(Button = new TextureRect @@ -95,6 +102,20 @@ namespace Content.Client.UserInterface } } + public void Highlight(bool on) + { + // I make no claim that this actually looks good but it's a start. + if (on) + { + Button.ShaderOverride = _highlightShader; + } + else + { + Button.ShaderOverride = null; + } + + } + private void OnButtonPressed(GUIBoundKeyEventArgs args) { OnPressed?.Invoke(args); diff --git a/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs b/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs index ee4f6b1172..c208e42536 100644 --- a/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs +++ b/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs @@ -150,7 +150,6 @@ namespace Content.Client.UserInterface AddButton(ContentKeyFunctions.ReleasePulledObject, "Release pulled object"); AddButton(ContentKeyFunctions.Point, "Point at location"); - AddHeader("User Interface"); AddButton(ContentKeyFunctions.FocusChat, "Focus chat"); AddButton(ContentKeyFunctions.FocusOOC, "Focus chat (OOC)"); @@ -160,6 +159,7 @@ namespace Content.Client.UserInterface AddButton(ContentKeyFunctions.OpenCraftingMenu, "Open crafting menu"); AddButton(ContentKeyFunctions.OpenInventoryMenu, "Open inventory"); AddButton(ContentKeyFunctions.OpenTutorial, "Open tutorial"); + AddButton(ContentKeyFunctions.OpenActionsMenu, "Open action menu"); AddButton(ContentKeyFunctions.OpenEntitySpawnWindow, "Open entity spawn menu"); AddButton(ContentKeyFunctions.OpenSandboxWindow, "Open sandbox menu"); AddButton(ContentKeyFunctions.OpenTileSpawnWindow, "Open tile spawn menu"); @@ -169,6 +169,18 @@ namespace Content.Client.UserInterface AddButton(ContentKeyFunctions.TakeScreenshot, "Take screenshot"); AddButton(ContentKeyFunctions.TakeScreenshotNoUI, "Take screenshot (without UI)"); + AddHeader("Hotbar"); + AddButton(ContentKeyFunctions.Hotbar1, "Hotbar slot 1"); + AddButton(ContentKeyFunctions.Hotbar2, "Hotbar slot 2"); + AddButton(ContentKeyFunctions.Hotbar3, "Hotbar slot 3"); + AddButton(ContentKeyFunctions.Hotbar4, "Hotbar slot 4"); + AddButton(ContentKeyFunctions.Hotbar5, "Hotbar slot 5"); + AddButton(ContentKeyFunctions.Hotbar6, "Hotbar slot 6"); + AddButton(ContentKeyFunctions.Hotbar7, "Hotbar slot 7"); + AddButton(ContentKeyFunctions.Hotbar8, "Hotbar slot 8"); + AddButton(ContentKeyFunctions.Hotbar9, "Hotbar slot 9"); + AddButton(ContentKeyFunctions.Hotbar0, "Hotbar slot 0"); + AddHeader("Map Editor"); AddButton(EngineKeyFunctions.EditorPlaceObject, "Place object"); AddButton(EngineKeyFunctions.EditorCancelPlace, "Cancel placement"); diff --git a/Content.Client/UserInterface/Stylesheets/StyleNano.cs b/Content.Client/UserInterface/Stylesheets/StyleNano.cs index a29b687b72..734946b907 100644 --- a/Content.Client/UserInterface/Stylesheets/StyleNano.cs +++ b/Content.Client/UserInterface/Stylesheets/StyleNano.cs @@ -1,5 +1,6 @@ using System.Linq; using Content.Client.GameObjects.EntitySystems; +using Content.Client.UserInterface.Controls; using Content.Client.Utility; using Robust.Client.Graphics.Drawing; using Robust.Client.Interfaces.ResourceManagement; @@ -15,10 +16,19 @@ namespace Content.Client.UserInterface.Stylesheets { public const string StyleClassBorderedWindowPanel = "BorderedWindowPanel"; public const string StyleClassTransparentBorderedWindowPanel = "TransparentBorderedWindowPanel"; + public const string StyleClassHotbarPanel = "HotbarPanel"; public const string StyleClassTooltipPanel = "tooltipBox"; public const string StyleClassTooltipAlertTitle = "tooltipAlertTitle"; public const string StyleClassTooltipAlertDescription = "tooltipAlertDesc"; public const string StyleClassTooltipAlertCooldown = "tooltipAlertCooldown"; + public const string StyleClassTooltipActionTitle = "tooltipActionTitle"; + public const string StyleClassTooltipActionDescription = "tooltipActionDesc"; + public const string StyleClassTooltipActionCooldown = "tooltipActionCooldown"; + public const string StyleClassTooltipActionRequirements = "tooltipActionCooldown"; + public const string StyleClassHotbarSlotNumber = "hotbarSlotNumber"; + public const string StyleClassActionSearchBox = "actionSearchBox"; + public const string StyleClassActionMenuItemRevoked = "actionMenuItemRevoked"; + public const string StyleClassSliderRed = "Red"; public const string StyleClassSliderGreen = "Green"; @@ -60,6 +70,8 @@ namespace Content.Client.UserInterface.Stylesheets var notoSansItalic12 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Italic.ttf", 12); var notoSansBold12 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 12); var notoSansDisplayBold14 = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 14); + var notoSansDisplayBold16 = resCache.GetFont("/Fonts/NotoSansDisplay/NotoSansDisplay-Bold.ttf", 16); + var notoSans15 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Regular.ttf", 15); var notoSans16 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Regular.ttf", 16); var notoSansBold16 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 16); var notoSansBold18 = resCache.GetFont("/Fonts/NotoSans/NotoSans-Bold.ttf", 18); @@ -95,6 +107,61 @@ namespace Content.Client.UserInterface.Stylesheets }; borderedTransparentWindowBackground.SetPatchMargin(StyleBox.Margin.All, 2); + var hotbarBackground = new StyleBoxTexture + { + Texture = borderedWindowBackgroundTex, + }; + hotbarBackground.SetPatchMargin(StyleBox.Margin.All, 2); + hotbarBackground.SetExpandMargin(StyleBox.Margin.All, 4); + + var buttonRectTex = resCache.GetTexture("/Textures/Interface/Nano/light_panel_background_bordered.png"); + var buttonRect = new StyleBoxTexture(BaseButton) + { + Texture = buttonRectTex + }; + buttonRect.SetPatchMargin(StyleBox.Margin.All, 2); + buttonRect.SetPadding(StyleBox.Margin.All, 2); + buttonRect.SetContentMarginOverride(StyleBox.Margin.Vertical, 2); + buttonRect.SetContentMarginOverride(StyleBox.Margin.Horizontal, 2); + + var buttonRectHover = new StyleBoxTexture(buttonRect) + { + Modulate = ButtonColorHovered + }; + + var buttonRectPressed = new StyleBoxTexture(buttonRect) + { + Modulate = ButtonColorPressed + }; + + var buttonRectDisabled = new StyleBoxTexture(buttonRect) + { + Modulate = ButtonColorDisabled + }; + + var buttonRectActionMenuItemTex = resCache.GetTexture("/Textures/Interface/Nano/black_panel_light_thin_border.png"); + var buttonRectActionMenuRevokedItemTex = resCache.GetTexture("/Textures/Interface/Nano/black_panel_red_thin_border.png"); + var buttonRectActionMenuItem = new StyleBoxTexture(BaseButton) + { + Texture = buttonRectActionMenuItemTex + }; + buttonRectActionMenuItem.SetPatchMargin(StyleBox.Margin.All, 2); + buttonRectActionMenuItem.SetPadding(StyleBox.Margin.All, 2); + buttonRectActionMenuItem.SetContentMarginOverride(StyleBox.Margin.Vertical, 2); + buttonRectActionMenuItem.SetContentMarginOverride(StyleBox.Margin.Horizontal, 2); + var buttonRectActionMenuItemRevoked = new StyleBoxTexture(buttonRectActionMenuItem) + { + Texture = buttonRectActionMenuRevokedItemTex + }; + var buttonRectActionMenuItemHover = new StyleBoxTexture(buttonRectActionMenuItem) + { + Modulate = ButtonColorHovered + }; + var buttonRectActionMenuItemPressed = new StyleBoxTexture(buttonRectActionMenuItem) + { + Modulate = ButtonColorPressed + }; + var textureInvertedTriangle = resCache.GetTexture("/Textures/Interface/Nano/inverted_triangle.svg.png"); var lineEditTex = resCache.GetTexture("/Textures/Interface/Nano/lineedit.png"); @@ -105,6 +172,14 @@ namespace Content.Client.UserInterface.Stylesheets lineEdit.SetPatchMargin(StyleBox.Margin.All, 3); lineEdit.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5); + var actionSearchBoxTex = resCache.GetTexture("/Textures/Interface/Nano/black_panel_dark_thin_border.png"); + var actionSearchBox = new StyleBoxTexture + { + Texture = actionSearchBoxTex, + }; + actionSearchBox.SetPatchMargin(StyleBox.Margin.All, 3); + actionSearchBox.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5); + var tabContainerPanelTex = resCache.GetTexture("/Textures/Interface/Nano/tabcontainer_panel.png"); var tabContainerPanel = new StyleBoxTexture { @@ -280,6 +355,12 @@ namespace Content.Client.UserInterface.Stylesheets { new StyleProperty(PanelContainer.StylePropertyPanel, borderedTransparentWindowBackground), }), + // Hotbar background + new StyleRule(new SelectorElement(typeof(PanelContainer), new[] {StyleClassHotbarPanel}, null, null), + new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, hotbarBackground), + }), // Window header. new StyleRule( new SelectorElement(typeof(PanelContainer), new[] {SS14Window.StyleClassWindowHeader}, null, null), @@ -376,6 +457,43 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty("font-color", Color.FromHex("#E5E5E581")), }), + // action slot hotbar buttons + new StyleRule(new SelectorElement(typeof(ActionSlot), null, null, new[] {ContainerButton.StylePseudoClassNormal}), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, buttonRect), + }), + new StyleRule(new SelectorElement(typeof(ActionSlot), null, null, new[] {ContainerButton.StylePseudoClassHover}), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, buttonRectHover), + }), + new StyleRule(new SelectorElement(typeof(ActionSlot), null, null, new[] {ContainerButton.StylePseudoClassPressed}), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, buttonRectPressed), + }), + new StyleRule(new SelectorElement(typeof(ActionSlot), null, null, new[] {ContainerButton.StylePseudoClassDisabled}), new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, buttonRectDisabled), + }), + + // action menu item buttons + new StyleRule(new SelectorElement(typeof(ActionMenuItem), null, null, new[] {ContainerButton.StylePseudoClassNormal}), new[] + { + new StyleProperty(ContainerButton.StylePropertyStyleBox, buttonRectActionMenuItem), + }), + // we don't actually disable the action menu items, only change their style based on the underlying action being revoked + new StyleRule(new SelectorElement(typeof(ActionMenuItem), new [] {StyleClassActionMenuItemRevoked}, null, new[] {ContainerButton.StylePseudoClassNormal}), new[] + { + new StyleProperty(ContainerButton.StylePropertyStyleBox, buttonRectActionMenuItemRevoked), + }), + new StyleRule(new SelectorElement(typeof(ActionMenuItem), null, null, new[] {ContainerButton.StylePseudoClassHover}), new[] + { + new StyleProperty(ContainerButton.StylePropertyStyleBox, buttonRectActionMenuItemHover), + }), + new StyleRule(new SelectorElement(typeof(ActionMenuItem), null, null, new[] {ContainerButton.StylePseudoClassPressed}), new[] + { + new StyleProperty(ContainerButton.StylePropertyStyleBox, buttonRectActionMenuItemPressed), + }), + // Main menu: Make those buttons bigger. new StyleRule(new SelectorChild( new SelectorElement(typeof(Button), null, "mainMenu", null), @@ -413,6 +531,13 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty("font-color", Color.Gray), }), + // Action searchbox lineedit + new StyleRule(new SelectorElement(typeof(LineEdit), new[] {StyleClassActionSearchBox}, null, null), + new[] + { + new StyleProperty(LineEdit.StylePropertyStyleBox, actionSearchBox), + }), + // TabContainer new StyleRule(new SelectorElement(typeof(TabContainer), null, null, null), new[] @@ -531,6 +656,30 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty("font", notoSans16) }), + // action tooltip + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipActionTitle}, null, null), new[] + { + new StyleProperty("font", notoSansBold16) + }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipActionDescription}, null, null), new[] + { + new StyleProperty("font", notoSans15) + }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipActionCooldown}, null, null), new[] + { + new StyleProperty("font", notoSans15) + }), + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassTooltipActionRequirements}, null, null), new[] + { + new StyleProperty("font", notoSans15) + }), + + // hotbar slot + new StyleRule(new SelectorElement(typeof(RichTextLabel), new[] {StyleClassHotbarSlotNumber}, null, null), new[] + { + new StyleProperty("font", notoSansDisplayBold16) + }), + // Entity tooltip new StyleRule( new SelectorElement(typeof(PanelContainer), new[] {ExamineSystem.StyleClassEntityTooltip}, null, diff --git a/Content.Client/UserInterface/TutorialWindow.cs b/Content.Client/UserInterface/TutorialWindow.cs index eec5731bbb..ca1c4c0c53 100644 --- a/Content.Client/UserInterface/TutorialWindow.cs +++ b/Content.Client/UserInterface/TutorialWindow.cs @@ -74,6 +74,7 @@ Smart equip from belt: [color=#a4885c]{25}[/color] Open inventory: [color=#a4885c]{7}[/color] Open character window: [color=#a4885c]{8}[/color] Open crafting window: [color=#a4885c]{9}[/color] +Open action menu: [color=#a4885c]{33}[/color] Focus chat: [color=#a4885c]{10}[/color] Focus OOC: [color=#a4885c]{26}[/color] Focus Admin Chat: [color=#a4885c]{27}[/color] @@ -94,7 +95,18 @@ Toggle debug overlay: [color=#a4885c]{18}[/color] Toggle entity spawner: [color=#a4885c]{19}[/color] Toggle tile spawner: [color=#a4885c]{20}[/color] Toggle sandbox window: [color=#a4885c]{21}[/color] -Toggle admin menu [color=#a4885c]{31}[/color]", +Toggle admin menu [color=#a4885c]{31}[/color] +Hotbar slot 1: [color=#a4885c]{34}[/color] +Hotbar slot 2: [color=#a4885c]{35}[/color] +Hotbar slot 3: [color=#a4885c]{36}[/color] +Hotbar slot 4: [color=#a4885c]{37}[/color] +Hotbar slot 5: [color=#a4885c]{38}[/color] +Hotbar slot 6: [color=#a4885c]{39}[/color] +Hotbar slot 7: [color=#a4885c]{40}[/color] +Hotbar slot 8: [color=#a4885c]{41}[/color] +Hotbar slot 9: [color=#a4885c]{42}[/color] +Hotbar slot 0: [color=#a4885c]{43}[/color] + ", Key(MoveUp), Key(MoveLeft), Key(MoveDown), Key(MoveRight), Key(SwapHands), Key(ActivateItemInHand), @@ -124,7 +136,18 @@ Toggle admin menu [color=#a4885c]{31}[/color]", Key(TryPullObject), Key(MovePulledObject), Key(OpenAdminMenu), - Key(ReleasePulledObject))); + Key(ReleasePulledObject), + Key(OpenActionsMenu), + Key(Hotbar1), + Key(Hotbar2), + Key(Hotbar3), + Key(Hotbar4), + Key(Hotbar5), + Key(Hotbar6), + Key(Hotbar7), + Key(Hotbar8), + Key(Hotbar9), + Key(Hotbar0))); //Gameplay VBox.AddChild(new Label { FontOverride = headerFont, Text = "\nGameplay" }); diff --git a/Content.Client/Utility/DragDropHelper.cs b/Content.Client/Utility/DragDropHelper.cs new file mode 100644 index 0000000000..71add4725f --- /dev/null +++ b/Content.Client/Utility/DragDropHelper.cs @@ -0,0 +1,172 @@ +using Robust.Client.Interfaces.Input; +using Robust.Shared.IoC; +using Robust.Shared.Maths; + +namespace Content.Client.Utility +{ + /// + /// Helper for implementing drag and drop interactions. + /// + /// The basic flow for a drag drop interaction as per this helper is: + /// 1. User presses mouse down on something (using class should communicate this to helper by calling MouseDown()). + /// 2. User continues to hold the mouse down and moves the mouse outside of the defined + /// deadzone. OnBeginDrag is invoked to see if a drag should be initiated. If so, initiates a drag. + /// If user didn't move the mouse beyond the deadzone the drag is not initiated (OnEndDrag invoked). + /// 3. Every Update/FrameUpdate, OnContinueDrag is invoked. + /// 4. User lifts mouse up. This is not handled by DragDropHelper. The using class of the helper should + /// do whatever they want and then end the drag by calling EndDrag() (which invokes OnEndDrag). + /// + /// If for any reason the drag is ended, OnEndDrag is invoked. + /// + /// thing being dragged and dropped + public class DragDropHelper + { + private const float DefaultDragDeadzone = 2f; + + private readonly IInputManager _inputManager; + + private readonly OnBeginDrag _onBeginDrag; + private readonly OnEndDrag _onEndDrag; + private readonly OnContinueDrag _onContinueDrag; + private readonly float _deadzone; + + /// + /// Convenience method, current mouse screen position as provided by inputmanager. + /// + public Vector2 MouseScreenPosition => _inputManager.MouseScreenPosition; + + /// + /// True if initiated a drag and currently dragging something. + /// I.e. this will be false if we've just had a mousedown over something but the mouse + /// has not moved outside of the drag deadzone. + /// + public bool IsDragging => _state == DragState.Dragging; + + /// + /// Current thing being dragged or which mouse button is being held down on. + /// + public T Dragged { get; private set; } + + // screen pos where the mouse down began for the drag + private Vector2 _mouseDownScreenPos; + private DragState _state = DragState.NotDragging; + + private enum DragState : byte + { + NotDragging, + // not dragging yet, waiting to see + // if they hold for long enough + MouseDown, + // currently dragging something + Dragging, + } + + /// + /// + /// + /// drag will be triggered when mouse leaves + /// this deadzone around the mousedown position + public DragDropHelper(OnBeginDrag onBeginDrag, OnContinueDrag onContinueDrag, + OnEndDrag onEndDrag, float deadzone = DefaultDragDeadzone) + { + _deadzone = deadzone; + _inputManager = IoCManager.Resolve(); + _onBeginDrag = onBeginDrag; + _onEndDrag = onEndDrag; + _onContinueDrag = onContinueDrag; + } + + /// + /// Tell the helper that the mouse button was pressed down on + /// a target, thus a drag has the possibility to begin for this target. + /// Assumes current mouse screen position is the location the mouse was clicked. + /// + /// EndDrag should be called when the drag is done. + /// + public void MouseDown(T target) + { + if (_state != DragState.NotDragging) + { + EndDrag(); + } + + Dragged = target; + _state = DragState.MouseDown; + _mouseDownScreenPos = _inputManager.MouseScreenPosition; + } + + /// + /// Stop the current drag / drop operation no matter what state it is in. + /// + public void EndDrag() + { + Dragged = default; + _state = DragState.NotDragging; + _onEndDrag.Invoke(); + } + + private void StartDragging() + { + if (_onBeginDrag.Invoke()) + { + _state = DragState.Dragging; + } + else + { + EndDrag(); + } + } + + /// + /// Should be invoked by using class every FrameUpdate or Update. + /// + public void Update(float frameTime) + { + switch (_state) + { + // check if dragging should begin + case DragState.MouseDown: + { + var screenPos = _inputManager.MouseScreenPosition; + if ((_mouseDownScreenPos - screenPos).Length > _deadzone) + { + StartDragging(); + } + + break; + } + case DragState.Dragging: + { + if (!_onContinueDrag.Invoke(frameTime)) + { + EndDrag(); + } + + break; + } + } + } + } + + /// + /// Invoked when a drag is confirmed and going to be initiated. Implementation should + /// typically set the drag shadow texture based on the target. + /// + /// true if drag should begin, false to end. + public delegate bool OnBeginDrag(); + + /// + /// Invoked every frame when drag is ongoing. Typically implementation should + /// make the drag shadow follow the mouse position. + /// + /// true if drag should continue, false to end. + public delegate bool OnContinueDrag(float frameTime); + + /// + /// invoked when + /// the drag drop is ending for any reason. This + /// should typically just clear the drag shadow. + /// + public delegate void OnEndDrag(); + +} diff --git a/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/ActionsComponentTests.cs b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/ActionsComponentTests.cs new file mode 100644 index 0000000000..850391c21d --- /dev/null +++ b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/ActionsComponentTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Threading.Tasks; +using Content.Client.GameObjects.Components.Mobs; +using Content.Client.UserInterface; +using Content.Client.UserInterface.Controls; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Actions; +using Content.Shared.Alert; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.GameObjects.EntitySystems; +using NUnit.Framework; +using Robust.Client.Interfaces.UserInterface; +using Robust.Client.Player; + +namespace Content.IntegrationTests.Tests.GameObjects.Components.Mobs +{ + [TestFixture] + [TestOf(typeof(SharedActionsComponent))] + [TestOf(typeof(ClientActionsComponent))] + [TestOf(typeof(ServerActionsComponent))] + public class ActionsComponentTests : ContentIntegrationTest + { + [Test] + public async Task GrantsAndRevokesActionsTest() + { + var (client, server) = await StartConnectedServerClientPair(); + + await server.WaitIdleAsync(); + await client.WaitIdleAsync(); + + var serverPlayerManager = server.ResolveDependency(); + var innateActions = new List(); + + await server.WaitAssertion(() => + { + var player = serverPlayerManager.GetAllPlayers().Single(); + var playerEnt = player.AttachedEntity; + var actionsComponent = playerEnt.GetComponent(); + + // player should begin with their innate actions granted + innateActions.AddRange(actionsComponent.InnateActions); + foreach (var innateAction in actionsComponent.InnateActions) + { + Assert.That(actionsComponent.TryGetActionState(innateAction, out var innateState)); + Assert.That(innateState.Enabled); + } + + actionsComponent.Grant(ActionType.DebugInstant); + Assert.That(actionsComponent.TryGetActionState(ActionType.HumanScream, out var state) && state.Enabled); + }); + + // check that client has the actions + await server.WaitRunTicks(5); + await client.WaitRunTicks(5); + + var clientPlayerMgr = client.ResolveDependency(); + var clientUIMgr = client.ResolveDependency(); + var expectedOrder = new List(); + await client.WaitAssertion(() => + { + + var local = clientPlayerMgr.LocalPlayer; + var controlled = local.ControlledEntity; + var actionsComponent = controlled.GetComponent(); + + // we should have our innate actions and debug1. + foreach (var innateAction in innateActions) + { + Assert.That(actionsComponent.TryGetActionState(innateAction, out var innateState)); + Assert.That(innateState.Enabled); + } + Assert.That(actionsComponent.TryGetActionState(ActionType.DebugInstant, out var state) && state.Enabled); + + // innate actions should've auto-populated into our slots (in non-deterministic order), + // but debug1 should be in the last slot + var actionsUI = + clientUIMgr.StateRoot.Children.FirstOrDefault(c => c is ActionsUI) as ActionsUI; + Assert.That(actionsUI, Is.Not.Null); + + var expectedInnate = new HashSet(innateActions); + var expectEmpty = false; + expectedOrder.Clear(); + foreach (var slot in actionsUI.Slots) + { + if (expectEmpty) + { + Assert.That(slot.HasAssignment, Is.False); + Assert.That(slot.Item, Is.Null); + Assert.That(slot.Action, Is.Null); + Assert.That(slot.ActionEnabled, Is.False); + continue; + } + Assert.That(slot.HasAssignment); + // all the actions we gave so far are not tied to an item + Assert.That(slot.Item, Is.Null); + Assert.That(slot.Action, Is.Not.Null); + Assert.That(slot.ActionEnabled); + var asAction = slot.Action as ActionPrototype; + Assert.That(asAction, Is.Not.Null); + expectedOrder.Add(asAction.ActionType); + + if (expectedInnate.Count != 0) + { + Assert.That(expectedInnate.Remove(asAction.ActionType)); + } + else + { + Assert.That(asAction.ActionType, Is.EqualTo(ActionType.DebugInstant)); + Assert.That(slot.Cooldown, Is.Null); + expectEmpty = true; + } + } + }); + + // now revoke the action and check that the client sees it as revoked + await server.WaitAssertion(() => + { + var player = serverPlayerManager.GetAllPlayers().Single(); + var playerEnt = player.AttachedEntity; + var actionsComponent = playerEnt.GetComponent(); + actionsComponent.Revoke(ActionType.DebugInstant); + }); + + await server.WaitRunTicks(5); + await client.WaitRunTicks(5); + + await client.WaitAssertion(() => + { + + var local = clientPlayerMgr.LocalPlayer; + var controlled = local.ControlledEntity; + var actionsComponent = controlled.GetComponent(); + + // we should have our innate actions, but debug1 should be revoked + foreach (var innateAction in innateActions) + { + Assert.That(actionsComponent.TryGetActionState(innateAction, out var innateState)); + Assert.That(innateState.Enabled); + } + Assert.That(actionsComponent.TryGetActionState(ActionType.DebugInstant, out var state), Is.False); + + // all actions should be in the same order as before, but the slot with DebugInstant should appear + // disabled. + var actionsUI = + clientUIMgr.StateRoot.Children.FirstOrDefault(c => c is ActionsUI) as ActionsUI; + Assert.That(actionsUI, Is.Not.Null); + + var idx = 0; + foreach (var slot in actionsUI.Slots) + { + if (idx < expectedOrder.Count) + { + var expected = expectedOrder[idx++]; + Assert.That(slot.HasAssignment); + // all the actions we gave so far are not tied to an item + Assert.That(slot.Item, Is.Null); + Assert.That(slot.Action, Is.Not.Null); + var asAction = slot.Action as ActionPrototype; + Assert.That(asAction, Is.Not.Null); + + if (asAction.ActionType == ActionType.DebugInstant) + { + Assert.That(slot.ActionEnabled, Is.False); + } + else + { + Assert.That(slot.ActionEnabled); + } + } + else + { + Assert.That(slot.HasAssignment, Is.False); + Assert.That(slot.Item, Is.Null); + Assert.That(slot.Action, Is.Null); + Assert.That(slot.ActionEnabled, Is.False); + continue; + } + } + }); + } + + } +} diff --git a/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs index e48a18daac..073592d783 100644 --- a/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs +++ b/Content.IntegrationTests/Tests/GameObjects/Components/Mobs/AlertsComponentTests.cs @@ -2,14 +2,12 @@ using System.Threading.Tasks; using Content.Client.GameObjects.Components.Mobs; using Content.Client.UserInterface; +using Content.Client.UserInterface.Controls; using Content.Server.GameObjects.Components.Mobs; using Content.Shared.Alert; using NUnit.Framework; using Robust.Client.Interfaces.UserInterface; using Robust.Client.Player; -using Robust.Shared.Interfaces.Map; -using Robust.Shared.IoC; -using Robust.Shared.Map; namespace Content.IntegrationTests.Tests.GameObjects.Components.Mobs { diff --git a/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs b/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs index b9697ce770..c6ea3464a2 100644 --- a/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs +++ b/Content.IntegrationTests/Tests/Gravity/WeightlessStatusTests.cs @@ -25,7 +25,7 @@ namespace Content.IntegrationTests.Tests.Gravity name: HumanDummy id: HumanDummy components: - - type: AlertsUI + - type: Alerts "; [Test] public async Task WeightlessStatusTest() diff --git a/Content.Server/Actions/DebugInstant.cs b/Content.Server/Actions/DebugInstant.cs new file mode 100644 index 0000000000..04a99ede01 --- /dev/null +++ b/Content.Server/Actions/DebugInstant.cs @@ -0,0 +1,39 @@ +using Content.Server.Utility; +using Content.Shared.Actions; +using Content.Shared.Utility; +using JetBrains.Annotations; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + /// + /// Just shows a popup message.asd + /// + [UsedImplicitly] + public class DebugInstant : IInstantAction, IInstantItemAction + { + public string Message { get; private set; } + public float Cooldown { get; private set; } + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(this, x => x.Message, "message", "Instant action used."); + serializer.DataField(this, x => x.Cooldown, "cooldown", 0); + } + + public void DoInstantAction(InstantItemActionEventArgs args) + { + args.Performer.PopupMessageEveryone(Message); + if (Cooldown > 0) + { + args.ItemActions.Cooldown(args.ActionType, Cooldowns.SecondsFromNow(Cooldown)); + } + } + + public void DoInstantAction(InstantActionEventArgs args) + { + args.Performer.PopupMessageEveryone(Message); + args.PerformerActions.Cooldown(args.ActionType, Cooldowns.SecondsFromNow(Cooldown)); + } + } +} diff --git a/Content.Server/Actions/DebugTargetEntity.cs b/Content.Server/Actions/DebugTargetEntity.cs new file mode 100644 index 0000000000..89d4a6d751 --- /dev/null +++ b/Content.Server/Actions/DebugTargetEntity.cs @@ -0,0 +1,28 @@ +using Content.Server.Utility; +using Content.Shared.Actions; +using JetBrains.Annotations; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + [UsedImplicitly] + public class DebugTargetEntity : ITargetEntityAction, ITargetEntityItemAction + { + + public void ExposeData(ObjectSerializer serializer) + { + } + + public void DoTargetEntityAction(TargetEntityItemActionEventArgs args) + { + args.Performer.PopupMessageEveryone(args.Item.Name + ": Clicked " + + args.Target.Name); + } + + public void DoTargetEntityAction(TargetEntityActionEventArgs args) + { + args.Performer.PopupMessageEveryone("Clicked " + + args.Target.Name); + } + } +} diff --git a/Content.Server/Actions/DebugTargetPoint.cs b/Content.Server/Actions/DebugTargetPoint.cs new file mode 100644 index 0000000000..00429a1a0c --- /dev/null +++ b/Content.Server/Actions/DebugTargetPoint.cs @@ -0,0 +1,29 @@ +using Content.Server.Utility; +using Content.Shared.Actions; +using JetBrains.Annotations; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + [UsedImplicitly] + public class DebugTargetPoint : ITargetPointAction, ITargetPointItemAction + { + public void ExposeData(ObjectSerializer serializer) + { + } + + public void DoTargetPointAction(TargetPointItemActionEventArgs args) + { + args.Performer.PopupMessageEveryone(args.Item.Name + ": Clicked local position " + + args.Target); + } + + public void DoTargetPointAction(TargetPointActionEventArgs args) + { + args.Performer.PopupMessageEveryone("Clicked local position " + + args.Target); + } + } +} diff --git a/Content.Server/Actions/DebugToggle.cs b/Content.Server/Actions/DebugToggle.cs new file mode 100644 index 0000000000..56ba9d8284 --- /dev/null +++ b/Content.Server/Actions/DebugToggle.cs @@ -0,0 +1,48 @@ +using Content.Server.Utility; +using Content.Shared.Actions; +using JetBrains.Annotations; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + [UsedImplicitly] + public class DebugToggle : IToggleAction, IToggleItemAction + { + public string MessageOn { get; private set; } + public string MessageOff { get; private set; } + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(this, x => x.MessageOn, "messageOn", "on!"); + serializer.DataField(this, x => x.MessageOff, "messageOff", "off!"); + } + + public bool DoToggleAction(ToggleItemActionEventArgs args) + { + if (args.ToggledOn) + { + args.Performer.PopupMessageEveryone(args.Item.Name + ": " + MessageOn); + } + else + { + args.Performer.PopupMessageEveryone(args.Item.Name + ": " +MessageOff); + } + + return true; + } + + public bool DoToggleAction(ToggleActionEventArgs args) + { + if (args.ToggledOn) + { + args.Performer.PopupMessageEveryone(MessageOn); + } + else + { + args.Performer.PopupMessageEveryone(MessageOff); + } + + return true; + } + } +} diff --git a/Content.Server/Actions/ScreamAction.cs b/Content.Server/Actions/ScreamAction.cs new file mode 100644 index 0000000000..e89d2452e7 --- /dev/null +++ b/Content.Server/Actions/ScreamAction.cs @@ -0,0 +1,82 @@ +using System; +using System.Collections.Generic; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Actions; +using Content.Shared.Audio; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.GameObjects.EntitySystems; +using Content.Shared.Preferences; +using Content.Shared.Utility; +using JetBrains.Annotations; +using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.Audio; +using Robust.Shared.GameObjects.Systems; +using Robust.Shared.Interfaces.Random; +using Robust.Shared.IoC; +using Robust.Shared.Random; +using Robust.Shared.Serialization; + +namespace Content.Server.Actions +{ + [UsedImplicitly] + public class ScreamAction : IInstantAction + { + private const float Variation = 0.125f; + private const float Volume = 4f; + + private List _male; + private List _female; + private string _wilhelm; + /// seconds + private float _cooldown; + + private IRobustRandom _random; + + public ScreamAction() + { + _random = IoCManager.Resolve(); + } + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(ref _male, "male", null); + serializer.DataField(ref _female, "female", null); + serializer.DataField(ref _wilhelm, "wilhelm", null); + serializer.DataField(ref _cooldown, "cooldown", 10); + } + + public void DoInstantAction(InstantActionEventArgs args) + { + if (!ActionBlockerSystem.CanSpeak(args.Performer)) return; + if (!args.Performer.TryGetComponent(out var humanoid)) return; + if (!args.Performer.TryGetComponent(out var actions)) return; + + if (_random.Prob(.01f) && !string.IsNullOrWhiteSpace(_wilhelm)) + { + EntitySystem.Get().PlayFromEntity(_wilhelm, args.Performer, AudioParams.Default.WithVolume(Volume)); + } + else + { + switch (humanoid.Sex) + { + case Sex.Male: + if (_male == null) break; + EntitySystem.Get().PlayFromEntity(_random.Pick(_male), args.Performer, + AudioHelpers.WithVariation(Variation).WithVolume(Volume)); + break; + case Sex.Female: + if (_female == null) break; + EntitySystem.Get().PlayFromEntity(_random.Pick(_female), args.Performer, + AudioHelpers.WithVariation(Variation).WithVolume(Volume)); + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + + + actions.Cooldown(args.ActionType, Cooldowns.SecondsFromNow(_cooldown)); + } + } +} diff --git a/Content.Server/Alert/Click/ResistFire.cs b/Content.Server/Alert/Click/ResistFire.cs new file mode 100644 index 0000000000..e7f7e42231 --- /dev/null +++ b/Content.Server/Alert/Click/ResistFire.cs @@ -0,0 +1,24 @@ +using Content.Server.GameObjects.Components.Atmos; + using Content.Shared.Alert; + using JetBrains.Annotations; + using Robust.Shared.Serialization; + +namespace Content.Server.Alert.Click +{ + /// + /// Resist fire + /// + [UsedImplicitly] + public class ResistFire : IAlertClick + { + public void ExposeData(ObjectSerializer serializer) { } + + public void AlertClicked(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out FlammableComponent flammable)) + { + flammable.Resist(); + } + } + } +} diff --git a/Content.Server/Alert/Click/StopPiloting.cs b/Content.Server/Alert/Click/StopPiloting.cs new file mode 100644 index 0000000000..cc8164c071 --- /dev/null +++ b/Content.Server/Alert/Click/StopPiloting.cs @@ -0,0 +1,24 @@ +using Content.Server.GameObjects.Components.Movement; + using Content.Shared.Alert; + using JetBrains.Annotations; + using Robust.Shared.Serialization; + +namespace Content.Server.Alert.Click +{ + /// + /// Stop piloting shuttle + /// + [UsedImplicitly] + public class StopPiloting : IAlertClick + { + public void ExposeData(ObjectSerializer serializer) { } + + public void AlertClicked(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out ShuttleControllerComponent controller)) + { + controller.RemoveController(); + } + } + } +} diff --git a/Content.Server/Alert/Click/StopPulling.cs b/Content.Server/Alert/Click/StopPulling.cs new file mode 100644 index 0000000000..2153769ae6 --- /dev/null +++ b/Content.Server/Alert/Click/StopPulling.cs @@ -0,0 +1,27 @@ +using Content.Shared.Alert; + using Content.Shared.GameObjects.Components.Pulling; + using Content.Shared.GameObjects.EntitySystems; + using JetBrains.Annotations; + using Robust.Shared.GameObjects.Systems; + using Robust.Shared.Serialization; + +namespace Content.Server.Alert.Click +{ + /// + /// Stop pulling something + /// + [UsedImplicitly] + public class StopPulling : IAlertClick + { + public void ExposeData(ObjectSerializer serializer) { } + + public void AlertClicked(ClickAlertEventArgs args) + { + EntitySystem + .Get() + .GetPulled(args.Player)? + .GetComponentOrNull()? + .TryStopPull(); + } + } +} diff --git a/Content.Server/Alert/Click/Unbuckle.cs b/Content.Server/Alert/Click/Unbuckle.cs new file mode 100644 index 0000000000..742f19ad29 --- /dev/null +++ b/Content.Server/Alert/Click/Unbuckle.cs @@ -0,0 +1,24 @@ +using Content.Server.GameObjects.Components.Buckle; +using Content.Shared.Alert; +using Robust.Shared.Serialization; +using JetBrains.Annotations; + +namespace Content.Server.Alert.Click +{ + /// + /// Unbuckles if player is currently buckled. + /// + [UsedImplicitly] + public class Unbuckle : IAlertClick + { + public void ExposeData(ObjectSerializer serializer) { } + + public void AlertClicked(ClickAlertEventArgs args) + { + if (args.Player.TryGetComponent(out BuckleComponent buckle)) + { + buckle.TryUnbuckle(args.Player); + } + } + } +} diff --git a/Content.Server/Commands/Actions/CooldownAction.cs b/Content.Server/Commands/Actions/CooldownAction.cs new file mode 100644 index 0000000000..15c9b9a903 --- /dev/null +++ b/Content.Server/Commands/Actions/CooldownAction.cs @@ -0,0 +1,65 @@ +#nullable enable +using System; +using Content.Server.Administration; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Actions; +using Content.Shared.Administration; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; + +namespace Content.Server.Commands.Actions +{ + [AdminCommand(AdminFlags.Debug)] + public sealed class CooldownAction : IClientCommand + { + public string Command => "coolaction"; + public string Description => "Sets a cooldown on an action for a player, defaulting to current player"; + public string Help => "coolaction "; + + public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) + { + if (player == null) return; + var attachedEntity = player.AttachedEntity; + if (args.Length > 2) + { + var target = args[2]; + if (!CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; + } + + if (attachedEntity == null) return; + if (!attachedEntity.TryGetComponent(out ServerActionsComponent? actionsComponent)) + { + shell.SendText(player, "user has no actions component"); + return; + } + + var actionTypeRaw = args[0]; + if (!Enum.TryParse(actionTypeRaw, out var actionType)) + { + shell.SendText(player, "unrecognized ActionType enum value, please" + + " ensure you used correct casing: " + actionTypeRaw); + return; + } + var actionMgr = IoCManager.Resolve(); + + if (!actionMgr.TryGet(actionType, out var action)) + { + shell.SendText(player, "unrecognized actionType " + actionType); + return; + } + + var cooldownStart = IoCManager.Resolve().CurTime; + if (!uint.TryParse(args[1], out var seconds)) + { + shell.SendText(player, "cannot parse seconds: " + args[1]); + return; + } + + var cooldownEnd = cooldownStart.Add(TimeSpan.FromSeconds(seconds)); + + actionsComponent.Cooldown(action.ActionType, (cooldownStart, cooldownEnd)); + } + } +} diff --git a/Content.Server/Commands/Actions/GrantAction.cs b/Content.Server/Commands/Actions/GrantAction.cs new file mode 100644 index 0000000000..fd2cd3d6b8 --- /dev/null +++ b/Content.Server/Commands/Actions/GrantAction.cs @@ -0,0 +1,52 @@ +#nullable enable +using System; +using Content.Server.Administration; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Actions; +using Content.Shared.Administration; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.IoC; + +namespace Content.Server.Commands.Actions +{ + [AdminCommand(AdminFlags.Debug)] + public sealed class GrantAction : IClientCommand + { + public string Command => "grantaction"; + public string Description => "Grants an action to a player, defaulting to current player"; + public string Help => "grantaction "; + public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) + { + if (player == null) return; + var attachedEntity = player.AttachedEntity; + if (args.Length > 1) + { + var target = args[1]; + if (!Commands.CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; + } + + if (attachedEntity == null) return; + if (!attachedEntity.TryGetComponent(out ServerActionsComponent? actionsComponent)) + { + shell.SendText(player, "user has no actions component"); + return; + } + + var actionTypeRaw = args[0]; + if (!Enum.TryParse(actionTypeRaw, out var actionType)) + { + shell.SendText(player, "unrecognized ActionType enum value, please" + + " ensure you used correct casing: " + actionTypeRaw); + return; + } + var actionMgr = IoCManager.Resolve(); + if (!actionMgr.TryGet(actionType, out var action)) + { + shell.SendText(player, "unrecognized actionType " + actionType); + return; + } + actionsComponent.Grant(action.ActionType); + } + } +} diff --git a/Content.Server/Commands/Actions/RevokeAction.cs b/Content.Server/Commands/Actions/RevokeAction.cs new file mode 100644 index 0000000000..be7ca6082d --- /dev/null +++ b/Content.Server/Commands/Actions/RevokeAction.cs @@ -0,0 +1,53 @@ +#nullable enable +using System; +using Content.Server.Administration; +using Content.Server.GameObjects.Components.Mobs; +using Content.Shared.Actions; +using Content.Shared.Administration; +using Robust.Server.Interfaces.Console; +using Robust.Server.Interfaces.Player; +using Robust.Shared.IoC; + +namespace Content.Server.Commands.Actions +{ + [AdminCommand(AdminFlags.Debug)] + public sealed class RevokeAction : IClientCommand + { + public string Command => "revokeaction"; + public string Description => "Revokes an action from a player, defaulting to current player"; + public string Help => "revokeaction "; + + public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) + { + if (player == null) return; + var attachedEntity = player.AttachedEntity; + if (args.Length > 1) + { + var target = args[1]; + if (!CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; + } + if (attachedEntity == null) return; + if (!attachedEntity.TryGetComponent(out ServerActionsComponent? actionsComponent)) + { + shell.SendText(player, "user has no actions component"); + return; + } + + var actionTypeRaw = args[0]; + if (!Enum.TryParse(actionTypeRaw, out var actionType)) + { + shell.SendText(player, "unrecognized ActionType enum value, please" + + " ensure you used correct casing: " + actionTypeRaw); + return; + } + var actionMgr = IoCManager.Resolve(); + if (!actionMgr.TryGet(actionType, out var action)) + { + shell.SendText(player, "unrecognized actionType " + actionType); + return; + } + + actionsComponent.Revoke(action.ActionType); + } + } +} diff --git a/Content.Server/Commands/Alerts/ClearAlert.cs b/Content.Server/Commands/Alerts/ClearAlert.cs index 5761df0401..c8fabad81c 100644 --- a/Content.Server/Commands/Alerts/ClearAlert.cs +++ b/Content.Server/Commands/Alerts/ClearAlert.cs @@ -19,22 +19,20 @@ namespace Content.Server.Commands.Alerts public void Execute(IConsoleShell shell, IPlayerSession? player, string[] args) { - var attachedEntity = player?.AttachedEntity; - - if (attachedEntity == null) + if (player?.AttachedEntity == null) { shell.SendText(player, "You don't have an entity."); return; } + var attachedEntity = player.AttachedEntity; + if (args.Length > 1) { var target = args[1]; if (!CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; } - if (!CommandUtils.ValidateAttachedEntity(shell, player, attachedEntity)) return; - if (!attachedEntity.TryGetComponent(out ServerAlertsComponent? alertsComponent)) { shell.SendText(player, "user has no alerts component"); diff --git a/Content.Server/Commands/Alerts/ShowAlert.cs b/Content.Server/Commands/Alerts/ShowAlert.cs index ef85091bb5..20a08cc809 100644 --- a/Content.Server/Commands/Alerts/ShowAlert.cs +++ b/Content.Server/Commands/Alerts/ShowAlert.cs @@ -39,9 +39,6 @@ namespace Content.Server.Commands.Alerts if (!CommandUtils.TryGetAttachedEntityByUsernameOrId(shell, target, player, out attachedEntity)) return; } - if (!CommandUtils.ValidateAttachedEntity(shell, player, attachedEntity)) - return; - if (!attachedEntity.TryGetComponent(out ServerAlertsComponent? alertsComponent)) { shell.SendText(player, "user has no alerts component"); diff --git a/Content.Server/Commands/CommandUtils.cs b/Content.Server/Commands/CommandUtils.cs index b6535dc7d3..66e36773fb 100644 --- a/Content.Server/Commands/CommandUtils.cs +++ b/Content.Server/Commands/CommandUtils.cs @@ -1,4 +1,6 @@ -using System; +#nullable enable +using System; +using System.Diagnostics.CodeAnalysis; using Robust.Server.Interfaces.Console; using Robust.Server.Interfaces.Player; using Robust.Shared.Interfaces.GameObjects; @@ -17,7 +19,7 @@ namespace Content.Server.Commands /// sending a failure to the performer if unable to. /// public static bool TryGetSessionByUsernameOrId(IConsoleShell shell, - string usernameOrId, IPlayerSession performer, out IPlayerSession session) + string usernameOrId, IPlayerSession performer, [NotNullWhen(true)] out IPlayerSession? session) { var plyMgr = IoCManager.Resolve(); if (plyMgr.TryGetSessionByUsername(usernameOrId, out session)) return true; @@ -37,7 +39,7 @@ namespace Content.Server.Commands /// sending a failure to the performer if unable to. /// public static bool TryGetAttachedEntityByUsernameOrId(IConsoleShell shell, - string usernameOrId, IPlayerSession performer, out IEntity attachedEntity) + string usernameOrId, IPlayerSession performer, [NotNullWhen(true)] out IEntity? attachedEntity) { attachedEntity = null; if (!TryGetSessionByUsernameOrId(shell, usernameOrId, performer, out var session)) return false; @@ -50,17 +52,5 @@ namespace Content.Server.Commands attachedEntity = session.AttachedEntity; return true; } - - /// - /// Checks if attached entity is null, returning false and sending a message - /// to performer if not. - /// - public static bool ValidateAttachedEntity(IConsoleShell shell, IPlayerSession performer, IEntity attachedEntity) - { - if (attachedEntity != null) return true; - shell.SendText(performer, "User has no attached entity."); - return false; - } - } } diff --git a/Content.Server/EntryPoint.cs b/Content.Server/EntryPoint.cs index 9275046155..f7bbac022d 100644 --- a/Content.Server/EntryPoint.cs +++ b/Content.Server/EntryPoint.cs @@ -10,6 +10,7 @@ using Content.Server.Interfaces.Chat; using Content.Server.Interfaces.GameTicking; using Content.Server.Interfaces.PDA; using Content.Server.Sandbox; +using Content.Shared.Actions; using Content.Shared.Kitchen; using Content.Shared.Alert; using Robust.Server.Interfaces.Player; @@ -81,6 +82,7 @@ namespace Content.Server _gameTicker.Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); + IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().Initialize(); diff --git a/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs b/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs index 3bfdcd27f9..17c40e111b 100644 --- a/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs +++ b/Content.Server/GameObjects/Components/Atmos/FlammableComponent.cs @@ -100,7 +100,7 @@ namespace Content.Server.GameObjects.Components.Atmos return; } - status?.ShowAlert(AlertType.Fire, onClickAlert: OnClickAlert); + status?.ShowAlert(AlertType.Fire); if (FireStacks > 0) { @@ -152,14 +152,6 @@ namespace Content.Server.GameObjects.Components.Atmos } } - private void OnClickAlert(ClickAlertEventArgs args) - { - if (args.Player.TryGetComponent(out FlammableComponent flammable)) - { - flammable.Resist(); - } - } - public void CollideWith(IEntity collidedWith) { if (!collidedWith.TryGetComponent(out FlammableComponent otherFlammable)) diff --git a/Content.Server/GameObjects/Components/Atmos/GasTankComponent.cs b/Content.Server/GameObjects/Components/Atmos/GasTankComponent.cs index afb603b0b4..5dd0bcb40d 100644 --- a/Content.Server/GameObjects/Components/Atmos/GasTankComponent.cs +++ b/Content.Server/GameObjects/Components/Atmos/GasTankComponent.cs @@ -5,18 +5,22 @@ using Content.Server.Explosions; using Content.Server.GameObjects.Components.Body.Respiratory; using Content.Server.Interfaces; using Content.Server.Utility; +using Content.Shared.Actions; using Content.Shared.Atmos; using Content.Shared.Audio; using Content.Shared.GameObjects.Components.Atmos.GasTank; +using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.EntitySystems; using Content.Shared.GameObjects.Verbs; using Content.Shared.Interfaces.GameObjects.Components; +using JetBrains.Annotations; using Robust.Server.GameObjects.Components.UserInterface; using Robust.Server.GameObjects.EntitySystems; using Robust.Server.Interfaces.GameObjects; using Robust.Server.Interfaces.Player; using Robust.Shared.Containers; using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.ComponentDependencies; using Robust.Shared.GameObjects.Systems; using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.Localization; @@ -30,13 +34,15 @@ namespace Content.Server.GameObjects.Components.Atmos [ComponentReference(typeof(IActivate))] public class GasTankComponent : SharedGasTankComponent, IExamine, IGasMixtureHolder, IUse, IDropped, IActivate { - private const float MaxExplosionRange = 14f; + private const float MaxExplosionRange = 14f; private const float DefaultOutputPressure = Atmospherics.OneAtmosphere; private float _pressureResistance; private int _integrity = 3; + [ComponentDependency] private readonly ItemActionsComponent? _itemActions = null; + [ViewVariables] private BoundUserInterface? _userInterface; [ViewVariables] public GasMixture? Air { get; set; } @@ -191,14 +197,18 @@ namespace Content.Server.GameObjects.Components.Atmos private void UpdateUserInterface(bool initialUpdate = false) { + var internals = GetInternalsComponent(); _userInterface?.SetState( new GasTankBoundUserInterfaceState { TankPressure = Air?.Pressure ?? 0, OutputPressure = initialUpdate ? OutputPressure : (float?) null, InternalsConnected = IsConnected, - CanConnectInternals = IsFunctional && GetInternalsComponent() != null + CanConnectInternals = IsFunctional && internals != null }); + + if (internals == null) return; + _itemActions?.GrantOrUpdate(ItemActionType.ToggleInternals, IsFunctional, IsConnected); } private void UserInterfaceOnOnReceiveMessage(ServerBoundUserInterfaceMessage message) @@ -214,8 +224,9 @@ namespace Content.Server.GameObjects.Components.Atmos } } - private void ToggleInternals() + internal void ToggleInternals() { + if (!ActionBlockerSystem.CanUse(GetInternalsComponent()?.Owner)) return; if (IsConnected) { DisconnectFromInternals(); @@ -311,6 +322,11 @@ namespace Content.Server.GameObjects.Components.Atmos _integrity++; } + public void Dropped(DroppedEventArgs eventArgs) + { + DisconnectFromInternals(eventArgs.User); + } + /// /// Open interaction window /// @@ -341,10 +357,21 @@ namespace Content.Server.GameObjects.Components.Atmos component.OpenInterface(actor.playerSession); } } + } - public void Dropped(DroppedEventArgs eventArgs) + [UsedImplicitly] + public class ToggleInternalsAction : IToggleItemAction + { + public void ExposeData(ObjectSerializer serializer) {} + + public bool DoToggleAction(ToggleItemActionEventArgs args) { - DisconnectFromInternals(eventArgs.User); + if (!args.Item.TryGetComponent(out var gasTankComponent)) return false; + // no change + if (gasTankComponent.IsConnected == args.ToggledOn) return false; + gasTankComponent.ToggleInternals(); + // did we successfully toggle to the desired status? + return gasTankComponent.IsConnected == args.ToggledOn; } } } diff --git a/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs b/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs index 2c7cdc67d4..5fef60e9de 100644 --- a/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs +++ b/Content.Server/GameObjects/Components/Buckle/BuckleComponent.cs @@ -108,8 +108,7 @@ namespace Content.Server.GameObjects.Components.Buckle if (Buckled) { - _serverAlertsComponent.ShowAlert(BuckledTo != null ? BuckledTo.BuckledAlertType : AlertType.Buckled, - onClickAlert: OnClickAlert); + _serverAlertsComponent.ShowAlert(BuckledTo?.BuckledAlertType ?? AlertType.Buckled); } else { @@ -117,14 +116,6 @@ namespace Content.Server.GameObjects.Components.Buckle } } - private void OnClickAlert(ClickAlertEventArgs args) - { - if (args.Player.TryGetComponent(out BuckleComponent? buckle)) - { - buckle.TryUnbuckle(args.Player); - } - } - /// /// Reattaches this entity to the strap, modifying its position and rotation. diff --git a/Content.Server/GameObjects/Components/GUI/HandsComponent.cs b/Content.Server/GameObjects/Components/GUI/HandsComponent.cs index 7be23be0e4..35b22f0c59 100644 --- a/Content.Server/GameObjects/Components/GUI/HandsComponent.cs +++ b/Content.Server/GameObjects/Components/GUI/HandsComponent.cs @@ -30,6 +30,7 @@ namespace Content.Server.GameObjects.Components.GUI [RegisterComponent] [ComponentReference(typeof(IHandsComponent))] [ComponentReference(typeof(ISharedHandsComponent))] + [ComponentReference(typeof(SharedHandsComponent))] public class HandsComponent : SharedHandsComponent, IHandsComponent, IBodyPartAdded, IBodyPartRemoved { [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; @@ -82,7 +83,7 @@ namespace Content.Server.GameObjects.Components.GUI } } - public bool IsHolding(IEntity entity) + public override bool IsHolding(IEntity entity) { foreach (var hand in _hands) { @@ -165,6 +166,7 @@ namespace Content.Server.GameObjects.Components.GUI } Dirty(); + var success = hand.Container.Insert(item.Owner); if (success) { @@ -172,6 +174,9 @@ namespace Content.Server.GameObjects.Components.GUI OnItemChanged?.Invoke(); } + _entitySystemManager.GetEntitySystem().EquippedHandInteraction(Owner, item.Owner, + ToSharedHand(hand)); + _entitySystemManager.GetEntitySystem().HandSelectedInteraction(Owner, item.Owner); return success; @@ -266,6 +271,9 @@ namespace Content.Server.GameObjects.Components.GUI return false; } + _entitySystemManager.GetEntitySystem().UnequippedHandInteraction(Owner, item.Owner, + ToSharedHand(hand)); + if (doDropInteraction && !DroppedInteraction(item, false)) return false; @@ -288,6 +296,61 @@ namespace Content.Server.GameObjects.Components.GUI return true; } + + public bool Drop(string slot, BaseContainer targetContainer, bool doMobChecks = true, bool doDropInteraction = true) + { + if (slot == null) + { + throw new ArgumentNullException(nameof(slot)); + } + + if (targetContainer == null) + { + throw new ArgumentNullException(nameof(targetContainer)); + } + + var hand = GetHand(slot); + if (!CanDrop(slot, doMobChecks) || hand?.Entity == null) + { + return false; + } + + if (!hand.Container.CanRemove(hand.Entity)) + { + return false; + } + + if (!targetContainer.CanInsert(hand.Entity)) + { + return false; + } + + var item = hand.Entity.GetComponent(); + + if (!hand.Container.Remove(hand.Entity)) + { + throw new InvalidOperationException(); + } + + _entitySystemManager.GetEntitySystem().UnequippedHandInteraction(Owner, item.Owner, + ToSharedHand(hand)); + + if (doDropInteraction && !DroppedInteraction(item, doMobChecks)) + return false; + + item.RemovedFromSlot(); + + if (!targetContainer.Insert(item.Owner)) + { + throw new InvalidOperationException(); + } + + OnItemChanged?.Invoke(); + + Dirty(); + return true; + } + public bool Drop(IEntity entity, EntityCoordinates coords, bool doMobChecks = true, bool doDropInteraction = true) { if (entity == null) @@ -323,57 +386,6 @@ namespace Content.Server.GameObjects.Components.GUI return Drop(slot, Owner.Transform.Coordinates, mobChecks, doDropInteraction); } - public bool Drop(string slot, BaseContainer targetContainer, bool doMobChecks = true, bool doDropInteraction = true) - { - if (slot == null) - { - throw new ArgumentNullException(nameof(slot)); - } - - if (targetContainer == null) - { - throw new ArgumentNullException(nameof(targetContainer)); - } - - var hand = GetHand(slot); - if (!CanDrop(slot, doMobChecks) || hand?.Entity == null) - { - return false; - } - - if (!hand.Container.CanRemove(hand.Entity)) - { - return false; - } - - if (!targetContainer.CanInsert(hand.Entity)) - { - return false; - } - - var item = hand.Entity.GetComponent(); - - if (!hand.Container.Remove(hand.Entity)) - { - throw new InvalidOperationException(); - } - - if (doDropInteraction && !DroppedInteraction(item, doMobChecks)) - return false; - - item.RemovedFromSlot(); - - if (!targetContainer.Insert(item.Owner)) - { - throw new InvalidOperationException(); - } - - OnItemChanged?.Invoke(); - - Dirty(); - return true; - } - public bool Drop(IEntity entity, BaseContainer targetContainer, bool doMobChecks = true, bool doDropInteraction = true) { if (entity == null) @@ -463,19 +475,28 @@ namespace Content.Server.GameObjects.Components.GUI for (var i = 0; i < _hands.Count; i++) { - var location = i == 0 - ? HandLocation.Right - : i == _hands.Count - 1 - ? HandLocation.Left - : HandLocation.Middle; - - var hand = _hands[i].ToShared(i, location); + var hand = _hands[i].ToShared(i, IndexToHandLocation(i)); hands[i] = hand; } return new HandsComponentState(hands, ActiveHand); } + private HandLocation IndexToHandLocation(int index) + { + return index == 0 + ? HandLocation.Right + : index == _hands.Count - 1 + ? HandLocation.Left + : HandLocation.Middle; + } + + private SharedHand ToSharedHand(Hand hand) + { + var index = _hands.IndexOf(hand); + return hand.ToShared(index, IndexToHandLocation(index)); + } + public void SwapHands() { if (ActiveHand == null) diff --git a/Content.Server/GameObjects/Components/GUI/InventoryComponent.cs b/Content.Server/GameObjects/Components/GUI/InventoryComponent.cs index 3de18229ef..33152537ec 100644 --- a/Content.Server/GameObjects/Components/GUI/InventoryComponent.cs +++ b/Content.Server/GameObjects/Components/GUI/InventoryComponent.cs @@ -25,6 +25,7 @@ using static Content.Shared.GameObjects.Components.Inventory.SharedInventoryComp namespace Content.Server.GameObjects.Components.GUI { [RegisterComponent] + [ComponentReference(typeof(SharedInventoryComponent))] public class InventoryComponent : SharedInventoryComponent, IExAct, IEffectBlocker, IPressureProtection { [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!; @@ -572,5 +573,20 @@ namespace Content.Server.GameObjects.Components.GUI } } } + + public override bool IsEquipped(IEntity item) + { + if (item == null) return false; + foreach (var containerSlot in _slotContainers.Values) + { + // we don't want a recursive check here + if (containerSlot.Contains(item)) + { + return true; + } + } + + return false; + } } } diff --git a/Content.Server/GameObjects/Components/Interactable/HandheldLightComponent.cs b/Content.Server/GameObjects/Components/Interactable/HandheldLightComponent.cs index 25f3314fc5..958ec9768f 100644 --- a/Content.Server/GameObjects/Components/Interactable/HandheldLightComponent.cs +++ b/Content.Server/GameObjects/Components/Interactable/HandheldLightComponent.cs @@ -1,18 +1,26 @@ #nullable enable using System.Threading.Tasks; +using Content.Server.GameObjects.Components.Atmos; +using Content.Server.GameObjects.Components.GUI; using Content.Server.GameObjects.Components.Items.Clothing; using Content.Server.GameObjects.Components.Items.Storage; +using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Power; +using Content.Shared.Actions; using Content.Server.GameObjects.Components.Weapon.Ranged.Barrels; using Content.Shared.GameObjects.Components; +using Content.Shared.GameObjects.Components.Mobs; using Content.Shared.GameObjects.EntitySystems; using Content.Shared.GameObjects.Verbs; using Content.Shared.Interfaces; using Content.Shared.Interfaces.GameObjects.Components; using Content.Shared.Utility; +using JetBrains.Annotations; using Robust.Server.GameObjects; using Robust.Server.GameObjects.EntitySystems; +using Robust.Shared.Containers; using Robust.Shared.GameObjects; +using Robust.Shared.GameObjects.ComponentDependencies; using Robust.Shared.GameObjects.Systems; using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.Localization; @@ -45,6 +53,8 @@ namespace Content.Server.GameObjects.Components.Interactable [ViewVariables(VVAccess.ReadWrite)] public string? TurnOnFailSound; [ViewVariables(VVAccess.ReadWrite)] public string? TurnOffSound; + [ComponentDependency] private readonly ItemActionsComponent? _itemActions = null; + /// /// Client-side ItemStatus level /// @@ -98,8 +108,9 @@ namespace Content.Server.GameObjects.Components.Interactable /// Illuminates the light if it is not active, extinguishes it if it is active. /// /// True if the light's status was toggled, false otherwise. - private bool ToggleStatus(IEntity user) + public bool ToggleStatus(IEntity user) { + if (!ActionBlockerSystem.CanUse(user)) return false; return Activated ? TurnOff() : TurnOn(user); } @@ -112,6 +123,7 @@ namespace Content.Server.GameObjects.Components.Interactable SetState(false); Activated = false; + UpdateLightAction(); if (makeNoise) { @@ -132,6 +144,7 @@ namespace Content.Server.GameObjects.Components.Interactable { if (TurnOnFailSound != null) EntitySystem.Get().PlayFromEntity(TurnOnFailSound, Owner); Owner.PopupMessage(user, Loc.GetString("Cell missing...")); + UpdateLightAction(); return false; } @@ -142,10 +155,12 @@ namespace Content.Server.GameObjects.Components.Interactable { if (TurnOnFailSound != null) EntitySystem.Get().PlayFromEntity(TurnOnFailSound, Owner); Owner.PopupMessage(user, Loc.GetString("Dead cell...")); + UpdateLightAction(); return false; } Activated = true; + UpdateLightAction(); SetState(true); if (TurnOnSound != null) EntitySystem.Get().PlayFromEntity(TurnOnSound, Owner); @@ -175,6 +190,11 @@ namespace Content.Server.GameObjects.Components.Interactable } } + private void UpdateLightAction() + { + _itemActions?.Toggle(ItemActionType.ToggleLight, Activated); + } + public void OnUpdate(float frameTime) { if (Cell == null) @@ -249,4 +269,17 @@ namespace Content.Server.GameObjects.Components.Interactable } } } + + [UsedImplicitly] + public class ToggleLightAction : IToggleItemAction + { + public void ExposeData(ObjectSerializer serializer) {} + + public bool DoToggleAction(ToggleItemActionEventArgs args) + { + if (!args.Item.TryGetComponent(out var lightComponent)) return false; + if (lightComponent.Activated == args.ToggledOn) return false; + return lightComponent.ToggleStatus(args.Performer); + } + } } diff --git a/Content.Server/GameObjects/Components/Items/DebugEquipComponent.cs b/Content.Server/GameObjects/Components/Items/DebugEquipComponent.cs new file mode 100644 index 0000000000..6fd0a50f99 --- /dev/null +++ b/Content.Server/GameObjects/Components/Items/DebugEquipComponent.cs @@ -0,0 +1,35 @@ +using Content.Shared.Interfaces; +using Content.Shared.Interfaces.GameObjects.Components; +using Robust.Shared.GameObjects; + +namespace Content.Server.GameObjects.Components.Items +{ + /// + /// Pops up a message when equipped / unequipped (including hands). + /// For debugging purposes. + /// + [RegisterComponent] + public class DebugEquipComponent : Component, IEquipped, IEquippedHand, IUnequipped, IUnequippedHand + { + public override string Name => "DebugEquip"; + public void Equipped(EquippedEventArgs eventArgs) + { + eventArgs.User.PopupMessage("equipped " + Owner.Name); + } + + public void EquippedHand(EquippedHandEventArgs eventArgs) + { + eventArgs.User.PopupMessage("equipped hand " + Owner.Name); + } + + public void Unequipped(UnequippedEventArgs eventArgs) + { + eventArgs.User.PopupMessage("unequipped " + Owner.Name); + } + + public void UnequippedHand(UnequippedHandEventArgs eventArgs) + { + eventArgs.User.PopupMessage("unequipped hand" + Owner.Name); + } + } +} diff --git a/Content.Server/GameObjects/Components/Mobs/ServerActionsComponent.cs b/Content.Server/GameObjects/Components/Mobs/ServerActionsComponent.cs new file mode 100644 index 0000000000..e5e80544b5 --- /dev/null +++ b/Content.Server/GameObjects/Components/Mobs/ServerActionsComponent.cs @@ -0,0 +1,199 @@ +#nullable enable +using System; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.GameObjects.EntitySystems; +using Robust.Server.Interfaces.GameObjects; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Network; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Map; +using Robust.Shared.Maths; +using Robust.Shared.Players; + +namespace Content.Server.GameObjects.Components.Mobs +{ + [RegisterComponent] + [ComponentReference(typeof(SharedActionsComponent))] + public sealed class ServerActionsComponent : SharedActionsComponent + { + [Dependency] private readonly IServerEntityManager _entityManager = default!; + + public override void HandleNetworkMessage(ComponentMessage message, INetChannel netChannel, ICommonSession? session = null) + { + base.HandleNetworkMessage(message, netChannel, session); + + if (message is not BasePerformActionMessage performActionMessage) return; + if (session == null) + { + throw new ArgumentNullException(nameof(session)); + } + + var player = session.AttachedEntity; + if (player != Owner) return; + var attempt = ActionAttempt(performActionMessage, session); + if (attempt == null) return; + + if (!attempt.TryGetActionState(this, out var actionState) || !actionState.Enabled) + { + Logger.DebugS("action", "user {0} attempted to use" + + " action {1} which is not granted to them", player.Name, + attempt); + return; + } + + if (actionState.IsOnCooldown(GameTiming)) + { + Logger.DebugS("action", "user {0} attempted to use" + + " action {1} which is on cooldown", player.Name, + attempt); + return; + } + + switch (performActionMessage.BehaviorType) + { + case BehaviorType.Instant: + attempt.DoInstantAction(player); + break; + case BehaviorType.Toggle: + if (performActionMessage is not IToggleActionMessage toggleMsg) return; + if (toggleMsg.ToggleOn == actionState.ToggledOn) + { + Logger.DebugS("action", "user {0} attempted to" + + " toggle action {1} to {2}, but it is already toggled {2}", player.Name, + attempt.Action.Name, toggleMsg.ToggleOn); + return; + } + + if (attempt.DoToggleAction(player, toggleMsg.ToggleOn)) + { + attempt.ToggleAction(this, toggleMsg.ToggleOn); + } + else + { + // if client predicted the toggle will work, need to reset + // that prediction + Dirty(); + } + break; + case BehaviorType.TargetPoint: + if (performActionMessage is not ITargetPointActionMessage targetPointMsg) return; + if (!CheckRangeAndSetFacing(targetPointMsg.Target, player)) return; + attempt.DoTargetPointAction(player, targetPointMsg.Target); + break; + case BehaviorType.TargetEntity: + if (performActionMessage is not ITargetEntityActionMessage targetEntityMsg) return; + if (!EntityManager.TryGetEntity(targetEntityMsg.Target, out var entity)) + { + Logger.DebugS("action", "user {0} attempted to" + + " perform target entity action {1} but could not find entity with " + + "provided uid {2}", player.Name, attempt.Action.Name, + targetEntityMsg.Target); + return; + } + if (!CheckRangeAndSetFacing(entity.Transform.Coordinates, player)) return; + + attempt.DoTargetEntityAction(player, entity); + break; + case BehaviorType.None: + break; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private IActionAttempt? ActionAttempt(BasePerformActionMessage message, ICommonSession session) + { + IActionAttempt? attempt; + switch (message) + { + case PerformActionMessage performActionMessage: + if (!ActionManager.TryGet(performActionMessage.ActionType, out var action)) + { + Logger.DebugS("action", "user {0} attempted to perform" + + " unrecognized action {1}", session.AttachedEntity, + performActionMessage.ActionType); + return null; + } + attempt = new ActionAttempt(action); + break; + case PerformItemActionMessage performItemActionMessage: + if (!ActionManager.TryGet(performItemActionMessage.ActionType, out var itemAction)) + { + Logger.DebugS("action", "user {0} attempted to perform" + + " unrecognized item action {1}", + session.AttachedEntity, performItemActionMessage.ActionType); + return null; + } + + if (!EntityManager.TryGetEntity(performItemActionMessage.Item, out var item)) + { + Logger.DebugS("action", "user {0} attempted to perform" + + " item action {1} for unknown item {2}", + session.AttachedEntity, performItemActionMessage.ActionType, performItemActionMessage.Item); + return null; + } + + if (!item.TryGetComponent(out var actionsComponent)) + { + Logger.DebugS("action", "user {0} attempted to perform" + + " item action {1} for item {2} which has no ItemActionsComponent", + session.AttachedEntity, performItemActionMessage.ActionType, item); + return null; + } + + if (actionsComponent.Holder != session.AttachedEntity) + { + Logger.DebugS("action", "user {0} attempted to perform" + + " item action {1} for item {2} which they are not holding", + session.AttachedEntity, performItemActionMessage.ActionType, item); + return null; + } + + attempt = new ItemActionAttempt(itemAction, item, actionsComponent); + break; + default: + return null; + } + + if (message.BehaviorType != attempt.Action.BehaviorType) + { + Logger.DebugS("action", "user {0} attempted to" + + " perform action {1} as a {2} behavior, but this action is actually a" + + " {3} behavior", session.AttachedEntity, attempt, message.BehaviorType, + attempt.Action.BehaviorType); + return null; + } + + return attempt; + } + + private bool CheckRangeAndSetFacing(EntityCoordinates target, IEntity player) + { + // ensure it's within their clickable range + var targetWorldPos = target.ToMapPos(EntityManager); + var rangeBox = new Box2(player.Transform.WorldPosition, player.Transform.WorldPosition) + .Enlarged(_entityManager.MaxUpdateRange); + if (!rangeBox.Contains(targetWorldPos)) + { + Logger.DebugS("action", "user {0} attempted to" + + " perform target action further than allowed range", + player.Name); + return false; + } + + if (!ActionBlockerSystem.CanChangeDirection(player)) return true; + + // don't set facing unless they clicked far enough away + var diff = targetWorldPos - player.Transform.WorldPosition; + if (diff.LengthSquared > 0.01f) + { + player.Transform.LocalRotation = new Angle(diff); + } + + return true; + } + } +} diff --git a/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs b/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs index f035cedd84..2ec4c6f398 100644 --- a/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs +++ b/Content.Server/GameObjects/Components/Mobs/ServerAlertsComponent.cs @@ -1,5 +1,6 @@ using System; using Content.Server.GameObjects.EntitySystems; +using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Mobs; using Robust.Shared.GameObjects; using Robust.Shared.GameObjects.Systems; @@ -42,11 +43,6 @@ namespace Content.Server.GameObjects.Components.Mobs base.OnRemove(); } - public override ComponentState GetComponentState() - { - return new AlertsComponentState(CreateAlertStatesArray()); - } - public override void HandleNetworkMessage(ComponentMessage message, INetChannel netChannel, ICommonSession session = null) { base.HandleNetworkMessage(message, netChannel, session); @@ -67,14 +63,21 @@ namespace Content.Server.GameObjects.Components.Mobs break; } - // TODO: Implement clicking other status effects in the HUD - if (AlertManager.TryDecode(msg.EncodedAlert, out var alert)) + if (!IsShowingAlert(msg.AlertType)) { - PerformAlertClickCallback(alert, player); + Logger.DebugS("alert", "user {0} attempted to" + + " click alert {1} which is not currently showing for them", + player.Name, msg.AlertType); + break; + } + + if (AlertManager.TryGet(msg.AlertType, out var alert)) + { + alert.OnClick.AlertClicked(new ClickAlertEventArgs(player, alert)); } else { - Logger.WarningS("alert", "unrecognized encoded alert {0}", msg.EncodedAlert); + Logger.WarningS("alert", "unrecognized encoded alert {0}", msg.AlertType); } break; diff --git a/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs b/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs index e21cabb0d5..5660f28d57 100644 --- a/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs +++ b/Content.Server/GameObjects/Components/Movement/ShuttleControllerComponent.cs @@ -145,15 +145,7 @@ namespace Content.Server.GameObjects.Components.Movement mind.Mind.Visit(Owner); _controller = entity; - status.ShowAlert(_pilotingAlertType, onClickAlert: OnClickAlert); - } - - private void OnClickAlert(ClickAlertEventArgs args) - { - if (args.Player.TryGetComponent(out ShuttleControllerComponent? controller)) - { - controller.RemoveController(); - } + status.ShowAlert(_pilotingAlertType); } /// diff --git a/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs b/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs index 0210f5de3b..b1cb6b286c 100644 --- a/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs +++ b/Content.Server/GameObjects/EntitySystems/Click/InteractionSystem.cs @@ -1,12 +1,14 @@ using System; using System.Linq; using System.Threading.Tasks; +using Content.Server.GameObjects.Components.GUI; using Content.Server.GameObjects.Components.Items.Storage; using Content.Server.GameObjects.Components.Mobs; using Content.Server.GameObjects.Components.Pulling; using Content.Server.GameObjects.Components.Timing; using Content.Server.Interfaces.GameObjects.Components.Items; using Content.Shared.GameObjects.Components.Inventory; +using Content.Shared.GameObjects.Components.Items; using Content.Shared.GameObjects.EntitySystemMessages; using Content.Shared.GameObjects.EntitySystems; using Content.Shared.Input; @@ -113,11 +115,9 @@ namespace Content.Server.GameObjects.EntitySystems.Click } /// - /// Activates the Activate behavior of an object + /// Activates the IActivate behavior of an object /// Verifies that the user is capable of doing the use interaction first /// - /// - /// public void TryInteractionActivate(IEntity user, IEntity used) { if (user != null && used != null && ActionBlockerSystem.CanUse(user)) @@ -504,7 +504,7 @@ namespace Content.Server.GameObjects.EntitySystems.Click } /// - /// Activates the Use behavior of an object + /// Activates the IUse behaviors of an entity /// Verifies that the user is capable of doing the use interaction first /// /// @@ -518,8 +518,8 @@ namespace Content.Server.GameObjects.EntitySystems.Click } /// - /// Activates/Uses an object in control/possession of a user - /// If the item has the IUse interface on one of its components we use the object in our hand + /// Activates the IUse behaviors of an entity without first checking + /// if the user is capable of doing the use interaction. /// public void UseInteraction(IEntity user, IEntity used) { @@ -679,6 +679,48 @@ namespace Content.Server.GameObjects.EntitySystems.Click } } + /// + /// Calls EquippedHand on all components that implement the IEquippedHand interface + /// on an item. + /// + public void EquippedHandInteraction(IEntity user, IEntity item, SharedHand hand) + { + var equippedHandMessage = new EquippedHandMessage(user, item, hand); + RaiseLocalEvent(equippedHandMessage); + if (equippedHandMessage.Handled) + { + return; + } + + var comps = item.GetAllComponents().ToList(); + + foreach (var comp in comps) + { + comp.EquippedHand(new EquippedHandEventArgs(user, hand)); + } + } + + /// + /// Calls UnequippedHand on all components that implement the IUnequippedHand interface + /// on an item. + /// + public void UnequippedHandInteraction(IEntity user, IEntity item, SharedHand hand) + { + var unequippedHandMessage = new UnequippedHandMessage(user, item, hand); + RaiseLocalEvent(unequippedHandMessage); + if (unequippedHandMessage.Handled) + { + return; + } + + var comps = item.GetAllComponents().ToList(); + + foreach (var comp in comps) + { + comp.UnequippedHand(new UnequippedHandEventArgs(user, hand)); + } + } + /// /// Activates the Dropped behavior of an object /// Verifies that the user is capable of doing the drop interaction first @@ -757,7 +799,6 @@ namespace Content.Server.GameObjects.EntitySystems.Click } } - /// /// Will have two behaviors, either "uses" the weapon at range on the entity if it is capable of accepting that action /// Or it will use the weapon itself on the position clicked, regardless of what was there diff --git a/Content.Server/ServerContentIoC.cs b/Content.Server/ServerContentIoC.cs index 9f150e83df..09a1f2d17c 100644 --- a/Content.Server/ServerContentIoC.cs +++ b/Content.Server/ServerContentIoC.cs @@ -20,6 +20,7 @@ using Content.Server.PDA; using Content.Server.Preferences; using Content.Server.Sandbox; using Content.Server.Utility; +using Content.Shared.Actions; using Content.Shared.Interfaces; using Content.Shared.Kitchen; using Content.Shared.Alert; @@ -43,6 +44,7 @@ namespace Content.Server IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); diff --git a/Content.Shared/Actions/ActionManager.cs b/Content.Shared/Actions/ActionManager.cs new file mode 100644 index 0000000000..dd9e3fe893 --- /dev/null +++ b/Content.Shared/Actions/ActionManager.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Actions +{ + /// + /// Provides access to all configured actions by action type. + /// + public class ActionManager + { + [Dependency] + private readonly IPrototypeManager _prototypeManager = default!; + + private Dictionary _typeToAction; + private Dictionary _typeToItemAction; + + public void Initialize() + { + _typeToAction = new Dictionary(); + foreach (var action in _prototypeManager.EnumeratePrototypes()) + { + if (!_typeToAction.TryAdd(action.ActionType, action)) + { + Logger.ErrorS("action", + "Found action with duplicate actionType {0} - all actions must have" + + " a unique actionType, this one will be skipped", action.ActionType); + } + } + + _typeToItemAction = new Dictionary(); + foreach (var action in _prototypeManager.EnumeratePrototypes()) + { + if (!_typeToItemAction.TryAdd(action.ActionType, action)) + { + Logger.ErrorS("action", + "Found itemAction with duplicate actionType {0} - all actions must have" + + " a unique actionType, this one will be skipped", action.ActionType); + } + } + } + + /// all action prototypes of all types + public IEnumerable EnumerateActions() + { + return _typeToAction.Values.Concat(_typeToItemAction.Values); + } + + + /// + /// Tries to get the action of the indicated type + /// + /// true if found + public bool TryGet(ActionType actionType, out ActionPrototype action) + { + return _typeToAction.TryGetValue(actionType, out action); + } + + /// + /// Tries to get the item action of the indicated type + /// + /// true if found + public bool TryGet(ItemActionType actionType, out ItemActionPrototype action) + { + return _typeToItemAction.TryGetValue(actionType, out action); + } + } +} diff --git a/Content.Shared/Actions/ActionPrototype.cs b/Content.Shared/Actions/ActionPrototype.cs new file mode 100644 index 0000000000..c5f5d7a0bc --- /dev/null +++ b/Content.Shared/Actions/ActionPrototype.cs @@ -0,0 +1,100 @@ +using Content.Shared.Interfaces; +using Robust.Shared.IoC; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using YamlDotNet.RepresentationModel; +using Robust.Shared.Log; + +namespace Content.Shared.Actions +{ + /// + /// An action which is granted directly to an entity (such as an innate ability + /// or skill). + /// + [Prototype("action")] + public class ActionPrototype : BaseActionPrototype + { + /// + /// Type of action, no 2 action prototypes should have the same one. + /// + public ActionType ActionType { get; private set; } + + /// + /// The IInstantAction that should be invoked when performing this + /// action. Null if this is not an Instant ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public IInstantAction InstantAction { get; private set; } + + /// + /// The IToggleAction that should be invoked when performing this + /// action. Null if this is not a Toggle ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public IToggleAction ToggleAction { get; private set; } + + /// + /// The ITargetEntityAction that should be invoked when performing this + /// action. Null if this is not a TargetEntity ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public ITargetEntityAction TargetEntityAction { get; private set; } + + /// + /// The ITargetPointAction that should be invoked when performing this + /// action. Null if this is not a TargetPoint ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public ITargetPointAction TargetPointAction { get; private set; } + + public override void LoadFrom(YamlMappingNode mapping) + { + base.LoadFrom(mapping); + var serializer = YamlObjectSerializer.NewReader(mapping); + + serializer.DataField(this, x => x.ActionType, "actionType", ActionType.Error); + if (ActionType == ActionType.Error) + { + Logger.ErrorS("action", "missing or invalid actionType for action with name {0}", Name); + } + + // TODO: Split this class into server/client after RobustToolbox#1405 + if (IoCManager.Resolve().IsClientModule) return; + + IActionBehavior behavior = null; + serializer.DataField(ref behavior, "behavior", null); + switch (behavior) + { + case null: + BehaviorType = BehaviorType.None; + Logger.ErrorS("action", "missing or invalid behavior for action with name {0}", Name); + break; + case IInstantAction instantAction: + ValidateBehaviorType(BehaviorType.Instant, typeof(IInstantAction)); + BehaviorType = BehaviorType.Instant; + InstantAction = instantAction; + break; + case IToggleAction toggleAction: + ValidateBehaviorType(BehaviorType.Toggle, typeof(IToggleAction)); + BehaviorType = BehaviorType.Toggle; + ToggleAction = toggleAction; + break; + case ITargetEntityAction targetEntity: + ValidateBehaviorType(BehaviorType.TargetEntity, typeof(ITargetEntityAction)); + BehaviorType = BehaviorType.TargetEntity; + TargetEntityAction = targetEntity; + break; + case ITargetPointAction targetPointAction: + ValidateBehaviorType(BehaviorType.TargetPoint, typeof(ITargetPointAction)); + BehaviorType = BehaviorType.TargetPoint; + TargetPointAction = targetPointAction; + break; + default: + BehaviorType = BehaviorType.None; + Logger.ErrorS("action", "unrecognized behavior type for action with name {0}", Name); + break; + } + + } + } +} diff --git a/Content.Shared/Actions/ActionType.cs b/Content.Shared/Actions/ActionType.cs new file mode 100644 index 0000000000..151d78b1dd --- /dev/null +++ b/Content.Shared/Actions/ActionType.cs @@ -0,0 +1,33 @@ +namespace Content.Shared.Actions +{ + /// + /// Every possible action. Corresponds to actionType in action prototypes. + /// + public enum ActionType : byte + { + Error, + HumanScream, + DebugInstant, + DebugToggle, + DebugTargetPoint, + DebugTargetPointRepeat, + DebugTargetEntity, + DebugTargetEntityRepeat + } + + /// + /// Every possible item action. Corresponds to actionType in itemAction prototypes. + /// + public enum ItemActionType : byte + { + Error, + ToggleInternals, + ToggleLight, + DebugInstant, + DebugToggle, + DebugTargetPoint, + DebugTargetPointRepeat, + DebugTargetEntity, + DebugTargetEntityRepeat + } +} diff --git a/Content.Shared/Actions/BaseActionPrototype.cs b/Content.Shared/Actions/BaseActionPrototype.cs new file mode 100644 index 0000000000..4068c9da79 --- /dev/null +++ b/Content.Shared/Actions/BaseActionPrototype.cs @@ -0,0 +1,166 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Content.Shared.Interfaces; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using Robust.Shared.Utility; +using Robust.Shared.ViewVariables; +using YamlDotNet.RepresentationModel; + +namespace Content.Shared.Actions +{ + /// + /// Base class for action prototypes. + /// + public abstract class BaseActionPrototype : IPrototype + { + /// + /// Icon representing this action in the UI. + /// + [ViewVariables] + public SpriteSpecifier Icon { get; private set; } + + /// + /// For toggle actions only, icon to show when toggled on. If omitted, + /// the action will simply be highlighted when turned on. + /// + [ViewVariables] + public SpriteSpecifier IconOn { get; private set; } + + + + /// + /// Name to show in UI. Accepts formatting. + /// + public FormattedMessage Name { get; private set; } + + /// + /// Description to show in UI. Accepts formatting. + /// + public FormattedMessage Description { get; private set; } + + /// + /// Requirements message to show in UI. Accepts formatting, but generally should be avoided + /// so the requirements message isn't too prominent in the tooltip. + /// + public string Requires { get; private set; } + + /// + /// The type of behavior this action has. This is valid clientside and serverside. + /// + public BehaviorType BehaviorType { get; protected set; } + + /// + /// For targetpoint or targetentity actions, if this is true the action will remain + /// selected after it is used, so it can be continuously re-used. If this is false, + /// the action will be deselected after one use. + /// + public bool Repeat { get; private set; } + + /// + /// Filters that can be used to filter this item in action menu. + /// + public IEnumerable Filters { get; private set; } + + /// + /// Keywords that can be used to search this item in action menu. + /// + public IEnumerable Keywords { get; private set; } + + public virtual void LoadFrom(YamlMappingNode mapping) + { + var serializer = YamlObjectSerializer.NewReader(mapping); + + serializer.DataReadFunction("name", string.Empty, + s => Name = FormattedMessage.FromMarkup(s)); + serializer.DataReadFunction("description", string.Empty, + s => Description = FormattedMessage.FromMarkup(s)); + + serializer.DataField(this, x => x.Requires,"requires", null); + serializer.DataField(this, x => x.Icon,"icon", SpriteSpecifier.Invalid); + serializer.DataField(this, x => x.IconOn,"iconOn", SpriteSpecifier.Invalid); + + // client needs to know what type of behavior it is even if the actual implementation is only + // on server side. If we wanted to avoid this we'd need to always add a shared or clientside interface + // for each action even if there was only server-side logic, which would be cumbersome + serializer.DataField(this, x => x.BehaviorType, "behaviorType", BehaviorType.None); + if (BehaviorType == BehaviorType.None) + { + Logger.ErrorS("action", "Missing behaviorType for action with name {0}", Name); + } + + if (BehaviorType != BehaviorType.Toggle && IconOn != SpriteSpecifier.Invalid) + { + Logger.ErrorS("action", "for action {0}, iconOn was specified but behavior" + + " type was {1}. iconOn is only supported for Toggle behavior type.", Name); + } + + serializer.DataField(this, x => x.Repeat, "repeat", false); + if (Repeat && BehaviorType != BehaviorType.TargetEntity && BehaviorType != BehaviorType.TargetPoint) + { + Logger.ErrorS("action", " action named {0} used repeat: true, but this is only supported for" + + " TargetEntity and TargetPoint behaviorType and its behaviorType is {1}", + Name, BehaviorType); + } + + serializer.DataReadFunction("filters", new List(), + rawTags => + { + Filters = rawTags.Select(rawTag => rawTag.Trim()).ToList(); + }); + + serializer.DataReadFunction("keywords", new List(), + rawTags => + { + Keywords = rawTags.Select(rawTag => rawTag.Trim()).ToList(); + }); + } + + protected void ValidateBehaviorType(BehaviorType expected, Type actualInterface) + { + if (BehaviorType != expected) + { + Logger.ErrorS("action", "for action named {0}, behavior implements " + + "{1}, so behaviorType should be {2} but was {3}", Name, actualInterface.Name, expected, BehaviorType); + } + } + } + + /// + /// The behavior / logic of the action. Each of these corresponds to a particular IActionBehavior + /// (for actions) or IItemActionBehavior (for item actions) + /// interface. Corresponds to action.behaviorType in YAML + /// + public enum BehaviorType + { + /// + /// Action doesn't do anything. + /// + None, + + /// + /// IInstantAction/IInstantItemAction. Action which does something immediately when used and has + /// no target. + /// + Instant, + + /// + /// IToggleAction/IToggleItemAction Action which can be toggled on and off + /// + Toggle, + + /// + /// ITargetEntityAction/ITargetEntityItemAction. Action which is used on a targeted entity. + /// + TargetEntity, + + /// + /// ITargetPointAction/ITargetPointItemAction. Action which requires the user to select a target point, which + /// does not necessarily have an entity on it. + /// + TargetPoint + } +} diff --git a/Content.Shared/Actions/IActionBehavior.cs b/Content.Shared/Actions/IActionBehavior.cs new file mode 100644 index 0000000000..fad5518d77 --- /dev/null +++ b/Content.Shared/Actions/IActionBehavior.cs @@ -0,0 +1,44 @@ +using System; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; + +namespace Content.Shared.Actions +{ + /// + /// Currently just a marker interface delineating the different possible + /// types of action behaviors. + /// + public interface IActionBehavior : IExposeData { } + + /// + /// Base class for all action event args + /// + public abstract class ActionEventArgs : EventArgs + { + /// + /// Entity performing the action. + /// + public readonly IEntity Performer; + /// + /// Action being performed + /// + public readonly ActionType ActionType; + /// + /// Actions component of the performer. + /// + public readonly SharedActionsComponent PerformerActions; + + public ActionEventArgs(IEntity performer, ActionType actionType) + { + Performer = performer; + ActionType = actionType; + if (!Performer.TryGetComponent(out PerformerActions)) + { + throw new InvalidOperationException($"performer {performer.Name} tried to perform action {actionType} " + + $" but the performer had no actions component," + + " which should never occur"); + } + } + } +} diff --git a/Content.Shared/Actions/IInstantAction.cs b/Content.Shared/Actions/IInstantAction.cs new file mode 100644 index 0000000000..73c24f1a67 --- /dev/null +++ b/Content.Shared/Actions/IInstantAction.cs @@ -0,0 +1,27 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; + +namespace Content.Shared.Actions +{ + /// + /// Action which does something immediately when used and has + /// no target. + /// + public interface IInstantAction : IActionBehavior + { + + /// + /// Invoked when the instant action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoInstantAction(InstantActionEventArgs args); + } + + public class InstantActionEventArgs : ActionEventArgs + { + public InstantActionEventArgs(IEntity performer, ActionType actionType) : base(performer, actionType) + { + } + } +} diff --git a/Content.Shared/Actions/IInstantItemAction.cs b/Content.Shared/Actions/IInstantItemAction.cs new file mode 100644 index 0000000000..74ac02c28d --- /dev/null +++ b/Content.Shared/Actions/IInstantItemAction.cs @@ -0,0 +1,28 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; + +namespace Content.Shared.Actions +{ + /// + /// Item action which does something immediately when used and has + /// no target. + /// + public interface IInstantItemAction : IItemActionBehavior + { + + /// + /// Invoked when the instant action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoInstantAction(InstantItemActionEventArgs args); + } + + public class InstantItemActionEventArgs : ItemActionEventArgs + { + public InstantItemActionEventArgs(IEntity performer, IEntity item, ItemActionType actionType) : + base(performer, item, actionType) + { + } + } +} diff --git a/Content.Shared/Actions/IItemActionBehavior.cs b/Content.Shared/Actions/IItemActionBehavior.cs new file mode 100644 index 0000000000..dd1ee99b3d --- /dev/null +++ b/Content.Shared/Actions/IItemActionBehavior.cs @@ -0,0 +1,52 @@ +using System; +using Content.Shared.GameObjects.Components.Mobs; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; + +namespace Content.Shared.Actions +{ + /// + /// Currently just a marker interface delineating the different possible + /// types of item action behaviors. + /// + public interface IItemActionBehavior : IExposeData + { + + } + + /// + /// Base class for all item action event args + /// + public abstract class ItemActionEventArgs : EventArgs + { + /// + /// Entity performing the action. + /// + public readonly IEntity Performer; + /// + /// Item being used to perform the action + /// + public readonly IEntity Item; + /// + /// Action being performed + /// + public readonly ItemActionType ActionType; + /// + /// Item actions component of the item. + /// + public readonly ItemActionsComponent ItemActions; + + public ItemActionEventArgs(IEntity performer, IEntity item, ItemActionType actionType) + { + Performer = performer; + ActionType = actionType; + Item = item; + if (!Item.TryGetComponent(out ItemActions)) + { + throw new InvalidOperationException($"performer {performer.Name} tried to perform item action {actionType} " + + $" for item {Item.Name} but the item had no ItemActionsComponent," + + " which should never occur"); + } + } + } +} diff --git a/Content.Shared/Actions/ITargetEntityAction.cs b/Content.Shared/Actions/ITargetEntityAction.cs new file mode 100644 index 0000000000..44266bd0ea --- /dev/null +++ b/Content.Shared/Actions/ITargetEntityAction.cs @@ -0,0 +1,33 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.Map; + +namespace Content.Shared.Actions +{ + /// + /// Action which is used on a targeted entity. + /// + public interface ITargetEntityAction : IActionBehavior + { + /// + /// Invoked when the target entity action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoTargetEntityAction(TargetEntityActionEventArgs args); + } + + public class TargetEntityActionEventArgs : ActionEventArgs + { + /// + /// Entity being targeted + /// + public readonly IEntity Target; + + public TargetEntityActionEventArgs(IEntity performer, ActionType actionType, IEntity target) : + base(performer, actionType) + { + Target = target; + } + } +} diff --git a/Content.Shared/Actions/ITargetEntityItemAction.cs b/Content.Shared/Actions/ITargetEntityItemAction.cs new file mode 100644 index 0000000000..388ea55414 --- /dev/null +++ b/Content.Shared/Actions/ITargetEntityItemAction.cs @@ -0,0 +1,34 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.Map; + +namespace Content.Shared.Actions +{ + /// + /// Item action which is used on a targeted entity. + /// + public interface ITargetEntityItemAction : IItemActionBehavior + { + /// + /// Invoked when the target entity action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoTargetEntityAction(TargetEntityItemActionEventArgs args); + } + + public class TargetEntityItemActionEventArgs : ItemActionEventArgs + { + /// + /// Entity being targeted + /// + public readonly IEntity Target; + + public TargetEntityItemActionEventArgs(IEntity performer, IEntity target, IEntity item, + ItemActionType actionType) : base(performer, item, actionType) + { + Target = target; + + } + } +} diff --git a/Content.Shared/Actions/ITargetPointAction.cs b/Content.Shared/Actions/ITargetPointAction.cs new file mode 100644 index 0000000000..7e78cefebf --- /dev/null +++ b/Content.Shared/Actions/ITargetPointAction.cs @@ -0,0 +1,33 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Map; + +namespace Content.Shared.Actions +{ + /// + /// Action which requires the user to select a target point, which + /// does not necessarily have an entity on it. + /// + public interface ITargetPointAction : IActionBehavior + { + /// + /// Invoked when the target point action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoTargetPointAction(TargetPointActionEventArgs args); + } + + public class TargetPointActionEventArgs : ActionEventArgs + { + /// + /// Local coordinates of the targeted position. + /// + public readonly EntityCoordinates Target; + + public TargetPointActionEventArgs(IEntity performer, EntityCoordinates target, ActionType actionType) + : base(performer, actionType) + { + Target = target; + } + } +} diff --git a/Content.Shared/Actions/ITargetPointItemAction.cs b/Content.Shared/Actions/ITargetPointItemAction.cs new file mode 100644 index 0000000000..5a7c2384f9 --- /dev/null +++ b/Content.Shared/Actions/ITargetPointItemAction.cs @@ -0,0 +1,33 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Map; + +namespace Content.Shared.Actions +{ + /// + /// Item action which requires the user to select a target point, which + /// does not necessarily have an entity on it. + /// + public interface ITargetPointItemAction : IItemActionBehavior + { + /// + /// Invoked when the target point action should be performed. + /// Implementation should perform the server side logic of the action. + /// + void DoTargetPointAction(TargetPointItemActionEventArgs args); + } + + public class TargetPointItemActionEventArgs : ItemActionEventArgs + { + /// + /// Local coordinates of the targeted position. + /// + public readonly EntityCoordinates Target; + + public TargetPointItemActionEventArgs(IEntity performer, EntityCoordinates target, IEntity item, + ItemActionType actionType) : base(performer, item, actionType) + { + Target = target; + } + } +} diff --git a/Content.Shared/Actions/IToggleAction.cs b/Content.Shared/Actions/IToggleAction.cs new file mode 100644 index 0000000000..d20b576ea3 --- /dev/null +++ b/Content.Shared/Actions/IToggleAction.cs @@ -0,0 +1,41 @@ +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Shared.Actions +{ + /// + /// Action which can be toggled on and off + /// + public interface IToggleAction : IActionBehavior + { + /// + /// Invoked when the action will be toggled on/off. + /// Implementation should perform the server side logic of whatever + /// happens when it is toggled on / off. + /// + /// true if the attempt to toggle was successful, meaning the state should be toggled to the desired value. + /// False to leave toggle status unchanged. This is NOT returning the new toggle status, it is only returning + /// whether the attempt to toggle to the indicated status was successful. + /// + /// Note that it's still okay if the implementation directly modifies toggle status via SharedActionsComponent, + /// this is just an additional level of safety to ensure implementations will always + /// explicitly indicate if the toggle status should be changed. + bool DoToggleAction(ToggleActionEventArgs args); + } + + public class ToggleActionEventArgs : ActionEventArgs + { + /// + /// True if the toggle is attempting to be toggled on, false if attempting to toggle off + /// + public readonly bool ToggledOn; + /// + /// Opposite of ToggledOn + /// + public bool ToggledOff => !ToggledOn; + + public ToggleActionEventArgs(IEntity performer, ActionType actionType, bool toggledOn) : base(performer, actionType) + { + ToggledOn = toggledOn; + } + } +} diff --git a/Content.Shared/Actions/IToggleItemAction.cs b/Content.Shared/Actions/IToggleItemAction.cs new file mode 100644 index 0000000000..7930dce933 --- /dev/null +++ b/Content.Shared/Actions/IToggleItemAction.cs @@ -0,0 +1,42 @@ +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Shared.Actions +{ + /// + /// Item action which can be toggled on and off + /// + public interface IToggleItemAction : IItemActionBehavior + { + /// + /// Invoked when the action will be toggled on/off. + /// Implementation should perform the server side logic of whatever + /// happens when it is toggled on / off. + /// + /// true if the attempt to toggle was successful, meaning the state should be toggled to the desired value. + /// False to leave toggle status unchanged. This is NOT returning the new toggle status, it is only returning + /// whether the attempt to toggle to the indicated status was successful. + /// + /// Note that it's still okay if the implementation directly modifies toggle status via ItemActionsComponent, + /// this is just an additional level of safety to ensure implementations will always + /// explicitly indicate if the toggle status should be changed. + bool DoToggleAction(ToggleItemActionEventArgs args); + } + + public class ToggleItemActionEventArgs : ItemActionEventArgs + { + /// + /// True if the toggle was toggled on, false if it was toggled off + /// + public readonly bool ToggledOn; + /// + /// Opposite of ToggledOn + /// + public bool ToggledOff => !ToggledOn; + + public ToggleItemActionEventArgs(IEntity performer, bool toggledOn, IEntity item, + ItemActionType actionType) : base(performer, item, actionType) + { + ToggledOn = toggledOn; + } + } +} diff --git a/Content.Shared/Actions/ItemActionPrototype.cs b/Content.Shared/Actions/ItemActionPrototype.cs new file mode 100644 index 0000000000..25de3910c7 --- /dev/null +++ b/Content.Shared/Actions/ItemActionPrototype.cs @@ -0,0 +1,122 @@ +using Content.Shared.Interfaces; +using Robust.Shared.IoC; +using Robust.Shared.Log; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; +using YamlDotNet.RepresentationModel; + +namespace Content.Shared.Actions +{ + /// + /// An action which is granted to an entity via an item (such as toggling a flashlight). + /// + [Prototype("itemAction")] + public class ItemActionPrototype : BaseActionPrototype + { + /// + /// Type of item action, no 2 itemAction prototypes should have the same one. + /// + public ItemActionType ActionType { get; private set; } + + /// + public ItemActionIconStyle IconStyle { get; private set; } + + /// + /// The IInstantItemAction that should be invoked when performing this + /// action. Null if this is not an Instant ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public IInstantItemAction InstantAction { get; private set; } + + /// + /// The IToggleItemAction that should be invoked when performing this + /// action. Null if this is not a Toggle ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public IToggleItemAction ToggleAction { get; private set; } + + /// + /// The ITargetEntityItemAction that should be invoked when performing this + /// action. Null if this is not a TargetEntity ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public ITargetEntityItemAction TargetEntityAction { get; private set; } + + /// + /// The ITargetPointItemAction that should be invoked when performing this + /// action. Null if this is not a TargetPoint ActionBehaviorType. + /// Will be null on client side if the behavior is not in Content.Client. + /// + public ITargetPointItemAction TargetPointAction { get; private set; } + + public override void LoadFrom(YamlMappingNode mapping) + { + base.LoadFrom(mapping); + var serializer = YamlObjectSerializer.NewReader(mapping); + + serializer.DataField(this, x => x.ActionType, "actionType", ItemActionType.Error); + if (ActionType == ItemActionType.Error) + { + Logger.ErrorS("action", "missing or invalid actionType for action with name {0}", Name); + } + + serializer.DataField(this, x => x.IconStyle, "iconStyle", ItemActionIconStyle.BigItem); + + // TODO: Split this class into server/client after RobustToolbox#1405 + if (IoCManager.Resolve().IsClientModule) return; + + IItemActionBehavior behavior = null; + serializer.DataField(ref behavior, "behavior", null); + switch (behavior) + { + case null: + BehaviorType = BehaviorType.None; + Logger.ErrorS("action", "missing or invalid behavior for action with name {0}", Name); + break; + case IInstantItemAction instantAction: + ValidateBehaviorType(BehaviorType.Instant, typeof(IInstantItemAction)); + BehaviorType = BehaviorType.Instant; + InstantAction = instantAction; + break; + case IToggleItemAction toggleAction: + ValidateBehaviorType(BehaviorType.Toggle, typeof(IToggleItemAction)); + BehaviorType = BehaviorType.Toggle; + ToggleAction = toggleAction; + break; + case ITargetEntityItemAction targetEntity: + ValidateBehaviorType(BehaviorType.TargetEntity, typeof(ITargetEntityItemAction)); + BehaviorType = BehaviorType.TargetEntity; + TargetEntityAction = targetEntity; + break; + case ITargetPointItemAction targetPointAction: + ValidateBehaviorType(BehaviorType.TargetPoint, typeof(ITargetPointItemAction)); + BehaviorType = BehaviorType.TargetPoint; + TargetPointAction = targetPointAction; + break; + default: + BehaviorType = BehaviorType.None; + Logger.ErrorS("action", "unrecognized behavior type for action with name {0}", Name); + break; + } + } + } + + /// + /// Determines how the action icon appears in the hotbar for item actions. + /// + public enum ItemActionIconStyle : byte + { + /// + /// The default - the item icon will be big with a small action icon in the corner + /// + BigItem, + /// + /// The action icon will be big with a small item icon in the corner + /// + BigAction, + /// + /// BigAction but no item icon will be shown in the corner. + /// + NoItem + } +} diff --git a/Content.Shared/Alert/AlertManager.cs b/Content.Shared/Alert/AlertManager.cs index 392b731a83..64afd7a998 100644 --- a/Content.Shared/Alert/AlertManager.cs +++ b/Content.Shared/Alert/AlertManager.cs @@ -1,6 +1,4 @@ using System.Collections.Generic; -using System.Linq; -using Content.Shared.Prototypes.Kitchen; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Prototypes; @@ -8,41 +6,28 @@ using Robust.Shared.Prototypes; namespace Content.Shared.Alert { /// - /// Provides access to all configured alerts. Ability to encode/decode a given state - /// to an int. + /// Provides access to all configured alerts by alert type. /// public class AlertManager { [Dependency] private readonly IPrototypeManager _prototypeManager = default!; - private AlertPrototype[] _orderedAlerts; - private Dictionary _typeToIndex; + private Dictionary _typeToAlert; public void Initialize() { - // order by type value so we can map between the id and an integer index and use - // the index for compact alert change messages - _orderedAlerts = - _prototypeManager.EnumeratePrototypes() - .OrderBy(prototype => prototype.AlertType).ToArray(); - _typeToIndex = new Dictionary(); + _typeToAlert = new Dictionary(); - for (var i = 0; i < _orderedAlerts.Length; i++) + foreach (var alert in _prototypeManager.EnumeratePrototypes()) { - if (i > byte.MaxValue) - { - Logger.ErrorS("alert", "too many alerts for byte encoding ({0})! encoding will need" + - " to be changed to use a ushort rather than byte", _typeToIndex.Count); - break; - } - if (!_typeToIndex.TryAdd(_orderedAlerts[i].AlertType, (byte) i)) + if (!_typeToAlert.TryAdd(alert.AlertType, alert)) { Logger.ErrorS("alert", - "Found alert with duplicate id {0}", _orderedAlerts[i].AlertType); + "Found alert with duplicate alertType {0} - all alerts must have" + + " a unique alerttype, this one will be skipped", alert.AlertType); } } - } /// @@ -51,74 +36,7 @@ namespace Content.Shared.Alert /// true if found public bool TryGet(AlertType alertType, out AlertPrototype alert) { - if (_typeToIndex.TryGetValue(alertType, out var idx)) - { - alert = _orderedAlerts[idx]; - return true; - } - - alert = null; - return false; - } - - /// - /// Tries to get the alert of the indicated type along with its encoding - /// - /// true if found - public bool TryGetWithEncoded(AlertType alertType, out AlertPrototype alert, out byte encoded) - { - if (_typeToIndex.TryGetValue(alertType, out var idx)) - { - alert = _orderedAlerts[idx]; - encoded = (byte) idx; - return true; - } - - alert = null; - encoded = 0; - return false; - } - - /// - /// Tries to get the compact encoded representation of this alert - /// - /// true if successful - public bool TryEncode(AlertPrototype alert, out byte encoded) - { - return TryEncode(alert.AlertType, out encoded); - } - - /// - /// Tries to get the compact encoded representation of the alert with - /// the indicated id - /// - /// true if successful - public bool TryEncode(AlertType alertType, out byte encoded) - { - if (_typeToIndex.TryGetValue(alertType, out var idx)) - { - encoded = idx; - return true; - } - - encoded = 0; - return false; - } - - /// - /// Tries to get the alert from the encoded representation - /// - /// true if successful - public bool TryDecode(byte encodedAlert, out AlertPrototype alert) - { - if (encodedAlert >= _orderedAlerts.Length) - { - alert = null; - return false; - } - - alert = _orderedAlerts[encodedAlert]; - return true; + return _typeToAlert.TryGetValue(alertType, out alert); } } } diff --git a/Content.Shared/Alert/AlertPrototype.cs b/Content.Shared/Alert/AlertPrototype.cs index c9e7427ae1..9a6e0bd674 100644 --- a/Content.Shared/Alert/AlertPrototype.cs +++ b/Content.Shared/Alert/AlertPrototype.cs @@ -1,4 +1,6 @@ using System; +using Content.Shared.Interfaces; +using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Prototypes; using Robust.Shared.Serialization; @@ -70,6 +72,11 @@ namespace Content.Shared.Alert /// public bool SupportsSeverity => MaxSeverity != -1; + /// + /// Defines what to do when the alert is clicked. + /// + public IAlertClick OnClick { get; private set; } + public void LoadFrom(YamlMappingNode mapping) { var serializer = YamlObjectSerializer.NewReader(mapping); @@ -94,6 +101,9 @@ namespace Content.Shared.Alert Category = alertCategory; } AlertKey = new AlertKey(AlertType, Category); + + if (IoCManager.Resolve().IsClientModule) return; + serializer.DataField(this, x => x.OnClick, "onClick", null); } /// severity level, if supported by this alert @@ -143,30 +153,26 @@ namespace Content.Shared.Alert [Serializable, NetSerializable] public struct AlertKey { - private readonly AlertType? _alertType; - private readonly AlertCategory? _alertCategory; + public readonly AlertType? AlertType; + public readonly AlertCategory? AlertCategory; /// NOTE: if the alert has a category you must pass the category for this to work - /// properly as a key. I.e. if the alert has a category and you pass only the ID, and you - /// compare this to another AlertKey that has both the category and the same ID, it will not consider them equal. + /// properly as a key. I.e. if the alert has a category and you pass only the alert type, and you + /// compare this to another AlertKey that has both the category and the same alert type, it will not consider them equal. public AlertKey(AlertType? alertType, AlertCategory? alertCategory) { - // if there is a category, ignore the alerttype. - if (alertCategory != null) - { - _alertCategory = alertCategory; - _alertType = null; - } - else - { - _alertCategory = null; - _alertType = alertType; - } + AlertCategory = alertCategory; + AlertType = alertType; } public bool Equals(AlertKey other) { - return _alertType == other._alertType && _alertCategory == other._alertCategory; + // compare only on alert category if we have one + if (AlertCategory.HasValue) + { + return other.AlertCategory == AlertCategory; + } + return AlertType == other.AlertType && AlertCategory == other.AlertCategory; } public override bool Equals(object obj) @@ -176,11 +182,14 @@ namespace Content.Shared.Alert public override int GetHashCode() { - return HashCode.Combine(_alertType, _alertCategory); + // use only alert category if we have one + if (AlertCategory.HasValue) return AlertCategory.GetHashCode(); + return AlertType.GetHashCode(); } /// alert category, must not be null - /// An alert key for the provided alert category + /// An alert key for the provided alert category. This must only be used for + /// queries and never storage, as it is lacking an alert type. public static AlertKey ForCategory(AlertCategory category) { return new(null, category); diff --git a/Content.Shared/Alert/AlertType.cs b/Content.Shared/Alert/AlertType.cs index b4a3866e0c..8c2afb48d8 100644 --- a/Content.Shared/Alert/AlertType.cs +++ b/Content.Shared/Alert/AlertType.cs @@ -16,8 +16,10 @@ /// /// Every kind of alert. Corresponds to alertType field in alert prototypes defined in YML + /// NOTE: Using byte for a compact encoding when sending this in messages, can upgrade + /// to ushort /// - public enum AlertType + public enum AlertType : byte { Error, LowPressure, diff --git a/Content.Shared/Alert/IAlertClick.cs b/Content.Shared/Alert/IAlertClick.cs new file mode 100644 index 0000000000..b6240edde5 --- /dev/null +++ b/Content.Shared/Alert/IAlertClick.cs @@ -0,0 +1,37 @@ +using System; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; + +namespace Content.Shared.Alert +{ + /// + /// Defines what should happen when an alert is clicked. + /// + public interface IAlertClick : IExposeData + { + + /// + /// Invoked on server side when user clicks an alert. + /// + /// + void AlertClicked(ClickAlertEventArgs args); + } + + public class ClickAlertEventArgs : EventArgs + { + /// + /// Player clicking the alert + /// + public readonly IEntity Player; + /// + /// Alert that was clicked + /// + public readonly AlertPrototype Alert; + + public ClickAlertEventArgs(IEntity player, AlertPrototype alert) + { + Player = player; + Alert = alert; + } + } +} diff --git a/Content.Shared/GameObjects/Components/Inventory/EquipmentSlotDefinitions.cs b/Content.Shared/GameObjects/Components/Inventory/EquipmentSlotDefinitions.cs index bd944f2958..b7561b4f89 100644 --- a/Content.Shared/GameObjects/Components/Inventory/EquipmentSlotDefinitions.cs +++ b/Content.Shared/GameObjects/Components/Inventory/EquipmentSlotDefinitions.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using Content.Shared.GameObjects.Components.Items; using JetBrains.Annotations; using Robust.Shared.Serialization; @@ -26,7 +27,7 @@ namespace Content.Shared.GameObjects.Components.Inventory /// Uniquely identifies a single slot in an inventory. /// [Serializable, NetSerializable] - public enum Slots + public enum Slots : byte { NONE = 0, HEAD, @@ -148,6 +149,5 @@ namespace Content.Shared.GameObjects.Components.Inventory "Hands_left", "Hands_right", }; - } } diff --git a/Content.Shared/GameObjects/Components/Inventory/SharedInventoryComponent.cs b/Content.Shared/GameObjects/Components/Inventory/SharedInventoryComponent.cs index dfea45389e..608941b390 100644 --- a/Content.Shared/GameObjects/Components/Inventory/SharedInventoryComponent.cs +++ b/Content.Shared/GameObjects/Components/Inventory/SharedInventoryComponent.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.Interfaces.Reflection; using Robust.Shared.IoC; using Robust.Shared.Serialization; @@ -47,6 +48,10 @@ namespace Content.Shared.GameObjects.Components.Inventory InventoryInstance = DynamicTypeFactory.CreateInstance(type); } + /// true if the item is equipped to an equip slot (NOT inside an equipped container + /// like inside a backpack) + public abstract bool IsEquipped(IEntity item); + [Serializable, NetSerializable] protected class InventoryComponentState : ComponentState { diff --git a/Content.Shared/GameObjects/Components/Items/SharedHandsComponent.cs b/Content.Shared/GameObjects/Components/Items/SharedHandsComponent.cs index c8eb3a5255..83e24cdadb 100644 --- a/Content.Shared/GameObjects/Components/Items/SharedHandsComponent.cs +++ b/Content.Shared/GameObjects/Components/Items/SharedHandsComponent.cs @@ -1,6 +1,7 @@ #nullable enable using System; using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.Serialization; namespace Content.Shared.GameObjects.Components.Items @@ -9,6 +10,9 @@ namespace Content.Shared.GameObjects.Components.Items { public sealed override string Name => "Hands"; public sealed override uint? NetID => ContentNetIDs.HANDS; + + /// true if the item is in one of the hands + public abstract bool IsHolding(IEntity item); } [Serializable, NetSerializable] diff --git a/Content.Shared/GameObjects/Components/Mobs/IActionAttempt.cs b/Content.Shared/GameObjects/Components/Mobs/IActionAttempt.cs new file mode 100644 index 0000000000..3dd493804c --- /dev/null +++ b/Content.Shared/GameObjects/Components/Mobs/IActionAttempt.cs @@ -0,0 +1,208 @@ +#nullable enable + +using Content.Shared.Actions; +using Robust.Shared.GameObjects; +using Robust.Shared.Input.Binding; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Map; + +namespace Content.Shared.GameObjects.Components.Mobs +{ + /// + /// An attempt to perform a specific action. Main purpose of this interface is to + /// reduce code duplication related to handling attempts to perform non-item vs item actions by + /// providing a single interface for various functionality that needs to be performed on both. + /// + public interface IActionAttempt + { + /// + /// Action Prototype attempting to be performed + /// + BaseActionPrototype Action { get; } + ComponentMessage PerformInstantActionMessage(); + ComponentMessage PerformToggleActionMessage(bool on); + ComponentMessage PerformTargetPointActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args); + ComponentMessage PerformTargetEntityActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args); + /// + /// Tries to get the action state for this action from the actionsComponent, returning + /// true if found. + /// + bool TryGetActionState(SharedActionsComponent actionsComponent, out ActionState actionState); + + /// + /// Toggles the action within the provided action component + /// + void ToggleAction(SharedActionsComponent actionsComponent, bool toggleOn); + + /// + /// Perform the server-side logic of the action + /// + void DoInstantAction(IEntity player); + + /// + /// Perform the server-side logic of the toggle action + /// + /// true if the attempt to toggle was successful, meaning the state should be toggled to the + /// indicated value + bool DoToggleAction(IEntity player, bool on); + + /// + /// Perform the server-side logic of the target point action + /// + void DoTargetPointAction(IEntity player, EntityCoordinates target); + + /// + /// Perform the server-side logic of the target entity action + /// + void DoTargetEntityAction(IEntity player, IEntity target); + } + + public class ActionAttempt : IActionAttempt + { + private readonly ActionPrototype _action; + + public BaseActionPrototype Action => _action; + + public ActionAttempt(ActionPrototype action) + { + _action = action; + } + + public bool TryGetActionState(SharedActionsComponent actionsComponent, out ActionState actionState) + { + return actionsComponent.TryGetActionState(_action.ActionType, out actionState); + } + + public void ToggleAction(SharedActionsComponent actionsComponent, bool toggleOn) + { + actionsComponent.ToggleAction(_action.ActionType, toggleOn); + } + + public void DoInstantAction(IEntity player) + { + _action.InstantAction.DoInstantAction(new InstantActionEventArgs(player, _action.ActionType)); + } + + public bool DoToggleAction(IEntity player, bool on) + { + return _action.ToggleAction.DoToggleAction(new ToggleActionEventArgs(player, _action.ActionType, on)); + } + + public void DoTargetPointAction(IEntity player, EntityCoordinates target) + { + _action.TargetPointAction.DoTargetPointAction(new TargetPointActionEventArgs(player, target, _action.ActionType)); + } + + public void DoTargetEntityAction(IEntity player, IEntity target) + { + _action.TargetEntityAction.DoTargetEntityAction(new TargetEntityActionEventArgs(player, _action.ActionType, + target)); + } + + public ComponentMessage PerformInstantActionMessage() + { + return new PerformInstantActionMessage(_action.ActionType); + } + + public ComponentMessage PerformToggleActionMessage(bool toggleOn) + { + if (toggleOn) + { + return new PerformToggleOnActionMessage(_action.ActionType); + } + return new PerformToggleOffActionMessage(_action.ActionType); + } + + public ComponentMessage PerformTargetPointActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args) + { + return new PerformTargetPointActionMessage(_action.ActionType, args.Coordinates); + } + + public ComponentMessage PerformTargetEntityActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args) + { + return new PerformTargetEntityActionMessage(_action.ActionType, args.EntityUid); + } + + public override string ToString() + { + return $"{nameof(_action)}: {_action.ActionType}"; + } + } + + public class ItemActionAttempt : IActionAttempt + { + private readonly ItemActionPrototype _action; + private readonly IEntity _item; + private readonly ItemActionsComponent _itemActions; + + public BaseActionPrototype Action => _action; + + public ItemActionAttempt(ItemActionPrototype action, IEntity item, ItemActionsComponent itemActions) + { + _action = action; + _item = item; + _itemActions = itemActions; + } + + public void DoInstantAction(IEntity player) + { + _action.InstantAction.DoInstantAction(new InstantItemActionEventArgs(player, _item, _action.ActionType)); + } + + public bool DoToggleAction(IEntity player, bool on) + { + return _action.ToggleAction.DoToggleAction(new ToggleItemActionEventArgs(player, on, _item, _action.ActionType)); + } + + public void DoTargetPointAction(IEntity player, EntityCoordinates target) + { + _action.TargetPointAction.DoTargetPointAction(new TargetPointItemActionEventArgs(player, target, _item, + _action.ActionType)); + } + + public void DoTargetEntityAction(IEntity player, IEntity target) + { + _action.TargetEntityAction.DoTargetEntityAction(new TargetEntityItemActionEventArgs(player, target, + _item, _action.ActionType)); + } + + public bool TryGetActionState(SharedActionsComponent actionsComponent, out ActionState actionState) + { + return actionsComponent.TryGetItemActionState(_action.ActionType, _item, out actionState); + } + + public void ToggleAction(SharedActionsComponent actionsComponent, bool toggleOn) + { + _itemActions.Toggle(_action.ActionType, toggleOn); + } + + public ComponentMessage PerformInstantActionMessage() + { + return new PerformInstantItemActionMessage(_action.ActionType, _item.Uid); + } + + public ComponentMessage PerformToggleActionMessage(bool toggleOn) + { + if (toggleOn) + { + return new PerformToggleOnItemActionMessage(_action.ActionType, _item.Uid); + } + return new PerformToggleOffItemActionMessage(_action.ActionType, _item.Uid); + } + + public ComponentMessage PerformTargetPointActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args) + { + return new PerformTargetPointItemActionMessage(_action.ActionType, _item.Uid, args.Coordinates); + } + + public ComponentMessage PerformTargetEntityActionMessage(PointerInputCmdHandler.PointerInputCmdArgs args) + { + return new PerformTargetEntityItemActionMessage(_action.ActionType, _item.Uid, args.EntityUid); + } + + public override string ToString() + { + return $"{nameof(_action)}: {_action.ActionType}, {nameof(_item)}: {_item}"; + } + } +} diff --git a/Content.Shared/GameObjects/Components/Mobs/ItemActionsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/ItemActionsComponent.cs new file mode 100644 index 0000000000..fa599e8cb5 --- /dev/null +++ b/Content.Shared/GameObjects/Components/Mobs/ItemActionsComponent.cs @@ -0,0 +1,249 @@ +#nullable enable +using System; +using System.Collections.Generic; +using Content.Shared.Actions; +using Content.Shared.GameObjects.Components.Inventory; +using Content.Shared.GameObjects.Components.Items; +using Content.Shared.Interfaces.GameObjects.Components; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Serialization; +using Robust.Shared.Log; +using Robust.Shared.Serialization; + +namespace Content.Shared.GameObjects.Components.Mobs +{ + /// + /// This should be used on items which provide actions. Defines which actions the item provides + /// and allows modifying the states of those actions. Item components should use this rather than + /// SharedActionsComponent on the player to handle granting / revoking / modifying the states of the + /// actions provided by this item. + /// + /// When a player equips this item, all the actions defined in this component will be granted to the + /// player in their current states. This means the states will persist between players. + /// + /// Currently only maintained server side and not synced to client, as are all the equip/unequip events. + /// + [RegisterComponent] + public class ItemActionsComponent : Component, IEquippedHand, IEquipped, IUnequipped, IUnequippedHand + { + public override string Name => "ItemActions"; + + /// + /// Configuration for the item actions initially provided by this item. Actions defined here + /// will be automatically granted unless their state is modified using the methods + /// on this component. Additional actions can be granted by this item via GrantOrUpdate + /// + public IEnumerable ActionConfigs => _actionConfigs; + + public bool IsEquipped => InSlot != EquipmentSlotDefines.Slots.NONE || InHand != null; + /// + /// Slot currently equipped to, NONE if not equipped to an equip slot. + /// + public EquipmentSlotDefines.Slots InSlot { get; private set; } + /// + /// hand it's currently in, null if not in a hand. + /// + public SharedHand? InHand { get; private set; } + + /// + /// Entity currently holding this in hand or equip slot. Null if not held. + /// + public IEntity? Holder { get; private set; } + // cached actions component of the holder, since we'll need to access it frequently + private SharedActionsComponent? _holderActionsComponent; + private List _actionConfigs = new(); + // State of all actions provided by this item. + private readonly Dictionary _actions = new(); + + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + + serializer.DataField(ref _actionConfigs,"actions", new List()); + + foreach (var actionConfig in _actionConfigs) + { + GrantOrUpdate(actionConfig.ActionType, actionConfig.Enabled, false, null); + } + } + + protected override void Startup() + { + base.Startup(); + GrantOrUpdateAllToHolder(); + } + + protected override void Shutdown() + { + base.Shutdown(); + RevokeAllFromHolder(); + } + + private void GrantOrUpdateAllToHolder() + { + if (_holderActionsComponent == null) return; + foreach (var (actionType, state) in _actions) + { + _holderActionsComponent.GrantOrUpdateItemAction(actionType, Owner.Uid, state); + } + } + + private void RevokeAllFromHolder() + { + if (_holderActionsComponent == null) return; + foreach (var (actionType, state) in _actions) + { + _holderActionsComponent.RevokeItemAction(actionType, Owner.Uid); + } + } + + /// + /// Update the state of the action, granting it if it isn't already granted. + /// If the action had any existing state, those specific fields will be overwritten by any + /// corresponding non-null arguments. + /// + /// action being granted / updated + /// When null, preserves the current enable status of the action, defaulting + /// to true if action has no current state. + /// When non-null, indicates whether the entity is able to perform the action (if disabled, + /// the player will see they have the action but it will appear greyed out) + /// When null, preserves the current toggle status of the action, defaulting + /// to false if action has no current state. + /// When non-null, action will be shown toggled to this value + /// When null (unless clearCooldown is true), preserves the current cooldown status of the action, defaulting + /// to no cooldown if action has no current state. + /// When non-null or clearCooldown is true, action cooldown will be set to this value. Note that this cooldown + /// is tied to this item. + /// If true, setting cooldown to null will clear the current cooldown + /// of this action rather than preserving it. + public void GrantOrUpdate(ItemActionType actionType, bool? enabled = null, + bool? toggleOn = null, + (TimeSpan start, TimeSpan end)? cooldown = null, bool clearCooldown = false) + { + var dirty = false; + // this will be overwritten if we find the value in our dict, otherwise + // we will use this as our new action state. + if (!_actions.TryGetValue(actionType, out var actionState)) + { + dirty = true; + actionState = new ActionState(enabled ?? true, toggleOn ?? false); + } + + if (enabled.HasValue && enabled != actionState.Enabled) + { + dirty = true; + actionState.Enabled = true; + } + + if ((cooldown.HasValue || clearCooldown) && actionState.Cooldown != cooldown) + { + dirty = true; + actionState.Cooldown = cooldown; + } + + if (toggleOn.HasValue && actionState.ToggledOn != toggleOn.Value) + { + dirty = true; + actionState.ToggledOn = toggleOn.Value; + } + + if (!dirty) return; + + _actions[actionType] = actionState; + _holderActionsComponent?.GrantOrUpdateItemAction(actionType, Owner.Uid, actionState); + } + + /// + /// Update the cooldown of a particular action. Actions on cooldown cannot be used. + /// Setting the cooldown to null clears it. + /// + public void Cooldown(ItemActionType actionType, (TimeSpan start, TimeSpan end)? cooldown = null) + { + GrantOrUpdate(actionType, cooldown: cooldown, clearCooldown: true); + } + + /// + /// Enable / disable this action. Disabled actions are still shown to the player, but + /// shown as not usable. + /// + public void SetEnabled(ItemActionType actionType, bool enabled) + { + GrantOrUpdate(actionType, enabled); + } + + /// + /// Toggle the action on / off + /// + public void Toggle(ItemActionType actionType, bool toggleOn) + { + GrantOrUpdate(actionType, toggleOn: toggleOn); + } + + public void EquippedHand(EquippedHandEventArgs eventArgs) + { + // this entity cannot be granted actions if no actions component + if (!eventArgs.User.TryGetComponent(out var actionsComponent)) + return; + Holder = eventArgs.User; + _holderActionsComponent = actionsComponent; + InSlot = EquipmentSlotDefines.Slots.NONE; + InHand = eventArgs.Hand; + GrantOrUpdateAllToHolder(); + } + + public void Equipped(EquippedEventArgs eventArgs) + { + // this entity cannot be granted actions if no actions component + if (!eventArgs.User.TryGetComponent(out var actionsComponent)) + return; + Holder = eventArgs.User; + _holderActionsComponent = actionsComponent; + InSlot = eventArgs.Slot; + InHand = null; + GrantOrUpdateAllToHolder(); + } + + public void Unequipped(UnequippedEventArgs eventArgs) + { + RevokeAllFromHolder(); + Holder = null; + _holderActionsComponent = null; + InSlot = EquipmentSlotDefines.Slots.NONE; + InHand = null; + + } + + public void UnequippedHand(UnequippedHandEventArgs eventArgs) + { + RevokeAllFromHolder(); + Holder = null; + _holderActionsComponent = null; + InSlot = EquipmentSlotDefines.Slots.NONE; + InHand = null; + } + } + + /// + /// Configuration for an item action provided by an item. + /// + public class ItemActionConfig : IExposeData + { + public ItemActionType ActionType { get; private set; } + /// + /// Whether action is initially enabled on this item. Defaults to true. + /// + public bool Enabled { get; private set; } + + public void ExposeData(ObjectSerializer serializer) + { + serializer.DataField(this, x => x.ActionType, "actionType", ItemActionType.Error); + if (ActionType == ItemActionType.Error) + { + Logger.ErrorS("action", "invalid or missing actionType"); + } + serializer.DataField(this, x => x.Enabled, "enabled", true); + } + } +} diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedActionsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedActionsComponent.cs new file mode 100644 index 0000000000..5a9f77a27f --- /dev/null +++ b/Content.Shared/GameObjects/Components/Mobs/SharedActionsComponent.cs @@ -0,0 +1,652 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using Content.Shared.Actions; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; +using Robust.Shared.Map; +using Robust.Shared.Serialization; +using Robust.Shared.ViewVariables; + +namespace Content.Shared.GameObjects.Components.Mobs +{ + /// + /// Manages the actions available to an entity. + /// Should only be used for player-controlled entities. + /// + /// Actions are granted directly to the owner entity. Item actions are granted via a particular item which + /// must be in the owner's inventory (the action is revoked when item leaves the owner's inventory). This + /// should almost always be done via ItemActionsComponent on the item entity (which also tracks the + /// cooldowns associated with the actions on that item). + /// + /// Actions can still have an associated state even when revoked. For example, a flashlight toggle action + /// may be unusable while the player is stunned, but this component will still have an entry for the action + /// so the user can see whether it's currently toggled on or off. + /// + public abstract class SharedActionsComponent : Component + { + private static readonly TimeSpan CooldownExpiryThreshold = TimeSpan.FromSeconds(10); + + [Dependency] + protected readonly ActionManager ActionManager = default!; + [Dependency] + protected readonly IGameTiming GameTiming = default!; + [Dependency] + protected readonly IEntityManager EntityManager = default!; + + public override string Name => "Actions"; + public override uint? NetID => ContentNetIDs.ACTIONS; + + /// + /// Actions granted to this entity as soon as they spawn, regardless + /// of the status of the entity. + /// + public IEnumerable InnateActions => _innateActions ?? Enumerable.Empty(); + private List? _innateActions; + + + // entries are removed from this if they are at the initial state (not enabled, no cooldown, toggled off). + // a system runs which periodically removes cooldowns from entries when they are revoked and their + // cooldowns have expired for a long enough time, also removing the entry if it is then at initial state. + // This helps to keep our component state smaller. + [ViewVariables] + private Dictionary _actions = new(); + + // Holds item action states. Item actions are only added to this when granted, and are removed + // when revoked or when they leave inventory. This is almost entirely handled by ItemActionsComponent on + // item entities. + [ViewVariables] + private Dictionary> _itemActions = + new(); + + public override void ExposeData(ObjectSerializer serializer) + { + base.ExposeData(serializer); + serializer.DataField(ref _innateActions,"innateActions", null); + } + protected override void Startup() + { + foreach (var actionType in InnateActions) + { + Grant(actionType); + } + } + + + public override ComponentState GetComponentState() + { + return new ActionComponentState(_actions, _itemActions); + } + + public override void HandleComponentState(ComponentState? curState, ComponentState? nextState) + { + base.HandleComponentState(curState, nextState); + + if (curState is not ActionComponentState state) + { + return; + } + _actions = state.Actions; + _itemActions = state.ItemActions; + } + + /// + /// Gets the action state associated with the specified action type, if it has been + /// granted, has a cooldown, or has been toggled on + /// + /// false if not found for this action type + public bool TryGetActionState(ActionType actionType, out ActionState actionState) + { + return _actions.TryGetValue(actionType, out actionState); + } + + /// + /// Gets the item action states associated with the specified item if any have been granted + /// and not yet revoked. + /// + /// false if no states found for this item action type. + public bool TryGetItemActionStates(EntityUid item, [NotNullWhen((true))] out IReadOnlyDictionary? itemActionStates) + { + if (_itemActions.TryGetValue(item, out var actualItemActionStates)) + { + itemActionStates = actualItemActionStates; + return true; + } + + itemActionStates = null; + return false; + } + + /// + public bool TryGetItemActionStates(IEntity item, + [NotNullWhen((true))] out IReadOnlyDictionary? itemActionStates) + { + return TryGetItemActionStates(item.Uid, out itemActionStates); + } + + /// + /// Gets the item action state associated with the specified item action type for the specified item, if it has any. + /// + /// false if no state found for this item action type for this item + public bool TryGetItemActionState(ItemActionType actionType, EntityUid item, out ActionState actionState) + { + if (_itemActions.TryGetValue(item, out var actualItemActionStates)) + { + return actualItemActionStates.TryGetValue(actionType, out actionState); + } + + actionState = default; + return false; + } + + /// true if the action is granted and enabled (if item action, if granted and enabled for any item) + public bool IsGranted(BaseActionPrototype actionType) + { + return actionType switch + { + ActionPrototype actionPrototype => IsGranted(actionPrototype.ActionType), + ItemActionPrototype itemActionPrototype => IsGranted(itemActionPrototype.ActionType), + _ => false + }; + } + + public bool IsGranted(ActionType actionType) + { + if (TryGetActionState(actionType, out var actionState)) + { + return actionState.Enabled; + } + + return false; + } + + /// true if the action is granted and enabled for any item. This + /// has to traverse the entire item state dictionary so please avoid frequent calls. + public bool IsGranted(ItemActionType actionType) + { + return _itemActions.Values.SelectMany(vals => vals) + .Any(state => state.Key == actionType && state.Value.Enabled); + } + + /// + public bool TryGetItemActionState(ItemActionType actionType, IEntity item, out ActionState actionState) + { + return TryGetItemActionState(actionType, item.Uid, out actionState); + } + + /// + /// Gets all action types that have non-initial state (granted, have a cooldown, or toggled on). + /// + public IReadOnlyDictionary ActionStates() + { + return _actions; + } + + /// + /// Gets all items that have actions currently granted (that are not revoked + /// and still in inventory). + /// Map from item uid -> (action type -> associated action state) + /// PLEASE DO NOT MODIFY THE INNER DICTIONARY! I CANNOT CAST IT TO IReadOnlyDictionary! + /// + public IReadOnlyDictionary> ItemActionStates() + { + return _itemActions; + } + + /// + /// Creates or updates the action state with the supplied non-null values + /// + private void GrantOrUpdate(ActionType actionType, bool? enabled = null, bool? toggleOn = null, + (TimeSpan start, TimeSpan end)? cooldown = null, bool clearCooldown = false) + { + var dirty = false; + if (!_actions.TryGetValue(actionType, out var actionState)) + { + // no state at all for this action, create it anew + dirty = true; + actionState = new ActionState(enabled ?? false, toggleOn ?? false); + } + + if (enabled.HasValue && actionState.Enabled != enabled.Value) + { + dirty = true; + actionState.Enabled = enabled.Value; + } + + if ((cooldown.HasValue || clearCooldown) && actionState.Cooldown != cooldown) + { + dirty = true; + actionState.Cooldown = cooldown; + } + + if (toggleOn.HasValue && actionState.ToggledOn != toggleOn.Value) + { + dirty = true; + actionState.ToggledOn = toggleOn.Value; + } + + if (!dirty) return; + + _actions[actionType] = actionState; + AfterActionChanged(); + Dirty(); + } + + /// + /// Intended to only be used by ItemActionsComponent. + /// Updates the state of the item action provided by the item, granting the action + /// if it is not yet granted to the player. Should be called whenever the + /// status changes. The existing state will be completely overwritten by the new state. + /// + public void GrantOrUpdateItemAction(ItemActionType actionType, EntityUid item, ActionState state) + { + if (!_itemActions.TryGetValue(item, out var itemStates)) + { + itemStates = new Dictionary(); + _itemActions[item] = itemStates; + } + + itemStates[actionType] = state; + AfterActionChanged(); + Dirty(); + } + + /// + /// Intended to only be used by ItemActionsComponent. Revokes the item action so the player no longer + /// sees it and can no longer use it. + /// + public void RevokeItemAction(ItemActionType actionType, EntityUid item) + { + if (!_itemActions.TryGetValue(item, out var itemStates)) + return; + + itemStates.Remove(actionType); + AfterActionChanged(); + Dirty(); + } + + /// + /// Grants the entity the ability to perform the action, optionally overriding its + /// current state with specified values. + /// + /// Even if the action was already granted, if the action had any state (cooldown, toggle) prior to this method + /// being called, it will be preserved, with specific fields optionally overridden by any of the provided + /// non-null arguments. + /// + /// When null, preserves the current toggle status of the action, defaulting + /// to false if action has no current state. + /// When non-null, action will be shown toggled to this value + /// When null, preserves the current cooldown status of the action, defaulting + /// to no cooldown if action has no current state. + /// When non-null, action cooldown will be set to this value. + public void Grant(ActionType actionType, bool? toggleOn = null, + (TimeSpan start, TimeSpan end)? cooldown = null) + { + GrantOrUpdate(actionType, true, toggleOn, cooldown); + } + + /// + /// Grants the entity the ability to perform the action, resetting its state + /// to its initial state and settings its state based on supplied parameters. + /// + /// Even if the action was already granted, if the action had any state (cooldown, toggle) prior to this method + /// being called, it will be reset to initial (no cooldown, toggled off). + /// + /// action will be shown toggled to this value + /// action cooldown will be set to this value (by default the cooldown is cleared). + public void GrantFromInitialState(ActionType actionType, bool toggleOn = false, + (TimeSpan start, TimeSpan end)? cooldown = null) + { + _actions.Remove(actionType); + Grant(actionType, toggleOn, cooldown); + } + + /// + /// Sets the cooldown for the action. Actions on cooldown cannot be used. + /// + /// This will work even if the action is revoked - + /// for example if there's an ability with a cooldown which is temporarily unusable due + /// to the player being stunned, the cooldown will still tick down even while the player + /// is stunned. + /// + /// Setting cooldown to null clears it. + /// + public void Cooldown(ActionType actionType, (TimeSpan start, TimeSpan end)? cooldown) + { + GrantOrUpdate(actionType, cooldown: cooldown, clearCooldown: true); + } + + /// + /// Revokes the ability to perform the action for this entity. Current state + /// of the action (toggle / cooldown) is preserved. + /// + public void Revoke(ActionType actionType) + { + if (!_actions.TryGetValue(actionType, out var actionState)) return; + + if (!actionState.Enabled) return; + + actionState.Enabled = false; + + // don't store it anymore if its at its initial state. + if (actionState.IsAtInitialState) + { + _actions.Remove(actionType); + } + else + { + _actions[actionType] = actionState; + } + + AfterActionChanged(); + Dirty(); + } + + /// + /// Toggles the action to the specified value. Works even if the action is on cooldown + /// or revoked. + /// + public void ToggleAction(ActionType actionType, bool toggleOn) + { + Grant(actionType, toggleOn); + } + + /// + /// Clears any cooldowns which have expired beyond the predefined threshold. + /// this should be run periodically to ensure we don't have unbounded growth of + /// our saved action data, and keep our component state sent to the client as minimal as possible. + /// + public void ExpireCooldowns() + { + + // actions - only clear cooldowns and remove associated action state + // if the action is at initial state + var actionTypesToRemove = new List(); + foreach (var (actionType, actionState) in _actions) + { + // ignore it unless we may be able to delete it due to + // clearing the cooldown + if (!actionState.IsAtInitialStateExceptCooldown) continue; + if (!actionState.Cooldown.HasValue) + { + actionTypesToRemove.Add(actionType); + continue; + } + var expiryTime = GameTiming.CurTime - actionState.Cooldown.Value.Item2; + if (expiryTime > CooldownExpiryThreshold) + { + actionTypesToRemove.Add(actionType); + } + } + + foreach (var remove in actionTypesToRemove) + { + _actions.Remove(remove); + } + } + + /// + /// Invoked after a change has been made to an action state in this component. + /// + protected virtual void AfterActionChanged() { } + } + + [Serializable, NetSerializable] + public class ActionComponentState : ComponentState + { + public Dictionary Actions; + public Dictionary> ItemActions; + + public ActionComponentState(Dictionary actions, + Dictionary> itemActions) : base(ContentNetIDs.ACTIONS) + { + Actions = actions; + ItemActions = itemActions; + } + } + + [Serializable, NetSerializable] + public struct ActionState + { + /// + /// False if this action is not currently allowed to be performed. + /// + public bool Enabled; + /// + /// Only used for toggle actions, indicates whether it's currently toggled on or off + /// TODO: Eventually this should probably be a byte so we it can toggle through multiple states. + /// + public bool ToggledOn; + public (TimeSpan start, TimeSpan end)? Cooldown; + public bool IsAtInitialState => IsAtInitialStateExceptCooldown && !Cooldown.HasValue; + public bool IsAtInitialStateExceptCooldown => !Enabled && !ToggledOn; + + /// + /// Creates an action state for the indicated type, defaulting to the + /// initial state. + /// + public ActionState(bool enabled = false, bool toggledOn = false, (TimeSpan start, TimeSpan end)? cooldown = null) + { + Enabled = enabled; + ToggledOn = toggledOn; + Cooldown = cooldown; + } + + public bool IsOnCooldown(TimeSpan curTime) + { + if (Cooldown == null) return false; + return curTime < Cooldown.Value.Item2; + } + public bool IsOnCooldown(IGameTiming gameTiming) + { + return IsOnCooldown(gameTiming.CurTime); + } + + public bool Equals(ActionState other) + { + return Enabled == other.Enabled && ToggledOn == other.ToggledOn && Nullable.Equals(Cooldown, other.Cooldown); + } + + public override bool Equals(object? obj) + { + return obj is ActionState other && Equals(other); + } + + public override int GetHashCode() + { + return HashCode.Combine(Enabled, ToggledOn, Cooldown); + } + } + + [Serializable, NetSerializable] + public abstract class BasePerformActionMessage : ComponentMessage + { + public abstract BehaviorType BehaviorType { get; } + } + + [Serializable, NetSerializable] + public abstract class PerformActionMessage : BasePerformActionMessage + { + public readonly ActionType ActionType; + + protected PerformActionMessage(ActionType actionType) + { + Directed = true; + ActionType = actionType; + } + } + + [Serializable, NetSerializable] + public abstract class PerformItemActionMessage : BasePerformActionMessage + { + public readonly ItemActionType ActionType; + public readonly EntityUid Item; + + protected PerformItemActionMessage(ItemActionType actionType, EntityUid item) + { + Directed = true; + ActionType = actionType; + Item = item; + } + } + + + /// + /// A message that tells server we want to run the instant action logic. + /// + [Serializable, NetSerializable] + public class PerformInstantActionMessage : PerformActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Instant; + + public PerformInstantActionMessage(ActionType actionType) : base(actionType) + { + } + } + + /// + /// A message that tells server we want to run the instant action logic. + /// + [Serializable, NetSerializable] + public class PerformInstantItemActionMessage : PerformItemActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Instant; + + public PerformInstantItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) + { + } + } + + public interface IToggleActionMessage + { + bool ToggleOn { get; } + } + + public interface ITargetPointActionMessage + { + /// + /// Targeted local coordinates + /// + EntityCoordinates Target { get; } + } + + public interface ITargetEntityActionMessage + { + /// + /// Targeted entity + /// + EntityUid Target { get; } + } + + /// + /// A message that tells server we want to toggle on the indicated action. + /// + [Serializable, NetSerializable] + public class PerformToggleOnActionMessage : PerformActionMessage, IToggleActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Toggle; + public bool ToggleOn => true; + public PerformToggleOnActionMessage(ActionType actionType) : base(actionType) { } + } + + /// + /// A message that tells server we want to toggle off the indicated action. + /// + [Serializable, NetSerializable] + public class PerformToggleOffActionMessage : PerformActionMessage, IToggleActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Toggle; + public bool ToggleOn => false; + public PerformToggleOffActionMessage(ActionType actionType) : base(actionType) { } + } + + /// + /// A message that tells server we want to toggle on the indicated action. + /// + [Serializable, NetSerializable] + public class PerformToggleOnItemActionMessage : PerformItemActionMessage, IToggleActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Toggle; + public bool ToggleOn => true; + public PerformToggleOnItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) { } + } + + /// + /// A message that tells server we want to toggle off the indicated action. + /// + [Serializable, NetSerializable] + public class PerformToggleOffItemActionMessage : PerformItemActionMessage, IToggleActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.Toggle; + public bool ToggleOn => false; + public PerformToggleOffItemActionMessage(ItemActionType actionType, EntityUid item) : base(actionType, item) { } + } + + /// + /// A message that tells server we want to target the provided point with a particular action. + /// + [Serializable, NetSerializable] + public class PerformTargetPointActionMessage : PerformActionMessage, ITargetPointActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.TargetPoint; + private readonly EntityCoordinates _target; + public EntityCoordinates Target => _target; + + public PerformTargetPointActionMessage(ActionType actionType, EntityCoordinates target) : base(actionType) + { + _target = target; + } + } + + /// + /// A message that tells server we want to target the provided point with a particular action. + /// + [Serializable, NetSerializable] + public class PerformTargetPointItemActionMessage : PerformItemActionMessage, ITargetPointActionMessage + { + private readonly EntityCoordinates _target; + public EntityCoordinates Target => _target; + public override BehaviorType BehaviorType => BehaviorType.TargetPoint; + + public PerformTargetPointItemActionMessage(ItemActionType actionType, EntityUid item, EntityCoordinates target) : base(actionType, item) + { + _target = target; + } + } + + /// + /// A message that tells server we want to target the provided entity with a particular action. + /// + [Serializable, NetSerializable] + public class PerformTargetEntityActionMessage : PerformActionMessage, ITargetEntityActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.TargetEntity; + private readonly EntityUid _target; + public EntityUid Target => _target; + + public PerformTargetEntityActionMessage(ActionType actionType, EntityUid target) : base(actionType) + { + _target = target; + } + } + + /// + /// A message that tells server we want to target the provided entity with a particular action. + /// + [Serializable, NetSerializable] + public class PerformTargetEntityItemActionMessage : PerformItemActionMessage, ITargetEntityActionMessage + { + public override BehaviorType BehaviorType => BehaviorType.TargetEntity; + private readonly EntityUid _target; + public EntityUid Target => _target; + + public PerformTargetEntityItemActionMessage(ItemActionType actionType, EntityUid item, EntityUid target) : base(actionType, item) + { + _target = target; + } + } +} diff --git a/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs b/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs index 162dacd1c5..30b24fdb14 100644 --- a/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs +++ b/Content.Shared/GameObjects/Components/Mobs/SharedAlertsComponent.cs @@ -1,13 +1,10 @@ using System; using System.Collections.Generic; -using System.Linq; using Content.Shared.Alert; using Robust.Shared.GameObjects; -using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Serialization; -using Robust.Shared.Utility; using Robust.Shared.ViewVariables; namespace Content.Shared.GameObjects.Components.Mobs @@ -18,16 +15,30 @@ namespace Content.Shared.GameObjects.Components.Mobs /// public abstract class SharedAlertsComponent : Component { - private static readonly AlertState[] NO_ALERTS = new AlertState[0]; - [Dependency] protected readonly AlertManager AlertManager = default!; - public override string Name => "AlertsUI"; + public override string Name => "Alerts"; public override uint? NetID => ContentNetIDs.ALERTS; - [ViewVariables] - private Dictionary _alerts = new(); + [ViewVariables] private Dictionary _alerts = new(); + + public override void HandleComponentState(ComponentState curState, ComponentState nextState) + { + base.HandleComponentState(curState, nextState); + + if (curState is not AlertsComponentState state) + { + return; + } + + _alerts = state.Alerts; + } + + public override ComponentState GetComponentState() + { + return new AlertsComponentState(_alerts); + } /// true iff an alert of the indicated alert category is currently showing public bool IsShowingAlertCategory(AlertCategory alertCategory) @@ -53,82 +64,14 @@ namespace Content.Shared.GameObjects.Components.Mobs return _alerts.ContainsKey(alertKey); } - protected IEnumerable EnumerateAlertStates() + protected IEnumerable> EnumerateAlertStates() { - return _alerts.Values.Select(alertData => alertData.AlertState); - } - - /// - /// Invokes the alert's specified callback if there is one. - /// Not intended to be used on clientside. - /// - protected void PerformAlertClickCallback(AlertPrototype alert, IEntity owner) - { - if (_alerts.TryGetValue(alert.AlertKey, out var alertStateCallback)) - { - alertStateCallback.OnClickAlert?.Invoke(new ClickAlertEventArgs(owner, alert)); - } - else - { - Logger.DebugS("alert", "player {0} attempted to invoke" + - " alert click for {1} but that alert is not currently" + - " showing", owner.Name, alert.AlertType); - } - } - - /// - /// Creates a new array containing all of the current alert states. - /// - /// - protected AlertState[] CreateAlertStatesArray() - { - if (_alerts.Count == 0) return NO_ALERTS; - var states = new AlertState[_alerts.Count]; - // because I don't trust LINQ - var idx = 0; - foreach (var alertData in _alerts.Values) - { - states[idx++] = alertData.AlertState; - } - - return states; + return _alerts; } protected bool TryGetAlertState(AlertKey key, out AlertState alertState) { - if (_alerts.TryGetValue(key, out var alertData)) - { - alertState = alertData.AlertState; - return true; - } - - alertState = default; - return false; - } - - /// - /// Replace the current active alerts with the specified alerts. Any - /// OnClickAlert callbacks on the active alerts will be erased. - /// - protected void SetAlerts(AlertState[] alerts) - { - var newAlerts = new Dictionary(); - foreach (var alertState in alerts) - { - if (AlertManager.TryDecode(alertState.AlertEncoded, out var alert)) - { - newAlerts[alert.AlertKey] = new ClickableAlertState - { - AlertState = alertState - }; - } - else - { - Logger.ErrorS("alert", "unrecognized encoded alert {0}", alertState.AlertEncoded); - } - } - - _alerts = newAlerts; + return _alerts.TryGetValue(key, out alertState); } /// @@ -136,30 +79,24 @@ namespace Content.Shared.GameObjects.Components.Mobs /// it will be updated / replaced with the specified values. /// /// type of the alert to set - /// callback to invoke when ClickAlertMessage is received by the server - /// after being clicked by client. Has no effect when specified on the clientside. /// severity, if supported by the alert /// cooldown start and end, if null there will be no cooldown (and it will /// be erased if there is currently a cooldown for the alert) - public void ShowAlert(AlertType alertType, short? severity = null, OnClickAlert onClickAlert = null, - ValueTuple? cooldown = null) + public void ShowAlert(AlertType alertType, short? severity = null, ValueTuple? cooldown = null) { - if (AlertManager.TryGetWithEncoded(alertType, out var alert, out var encoded)) + if (AlertManager.TryGet(alertType, out var alert)) { if (_alerts.TryGetValue(alert.AlertKey, out var alertStateCallback) && - alertStateCallback.AlertState.AlertEncoded == encoded && - alertStateCallback.AlertState.Severity == severity && alertStateCallback.AlertState.Cooldown == cooldown) + alert.AlertType == alertType && + alertStateCallback.Severity == severity && alertStateCallback.Cooldown == cooldown) { - alertStateCallback.OnClickAlert = onClickAlert; return; } - _alerts[alert.AlertKey] = new ClickableAlertState - { - AlertState = new AlertState - {Cooldown = cooldown, AlertEncoded = encoded, Severity = severity}, - OnClickAlert = onClickAlert - }; + _alerts[alert.AlertKey] = new AlertState + {Cooldown = cooldown, Severity = severity}; + + AfterShowAlert(); Dirty(); @@ -212,7 +149,12 @@ namespace Content.Shared.GameObjects.Components.Mobs } /// - /// Invoked after clearing an alert prior to dirtying the control + /// Invoked after showing an alert prior to dirtying the component + /// + protected virtual void AfterShowAlert() { } + + /// + /// Invoked after clearing an alert prior to dirtying the component /// protected virtual void AfterClearAlert() { } } @@ -220,9 +162,9 @@ namespace Content.Shared.GameObjects.Components.Mobs [Serializable, NetSerializable] public class AlertsComponentState : ComponentState { - public AlertState[] Alerts; + public Dictionary Alerts; - public AlertsComponentState(AlertState[] alerts) : base(ContentNetIDs.ALERTS) + public AlertsComponentState(Dictionary alerts) : base(ContentNetIDs.ALERTS) { Alerts = alerts; } @@ -234,46 +176,19 @@ namespace Content.Shared.GameObjects.Components.Mobs [Serializable, NetSerializable] public class ClickAlertMessage : ComponentMessage { - public readonly byte EncodedAlert; + public readonly AlertType AlertType; - public ClickAlertMessage(byte encodedAlert) + public ClickAlertMessage(AlertType alertType) { Directed = true; - EncodedAlert = encodedAlert; + AlertType = alertType; } } [Serializable, NetSerializable] public struct AlertState { - public byte AlertEncoded; public short? Severity; public ValueTuple? Cooldown; } - - public struct ClickableAlertState - { - public AlertState AlertState; - public OnClickAlert OnClickAlert; - } - - public delegate void OnClickAlert(ClickAlertEventArgs args); - - public class ClickAlertEventArgs : EventArgs - { - /// - /// Player clicking the alert - /// - public readonly IEntity Player; - /// - /// Alert that was clicked - /// - public readonly AlertPrototype Alert; - - public ClickAlertEventArgs(IEntity player, AlertPrototype alert) - { - Player = player; - Alert = alert; - } - } } diff --git a/Content.Shared/GameObjects/Components/Pulling/SharedPullerComponent.cs b/Content.Shared/GameObjects/Components/Pulling/SharedPullerComponent.cs index 391eeb5ccd..ae4719fe9c 100644 --- a/Content.Shared/GameObjects/Components/Pulling/SharedPullerComponent.cs +++ b/Content.Shared/GameObjects/Components/Pulling/SharedPullerComponent.cs @@ -69,17 +69,11 @@ namespace Content.Shared.GameObjects.Components.Pulling { case PullStartedMessage msg: Pulling = msg.Pulled.Owner; - if (ownerStatus != null) - { - ownerStatus.ShowAlert(AlertType.Pulling, onClickAlert: OnClickAlert); - } + ownerStatus?.ShowAlert(AlertType.Pulling); break; case PullStoppedMessage _: Pulling = null; - if (ownerStatus != null) - { - ownerStatus.ClearAlert(AlertType.Pulling); - } + ownerStatus?.ClearAlert(AlertType.Pulling); break; } } diff --git a/Content.Shared/GameObjects/ContentNetIDs.cs b/Content.Shared/GameObjects/ContentNetIDs.cs index d0a17dc3c0..3c54d324ed 100644 --- a/Content.Shared/GameObjects/ContentNetIDs.cs +++ b/Content.Shared/GameObjects/ContentNetIDs.cs @@ -86,7 +86,8 @@ public const uint SINGULARITY = 1080; public const uint CHARACTERINFO = 1081; public const uint REAGENT_GRINDER = 1082; - public const uint DAMAGEABLE = 1083; + public const uint ACTIONS = 1083; + public const uint DAMAGEABLE = 1084; // Net IDs for integration tests. public const uint PREDICTION_TEST = 10001; diff --git a/Content.Shared/GameObjects/EntitySystems/SharedActionSystem.cs b/Content.Shared/GameObjects/EntitySystems/SharedActionSystem.cs new file mode 100644 index 0000000000..f7ed7cac43 --- /dev/null +++ b/Content.Shared/GameObjects/EntitySystems/SharedActionSystem.cs @@ -0,0 +1,30 @@ +using Content.Shared.GameObjects.Components.Mobs; +using Content.Shared.Interfaces.GameObjects.Components; +using Robust.Shared.GameObjects.Systems; + +namespace Content.Shared.GameObjects.EntitySystems +{ + /// + /// Evicts action states with expired cooldowns. + /// + public class SharedActionSystem : EntitySystem + { + private const float CooldownCheckIntervalSeconds = 10; + private float _timeSinceCooldownCheck; + + + public override void Update(float frameTime) + { + base.Update(frameTime); + + _timeSinceCooldownCheck += frameTime; + if (_timeSinceCooldownCheck < CooldownCheckIntervalSeconds) return; + + foreach (var comp in ComponentManager.EntityQuery(false)) + { + comp.ExpireCooldowns(); + } + _timeSinceCooldownCheck -= CooldownCheckIntervalSeconds; + } + } +} diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs index d21f722d99..3d9e61a2e0 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -41,5 +41,16 @@ namespace Content.Shared.Input public static readonly BoundKeyFunction Arcade1 = "Arcade1"; public static readonly BoundKeyFunction Arcade2 = "Arcade2"; public static readonly BoundKeyFunction Arcade3 = "Arcade3"; + public static readonly BoundKeyFunction OpenActionsMenu = "OpenAbilitiesMenu"; + public static readonly BoundKeyFunction Hotbar0 = "Hotbar0"; + public static readonly BoundKeyFunction Hotbar1 = "Hotbar1"; + public static readonly BoundKeyFunction Hotbar2 = "Hotbar2"; + public static readonly BoundKeyFunction Hotbar3 = "Hotbar3"; + public static readonly BoundKeyFunction Hotbar4 = "Hotbar4"; + public static readonly BoundKeyFunction Hotbar5 = "Hotbar5"; + public static readonly BoundKeyFunction Hotbar6 = "Hotbar6"; + public static readonly BoundKeyFunction Hotbar7 = "Hotbar7"; + public static readonly BoundKeyFunction Hotbar8 = "Hotbar8"; + public static readonly BoundKeyFunction Hotbar9 = "Hotbar9"; } } diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IActivate.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IActivate.cs index 07b0b9b2cf..80bf4c05e5 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IActivate.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IActivate.cs @@ -6,8 +6,11 @@ using Robust.Shared.Interfaces.GameObjects; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components behavior when being activated in the world when the user - /// is in range and has unobstructed access to the target entity (allows inside blockers). + /// This interface gives components behavior when being activated (by default, + /// this is done via the "E" key) when the user is in range and has unobstructed access to the target entity + /// (allows inside blockers). This includes activating an object in the world as well as activating an + /// object in inventory. Unlike IUse, this can be performed on entities that aren't in the active hand, + /// even when the active hand is currently holding something else. /// public interface IActivate { diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IAfterInteract.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IAfterInteract.cs index 9c5724fb28..55ca853924 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IAfterInteract.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IAfterInteract.cs @@ -7,8 +7,9 @@ using Robust.Shared.Map; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components a behavior when clicking on another object and no interaction occurs, - /// at any range. + /// This interface gives components a behavior when their entity is in the active hand, when + /// clicking on another object and no interaction occurs, at any range. This includes + /// clicking on an object in the world as well as clicking on an object in inventory. /// public interface IAfterInteract { diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquipped.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquipped.cs index f2c0ae0df6..f5b4cd5685 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquipped.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquipped.cs @@ -7,22 +7,35 @@ using Robust.Shared.Interfaces.GameObjects; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components behavior when their owner is put in an inventory slot. + /// This interface gives components behavior when their entity is put in a non-hand inventory slot, + /// regardless of where it came from. This includes moving the entity from a hand slot into a non-hand slot + /// (which would also fire ). + /// + /// This DOES NOT fire when putting the entity into a hand slot (), nor + /// does it fire when putting the entity into held/equipped storage. /// public interface IEquipped { void Equipped(EquippedEventArgs eventArgs); } - public class EquippedEventArgs : EventArgs + public abstract class UserEventArgs : EventArgs { - public EquippedEventArgs(IEntity user, EquipmentSlotDefines.Slots slot) + public IEntity User { get; } + + protected UserEventArgs(IEntity user) { User = user; + } + } + + public class EquippedEventArgs : UserEventArgs + { + public EquippedEventArgs(IEntity user, EquipmentSlotDefines.Slots slot) : base(user) + { Slot = slot; } - public IEntity User { get; } public EquipmentSlotDefines.Slots Slot { get; } } diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquippedHand.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquippedHand.cs new file mode 100644 index 0000000000..768149c4f7 --- /dev/null +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IEquippedHand.cs @@ -0,0 +1,64 @@ +using System; +using Content.Shared.GameObjects.Components.Inventory; +using Content.Shared.GameObjects.Components.Items; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Shared.Interfaces.GameObjects.Components +{ + /// + /// This interface gives components behavior when their entity is put in a hand inventory slot, + /// even if it came from another hand slot (which would also fire ). + /// This includes moving the entity from a non-hand slot into a hand slot + /// (which would also fire ). + /// + public interface IEquippedHand + { + void EquippedHand(EquippedHandEventArgs eventArgs); + } + + public class EquippedHandEventArgs : UserEventArgs + { + public EquippedHandEventArgs(IEntity user, SharedHand hand) : base(user) + { + Hand = hand; + } + + public SharedHand Hand { get; } + } + + /// + /// Raised when putting the entity into a hand slot + /// + [PublicAPI] + public class EquippedHandMessage : EntitySystemMessage + { + /// + /// If this message has already been "handled" by a previous system. + /// + public bool Handled { get; set; } + + /// + /// Entity that equipped the item. + /// + public IEntity User { get; } + + /// + /// Item that was equipped. + /// + public IEntity Equipped { get; } + + /// + /// Hand the item is going into. + /// + public SharedHand Hand { get; } + + public EquippedHandMessage(IEntity user, IEntity equipped, SharedHand hand) + { + User = user; + Equipped = equipped; + Hand = hand; + } + } +} diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IInteractUsing.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IInteractUsing.cs index aadfa594db..d233957b76 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IInteractUsing.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IInteractUsing.cs @@ -8,8 +8,9 @@ using Robust.Shared.Map; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components behavior when being clicked on by a user with an object in their hand - /// who is in range and has unobstructed reach of the target entity (allows inside blockers). + /// This interface gives components behavior when their entity is clicked on by a user with an object in their hand + /// who is in range and has unobstructed reach of the target entity (allows inside blockers). This includes + /// clicking on an object in the world as well as clicking on an object in inventory. /// public interface IInteractUsing { diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequipped.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequipped.cs index 5d9cb951ce..d9037fd93d 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequipped.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequipped.cs @@ -7,22 +7,25 @@ using Robust.Shared.Interfaces.GameObjects; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components behavior when their owner is removed from an inventory slot. + /// This interface gives components behavior when their entity is removed from a non-hand inventory slot, + /// regardless of where it's going to. This includes moving the entity from a non-hand slot into a hand slot + /// (which would also fire ). + /// + /// This DOES NOT fire when removing the entity from a hand slot (), nor + /// does it fire when removing the entity from held/equipped storage. /// public interface IUnequipped { void Unequipped(UnequippedEventArgs eventArgs); } - public class UnequippedEventArgs : EventArgs + public class UnequippedEventArgs : UserEventArgs { - public UnequippedEventArgs(IEntity user, EquipmentSlotDefines.Slots slot) + public UnequippedEventArgs(IEntity user, EquipmentSlotDefines.Slots slot) : base(user) { - User = user; Slot = slot; } - public IEntity User { get; } public EquipmentSlotDefines.Slots Slot { get; } } @@ -43,19 +46,19 @@ namespace Content.Shared.Interfaces.GameObjects.Components public IEntity User { get; } /// - /// Item that was equipped. + /// Item that was unequipped. /// - public IEntity Equipped { get; } + public IEntity Unequipped { get; } /// /// Slot where the item was removed from. /// public EquipmentSlotDefines.Slots Slot { get; } - public UnequippedMessage(IEntity user, IEntity equipped, EquipmentSlotDefines.Slots slot) + public UnequippedMessage(IEntity user, IEntity unequipped, EquipmentSlotDefines.Slots slot) { User = user; - Equipped = equipped; + Unequipped = unequipped; Slot = slot; } } diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequippedHand.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequippedHand.cs new file mode 100644 index 0000000000..c738752896 --- /dev/null +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUnequippedHand.cs @@ -0,0 +1,63 @@ +using System; +using Content.Shared.GameObjects.Components.Inventory; +using Content.Shared.GameObjects.Components.Items; +using JetBrains.Annotations; +using Robust.Shared.GameObjects; +using Robust.Shared.Interfaces.GameObjects; + +namespace Content.Shared.Interfaces.GameObjects.Components +{ + /// + /// This interface gives components behavior when their entity is removed from a hand slot, + /// even if it is going into another hand slot (which would also fire ). + /// This includes moving the entity from a hand slot into a non-hand slot (which would also fire ). + /// + public interface IUnequippedHand + { + void UnequippedHand(UnequippedHandEventArgs eventArgs); + } + + public class UnequippedHandEventArgs : UserEventArgs + { + public UnequippedHandEventArgs(IEntity user, SharedHand hand) : base(user) + { + Hand = hand; + } + + public SharedHand Hand { get; } + } + + /// + /// Raised when removing the entity from an inventory slot. + /// + [PublicAPI] + public class UnequippedHandMessage : EntitySystemMessage + { + /// + /// If this message has already been "handled" by a previous system. + /// + public bool Handled { get; set; } + + /// + /// Entity that equipped the item. + /// + public IEntity User { get; } + + /// + /// Item that was unequipped. + /// + public IEntity Unequipped { get; } + + /// + /// Hand the item is removed from. + /// + public SharedHand Hand { get; } + + public UnequippedHandMessage(IEntity user, IEntity unequipped, SharedHand hand) + { + User = user; + Unequipped = unequipped; + Hand = hand; + } + } +} diff --git a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUse.cs b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUse.cs index ec6422deab..e2028ab551 100644 --- a/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUse.cs +++ b/Content.Shared/Interfaces/GameObjects/Components/Interaction/IUse.cs @@ -6,7 +6,8 @@ using Robust.Shared.Interfaces.GameObjects; namespace Content.Shared.Interfaces.GameObjects.Components { /// - /// This interface gives components behavior when using the entity in your hands + /// This interface gives components behavior when using the entity in your active hand + /// (done by clicking the entity in the active hand or pressing the keybind that defaults to Z). /// public interface IUse { diff --git a/Content.Shared/Utility/Cooldowns.cs b/Content.Shared/Utility/Cooldowns.cs new file mode 100644 index 0000000000..04a2aa39f4 --- /dev/null +++ b/Content.Shared/Utility/Cooldowns.cs @@ -0,0 +1,28 @@ +using System; +using Robust.Shared.Interfaces.Timing; +using Robust.Shared.IoC; + +namespace Content.Shared.Utility +{ + /// + /// Utilities for working with cooldowns. + /// + public static class Cooldowns + { + /// game timing to use, otherwise will resolve using IoCManager. + /// a cooldown interval starting at GameTiming.Curtime and ending at (offset) from CurTime. + /// For example, passing TimeSpan.FromSeconds(5) will create an interval + /// from now to 5 seconds from now. + public static (TimeSpan start, TimeSpan end) FromNow(TimeSpan offset, IGameTiming gameTiming = null) + { + var now = (gameTiming ?? IoCManager.Resolve()).CurTime; + return (now, now + offset); + } + + /// + public static (TimeSpan start, TimeSpan end) SecondsFromNow(double seconds, IGameTiming gameTiming = null) + { + return FromNow(TimeSpan.FromSeconds(seconds), gameTiming); + } + } +} diff --git a/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs b/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs index 89ddb24c53..9f3faf4e9d 100644 --- a/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs +++ b/Content.Tests/Server/GameObjects/Components/Mobs/ServerAlertsComponentTests.cs @@ -1,14 +1,10 @@ using System.IO; -using System.Linq; using Content.Server.GameObjects.Components.Mobs; using Content.Shared.Alert; using Content.Shared.GameObjects.Components.Mobs; -using Content.Shared.Utility; using NUnit.Framework; -using Robust.Shared.GameObjects; using Robust.Shared.Interfaces.GameObjects; using Robust.Shared.IoC; -using Robust.Shared.Map; using Robust.Shared.Prototypes; namespace Content.Tests.Server.GameObjects.Components.Mobs @@ -48,23 +44,23 @@ namespace Content.Tests.Server.GameObjects.Components.Mobs var alertsComponent = new ServerAlertsComponent(); alertsComponent = IoCManager.InjectDependencies(alertsComponent); - Assert.That(alertManager.TryGetWithEncoded(AlertType.LowPressure, out var lowpressure, out var lpencoded)); - Assert.That(alertManager.TryGetWithEncoded(AlertType.HighPressure, out var highpressure, out var hpencoded)); + Assert.That(alertManager.TryGet(AlertType.LowPressure, out var lowpressure)); + Assert.That(alertManager.TryGet(AlertType.HighPressure, out var highpressure)); alertsComponent.ShowAlert(AlertType.LowPressure); var alertState = alertsComponent.GetComponentState() as AlertsComponentState; Assert.NotNull(alertState); - Assert.That(alertState.Alerts.Length, Is.EqualTo(1)); - Assert.That(alertState.Alerts[0], Is.EqualTo(new AlertState{AlertEncoded = lpencoded})); + Assert.That(alertState.Alerts.Count, Is.EqualTo(1)); + Assert.That(alertState.Alerts.ContainsKey(lowpressure.AlertKey)); alertsComponent.ShowAlert(AlertType.HighPressure); alertState = alertsComponent.GetComponentState() as AlertsComponentState; - Assert.That(alertState.Alerts.Length, Is.EqualTo(1)); - Assert.That(alertState.Alerts[0], Is.EqualTo(new AlertState{AlertEncoded = hpencoded})); + Assert.That(alertState.Alerts.Count, Is.EqualTo(1)); + Assert.That(alertState.Alerts.ContainsKey(highpressure.AlertKey)); alertsComponent.ClearAlertCategory(AlertCategory.Pressure); alertState = alertsComponent.GetComponentState() as AlertsComponentState; - Assert.That(alertState.Alerts.Length, Is.EqualTo(0)); + Assert.That(alertState.Alerts.Count, Is.EqualTo(0)); } } } diff --git a/Content.Tests/Shared/Alert/AlertManagerTests.cs b/Content.Tests/Shared/Alert/AlertManagerTests.cs index 9a82d89e08..540444469b 100644 --- a/Content.Tests/Shared/Alert/AlertManagerTests.cs +++ b/Content.Tests/Shared/Alert/AlertManagerTests.cs @@ -37,25 +37,10 @@ namespace Content.Tests.Shared.Alert Assert.That(alertManager.TryGet(AlertType.HighPressure, out var highPressure)); Assert.That(highPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/highpressure.png")); - Assert.That(alertManager.TryGetWithEncoded(AlertType.LowPressure, out lowPressure, out var encodedLowPressure)); + Assert.That(alertManager.TryGet(AlertType.LowPressure, out lowPressure)); Assert.That(lowPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/lowpressure.png")); - Assert.That(alertManager.TryGetWithEncoded(AlertType.HighPressure, out highPressure, out var encodedHighPressure)); + Assert.That(alertManager.TryGet(AlertType.HighPressure, out highPressure)); Assert.That(highPressure.IconPath, Is.EqualTo("/Textures/Interface/Alerts/Pressure/highpressure.png")); - - Assert.That(alertManager.TryEncode(lowPressure, out var encodedLowPressure2)); - Assert.That(encodedLowPressure2, Is.EqualTo(encodedLowPressure)); - Assert.That(alertManager.TryEncode(highPressure, out var encodedHighPressure2)); - Assert.That(encodedHighPressure2, Is.EqualTo(encodedHighPressure)); - Assert.That(encodedLowPressure, Is.Not.EqualTo(encodedHighPressure)); - - Assert.That(alertManager.TryDecode(encodedLowPressure, out var decodedLowPressure)); - Assert.That(decodedLowPressure, Is.EqualTo(lowPressure)); - Assert.That(alertManager.TryDecode(encodedHighPressure, out var decodedHighPressure)); - Assert.That(decodedHighPressure, Is.EqualTo(highPressure)); - - Assert.False(alertManager.TryEncode(AlertType.Debug1, out _)); - Assert.False(alertManager.TryGetWithEncoded(AlertType.Debug1, out _, out _)); - } } } diff --git a/Content.Tests/Shared/Alert/AlertPrototypeTests.cs b/Content.Tests/Shared/Alert/AlertPrototypeTests.cs index 6729028337..b557fca622 100644 --- a/Content.Tests/Shared/Alert/AlertPrototypeTests.cs +++ b/Content.Tests/Shared/Alert/AlertPrototypeTests.cs @@ -1,9 +1,13 @@ using System.IO; +using Content.Server.Utility; using Content.Shared.Alert; +using Content.Shared.Interfaces; using NUnit.Framework; using Robust.Shared.Interfaces.Log; +using Robust.Shared.Interfaces.Reflection; using Robust.Shared.IoC; using Robust.Shared.Log; +using Robust.Shared.Serialization; using Robust.Shared.Utility; using Robust.UnitTesting; using YamlDotNet.RepresentationModel; @@ -22,7 +26,6 @@ namespace Content.Tests.Shared.Alert minSeverity: 0 maxSeverity: 6"; - [Test] public void TestAlertKey() { diff --git a/Resources/Audio/Voice/Human/femalescream_1.ogg b/Resources/Audio/Voice/Human/femalescream_1.ogg new file mode 100644 index 0000000000..c0f80a1408 Binary files /dev/null and b/Resources/Audio/Voice/Human/femalescream_1.ogg differ diff --git a/Resources/Audio/Voice/Human/femalescream_2.ogg b/Resources/Audio/Voice/Human/femalescream_2.ogg new file mode 100644 index 0000000000..978a236dd1 Binary files /dev/null and b/Resources/Audio/Voice/Human/femalescream_2.ogg differ diff --git a/Resources/Audio/Voice/Human/femalescream_3.ogg b/Resources/Audio/Voice/Human/femalescream_3.ogg new file mode 100644 index 0000000000..30e4150a56 Binary files /dev/null and b/Resources/Audio/Voice/Human/femalescream_3.ogg differ diff --git a/Resources/Audio/Voice/Human/femalescream_4.ogg b/Resources/Audio/Voice/Human/femalescream_4.ogg new file mode 100644 index 0000000000..bb73a1ef5d Binary files /dev/null and b/Resources/Audio/Voice/Human/femalescream_4.ogg differ diff --git a/Resources/Audio/Voice/Human/femalescream_5.ogg b/Resources/Audio/Voice/Human/femalescream_5.ogg new file mode 100644 index 0000000000..96a08297b2 Binary files /dev/null and b/Resources/Audio/Voice/Human/femalescream_5.ogg differ diff --git a/Resources/Audio/Voice/Human/license.txt b/Resources/Audio/Voice/Human/license.txt new file mode 100644 index 0000000000..51c969fd0e --- /dev/null +++ b/Resources/Audio/Voice/Human/license.txt @@ -0,0 +1,16 @@ +All below sounds taken from https://github.com/tgstation/tgstation/commit/3d049e69fe71a0be2133005e65ea469135d648c8 +femalescream_1 +femalescream_2 +femalescream_3 +femalescream_4 +femalescream_5 +malescream_1 +malescream_2 +malescream_3 +malescream_4 +malescream_5 +malescream_6 +manlaugh_1 +manlaugh_2 +wilhelm_scream +womanlaugh diff --git a/Resources/Audio/Voice/Human/malescream_1.ogg b/Resources/Audio/Voice/Human/malescream_1.ogg new file mode 100644 index 0000000000..ee9005b892 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_1.ogg differ diff --git a/Resources/Audio/Voice/Human/malescream_2.ogg b/Resources/Audio/Voice/Human/malescream_2.ogg new file mode 100644 index 0000000000..989b612ab1 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_2.ogg differ diff --git a/Resources/Audio/Voice/Human/malescream_3.ogg b/Resources/Audio/Voice/Human/malescream_3.ogg new file mode 100644 index 0000000000..902db1c132 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_3.ogg differ diff --git a/Resources/Audio/Voice/Human/malescream_4.ogg b/Resources/Audio/Voice/Human/malescream_4.ogg new file mode 100644 index 0000000000..62f787d4a7 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_4.ogg differ diff --git a/Resources/Audio/Voice/Human/malescream_5.ogg b/Resources/Audio/Voice/Human/malescream_5.ogg new file mode 100644 index 0000000000..2aec2c7149 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_5.ogg differ diff --git a/Resources/Audio/Voice/Human/malescream_6.ogg b/Resources/Audio/Voice/Human/malescream_6.ogg new file mode 100644 index 0000000000..bdf732c221 Binary files /dev/null and b/Resources/Audio/Voice/Human/malescream_6.ogg differ diff --git a/Resources/Audio/Voice/Human/manlaugh1.ogg b/Resources/Audio/Voice/Human/manlaugh1.ogg new file mode 100644 index 0000000000..957d7a6529 Binary files /dev/null and b/Resources/Audio/Voice/Human/manlaugh1.ogg differ diff --git a/Resources/Audio/Voice/Human/manlaugh2.ogg b/Resources/Audio/Voice/Human/manlaugh2.ogg new file mode 100644 index 0000000000..fe6c0c9b7b Binary files /dev/null and b/Resources/Audio/Voice/Human/manlaugh2.ogg differ diff --git a/Resources/Audio/Voice/Human/wilhelm_scream.ogg b/Resources/Audio/Voice/Human/wilhelm_scream.ogg new file mode 100644 index 0000000000..9a81c47f37 Binary files /dev/null and b/Resources/Audio/Voice/Human/wilhelm_scream.ogg differ diff --git a/Resources/Audio/Voice/Human/womanlaugh.ogg b/Resources/Audio/Voice/Human/womanlaugh.ogg new file mode 100644 index 0000000000..1313bd445f Binary files /dev/null and b/Resources/Audio/Voice/Human/womanlaugh.ogg differ diff --git a/Resources/Prototypes/Actions/actions.yml b/Resources/Prototypes/Actions/actions.yml new file mode 100644 index 0000000000..c30aef5800 --- /dev/null +++ b/Resources/Prototypes/Actions/actions.yml @@ -0,0 +1,91 @@ +- type: action + actionType: HumanScream + icon: Interface/Actions/scream.png + name: "Scream" + filters: + - human + behaviorType: Instant + behavior: !type:ScreamAction + cooldown: 10 + male: + - /Audio/Voice/Human/malescream_1.ogg + - /Audio/Voice/Human/malescream_2.ogg + - /Audio/Voice/Human/malescream_3.ogg + - /Audio/Voice/Human/malescream_4.ogg + - /Audio/Voice/Human/malescream_5.ogg + - /Audio/Voice/Human/malescream_6.ogg + female: + - /Audio/Voice/Human/femalescream_1.ogg + - /Audio/Voice/Human/femalescream_2.ogg + - /Audio/Voice/Human/femalescream_3.ogg + - /Audio/Voice/Human/femalescream_4.ogg + - /Audio/Voice/Human/femalescream_5.ogg + wilhelm: /Audio/Voice/Human/wilhelm_scream.ogg + +- type: action + actionType: DebugInstant + icon: Interface/Alerts/Human/human1.png + name: "[color=red]Debug Instant[/color]" + description: "This is a [color=red]debug message[/color]." + requires: "Requires blah blah" + filters: + - debug + behaviorType: Instant + behavior: !type:DebugInstant + message: Instant action was used! + +- type: action + actionType: DebugToggle + icon: Interface/Alerts/Human/human3.png + name: "[color=red]Debug Toggle[/color]" + description: "This is a [color=red]debug message[/color]." + requires: "Requires blah blah" + filters: + - debug + behaviorType: Toggle + behavior: !type:DebugToggle + messageOn: Toggled on! + messageOff: Toggled off! + +- type: action + actionType: DebugTargetPoint + icon: Interface/Alerts/Human/human4.png + name: "[color=red]Debug Target Position[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetPoint + behavior: !type:DebugTargetPoint { } + +- type: action + actionType: DebugTargetPointRepeat + icon: Interface/Alerts/Human/human2.png + name: "[color=red]Repeating Debug Target Position[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetPoint + repeat: true + behavior: !type:DebugTargetPoint { } + +- type: action + actionType: DebugTargetEntity + icon: Interface/Alerts/Human/human6.png + name: "[color=red]Debug Target Entity[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetEntity + behavior: !type:DebugTargetEntity { } + +- type: action + actionType: DebugTargetEntityRepeat + icon: Interface/Alerts/Human/human5.png + name: "[color=red]Repeating Debug Target Entity[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetEntity + repeat: true + behavior: !type:DebugTargetEntity { } + diff --git a/Resources/Prototypes/Actions/item_actions.yml b/Resources/Prototypes/Actions/item_actions.yml new file mode 100644 index 0000000000..f5ea9a4766 --- /dev/null +++ b/Resources/Prototypes/Actions/item_actions.yml @@ -0,0 +1,121 @@ +- type: itemAction + actionType: ToggleInternals + icon: Interface/Actions/internal0.png + iconOn: Interface/Actions/internal1.png + name: "Toggle Internals" + description: "Breathe from the equipped gas tank." + requires: "Requires equipped breath mask and gas tank" + filters: + - common + - atmos + keywords: + - gas + - tank + - breath + behaviorType: Toggle + behavior: !type:ToggleInternalsAction { } + +- type: itemAction + actionType: ToggleLight + icon: Objects/Tools/flashlight.rsi/lantern_off.png + iconOn: Objects/Tools/flashlight.rsi/lantern_on.png + name: "Toggle Light" + description: "Turn the light on." + filters: + - tools + keywords: + - lantern + - lamp + behaviorType: Toggle + behavior: !type:ToggleLightAction { } + +- type: itemAction + actionType: DebugInstant + icon: Interface/Alerts/Human/human1.png + iconStyle: BigAction + name: "[color=red]Debug Item Instant[/color]" + description: "This is a [color=red]debug message[/color]." + requires: "Requires blah blah" + filters: + - debug + behaviorType: Instant + behavior: !type:DebugInstant + message: Instant action was used! + cooldown: 10 + +- type: itemAction + actionType: DebugToggle + iconStyle: BigItem + icon: Interface/Alerts/Human/human3.png + name: "[color=red]Debug Item Toggle[/color]" + description: "This is a [color=red]debug message[/color]." + requires: "Requires blah blah" + filters: + - debug + behaviorType: Toggle + behavior: !type:DebugToggle + messageOn: Toggled on! + messageOff: Toggled off! + +- type: itemAction + actionType: DebugTargetPoint + iconStyle: NoItem + icon: Interface/Alerts/Human/human4.png + name: "[color=red]Debug Item Target Position[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetPoint + behavior: !type:DebugTargetPoint { } + +- type: itemAction + actionType: DebugTargetPointRepeat + iconStyle: BigAction + icon: Interface/Alerts/Human/human2.png + name: "[color=red]Repeating Debug Item Target Position[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetPoint + repeat: true + behavior: !type:DebugTargetPoint { } + +- type: itemAction + actionType: DebugTargetEntity + iconStyle: BigAction + icon: Interface/Alerts/Human/human6.png + name: "[color=red]Debug Item Target Entity[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetEntity + behavior: !type:DebugTargetEntity { } + +- type: itemAction + actionType: DebugTargetEntityRepeat + icon: Interface/Alerts/Human/human5.png + name: "[color=red]Repeating Debug Item Target Entity[/color]" + description: "This is a [color=red]debug message[/color]." + filters: + - debug + behaviorType: TargetEntity + repeat: true + behavior: !type:DebugTargetEntity { } + +- type: entity + name: item action example + parent: BaseItem + id: ItemActionExample + description: for testing item actions + components: + - type: Sprite + sprite: Objects/Fun/bikehorn.rsi + state: icon + - type: ItemActions + actions: + - actionType: DebugInstant + - actionType: DebugToggle + - actionType: DebugTargetPoint + - actionType: DebugTargetPointRepeat + - actionType: DebugTargetEntity + - actionType: DebugTargetEntityRepeat diff --git a/Resources/Prototypes/Alerts/alerts.yml b/Resources/Prototypes/Alerts/alerts.yml index 52e0d5a655..326ef60494 100644 --- a/Resources/Prototypes/Alerts/alerts.yml +++ b/Resources/Prototypes/Alerts/alerts.yml @@ -36,6 +36,7 @@ - type: alert alertType: Fire icon: /Textures/Interface/Alerts/Fire/fire.png + onClick: !type:ResistFire { } name: "[color=red]On Fire[/color]" description: "You're [color=red]on fire[/color]. Click the alert to stop, drop and roll to put the fire out or move to a vacuum area." @@ -80,6 +81,7 @@ - type: alert alertType: Buckled category: Buckled + onClick: !type:Unbuckle { } icon: /Textures/Interface/Alerts/Buckle/buckled.png name: "[color=yellow]Buckled[/color]" description: "You've been [color=yellow]buckled[/color] to something. Click the alert to unbuckle unless you're [color=yellow]handcuffed.[/color]" @@ -110,6 +112,7 @@ - type: alert alertType: PilotingShuttle category: Piloting + onClick: !type:StopPiloting { } icon: /Textures/Interface/Alerts/Buckle/buckled.png name: Piloting Shuttle description: You are piloting a shuttle. Click the alert to stop. @@ -165,6 +168,7 @@ - type: alert alertType: Pulling icon: /Textures/Interface/Alerts/Pull/pulling.png + onClick: !type:StopPulling { } name: Pulling description: You're pulling something. Click the alert to stop. diff --git a/Resources/Prototypes/Entities/Clothing/Head/hardhats.yml b/Resources/Prototypes/Entities/Clothing/Head/hardhats.yml index 236cfac568..e8e403ae9b 100644 --- a/Resources/Prototypes/Entities/Clothing/Head/hardhats.yml +++ b/Resources/Prototypes/Entities/Clothing/Head/hardhats.yml @@ -20,6 +20,9 @@ visuals: - type: FlashLightVisualizer - type: HandheldLight + - type: ItemActions + actions: + - actionType: ToggleLight - type: PowerCellSlot - type: entity diff --git a/Resources/Prototypes/Entities/Mobs/Player/human.yml b/Resources/Prototypes/Entities/Mobs/Player/human.yml index 671a69b4db..e692fa9830 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/human.yml @@ -10,7 +10,10 @@ show_examine_info: true - type: Input context: "human" - - type: AlertsUI + - type: Alerts + - type: Actions + innateActions: + - HumanScream - type: OverlayEffectsUI - type: Eye zoom: 0.5, 0.5 diff --git a/Resources/Prototypes/Entities/Objects/Misc/fluff_lights.yml b/Resources/Prototypes/Entities/Objects/Misc/fluff_lights.yml index 41169aa299..279f3bde1a 100644 --- a/Resources/Prototypes/Entities/Objects/Misc/fluff_lights.yml +++ b/Resources/Prototypes/Entities/Objects/Misc/fluff_lights.yml @@ -6,6 +6,9 @@ abstract: true components: - type: HandheldLight + - type: ItemActions + actions: + - actionType: ToggleLight - type: Sprite sprite: Objects/Misc/lights.rsi - type: Item diff --git a/Resources/Prototypes/Entities/Objects/Tools/flashlight.yml b/Resources/Prototypes/Entities/Objects/Tools/flashlight.yml index 7fa4d153b3..cd3dd41e3e 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/flashlight.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/flashlight.yml @@ -5,6 +5,9 @@ description: They light the way to freedom. components: - type: HandheldLight + - type: ItemActions + actions: + - actionType: ToggleLight - type: PowerCellSlot - type: Sprite sprite: Objects/Tools/flashlight.rsi diff --git a/Resources/Prototypes/Entities/Objects/Tools/gas_tanks.yml b/Resources/Prototypes/Entities/Objects/Tools/gas_tanks.yml index 7f1b12ea5f..26e3cc5788 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/gas_tanks.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/gas_tanks.yml @@ -19,6 +19,9 @@ - Back - Belt - type: GasTank + - type: ItemActions + actions: + - actionType: ToggleInternals - type: entity id: OxygenTank @@ -281,7 +284,7 @@ sprite: Objects/Tanks/phoron.rsi Slots: - Belt - + - type: entity id: PhoronTankFilled parent: PhoronTank diff --git a/Resources/Prototypes/Entities/Objects/Tools/lantern.yml b/Resources/Prototypes/Entities/Objects/Tools/lantern.yml index 5fbbbe13f1..a8f62030fd 100644 --- a/Resources/Prototypes/Entities/Objects/Tools/lantern.yml +++ b/Resources/Prototypes/Entities/Objects/Tools/lantern.yml @@ -5,6 +5,9 @@ description: The holy light guides the way. components: - type: HandheldLight + - type: ItemActions + actions: + - actionType: ToggleLight - type: Sprite sprite: Objects/Tools/lantern.rsi layers: diff --git a/Resources/Textures/Interface/Actions/internal0.png b/Resources/Textures/Interface/Actions/internal0.png new file mode 100644 index 0000000000..c136016c64 Binary files /dev/null and b/Resources/Textures/Interface/Actions/internal0.png differ diff --git a/Resources/Textures/Interface/Actions/internal1.png b/Resources/Textures/Interface/Actions/internal1.png new file mode 100644 index 0000000000..f405a3af01 Binary files /dev/null and b/Resources/Textures/Interface/Actions/internal1.png differ diff --git a/Resources/Textures/Interface/Actions/meta.json b/Resources/Textures/Interface/Actions/meta.json new file mode 100644 index 0000000000..6ccd7cf002 --- /dev/null +++ b/Resources/Textures/Interface/Actions/meta.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "size": { + "x": 32, + "y": 32 + }, + "license": "CC-BY-SA-3.0", + "copyright": "Taken from https://github.com/tgstation/tgstation/commit/3d049e69fe71a0be2133005e65ea469135d648c8", + "states": [ + { + "name": "internal0", + "directions": 1 + }, + { + "name": "internal1", + "directions": 1 + }, + { + "name": "scream", + "directions": 1 + } + ] +} diff --git a/Resources/Textures/Interface/Actions/scream.png b/Resources/Textures/Interface/Actions/scream.png new file mode 100644 index 0000000000..af3494368a Binary files /dev/null and b/Resources/Textures/Interface/Actions/scream.png differ diff --git a/Resources/Textures/Interface/Nano/black_panel_dark_thin_border.png b/Resources/Textures/Interface/Nano/black_panel_dark_thin_border.png new file mode 100644 index 0000000000..b95f047938 Binary files /dev/null and b/Resources/Textures/Interface/Nano/black_panel_dark_thin_border.png differ diff --git a/Resources/Textures/Interface/Nano/black_panel_light_thin_border.png b/Resources/Textures/Interface/Nano/black_panel_light_thin_border.png new file mode 100644 index 0000000000..d1a9f279ba Binary files /dev/null and b/Resources/Textures/Interface/Nano/black_panel_light_thin_border.png differ diff --git a/Resources/Textures/Interface/Nano/black_panel_red_thin_border.png b/Resources/Textures/Interface/Nano/black_panel_red_thin_border.png new file mode 100644 index 0000000000..9d3960ccfa Binary files /dev/null and b/Resources/Textures/Interface/Nano/black_panel_red_thin_border.png differ diff --git a/Resources/Textures/Interface/Nano/gear.svg b/Resources/Textures/Interface/Nano/gear.svg new file mode 100644 index 0000000000..ccb451d706 --- /dev/null +++ b/Resources/Textures/Interface/Nano/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Textures/Interface/Nano/gear.svg.png b/Resources/Textures/Interface/Nano/gear.svg.png new file mode 100644 index 0000000000..522472042c Binary files /dev/null and b/Resources/Textures/Interface/Nano/gear.svg.png differ diff --git a/Resources/Textures/Interface/Nano/left_arrow.svg b/Resources/Textures/Interface/Nano/left_arrow.svg new file mode 100644 index 0000000000..f3f1bb49b3 --- /dev/null +++ b/Resources/Textures/Interface/Nano/left_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Textures/Interface/Nano/left_arrow.svg.png b/Resources/Textures/Interface/Nano/left_arrow.svg.png new file mode 100644 index 0000000000..9453502832 Binary files /dev/null and b/Resources/Textures/Interface/Nano/left_arrow.svg.png differ diff --git a/Resources/Textures/Interface/Nano/light_panel_background_bordered.png b/Resources/Textures/Interface/Nano/light_panel_background_bordered.png new file mode 100644 index 0000000000..95aab33ba3 Binary files /dev/null and b/Resources/Textures/Interface/Nano/light_panel_background_bordered.png differ diff --git a/Resources/Textures/Interface/Nano/lock.svg b/Resources/Textures/Interface/Nano/lock.svg new file mode 100644 index 0000000000..54d5211aba --- /dev/null +++ b/Resources/Textures/Interface/Nano/lock.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Textures/Interface/Nano/lock.svg.png b/Resources/Textures/Interface/Nano/lock.svg.png new file mode 100644 index 0000000000..148cc20f5e Binary files /dev/null and b/Resources/Textures/Interface/Nano/lock.svg.png differ diff --git a/Resources/Textures/Interface/Nano/lock_open.svg b/Resources/Textures/Interface/Nano/lock_open.svg new file mode 100644 index 0000000000..e4f0449523 --- /dev/null +++ b/Resources/Textures/Interface/Nano/lock_open.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/lock_open.svg.png b/Resources/Textures/Interface/Nano/lock_open.svg.png new file mode 100644 index 0000000000..d08584bc17 Binary files /dev/null and b/Resources/Textures/Interface/Nano/lock_open.svg.png differ diff --git a/Resources/Textures/Interface/Nano/right_arrow.svg b/Resources/Textures/Interface/Nano/right_arrow.svg new file mode 100644 index 0000000000..eff5fdff21 --- /dev/null +++ b/Resources/Textures/Interface/Nano/right_arrow.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/Textures/Interface/Nano/right_arrow.svg.png b/Resources/Textures/Interface/Nano/right_arrow.svg.png new file mode 100644 index 0000000000..9ed4cb62fa Binary files /dev/null and b/Resources/Textures/Interface/Nano/right_arrow.svg.png differ diff --git a/Resources/Textures/Interface/Nano/square.png b/Resources/Textures/Interface/Nano/square.png new file mode 100644 index 0000000000..468260b1e6 Binary files /dev/null and b/Resources/Textures/Interface/Nano/square.png differ diff --git a/Resources/Textures/Interface/Nano/square.svg b/Resources/Textures/Interface/Nano/square.svg new file mode 100644 index 0000000000..55ba05166b --- /dev/null +++ b/Resources/Textures/Interface/Nano/square.svg @@ -0,0 +1,70 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png b/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png index 987ad4cba6..b9cc0a9ce1 100644 Binary files a/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png and b/Resources/Textures/Interface/Nano/transparent_window_background_bordered.png differ diff --git a/Resources/Textures/Interface/Nano/window_background_bordered.png b/Resources/Textures/Interface/Nano/window_background_bordered.png index 461fbf3114..de59bb0b3a 100644 Binary files a/Resources/Textures/Interface/Nano/window_background_bordered.png and b/Resources/Textures/Interface/Nano/window_background_bordered.png differ diff --git a/Resources/Textures/Objects/Tools/flashlight.rsi/lantern_on.png b/Resources/Textures/Objects/Tools/flashlight.rsi/lantern_on.png new file mode 100644 index 0000000000..ff8752b672 Binary files /dev/null and b/Resources/Textures/Objects/Tools/flashlight.rsi/lantern_on.png differ diff --git a/Resources/Textures/Objects/Tools/flashlight.rsi/meta.json b/Resources/Textures/Objects/Tools/flashlight.rsi/meta.json index eab8fd1d32..e2ed8c31da 100644 --- a/Resources/Textures/Objects/Tools/flashlight.rsi/meta.json +++ b/Resources/Textures/Objects/Tools/flashlight.rsi/meta.json @@ -14,6 +14,15 @@ ] ] }, + { + "name": "lantern_on", + "directions": 1, + "delays": [ + [ + 1.0 + ] + ] + }, { "name": "HandheldLightOnOverlay", "directions": 1, diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml index 98aac8e543..124df8edc8 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -301,3 +301,36 @@ binds: - function: Arcade3 type: State key: Z +- function: OpenAbilitiesMenu + type: State + key: Equal +- function: Hotbar0 + type: State + key: Num0 +- function: Hotbar1 + type: State + key: Num1 +- function: Hotbar2 + type: State + key: Num2 +- function: Hotbar3 + type: State + key: Num3 +- function: Hotbar4 + type: State + key: Num4 +- function: Hotbar5 + type: State + key: Num5 +- function: Hotbar6 + type: State + key: Num6 +- function: Hotbar7 + type: State + key: Num7 +- function: Hotbar8 + type: State + key: Num8 +- function: Hotbar9 + type: State + key: Num9