Fix hideable humanoid layers (#42553)

* Fix hideable humanoid layers

* test maintenance coin

* clean return

* voxes can no longer have human beards

* voxes fixes

* voxing out

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
This commit is contained in:
pathetic meowmeow
2026-01-20 17:24:09 -05:00
committed by GitHub
parent acb685f3f9
commit 0ec9975e4f
16 changed files with 245 additions and 49 deletions

View File

@@ -232,6 +232,9 @@ public sealed class VisualBodySystem : SharedVisualBodySystem
private void OnMarkingsChangedVisibility(Entity<VisualOrganMarkingsComponent> ent, ref BodyRelayedEvent<HumanoidLayerVisibilityChangedEvent> args)
{
if (!ent.Comp.HideableLayers.Contains(args.Args.Layer))
return;
foreach (var markings in ent.Comp.Markings.Values)
{
foreach (var marking in markings)
@@ -239,7 +242,7 @@ public sealed class VisualBodySystem : SharedVisualBodySystem
if (!_marking.TryGetMarking(marking, out var proto))
continue;
if (proto.BodyPart != args.Args.Layer)
if (proto.BodyPart != args.Args.Layer && !(ent.Comp.DependentHidingLayers.TryGetValue(args.Args.Layer, out var dependent) && dependent.Contains(proto.BodyPart)))
continue;
foreach (var sprite in proto.Sprites)

View File

@@ -26,13 +26,13 @@ public sealed class HideableHumanoidLayersSystem : SharedHideableHumanoidLayersS
UpdateSprite(ent);
}
public override void SetLayerVisibility(
public override void SetLayerOcclusion(
Entity<HideableHumanoidLayersComponent?> ent,
HumanoidVisualLayers layer,
bool visible,
SlotFlags source)
{
base.SetLayerVisibility(ent, layer, visible, source);
base.SetLayerOcclusion(ent, layer, visible, source);
if (Resolve(ent, ref ent.Comp))
UpdateSprite((ent, ent.Comp));

View File

@@ -0,0 +1,88 @@
using System.Collections.Generic;
using Content.IntegrationTests.Tests.Interaction;
using Content.Shared.Body;
using Content.Shared.Humanoid;
using Content.Shared.Humanoid.Markings;
using Content.Shared.Inventory;
using Robust.Client.GameObjects;
namespace Content.IntegrationTests.Tests.Humanoid;
[TestOf(typeof(SharedHideableHumanoidLayersSystem))]
public sealed class HideableHumanoidLayersTest : InteractionTest
{
protected override string PlayerPrototype => "MobVulpkanin";
[Test]
public async Task BasicHiding()
{
await SpawnTarget("ClothingMaskGas");
await Pickup(); // equip mask
await UseInHand();
await Server.WaitAssertion(() =>
{
var hideableHumanoidLayers = SEntMan.GetComponent<HideableHumanoidLayersComponent>(SPlayer);
Assert.That(hideableHumanoidLayers.HiddenLayers, Does.ContainKey(HumanoidVisualLayers.Snout).WithValue(SlotFlags.MASK));
});
await Server.WaitAssertion(() =>
{
SEntMan.DeleteEntity(STarget); // de-equip mask
var hideableHumanoidLayers = SEntMan.GetComponent<HideableHumanoidLayersComponent>(SPlayer);
Assert.That(hideableHumanoidLayers.HiddenLayers, Does.Not.ContainKey(HumanoidVisualLayers.Snout));
});
}
[Test]
public async Task DependentHiding()
{
await Server.WaitAssertion(() =>
{
var visualBody = SEntMan.System<SharedVisualBodySystem>();
visualBody.ApplyMarkings(SPlayer, new()
{
["Head"] = new()
{
[HumanoidVisualLayers.SnoutCover] = new List<Marking>() { new("VulpSnoutNose", 1) },
},
});
});
await SpawnTarget("ClothingMaskGas");
await Pickup(); // equip mask
await UseInHand();
await RunTicks(20);
await Client.WaitAssertion(() =>
{
var spriteSystem = CEntMan.System<SpriteSystem>();
var snoutIndex = spriteSystem.LayerMapGet(CPlayer, "VulpSnout-snout");
var snoutCoverIndex = spriteSystem.LayerMapGet(CPlayer, "VulpSnoutNose-snout-nose");
var spriteComp = CEntMan.GetComponent<SpriteComponent>(CPlayer);
Assert.That(spriteComp[snoutIndex].Visible, Is.False);
Assert.That(spriteComp[snoutCoverIndex].Visible, Is.False);
});
await Server.WaitAssertion(() =>
{
SEntMan.DeleteEntity(STarget); // de-equip mask
});
await RunTicks(20);
await Client.WaitAssertion(() =>
{
var spriteSystem = CEntMan.System<SpriteSystem>();
var snoutIndex = spriteSystem.LayerMapGet(CPlayer, "VulpSnout-snout");
var snoutCoverIndex = spriteSystem.LayerMapGet(CPlayer, "VulpSnoutNose-snout-nose");
var spriteComp = CEntMan.GetComponent<SpriteComponent>(CPlayer);
Assert.That(spriteComp[snoutIndex].Visible, Is.True);
Assert.That(spriteComp[snoutCoverIndex].Visible, Is.True);
});
}
}

View File

@@ -0,0 +1,80 @@
using System.Collections.Generic;
using System.Linq;
using Content.Shared.Body;
using Content.Shared.Clothing.Components;
using Content.Shared.Humanoid;
using Robust.Shared.Prototypes;
namespace Content.IntegrationTests.Tests.Humanoid;
[TestFixture]
public sealed class HideablePrototypeValidation
{
[Test]
public async Task NoOrgansWithoutClothing()
{
await using var pair = await PoolManager.GetServerClient();
var requirements = new Dictionary<Enum, HashSet<EntProtoId>>();
foreach (var (proto, component) in pair.GetPrototypesWithComponent<VisualOrganMarkingsComponent>())
{
foreach (var layer in component.HideableLayers)
{
requirements[layer] = requirements.GetValueOrDefault(layer) ?? [];
requirements[layer].Add(proto.ID);
}
}
var provided = new HashSet<HumanoidVisualLayers>();
foreach (var (_, component) in pair.GetPrototypesWithComponent<HideLayerClothingComponent>())
{
#pragma warning disable CS0618 // Type or member is obsolete
if (component.Slots is { } slots)
{
provided.UnionWith(slots);
}
provided.UnionWith(component.Layers.Keys);
#pragma warning restore CS0618 // Type or member is obsolete
}
using var scope = Assert.EnterMultipleScope();
foreach (var (key, requirement) in requirements)
{
Assert.That(provided, Does.Contain(key), $"No clothing will hide {key} that can be hidden on {string.Join(", ", requirement.Select(it => it.Id))}");
}
await pair.CleanReturnAsync();
}
[Test]
public async Task NoClothingWithoutOrgans()
{
await using var pair = await PoolManager.GetServerClient();
var requirements = new Dictionary<Enum, HashSet<EntProtoId>>();
foreach (var (proto, component) in pair.GetPrototypesWithComponent<HideLayerClothingComponent>())
{
#pragma warning disable CS0618 // Type or member is obsolete
foreach (var layer in component.Layers.Keys.Concat(component.Slots ?? []))
#pragma warning restore CS0618 // Type or member is obsolete
{
requirements[layer] = requirements.GetValueOrDefault(layer) ?? [];
requirements[layer].Add(proto.ID);
}
}
var provided = new HashSet<Enum>();
foreach (var (_, component) in pair.GetPrototypesWithComponent<VisualOrganMarkingsComponent>())
{
provided.UnionWith(component.HideableLayers);
}
using var scope = Assert.EnterMultipleScope();
foreach (var (key, requirement) in requirements)
{
Assert.That(provided, Does.Contain(key), $"No organ will hide {key} that can be hidden by {string.Join(", ", requirement.Select(it => it.Id))}");
}
await pair.CleanReturnAsync();
}
}

View File

@@ -22,6 +22,18 @@ public sealed partial class VisualOrganMarkingsComponent : Component
[DataField, AutoNetworkedField]
public Dictionary<HumanoidVisualLayers, List<Marking>> Markings = new();
/// <summary>
/// Layers that are eligible for hiding based on e.g. clothing
/// </summary>
[DataField, AutoNetworkedField]
public HashSet<Enum> HideableLayers = new();
/// <summary>
/// A dictionary of layers to other layers that visually depend on them for hiding, e.g. SnoutCover depends on Snout
/// </summary>
[DataField, AutoNetworkedField]
public Dictionary<Enum, HashSet<Enum>> DependentHidingLayers = new();
/// <summary>
/// Client only - the last markings applied by this component
/// </summary>

View File

@@ -51,7 +51,6 @@ public sealed class HideLayerClothingSystem : EntitySystem
hideLayers &= IsEnabled(clothing!);
var hideable = user.Comp.HideLayersOnEquip;
var inSlot = clothing.Comp2.InSlotFlag ?? SlotFlags.NONE;
// This method should only be getting called while the clothing is equipped (though possibly currently in
@@ -64,12 +63,9 @@ public sealed class HideLayerClothingSystem : EntitySystem
// the clothing is (or was)equipped in a matching slot.
foreach (var (layer, validSlots) in clothing.Comp1.Layers)
{
if (!hideable.Contains(layer))
continue;
// Only update this layer if we are currently equipped to the relevant slot.
if (validSlots.HasFlag(inSlot))
_hideableHumanoidLayers.SetLayerVisibility(user, layer, !hideLayers, inSlot);
_hideableHumanoidLayers.SetLayerOcclusion(user, layer, hideLayers, inSlot);
}
// Fallback for obsolete field: assume we want to hide **all** layers, as long as we are equipped to any
@@ -80,8 +76,7 @@ public sealed class HideLayerClothingSystem : EntitySystem
{
foreach (var layer in slots)
{
if (hideable.Contains(layer))
_hideableHumanoidLayers.SetLayerVisibility(user, layer, !hideLayers, inSlot);
_hideableHumanoidLayers.SetLayerOcclusion(user, layer, hideLayers, inSlot);
}
}
}

View File

@@ -15,12 +15,6 @@ public sealed partial class HideableHumanoidLayersComponent : Component
[DataField, AutoNetworkedField]
public Dictionary<HumanoidVisualLayers, SlotFlags> HiddenLayers = new();
/// <summary>
/// Which layers of this humanoid that should be hidden on equipping a corresponding item..
/// </summary>
[DataField]
public HashSet<HumanoidVisualLayers> HideLayersOnEquip = [HumanoidVisualLayers.Hair];
/// <summary>
/// Client only - which layers were last hidden
/// </summary>

View File

@@ -11,12 +11,12 @@ public abstract partial class SharedHideableHumanoidLayersSystem : EntitySystem
/// </summary>
/// <param name="ent">Humanoid entity</param>
/// <param name="layer">Layer to toggle visibility for</param>
/// <param name="visible">Whether to hide or show the layer. If more than once piece of clothing is hiding the layer, it may remain hidden.</param>
/// <param name="hidden">Whether to hide (true) or show (false) the layer. If more than once piece of clothing is hiding the layer, it may remain hidden.</param>
/// <param name="slot">Equipment slot that has the clothing that is (or was) hiding the layer.</param>
public virtual void SetLayerVisibility(
public virtual void SetLayerOcclusion(
Entity<HideableHumanoidLayersComponent?> ent,
HumanoidVisualLayers layer,
bool visible,
bool hidden,
SlotFlags slot)
{
if (!Resolve(ent, ref ent.Comp))
@@ -30,7 +30,7 @@ public abstract partial class SharedHideableHumanoidLayersSystem : EntitySystem
#endif
var dirty = false;
if (visible)
if (hidden)
{
var oldSlots = ent.Comp.HiddenLayers.GetValueOrDefault(layer);
ent.Comp.HiddenLayers[layer] = slot | oldSlots;
@@ -52,7 +52,7 @@ public abstract partial class SharedHideableHumanoidLayersSystem : EntitySystem
Dirty(ent);
var evt = new HumanoidLayerVisibilityChangedEvent(layer, visible);
var evt = new HumanoidLayerVisibilityChangedEvent(layer, ent.Comp.HiddenLayers.ContainsKey(layer));
RaiseLocalEvent(ent, ref evt);
}
}

View File

@@ -130,6 +130,11 @@
- type: entity
parent: [ OrganBaseHeadSexed, OrganBaseHead, OrganHumanExternal ]
id: OrganHumanHead
components:
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Hair
- enum.HumanoidVisualLayers.Snout
- type: entity
parent: [ OrganBaseArmLeft, OrganHumanExternal ]

View File

@@ -239,6 +239,10 @@
- type: entity
parent: [ OrganBaseHead, OrganMothExternal ]
id: OrganMothHead
components:
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.HeadTop
- type: entity
parent: [ OrganBaseArmLeft, OrganMothExternal ]

View File

@@ -205,10 +205,20 @@
- type: entity
parent: [ OrganBaseTorsoSexed, OrganBaseTorso, OrganReptilianExternal ]
id: OrganReptilianTorso
components:
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Tail
- type: entity
parent: [ OrganBaseHeadSexed, OrganBaseHead, OrganReptilianExternal ]
id: OrganReptilianHead
components:
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Snout
- enum.HumanoidVisualLayers.HeadTop
- enum.HumanoidVisualLayers.HeadSide
- type: entity
parent: [ OrganBaseArmLeft, OrganReptilianExternal ]

View File

@@ -4,13 +4,15 @@
limits:
enum.HumanoidVisualLayers.Hair:
limit: 1
onlyGroupWhitelisted: true
required: false
enum.HumanoidVisualLayers.FacialHair:
limit: 1
onlyGroupWhitelisted: true
required: false
enum.HumanoidVisualLayers.Head:
limit: 4
required: true
required: false
enum.HumanoidVisualLayers.Snout:
limit: 1
required: true
@@ -19,12 +21,12 @@
limit: 1
required: false
enum.HumanoidVisualLayers.LArm:
limit: 1
required: true
limit: 2
required: false
default: [ VoxLArmScales ]
enum.HumanoidVisualLayers.RArm:
limit: 1
required: true
limit: 2
required: false
default: [ VoxRArmScales ]
enum.HumanoidVisualLayers.LHand:
limit: 1
@@ -36,11 +38,11 @@
default: [ VoxRHandScales ]
enum.HumanoidVisualLayers.LLeg:
limit: 1
required: true
required: false
default: [ VoxLLegScales ]
enum.HumanoidVisualLayers.RLeg:
limit: 1
required: true
required: false
default: [ VoxRLegScales ]
enum.HumanoidVisualLayers.LFoot:
limit: 1
@@ -290,22 +292,19 @@
- type: entity
parent: [ OrganBaseTorso, OrganVoxExternal ]
id: OrganVoxTorso
components:
- type: Sprite
state: torso
- type: VisualOrgan
data:
state: torso
- type: entity
parent: [ OrganBaseHead, OrganVoxExternal ]
id: OrganVoxHead
components:
- type: Sprite
state: head
- type: VisualOrgan
data:
state: head
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Snout
- enum.HumanoidVisualLayers.Hair
- enum.HumanoidVisualLayers.FacialHair
dependentHidingLayers:
enum.HumanoidVisualLayers.Snout:
- enum.HumanoidVisualLayers.SnoutCover
- type: entity
parent: [ OrganBaseArmLeft, OrganVoxExternal ]

View File

@@ -240,6 +240,17 @@
- type: entity
parent: [ OrganBaseHead, OrganVulpkaninExternal ]
id: OrganVulpkaninHead
components:
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Snout
- enum.HumanoidVisualLayers.HeadTop
- enum.HumanoidVisualLayers.HeadSide
- enum.HumanoidVisualLayers.Hair
- enum.HumanoidVisualLayers.FacialHair
dependentHidingLayers:
enum.HumanoidVisualLayers.Snout:
- enum.HumanoidVisualLayers.SnoutCover
- type: entity
parent: [ OrganBaseArmLeft, OrganVulpkaninExternal ]

View File

@@ -60,6 +60,8 @@
data:
state: head
- type: VisualOrganMarkings
hideableLayers:
- enum.HumanoidVisualLayers.Hair
markingData:
layers:
- Head

View File

@@ -84,13 +84,6 @@
- type: ContainerContainer
- type: Appearance
- type: HideableHumanoidLayers
hideLayersOnEquip:
- Snout
- SnoutCover
- HeadTop
- HeadSide
- FacialHair
- Hair
- type: UserInterface
interfaces:
enum.HumanoidMarkingModifierKey.Key:

View File

@@ -162,7 +162,7 @@
sprites:
- sprite: Mobs/Customization/vox_tattoos.rsi
state: eyeshadow_large
- type: marking
id: VoxTattooEyeliner
bodyPart: Eyes
@@ -173,7 +173,7 @@
- type: marking
id: VoxBeakCoverStripe
bodyPart: Snout
bodyPart: SnoutCover
coloring:
default:
type:
@@ -186,7 +186,7 @@
- type: marking
id: VoxBeakCoverTip
bodyPart: Snout
bodyPart: SnoutCover
coloring:
default:
type: