using Content.Client.Actions.Assignments; using Content.Client.Actions.UI; using Content.Client.Construction; using Content.Client.DragDrop; using Content.Client.Hands; using Content.Client.Items.Managers; using Content.Client.Outline; using Content.Client.Popups; using Content.Shared.Actions; using Content.Shared.Actions.ActionTypes; using Content.Shared.Input; using JetBrains.Annotations; using Robust.Client.GameObjects; using Robust.Client.Graphics; using Robust.Client.Player; using Robust.Client.UserInterface; using Robust.Client.Utility; using Robust.Shared.Audio; using Robust.Shared.ContentPack; using Robust.Shared.GameStates; using Robust.Shared.Input; using Robust.Shared.Input.Binding; using Robust.Shared.Player; using Robust.Shared.Serialization.Manager; using Robust.Shared.Serialization.Markdown; using Robust.Shared.Serialization.Markdown.Mapping; using Robust.Shared.Serialization.Markdown.Sequence; using Robust.Shared.Utility; using System.IO; using System.Linq; using YamlDotNet.RepresentationModel; namespace Content.Client.Actions { [UsedImplicitly] public sealed class ActionsSystem : SharedActionsSystem { [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IUserInterfaceManager _uiManager = default!; [Dependency] private readonly IItemSlotManager _itemSlotManager = default!; [Dependency] private readonly ISerializationManager _serializationManager = default!; [Dependency] private readonly IResourceManager _resourceManager = default!; [Dependency] private readonly IOverlayManager _overlayMan = default!; [Dependency] private readonly PopupSystem _popupSystem = default!; [Dependency] private readonly InteractionOutlineSystem _interactionOutline = default!; [Dependency] private readonly TargetOutlineSystem _targetOutline = default!; // TODO Redo assignments, including allowing permanent user configurable slot assignments. /// /// Current assignments for all hotbars / slots for this entity. /// public ActionAssignments Assignments = new(Hotbars, Slots); public const byte Hotbars = 9; public const byte Slots = 10; public bool UIDirty; public ActionsUI? Ui; private EntityUid? _highlightedEntity; 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)) .Bind(ContentKeyFunctions.Loadout1, HandleChangeHotbarKeybind(0)) .Bind(ContentKeyFunctions.Loadout2, HandleChangeHotbarKeybind(1)) .Bind(ContentKeyFunctions.Loadout3, HandleChangeHotbarKeybind(2)) .Bind(ContentKeyFunctions.Loadout4, HandleChangeHotbarKeybind(3)) .Bind(ContentKeyFunctions.Loadout5, HandleChangeHotbarKeybind(4)) .Bind(ContentKeyFunctions.Loadout6, HandleChangeHotbarKeybind(5)) .Bind(ContentKeyFunctions.Loadout7, HandleChangeHotbarKeybind(6)) .Bind(ContentKeyFunctions.Loadout8, HandleChangeHotbarKeybind(7)) .Bind(ContentKeyFunctions.Loadout9, HandleChangeHotbarKeybind(8)) // 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, outsidePrediction: true), typeof(ConstructionSystem), typeof(DragDropSystem)) .BindBefore(EngineKeyFunctions.UIRightClick, new PointerInputCmdHandler(TargetingCancel, outsidePrediction: true)) .Register(); SubscribeLocalEvent(OnPlayerAttached); SubscribeLocalEvent(OnPlayerDetached); SubscribeLocalEvent(HandleState); } public override void Dirty(ActionType action) { // Should only ever receive component states for attached player's component. // --> lets not bother unnecessarily dirtying and prediction-resetting actions for other players. if (action.AttachedEntity != _playerManager.LocalPlayer?.ControlledEntity) return; base.Dirty(action); UIDirty = true; } private void HandleState(EntityUid uid, ActionsComponent component, ref ComponentHandleState args) { // Client only needs to care about local player. if (uid != _playerManager.LocalPlayer?.ControlledEntity) return; if (args.Current is not ActionsComponentState state) return; var serverActions = new SortedSet(state.Actions); foreach (var act in component.Actions.ToList()) { if (act.ClientExclusive) continue; if (!serverActions.TryGetValue(act, out var serverAct)) { component.Actions.Remove(act); if (act.AutoRemove && !(Ui?.Locked ?? false)) Assignments.Remove(act); continue; } act.CopyFrom(serverAct); serverActions.Remove(serverAct); } // Anything that remains is a new action foreach (var newAct in serverActions) { // We create a new action, not just sorting a reference to the state's action. component.Actions.Add((ActionType) newAct.Clone()); } UIDirty = true; } /// /// Highlights the item slot (inventory or hand) that contains this item /// /// public void HighlightItemSlot(EntityUid item) { StopHighlightingItemSlot(); _highlightedEntity = item; _itemSlotManager.HighlightEntity(item); } /// /// Stops highlighting any item slots we are currently highlighting. /// H public void StopHighlightingItemSlot() { if (_highlightedEntity == null) return; _itemSlotManager.UnHighlightEntity(_highlightedEntity.Value); _highlightedEntity = null; } protected override void AddActionInternal(ActionsComponent comp, ActionType action) { // Sometimes the client receives actions from the server, before predicting that newly added components will add // their own shared actions. Just in case those systems ever decided to directly access action properties (e.g., // action.Toggled), we will remove duplicates: if (comp.Actions.TryGetValue(action, out var existing)) { comp.Actions.Remove(existing); Assignments.Replace(existing, action); } comp.Actions.Add(action); } public override void AddAction(EntityUid uid, ActionType action, EntityUid? provider, ActionsComponent? comp = null, bool dirty = true) { if (uid != _playerManager.LocalPlayer?.ControlledEntity) return; if (!Resolve(uid, ref comp, false)) return; base.AddAction(uid, action, provider, comp, dirty); UIDirty = true; } public override void RemoveActions(EntityUid uid, IEnumerable actions, ActionsComponent? comp = null, bool dirty = true) { if (uid != _playerManager.LocalPlayer?.ControlledEntity) return; if (!Resolve(uid, ref comp, false)) return; base.RemoveActions(uid, actions, comp, dirty); foreach (var act in actions) { if (act.AutoRemove && !(Ui?.Locked ?? false)) Assignments.Remove(act); } UIDirty = true; } public override void FrameUpdate(float frameTime) { // avoid updating GUI when doing predictions & resetting state. if (UIDirty) { UIDirty = false; UpdateUI(); } } /// /// Updates the displayed hotbar (and menu) based on current state of actions. /// public void UpdateUI() { if (Ui == null) return; foreach (var action in Ui.Component.Actions) { if (action.AutoPopulate && !Assignments.Assignments.ContainsKey(action)) Assignments.AutoPopulate(action, Ui.SelectedHotbar, false); } // get rid of actions that are no longer available to the user foreach (var (action, index) in Assignments.Assignments.ToList()) { if (index.Count == 0) { Assignments.Assignments.Remove(action); continue; } if (action.AutoRemove && !Ui.Locked && !Ui.Component.Actions.Contains(action)) Assignments.ClearSlot(index[0].Hotbar, index[0].Slot, false); } Assignments.PreventAutoPopulate.RemoveWhere(action => !Ui.Component.Actions.Contains(action)); Ui.UpdateUI(); } public void HandleHotbarKeybind(byte slot, in PointerInputCmdHandler.PointerInputCmdArgs args) { Ui?.HandleHotbarKeybind(slot, args); } public void HandleChangeHotbarKeybind(byte hotbar, in PointerInputCmdHandler.PointerInputCmdArgs args) { Ui?.HandleChangeHotbarKeybind(hotbar, args); } private void OnPlayerDetached(EntityUid uid, ActionsComponent component, PlayerDetachedEvent args) { if (Ui == null) return; _uiManager.StateRoot.RemoveChild(Ui); Ui = null; } private void OnPlayerAttached(EntityUid uid, ActionsComponent component, PlayerAttachedEvent args) { Assignments = new(Hotbars, Slots); Ui = new ActionsUI(this, component); _uiManager.StateRoot.AddChild(Ui); UIDirty = true; } 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 || !EntityManager.TryGetComponent(playerEntity.Value, out var actionsComponent)) return false; HandleHotbarKeybind(slot, args); return true; }, false); } private PointerInputCmdHandler HandleChangeHotbarKeybind(byte hotbar) { // delegate to the ActionsUI, simulating a click on it return new((in PointerInputCmdHandler.PointerInputCmdArgs args) => { var playerEntity = _playerManager.LocalPlayer?.ControlledEntity; if (!EntityManager.TryGetComponent(playerEntity, out var actionsComponent)) return false; HandleChangeHotbarKeybind(hotbar, args); return true; }, false); } private void ToggleActionsMenu() { Ui?.ToggleActionsMenu(); } /// /// A action slot was pressed. This either performs the action or toggles the targeting mode. /// internal void OnSlotPressed(ActionSlot slot) { if (Ui == null) return; if (slot.Action == null || _playerManager.LocalPlayer?.ControlledEntity is not EntityUid user) return; if (slot.Action.Provider != null && Deleted(slot.Action.Provider)) return; if (slot.Action is not InstantAction instantAction) { // 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); return; } if (slot.Action.ClientExclusive) { if (instantAction.Event != null) instantAction.Event.Performer = user; PerformAction(Ui.Component, instantAction, instantAction.Event, GameTiming.CurTime); } else { var request = new RequestPerformActionEvent(instantAction); EntityManager.RaisePredictiveEvent(request); } } private bool TargetingCancel(in PointerInputCmdHandler.PointerInputCmdArgs args) { if (!GameTiming.IsFirstTimePredicted) return false; // only do something for actual target-based actions if (Ui?.SelectingTargetFor?.Action == null) return false; Ui.StopTargeting(); return true; } /// /// If the user clicked somewhere, and they are currently targeting an action, try and perform it. /// private bool TargetingOnUse(in PointerInputCmdHandler.PointerInputCmdArgs args) { if (!GameTiming.IsFirstTimePredicted) return false; // only do something for actual target-based actions if (Ui?.SelectingTargetFor?.Action is not TargetedAction action) return false; if (_playerManager.LocalPlayer?.ControlledEntity is not EntityUid user) return false; if (!TryComp(user, out ActionsComponent? comp)) return false; // Is the action currently valid? if (!action.Enabled || action.Charges != null && action.Charges == 0 || action.Cooldown.HasValue && action.Cooldown.Value.End > GameTiming.CurTime) { // The user is targeting with this action, but it is not valid. Maybe mark this click as // handled and prevent further interactions. return !action.InteractOnMiss; } switch (action) { case WorldTargetAction mapTarget: return TryTargetWorld(args, mapTarget, user, comp) || !action.InteractOnMiss; case EntityTargetAction entTarget: return TargetEntity(args, entTarget, user, comp) || !action.InteractOnMiss; default: Logger.Error($"Unknown targeting action: {action.GetType()}"); return false; } } private bool TryTargetWorld(in PointerInputCmdHandler.PointerInputCmdArgs args, WorldTargetAction action, EntityUid user, ActionsComponent actionComp) { var coords = args.Coordinates.ToMap(EntityManager); if (!ValidateWorldTarget(user, coords, action)) { // Invalid target. if (action.DeselectOnMiss) Ui?.StopTargeting(); return false; } if (action.ClientExclusive) { if (action.Event != null) { action.Event.Target = coords; action.Event.Performer = user; } PerformAction(actionComp, action, action.Event, GameTiming.CurTime); } else EntityManager.RaisePredictiveEvent(new RequestPerformActionEvent(action, coords)); if (!action.Repeat) Ui?.StopTargeting(); return true; } private bool TargetEntity(in PointerInputCmdHandler.PointerInputCmdArgs args, EntityTargetAction action, EntityUid user, ActionsComponent actionComp) { if (!ValidateEntityTarget(user, args.EntityUid, action)) { if (action.DeselectOnMiss) Ui?.StopTargeting(); return false; } if (action.ClientExclusive) { if (action.Event != null) { action.Event.Target = args.EntityUid; action.Event.Performer = user; } PerformAction(actionComp, action, action.Event, GameTiming.CurTime); } else EntityManager.RaisePredictiveEvent(new RequestPerformActionEvent(action, args.EntityUid)); if (!action.Repeat) Ui?.StopTargeting(); return true; } /// /// Execute convenience functionality for actions (pop-ups, sound, speech) /// protected override bool PerformBasicActions(EntityUid user, ActionType action) { var performedAction = action.Sound != null || !string.IsNullOrWhiteSpace(action.UserPopup) || !string.IsNullOrWhiteSpace(action.Popup); if (!GameTiming.IsFirstTimePredicted) return performedAction; if (!string.IsNullOrWhiteSpace(action.UserPopup)) { var msg = (!action.Toggled || string.IsNullOrWhiteSpace(action.PopupToggleSuffix)) ? Loc.GetString(action.UserPopup) : Loc.GetString(action.UserPopup + action.PopupToggleSuffix); _popupSystem.PopupEntity(msg, user); } else if (!string.IsNullOrWhiteSpace(action.Popup)) { var msg = (!action.Toggled || string.IsNullOrWhiteSpace(action.PopupToggleSuffix)) ? Loc.GetString(action.Popup) : Loc.GetString(action.Popup + action.PopupToggleSuffix); _popupSystem.PopupEntity(msg, user); } if (action.Sound != null) SoundSystem.Play(Filter.Local(), action.Sound.GetSound(), user, action.AudioParams); return performedAction; } internal void StopTargeting() { _targetOutline.Disable(); _interactionOutline.SetEnabled(true); if (!_overlayMan.TryGetOverlay(out var handOverlay) || handOverlay == null) return; handOverlay.IconOverride = null; handOverlay.EntityOverride = null; } internal void StartTargeting(TargetedAction action) { // override "held-item" overlay if (action.TargetingIndicator && _overlayMan.TryGetOverlay(out var handOverlay) && handOverlay != null) { if (action.ItemIconStyle == ItemActionIconStyle.BigItem && action.Provider != null) { handOverlay.EntityOverride = action.Provider; } else if (action.Toggled && action.IconOn != null) handOverlay.IconOverride = action.IconOn.Frame0(); else if (action.Icon != null) handOverlay.IconOverride = action.Icon.Frame0(); } // TODO: allow world-targets to check valid positions. E.g., maybe: // - Draw a red/green ghost entity // - Add a yes/no checkmark where the HandItemOverlay usually is // Highlight valid entity targets if (action is not EntityTargetAction entityAction) return; Func? predicate = null; if (!entityAction.CanTargetSelf) predicate = e => e != entityAction.AttachedEntity; var range = entityAction.CheckCanAccess ? action.Range : -1; _interactionOutline.SetEnabled(false); _targetOutline.Enable(range, entityAction.CheckCanAccess, predicate, entityAction.Whitelist, null); } internal void TryFillSlot(byte hotbar, byte index) { if (Ui == null) return; var fillEvent = new FillActionSlotEvent(); RaiseLocalEvent(Ui.Component.Owner, fillEvent, broadcast: true); if (fillEvent.Action == null) return; fillEvent.Action.ClientExclusive = true; fillEvent.Action.Temporary = true; fillEvent.Action.AutoPopulate = false; Ui.Component.Actions.Add(fillEvent.Action); Assignments.AssignSlot(hotbar, index, fillEvent.Action); Ui.UpdateUI(); } /*public void SaveActionAssignments(string path) { // Currently only tested with temporary innate actions (i.e., mapping actions). No guarantee it works with // other actions. If its meant to be used for full game state saving/loading, the entity that provides // actions needs to keep the same uid. var sequence = new SequenceDataNode(); foreach (var (action, assigns) in Assignments.Assignments) { var slot = new MappingDataNode(); slot.Add("action", _serializationManager.WriteValue(action)); slot.Add("assignments", _serializationManager.WriteValue(assigns)); sequence.Add(slot); } using var writer = _resourceManager.UserData.OpenWriteText(new ResourcePath(path).ToRootedPath()); var stream = new YamlStream { new(sequence.ToSequenceNode()) }; stream.Save(new YamlMappingFix(new Emitter(writer)), false); }*/ /// /// Load actions and their toolbar assignments from a file. /// public void LoadActionAssignments(string path, bool userData) { if (Ui == null) return; var file = new ResourcePath(path).ToRootedPath(); TextReader reader = userData ? _resourceManager.UserData.OpenText(file) : _resourceManager.ContentFileReadText(file); var yamlStream = new YamlStream(); yamlStream.Load(reader); if (yamlStream.Documents[0].RootNode.ToDataNode() is not SequenceDataNode sequence) return; foreach (var (action, assigns) in Assignments.Assignments) { foreach (var index in assigns) { Assignments.ClearSlot(index.Hotbar, index.Slot, true); } } foreach (var entry in sequence.Sequence) { if (entry is not MappingDataNode map) continue; if (!map.TryGet("action", out var actionNode)) continue; var action = _serializationManager.Read(actionNode); if (Ui.Component.Actions.TryGetValue(action, out var existingAction)) { existingAction.CopyFrom(action); action = existingAction; } else Ui.Component.Actions.Add(action); if (!map.TryGet("assignments", out var assignmentNode)) continue; var assignments = _serializationManager.Read>(assignmentNode); foreach (var index in assignments) { Assignments.AssignSlot(index.Hotbar, index.Slot, action); } } UIDirty = true; } } }