CatStation/Content.Client/Clothing/ClientClothingSystem.cs
Whatstone 0b7499fe0f
Bugfix: fix helmets keeping species-specific shape (#45660)
ClientClothing/ToggleableVisuals: copy Layers
2026-09-10 11:13:55 +00:00

403 lines
15 KiB
C#

using System.Diagnostics.CodeAnalysis;
using System.Linq;
using Content.Client.DisplacementMap;
using Content.Client.Inventory;
using Content.Shared.Clothing;
using Content.Shared.Clothing.Components;
using Content.Shared.Clothing.EntitySystems;
using Content.Shared.Humanoid;
using Content.Shared.Inventory;
using Content.Shared.Inventory.Events;
using Content.Shared.Item;
using Robust.Client.GameObjects;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Shared.Serialization.Manager;
using Robust.Shared.Serialization.TypeSerializers.Implementations;
using Robust.Shared.Utility;
using static Robust.Client.GameObjects.SpriteComponent;
namespace Content.Client.Clothing;
public sealed partial class ClientClothingSystem : ClothingSystem
{
public const string Jumpsuit = "jumpsuit";
/// <summary>
/// This is a shitty hotfix written by me (Paul) to save me from renaming all files.
/// For some context, im currently refactoring inventory. Part of that is slots not being indexed by a massive enum anymore, but by strings.
/// Problem here: Every rsi-state is using the old enum-names in their state. I already used the new inventoryslots ALOT. tldr: its this or another week of renaming files.
/// </summary>
private static readonly Dictionary<string, string> TemporarySlotMap = new()
{
{"head", "HELMET"},
{"eyes", "EYES"},
{"ears", "EARS"},
{"mask", "MASK"},
{"outerClothing", "OUTERCLOTHING"},
{Jumpsuit, "INNERCLOTHING"},
{"neck", "NECK"},
{"back", "BACKPACK"},
{"belt", "BELT"},
{"gloves", "HAND"},
{"shoes", "FEET"},
{"id", "IDCARD"},
{"pocket1", "POCKET1"},
{"pocket2", "POCKET2"},
{"suitstorage", "SUITSTORAGE"},
};
[Dependency] private IResourceCache _cache = default!;
[Dependency] private ISerializationManager _seriMan = default!;
[Dependency] private DisplacementMapSystem _displacement = default!;
[Dependency] private InventorySystem _inventorySystem = default!;
[Dependency] private SpriteSystem _sprite = default!;
[Dependency] private EntityQuery<InventorySlotsComponent> _inventorySlotsQuery;
[Dependency] private EntityQuery<SpriteComponent> _spriteQuery;
#region Entity Handlers
[SubscribeLocalEvent]
private void OnAppearanceUpdate(Entity<InventoryComponent> ent, ref AppearanceChangeEvent args)
{
// May need to update displacement maps if the sex changed. Also required to properly set the stencil on init
if (args.Sprite == null)
return;
UpdateAllSlots(ent.AsNullable());
// No clothing equipped -> make sure the layer is hidden, though this should already be handled by on-unequip.
if (_sprite.LayerMapTryGet((ent, args.Sprite), HumanoidVisualLayers.StencilMask, out var layer, false))
{
DebugTools.Assert(!args.Sprite[layer].Visible);
_sprite.LayerSetVisible((ent, args.Sprite), layer, false);
}
}
[SubscribeLocalEvent]
private void OnInventoryTemplateUpdated(Entity<InventoryComponent> ent, ref InventoryTemplateUpdated args)
{
UpdateAllSlots(ent.AsNullable());
}
private void UpdateAllSlots(
Entity<InventoryComponent?> ent)
{
var enumerator = _inventorySystem.GetSlotEnumerator((ent, ent.Comp));
while (enumerator.NextItem(out var item, out var slot))
{
RenderEquipment(ent, item, slot.Name, ent.Comp);
}
}
[SubscribeLocalEvent]
private void OnGetVisuals(Entity<ClothingComponent> ent, ref GetEquipmentVisualsEvent args)
{
if (!InventoryQuery.TryComp(args.Equipee, out var inventory))
return;
List<PrototypeLayerData>? layers = null;
// first attempt to get species specific data.
if (inventory.SpeciesId != null)
ent.Comp.ClothingVisuals.TryGetValue($"{args.Slot}-{inventory.SpeciesId}", out layers);
// if that returned nothing, attempt to find generic data
if (layers == null && !ent.Comp.ClothingVisuals.TryGetValue(args.Slot, out layers))
{
// No generic data either. Attempt to generate defaults from the item's RSI & item-prefixes
if (!TryGetDefaultVisuals(ent, args.Slot, inventory.SpeciesId, out layers))
return;
}
// add each layer to the visuals
var i = 0;
foreach (var layer in layers)
{
var key = layer.MapKeys?.FirstOrDefault();
if (key == null)
{
// using the $"{args.Slot}" layer key as the "bookmark" for layer ordering until layer draw depths get added
key = $"{args.Slot}-{i}";
i++;
}
ent.Comp.MappedLayer = key;
// Create a copy of the layer, which might get modified.
PrototypeLayerData newLayer = new();
_seriMan.CopyTo(layer, ref newLayer, notNullableOverride: true);
args.Layers.Add((key, newLayer));
}
}
[SubscribeLocalEvent]
private void OnVisualsChanged(Entity<InventoryComponent> ent, ref VisualsChangedEvent args)
{
var item = GetEntity(args.Item);
if (!ClothingQuery.TryComp(item, out var clothing) || clothing.InSlot == null)
return;
RenderEquipment(ent, item, clothing.InSlot, ent.Comp, null, clothing);
}
[SubscribeLocalEvent]
private void OnDidUnequip(Entity<SpriteComponent> entity, ref DidUnequipEvent args)
{
if (!_inventorySlotsQuery.TryComp(entity, out var inventorySlots))
return;
if (!inventorySlots.VisualLayerKeys.TryGetValue(args.Slot, out var revealedLayers))
return;
// Remove old layers. We could also just set them to invisible, but as items may add arbitrary layers, this
// may eventually bloat the player with lots of invisible layers.
foreach (var layer in revealedLayers)
{
_sprite.RemoveLayer(entity.AsNullable(), layer);
}
revealedLayers.Clear();
}
#endregion Entity Handlers
#region Public API
public void InitClothing(EntityUid uid, InventoryComponent component)
{
if (!_spriteQuery.TryComp(uid, out var sprite))
return;
var enumerator = _inventorySystem.GetSlotEnumerator((uid, component));
while (enumerator.NextItem(out var item, out var slot))
{
RenderEquipment(uid, item, slot.Name, component, sprite);
}
}
#endregion Public API
#region Internal
/// <summary>
/// If no explicit clothing visuals were specified, this attempts to populate with default values.
/// </summary>
/// <remarks>
/// Useful for lazily adding clothing sprites without modifying yaml. And for backwards compatibility.
/// </remarks>
private bool TryGetDefaultVisuals(Entity<ClothingComponent> ent, string slot, string? speciesId,
[NotNullWhen(true)] out List<PrototypeLayerData>? layers)
{
layers = null;
RSI? rsi = null;
if (ent.Comp.RsiPath != null)
rsi = _cache.GetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / ent.Comp.RsiPath).RSI;
else if (_spriteQuery.TryComp(ent, out var sprite))
rsi = sprite.BaseRSI;
if (rsi == null)
return false;
var correctedSlot = slot;
TemporarySlotMap.TryGetValue(correctedSlot, out correctedSlot);
var state = $"equipped-{correctedSlot}";
if (!string.IsNullOrEmpty(ent.Comp.EquippedPrefix))
state = $"{ent.Comp.EquippedPrefix}-equipped-{correctedSlot}";
if (ent.Comp.EquippedState != null)
state = $"{ent.Comp.EquippedState}";
// species specific
if (speciesId != null && rsi.TryGetState($"{state}-{speciesId}", out _))
state = $"{state}-{speciesId}";
else if (!rsi.TryGetState(state, out _))
return false;
layers = new() {
new() {
RsiPath = rsi.Path.ToString(),
State = state,
Scale = ent.Comp.Scale
}
};
return true;
}
/// <summary>
/// Checks the given Clothing for species-specific layers.
/// Adjusts <paramref name="layers"/> to append "-SPECIES" per layer if that state exists,
/// where SPECIES is given in <paramref name="speciesId"/>.
/// </summary>
/// <remarks>
/// Useful for avoiding YAML redundancy with species-specific ClothingVisuals.
/// </remarks>
private void SetSpeciesSpecificLayers(Entity<ClothingComponent, SpriteComponent?> ent, string speciesId, List<(string, PrototypeLayerData)> layers)
{
var clothing = ent.Comp1;
var sprite = ent.Comp2;
// Find fallback RSI
RSI? baseRSI;
if (clothing.RsiPath != null)
baseRSI = _cache.GetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / clothing.RsiPath).RSI;
else
baseRSI = sprite?.BaseRSI;
// For each layer, check that a species-specific version exists.
foreach (var tuple in layers)
{
var layer = tuple.Item2;
// Empty or species-specific layer, nothing to do.
if (layer.State == null || layer.State.EndsWith(speciesId))
continue;
// Use a path for the layer if specified, otherwise use the default.
RSI? rsi;
if (layer.RsiPath != null && _cache.TryGetResource<RSIResource>(layer.RsiPath, out var rsiResource))
rsi = rsiResource.RSI;
else
rsi = baseRSI;
// Lookup your state.
if (rsi != null)
{
var speciesState = $"{layer.State}-{speciesId}";
if (rsi.TryGetState(speciesState, out _))
layer.State = speciesState;
}
}
}
protected override void OnGotEquipped(EntityUid uid, ClothingComponent component, GotEquippedEvent args)
{
base.OnGotEquipped(uid, component, args);
RenderEquipment(args.EquipTarget, uid, args.Slot, clothingComponent: component);
}
private void RenderEquipment(EntityUid equipee, EntityUid equipment, string slot,
InventoryComponent? inventory = null, SpriteComponent? sprite = null, ClothingComponent? clothingComponent = null,
InventorySlotsComponent? inventorySlots = null)
{
if (!Resolve(equipee, ref inventory, ref sprite, ref inventorySlots) ||
!Resolve(equipment, ref clothingComponent, false))
{
return;
}
if (!_inventorySystem.TryGetSlot(equipee, slot, out var slotDef, inventory))
return;
// Remove old layers. We could also just set them to invisible, but as items may add arbitrary layers, this
// may eventually bloat the player with lots of invisible layers.
if (inventorySlots.VisualLayerKeys.TryGetValue(slot, out var revealedLayers))
{
foreach (var key in revealedLayers)
{
_sprite.RemoveLayer((equipee, sprite), key);
}
revealedLayers.Clear();
}
else
{
revealedLayers = new();
inventorySlots.VisualLayerKeys[slot] = revealedLayers;
}
// Cache the clothing sprite, used later.
_spriteQuery.TryComp(equipment, out var clothingSprite);
var ev = new GetEquipmentVisualsEvent(equipee, slot);
RaiseLocalEvent(equipment, ev);
if (ev.Layers.Count == 0)
{
RaiseLocalEvent(equipment, new EquipmentVisualsUpdatedEvent(equipee, slot, revealedLayers), true);
return;
}
if (!string.IsNullOrEmpty(inventory.SpeciesId))
SetSpeciesSpecificLayers((equipment, clothingComponent, clothingSprite), inventory.SpeciesId, ev.Layers);
// temporary, until layer draw depths get added. Basically: a layer with the key "slot" is being used as a
// bookmark to determine where in the list of layers we should insert the clothing layers.
var slotLayerExists = _sprite.LayerMapTryGet((equipee, sprite), slot, out var index, false);
// Select displacement maps
var displacementData = inventory.Displacements.GetValueOrDefault(slot); //Default unsexed map
var equipeeSex = CompOrNull<HumanoidProfileComponent>(equipee)?.Sex;
if (equipeeSex != null)
{
switch (equipeeSex)
{
case Sex.Male:
if (inventory.MaleDisplacements.Count > 0)
displacementData = inventory.MaleDisplacements.GetValueOrDefault(slot);
break;
case Sex.Female:
if (inventory.FemaleDisplacements.Count > 0)
displacementData = inventory.FemaleDisplacements.GetValueOrDefault(slot);
break;
}
}
// add the new layers
foreach (var (key, layerData) in ev.Layers)
{
if (!revealedLayers.Add(key))
{
Log.Warning($"Duplicate key for clothing visuals: {key}. Are multiple components attempting to modify the same layer? Equipment: {ToPrettyString(equipment)}");
continue;
}
if (slotLayerExists)
{
index++;
// note that every insertion requires reshuffling & remapping all the existing layers.
_sprite.AddBlankLayer((equipee, sprite), index);
_sprite.LayerMapSet((equipee, sprite), key, index);
if (layerData.Color != null)
_sprite.LayerSetColor((equipee, sprite), key, layerData.Color.Value);
if (layerData.Scale != null)
_sprite.LayerSetScale((equipee, sprite), key, layerData.Scale.Value);
}
else
index = _sprite.LayerMapReserve((equipee, sprite), key);
if (sprite[index] is not Layer layer)
continue;
// In case no RSI is given, use the item's base RSI as a default. This cuts down on a lot of unnecessary yaml entries.
if (layerData.RsiPath == null
&& layerData.TexturePath == null
&& layer.RSI == null
&& clothingSprite != null)
{
_sprite.LayerSetRsi(layer, clothingSprite.BaseRSI);
}
_sprite.LayerSetData((equipee, sprite), index, layerData);
_sprite.LayerSetOffset(layer, layer.Offset + slotDef.Offset);
if (displacementData is not null)
{
//Checking that the state is not tied to the current race. In this case we don't need to use the displacement maps.
if (layerData.State is not null && inventory.SpeciesId is not null && layerData.State.EndsWith(inventory.SpeciesId))
continue;
if (_displacement.TryAddDisplacement(displacementData, (equipee, sprite), index, key, out var displacementKey))
{
revealedLayers.Add(displacementKey);
index++;
}
}
}
RaiseLocalEvent(equipment, new EquipmentVisualsUpdatedEvent(equipee, slot, revealedLayers), true);
}
#endregion Internal
}