]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Fix hideable humanoid layers (#42553)
authorpathetic meowmeow <uhhadd@gmail.com>
Tue, 20 Jan 2026 22:24:09 +0000 (17:24 -0500)
committerGitHub <noreply@github.com>
Tue, 20 Jan 2026 22:24:09 +0000 (22:24 +0000)
* 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>
16 files changed:
Content.Client/Body/VisualBodySystem.cs
Content.Client/Humanoid/HideableHumanoidLayersSystem.cs
Content.IntegrationTests/Tests/Humanoid/HideableHumanoidLayersTest.cs [new file with mode: 0644]
Content.IntegrationTests/Tests/Humanoid/HideablePrototypeValidation.cs [new file with mode: 0644]
Content.Shared/Body/VisualOrganMarkingsComponent.cs
Content.Shared/Clothing/EntitySystems/HideLayerClothingSystem.cs
Content.Shared/Humanoid/HideableHumanoidLayersComponent.cs
Content.Shared/Humanoid/SharedHideableHumanoidLayersSystem.cs
Resources/Prototypes/Body/Species/human.yml
Resources/Prototypes/Body/Species/moth.yml
Resources/Prototypes/Body/Species/reptilian.yml
Resources/Prototypes/Body/Species/vox.yml
Resources/Prototypes/Body/Species/vulpkanin.yml
Resources/Prototypes/Body/base_organs.yml
Resources/Prototypes/Body/species_appearance.yml
Resources/Prototypes/Entities/Mobs/Customization/Markings/vox_tattoos.yml

index 724dd220173db42c0db3207c7615bef4d4261336..fba936ee58a75c356bdd6506b103480286eca2e4 100644 (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)
index 4feb48cbdafec1adf3debec2a428f982b8ba5a5c..9d034a7a639cfb37c09ea0ec10ab9ec7157d4053 100644 (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));
diff --git a/Content.IntegrationTests/Tests/Humanoid/HideableHumanoidLayersTest.cs b/Content.IntegrationTests/Tests/Humanoid/HideableHumanoidLayersTest.cs
new file mode 100644 (file)
index 0000000..24d8da4
--- /dev/null
@@ -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);
+        });
+    }
+}
diff --git a/Content.IntegrationTests/Tests/Humanoid/HideablePrototypeValidation.cs b/Content.IntegrationTests/Tests/Humanoid/HideablePrototypeValidation.cs
new file mode 100644 (file)
index 0000000..d95992b
--- /dev/null
@@ -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();
+    }
+}
index e0ec567cf4190d3ee5f29fa91f3ae75d2e53be6c..a0af5a6a16e4810b78208cd026597f0902096833 100644 (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>
index 1d58e7071c1abbfb1851086c0955d0ef136f0324..ee0f3932d6c4fe019ffaea2b810027feb53ed001 100644 (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);
             }
         }
     }
index 8fa0998ab51ae7d18c449c7b8fbd2eafcbb1b444..75abeb431608f9336ae62216926045d8e189da18 100644 (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>
index 6e308cc4bb59d84b3364860a2f048b4e48782065..0baea47892db68067649c03176cee7aaee451d12 100644 (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);
     }
 }
index 7c0ee31f3291d3b71d80d372d8ba829089823e3a..fd9dd1fbbeae9960370059e35c997c096c8abaeb 100644 (file)
 - type: entity
   parent: [ OrganBaseHeadSexed, OrganBaseHead, OrganHumanExternal ]
   id: OrganHumanHead
+  components:
+  - type: VisualOrganMarkings
+    hideableLayers:
+    - enum.HumanoidVisualLayers.Hair
+    - enum.HumanoidVisualLayers.Snout
 
 - type: entity
   parent: [ OrganBaseArmLeft, OrganHumanExternal ]
index a5a1874169fa4463b00d228efe9be300202dedb4..a1a10ff8d90371e30cd5fbcd345f1d71129df9af 100644 (file)
 - type: entity
   parent: [ OrganBaseHead, OrganMothExternal ]
   id: OrganMothHead
+  components:
+  - type: VisualOrganMarkings
+    hideableLayers:
+    - enum.HumanoidVisualLayers.HeadTop
 
 - type: entity
   parent: [ OrganBaseArmLeft, OrganMothExternal ]
index 3dd25cf46801bb111626cd29235c698347a2ac44..8083d8593c11354fdfda3c6bdea472a86a720944 100644 (file)
 - 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 ]
index 15190165e076975eba80f2a9f879dceef297472a..08a1b88dc19fe92bc656fa2d5307416a16783e6a 100644 (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
       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
       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
 - 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 ]
index 9209e4c8b73a5fa6aff80084882107408b67afd8..d2d094e31b4bdd4f35f253fd4cf4f93552ff8c0f 100644 (file)
 - 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 ]
index 0a5607acefad2bd348684bd06e8cc755c9b3cdb6..5c16184a7865907977a27a109f1364d8f6dcc069 100644 (file)
@@ -60,6 +60,8 @@
     data:
       state: head
   - type: VisualOrganMarkings
+    hideableLayers:
+    - enum.HumanoidVisualLayers.Hair
     markingData:
       layers:
       - Head
index ab5f83dd7c77ee04043896a03044fb0bf0901b77..aa584a5e0c7a9824e481c9f94e15c3604937cc83 100644 (file)
   - type: ContainerContainer
   - type: Appearance
   - type: HideableHumanoidLayers
-    hideLayersOnEquip:
-    - Snout
-    - SnoutCover
-    - HeadTop
-    - HeadSide
-    - FacialHair
-    - Hair
   - type: UserInterface
     interfaces:
       enum.HumanoidMarkingModifierKey.Key:
index c10880d281a1e22fc8d702b77af58c0f2940f103..c1112ebaea63f0f3a3ecc5f860403daf2141862c 100644 (file)
   sprites:
   - sprite: Mobs/Customization/vox_tattoos.rsi
     state: eyeshadow_large
-    
+
 - type: marking
   id: VoxTattooEyeliner
   bodyPart: Eyes
 
 - type: marking
   id: VoxBeakCoverStripe
-  bodyPart: Snout
+  bodyPart: SnoutCover
   coloring:
     default:
       type:
 
 - type: marking
   id: VoxBeakCoverTip
-  bodyPart: Snout
+  bodyPart: SnoutCover
   coloring:
     default:
       type: