From b67c0290610fd652016082d3198857b6ac76030f Mon Sep 17 00:00:00 2001 From: KOTOB <59124164+kotobdev@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:13:57 -0700 Subject: [PATCH] Port Item Offering from Euphoria (#5597) * Item offering port (#96) * Offer item system port * More fixes * Make the formatting a bit more readable * Add a missing keybind * Add missing alert click handler and make some of the code more readable * Duplicate offer-item-system.ftl Signed-off-by: Mnemotechnican <69920617+Mnemotechnician@users.noreply.github.com> * Namespace fix and prediction fixes * Port various carrying and pseudo-item fixes from floofstation * Refactor the offer system to hopefully bring more clarity * Final fixes * Final final final fixes maybe * Sike, just do it the proper way --------- Signed-off-by: Mnemotechnican <69920617+Mnemotechnician@users.noreply.github.com> * fixes * empty commit (weird test fail?) * Moved some stuff to shared. Reorganized a bit. I could be here all day... --------- Signed-off-by: Mnemotechnican <69920617+Mnemotechnician@users.noreply.github.com> Co-authored-by: Mnemotechnican <69920617+Mnemotechnician@users.noreply.github.com> Co-authored-by: Vanessa --- Content.Client/Input/ContentContexts.cs | 3 + .../Options/UI/Tabs/KeyRebindTab.xaml.cs | 1 + .../OfferItem/OfferItemIndicatorsOverlay.cs | 71 +++ .../_Floof/OfferItem/OfferItemSystem.cs | 39 ++ .../Item/PseudoItem/PseudoItemSystem.cs | 6 +- .../_Floof/OfferItem/OfferItemSystem.cs | 5 + Content.Shared/Input/ContentKeyFunctions.cs | 1 + .../Movement/Pulling/Systems/PullingSystem.cs | 4 +- .../Item/PseudoItem/SharedPseudoItemSystem.cs | 41 +- Content.Shared/_DV/Carrying/CarryingSystem.cs | 29 +- .../_Floof/OfferItem/AcceptOfferAlertEvent.cs | 5 + .../_Floof/OfferItem/OfferItemComponent.cs | 52 +++ .../OfferableVirtualItemComponent.cs | 11 + .../SharedOfferItemSystem.Interactions.cs | 93 ++++ .../_Floof/OfferItem/SharedOfferItemSystem.cs | 414 ++++++++++++++++++ .../_Floof/interaction/offer-item-system.ftl | 17 + Resources/Prototypes/Alerts/alerts.yml | 1 + .../Prototypes/Body/species_appearance.yml | 1 + Resources/Prototypes/_Floof/alerts.yml | 8 + .../Interface/Alerts/offer_item.rsi/meta.json | 14 + .../Alerts/offer_item.rsi/offer_item.png | Bin 0 -> 782 bytes .../Misc/give_item.rsi/give_item.png | Bin 0 -> 7276 bytes .../Interface/Misc/give_item.rsi/meta.json | 14 + Resources/keybinds.yml | 5 + 24 files changed, 810 insertions(+), 25 deletions(-) create mode 100644 Content.Client/_Floof/OfferItem/OfferItemIndicatorsOverlay.cs create mode 100644 Content.Client/_Floof/OfferItem/OfferItemSystem.cs create mode 100644 Content.Server/_Floof/OfferItem/OfferItemSystem.cs create mode 100644 Content.Shared/_Floof/OfferItem/AcceptOfferAlertEvent.cs create mode 100644 Content.Shared/_Floof/OfferItem/OfferItemComponent.cs create mode 100644 Content.Shared/_Floof/OfferItem/OfferableVirtualItemComponent.cs create mode 100644 Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.Interactions.cs create mode 100644 Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.cs create mode 100644 Resources/Locale/en-US/_Floof/interaction/offer-item-system.ftl create mode 100644 Resources/Prototypes/_Floof/alerts.yml create mode 100644 Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/meta.json create mode 100644 Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/offer_item.png create mode 100644 Resources/Textures/_Floof/Interface/Misc/give_item.rsi/give_item.png create mode 100644 Resources/Textures/_Floof/Interface/Misc/give_item.rsi/meta.json diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 8c8dd1a4748..ac59adc1611 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -34,6 +34,9 @@ namespace Content.Client.Input common.AddFunction(ContentKeyFunctions.RotateStoredItem); common.AddFunction(ContentKeyFunctions.SaveItemLocation); common.AddFunction(ContentKeyFunctions.Point); + // Floofstation section + common.AddFunction(ContentKeyFunctions.OfferItem); + // Floofstation section end common.AddFunction(ContentKeyFunctions.ZoomOut); common.AddFunction(ContentKeyFunctions.ZoomIn); common.AddFunction(ContentKeyFunctions.ResetZoom); diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs index 5e1bcd59857..79c36c969e5 100644 --- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs +++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs @@ -208,6 +208,7 @@ namespace Content.Client.Options.UI.Tabs AddButton(ContentKeyFunctions.MovePulledObject); AddButton(ContentKeyFunctions.ReleasePulledObject); AddButton(ContentKeyFunctions.Point); + AddButton(ContentKeyFunctions.OfferItem); // Floofstation AddButton(ContentKeyFunctions.RotateObjectClockwise); AddButton(ContentKeyFunctions.RotateObjectCounterclockwise); AddButton(ContentKeyFunctions.FlipObject); diff --git a/Content.Client/_Floof/OfferItem/OfferItemIndicatorsOverlay.cs b/Content.Client/_Floof/OfferItem/OfferItemIndicatorsOverlay.cs new file mode 100644 index 00000000000..c306303b5b3 --- /dev/null +++ b/Content.Client/_Floof/OfferItem/OfferItemIndicatorsOverlay.cs @@ -0,0 +1,71 @@ +using System.Numerics; +using Robust.Client.GameObjects; +using Robust.Client.Graphics; +using Robust.Client.Input; +using Robust.Client.UserInterface; +using Robust.Shared.Enums; +using Robust.Shared.Utility; + +namespace Content.Client._Floof.OfferItem; + +public sealed class OfferItemIndicatorsOverlay : Overlay +{ + private readonly IInputManager _inputManager; + private readonly IEntityManager _entMan; + private readonly IEyeManager _eye; + private readonly OfferItemSystem _offer; + + private readonly Texture _sight; + + public override OverlaySpace Space => OverlaySpace.ScreenSpace; + + private readonly Color _mainColor = Color.White.WithAlpha(0.3f); + private readonly Color _strokeColor = Color.Black.WithAlpha(0.5f); + private readonly float _scale = 0.6f; // 1 is a little big + + public OfferItemIndicatorsOverlay(IInputManager input, IEntityManager entMan, + IEyeManager eye, OfferItemSystem offerSys) + { + _inputManager = input; + _entMan = entMan; + _eye = eye; + _offer = offerSys; + + var spriteSys = _entMan.EntitySysManager.GetEntitySystem(); + _sight = spriteSys.Frame0(new SpriteSpecifier.Rsi(new("/Textures/_Floof/Interface/Misc/give_item.rsi"), "give_item")); + } + + protected override bool BeforeDraw(in OverlayDrawArgs args) + { + if (!_offer.IsInOfferMode()) + return false; + + return base.BeforeDraw(in args); + } + + protected override void Draw(in OverlayDrawArgs args) + { + var mouseScreenPosition = _inputManager.MouseScreenPosition; + var mousePosMap = _eye.PixelToMap(mouseScreenPosition); + if (mousePosMap.MapId != args.MapId) + return; + + + var mousePos = mouseScreenPosition.Position; + var uiScale = (args.ViewportControl as Control)?.UIScale ?? 1f; + var limitedScale = uiScale > 1.25f ? 1.25f : uiScale; + + DrawSight(_sight, args.ScreenHandle, mousePos, limitedScale * _scale); + } + + private void DrawSight(Texture sight, DrawingHandleScreen screen, Vector2 centerPos, float scale) + { + var sightSize = sight.Size * scale; + var expandedSize = sightSize + new Vector2(7f, 7f); + + screen.DrawTextureRect(sight, + UIBox2.FromDimensions(centerPos - sightSize * 0.5f, sightSize), _strokeColor); + screen.DrawTextureRect(sight, + UIBox2.FromDimensions(centerPos - expandedSize * 0.5f, expandedSize), _mainColor); + } +} diff --git a/Content.Client/_Floof/OfferItem/OfferItemSystem.cs b/Content.Client/_Floof/OfferItem/OfferItemSystem.cs new file mode 100644 index 00000000000..0487d361fc0 --- /dev/null +++ b/Content.Client/_Floof/OfferItem/OfferItemSystem.cs @@ -0,0 +1,39 @@ +using Content.Shared._Floof.OfferItem; +using Robust.Client.Graphics; +using Robust.Client.Input; +using Robust.Client.Player; + +namespace Content.Client._Floof.OfferItem; + +public sealed class OfferItemSystem : SharedOfferItemSystem +{ + [Dependency] private readonly IOverlayManager _overlayManager = default!; + [Dependency] private readonly IPlayerManager _playerManager = default!; + [Dependency] private readonly IInputManager _inputManager = default!; + [Dependency] private readonly IEyeManager _eye = default!; + + public override void Initialize() + { + base.Initialize(); + _overlayManager.AddOverlay(new OfferItemIndicatorsOverlay( + _inputManager, + EntityManager, + _eye, + this)); + } + public override void Shutdown() + { + _overlayManager.RemoveOverlay(); + base.Shutdown(); + } + + public bool IsInOfferMode() + { + var entity = _playerManager.LocalEntity; + + if (entity == null) + return false; + + return IsInOfferMode(entity.Value); + } +} diff --git a/Content.Server/Nyanotrasen/Item/PseudoItem/PseudoItemSystem.cs b/Content.Server/Nyanotrasen/Item/PseudoItem/PseudoItemSystem.cs index b4a3649c5cb..953370aa9b8 100644 --- a/Content.Server/Nyanotrasen/Item/PseudoItem/PseudoItemSystem.cs +++ b/Content.Server/Nyanotrasen/Item/PseudoItem/PseudoItemSystem.cs @@ -55,9 +55,13 @@ public sealed class PseudoItemSystem : SharedPseudoItemSystem protected override void OnGettingPickedUpAttempt(EntityUid uid, PseudoItemComponent component, GettingPickedUpAttemptEvent args) { + // Floof - changed this a bit to actually start a do-after // Try to pick the entity up instead first - if (args.User != args.Item && _carrying.TryCarry(args.User, uid)) + if (args.User != args.Item + && TryComp(uid, out var carriable) + && _carrying.CanCarry(args.User, (uid, carriable))) { + _carrying.StartCarryDoAfter(args.User, (uid, carriable)); args.Cancel(); return; } diff --git a/Content.Server/_Floof/OfferItem/OfferItemSystem.cs b/Content.Server/_Floof/OfferItem/OfferItemSystem.cs new file mode 100644 index 00000000000..b72ce2d5849 --- /dev/null +++ b/Content.Server/_Floof/OfferItem/OfferItemSystem.cs @@ -0,0 +1,5 @@ +using Content.Shared._Floof.OfferItem; + +namespace Content.Server._Floof.OfferItem; + +public sealed partial class OfferItemSystem : SharedOfferItemSystem; diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs index a7e4164b920..6b633d39b13 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -68,6 +68,7 @@ namespace Content.Shared.Input public static readonly BoundKeyFunction TakeScreenshotNoUI = "TakeScreenshotNoUI"; public static readonly BoundKeyFunction ToggleFullscreen = "ToggleFullscreen"; public static readonly BoundKeyFunction Point = "Point"; + public static readonly BoundKeyFunction OfferItem = "OfferItem"; // Floofstation public static readonly BoundKeyFunction ZoomOut = "ZoomOut"; public static readonly BoundKeyFunction ZoomIn = "ZoomIn"; public static readonly BoundKeyFunction ResetZoom = "ResetZoom"; diff --git a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs index 490050553d3..62aaae1e691 100644 --- a/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs +++ b/Content.Shared/Movement/Pulling/Systems/PullingSystem.cs @@ -1,4 +1,5 @@ using Content.Shared._ST.Interaction; // Stellar - interaction particles +using Content.Shared._Floof.OfferItem; // Floof using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; using Content.Shared.Alert; @@ -117,10 +118,11 @@ public sealed class PullingSystem : EntitySystem if (TryComp(args.PullerUid, out PullerComponent? pullerComp) && !pullerComp.NeedsHands) return; - if (!_virtual.TrySpawnVirtualItemInHand(args.PulledUid, uid)) + if (!_virtual.TrySpawnVirtualItemInHand(args.PulledUid, uid, out var virt)) // Floofstation - store item { DebugTools.Assert("Unable to find available hand when starting pulling??"); } + EnsureComp(virt.Value); // Floofstation - add a special component to allow offering it } private void HandlePullStopped(EntityUid uid, HandsComponent component, PullStoppedMessage args) diff --git a/Content.Shared/Nyanotrasen/Item/PseudoItem/SharedPseudoItemSystem.cs b/Content.Shared/Nyanotrasen/Item/PseudoItem/SharedPseudoItemSystem.cs index bcbdd4b8629..c7a4e0f74b6 100644 --- a/Content.Shared/Nyanotrasen/Item/PseudoItem/SharedPseudoItemSystem.cs +++ b/Content.Shared/Nyanotrasen/Item/PseudoItem/SharedPseudoItemSystem.cs @@ -1,8 +1,7 @@ using Content.Shared.Actions; -using Content.Shared.Bed.Sleep; using Content.Shared.DoAfter; using Content.Shared.Hands; -using Content.Shared.IdentityManagement; +using Content.Shared.Hands.EntitySystems; using Content.Shared.Interaction.Events; using Content.Shared.Item; using Content.Shared.Item.PseudoItem; @@ -24,7 +23,8 @@ public abstract partial class SharedPseudoItemSystem : EntitySystem [Dependency] private readonly TagSystem _tag = default!; [Dependency] private readonly SharedPopupSystem _popupSystem = default!; [Dependency] private readonly SharedActionsSystem _actions = default!; - [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly SharedContainerSystem _container = default!; // Floofstation + [Dependency] private readonly SharedHandsSystem _hands = default!; // Floofstation private readonly ProtoId PreventTag = "PreventLabel"; private readonly EntProtoId SleepActionId = "ActionSleep"; // The action used for sleeping inside bags. Currently uses the default sleep action (same as beds) @@ -116,17 +116,35 @@ public abstract partial class SharedPseudoItemSystem : EntitySystem protected virtual void OnGettingPickedUpAttempt(EntityUid uid, PseudoItemComponent component, GettingPickedUpAttemptEvent args) { - if (args.User == args.Item) - return; + args.Cancel(); // Floof - this is a terrible idea. This triggers every time ANY system checks if a pseudo-item can be picked up. + // WHY DID YOU DO THAT, NYANOTRASEN??? - _transform.AttachToGridOrMap(uid); - args.Cancel(); + // if (args.User == args.Item) + // return; + // + // _transform.AttachToGridOrMap(uid); + // args.Cancel(); } private void OnDropAttempt(EntityUid uid, PseudoItemComponent component, DropAttemptEvent args) { - if (component.Active) - args.Cancel(); + if (!component.Active) + return; + + // Floof - we try to get the containing container and try to drop it into it + // If possible, we do it, since a bagged cat probably can put things back into the bag just like they can pick them up. + string? failReason = null; + if (_hands.GetActiveItem(uid) is { Valid: true, } droppedItem + && _container.TryGetContainingContainer(Transform(uid).ParentUid, uid, out var pseudoItemContainer) + && TryComp(pseudoItemContainer.Owner, out var targetStorage) + && _storage.CanInsert(pseudoItemContainer.Owner, droppedItem, out failReason, targetStorage, ignoreStacks: true) + ) + _storage.Insert(pseudoItemContainer.Owner, droppedItem, out _, uid, targetStorage, stackAutomatically: false); + + if (failReason != null) + _popupSystem.PopupEntity(Loc.GetString(failReason), uid, uid); + + args.Cancel(); } private void OnInsertAttempt(EntityUid uid, PseudoItemComponent component, @@ -141,8 +159,9 @@ public abstract partial class SharedPseudoItemSystem : EntitySystem // Prevents moving within the bag :) private void OnInteractAttempt(EntityUid uid, PseudoItemComponent component, InteractionAttemptEvent args) { - if (args.Uid == args.Target && component.Active) - args.Cancelled = true; + // Floof - why the fuck. + // if (args.Uid == args.Target && component.Active) + // args.Cancelled = true; } private void OnDoAfter(EntityUid uid, PseudoItemComponent component, DoAfterEvent args) diff --git a/Content.Shared/_DV/Carrying/CarryingSystem.cs b/Content.Shared/_DV/Carrying/CarryingSystem.cs index aa836f81cd0..a6a62d7ff66 100644 --- a/Content.Shared/_DV/Carrying/CarryingSystem.cs +++ b/Content.Shared/_DV/Carrying/CarryingSystem.cs @@ -28,6 +28,7 @@ using Robust.Shared.Network; using Robust.Shared.Physics.Components; using System.Numerics; using Content.Shared._DV.Polymorph; +using Content.Shared._Floof.OfferItem; using Content.Shared.Hands.EntitySystems; namespace Content.Shared._DV.Carrying; @@ -181,14 +182,15 @@ public sealed class CarryingSystem : EntitySystem /// private void OnInteractionAttempt(Entity ent, ref InteractionAttemptEvent args) { - if (args.Target is not {} target) - return; - - var targetParent = Transform(target).ParentUid; - - var carrier = ent.Comp.Carrier; - if (target != carrier && targetParent != carrier && targetParent != ent.Owner) - args.Cancelled = true; + // Floofstation - no - this prevents the person from escaping and more. + // if (args.Target is not {} target) + // return; + // + // var targetParent = Transform(target).ParentUid; + // + // var carrier = ent.Comp.Carrier; + // if (target != carrier && targetParent != carrier && targetParent != ent.Owner) + // args.Cancelled = true; } private void OnMoveAttempt(Entity ent, ref UpdateCanMoveEvent args) @@ -203,8 +205,9 @@ public sealed class CarryingSystem : EntitySystem private void OnInteractedWith(Entity ent, ref GettingInteractedWithAttemptEvent args) { - if (args.Uid != ent.Comp.Carrier) - args.Cancelled = true; + // Floofstation - why? + // if (args.Uid != ent.Comp.Carrier) + // args.Cancelled = true; } private void OnPullAttempt(Entity ent, ref PullAttemptEvent args) @@ -242,7 +245,8 @@ public sealed class CarryingSystem : EntitySystem args.Handled = true; } - private void StartCarryDoAfter(EntityUid carrier, Entity carried) + // Floofstation - made public + public void StartCarryDoAfter(EntityUid carrier, Entity carried) { TimeSpan length = GetPickupDuration(carrier, carried); @@ -299,7 +303,8 @@ public sealed class CarryingSystem : EntitySystem for (var x = 0; x < Comp(carried).FreeHandsRequired; x++) { - _virtualItem.TrySpawnVirtualItemInHand(carried, carrier); + if (_virtualItem.TrySpawnVirtualItemInHand(carried, carrier, out var virtualItem)) + EnsureComp(virtualItem.Value); } } diff --git a/Content.Shared/_Floof/OfferItem/AcceptOfferAlertEvent.cs b/Content.Shared/_Floof/OfferItem/AcceptOfferAlertEvent.cs new file mode 100644 index 00000000000..1f5e2e851ad --- /dev/null +++ b/Content.Shared/_Floof/OfferItem/AcceptOfferAlertEvent.cs @@ -0,0 +1,5 @@ +using Content.Shared.Alert; + +namespace Content.Shared._Floof.OfferItem; + +public sealed partial class AcceptOfferAlertEvent : BaseAlertEvent; diff --git a/Content.Shared/_Floof/OfferItem/OfferItemComponent.cs b/Content.Shared/_Floof/OfferItem/OfferItemComponent.cs new file mode 100644 index 00000000000..926f397ce14 --- /dev/null +++ b/Content.Shared/_Floof/OfferItem/OfferItemComponent.cs @@ -0,0 +1,52 @@ +using Content.Shared.Alert; +using Content.Shared.Inventory.VirtualItem; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared._Floof.OfferItem; + +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)] +[Access(typeof(SharedOfferItemSystem))] +public sealed partial class OfferItemComponent : Component +{ + /// + /// Apparently this indicates whether the entity is currently choosing an entity to offer (right after pressing F). + /// + [ViewVariables(VVAccess.ReadWrite), DataField, AutoNetworkedField] + public bool IsInOfferMode; + + /// + /// If this is true, then someone is currently offering an item to this entity, and + /// stores the ID of that entity. + /// + [DataField, AutoNetworkedField] + public bool IsInReceiveMode; + + [DataField, AutoNetworkedField] + public string? Hand; + + [DataField, AutoNetworkedField] + public EntityUid? Item; + + /// + /// Floofstation note. So, this is EE shitcode, so prepare for an emotional rollercoaster. + /// This field can mean TWO things. It's either the target entity this entity is offering an item to, + /// or an entity that is offering an item to this entity. + /// Whether it's one or the other is distinguished by .

+ /// + /// In rare cases it can be both. According to my research, if entity A offers an item to entity B, and entity B offers to entity A, + /// then both entities will end up in receive mode, and they will have each other as targets. There's a check preventing offer loops + /// of length more than 2. + ///
+ [DataField, AutoNetworkedField] + public EntityUid? ReceivingFrom; + + [DataField] + public float MaxOfferDistance = 2f; + + [DataField] + public ProtoId OfferAlert = "Offer"; + + public EntityUid GetRealEntity(EntityManager entityManager) => + entityManager.GetComponentOrNull(Item)?.BlockingEntity ?? Item ?? EntityUid.Invalid; +} diff --git a/Content.Shared/_Floof/OfferItem/OfferableVirtualItemComponent.cs b/Content.Shared/_Floof/OfferItem/OfferableVirtualItemComponent.cs new file mode 100644 index 00000000000..0c4b994c400 --- /dev/null +++ b/Content.Shared/_Floof/OfferItem/OfferableVirtualItemComponent.cs @@ -0,0 +1,11 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared._Floof.OfferItem; + +/// +/// A marker component that, when applied to a virtual item, allows it to be offered using item offering. +/// Implementors have to listen on ItemTransferredEvent. +/// +[RegisterComponent] +[NetworkedComponent] +public sealed partial class OfferableVirtualItemComponent : Component; diff --git a/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.Interactions.cs b/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.Interactions.cs new file mode 100644 index 00000000000..cac0280cd1f --- /dev/null +++ b/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.Interactions.cs @@ -0,0 +1,93 @@ +using Content.Shared.ActionBlocker; +using Content.Shared.Alert; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.Input; +using Content.Shared.Popups; +using Robust.Shared.Input.Binding; +using Robust.Shared.Player; + +namespace Content.Shared._Floof.OfferItem; + +public abstract partial class SharedOfferItemSystem +{ + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + [Dependency] private readonly SharedPopupSystem _popup = default!; + + + private void InitializeInteractions() + { + base.Initialize(); + + CommandBinds.Builder + .Bind(ContentKeyFunctions.OfferItem, InputCmdHandler.FromDelegate(SetInOfferMode, handle: false, outsidePrediction: false)) + .Register(); + } + + public override void Shutdown() + { + base.Shutdown(); + + CommandBinds.Unregister(); + } + + /// + /// This sets IsInOfferMode to true, allowing the player to select whom to offer an item to with interaction. + /// + private void SetInOfferMode(ICommonSession? offerer) + { + if (offerer is not { } playerSession) + return; + + if (playerSession.AttachedEntity is not { Valid: true } uid) + return; + + if (!Exists(uid)) + return; + + if (!_actionBlocker.CanInteract(uid, null)) + return; + + if (!TryComp(uid, out var offerItem)) + return; + + if (!TryComp(uid, out var hands)) + return; + + if (_hands.GetActiveHand((uid, hands)) is not { } activeHandName) + return; + + if (!_hands.TryGetHeldItem((uid, hands), activeHandName, out var heldItem)) + return; + + offerItem.Item = heldItem; + if (!offerItem.IsInOfferMode) + { + if (offerItem.Item == null) + { + _popup.PopupEntity(Loc.GetString("offer-item-empty-hand"), uid, uid); + return; + } + + if (offerItem.Hand == null || offerItem.ReceivingFrom == null) + { + offerItem.IsInOfferMode = true; + offerItem.Hand = activeHandName; + + Dirty(uid, offerItem); + return; + } + } + + // If we're already offering an item to someone, cancel that offer + if (offerItem.ReceivingFrom != null) + { + UnReceive(offerItem.ReceivingFrom.Value, offererComp: offerItem); + offerItem.IsInOfferMode = false; + Dirty(uid, offerItem); + return; + } + + UnOffer(uid, offerItem); + } +} diff --git a/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.cs b/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.cs new file mode 100644 index 00000000000..9392a226fa2 --- /dev/null +++ b/Content.Shared/_Floof/OfferItem/SharedOfferItemSystem.cs @@ -0,0 +1,414 @@ +using Content.Shared._DV.Carrying; +using Content.Shared.Alert; +using Content.Shared.Hands.Components; +using Content.Shared.Hands.EntitySystems; +using Content.Shared.IdentityManagement; +using Content.Shared.Interaction; +using Content.Shared.Movement.Pulling.Components; +using Content.Shared.Movement.Pulling.Systems; +using Content.Shared.Nutrition.EntitySystems; +using Robust.Shared.Network; +using Robust.Shared.Player; +using Robust.Shared.Timing; + +// Dear contributor. +// This system is fucking unmaintainable. +// If you ever happen to touch this again, please do your best to document your changes and try to resolve mysteries surrounding this code. +// I did what I could to document the parts I managed to understand, but there is still more truth to be unveiled. +// +// HOURS_WASTED_HERE_FLOOFSTATION = 10 +// HOURS_WASTED_HERE_DELTAV = 1 + +namespace Content.Shared._Floof.OfferItem; + +public abstract partial class SharedOfferItemSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedTransformSystem _transform = default!; + [Dependency] private readonly SharedHandsSystem _hands = default!; + [Dependency] private readonly AlertsSystem _alertsSystem = default!; + [Dependency] private readonly CarryingSystem _carrying = default!; + [Dependency] private readonly PullingSystem _pulling = default!; + + public override void Initialize() + { + base.Initialize(); + SubscribeLocalEvent(OnAcceptOffer); + SubscribeLocalEvent(OnInteractWithReceiver, before: [typeof(IngestionSystem)]); + SubscribeLocalEvent(OnRangedInteractWithReceiver); + SubscribeLocalEvent(OnMove); + + SubscribeLocalEvent(OnCarryTransfer); + SubscribeLocalEvent(OnPulledTransfer); + + InitializeInteractions(); + } + + public override void Update(float frameTime) + { + base.Update(frameTime); + + var query = EntityQueryEnumerator(); + while (query.MoveNext(out var uid, out var offerItem, out var hands)) + { + // If the mob no longer holds an item in the original offering hand, clear offering mode + if (offerItem.Hand != null && !_hands.TryGetHeldItem((uid, hands), offerItem.Hand, out _)) + { + if (offerItem.ReceivingFrom != null) + { + UnReceive(offerItem.ReceivingFrom.Value, offererComp: offerItem); + offerItem.IsInOfferMode = false; + Dirty(uid, offerItem); + } + else + UnOffer(uid, offerItem); + } + + if (!offerItem.IsInReceiveMode) + { + _alertsSystem.ClearAlert(uid, offerItem.OfferAlert); + continue; + } + + _alertsSystem.ShowAlert(uid, offerItem.OfferAlert); + } + } + + #region Events + private void OnAcceptOffer(Entity ent, ref AcceptOfferAlertEvent args) + { + Receive((ent, ent.Comp)); + } + + private void OnInteractWithReceiver(Entity receiver, ref InteractUsingEvent args) + { + if (!_timing.IsFirstTimePredicted || _timing.ApplyingState || args.Handled) + return; + + if (!TryComp(args.User, out var offererComponent)) + return; + + args.Handled = CreateOffer(receiver, (args.User, offererComponent)); + } + + private void OnRangedInteractWithReceiver(Entity virtItem, ref BeforeRangedInteractEvent args) + { + // If the entity being offered is a virtual item, InteractUsing will not be raised + // because virtual items exclude themselves from being marked as used + // If this is the case, InteractHand will be raised instead, which we can use anyway because OfferItem.Item stores the offered item + // + // We also can't check Handled here because VirtualItemSystem handles it, ffs + // This won't lead you to accidentally offering someone your gun + // + // This is shitcode, this time my shitcode. My changes to the offering system allow you to transfer carrying and pulling, + // but in order to handle these, we need to be able to intercept interactions with virtual items. + // + // Ideally this code should be rewritten to: + // a) Have each different virtual item have a distinct component (e.g. CarryingVirtualItem) which would allow to distinguish them from the rest + // b) Not rely on the InteractionSystem. + // However, I'm not in the mood to do either. And I'm too deep into the rabbit hole of getting this shit to work. + if (!_timing.IsFirstTimePredicted || _timing.ApplyingState) + return; + + var receiver = args.Target; + if (!TryComp(receiver, out var receiverComponent)) + return; + + var offerer = args.User; + if (!TryComp(offerer, out var offererComponent) || offererComponent.Item == null) + return; + + // Since this is ranged, we must also check distance, because the interaction system wont check it for us in this case + if (!Transform(offerer).Coordinates.TryDistance(EntityManager, _transform, Transform(receiver.Value).Coordinates, out var dst) + || dst > offererComponent.MaxOfferDistance) + return; + + args.Handled = CreateOffer((receiver.Value, receiverComponent), (offerer, offererComponent)); + } + + private void OnMove(EntityUid uid, OfferItemComponent component, MoveEvent args) + { + if (_net.IsClient) // Client often mispredicts movement, we cant trust it here + return; + + if (component.ReceivingFrom == null) + return; + + if (_transform.InRange(args.NewPosition, Transform(component.ReceivingFrom.Value).Coordinates, component.MaxOfferDistance)) + return; + + UnOffer(uid, component); + } + + private void OnCarryTransfer(Entity ent, ref ItemTransferredEvent args) + { + if (args.Handled + || args.PassedItem == args.RealItem // Means the entity is transferred NOT via carrying + || args.RealItem is not { Valid: true } carried + || ent.Comp.Carrier is not { Valid: true } oldCarrier) + return; + + _carrying.DropCarried(oldCarrier, ent); + args.Handled = _carrying.TryCarry(args.Target, carried); + } + + private void OnPulledTransfer(Entity ent, ref ItemTransferredEvent args) + { + if (args.Handled + || args.PassedItem == args.RealItem // Means the entity is transferred NOT via pulling + || args.RealItem is not { Valid: true } pulled) + return; + + _pulling.TryStopPull(pulled, ent); + args.Handled = _pulling.TryStartPull(args.Target, ent, null, ent.Comp); + } + + #endregion + + #region Offering / Recieving + /// + /// Attempts to create an offer. Expects offerer.Item to already be set to the offered item, offererComponent.InReceiveMode == true. + /// Will fail if offerer == receiver or if receiver already has a set TargetOrOfferer, and that person is not the current offerer + /// + private bool CreateOffer(Entity receiver, Entity offerer) + { + var offererComponent = offerer.Comp; + var receiverComponent = receiver.Comp; + if (offerer == receiver || receiverComponent.IsInReceiveMode || !offererComponent.IsInOfferMode) + return false; + + if (offererComponent.IsInReceiveMode && offererComponent.ReceivingFrom != receiver) + return false; + + receiverComponent.IsInReceiveMode = true; + receiverComponent.ReceivingFrom = offerer; + + Dirty(receiver, receiverComponent); + + offererComponent.ReceivingFrom = receiver; // TODO this is ee shitcode, may not be necessary? + offererComponent.IsInOfferMode = false; + + Dirty(offerer, offererComponent); + + if (offererComponent.Item == null) + return false; + + // Sender popup (client-side only) + _popup.PopupClient( + Loc.GetString("offer-item-try-give", + ("item", Identity.Entity(offererComponent.GetRealEntity(EntityManager), EntityManager)), + ("target", Identity.Entity(receiver, EntityManager))), + offerer, + offerer); + // Receiver popup (server side only, not predicted because recipient != local player) + _popup.PopupEntity( + Loc.GetString("offer-item-try-give-target", + ("user", Identity.Entity(receiverComponent.ReceivingFrom.Value, EntityManager)), + ("item", Identity.Entity(offererComponent.GetRealEntity(EntityManager), EntityManager))), + offerer, + receiver, + Popups.PopupType.Medium); + + return true; + } + + + + /// + /// Resets the of the user and the target + /// + protected void UnOffer(EntityUid thisEntity, OfferItemComponent offererComp) + { + if (!TryComp(thisEntity, out var hands) || _hands.GetActiveHand((thisEntity, hands)) is null) + return; + + if (offererComp.ReceivingFrom is { } otherEntity && TryComp(otherEntity, out var otherOfferer)) + { + // So this tries to figure out which of these entities do what... + // if A.OfferItemComponent.Item != null, then A is currently offering an item to A.OfferItemComponent.TargetOrOfferer + // If it is null, then it is ONLY being offered an item TO. + if (offererComp.Item != null && _net.IsServer) + { + _popup.PopupEntity( + Loc.GetString("offer-item-no-give", + ("item", Identity.Entity(offererComp.GetRealEntity(EntityManager), EntityManager)), // Floof - resolve virtual items + ("target", Identity.Entity(otherEntity, EntityManager))), + thisEntity, + thisEntity); + _popup.PopupEntity( + Loc.GetString("offer-item-no-give-target", + ("user", Identity.Entity(thisEntity, EntityManager)), + ("item", Identity.Entity(offererComp.GetRealEntity(EntityManager), EntityManager))), + thisEntity, + otherEntity); + } + + else if (otherOfferer.Item != null && _net.IsServer) + { + _popup.PopupEntity( + Loc.GetString("offer-item-no-give", + ("item", Identity.Entity(otherOfferer.GetRealEntity(EntityManager), EntityManager)), // Floof - resolve virtual items + ("target", Identity.Entity(thisEntity, EntityManager))), + otherEntity, + otherEntity); + _popup.PopupEntity( + Loc.GetString("offer-item-no-give-target", + ("user", Identity.Entity(otherEntity, EntityManager)), + ("item", Identity.Entity(otherOfferer.GetRealEntity(EntityManager), EntityManager))), + otherEntity, + thisEntity); + } + + otherOfferer.IsInOfferMode = false; + otherOfferer.IsInReceiveMode = false; + otherOfferer.Hand = null; + otherOfferer.ReceivingFrom = null; + otherOfferer.Item = null; + + Dirty(otherEntity, otherOfferer); + } + + offererComp.IsInOfferMode = false; + offererComp.IsInReceiveMode = false; + offererComp.Hand = null; + offererComp.ReceivingFrom = null; + offererComp.Item = null; + + Dirty(thisEntity, offererComp); + } + + + /// + /// Cancels the transfer of the item + /// + protected void UnReceive(EntityUid receiver, OfferItemComponent? receiverComp = null, OfferItemComponent? offererComp = null) + { + if (!Resolve(receiver, ref receiverComp) + || receiverComp.ReceivingFrom is not {} offerer + || !Resolve(offerer, ref offererComp)) + return; + + // Idk why this check is here + if (!TryComp(receiver, out var hands) || _hands.GetActiveHand((receiver, hands)) == null || receiverComp.ReceivingFrom == null) + return; + + // If offererComp.Item != null, then they are actively offering to TargetOrOfferer + // Normally this method is called right after a transfer is done, but this part can be called from SetInOfferMode when the player presses F again to cancel an ongoing offer + if (offererComp.Item != null) + { + _popup.PopupClient( + Loc.GetString("offer-item-no-give", + ("item", Identity.Entity(offererComp.GetRealEntity(EntityManager), EntityManager)), // Floof - resolve virtual items + ("target", Identity.Entity(receiver, EntityManager))), + offerer, + offerer); + _popup.PopupEntity( + Loc.GetString("offer-item-no-give-target", + ("user", Identity.Entity(receiverComp.ReceivingFrom.Value, EntityManager)), // Floof - resolve virtual items + ("item", Identity.Entity(offererComp.GetRealEntity(EntityManager), EntityManager))), + offerer, + receiver); + } + + if (!offererComp.IsInReceiveMode) + { + offererComp.ReceivingFrom = null; + receiverComp.ReceivingFrom = null; + } + + offererComp.Item = null; + offererComp.Hand = null; + receiverComp.IsInReceiveMode = false; + + Dirty(receiver, receiverComp); + } + + /// + /// Accepting the offer and receive item + /// + public void Receive(Entity receiver) + { + if (!_timing.IsFirstTimePredicted) + return; + + if (!Resolve(receiver, ref receiver.Comp)) + return; + + if (!TryComp(receiver.Comp.ReceivingFrom, out var offererComponent) || + offererComponent.Hand == null || + receiver.Comp.ReceivingFrom is not {} sender || + !TryComp(receiver, out var hands)) + return; + + if (offererComponent.Item != null) + { + // Floof - check if there's something else handling it first + var realItem = offererComponent.GetRealEntity(EntityManager); + if (!TryHandleExtendedTransfer(sender, receiver, offererComponent.Item.Value, realItem) + && !_hands.TryPickup(receiver, offererComponent.Item.Value, handsComp: hands)) + { + _popup.PopupEntity(Loc.GetString("offer-item-full-hand"), receiver, receiver); + return; + } + + _popup.PopupEntity( + Loc.GetString("offer-item-give", + ("item", Identity.Entity(realItem, EntityManager)), // FLoof - resolve virtual items + ("target", Identity.Entity(receiver, EntityManager))), + sender, + sender); + _popup.PopupEntity( + Loc.GetString("offer-item-give-other", + ("user", Identity.Entity(receiver.Comp.ReceivingFrom.Value, EntityManager)), + ("item", Identity.Entity(realItem, EntityManager)), // FLoof - resolve virtual items + ("target", Identity.Entity(receiver, EntityManager))), + sender, + Filter.PvsExcept(sender, entityManager: EntityManager), + true); + } + + offererComponent.Item = null; + UnReceive(receiver, receiver.Comp, offererComponent); + } + #endregion + /// + /// Returns true if = true + /// + protected bool IsInOfferMode(Entity ent) + { + return Resolve(ent, ref ent.Comp, false) && ent.Comp.IsInOfferMode; + } + + private bool TryHandleExtendedTransfer(EntityUid user, EntityUid target, EntityUid offeredItem, EntityUid realItem) + { + var ev = new ItemTransferredEvent + { + User = user, + Target = target, + PassedItem = offeredItem, + RealItem = realItem, + }; + RaiseLocalEvent(realItem, ref ev); + return ev.Handled; + } +} + +/// +/// Raised on the entity that was transferred via item offering. +/// +[ByRefEvent] +public sealed class ItemTransferredEvent : HandledEntityEventArgs +{ + public EntityUid User; + public EntityUid Target; + + /// + /// The actual item being passed around. Can be a virtual item. + /// + public EntityUid PassedItem; + /// + /// If is a virtual item, this field contains the real item that was transferred. + /// + public EntityUid? RealItem; +} \ No newline at end of file diff --git a/Resources/Locale/en-US/_Floof/interaction/offer-item-system.ftl b/Resources/Locale/en-US/_Floof/interaction/offer-item-system.ftl new file mode 100644 index 00000000000..5c8e2057a22 --- /dev/null +++ b/Resources/Locale/en-US/_Floof/interaction/offer-item-system.ftl @@ -0,0 +1,17 @@ +offer-item-empty-hand = You don't have anything in your hand to give! + +offer-item-full-hand = Your hand isn't free to receive the item. + +offer-item-try-give = You offer {THE($item)} to {$target}. +offer-item-try-give-target = {CAPITALIZE(THE($user))} offers you {THE($item)}. + +offer-item-give = You handed {THE($item)} to {$target}. +offer-item-give-other = {CAPITALIZE(THE($user))} handed {THE($item)} to {$target}. +offer-item-give-target = {CAPITALIZE(THE($user))} handed you {THE($item)}. + +offer-item-no-give = You stop offering {THE($item)} to {$target}. +offer-item-no-give-target = {CAPITALIZE(THE($user))} is no longer offering {THE($item)} to you. + + +alerts-offer-name = Accept Offer +alerts-offer-desc = Click this alert to accept the item offered to you. diff --git a/Resources/Prototypes/Alerts/alerts.yml b/Resources/Prototypes/Alerts/alerts.yml index 532d4b436bf..a0ee84b00d8 100644 --- a/Resources/Prototypes/Alerts/alerts.yml +++ b/Resources/Prototypes/Alerts/alerts.yml @@ -28,6 +28,7 @@ - alertType: Rooted - alertType: Pacified - alertType: Stealthy + - alertType: Offer # Floofstation - port from EE - type: entity id: AlertSpriteView diff --git a/Resources/Prototypes/Body/species_appearance.yml b/Resources/Prototypes/Body/species_appearance.yml index 10c8df10c81..e5812802a00 100644 --- a/Resources/Prototypes/Body/species_appearance.yml +++ b/Resources/Prototypes/Body/species_appearance.yml @@ -97,3 +97,4 @@ type: HumanoidMarkingModifierBoundUserInterface enum.StrippingUiKey.Key: type: StrippableBoundUserInterface + - type: OfferItem # Floofstation diff --git a/Resources/Prototypes/_Floof/alerts.yml b/Resources/Prototypes/_Floof/alerts.yml new file mode 100644 index 00000000000..2718ac0fa5b --- /dev/null +++ b/Resources/Prototypes/_Floof/alerts.yml @@ -0,0 +1,8 @@ +- type: alert + id: Offer + clickEvent: !type:AcceptOfferAlertEvent { } + icons: + - sprite: /Textures/_Floof/Interface/Alerts/offer_item.rsi + state: offer_item + name: alerts-offer-name + description: alerts-offer-desc diff --git a/Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/meta.json b/Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/meta.json new file mode 100644 index 00000000000..9b5f9361314 --- /dev/null +++ b/Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/meta.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Original icon taken from https://github.com/ss220-space/Paradise/blob/master220/icons/mob/screen_alert.dmi, modified by Mocho", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "offer_item" + } + ] +} diff --git a/Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/offer_item.png b/Resources/Textures/_Floof/Interface/Alerts/offer_item.rsi/offer_item.png new file mode 100644 index 0000000000000000000000000000000000000000..44ac380ddb19fefba315b2d433582aa773b8e6c7 GIT binary patch literal 782 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