Stacked sprite visualizer (#3096)

* Add Stack Visualizer

* Add cigarette pack resources

Adds transparent layers for visualizing cigarettes

* Add Bag Open/Close Visualizer

So storage opened in inventory can have different icons when opened
or closed.

* Create a component that only enumerates single item

Used for creating stuff like matchbox, or cigarettes. As a bonus.
It will only update stack visualizer for that particullar item.

* Refactoring stuff

* Fix other usage of stack in Resources

* Add docs

* Apply suggestions from code review

Apply metalgearsloth suggestions

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>

* Applied suggestions from metalgearsloth

* Changed SingleItemStorageComponent to StorageCounterComponent

Difference. New component doesn't spawn items, merely counts them.

* Refactored StackVisualizer

* Fix breakage with master

* Update Resources/Prototypes/Entities/Objects/Consumable/fancy.yml

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>

* Update with MGS suggestions

Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
This commit is contained in:
Ygg01
2021-02-17 14:02:36 +01:00
committed by GitHub
parent 83f102ea75
commit 55d65889ae
20 changed files with 655 additions and 134 deletions

View File

@@ -1,6 +1,9 @@
using Content.Client.UserInterface.Stylesheets;
#nullable enable
using Content.Client.UserInterface.Stylesheets;
using Content.Client.Utility;
using Content.Shared.GameObjects.Components;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.GameObjects;
@@ -15,6 +18,7 @@ namespace Content.Client.GameObjects.Components
public class StackComponent : SharedStackComponent, IItemStatus
{
[ViewVariables(VVAccess.ReadWrite)] private bool _uiUpdateNeeded;
[ComponentDependency] private readonly AppearanceComponent? _appearanceComponent = default!;
public Control MakeControl() => new StatusControl(this);
@@ -23,12 +27,30 @@ namespace Content.Client.GameObjects.Components
get => base.Count;
set
{
var valueChanged = value != Count;
base.Count = value;
if (valueChanged)
{
_appearanceComponent?.SetData(StackVisuals.Actual, Count);
}
_uiUpdateNeeded = true;
}
}
public override void Initialize()
{
base.Initialize();
if (!Owner.Deleted)
{
_appearanceComponent?.SetData(StackVisuals.MaxCount, MaxCount);
_appearanceComponent?.SetData(StackVisuals.Hide, false);
}
}
private sealed class StatusControl : Control
{
private readonly StackComponent _parent;

View File

@@ -0,0 +1,174 @@
#nullable enable
using System.Collections.Generic;
using Content.Shared.GameObjects.Components;
using Content.Shared.Utility;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.Utility;
using YamlDotNet.RepresentationModel;
namespace Content.Client.GameObjects.Components
{
/// <summary>
/// Visualizer for items that come in stacks and have different appearance
/// depending on the size of the stack. Visualizer can work by switching between different
/// icons in <c>_spriteLayers</c> or if the sprite layers are supposed to be composed as transparent layers.
/// The former behavior is default and the latter behavior can be defined in prototypes.
///
/// <example>
/// <para>To define a Stack Visualizer prototype insert the following
/// snippet (you can skip Appearance if already defined)
/// </para>
/// <code>
/// - type: Appearance
/// visuals:
/// - type: StackVisualizer
/// stackLayers:
/// - goldbar_10
/// - goldbar_20
/// - goldbar_30
/// </code>
/// </example>
/// <example>
/// <para>Defining a stack visualizer with composable transparent layers</para>
/// <code>
/// - type: StackVisualizer
/// composite: true
/// stackLayers:
/// - cigarette_1
/// - cigarette_2
/// - cigarette_3
/// - cigarette_4
/// - cigarette_5
/// - cigarette_6
/// </code>
/// </example>
/// <seealso cref="_spriteLayers"/>
/// </summary>
[UsedImplicitly]
public class StackVisualizer : AppearanceVisualizer
{
/// <summary>
/// Default IconLayer stack.
/// </summary>
private const int IconLayer = 0;
/// <summary>
/// Sprite layers used in stack visualizer. Sprites first in layer correspond to lower stack states
/// e.g. <code>_spriteLayers[0]</code> is lower stack level than <code>_spriteLayers[1]</code>.
/// </summary>
private readonly List<string> _spriteLayers = new();
/// <summary>
/// Determines if the visualizer uses composite or non-composite layers for icons. Defaults to false.
///
/// <list type="bullet">
/// <item>
/// <description>false: they are opaque and mutually exclusive (e.g. sprites in a wire coil). <b>Default value</b></description>
/// </item>
/// <item>
/// <description>true: they are transparent and thus layered one over another in ascending order first</description>
/// </item>
/// </list>
///
/// </summary>
private bool _isComposite;
public override void LoadData(YamlMappingNode mapping)
{
base.LoadData(mapping);
if (mapping.TryGetNode<YamlSequenceNode>("stackLayers", out var spriteSequenceNode))
{
foreach (var yamlNode in spriteSequenceNode)
{
_spriteLayers.Add(((YamlScalarNode) yamlNode).Value!);
}
}
if (mapping.TryGetNode<YamlScalarNode>("composite", out var transparent))
{
_isComposite = transparent.AsBool();
}
}
public override void InitializeEntity(IEntity entity)
{
base.InitializeEntity(entity);
if (_isComposite
&& _spriteLayers.Count > 0
&& entity.TryGetComponent<ISpriteComponent>(out var spriteComponent))
{
foreach (var sprite in _spriteLayers)
{
var rsiPath = spriteComponent.BaseRSI!.Path!;
spriteComponent.LayerMapReserveBlank(sprite);
spriteComponent.LayerSetSprite(sprite, new SpriteSpecifier.Rsi(rsiPath, sprite));
spriteComponent.LayerSetVisible(sprite, false);
}
}
}
public override void OnChangeData(AppearanceComponent component)
{
base.OnChangeData(component);
if (component.Owner.TryGetComponent<ISpriteComponent>(out var spriteComponent))
{
if (_isComposite)
{
ProcessCompositeSprites(component, spriteComponent);
}
else
{
ProcessOpaqueSprites(component, spriteComponent);
}
}
}
private void ProcessOpaqueSprites(AppearanceComponent component, ISpriteComponent spriteComponent)
{
// Skip processing if no actual
if (!component.TryGetData<int>(StackVisuals.Actual, out var actual)) return;
if (!component.TryGetData<int>(StackVisuals.MaxCount, out var maxCount))
{
maxCount = _spriteLayers.Count;
}
var activeLayer = ContentHelpers.RoundToNearestLevels(actual, maxCount, _spriteLayers.Count - 1);
spriteComponent.LayerSetState(IconLayer, _spriteLayers[activeLayer]);
}
private void ProcessCompositeSprites(AppearanceComponent component, ISpriteComponent spriteComponent)
{
// If hidden, don't render any sprites
if (!component.TryGetData<bool>(StackVisuals.Hide, out var hide)
|| hide)
{
foreach (var transparentSprite in _spriteLayers)
{
spriteComponent.LayerSetVisible(transparentSprite, false);
}
return;
}
// Skip processing if no actual/maxCount
if (!component.TryGetData<int>(StackVisuals.Actual, out var actual)) return;
if (!component.TryGetData<int>(StackVisuals.MaxCount, out var maxCount))
{
maxCount = _spriteLayers.Count;
}
var activeTill = ContentHelpers.RoundToNearestLevels(actual, maxCount, _spriteLayers.Count);
for (var i = 0; i < _spriteLayers.Count; i++)
{
spriteComponent.LayerSetVisible(_spriteLayers[i], i < activeTill);
}
}
}
}

View File

@@ -0,0 +1,68 @@
#nullable enable
using Content.Shared.GameObjects.Components;
using JetBrains.Annotations;
using Robust.Client.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Utility;
using YamlDotNet.RepresentationModel;
namespace Content.Client.GameObjects.Components.Storage
{
[UsedImplicitly]
public class BagOpenCloseVisualizer : AppearanceVisualizer
{
private const string OpenIcon = "openIcon";
private string? _openIcon;
public override void LoadData(YamlMappingNode node)
{
base.LoadData(node);
if (node.TryGetNode<YamlScalarNode>(OpenIcon, out var openIconNode))
{
_openIcon = openIconNode.Value;
}
else
{
Logger.Warning("BagOpenCloseVisualizer is useless with no `openIcon`");
}
}
public override void InitializeEntity(IEntity entity)
{
base.InitializeEntity(entity);
if (_openIcon != null && entity.TryGetComponent<SpriteComponent>(out var spriteComponent))
{
var rsiPath = spriteComponent.BaseRSI!.Path!;
spriteComponent.LayerMapReserveBlank(OpenIcon);
spriteComponent.LayerSetSprite(OpenIcon, new SpriteSpecifier.Rsi(rsiPath, _openIcon));
spriteComponent.LayerSetVisible(OpenIcon, false);
}
}
public override void OnChangeData(AppearanceComponent component)
{
base.OnChangeData(component);
if (_openIcon != null
&& component.Owner.TryGetComponent<SpriteComponent>(out var spriteComponent))
{
if (component.TryGetData<SharedBagState>(SharedBagOpenVisuals.BagState, out var bagState))
{
switch (bagState)
{
case SharedBagState.Open:
spriteComponent.LayerSetVisible(OpenIcon, true);
break;
default:
spriteComponent.LayerSetVisible(OpenIcon, false);
break;
}
}
}
}
}
}

View File

@@ -1,8 +1,9 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.Animations;
using Content.Client.GameObjects.Components.Items;
using Content.Shared.GameObjects.Components;
using Content.Shared.GameObjects.Components.Storage;
using Content.Shared.Interfaces.GameObjects.Components;
using Robust.Client.GameObjects;
@@ -29,9 +30,18 @@ namespace Content.Client.GameObjects.Components.Storage
private int StorageSizeUsed;
private int StorageCapacityMax;
private StorageWindow Window;
private SharedBagState _bagState;
public override IReadOnlyList<IEntity> StoredEntities => _storedEntities;
public override void Initialize()
{
base.Initialize();
// Hide stackVisualizer on start
_bagState = SharedBagState.Close;
}
public override void OnAdd()
{
base.OnAdd();
@@ -69,12 +79,15 @@ namespace Content.Client.GameObjects.Components.Storage
//Updates what we are storing for the UI
case StorageHeldItemsMessage msg:
HandleStorageMessage(msg);
ChangeStorageVisualization(_bagState);
break;
//Opens the UI
case OpenStorageUIMessage _:
ChangeStorageVisualization(SharedBagState.Open);
ToggleUI();
break;
case CloseStorageUIMessage _:
ChangeStorageVisualization(SharedBagState.Close);
CloseUI();
break;
case AnimateInsertingEntitiesMessage msg:
@@ -119,6 +132,7 @@ namespace Content.Client.GameObjects.Components.Storage
private void ToggleUI()
{
if (Window.IsOpen)
Window.Close();
else
Window.Open();
@@ -129,6 +143,16 @@ namespace Content.Client.GameObjects.Components.Storage
Window.Close();
}
private void ChangeStorageVisualization(SharedBagState state)
{
_bagState = state;
if (Owner.TryGetComponent<AppearanceComponent>(out var appearanceComponent))
{
appearanceComponent.SetData(SharedBagOpenVisuals.BagState, state);
appearanceComponent.SetData(StackVisuals.Hide, state == SharedBagState.Close);
}
}
/// <summary>
/// Function for clicking one of the stored entity buttons in the UI, tells server to remove that entity
/// </summary>

View File

@@ -164,6 +164,7 @@ namespace Content.Client
"Firelock",
"AtmosPlaque",
"Spillable",
"StorageCounter",
"SpaceVillainArcade",
"Flammable",
"CreamPie",

View File

@@ -0,0 +1,86 @@
#nullable enable
using System.Collections.Generic;
using Content.Shared.GameObjects.Components;
using Content.Shared.GameObjects.Components.Tag;
using Robust.Server.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.Log;
using Robust.Shared.Serialization;
namespace Content.Server.GameObjects.Components.Items.Storage
{
/// <summary>
/// Storage that spawns and counts a single item.
/// Usually used for things like matchboxes, cigarette packs,
/// cigar cases etc.
/// </summary>
/// <code>
/// - type: StorageCounter
/// amount: 6 # Note: this field can be omitted
/// countTag: Cigarette # Note: field doesn't point to entity Id, but its tag
/// </code>
[RegisterComponent]
public class StorageCounterComponent : Component
{
private string? _countTag;
private int? _maxAmount;
/// <summary>
/// Single item storage component usually have an attached StackedVisualizer.
/// </summary>
[ComponentDependency] private readonly AppearanceComponent? _appearanceComponent = default;
public override string Name => "StorageCounter";
public override void ExposeData(ObjectSerializer serializer)
{
base.ExposeData(serializer);
serializer.DataField(ref _countTag, "countTag", null);
if (_countTag == null)
{
Logger.Warning("StorageCounterComponent without a `countTag` is useless");
}
serializer.DataField(ref _maxAmount, "amount", null);
}
public override void HandleMessage(ComponentMessage message, IComponent? component)
{
base.HandleMessage(message, component);
if (_appearanceComponent != null)
{
switch (message)
{
case ContainerContentsModifiedMessage msg:
var actual = Count(msg.Container.ContainedEntities);
_appearanceComponent.SetData(StackVisuals.Actual, actual);
if (_maxAmount != null)
{
_appearanceComponent.SetData(StackVisuals.MaxCount, _maxAmount);
}
break;
}
}
}
private int Count(IReadOnlyList<IEntity> containerContainedEntities)
{
var count = 0;
if (_countTag != null)
{
foreach (var entity in containerContainedEntities)
{
if (entity.HasTag(_countTag))
{
count++;
}
}
}
return count;
}
}
}

View File

@@ -0,0 +1,19 @@
#nullable enable
using System;
using Robust.Shared.Serialization;
namespace Content.Shared.GameObjects.Components
{
[Serializable, NetSerializable]
public enum SharedBagOpenVisuals : byte
{
BagState,
}
[Serializable, NetSerializable]
public enum SharedBagState : byte
{
Open,
Close,
}
}

View File

@@ -0,0 +1,21 @@
#nullable enable
using System;
using Robust.Shared.Serialization;
namespace Content.Shared.GameObjects.Components
{
[Serializable, NetSerializable]
public enum StackVisuals : byte
{
/// <summary>
/// The amount of elements in the stack
/// </summary>
Actual,
/// <summary>
/// The total amount of elements in the stack. If unspecified, the visualizer assumes
/// its
/// </summary>
MaxCount,
Hide
}
}

View File

@@ -25,14 +25,17 @@ namespace Content.Shared.Utility
{
throw new ArgumentException("Levels must be greater than 0.", nameof(levels));
}
if (actual >= max)
{
return levels - 1;
}
if (actual <= 0)
{
return 0;
}
var toOne = actual / max;
double threshold;
if (levels % 2 == 0)
@@ -49,11 +52,11 @@ namespace Content.Shared.Utility
var preround = toOne * (levels - 1);
if (toOne <= threshold || levels <= 2)
{
return (int)Math.Ceiling(preround);
return (int) Math.Ceiling(preround);
}
else
{
return (int)Math.Floor(preround);
return (int) Math.Floor(preround);
}
}
@@ -81,28 +84,18 @@ namespace Content.Shared.Utility
{
throw new ArgumentException("Levels must be greater than 1.", nameof(levels));
}
if (actual >= max)
{
return levels;
}
if (actual <= 0)
{
return 0;
}
double step = max / levels;
int nearest = 0;
double nearestDiff = actual;
for (var i = 1; i <= levels; i++)
{
var diff = Math.Abs(actual - i * step);
if (diff < nearestDiff)
{
nearestDiff = diff;
nearest = i;
}
}
return nearest;
return (int) Math.Round(actual / max * levels, MidpointRounding.AwayFromZero);
}
}
}

