network HandsComponent, fix changeling not updating hand displacements on transform (#43545)

* network handscomp

* fix

* small clean

* only clone displacements

* TODO

---------

Co-authored-by: ScarKy0 <scarky0@onet.eu>
This commit is contained in:
slarticodefast 2026-08-19 15:03:59 +00:00 • committed by GitHub
parent 295d73317c
commit d1a53fcc54
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 204 additions and 38 deletions

View file

@ -57,6 +57,13 @@ namespace Content.Client.Hands.Systems
#region StateHandling
private void HandleComponentState(Entity<HandsComponent> 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);
}
/// <summary>
/// Update the players sprite with new in-hand visuals for all held items.
/// </summary>
private void UpdateAllHandVisuals(Entity<HandsComponent?> ent)
{
foreach (var handId in EnumerateHands(ent))
{
if (!TryGetHeldItem(ent, handId, out var held))
continue;
UpdateHandVisuals(ent, held.Value, handId);
}
}
/// <summary>
/// Update the players sprite with new in-hand visuals.
/// </summary>

View file

@ -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);
}

View file

@ -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;
}
}

View file

@ -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;
/// <summary>
/// Allows this entity to have hands so that it can interact with items.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentPause]
[Access(typeof(SharedHandsSystem))]
public sealed partial class HandsComponent : Component
public sealed partial class HandsComponent : Component, IComponentDelta
{
/// <inheritdoc />
public GameTick[] LastModifiedFields { get; set; }
/// <inheritdoc/>
public GameTick LastUnclassifiedDirty { get; set; }
/// <summary>
/// The currently active hand.
/// The currently active hand.
/// </summary>
[DataField]
public string? ActiveHandId;
/// <summary>
/// Intrinsic hands to be added on map init.
/// </summary>
[DataField]
public Dictionary<string, Hand> StartingHands = new();
/// <summary>
/// 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 <see cref="StartingHands"/> instead.
/// </summary>
[DataField]
public Dictionary<string, Hand> Hands = new();
@ -30,68 +48,68 @@ public sealed partial class HandsComponent : Component
public int Count => Hands.Count;
/// <summary>
/// List of hand-names. These are keys for <see cref="Hands"/>. The order of this list determines the order in which hands are iterated over.
/// List of hand-names. These are keys for <see cref="Hands"/>. The order of this list determines the order in which hands are iterated over.
/// </summary>
[DataField]
public List<string> SortedHands = new();
/// <summary>
/// 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.
/// </summary>
[DataField]
public bool DisableExplosionRecursion;
/// <summary>
/// Modifies the speed at which items are thrown.
/// Modifies the speed at which items are thrown.
/// </summary>
[DataField]
public float BaseThrowspeed = 11f;
/// <summary>
/// Distance after which longer throw targets stop increasing throw impulse.
/// Distance after which longer throw targets stop increasing throw impulse.
/// </summary>
[DataField]
public float ThrowRange = 8f;
/// <summary>
/// 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.
/// </summary>
[DataField]
public bool ShowInHands = true;
/// <summary>
/// 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.
/// </summary>
public readonly Dictionary<HandLocation, HashSet<string>> RevealedLayers = new();
/// <summary>
/// The time at which throws will be allowed again.
/// The time at which throws will be allowed again.
/// </summary>
[DataField, AutoPausedField]
public TimeSpan NextThrowTime;
/// <summary>
/// The minimum time inbetween throws.
/// The minimum time inbetween throws.
/// </summary>
[DataField]
public TimeSpan ThrowCooldown = TimeSpan.FromSeconds(0.5f);
/// <summary>
/// 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
/// </summary>
[DataField]
public DisplacementData? HandDisplacement;
/// <summary>
/// If defined, applies to all sprites in the left hand, ignoring <see cref="HandDisplacement"/>
/// If defined, applies to all sprites in the left hand, ignoring <see cref="HandDisplacement"/>
/// </summary>
[DataField]
public DisplacementData? LeftHandDisplacement;
/// <summary>
/// If defined, applies to all sprites in the right hand, ignoring <see cref="HandDisplacement"/>
/// If defined, applies to all sprites in the right hand, ignoring <see cref="HandDisplacement"/>
/// </summary>
[DataField]
public DisplacementData? RightHandDisplacement;
@ -103,6 +121,9 @@ public sealed partial class HandsComponent : Component
public bool CanBeStripped = true;
}
/// <summary>
/// Parameters for a single hand.
/// </summary>
[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<string, Hand> hands,
List<string> sortedHands,
bool showInHands,
DisplacementData? handDisplacement,
DisplacementData? leftHandDisplacement,
DisplacementData? rightHandDisplacement,
bool canBeStripped) : ComponentState
{
public readonly Dictionary<string, Hand> Hands;
public readonly List<string> SortedHands;
public readonly string? ActiveHandId;
public string? ActiveHandId = activeHandId;
public readonly Dictionary<string, Hand> Hands = new(hands);
public readonly List<string> 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)
/// <summary>
/// Delta state for the active hand so that we don't have to network
/// the entire component inluding displacements each time we switch hands.
/// </summary>
[Serializable, NetSerializable]
public sealed class HandsComponentActiveHandDeltaState(string? activeHandId) : IComponentDeltaState<HandsComponentState>
{
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;
}
}
/// <summary>
/// What side of the body this hand is on.
/// What side of the body this hand is on.
/// </summary>
public enum HandLocation : byte
{

View file

@ -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<ExtraHandsEquipmentComponent> ent, ref GotEquippedEvent args)
{
if (_timing.ApplyingState)
return; // The changes are already networked as part of the same game state.
if (!TryComp<HandsComponent>(args.EquipTarget, out var handsComp))
return;
@ -30,6 +35,9 @@ public sealed partial class ExtraHandsEquipmentSystem : EntitySystem
private void OnUnequipped(Entity<ExtraHandsEquipmentComponent> ent, ref GotUnequippedEvent args)
{
if (_timing.ApplyingState)
return; // The changes are already networked as part of the same game state.
if (!TryComp<HandsComponent>(args.EquipTarget, out var handsComp))
return;

View file

@ -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<Entity<HandsComponent>, string, HandLocation>? OnPlayerAddHand;
public event Action<Entity<HandsComponent>, string>? OnPlayerRemoveHand;
protected event Action<Entity<HandsComponent>?>? OnHandSetActive;
protected byte ActiveHandIdIndex;
public override void Initialize()
{
@ -43,6 +45,20 @@ public abstract partial class SharedHandsSystem
SubscribeLocalEvent<HandsComponent, ComponentInit>(OnInit);
SubscribeLocalEvent<HandsComponent, MapInitEvent>(OnMapInit);
SubscribeLocalEvent<HandsComponent, CloningEvent>(OnClone);
// Needed for manual delta states.
EntityManager.ComponentFactory.RegisterNetworkedFields<HandsComponent>(
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<HandsComponent> 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<HandsComponent> ent, ref CloningEvent args)
{
if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name))
return;
var targetComp = EnsureComp<HandsComponent>(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);
}
/// <summary>
/// Adds a hand with the given container id and supplied location to the specified entity.
/// </summary>
@ -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;
}

View file

@ -1665,9 +1665,8 @@
- map: [ "mask" ]
- map: [ "head" ]
- type: Hands
activeHandId: Hand
hands:
Hand:
startingHands:
hand_left:
location: Left
- type: ComplexInteraction
- type: CreamPied

View file

@ -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

View file

@ -73,6 +73,7 @@
- SpanishAccent
- StutteringAccent
eventComponents:
- Hands # displacements
- Puller
- Vocal # voice sounds

View file

@ -232,9 +232,8 @@
- type: Inventory
templateId: holoclown
- type: Hands
activeHandId: Hand
hands:
Hand:
startingHands:
hand_left:
location: Left
- type: ComplexInteraction
- type: MeleeWeapon