diff --git a/Content.Client/Hands/Systems/HandsSystem.cs b/Content.Client/Hands/Systems/HandsSystem.cs index 9461af62d0..75823d36a6 100644 --- a/Content.Client/Hands/Systems/HandsSystem.cs +++ b/Content.Client/Hands/Systems/HandsSystem.cs @@ -57,6 +57,13 @@ namespace Content.Client.Hands.Systems #region StateHandling private void HandleComponentState(Entity ent, ref ComponentHandleState args) { + // No need to update everything if we are only switching hands. + if (args.Current is HandsComponentActiveHandDeltaState activeHandState) + { + SetActiveHand(ent.AsNullable(), activeHandState.ActiveHandId); + return; + } + if (args.Current is not HandsComponentState state) return; @@ -72,10 +79,18 @@ namespace Content.Client.Hands.Systems { AddHand(ent.AsNullable(), handId, state.Hands[handId]); } - ent.Comp.SortedHands = new (state.SortedHands); + ent.Comp.SortedHands = new(state.SortedHands); SetActiveHand(ent.AsNullable(), state.ActiveHandId); + ent.Comp.ShowInHands = state.ShowInHands; + ent.Comp.HandDisplacement = state.HandDisplacement; + ent.Comp.LeftHandDisplacement = state.LeftHandDisplacement; + ent.Comp.RightHandDisplacement = state.RightHandDisplacement; + ent.Comp.CanBeStripped = state.CanBeStripped; + + // TODO: Ideally this would only update if the displacement data actually changed, but there is no way to compare it since the type is not equatable. + UpdateAllHandVisuals((ent.Owner, ent.Comp)); _stripSys.UpdateUi(ent); } #endregion @@ -243,6 +258,20 @@ namespace Content.Client.Hands.Systems OnPlayerHandUnblocked?.Invoke(args.Container.ID); } + /// + /// Update the players sprite with new in-hand visuals for all held items. + /// + private void UpdateAllHandVisuals(Entity ent) + { + foreach (var handId in EnumerateHands(ent)) + { + if (!TryGetHeldItem(ent, handId, out var held)) + continue; + + UpdateHandVisuals(ent, held.Value, handId); + } + } + /// /// Update the players sprite with new in-hand visuals. /// diff --git a/Content.Server/Hands/Systems/HandsSystem.cs b/Content.Server/Hands/Systems/HandsSystem.cs index 76e63da3d9..b004960212 100644 --- a/Content.Server/Hands/Systems/HandsSystem.cs +++ b/Content.Server/Hands/Systems/HandsSystem.cs @@ -67,7 +67,28 @@ namespace Content.Server.Hands.Systems private void GetComponentState(EntityUid uid, HandsComponent hands, ref ComponentGetState args) { - args.State = new HandsComponentState(hands); + // If we only switch hands don't send a full state. + if (args.FromTick > hands.CreationTick && hands.LastUnclassifiedDirty >= args.FromTick) + { + var aspects = EntityManager.GetModifiedAspects(hands, args.FromTick); + + if (aspects == ActiveHandIdIndex) + { + args.State = new HandsComponentActiveHandDeltaState(hands.ActiveHandId); + return; + } + } + + // Get full state. + args.State = new HandsComponentState( + hands.ActiveHandId, + hands.Hands, + hands.SortedHands, + hands.ShowInHands, + hands.HandDisplacement, + hands.LeftHandDisplacement, + hands.RightHandDisplacement, + hands.CanBeStripped); } diff --git a/Content.Shared/DisplacementMap/DisplacementData.cs b/Content.Shared/DisplacementMap/DisplacementData.cs index 8bc922708f..8855d42535 100644 --- a/Content.Shared/DisplacementMap/DisplacementData.cs +++ b/Content.Shared/DisplacementMap/DisplacementData.cs @@ -16,4 +16,12 @@ public sealed partial class DisplacementData [DataField] public string ShaderOverrideUnshaded = "DisplacedDrawUnshaded"; + + // Shallow copy for networking purposes. + public DisplacementData(DisplacementData other) + { + SizeMaps = new(other.SizeMaps); + ShaderOverride = other.ShaderOverride; + ShaderOverrideUnshaded = other.ShaderOverrideUnshaded; + } } diff --git a/Content.Shared/Hands/Components/HandsComponent.cs b/Content.Shared/Hands/Components/HandsComponent.cs index 128817a741..157d124f15 100644 --- a/Content.Shared/Hands/Components/HandsComponent.cs +++ b/Content.Shared/Hands/Components/HandsComponent.cs @@ -4,21 +4,39 @@ using Content.Shared.Whitelist; using Robust.Shared.GameStates; using Robust.Shared.Prototypes; using Robust.Shared.Serialization; +using Robust.Shared.Timing; namespace Content.Shared.Hands.Components; +/// +/// Allows this entity to have hands so that it can interact with items. +/// [RegisterComponent, NetworkedComponent, AutoGenerateComponentPause] [Access(typeof(SharedHandsSystem))] -public sealed partial class HandsComponent : Component +public sealed partial class HandsComponent : Component, IComponentDelta { + /// + public GameTick[] LastModifiedFields { get; set; } + + /// + public GameTick LastUnclassifiedDirty { get; set; } + /// - /// The currently active hand. + /// The currently active hand. /// [DataField] public string? ActiveHandId; /// + /// Intrinsic hands to be added on map init. + /// + [DataField] + public Dictionary StartingHands = new(); + + /// + /// Contains all hands this entity currently has. /// Dictionary relating a unique hand ID corresponding to a container slot on the attached entity to a class containing information about the Hand itself. + /// Do not set this in yaml if you want to add intrinsic hands. Use instead. /// [DataField] public Dictionary Hands = new(); @@ -30,68 +48,68 @@ public sealed partial class HandsComponent : Component public int Count => Hands.Count; /// - /// List of hand-names. These are keys for . The order of this list determines the order in which hands are iterated over. + /// List of hand-names. These are keys for . The order of this list determines the order in which hands are iterated over. /// [DataField] public List SortedHands = new(); /// - /// If true, the items in the hands won't be affected by explosions. + /// If true, the items in the hands won't be affected by explosions. /// [DataField] public bool DisableExplosionRecursion; /// - /// Modifies the speed at which items are thrown. + /// Modifies the speed at which items are thrown. /// [DataField] public float BaseThrowspeed = 11f; /// - /// Distance after which longer throw targets stop increasing throw impulse. + /// Distance after which longer throw targets stop increasing throw impulse. /// [DataField] public float ThrowRange = 8f; /// - /// Whether or not to add in-hand sprites for held items. Some entities (e.g., drones) don't want these. - /// Used by the client. + /// Whether or not to add in-hand sprites for held items. Some entities (e.g., drones) don't want these. + /// Used by the client. /// [DataField] public bool ShowInHands = true; /// - /// Data about the current sprite layers that the hand is contributing to the owner entity. Used for sprite in-hands. - /// Used by the client. + /// Data about the current sprite layers that the hand is contributing to the owner entity. Used for sprite in-hands. + /// Used by the client. /// public readonly Dictionary> RevealedLayers = new(); /// - /// The time at which throws will be allowed again. + /// The time at which throws will be allowed again. /// [DataField, AutoPausedField] public TimeSpan NextThrowTime; /// - /// The minimum time inbetween throws. + /// The minimum time inbetween throws. /// [DataField] public TimeSpan ThrowCooldown = TimeSpan.FromSeconds(0.5f); /// - /// Fallback displacement map applied to all sprites in the hand, unless otherwise specified + /// Fallback displacement map applied to all sprites in the hand, unless otherwise specified /// [DataField] public DisplacementData? HandDisplacement; /// - /// If defined, applies to all sprites in the left hand, ignoring + /// If defined, applies to all sprites in the left hand, ignoring /// [DataField] public DisplacementData? LeftHandDisplacement; /// - /// If defined, applies to all sprites in the right hand, ignoring + /// If defined, applies to all sprites in the right hand, ignoring /// [DataField] public DisplacementData? RightHandDisplacement; @@ -103,6 +121,9 @@ public sealed partial class HandsComponent : Component public bool CanBeStripped = true; } +/// +/// Parameters for a single hand. +/// [DataDefinition] [Serializable, NetSerializable] public partial record struct Hand @@ -150,24 +171,63 @@ public partial record struct Hand } } +// If you add more fields make sure to also add them to the RegisterFields call in SharedHandsSystem! +// This is needed for delta states. [Serializable, NetSerializable] -public sealed class HandsComponentState : ComponentState +public sealed class HandsComponentState( + string? activeHandId, + Dictionary hands, + List sortedHands, + bool showInHands, + DisplacementData? handDisplacement, + DisplacementData? leftHandDisplacement, + DisplacementData? rightHandDisplacement, + bool canBeStripped) : ComponentState { - public readonly Dictionary Hands; - public readonly List SortedHands; - public readonly string? ActiveHandId; + public string? ActiveHandId = activeHandId; + public readonly Dictionary Hands = new(hands); + public readonly List SortedHands = new(sortedHands); + public readonly bool ShowInHands = showInHands; + public readonly DisplacementData? HandDisplacement = handDisplacement == null ? null : new(handDisplacement); + public readonly DisplacementData? LeftHandDisplacement = leftHandDisplacement == null ? null : new(leftHandDisplacement); + public readonly DisplacementData? RightHandDisplacement = rightHandDisplacement == null ? null : new(rightHandDisplacement); + public readonly bool CanBeStripped = canBeStripped; +} - public HandsComponentState(HandsComponent handComp) +/// +/// Delta state for the active hand so that we don't have to network +/// the entire component inluding displacements each time we switch hands. +/// +[Serializable, NetSerializable] +public sealed class HandsComponentActiveHandDeltaState(string? activeHandId) : IComponentDeltaState +{ + public string? ActiveHandId = activeHandId; + + public void ApplyToFullState(HandsComponentState fullState) { - // cloning lists because of test networking. - Hands = new(handComp.Hands); - SortedHands = new(handComp.SortedHands); - ActiveHandId = handComp.ActiveHandId; + fullState.ActiveHandId = ActiveHandId; + } + + public HandsComponentState CreateNewFullState(HandsComponentState fullState) + { + var newState = new HandsComponentState( + fullState.ActiveHandId, + fullState.Hands, + fullState.SortedHands, + fullState.ShowInHands, + fullState.HandDisplacement, + fullState.LeftHandDisplacement, + fullState.RightHandDisplacement, + fullState.CanBeStripped) + { + ActiveHandId = fullState.ActiveHandId, + }; + return newState; } } /// -/// What side of the body this hand is on. +/// What side of the body this hand is on. /// public enum HandLocation : byte { diff --git a/Content.Shared/Hands/EntitySystems/ExtraHandsEquipmentSystem.cs b/Content.Shared/Hands/EntitySystems/ExtraHandsEquipmentSystem.cs index 6e6052f836..09453f39ae 100644 --- a/Content.Shared/Hands/EntitySystems/ExtraHandsEquipmentSystem.cs +++ b/Content.Shared/Hands/EntitySystems/ExtraHandsEquipmentSystem.cs @@ -1,11 +1,13 @@ using Content.Shared.Hands.Components; using Content.Shared.Inventory.Events; +using Robust.Shared.Timing; namespace Content.Shared.Hands.EntitySystems; public sealed partial class ExtraHandsEquipmentSystem : EntitySystem { [Dependency] private SharedHandsSystem _hands = default!; + [Dependency] private IGameTiming _timing = default!; public override void Initialize() { @@ -17,6 +19,9 @@ public sealed partial class ExtraHandsEquipmentSystem : EntitySystem private void OnEquipped(Entity ent, ref GotEquippedEvent args) { + if (_timing.ApplyingState) + return; // The changes are already networked as part of the same game state. + if (!TryComp(args.EquipTarget, out var handsComp)) return; @@ -30,6 +35,9 @@ public sealed partial class ExtraHandsEquipmentSystem : EntitySystem private void OnUnequipped(Entity ent, ref GotUnequippedEvent args) { + if (_timing.ApplyingState) + return; // The changes are already networked as part of the same game state. + if (!TryComp(args.EquipTarget, out var handsComp)) return; diff --git a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.cs b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.cs index cdb6a23814..54448af6ac 100644 --- a/Content.Shared/Hands/EntitySystems/SharedHandsSystem.cs +++ b/Content.Shared/Hands/EntitySystems/SharedHandsSystem.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; using Content.Shared.ActionBlocker; using Content.Shared.Administration.Logs; +using Content.Shared.Cloning.Events; using Content.Shared.Hands.Components; using Content.Shared.Interaction; using Content.Shared.Inventory; @@ -30,6 +31,7 @@ public abstract partial class SharedHandsSystem public event Action, string, HandLocation>? OnPlayerAddHand; public event Action, string>? OnPlayerRemoveHand; protected event Action?>? OnHandSetActive; + protected byte ActiveHandIdIndex; public override void Initialize() { @@ -43,6 +45,20 @@ public abstract partial class SharedHandsSystem SubscribeLocalEvent(OnInit); SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnClone); + + // Needed for manual delta states. + EntityManager.ComponentFactory.RegisterNetworkedFields( + nameof(HandsComponent.ActiveHandId), + nameof(HandsComponent.Hands), + nameof(HandsComponent.SortedHands), + nameof(HandsComponent.ShowInHands), + nameof(HandsComponent.HandDisplacement), + nameof(HandsComponent.LeftHandDisplacement), + nameof(HandsComponent.RightHandDisplacement), + nameof(HandsComponent.CanBeStripped)); + + ActiveHandIdIndex = 0; // Corresponds to HandsComponentActiveHandDeltaState } public override void Shutdown() @@ -63,10 +79,38 @@ public abstract partial class SharedHandsSystem private void OnMapInit(Entity ent, ref MapInitEvent args) { + foreach (var (handId, hand) in ent.Comp.StartingHands) + AddHand(ent.AsNullable(), handId, hand); + if (ent.Comp.ActiveHandId == null) SetActiveHand(ent.AsNullable(), ent.Comp.SortedHands.FirstOrDefault()); } + private void OnClone(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + var targetComp = EnsureComp(args.CloneUid); + // Don't copy the Hands or SortedHands datafields since those are dynamically added and removed on map init or through other components + // targetComp.StartingHands = ent.Comp.StartingHands; + // A lot of hand related stuff is done via organs. + // Because of that we cannot sanely clone hands, but we can still clone over displacements etc. + // Organ cloning logic cannot be worked on until body system work progresses enough to allow us to do that. + // TODO: Clone hands properly once we can do organ cloning. + targetComp.DisableExplosionRecursion = ent.Comp.DisableExplosionRecursion; + targetComp.BaseThrowspeed = ent.Comp.BaseThrowspeed; + targetComp.ThrowRange = ent.Comp.ThrowRange; + targetComp.ShowInHands = ent.Comp.ShowInHands; + targetComp.ThrowCooldown = ent.Comp.ThrowCooldown; + targetComp.HandDisplacement = ent.Comp.HandDisplacement; + targetComp.LeftHandDisplacement = ent.Comp.LeftHandDisplacement; + targetComp.RightHandDisplacement = ent.Comp.RightHandDisplacement; + targetComp.CanBeStripped = ent.Comp.CanBeStripped; + + Dirty(args.CloneUid, targetComp); + } + /// /// Adds a hand with the given container id and supplied location to the specified entity. /// @@ -338,7 +382,7 @@ public abstract partial class SharedHandsSystem if (TryGetHeldItem(ent, handId, out var newHeld)) RaiseLocalEvent(newHeld.Value, new HandSelectedEvent(ent)); - Dirty(ent); + DirtyField(ent, nameof(HandsComponent.ActiveHandId)); return true; } diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index 84a59dcd42..c9ea5785b6 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -1665,9 +1665,8 @@ - map: [ "mask" ] - map: [ "head" ] - type: Hands - activeHandId: Hand - hands: - Hand: + startingHands: + hand_left: location: Left - type: ComplexInteraction - type: CreamPied diff --git a/Resources/Prototypes/Entities/Mobs/Player/admin_ghost.yml b/Resources/Prototypes/Entities/Mobs/Player/admin_ghost.yml index be06e22ab4..6da95dc549 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/admin_ghost.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/admin_ghost.yml @@ -26,14 +26,11 @@ canInteract: true - type: GhostHearing - type: Hands - hands: + startingHands: hand_right: location: Right hand_left: location: Left - sortedHands: - - hand_right - - hand_left - type: ComplexInteraction - type: Puller needsHands: false diff --git a/Resources/Prototypes/Entities/Mobs/Player/clone.yml b/Resources/Prototypes/Entities/Mobs/Player/clone.yml index ed1f58aaf0..11ba6c28cd 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/clone.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/clone.yml @@ -73,6 +73,7 @@ - SpanishAccent - StutteringAccent eventComponents: + - Hands # displacements - Puller - Vocal # voice sounds diff --git a/Resources/Prototypes/Entities/Mobs/Player/guardian.yml b/Resources/Prototypes/Entities/Mobs/Player/guardian.yml index d4b0dc094a..8c7b225feb 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/guardian.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/guardian.yml @@ -232,9 +232,8 @@ - type: Inventory templateId: holoclown - type: Hands - activeHandId: Hand - hands: - Hand: + startingHands: + hand_left: location: Left - type: ComplexInteraction - type: MeleeWeapon