View File

@@ -9,42 +9,79 @@ namespace Content.Tests.Shared.Utility
[TestOf(typeof(ContentHelpers))]
public class ContentHelpers_Test
{
public static readonly IEnumerable<(double val, double max, int levels, int expected)> TestData = new(double, double, int, int)[]
public static readonly IEnumerable<(double val, double max, int levels, int expected)> TestData =
new (double, double, int, int)[]
{
// Testing odd level counts. These are easy.
(-1, 10, 5, 0),
( 0, 10, 5, 0),
( 0.01f, 10, 5, 1),
( 1, 10, 5, 1),
( 2, 10, 5, 1),
( 2.5f, 10, 5, 1),
( 2.51f, 10, 5, 2),
( 3, 10, 5, 2),
( 4, 10, 5, 2),
( 5, 10, 5, 2),
( 6, 10, 5, 2),
( 7, 10, 5, 2),
( 7.49f, 10, 5, 2),
( 7.5f, 10, 5, 3),
( 8, 10, 5, 3),
( 9, 10, 5, 3),
(0, 10, 5, 0),
(0.01f, 10, 5, 1),
(1, 10, 5, 1),
(2, 10, 5, 1),
(2.5f, 10, 5, 1),
(2.51f, 10, 5, 2),
(3, 10, 5, 2),
(4, 10, 5, 2),
(5, 10, 5, 2),
(6, 10, 5, 2),
(7, 10, 5, 2),
(7.49f, 10, 5, 2),
(7.5f, 10, 5, 3),
(8, 10, 5, 3),
(9, 10, 5, 3),
(10, 10, 5, 4),
(11, 10, 5, 4),
// Even level counts though..
( 1, 10, 6, 1),
( 2, 10, 6, 1),
( 3, 10, 6, 2),
( 4, 10, 6, 2),
( 5, 10, 6, 2),
( 6, 10, 6, 3),
( 7, 10, 6, 3),
( 8, 10, 6, 4),
( 9, 10, 6, 4),
(1, 10, 6, 1),
(2, 10, 6, 1),
(3, 10, 6, 2),
(4, 10, 6, 2),
(5, 10, 6, 2),
(6, 10, 6, 3),
(7, 10, 6, 3),
(8, 10, 6, 4),
(9, 10, 6, 4),
(10, 10, 6, 5),
};
public static readonly IEnumerable<(double val, double max, int levels, int expected)> TestNear =
new (double, double, int, int)[]
{
// Testing odd counts
(0, 5, 2, 0),
(1, 5, 2, 0),
(2, 5, 2, 1),
(3, 5, 2, 1),
(4, 5, 2, 2),
(5, 5, 2, 2),
// Testing even counts
(0, 6, 5, 0),
(1, 6, 5, 1),
(2, 6, 5, 2),
(3, 6, 5, 3),
(4, 6, 5, 3),
(5, 6, 5, 4),
(6, 6, 5, 5),
// Testing transparency disable use case
(0, 6, 6, 0),
(1, 6, 6, 1),
(2, 6, 6, 2),
(3, 6, 6, 3),
(4, 6, 6, 4),
(5, 6, 6, 5),
(6, 6, 6, 6),
// Testing edge cases
(0.1, 6, 5, 0),
(-32, 6, 5, 0),
(2.4, 6, 5, 2),
(2.5, 6, 5, 2),
(320, 6, 5, 5),
};
[Parallelizable]
[Test]
public void Test([ValueSource(nameof(TestData))] (double val, double max, int levels, int expected) data)
@@ -52,5 +89,13 @@ namespace Content.Tests.Shared.Utility
(double val, double max, int levels, int expected) = data;
Assert.That(ContentHelpers.RoundToLevels(val, max, levels), Is.EqualTo(expected));
}
[Parallelizable]
[Test]
public void TestNearest([ValueSource(nameof(TestNear))] (double val, double max, int size, int expected) data)
{
(double val, double max, int size, int expected) = data;
Assert.That(ContentHelpers.RoundToNearestLevels(val, max, size), Is.EqualTo(expected));
}
}
}

View File

@@ -1,4 +1,6 @@
- type: entity
- type: Tag
id: Cigarette
- type: entity
name: "Base Cigarette"
id: BaseCigarette
parent: BaseItem
@@ -13,6 +15,9 @@
Slots: [ mask ]
HeldPrefix: unlit
size: 1
- type: Tag
tags:
- Cigarette
- type: Smoking
duration: 30
- type: Appearance
@@ -33,10 +38,25 @@
components:
- type: Sprite
sprite: Objects/Consumable/Fancy/cigarettes.rsi
netsync: false
layers:
- state: cig
- type: StorageFill
contents:
- name: Cigarette
amount: 6
- type: StorageCounter
countTag: Cigarette
- type: Appearance
visuals:
- type: BagOpenCloseVisualizer
openIcon: cig_open
- type: StackVisualizer
composite: true
stackLayers:
- cigarette_1
- cigarette_2
- cigarette_3
- cigarette_4
- cigarette_5
- cigarette_6

View File

@@ -143,6 +143,14 @@
- type: Sprite
sprite: Objects/Materials/materials.rsi
state: goldbar_30
netsync: false
- type: Appearance
visuals:
- type: StackVisualizer
stackLayers:
- goldbar_10
- goldbar_20
- goldbar_30
- type: entity
id: GoldStack1

View File

@@ -1,4 +1,3 @@
# If you're looking at the rsi for this file, you'll probably be confused why
# I didn't just use an alpha for most of this stuff. Well icons don't have the
# ability to have applied colors yet in GUIs. -Swept
@@ -14,11 +13,13 @@
stacktype: enum.StackType.Cable
- type: Sprite
sprite: Objects/Tools/cables.rsi
netsync: false
- type: Item
sprite: Objects/Tools/cables.rsi
- type: WirePlacer
- type: Clickable
- type: entity
id: HVWireStack
parent: CableStack
@@ -35,6 +36,13 @@
- type: WirePlacer
wirePrototypeID: HVWire
blockingWireType: HighVoltage
- type: Appearance
visuals:
- type: StackVisualizer
stackLayers:
- coilhv-10
- coilhv-20
- coilhv-30
- type: entity
parent: HVWireStack
@@ -63,6 +71,13 @@
- type: WirePlacer
wirePrototypeID: ApcExtensionCable
blockingWireType: Apc
- type: Appearance
visuals:
- type: StackVisualizer
stackLayers:
- coillv-10
- coillv-20
- coillv-30
- type: entity
parent: ApcExtensionCableStack
@@ -92,6 +107,13 @@
- type: WirePlacer
wirePrototypeID: MVWire
blockingWireType: MediumVoltage
- type: Appearance
visuals:
- type: StackVisualizer
stackLayers:
- coilmv-10
- coilmv-20
- coilmv-30
- type: entity
parent: MVWireStack

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

View File

@@ -15,6 +15,24 @@
},
{
"name": "cig_open"
},
{
"name": "cigarette_1"
},
{
"name": "cigarette_2"
},
{
"name": "cigarette_3"
},
{
"name": "cigarette_4"
},
{
"name": "cigarette_5"
},
{
"name": "cigarette_6"
}
]
}