]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Make more objects spray paintable (Reviving #31328) (#37341)
authorWhatstone <166147148+whatston3@users.noreply.github.com>
Fri, 11 Jul 2025 00:36:57 +0000 (20:36 -0400)
committerGitHub <noreply@github.com>
Fri, 11 Jul 2025 00:36:57 +0000 (20:36 -0400)
* PaintableAirlockComponent and AirlockGroupPrototype have been replaced

* Slightly redesigned SprayPainterSystem for greater versatility

* Added handling of changes to the appearance of doors and storages

* PaintableGroup prototypes have been created

* Generating tabs with styles in the UI

* Fix error with undiscovered layer

* Slight improvement

* Removed unnecessary property

* The category for `PaintableGroup` was allocated to a separate prototype so that the engine itself would check if the category existed

* Added canisters, but repainting doesn't work

* Added localization to styles

* Fix sprite changing

* Added the ability to paint canisters

* slight ui improvement

* Fix yamllinter errors

* Fix test

* The UI now remembers which tab was open

* Fix build (?)

* Rename

* Charges have been added to the spray painter

* Added a charge texture for the spray painter

* Now spray painter can paint decals

* Increased number of charges

* Spawning dummy objects has been replaced by PrototypeManager

* added a signature about the painting of the object

* fix

* Code commenting

* Fix upstream

* Update Content.Shared/SprayPainter/Components/SprayPainterAmmo.cs

Co-authored-by: pathetic meowmeow <uhhadd@gmail.com>
* review

* Now decals can only be painted if the corresponding tab in the menu is open.

* Fixed a bug with pipe and decal tabs not being remembered

* Update EntityStorageVisualizerSystem.cs

* record

* loc

* Cleanup

* Revert electrified visuals

* more cleanup, fix charges, del ammo4

* no empty file, remove meta component

* closet exceptions, storage visualizer fixes

* enable/disable decal through alt-verb

* Fix missed merge conflicts

* fix snap offset, button event handlers

* simpler order, fix snap loc string

* Remove PaintableViz.BaseRSI, no decal item, A-Z

* State-respecting UI, BUI updates, FTL fixes

* revert DecalPlacerWindow changes

* revert unwanted changes, cleanup function order

* Limit SprayPainterAmmo write access to AmmoSystem

* Remove PaintedSystem

* spray paint ammo lathe recipe, youtool listing

* category as a list, groups as subtabs

* Restore inhand copyright in meta.json

* empty spray painter, recipe produces an empty one

* allow alpha on spray painter decals

* add comments

* paintable wall lockers

* Restrict painting more objects

* Suggested event changes, event cleanup

* component comments, fix ammo inhands

* uncleanable decals, dirty styles on mapinit

* organize paintables, separate emergency/closet grp

* fix categories newline at EOF

* airlock group whitespace cleanup

* realphabetize

* Clean up EntityStorageViz merge conflict markers

* Apply requested changes

* Apply suggestions from sowelipililimute's review

Co-authored-by: pathetic meowmeow <uhhadd@gmail.com>
* betrayal most foul

* Remove members from EntityPaintedEvent

* No emerg. group, steelsec to secure, locker/closet

* Enable repainting the medical wall locker

* comments, no flags on PaintableVisuals

* Remove locked variants from closets/wall closets

* removable decals

* off value consistency

* can't paint away those bones

* fix precedence

* Remove AirlockDepartment, AirlockGroup protos

Both unused.

* whitelist consistency re: ammo component

* add standing emergency closet styles

* alphabetize the spray painter listings

---------

Co-authored-by: Ertanic <black.ikra.14@gmail.com>
Co-authored-by: Эдуард <36124833+Ertanic@users.noreply.github.com>
Co-authored-by: pathetic meowmeow <uhhadd@gmail.com>
53 files changed:
Content.Client/Atmos/EntitySystems/GasCanisterAppearanceSystem.cs [new file with mode: 0644]
Content.Client/Doors/DoorSystem.cs
Content.Client/SprayPainter/SprayPainterSystem.cs
Content.Client/SprayPainter/UI/SprayPainterBoundUserInterface.cs
Content.Client/SprayPainter/UI/SprayPainterDecals.xaml [new file with mode: 0644]
Content.Client/SprayPainter/UI/SprayPainterDecals.xaml.cs [new file with mode: 0644]
Content.Client/SprayPainter/UI/SprayPainterGroup.xaml [new file with mode: 0644]
Content.Client/SprayPainter/UI/SprayPainterGroup.xaml.cs [new file with mode: 0644]
Content.Client/SprayPainter/UI/SprayPainterWindow.xaml
Content.Client/SprayPainter/UI/SprayPainterWindow.xaml.cs
Content.Client/Storage/Visualizers/EntityStorageVisualizerSystem.cs
Content.Server/SprayPainter/SprayPainterSystem.cs
Content.Shared/Doors/Components/DoorComponent.cs
Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs [deleted file]
Content.Shared/SprayPainter/Components/PaintableComponent.cs [new file with mode: 0644]
Content.Shared/SprayPainter/Components/PaintedComponent.cs [new file with mode: 0644]
Content.Shared/SprayPainter/Components/SprayPainterAmmo.cs [new file with mode: 0644]
Content.Shared/SprayPainter/Components/SprayPainterComponent.cs
Content.Shared/SprayPainter/Prototypes/AirlockDepartmentsPrototype.cs [deleted file]
Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs [deleted file]
Content.Shared/SprayPainter/Prototypes/PaintableGroupCategoryPrototype.cs [new file with mode: 0644]
Content.Shared/SprayPainter/Prototypes/PaintableGroupPrototype.cs [new file with mode: 0644]
Content.Shared/SprayPainter/SharedSprayPainterSystem.cs
Content.Shared/SprayPainter/SprayPainterAmmoSystem.cs [new file with mode: 0644]
Content.Shared/SprayPainter/SprayPainterEvents.cs
Resources/Locale/en-US/engineer-painter/engineer-painter.ftl [deleted file]
Resources/Locale/en-US/spray-painter/spray-painter.ftl [new file with mode: 0644]
Resources/Prototypes/Catalog/VendingMachines/Inventories/youtool.yml
Resources/Prototypes/Entities/Clothing/Belt/belts.yml
Resources/Prototypes/Entities/Objects/Tools/spray_painter.yml
Resources/Prototypes/Entities/Structures/Doors/Airlocks/airlocks.yml
Resources/Prototypes/Entities/Structures/Doors/Airlocks/base_structureairlocks.yml
Resources/Prototypes/Entities/Structures/Doors/Airlocks/clockwork.yml
Resources/Prototypes/Entities/Structures/Doors/Airlocks/external.yml
Resources/Prototypes/Entities/Structures/Doors/Airlocks/shuttle.yml
Resources/Prototypes/Entities/Structures/Doors/airlock_groups.yml [deleted file]
Resources/Prototypes/Entities/Structures/Storage/Canisters/gas_canisters.yml
Resources/Prototypes/Entities/Structures/Storage/Closets/Lockers/base_structurelockers.yml
Resources/Prototypes/Entities/Structures/Storage/Closets/Lockers/lockers.yml
Resources/Prototypes/Entities/Structures/Storage/Closets/base_structureclosets.yml
Resources/Prototypes/Entities/Structures/Storage/Crates/base_structurecrates.yml
Resources/Prototypes/Entities/Structures/Storage/Crates/crates.yml
Resources/Prototypes/Paintables/airlock_groups.yml [new file with mode: 0644]
Resources/Prototypes/Paintables/canister_groups.yml [new file with mode: 0644]
Resources/Prototypes/Paintables/categories.yml [new file with mode: 0644]
Resources/Prototypes/Paintables/crate_groups.yml [new file with mode: 0644]
Resources/Prototypes/Paintables/locker_groups.yml [new file with mode: 0644]
Resources/Prototypes/Recipes/Lathes/Packs/engineering.yml
Resources/Prototypes/Recipes/Lathes/tools.yml
Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-left.png [new file with mode: 0644]
Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-right.png [new file with mode: 0644]
Resources/Textures/Objects/Tools/spray_painter.rsi/ammo.png [new file with mode: 0644]
Resources/Textures/Objects/Tools/spray_painter.rsi/meta.json

diff --git a/Content.Client/Atmos/EntitySystems/GasCanisterAppearanceSystem.cs b/Content.Client/Atmos/EntitySystems/GasCanisterAppearanceSystem.cs
new file mode 100644 (file)
index 0000000..f16774c
--- /dev/null
@@ -0,0 +1,28 @@
+using Content.Shared.Atmos.Piping.Unary.Components;
+using Content.Shared.SprayPainter.Prototypes;
+using Robust.Client.GameObjects;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Atmos.EntitySystems;
+
+/// <summary>
+/// Used to change the appearance of gas canisters.
+/// </summary>
+public sealed class GasCanisterAppearanceSystem : VisualizerSystem<GasCanisterComponent>
+{
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+    protected override void OnAppearanceChange(EntityUid uid, GasCanisterComponent component, ref AppearanceChangeEvent args)
+    {
+        if (!AppearanceSystem.TryGetData<string>(uid, PaintableVisuals.Prototype, out var protoName, args.Component) || args.Sprite is not { } old)
+            return;
+
+        if (!_prototypeManager.HasIndex(protoName))
+            return;
+
+        // Create the given prototype and get its first layer.
+        var tempUid = Spawn(protoName);
+        SpriteSystem.LayerSetRsiState(uid, 0, SpriteSystem.LayerGetRsiState(tempUid, 0));
+        QueueDel(tempUid);
+    }
+}
index cb17cfaf213178e2c236429a002b48a8bb5ccecb..3d9a3e2a9aa503d09fcba1e88151ccbae2cedb42 100644 (file)
@@ -1,16 +1,17 @@
 using Content.Shared.Doors.Components;
 using Content.Shared.Doors.Systems;
+using Content.Shared.SprayPainter.Prototypes;
 using Robust.Client.Animations;
 using Robust.Client.GameObjects;
-using Robust.Client.ResourceManagement;
-using Robust.Shared.Serialization.TypeSerializers.Implementations;
+using Robust.Shared.Prototypes;
 
 namespace Content.Client.Doors;
 
 public sealed class DoorSystem : SharedDoorSystem
 {
     [Dependency] private readonly AnimationPlayerSystem _animationSystem = default!;
-    [Dependency] private readonly IResourceCache _resourceCache = default!;
+    [Dependency] private readonly IComponentFactory _componentFactory = default!;
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
     [Dependency] private readonly SpriteSystem _sprite = default!;
 
     public override void Initialize()
@@ -85,8 +86,8 @@ public sealed class DoorSystem : SharedDoorSystem
         if (!AppearanceSystem.TryGetData<DoorState>(entity, DoorVisuals.State, out var state, args.Component))
             state = DoorState.Closed;
 
-        if (AppearanceSystem.TryGetData<string>(entity, DoorVisuals.BaseRSI, out var baseRsi, args.Component))
-            UpdateSpriteLayers((entity.Owner, args.Sprite), baseRsi);
+        if (AppearanceSystem.TryGetData<string>(entity, PaintableVisuals.Prototype, out var prototype, args.Component))
+            UpdateSpriteLayers((entity.Owner, args.Sprite), prototype);
 
         if (_animationSystem.HasRunningAnimation(entity, DoorComponent.AnimationKey))
             _animationSystem.Stop(entity.Owner, DoorComponent.AnimationKey);
@@ -139,14 +140,14 @@ public sealed class DoorSystem : SharedDoorSystem
         }
     }
 
-    private void UpdateSpriteLayers(Entity<SpriteComponent> sprite, string baseRsi)
+    private void UpdateSpriteLayers(Entity<SpriteComponent> sprite, string targetProto)
     {
-        if (!_resourceCache.TryGetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / baseRsi, out var res))
-        {
-            Log.Error("Unable to load RSI '{0}'. Trace:\n{1}", baseRsi, Environment.StackTrace);
+        if (!_prototypeManager.TryIndex(targetProto, out var target))
+            return;
+
+        if (!target.TryGetComponent(out SpriteComponent? targetSprite, _componentFactory))
             return;
-        }
 
-        _sprite.SetBaseRsi(sprite.AsNullable(), res.RSI);
+        _sprite.SetBaseRsi(sprite.AsNullable(), targetSprite.BaseRSI);
     }
 }
index 6a1d27e98b7f998e092eb1e5c1a282529ec6d951..8f7d7f03622e798bceac0fe10a8c510516c97aae 100644 (file)
+using System.Linq;
+using Content.Client.Items;
+using Content.Client.Message;
+using Content.Client.Stylesheets;
+using Content.Shared.Decals;
 using Content.Shared.SprayPainter;
-using Robust.Client.Graphics;
-using Robust.Client.ResourceManagement;
-using Robust.Shared.Serialization.TypeSerializers.Implementations;
+using Content.Shared.SprayPainter.Components;
+using Content.Shared.SprayPainter.Prototypes;
+using Robust.Client.GameObjects;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
 using Robust.Shared.Utility;
-using System.Linq;
-using Robust.Shared.Graphics;
 
 namespace Content.Client.SprayPainter;
 
+/// <summary>
+/// Client-side spray painter functions. Caches information for spray painter windows and updates the UI to reflect component state.
+/// </summary>
 public sealed class SprayPainterSystem : SharedSprayPainterSystem
 {
-    [Dependency] private readonly IResourceCache _resourceCache = default!;
+    [Dependency] private readonly UserInterfaceSystem _ui = default!;
+
+    public List<SprayPainterDecalEntry> Decals = [];
+    public Dictionary<string, List<string>> PaintableGroupsByCategory = new();
+    public Dictionary<string, Dictionary<string, EntProtoId>> PaintableStylesByGroup = new();
+
+    public override void Initialize()
+    {
+        base.Initialize();
 
-    public List<SprayPainterEntry> Entries { get; private set; } = new();
+        Subs.ItemStatus<SprayPainterComponent>(ent => new StatusControl(ent));
+        SubscribeLocalEvent<SprayPainterComponent, AfterAutoHandleStateEvent>(OnStateUpdate);
+        SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
 
-    protected override void CacheStyles()
+        CachePrototypes();
+    }
+
+    private void OnStateUpdate(Entity<SprayPainterComponent> ent, ref AfterAutoHandleStateEvent args)
     {
-        base.CacheStyles();
+        UpdateUi(ent);
+    }
 
-        Entries.Clear();
-        foreach (var style in Styles)
+    protected override void UpdateUi(Entity<SprayPainterComponent> ent)
+    {
+        if (_ui.TryGetOpenUi(ent.Owner, SprayPainterUiKey.Key, out var bui))
+            bui.Update();
+    }
+
+    private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
+    {
+        if (!args.WasModified<PaintableGroupCategoryPrototype>() || !args.WasModified<PaintableGroupPrototype>() || !args.WasModified<DecalPrototype>())
+            return;
+
+        CachePrototypes();
+    }
+
+    private void CachePrototypes()
+    {
+        PaintableGroupsByCategory.Clear();
+        PaintableStylesByGroup.Clear();
+        foreach (var category in Proto.EnumeratePrototypes<PaintableGroupCategoryPrototype>().OrderBy(x => x.ID))
         {
-            var name = style.Name;
-            string? iconPath = Groups
-              .FindAll(x => x.StylePaths.ContainsKey(name))?
-              .MaxBy(x => x.IconPriority)?.StylePaths[name];
-            if (iconPath == null)
+            var groupList = new List<string>();
+            foreach (var groupId in category.Groups)
             {
-                Entries.Add(new SprayPainterEntry(name, null));
-                continue;
+                if (!Proto.TryIndex(groupId, out var group))
+                    continue;
+
+                groupList.Add(groupId);
+                PaintableStylesByGroup[groupId] = group.Styles;
             }
 
-            RSIResource doorRsi = _resourceCache.GetResource<RSIResource>(SpriteSpecifierSerializer.TextureRoot / new ResPath(iconPath));
-            if (!doorRsi.RSI.TryGetState("closed", out var icon))
-            {
-                Entries.Add(new SprayPainterEntry(name, null));
+            if (groupList.Count > 0)
+                PaintableGroupsByCategory[category.ID] = groupList;
+        }
+
+        Decals.Clear();
+        foreach (var decalPrototype in Proto.EnumeratePrototypes<DecalPrototype>().OrderBy(x => x.ID))
+        {
+            if (!decalPrototype.Tags.Contains("station")
+                && !decalPrototype.Tags.Contains("markings")
+                || decalPrototype.Tags.Contains("dirty"))
                 continue;
-            }
 
-            Entries.Add(new SprayPainterEntry(name, icon.Frame0));
+            Decals.Add(new SprayPainterDecalEntry(decalPrototype.ID, decalPrototype.Sprite));
         }
     }
-}
 
-public sealed class SprayPainterEntry
-{
-    public string Name;
-    public Texture? Icon;
-
-    public SprayPainterEntry(string name, Texture? icon)
+    private sealed class StatusControl : Control
     {
-        Name = name;
-        Icon = icon;
+        private readonly RichTextLabel _label;
+        private readonly Entity<SprayPainterComponent> _entity;
+        private DecalPaintMode? _lastPaintingDecals = null;
+
+        public StatusControl(Entity<SprayPainterComponent> ent)
+        {
+            _entity = ent;
+            _label = new RichTextLabel { StyleClasses = { StyleNano.StyleClassItemStatus } };
+            AddChild(_label);
+        }
+
+        protected override void FrameUpdate(FrameEventArgs args)
+        {
+            base.FrameUpdate(args);
+
+            if (_entity.Comp.DecalMode == _lastPaintingDecals)
+                return;
+
+            _lastPaintingDecals = _entity.Comp.DecalMode;
+
+            string modeLocString = _entity.Comp.DecalMode switch
+            {
+                DecalPaintMode.Add => "spray-painter-item-status-add",
+                DecalPaintMode.Remove => "spray-painter-item-status-remove",
+                _ => "spray-painter-item-status-off"
+            };
+
+            _label.SetMarkupPermissive(Robust.Shared.Localization.Loc.GetString("spray-painter-item-status-label",
+                ("mode", Robust.Shared.Localization.Loc.GetString(modeLocString))));
+        }
     }
 }
+
+/// <summary>
+/// A spray paintable decal, mapped by ID.
+/// </summary>
+public sealed record SprayPainterDecalEntry(string Name, SpriteSpecifier Sprite);
index 7d6a6cf2a5a09042fd5667c1f56bad2b211f2e35..701ec80bac87b8d453fe0b8c677ae066102d9470 100644 (file)
@@ -1,42 +1,96 @@
+using Content.Shared.Decals;
 using Content.Shared.SprayPainter;
 using Content.Shared.SprayPainter.Components;
 using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.Controls;
+using Robust.Shared.Prototypes;
 
 namespace Content.Client.SprayPainter.UI;
 
-public sealed class SprayPainterBoundUserInterface : BoundUserInterface
+/// <summary>
+/// A BUI for a spray painter. Allows selecting pipe colours, decals, and paintable object types sorted by category.
+/// </summary>
+public sealed class SprayPainterBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
 {
     [ViewVariables]
     private SprayPainterWindow? _window;
 
-    public SprayPainterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+    protected override void Open()
     {
+        base.Open();
+
+        if (_window == null)
+        {
+            _window = this.CreateWindow<SprayPainterWindow>();
+
+            _window.OnSpritePicked += OnSpritePicked;
+            _window.OnSetPipeColor += OnSetPipeColor;
+            _window.OnTabChanged += OnTabChanged;
+            _window.OnDecalChanged += OnDecalChanged;
+            _window.OnDecalColorChanged += OnDecalColorChanged;
+            _window.OnDecalAngleChanged += OnDecalAngleChanged;
+            _window.OnDecalSnapChanged += OnDecalSnapChanged;
+        }
+
+        var sprayPainter = EntMan.System<SprayPainterSystem>();
+        _window.PopulateCategories(sprayPainter.PaintableStylesByGroup, sprayPainter.PaintableGroupsByCategory, sprayPainter.Decals);
+        Update();
+
+        if (EntMan.TryGetComponent(Owner, out SprayPainterComponent? sprayPainterComp))
+            _window.SetSelectedTab(sprayPainterComp.SelectedTab);
     }
 
-    protected override void Open()
+    public override void Update()
     {
-        base.Open();
+        if (_window == null)
+            return;
 
-        _window = this.CreateWindow<SprayPainterWindow>();
+        if (!EntMan.TryGetComponent(Owner, out SprayPainterComponent? sprayPainter))
+            return;
 
-        _window.OnSpritePicked = OnSpritePicked;
-        _window.OnColorPicked = OnColorPicked;
+        _window.PopulateColors(sprayPainter.ColorPalette);
+        if (sprayPainter.PickedColor != null)
+            _window.SelectColor(sprayPainter.PickedColor);
+        _window.SetSelectedStyles(sprayPainter.StylesByGroup);
+        _window.SetSelectedDecal(sprayPainter.SelectedDecal);
+        _window.SetDecalAngle(sprayPainter.SelectedDecalAngle);
+        _window.SetDecalColor(sprayPainter.SelectedDecalColor);
+        _window.SetDecalSnap(sprayPainter.SnapDecals);
+    }
 
-        if (EntMan.TryGetComponent(Owner, out SprayPainterComponent? comp))
-        {
-            _window.Populate(EntMan.System<SprayPainterSystem>().Entries, comp.Index, comp.PickedColor, comp.ColorPalette);
-        }
+    private void OnDecalSnapChanged(bool snap)
+    {
+        SendPredictedMessage(new SprayPainterSetDecalSnapMessage(snap));
+    }
+
+    private void OnDecalAngleChanged(int angle)
+    {
+        SendPredictedMessage(new SprayPainterSetDecalAngleMessage(angle));
+    }
+
+    private void OnDecalColorChanged(Color? color)
+    {
+        SendPredictedMessage(new SprayPainterSetDecalColorMessage(color));
+    }
+
+    private void OnDecalChanged(ProtoId<DecalPrototype> protoId)
+    {
+        SendPredictedMessage(new SprayPainterSetDecalMessage(protoId));
+    }
+
+    private void OnTabChanged(int index, bool isSelectedTabWithDecals)
+    {
+        SendPredictedMessage(new SprayPainterTabChangedMessage(index, isSelectedTabWithDecals));
     }
 
-    private void OnSpritePicked(ItemList.ItemListSelectedEventArgs args)
+    private void OnSpritePicked(string group, string style)
     {
-        SendMessage(new SprayPainterSpritePickedMessage(args.ItemIndex));
+        SendPredictedMessage(new SprayPainterSetPaintableStyleMessage(group, style));
     }
 
-    private void OnColorPicked(ItemList.ItemListSelectedEventArgs args)
+    private void OnSetPipeColor(ItemList.ItemListSelectedEventArgs args)
     {
         var key = _window?.IndexToColorKey(args.ItemIndex);
-        SendMessage(new SprayPainterColorPickedMessage(key));
+        SendPredictedMessage(new SprayPainterSetPipeColorMessage(key));
     }
 }
diff --git a/Content.Client/SprayPainter/UI/SprayPainterDecals.xaml b/Content.Client/SprayPainter/UI/SprayPainterDecals.xaml
new file mode 100644 (file)
index 0000000..0d5c8e4
--- /dev/null
@@ -0,0 +1,26 @@
+<controls:SprayPainterDecals
+    xmlns="https://spacestation14.io"
+    xmlns:controls="clr-namespace:Content.Client.SprayPainter.UI">
+    <BoxContainer Orientation="Vertical">
+        <Label Text="{Loc 'spray-painter-selected-decals'}" />
+        <ScrollContainer VerticalExpand="True">
+            <GridContainer Columns="7" Name="DecalsGrid">
+                <!-- populated by code -->
+            </GridContainer>
+        </ScrollContainer>
+
+        <BoxContainer Orientation="Vertical">
+            <ColorSelectorSliders Name="ColorSelector" IsAlphaVisible="True" />
+            <CheckBox Name="UseCustomColorCheckBox" Text="{Loc 'spray-painter-use-custom-color'}" />
+            <CheckBox Name="SnapToTileCheckBox" Text="{Loc 'spray-painter-use-snap-to-tile'}" />
+        </BoxContainer>
+
+        <BoxContainer Orientation="Horizontal">
+            <Label Text="{Loc 'spray-painter-angle-rotation'}" />
+            <SpinBox Name="AngleSpinBox" HorizontalExpand="True" />
+            <Button Text="{Loc 'spray-painter-angle-rotation-90-sub'}" Name="SubAngleButton" />
+            <Button Text="{Loc 'spray-painter-angle-rotation-reset'}" Name="SetZeroAngleButton" />
+            <Button Text="{Loc 'spray-painter-angle-rotation-90-add'}" Name="AddAngleButton" />
+        </BoxContainer>
+    </BoxContainer>
+</controls:SprayPainterDecals>
diff --git a/Content.Client/SprayPainter/UI/SprayPainterDecals.xaml.cs b/Content.Client/SprayPainter/UI/SprayPainterDecals.xaml.cs
new file mode 100644 (file)
index 0000000..64d1f78
--- /dev/null
@@ -0,0 +1,174 @@
+using System.Numerics;
+using Content.Client.Stylesheets;
+using Content.Shared.Decals;
+using Robust.Client.AutoGenerated;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.SprayPainter.UI;
+
+/// <summary>
+/// Used to control decal painting parameters for the spray painter.
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class SprayPainterDecals : Control
+{
+    public Action<ProtoId<DecalPrototype>>? OnDecalSelected;
+    public Action<Color?>? OnColorChanged;
+    public Action<int>? OnAngleChanged;
+    public Action<bool>? OnSnapChanged;
+
+    private SpriteSystem? _sprite;
+    private string _selectedDecal = string.Empty;
+    private List<SprayPainterDecalEntry> _decals = [];
+
+    public SprayPainterDecals()
+    {
+        RobustXamlLoader.Load(this);
+
+        AddAngleButton.OnButtonUp += _ => AngleSpinBox.Value += 90;
+        SubAngleButton.OnButtonUp += _ => AngleSpinBox.Value -= 90;
+        SetZeroAngleButton.OnButtonUp += _ => AngleSpinBox.Value = 0;
+        AngleSpinBox.ValueChanged += args => OnAngleChanged?.Invoke(args.Value);
+
+        UseCustomColorCheckBox.OnPressed += UseCustomColorCheckBoxOnOnPressed;
+        SnapToTileCheckBox.OnPressed += SnapToTileCheckBoxOnOnPressed;
+        ColorSelector.OnColorChanged += OnColorSelected;
+    }
+
+    private void UseCustomColorCheckBoxOnOnPressed(BaseButton.ButtonEventArgs _)
+    {
+        OnColorChanged?.Invoke(UseCustomColorCheckBox.Pressed ? ColorSelector.Color : null);
+        UpdateColorButtons(UseCustomColorCheckBox.Pressed);
+    }
+
+    private void SnapToTileCheckBoxOnOnPressed(BaseButton.ButtonEventArgs _)
+    {
+        OnSnapChanged?.Invoke(SnapToTileCheckBox.Pressed);
+    }
+
+    /// <summary>
+    /// Updates the decal list.
+    /// </summary>
+    public void PopulateDecals(List<SprayPainterDecalEntry> decals, SpriteSystem sprite)
+    {
+        _sprite ??= sprite;
+
+        _decals = decals;
+        DecalsGrid.Children.Clear();
+
+        foreach (var decal in decals)
+        {
+            var button = new TextureButton()
+            {
+                TextureNormal = sprite.Frame0(decal.Sprite),
+                Name = decal.Name,
+                ToolTip = decal.Name,
+                Scale = new Vector2(2, 2),
+            };
+            button.OnPressed += DecalButtonOnPressed;
+
+            if (UseCustomColorCheckBox.Pressed)
+            {
+                button.Modulate = ColorSelector.Color;
+            }
+
+            if (_selectedDecal == decal.Name)
+            {
+                var panelContainer = new PanelContainer()
+                {
+                    PanelOverride = new StyleBoxFlat()
+                    {
+                        BackgroundColor = StyleNano.ButtonColorDefault,
+                    },
+                    Children =
+                    {
+                        button,
+                    },
+                };
+                DecalsGrid.AddChild(panelContainer);
+            }
+            else
+            {
+                DecalsGrid.AddChild(button);
+            }
+        }
+    }
+
+    private void OnColorSelected(Color color)
+    {
+        if (!UseCustomColorCheckBox.Pressed)
+            return;
+
+        OnColorChanged?.Invoke(color);
+
+        UpdateColorButtons(UseCustomColorCheckBox.Pressed);
+    }
+
+    private void UpdateColorButtons(bool apply)
+    {
+        Color modulateColor = apply ? ColorSelector.Color : Color.White;
+        foreach (var button in DecalsGrid.Children)
+        {
+            switch (button)
+            {
+                case TextureButton:
+                    button.Modulate = modulateColor;
+                    break;
+                case PanelContainer panelContainer:
+                    {
+                        foreach (TextureButton textureButton in panelContainer.Children)
+                            textureButton.Modulate = modulateColor;
+
+                        break;
+                    }
+            }
+        }
+    }
+
+    private void DecalButtonOnPressed(BaseButton.ButtonEventArgs obj)
+    {
+        if (obj.Button.Name is not { } name)
+            return;
+
+        _selectedDecal = name;
+        OnDecalSelected?.Invoke(_selectedDecal);
+
+        if (_sprite is null)
+            return;
+
+        PopulateDecals(_decals, _sprite);
+    }
+
+    public void SetSelectedDecal(string name)
+    {
+        _selectedDecal = name;
+
+        if (_sprite is null)
+            return;
+
+        PopulateDecals(_decals, _sprite);
+    }
+
+    public void SetAngle(int degrees)
+    {
+        AngleSpinBox.OverrideValue(degrees);
+    }
+
+    public void SetColor(Color? color)
+    {
+        UseCustomColorCheckBox.Pressed = color != null;
+        if (color != null)
+            ColorSelector.Color = color.Value;
+        UpdateColorButtons(UseCustomColorCheckBox.Pressed);
+    }
+
+    public void SetSnap(bool snap)
+    {
+        SnapToTileCheckBox.Pressed = snap;
+    }
+}
diff --git a/Content.Client/SprayPainter/UI/SprayPainterGroup.xaml b/Content.Client/SprayPainter/UI/SprayPainterGroup.xaml
new file mode 100644 (file)
index 0000000..aeb0d07
--- /dev/null
@@ -0,0 +1,12 @@
+<BoxContainer
+    xmlns="https://spacestation14.io"
+    xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+    Orientation="Vertical">
+    <Label Text="{Loc 'spray-painter-selected-style'}" />
+    <controls:ListContainer
+        Name="StyleList"
+        Toggle="True"
+        Group="True">
+        <!-- populated by code -->
+    </controls:ListContainer>
+</BoxContainer>
diff --git a/Content.Client/SprayPainter/UI/SprayPainterGroup.xaml.cs b/Content.Client/SprayPainter/UI/SprayPainterGroup.xaml.cs
new file mode 100644 (file)
index 0000000..fe2f5a8
--- /dev/null
@@ -0,0 +1,66 @@
+using Content.Client.UserInterface.Controls;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.SprayPainter.UI;
+
+/// <summary>
+/// Used to display a group of paintable styles in the spray painter menu.
+/// (e.g. each type of paintable locker or plastic crate)
+/// </summary>
+[GenerateTypedNameReferences]
+public sealed partial class SprayPainterGroup : BoxContainer
+{
+    public event Action<SpriteListData>? OnButtonPressed;
+
+    public SprayPainterGroup()
+    {
+        RobustXamlLoader.Load(this);
+
+        StyleList.GenerateItem = GenerateItems;
+    }
+
+    public void PopulateList(List<SpriteListData> spriteList)
+    {
+        StyleList.PopulateList(spriteList);
+    }
+
+    public void SelectItemByStyle(string key)
+    {
+        foreach (var elem in StyleList.Data)
+        {
+            if (elem is not SpriteListData spriteElem)
+                continue;
+
+            if (spriteElem.Style == key)
+            {
+                StyleList.Select(spriteElem);
+                break;
+            }
+        }
+    }
+
+    private void GenerateItems(ListData data, ListContainerButton button)
+    {
+        if (data is not SpriteListData spriteListData)
+            return;
+
+        var box = new BoxContainer() { Orientation = LayoutOrientation.Horizontal };
+        var protoView = new EntityPrototypeView();
+        protoView.SetPrototype(spriteListData.Prototype);
+        var label = new Label()
+        {
+            Text = Loc.GetString($"spray-painter-style-{spriteListData.Group.ToLower()}-{spriteListData.Style.ToLower()}")
+        };
+
+        box.AddChild(protoView);
+        box.AddChild(label);
+        button.AddChild(box);
+        button.AddStyleClass(ListContainer.StyleClassListContainerButton);
+        button.OnPressed += _ => OnButtonPressed?.Invoke(spriteListData);
+
+        if (spriteListData.SelectedIndex == button.Index)
+            button.Pressed = true;
+    }
+}
index 13e500c46c8cfb0abe589be38d955627c4e17598..46facb5d321f3a11a5f68aae918f5aa3195abaab 100644 (file)
@@ -1,34 +1,6 @@
 <DefaultWindow xmlns="https://spacestation14.io"
-               MinSize="500 300"
-               SetSize="500 500"
-               Title="{Loc 'spray-painter-window-title'}">
-    <BoxContainer Orientation="Horizontal"
-                  HorizontalExpand="True"
-                  VerticalExpand="True"
-                  SeparationOverride="4"
-                  MinWidth="450">
-        <BoxContainer Orientation="Vertical"
-                      HorizontalExpand="True"
-                      VerticalExpand="True"
-                      SeparationOverride="4"
-                      MinWidth="200">
-            <Label Name="SelectedSpriteLabel"
-                   Text="{Loc 'spray-painter-selected-style'}">
-            </Label>
-            <ItemList Name="SpriteList"
-                      SizeFlagsStretchRatio="8"
-                      VerticalExpand="True"/>
-        </BoxContainer>
-        <BoxContainer Orientation="Vertical"
-                      HorizontalExpand="True"
-                      VerticalExpand="True"
-                      SeparationOverride="4"
-                      MinWidth="200">
-            <Label Name="SelectedColorLabel"
-                   Text="{Loc 'spray-painter-selected-color'}"/>
-            <ItemList Name="ColorList"
-                      SizeFlagsStretchRatio="8"
-                      VerticalExpand="True"/>
-        </BoxContainer>
-    </BoxContainer>
+       MinSize="520 300"
+       SetSize="520 700"
+       Title="{Loc 'spray-painter-window-title'}">
+       <TabContainer Name="Tabs"/>
 </DefaultWindow>
index 4e7bdd89732b0f6d64f24b07e2a51091f90d2b44..eb1218ad6784398744647d0b903b60bd36915c56 100644 (file)
@@ -1,12 +1,19 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Decals;
 using Robust.Client.AutoGenerated;
 using Robust.Client.GameObjects;
 using Robust.Client.UserInterface.Controls;
 using Robust.Client.UserInterface.CustomControls;
 using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
 using Robust.Shared.Utility;
 
 namespace Content.Client.SprayPainter.UI;
 
+/// <summary>
+/// A window to select spray painter settings by object type, as well as pipe colours and decals.
+/// </summary>
 [GenerateTypedNameReferences]
 public sealed partial class SprayPainterWindow : DefaultWindow
 {
@@ -15,13 +22,33 @@ public sealed partial class SprayPainterWindow : DefaultWindow
 
     private readonly SpriteSystem _spriteSystem;
 
-    public Action<ItemList.ItemListSelectedEventArgs>? OnSpritePicked;
-    public Action<ItemList.ItemListSelectedEventArgs>? OnColorPicked;
+    // Events
+    public event Action<string, string>? OnSpritePicked;
+    public event Action<int, bool>? OnTabChanged;
+    public event Action<ProtoId<DecalPrototype>>? OnDecalChanged;
+    public event Action<ItemList.ItemListSelectedEventArgs>? OnSetPipeColor;
+    public event Action<Color?>? OnDecalColorChanged;
+    public event Action<int>? OnDecalAngleChanged;
+    public event Action<bool>? OnDecalSnapChanged;
+
+    // Pipe color data
+    private ItemList _colorList = default!;
     public Dictionary<string, int> ItemColorIndex = new();
 
-    private Dictionary<string, Color> currentPalette = new();
-    private const string colorLocKeyPrefix = "pipe-painter-color-";
-    private List<SprayPainterEntry> CurrentEntries = new List<SprayPainterEntry>();
+    private Dictionary<string, Color> _currentPalette = new();
+    private const string ColorLocKeyPrefix = "pipe-painter-color-";
+
+    // Paintable objects
+    private Dictionary<string, Dictionary<string, EntProtoId>> _currentStylesByGroup = new();
+    private Dictionary<string, List<string>> _currentGroupsByCategory = new();
+
+    // Tab controls
+    private Dictionary<string, SprayPainterGroup> _paintableControls = new();
+    private BoxContainer? _pipeControl;
+
+    // Decals
+    private List<SprayPainterDecalEntry> _currentDecals = [];
+    private SprayPainterDecals? _sprayPainterDecals;
 
     private readonly SpriteSpecifier _colorEntryIconTexture = new SpriteSpecifier.Rsi(
         new ResPath("Structures/Piping/Atmospherics/pipe.rsi"),
@@ -32,13 +59,14 @@ public sealed partial class SprayPainterWindow : DefaultWindow
         RobustXamlLoader.Load(this);
         IoCManager.InjectDependencies(this);
         _spriteSystem = _sysMan.GetEntitySystem<SpriteSystem>();
+        Tabs.OnTabChanged += (index) => OnTabChanged?.Invoke(index, _sprayPainterDecals?.GetPositionInParent() == index);
     }
 
     private string GetColorLocString(string? colorKey)
     {
         if (string.IsNullOrEmpty(colorKey))
             return Loc.GetString("pipe-painter-no-color-selected");
-        var locKey = colorLocKeyPrefix + colorKey;
+        var locKey = ColorLocKeyPrefix + colorKey;
 
         if (!_loc.TryGetString(locKey, out var locString))
             locString = colorKey;
@@ -48,51 +76,229 @@ public sealed partial class SprayPainterWindow : DefaultWindow
 
     public string? IndexToColorKey(int index)
     {
-        return (string?) ColorList[index].Metadata;
+        return _colorList[index].Text;
+    }
+
+    private void OnStyleSelected(ListData data)
+    {
+        if (data is SpriteListData listData)
+            OnSpritePicked?.Invoke(listData.Group, listData.Style);
+    }
+
+    /// <summary>
+    /// Wrapper to allow for selecting/deselecting the event to avoid loops
+    /// </summary>
+    private void OnColorPicked(ItemList.ItemListSelectedEventArgs args)
+    {
+        OnSetPipeColor?.Invoke(args);
     }
 
-    public void Populate(List<SprayPainterEntry> entries, int selectedStyle, string? selectedColorKey, Dictionary<string, Color> palette)
+    /// <summary>
+    /// Setup function for the window.
+    /// </summary>
+    /// <param name="stylesByGroup">Each group, mapped by name to the set of named styles by their associated entity prototype.</param>
+    /// <param name="groupsByCategory">The set of categories and the groups associated with them.</param>
+    /// <param name="decals">A list of each decal.</param>
+    public void PopulateCategories(Dictionary<string, Dictionary<string, EntProtoId>> stylesByGroup, Dictionary<string, List<string>> groupsByCategory, List<SprayPainterDecalEntry> decals)
     {
+        bool tabsCleared = false;
+        var lastTab = Tabs.CurrentTab;
+
+        if (!_currentGroupsByCategory.Equals(groupsByCategory))
+        {
+            // Destroy all existing tabs
+            tabsCleared = true;
+            _paintableControls.Clear();
+            _pipeControl = null;
+            _sprayPainterDecals = null;
+            Tabs.RemoveAllChildren();
+        }
+
         // Only clear if the entries change. Otherwise the list would "jump" after selecting an item
-        if (!CurrentEntries.Equals(entries))
+        if (tabsCleared || !_currentStylesByGroup.Equals(stylesByGroup))
+        {
+            _currentStylesByGroup = stylesByGroup;
+
+            var tabIndex = 0;
+            foreach (var (categoryName, categoryGroups) in groupsByCategory.OrderBy(c => c.Key))
+            {
+                if (categoryGroups.Count <= 0)
+                    continue;
+
+                // Repopulating controls:
+                //      ensure that categories with multiple groups have separate subtabs
+                //      but single-group categories do not.
+                if (tabsCleared)
+                {
+                    TabContainer? subTabs = null;
+                    if (categoryGroups.Count > 1)
+                        subTabs = new();
+
+                    foreach (var group in categoryGroups)
+                    {
+                        if (!stylesByGroup.TryGetValue(group, out var styles))
+                            continue;
+
+                        var groupControl = new SprayPainterGroup();
+                        groupControl.OnButtonPressed += OnStyleSelected;
+                        _paintableControls[group] = groupControl;
+                        if (categoryGroups.Count > 1)
+                        {
+                            if (subTabs != null)
+                            {
+                                subTabs?.AddChild(groupControl);
+                                var subTabLocalization = Loc.GetString("spray-painter-tab-group-" + group.ToLower());
+                                TabContainer.SetTabTitle(groupControl, subTabLocalization);
+                            }
+                        }
+                        else
+                        {
+                            Tabs.AddChild(groupControl);
+                        }
+                    }
+
+                    if (subTabs != null)
+                        Tabs.AddChild(subTabs);
+
+                    var tabLocalization = Loc.GetString("spray-painter-tab-category-" + categoryName.ToLower());
+                    Tabs.SetTabTitle(tabIndex, tabLocalization);
+                    tabIndex++;
+                }
+
+                // Finally, populate all groups with new data.
+                foreach (var group in categoryGroups)
+                {
+                    if (!stylesByGroup.TryGetValue(group, out var styles) ||
+                        !_paintableControls.TryGetValue(group, out var control))
+                        continue;
+
+                    var dataList = styles
+                        .Select(e => new SpriteListData(group, e.Key, e.Value, 0))
+                        .OrderBy(d => Loc.GetString($"spray-painter-style-{group.ToLower()}-{d.Style.ToLower()}"))
+                        .ToList();
+                    control.PopulateList(dataList);
+                }
+            }
+        }
+
+        PopulateColors(_currentPalette);
+
+        if (!_currentDecals.Equals(decals))
         {
-            CurrentEntries = entries;
-            SpriteList.Clear();
-            foreach (var entry in entries)
+            _currentDecals = decals;
+
+            if (_sprayPainterDecals is null)
             {
-                SpriteList.AddItem(entry.Name, entry.Icon);
+                _sprayPainterDecals = new SprayPainterDecals();
+
+                _sprayPainterDecals.OnDecalSelected += id => OnDecalChanged?.Invoke(id);
+                _sprayPainterDecals.OnColorChanged += color => OnDecalColorChanged?.Invoke(color);
+                _sprayPainterDecals.OnAngleChanged += angle => OnDecalAngleChanged?.Invoke(angle);
+                _sprayPainterDecals.OnSnapChanged += snap => OnDecalSnapChanged?.Invoke(snap);
+
+                Tabs.AddChild(_sprayPainterDecals);
+                TabContainer.SetTabTitle(_sprayPainterDecals, Loc.GetString("spray-painter-tab-category-decals"));
             }
+
+            _sprayPainterDecals.PopulateDecals(decals, _spriteSystem);
         }
 
-        if (!currentPalette.Equals(palette))
+        if (tabsCleared)
+            SetSelectedTab(lastTab);
+    }
+
+    public void PopulateColors(Dictionary<string, Color> palette)
+    {
+        // Create pipe tab controls if they don't exist
+        bool tabCreated = false;
+        if (_pipeControl == null)
+        {
+            _pipeControl = new BoxContainer() { Orientation = BoxContainer.LayoutOrientation.Vertical };
+
+            var label = new Label() { Text = Loc.GetString("spray-painter-selected-color") };
+
+            _colorList = new ItemList() { VerticalExpand = true };
+            _colorList.OnItemSelected += OnColorPicked;
+
+            _pipeControl.AddChild(label);
+            _pipeControl.AddChild(_colorList);
+
+            Tabs.AddChild(_pipeControl);
+            TabContainer.SetTabTitle(_pipeControl, Loc.GetString("spray-painter-tab-category-pipes"));
+            tabCreated = true;
+        }
+
+        // Populate the tab if needed (new tab/new data)
+        if (tabCreated || !_currentPalette.Equals(palette))
         {
-            currentPalette = palette;
+            _currentPalette = palette;
             ItemColorIndex.Clear();
-            ColorList.Clear();
+            _colorList.Clear();
 
+            int index = 0;
             foreach (var color in palette)
             {
                 var locString = GetColorLocString(color.Key);
-                var item = ColorList.AddItem(locString, _spriteSystem.Frame0(_colorEntryIconTexture));
+                var item = _colorList.AddItem(locString, _spriteSystem.Frame0(_colorEntryIconTexture), metadata: color.Key);
                 item.IconModulate = color.Value;
-                item.Metadata = color.Key;
 
-                ItemColorIndex.Add(color.Key, ColorList.IndexOf(item));
+                ItemColorIndex.Add(color.Key, index);
+                index++;
             }
         }
+    }
+
+    # region Setters
+    public void SetSelectedStyles(Dictionary<string, string> selectedStyles)
+    {
+        foreach (var (group, style) in selectedStyles)
+        {
+            if (!_paintableControls.TryGetValue(group, out var control))
+                continue;
 
-        // Disable event so we don't send a new event for pre-selectedStyle entry and end up in a loop
+            control.SelectItemByStyle(style);
+        }
+    }
 
-        if (selectedColorKey != null)
+    public void SelectColor(string color)
+    {
+        if (_colorList != null && ItemColorIndex.TryGetValue(color, out var colorIdx))
         {
-            var index = ItemColorIndex[selectedColorKey];
-            ColorList.OnItemSelected -= OnColorPicked;
-            ColorList[index].Selected = true;
-            ColorList.OnItemSelected += OnColorPicked;
+            _colorList.OnItemSelected -= OnColorPicked;
+            _colorList[colorIdx].Selected = true;
+            _colorList.OnItemSelected += OnColorPicked;
         }
+    }
+
+    public void SetSelectedTab(int tab)
+    {
+        Tabs.CurrentTab = int.Min(tab, Tabs.ChildCount - 1);
+    }
+
+    public void SetSelectedDecal(string decal)
+    {
+        if (_sprayPainterDecals != null)
+            _sprayPainterDecals.SetSelectedDecal(decal);
+    }
 
-        SpriteList.OnItemSelected -= OnSpritePicked;
-        SpriteList[selectedStyle].Selected = true;
-        SpriteList.OnItemSelected += OnSpritePicked;
+    public void SetDecalAngle(int angle)
+    {
+        if (_sprayPainterDecals != null)
+            _sprayPainterDecals.SetAngle(angle);
+    }
+
+    public void SetDecalColor(Color? color)
+    {
+        if (_sprayPainterDecals != null)
+            _sprayPainterDecals.SetColor(color);
     }
+
+    public void SetDecalSnap(bool snap)
+    {
+        if (_sprayPainterDecals != null)
+            _sprayPainterDecals.SetSnap(snap);
+    }
+    # endregion
 }
+
+public record SpriteListData(string Group, string Style, EntProtoId Prototype, int SelectedIndex) : ListData;
index e6e53ae87a75a42dfd364c15434d2bf66fad7fcb..c10a24ac3230654a08c8c51f150301db27852008 100644 (file)
@@ -1,10 +1,15 @@
+using Content.Shared.SprayPainter.Prototypes;
 using Content.Shared.Storage;
 using Robust.Client.GameObjects;
+using Robust.Shared.Prototypes;
 
 namespace Content.Client.Storage.Visualizers;
 
 public sealed class EntityStorageVisualizerSystem : VisualizerSystem<EntityStorageVisualsComponent>
 {
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+    [Dependency] private readonly IComponentFactory _componentFactory = default!;
+
     public override void Initialize()
     {
         base.Initialize();
@@ -26,12 +31,34 @@ public sealed class EntityStorageVisualizerSystem : VisualizerSystem<EntityStora
         SpriteSystem.LayerSetRsiState((uid, sprite), StorageVisualLayers.Base, comp.StateBaseClosed);
     }
 
-    protected override void OnAppearanceChange(EntityUid uid, EntityStorageVisualsComponent comp, ref AppearanceChangeEvent args)
+    protected override void OnAppearanceChange(EntityUid uid,
+        EntityStorageVisualsComponent comp,
+        ref AppearanceChangeEvent args)
     {
         if (args.Sprite == null
-        || !AppearanceSystem.TryGetData<bool>(uid, StorageVisuals.Open, out var open, args.Component))
+            || !AppearanceSystem.TryGetData<bool>(uid, StorageVisuals.Open, out var open, args.Component))
             return;
 
+        var forceRedrawBase = false;
+        if (AppearanceSystem.TryGetData<string>(uid, PaintableVisuals.Prototype, out var prototype, args.Component))
+        {
+            if (_prototypeManager.TryIndex(prototype, out var proto))
+            {
+                if (proto.TryGetComponent(out SpriteComponent? sprite, _componentFactory))
+                {
+                    SpriteSystem.SetBaseRsi((uid, args.Sprite), sprite.BaseRSI);
+                }
+                if (proto.TryGetComponent(out EntityStorageVisualsComponent? visuals, _componentFactory))
+                {
+                    comp.StateBaseOpen = visuals.StateBaseOpen;
+                    comp.StateBaseClosed = visuals.StateBaseClosed;
+                    comp.StateDoorOpen = visuals.StateDoorOpen;
+                    comp.StateDoorClosed = visuals.StateDoorClosed;
+                    forceRedrawBase = true;
+                }
+            }
+        }
+
         // Open/Closed state for the storage entity.
         if (SpriteSystem.LayerMapTryGet((uid, args.Sprite), StorageVisualLayers.Door, out _, false))
         {
@@ -52,6 +79,8 @@ public sealed class EntityStorageVisualizerSystem : VisualizerSystem<EntityStora
 
                 if (comp.StateBaseOpen != null)
                     SpriteSystem.LayerSetRsiState((uid, args.Sprite), StorageVisualLayers.Base, comp.StateBaseOpen);
+                else if (forceRedrawBase && comp.StateBaseClosed != null)
+                    SpriteSystem.LayerSetRsiState((uid, args.Sprite), StorageVisualLayers.Base, comp.StateBaseClosed);
             }
             else
             {
@@ -68,6 +97,8 @@ public sealed class EntityStorageVisualizerSystem : VisualizerSystem<EntityStora
 
                 if (comp.StateBaseClosed != null)
                     SpriteSystem.LayerSetRsiState((uid, args.Sprite), StorageVisualLayers.Base, comp.StateBaseClosed);
+                else if (forceRedrawBase && comp.StateBaseOpen != null)
+                    SpriteSystem.LayerSetRsiState((uid, args.Sprite), StorageVisualLayers.Base, comp.StateBaseOpen);
             }
         }
     }
index 9f6da20fda13340b019c83bb66b268c4ade726fc..24ab5e0ea2903497afc145f1ff587cbc6836c168 100644 (file)
 using Content.Server.Atmos.Piping.Components;
 using Content.Server.Atmos.Piping.EntitySystems;
+using Content.Server.Charges;
+using Content.Server.Decals;
+using Content.Server.Destructible;
+using Content.Server.Popups;
+using Content.Shared.Atmos.Piping.Unary.Components;
+using Content.Shared.Charges.Components;
+using Content.Shared.Coordinates.Helpers;
+using Content.Shared.Database;
+using Content.Shared.Decals;
 using Content.Shared.DoAfter;
 using Content.Shared.Interaction;
 using Content.Shared.SprayPainter;
 using Content.Shared.SprayPainter.Components;
+using Robust.Server.Audio;
+using Robust.Server.GameObjects;
+using Robust.Shared.Prototypes;
 
 namespace Content.Server.SprayPainter;
 
 /// <summary>
-/// Handles spraying pipes using a spray painter.
-/// Airlocks are handled in shared.
+/// Handles spraying pipes and decals using a spray painter.
+/// Other paintable objects are handled in shared.
 /// </summary>
 public sealed class SprayPainterSystem : SharedSprayPainterSystem
 {
     [Dependency] private readonly AtmosPipeColorSystem _pipeColor = default!;
+    [Dependency] private readonly PopupSystem _popup = default!;
+    [Dependency] private readonly DecalSystem _decals = default!;
+    [Dependency] private readonly AudioSystem _audio = default!;
+    [Dependency] private readonly ChargesSystem _charges = default!;
+    [Dependency] private readonly TransformSystem _transform = default!;
 
     public override void Initialize()
     {
         base.Initialize();
 
         SubscribeLocalEvent<SprayPainterComponent, SprayPainterPipeDoAfterEvent>(OnPipeDoAfter);
-
+        SubscribeLocalEvent<SprayPainterComponent, AfterInteractEvent>(OnFloorAfterInteract);
         SubscribeLocalEvent<AtmosPipeColorComponent, InteractUsingEvent>(OnPipeInteract);
+        SubscribeLocalEvent<GasCanisterComponent, EntityPaintedEvent>(OnCanisterPainted);
+    }
+
+    /// <summary>
+    /// Handles drawing decals when a spray painter is used to interact with the floor.
+    /// Spray painter must have decal painting enabled and enough charges of paint to paint on the floor.
+    /// </summary>
+    private void OnFloorAfterInteract(Entity<SprayPainterComponent> ent, ref AfterInteractEvent args)
+    {
+        if (args.Handled || !args.CanReach || args.Target != null)
+            return;
+
+        // Includes both off and all other don't cares
+        if (ent.Comp.DecalMode != DecalPaintMode.Add && ent.Comp.DecalMode != DecalPaintMode.Remove)
+            return;
+
+        args.Handled = true;
+        if (TryComp(ent, out LimitedChargesComponent? charges) && charges.LastCharges < ent.Comp.DecalChargeCost)
+        {
+            _popup.PopupEntity(Loc.GetString("spray-painter-interact-no-charges"), args.User, args.User);
+            return;
+        }
+
+        var position = args.ClickLocation;
+        if (ent.Comp.SnapDecals)
+            position = position.SnapToGrid(EntityManager);
+
+        if (ent.Comp.DecalMode == DecalPaintMode.Add)
+        {
+            // Offset painting for adding decals
+            position = position.Offset(new(-0.5f));
+
+            if (!_decals.TryAddDecal(ent.Comp.SelectedDecal, position, out _, ent.Comp.SelectedDecalColor, Angle.FromDegrees(ent.Comp.SelectedDecalAngle), 0, false))
+                return;
+        }
+        else
+        {
+            var gridUid = _transform.GetGrid(args.ClickLocation);
+            if (gridUid is not { } grid || !TryComp<DecalGridComponent>(grid, out var decalGridComp))
+            {
+                _popup.PopupEntity(Loc.GetString("spray-painter-interact-nothing-to-remove"), args.User, args.User);
+                return;
+            }
+
+            var decals = _decals.GetDecalsInRange(grid, position.Position, validDelegate: IsDecalRemovable);
+            if (decals.Count <= 0)
+            {
+                _popup.PopupEntity(Loc.GetString("spray-painter-interact-nothing-to-remove"), args.User, args.User);
+                return;
+            }
+
+            foreach (var decal in decals)
+            {
+                _decals.RemoveDecal(grid, decal.Index, decalGridComp);
+            }
+        }
+
+        _audio.PlayPvs(ent.Comp.SpraySound, ent);
+
+        _charges.TryUseCharges((ent, charges), ent.Comp.DecalChargeCost);
+
+        AdminLogger.Add(LogType.CrayonDraw, LogImpact.Low, $"{EntityManager.ToPrettyString(args.User):user} painted a {ent.Comp.SelectedDecal}");
+    }
+
+    /// <summary>
+    /// Handles drawing decals when a spray painter is used to interact with the floor.
+    /// Spray painter must have decal painting enabled and enough charges of paint to paint on the floor.
+    /// </summary>
+    private bool IsDecalRemovable(Decal decal)
+    {
+        if (!Proto.TryIndex<DecalPrototype>(decal.Id, out var decalProto))
+            return false;
+
+        return (decalProto.Tags.Contains("station")
+            || decalProto.Tags.Contains("markings"))
+            && !decalProto.Tags.Contains("dirty");
+    }
+
+    /// <summary>
+    /// Event handler when gas canisters are painted.
+    /// The canister's color should not change when it's destroyed.
+    /// </summary>
+    private void OnCanisterPainted(Entity<GasCanisterComponent> ent, ref EntityPaintedEvent args)
+    {
+        var dummy = Spawn(args.Prototype);
+
+        var destructibleComp = EnsureComp<DestructibleComponent>(dummy);
+        CopyComp(dummy, ent, destructibleComp);
+
+        Del(dummy);
     }
 
     private void OnPipeDoAfter(Entity<SprayPainterComponent> ent, ref SprayPainterPipeDoAfterEvent args)
@@ -29,14 +136,17 @@ public sealed class SprayPainterSystem : SharedSprayPainterSystem
         if (args.Handled || args.Cancelled)
             return;
 
-        if (args.Args.Target is not {} target)
+        if (args.Args.Target is not { } target)
             return;
 
         if (!TryComp<AtmosPipeColorComponent>(target, out var color))
             return;
 
-        Audio.PlayPvs(ent.Comp.SpraySound, ent);
+        if (TryComp<LimitedChargesComponent>(ent, out var charges) &&
+            !_charges.TryUseCharges((ent, charges), ent.Comp.PipeChargeCost))
+            return;
 
+        Audio.PlayPvs(ent.Comp.SpraySound, ent);
         _pipeColor.SetColor(target, color, args.Color);
 
         args.Handled = true;
@@ -47,13 +157,28 @@ public sealed class SprayPainterSystem : SharedSprayPainterSystem
         if (args.Handled)
             return;
 
-        if (!TryComp<SprayPainterComponent>(args.Used, out var painter) || painter.PickedColor is not {} colorName)
+        if (!TryComp<SprayPainterComponent>(args.Used, out var painter) ||
+            painter.PickedColor is not { } colorName)
             return;
 
         if (!painter.ColorPalette.TryGetValue(colorName, out var color))
             return;
 
-        var doAfterEventArgs = new DoAfterArgs(EntityManager, args.User, painter.PipeSprayTime, new SprayPainterPipeDoAfterEvent(color), args.Used, target: ent, used: args.Used)
+        if (TryComp<LimitedChargesComponent>(args.Used, out var charges)
+            && charges.LastCharges < painter.PipeChargeCost)
+        {
+            var msg = Loc.GetString("spray-painter-interact-no-charges");
+            _popup.PopupEntity(msg, args.User, args.User);
+            return;
+        }
+
+        var doAfterEventArgs = new DoAfterArgs(EntityManager,
+            args.User,
+            painter.PipeSprayTime,
+            new SprayPainterPipeDoAfterEvent(color),
+            args.Used,
+            target: ent,
+            used: args.Used)
         {
             BreakOnMove = true,
             BreakOnDamage = true,
index a8cb25782ed0edbce4dc9230d3d9b6f373c782a2..64b4ab1857ef38f05c7a8cb0ff171dcd103cc9f7 100644 (file)
@@ -317,7 +317,6 @@ public enum DoorVisuals : byte
     BoltLights,
     EmergencyLights,
     ClosedLights,
-    BaseRSI,
 }
 
 public enum DoorVisualLayers : byte
diff --git a/Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs b/Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs
deleted file mode 100644 (file)
index fdd0aee..0000000
+++ /dev/null
@@ -1,24 +0,0 @@
-using Content.Shared.Roles;
-using Content.Shared.SprayPainter.Prototypes;
-using Robust.Shared.GameStates;
-using Robust.Shared.Prototypes;
-
-namespace Content.Shared.SprayPainter.Components;
-
-[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
-public sealed partial class PaintableAirlockComponent : Component
-{
-    /// <summary>
-    /// Group of styles this airlock can be painted with, e.g. glass, standard or external.
-    /// </summary>
-    [DataField(required: true), AutoNetworkedField]
-    public ProtoId<AirlockGroupPrototype> Group = string.Empty;
-
-    /// <summary>
-    /// Department this airlock is painted as, or none.
-    /// Must be specified in prototypes for turf war to work.
-    /// To better catch any mistakes, you need to explicitly state a non-styled airlock has a null department.
-    /// </summary>
-    [DataField(required: true), AutoNetworkedField]
-    public ProtoId<DepartmentPrototype>? Department;
-}
diff --git a/Content.Shared/SprayPainter/Components/PaintableComponent.cs b/Content.Shared/SprayPainter/Components/PaintableComponent.cs
new file mode 100644 (file)
index 0000000..cfcb6a6
--- /dev/null
@@ -0,0 +1,19 @@
+using Content.Shared.SprayPainter.Prototypes;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.SprayPainter.Components;
+
+/// <summary>
+/// Marks objects that can be painted with the spray painter.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class PaintableComponent : Component
+{
+    /// <summary>
+    /// Group of styles this airlock can be painted with, e.g. glass, standard or external.
+    /// Set to null to make an entity unpaintable.
+    /// </summary>
+    [DataField(required: true)]
+    public ProtoId<PaintableGroupPrototype>? Group;
+}
diff --git a/Content.Shared/SprayPainter/Components/PaintedComponent.cs b/Content.Shared/SprayPainter/Components/PaintedComponent.cs
new file mode 100644 (file)
index 0000000..83f0e6e
--- /dev/null
@@ -0,0 +1,18 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Shared.SprayPainter.Components;
+
+/// <summary>
+/// Used to mark an entity that has been repainted.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+[AutoGenerateComponentState, AutoGenerateComponentPause]
+public sealed partial class PaintedComponent : Component
+{
+    /// <summary>
+    /// The time after which the entity is dried and does not appear as "freshly painted".
+    /// </summary>
+    [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField]
+    public TimeSpan DryTime;
+}
diff --git a/Content.Shared/SprayPainter/Components/SprayPainterAmmo.cs b/Content.Shared/SprayPainter/Components/SprayPainterAmmo.cs
new file mode 100644 (file)
index 0000000..d869c96
--- /dev/null
@@ -0,0 +1,17 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.SprayPainter.Components;
+
+/// <summary>
+/// Items with this component can be used to recharge a spray painter.
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SprayPainterAmmoSystem))]
+public sealed partial class SprayPainterAmmoComponent : Component
+{
+    /// <summary>
+    /// The value by which the charge in the spray painter will be recharged.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public int Charges = 15;
+}
index 0591cb2dcbd03b58c2614b272475e0b5afe9ca4f..5485870766d36c733749395a1329247586a10a09 100644 (file)
@@ -1,26 +1,42 @@
-using Content.Shared.DoAfter;
+using Content.Shared.Decals;
 using Robust.Shared.Audio;
 using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
 
 namespace Content.Shared.SprayPainter.Components;
 
-[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+/// <summary>
+/// Denotes an object that can be used to alter the appearance of paintable objects (e.g. doors, gas canisters).
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)]
 public sealed partial class SprayPainterComponent : Component
 {
+    public const string DefaultPickedColor = "red";
+    public static readonly ProtoId<DecalPrototype> DefaultDecal = "Arrows";
+
+    /// <summary>
+    /// The sound to be played after painting the entities.
+    /// </summary>
     [DataField]
     public SoundSpecifier SpraySound = new SoundPathSpecifier("/Audio/Effects/spray2.ogg");
 
+    /// <summary>
+    /// The amount of time it takes to paint a pipe.
+    /// </summary>
     [DataField]
-    public TimeSpan AirlockSprayTime = TimeSpan.FromSeconds(3);
+    public TimeSpan PipeSprayTime = TimeSpan.FromSeconds(1);
 
+    /// <summary>
+    /// The cost of spray painting a pipe, in charges.
+    /// </summary>
     [DataField]
-    public TimeSpan PipeSprayTime = TimeSpan.FromSeconds(1);
+    public int PipeChargeCost = 1;
 
     /// <summary>
     /// Pipe color chosen to spray with.
     /// </summary>
     [DataField, AutoNetworkedField]
-    public stringPickedColor;
+    public string PickedColor = DefaultPickedColor;
 
     /// <summary>
     /// Pipe colors that can be selected.
@@ -29,9 +45,82 @@ public sealed partial class SprayPainterComponent : Component
     public Dictionary<string, Color> ColorPalette = new();
 
     /// <summary>
-    /// Airlock style index selected.
-    /// After prototype reload this might not be the same style but it will never be out of bounds.
+    /// Spray paintable object styles selected per object.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public Dictionary<string, string> StylesByGroup = new();
+
+    /// <summary>
+    /// The currently open tab of the painter
+    /// (Are you selecting canister color?)
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public int SelectedTab;
+
+    /// <summary>
+    /// Whether or not the painter should be painting or removing decals when clicked.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public DecalPaintMode DecalMode = DecalPaintMode.Off;
+
+    /// <summary>
+    /// The currently selected decal prototype.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public ProtoId<DecalPrototype> SelectedDecal = DefaultDecal;
+
+    /// <summary>
+    /// The color in which to paint the decal.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public Color? SelectedDecalColor;
+
+    /// <summary>
+    /// The angle at which to paint the decal.
     /// </summary>
     [DataField, AutoNetworkedField]
-    public int Index;
+    public int SelectedDecalAngle;
+
+    /// <summary>
+    /// The angle at which to paint the decal.
+    /// </summary>
+    [DataField, AutoNetworkedField]
+    public bool SnapDecals = true;
+
+    /// <summary>
+    /// The cost of spray painting a decal, in charges.
+    /// </summary>
+    [DataField]
+    public int DecalChargeCost = 1;
+
+    /// <summary>
+    /// How long does the painter leave items as freshly painted?
+    /// </summary>
+    [DataField]
+    public TimeSpan FreshPaintDuration = TimeSpan.FromMinutes(15);
+
+    /// <summary>
+    /// The sound to play when swapping between decal modes.
+    /// </summary>
+    [DataField]
+    public SoundSpecifier SoundSwitchDecalMode = new SoundPathSpecifier("/Audio/Machines/quickbeep.ogg", AudioParams.Default.WithVolume(1.5f));
+}
+
+/// <summary>
+/// A set of operating modes for decal painting.
+/// </summary>
+public enum DecalPaintMode : byte
+{
+    /// <summary>
+    /// Clicking on the floor does nothing.
+    /// </summary>
+    Off = 0,
+    /// <summary>
+    /// Clicking on the floor adds a decal at the requested spot (or snapped to the grid)
+    /// </summary>
+    Add = 1,
+    /// <summary>
+    /// Clicking on the floor removes all decals at the requested spot (or snapped to the grid)
+    /// </summary>
+    Remove = 2,
 }
diff --git a/Content.Shared/SprayPainter/Prototypes/AirlockDepartmentsPrototype.cs b/Content.Shared/SprayPainter/Prototypes/AirlockDepartmentsPrototype.cs
deleted file mode 100644 (file)
index 8f98a1a..0000000
+++ /dev/null
@@ -1,21 +0,0 @@
-using Content.Shared.Roles;
-using Robust.Shared.Prototypes;
-
-namespace Content.Shared.SprayPainter.Prototypes;
-
-/// <summary>
-/// Maps airlock style names to department ids.
-/// </summary>
-[Prototype]
-public sealed partial class AirlockDepartmentsPrototype : IPrototype
-{
-    [IdDataField]
-    public string ID { get; private set; } = default!;
-
-    /// <summary>
-    /// Dictionary of style names to department ids.
-    /// If a style does not have a department (e.g. external) it is set to null.
-    /// </summary>
-    [DataField(required: true)]
-    public Dictionary<string, ProtoId<DepartmentPrototype>> Departments = new();
-}
diff --git a/Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs b/Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs
deleted file mode 100644 (file)
index 24c28b8..0000000
+++ /dev/null
@@ -1,19 +0,0 @@
-using Robust.Shared.Prototypes;
-
-namespace Content.Shared.SprayPainter.Prototypes;
-
-[Prototype("AirlockGroup")]
-public sealed partial class AirlockGroupPrototype : IPrototype
-{
-    [IdDataField]
-    public string ID { get; private set; } = default!;
-
-    [DataField("stylePaths")]
-    public Dictionary<string, string> StylePaths = default!;
-
-    // The priority determines, which sprite is used when showing
-    // the icon for a style in the SprayPainter UI. The highest priority
-    // gets shown.
-    [DataField("iconPriority")]
-    public int IconPriority = 0;
-}
diff --git a/Content.Shared/SprayPainter/Prototypes/PaintableGroupCategoryPrototype.cs b/Content.Shared/SprayPainter/Prototypes/PaintableGroupCategoryPrototype.cs
new file mode 100644 (file)
index 0000000..ba6423d
--- /dev/null
@@ -0,0 +1,19 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.SprayPainter.Prototypes;
+
+/// <summary>
+/// A category of spray paintable items (e.g. airlocks, crates)
+/// </summary>
+[Prototype]
+public sealed partial class PaintableGroupCategoryPrototype : IPrototype
+{
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// Each group that makes up this category.
+    /// </summary>
+    [DataField(required: true)]
+    public List<ProtoId<PaintableGroupPrototype>> Groups = new();
+}
diff --git a/Content.Shared/SprayPainter/Prototypes/PaintableGroupPrototype.cs b/Content.Shared/SprayPainter/Prototypes/PaintableGroupPrototype.cs
new file mode 100644 (file)
index 0000000..73944c4
--- /dev/null
@@ -0,0 +1,53 @@
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.SprayPainter.Prototypes;
+
+/// <summary>
+/// Contains a map of the objects from which the spray painter will take texture to paint another from the same group.
+/// </summary>
+[Prototype]
+public sealed partial class PaintableGroupPrototype : IPrototype
+{
+    [IdDataField]
+    public string ID { get; private set; } = default!;
+
+    /// <summary>
+    /// The time required to paint an object from a given group, in seconds.
+    /// </summary>
+    [DataField]
+    public float Time = 2.0f;
+
+    /// <summary>
+    /// To number of charges needed to paint an object of this group.
+    /// </summary>
+    [DataField]
+    public int Cost = 1;
+
+    /// <summary>
+    /// The default style to start painting.
+    /// </summary>
+    [DataField(required: true)]
+    public string DefaultStyle = default!;
+
+    /// <summary>
+    /// Map from localization keys and entity identifiers displayed in the spray painter menu.
+    /// </summary>
+    [DataField(required: true)]
+    public Dictionary<string, EntProtoId> Styles = new();
+
+    /// <summary>
+    /// If multiple groups have the same key, the group with the highest IconPriority has its icon displayed.
+    /// </summary>
+    [DataField]
+    public int IconPriority;
+}
+
+[Serializable, NetSerializable]
+public enum PaintableVisuals
+{
+    /// <summary>
+    /// The prototype to base the object's visuals off.
+    /// </summary>
+    Prototype
+}
index 1b5838167511d1d05b180876177dafd3dcd56130..0a766df3489b22f043b8b8effad1c624818cf024 100644 (file)
 using Content.Shared.Administration.Logs;
+using Content.Shared.Charges.Components;
+using Content.Shared.Charges.Systems;
 using Content.Shared.Database;
 using Content.Shared.DoAfter;
-using Content.Shared.Doors.Components;
+using Content.Shared.Examine;
 using Content.Shared.Interaction;
 using Content.Shared.Popups;
 using Content.Shared.SprayPainter.Components;
 using Content.Shared.SprayPainter.Prototypes;
+using Content.Shared.Verbs;
 using Robust.Shared.Audio.Systems;
 using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 using System.Linq;
 
 namespace Content.Shared.SprayPainter;
 
 /// <summary>
-/// System for painting airlocks using a spray painter.
+/// System for painting paintable objects using a spray painter.
 /// Pipes are handled serverside since AtmosPipeColorSystem is server only.
 /// </summary>
 public abstract class SharedSprayPainterSystem : EntitySystem
 {
+    [Dependency] private readonly IGameTiming _timing = default!;
     [Dependency] protected readonly IPrototypeManager Proto = default!;
-    [Dependency] private   readonly ISharedAdminLogManager _adminLogger = default!;
+    [Dependency] protected readonly ISharedAdminLogManager AdminLogger = default!;
     [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
     [Dependency] protected readonly SharedAudioSystem Audio = default!;
+    [Dependency] protected readonly SharedChargesSystem Charges = default!;
     [Dependency] protected readonly SharedDoAfterSystem DoAfter = default!;
-    [Dependency] private   readonly SharedPopupSystem _popup = default!;
-
-    public List<AirlockStyle> Styles { get; private set; } = new();
-    public List<AirlockGroupPrototype> Groups { get; private set; } = new();
-
-    private static readonly ProtoId<AirlockDepartmentsPrototype> Departments = "Departments";
+    [Dependency] private readonly SharedPopupSystem _popup = default!;
 
     public override void Initialize()
     {
         base.Initialize();
 
-        CacheStyles();
-
         SubscribeLocalEvent<SprayPainterComponent, MapInitEvent>(OnMapInit);
-        SubscribeLocalEvent<SprayPainterComponent, SprayPainterDoorDoAfterEvent>(OnDoorDoAfter);
-        Subs.BuiEvents<SprayPainterComponent>(SprayPainterUiKey.Key, subs =>
-        {
-            subs.Event<SprayPainterSpritePickedMessage>(OnSpritePicked);
-            subs.Event<SprayPainterColorPickedMessage>(OnColorPicked);
-        });
 
-        SubscribeLocalEvent<PaintableAirlockComponent, InteractUsingEvent>(OnAirlockInteract);
+        SubscribeLocalEvent<SprayPainterComponent, SprayPainterDoAfterEvent>(OnPainterDoAfter);
+        SubscribeLocalEvent<SprayPainterComponent, GetVerbsEvent<AlternativeVerb>>(OnPainterGetAltVerbs);
+        SubscribeLocalEvent<PaintableComponent, InteractUsingEvent>(OnPaintableInteract);
+        SubscribeLocalEvent<PaintedComponent, ExaminedEvent>(OnPainedExamined);
 
-        SubscribeLocalEvent<PrototypesReloadedEventArgs>(OnPrototypesReloaded);
+        Subs.BuiEvents<SprayPainterComponent>(SprayPainterUiKey.Key,
+            subs =>
+            {
+                subs.Event<SprayPainterSetPaintableStyleMessage>(OnSetPaintable);
+                subs.Event<SprayPainterSetPipeColorMessage>(OnSetPipeColor);
+                subs.Event<SprayPainterTabChangedMessage>(OnTabChanged);
+                subs.Event<SprayPainterSetDecalMessage>(OnSetDecal);
+                subs.Event<SprayPainterSetDecalColorMessage>(OnSetDecalColor);
+                subs.Event<SprayPainterSetDecalAngleMessage>(OnSetDecalAngle);
+                subs.Event<SprayPainterSetDecalSnapMessage>(OnSetDecalSnap);
+            });
     }
 
     private void OnMapInit(Entity<SprayPainterComponent> ent, ref MapInitEvent args)
     {
-        if (ent.Comp.ColorPalette.Count == 0)
+        bool stylesByGroupPopulated = false;
+        foreach (var groupProto in Proto.EnumeratePrototypes<PaintableGroupPrototype>())
+        {
+            ent.Comp.StylesByGroup[groupProto.ID] = groupProto.DefaultStyle;
+            stylesByGroupPopulated = true;
+        }
+        if (stylesByGroupPopulated)
+            Dirty(ent);
+
+        if (ent.Comp.ColorPalette.Count > 0)
+            SetPipeColor(ent, ent.Comp.ColorPalette.First().Key);
+    }
+
+    private void SetPipeColor(Entity<SprayPainterComponent> ent, string? paletteKey)
+    {
+        if (paletteKey == null || paletteKey == ent.Comp.PickedColor)
+            return;
+
+        if (!ent.Comp.ColorPalette.ContainsKey(paletteKey))
             return;
 
-        SetColor(ent, ent.Comp.ColorPalette.First().Key);
+        ent.Comp.PickedColor = paletteKey;
+        Dirty(ent);
+        UpdateUi(ent);
     }
 
-    private void OnDoorDoAfter(Entity<SprayPainterComponent> ent, ref SprayPainterDoorDoAfterEvent args)
+    #region Interaction
+
+    private void OnPainterDoAfter(Entity<SprayPainterComponent> ent, ref SprayPainterDoAfterEvent args)
     {
         if (args.Handled || args.Cancelled)
             return;
 
-        if (args.Args.Target is not {} target)
+        if (args.Args.Target is not { } target)
             return;
 
-        if (!TryComp<PaintableAirlockComponent>(target, out var airlock))
+        if (!HasComp<PaintableComponent>(target))
             return;
 
-        airlock.Department = args.Department;
-        Dirty(target, airlock);
-
+        Appearance.SetData(target, PaintableVisuals.Prototype, args.Prototype);
         Audio.PlayPredicted(ent.Comp.SpraySound, ent, args.Args.User);
-        Appearance.SetData(target, DoorVisuals.BaseRSI, args.Sprite);
-        _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.Args.User):user} painted {ToPrettyString(args.Args.Target.Value):target}");
+        Charges.TryUseCharges(new Entity<LimitedChargesComponent?>(ent, EnsureComp<LimitedChargesComponent>(ent)), args.Cost);
 
-        args.Handled = true;
-    }
+        var paintedComponent = EnsureComp<PaintedComponent>(target);
+        paintedComponent.DryTime = _timing.CurTime + ent.Comp.FreshPaintDuration;
+        Dirty(target, paintedComponent);
 
-    #region UI messages
+        var ev = new EntityPaintedEvent(
+            User: args.User,
+            Tool: ent,
+            Prototype: args.Prototype,
+            Group: args.Group);
+        RaiseLocalEvent(target, ref ev);
 
-    private void OnColorPicked(Entity<SprayPainterComponent> ent, ref SprayPainterColorPickedMessage args)
-    {
-        SetColor(ent, args.Key);
+        AdminLogger.Add(LogType.Action,
+            LogImpact.Low,
+            $"{ToPrettyString(args.Args.User):user} painted {ToPrettyString(args.Args.Target.Value):target}");
+
+        args.Handled = true;
     }
 
-    private void OnSpritePicked(Entity<SprayPainterComponent> ent, ref SprayPainterSpritePickedMessage args)
+    private void OnPainterGetAltVerbs(Entity<SprayPainterComponent> ent, ref GetVerbsEvent<AlternativeVerb> args)
     {
-        if (args.Index >= Styles.Count)
+        if (!args.CanAccess || !args.CanInteract || !args.Using.HasValue)
             return;
 
-        ent.Comp.Index = args.Index;
-        Dirty(ent, ent.Comp);
+        var user = args.User;
+
+        AlternativeVerb verb = new()
+        {
+            Text = Loc.GetString("spray-painter-verb-toggle-decals"),
+            Icon = new SpriteSpecifier.Texture(new ResPath("/Textures/Interface/VerbIcons/settings.svg.192dpi.png")),
+            Act = () => TogglePaintDecals(ent, user),
+            Impact = LogImpact.Low
+        };
+        args.Verbs.Add(verb);
     }
 
-    private void SetColor(Entity<SprayPainterComponent> ent, string? paletteKey)
+    /// <summary>
+    /// Toggles whether clicking on the floor paints a decal or not.
+    /// </summary>
+    private void TogglePaintDecals(Entity<SprayPainterComponent> ent, EntityUid user)
     {
-        if (paletteKey == null || paletteKey == ent.Comp.PickedColor)
+        if (!_timing.IsFirstTimePredicted)
             return;
 
-        if (!ent.Comp.ColorPalette.ContainsKey(paletteKey))
-            return;
+        var pitch = 1.0f;
+        switch (ent.Comp.DecalMode)
+        {
+            case DecalPaintMode.Off:
+            default:
+                ent.Comp.DecalMode = DecalPaintMode.Add;
+                pitch = 1.0f;
+                break;
+            case DecalPaintMode.Add:
+                ent.Comp.DecalMode = DecalPaintMode.Remove;
+                pitch = 1.2f;
+                break;
+            case DecalPaintMode.Remove:
+                ent.Comp.DecalMode = DecalPaintMode.Off;
+                pitch = 0.8f;
+                break;
+        }
+        Dirty(ent);
 
-        ent.Comp.PickedColor = paletteKey;
-        Dirty(ent, ent.Comp);
+        // Make the machine beep.
+        Audio.PlayPredicted(ent.Comp.SoundSwitchDecalMode, ent, user, ent.Comp.SoundSwitchDecalMode.Params.WithPitchScale(pitch));
     }
 
-    #endregion
-
-    private void OnAirlockInteract(Entity<PaintableAirlockComponent> ent, ref InteractUsingEvent args)
+    /// <summary>
+    /// Handles spray paint interactions with an object.
+    /// An object must belong to a spray paintable group to be painted, and the painter must have sufficient ammo to paint it.
+    /// </summary>
+    private void OnPaintableInteract(Entity<PaintableComponent> ent, ref InteractUsingEvent args)
     {
         if (args.Handled)
             return;
@@ -116,79 +179,140 @@ public abstract class SharedSprayPainterSystem : EntitySystem
         if (!TryComp<SprayPainterComponent>(args.Used, out var painter))
             return;
 
-        var group = Proto.Index<AirlockGroupPrototype>(ent.Comp.Group);
+        if (ent.Comp.Group is not { } group
+            || !painter.StylesByGroup.TryGetValue(group, out var selectedStyle)
+            || !Proto.TryIndex(group, out PaintableGroupPrototype? targetGroup))
+            return;
+
+        // Valid paint target.
+        args.Handled = true;
+
+        if (TryComp<LimitedChargesComponent>(args.Used, out var charges)
+            && charges.LastCharges < targetGroup.Cost)
+        {
+            var msg = Loc.GetString("spray-painter-interact-no-charges");
+            _popup.PopupClient(msg, args.User, args.User);
+            return;
+        }
 
-        var style = Styles[painter.Index];
-        if (!group.StylePaths.TryGetValue(style.Name, out var sprite))
+        if (!targetGroup.Styles.TryGetValue(selectedStyle, out var proto))
         {
-            string msg = Loc.GetString("spray-painter-style-not-available");
+            var msg = Loc.GetString("spray-painter-style-not-available");
             _popup.PopupClient(msg, args.User, args.User);
             return;
         }
 
-        var doAfterEventArgs = new DoAfterArgs(EntityManager, args.User, painter.AirlockSprayTime, new SprayPainterDoorDoAfterEvent(sprite, style.Department), args.Used, target: ent, used: args.Used)
+        var doAfterEventArgs = new DoAfterArgs(EntityManager,
+            args.User,
+            targetGroup.Time,
+            new SprayPainterDoAfterEvent(proto, group, targetGroup.Cost),
+            args.Used,
+            target: ent,
+            used: args.Used)
         {
             BreakOnMove = true,
             BreakOnDamage = true,
             NeedHand = true,
         };
-        if (!DoAfter.TryStartDoAfter(doAfterEventArgs, out var id))
-            return;
 
-        args.Handled = true;
+        if (!DoAfter.TryStartDoAfter(doAfterEventArgs, out _))
+            return;
 
         // Log the attempt
-        _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.User):user} is painting {ToPrettyString(ent):target} to '{style.Name}' at {Transform(ent).Coordinates:targetlocation}");
+        AdminLogger.Add(LogType.Action,
+            LogImpact.Low,
+            $"{ToPrettyString(args.User):user} is painting {ToPrettyString(ent):target} to '{selectedStyle}' at {Transform(ent).Coordinates:targetlocation}");
     }
 
-    #region Style caching
+    /// <summary>
+    /// Prints out if an object has been painted recently.
+    /// </summary>
+    private void OnPainedExamined(Entity<PaintedComponent> ent, ref ExaminedEvent args)
+    {
+        // If the paint's dried, it isn't detectable.
+        if (_timing.CurTime > ent.Comp.DryTime)
+            return;
 
-    private void OnPrototypesReloaded(PrototypesReloadedEventArgs args)
+        args.PushText(Loc.GetString("spray-painter-on-examined-painted-message"));
+    }
+
+    #endregion Interaction
+
+    #region UI
+
+    /// <summary>
+    /// Sets the style that a particular type of paintable object (e.g. lockers) should be painted in.
+    /// </summary>
+    private void OnSetPaintable(Entity<SprayPainterComponent> ent, ref SprayPainterSetPaintableStyleMessage args)
     {
-        if (!args.WasModified<AirlockGroupPrototype>() && !args.WasModified<AirlockDepartmentsPrototype>())
+        if (!ent.Comp.StylesByGroup.ContainsKey(args.Group))
             return;
 
-        Styles.Clear();
-        Groups.Clear();
-        CacheStyles();
+        ent.Comp.StylesByGroup[args.Group] = args.Style;
+        Dirty(ent);
+        UpdateUi(ent);
+    }
 
-        // style index might be invalid now so check them all
-        var max = Styles.Count - 1;
-        var query = AllEntityQuery<SprayPainterComponent>();
-        while (query.MoveNext(out var uid, out var comp))
-        {
-            if (comp.Index > max)
-            {
-                comp.Index = max;
-                Dirty(uid, comp);
-            }
-        }
+    /// <summary>
+    /// Changes the color to paint pipes in.
+    /// </summary>
+    private void OnSetPipeColor(Entity<SprayPainterComponent> ent, ref SprayPainterSetPipeColorMessage args)
+    {
+        SetPipeColor(ent, args.Key);
     }
 
-    protected virtual void CacheStyles()
+    /// <summary>
+    /// Tracks the tab the spray painter was on.
+    /// </summary>
+    private void OnTabChanged(Entity<SprayPainterComponent> ent, ref SprayPainterTabChangedMessage args)
     {
-        // collect every style's name
-        var names = new SortedSet<string>();
-        foreach (var group in Proto.EnumeratePrototypes<AirlockGroupPrototype>())
-        {
-            Groups.Add(group);
-            foreach (var style in group.StylePaths.Keys)
-            {
-                names.Add(style);
-            }
-        }
+        ent.Comp.SelectedTab = args.Index;
+        Dirty(ent);
+    }
 
-        // get their department ids too for the final style list
-        var departments = Proto.Index(Departments);
-        Styles.Capacity = names.Count;
-        foreach (var name in names)
-        {
-            departments.Departments.TryGetValue(name, out var department);
-            Styles.Add(new AirlockStyle(name, department));
-        }
+    /// <summary>
+    /// Sets the decal prototype to paint.
+    /// </summary>
+    private void OnSetDecal(Entity<SprayPainterComponent> ent, ref SprayPainterSetDecalMessage args)
+    {
+        ent.Comp.SelectedDecal = args.DecalPrototype;
+        Dirty(ent);
+        UpdateUi(ent);
+    }
+
+    /// <summary>
+    /// Sets the angle to paint decals at.
+    /// </summary>
+    private void OnSetDecalAngle(Entity<SprayPainterComponent> ent, ref SprayPainterSetDecalAngleMessage args)
+    {
+        ent.Comp.SelectedDecalAngle = args.Angle;
+        Dirty(ent);
+        UpdateUi(ent);
+    }
+
+    /// <summary>
+    /// Enables or disables snap-to-grid when painting decals.
+    /// </summary>
+    private void OnSetDecalSnap(Entity<SprayPainterComponent> ent, ref SprayPainterSetDecalSnapMessage args)
+    {
+        ent.Comp.SnapDecals = args.Snap;
+        Dirty(ent);
+        UpdateUi(ent);
+    }
+
+    /// <summary>
+    /// Sets the decal to paint on the ground.
+    /// </summary>
+    private void OnSetDecalColor(Entity<SprayPainterComponent> ent, ref SprayPainterSetDecalColorMessage args)
+    {
+        ent.Comp.SelectedDecalColor = args.Color;
+        Dirty(ent);
+        UpdateUi(ent);
+    }
+
+    protected virtual void UpdateUi(Entity<SprayPainterComponent> ent)
+    {
     }
 
     #endregion
 }
-
-public record struct AirlockStyle(string Name, string? Department);
diff --git a/Content.Shared/SprayPainter/SprayPainterAmmoSystem.cs b/Content.Shared/SprayPainter/SprayPainterAmmoSystem.cs
new file mode 100644 (file)
index 0000000..d43420e
--- /dev/null
@@ -0,0 +1,62 @@
+using Content.Shared.Charges.Components;
+using Content.Shared.Charges.Systems;
+using Content.Shared.Examine;
+using Content.Shared.Interaction;
+using Content.Shared.Popups;
+using Content.Shared.SprayPainter.Components;
+
+namespace Content.Shared.SprayPainter;
+
+/// <summary>
+/// The system handles interactions with spray painter ammo.
+/// </summary>
+public sealed class SprayPainterAmmoSystem : EntitySystem
+{
+    [Dependency] private readonly SharedChargesSystem _charges = default!;
+    [Dependency] private readonly SharedPopupSystem _popup = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<SprayPainterAmmoComponent, ExaminedEvent>(OnExamine);
+        SubscribeLocalEvent<SprayPainterAmmoComponent, AfterInteractEvent>(OnAfterInteract);
+    }
+
+    private void OnAfterInteract(Entity<SprayPainterAmmoComponent> ent, ref AfterInteractEvent args)
+    {
+        if (args.Handled || !args.CanReach)
+            return;
+
+        if (args.Target is not { Valid: true } target ||
+            !HasComp<SprayPainterComponent>(target) ||
+            !TryComp<LimitedChargesComponent>(target, out var charges))
+            return;
+
+        var user = args.User;
+        args.Handled = true;
+        var count = Math.Min(charges.MaxCharges - charges.LastCharges, ent.Comp.Charges);
+        if (count <= 0)
+        {
+            _popup.PopupClient(Loc.GetString("spray-painter-ammo-after-interact-full"), target, user);
+            return;
+        }
+
+        _popup.PopupClient(Loc.GetString("spray-painter-ammo-after-interact-refilled"), target, user);
+        _charges.AddCharges(target, count);
+        ent.Comp.Charges -= count;
+        Dirty(ent, ent.Comp);
+
+        if (ent.Comp.Charges <= 0)
+            PredictedQueueDel(ent.Owner);
+    }
+
+    private void OnExamine(Entity<SprayPainterAmmoComponent> ent, ref ExaminedEvent args)
+    {
+        if (!args.IsInDetailsRange)
+            return;
+
+        var examineMessage = Loc.GetString("rcd-ammo-component-on-examine", ("charges", ent.Comp.Charges));
+        args.PushText(examineMessage);
+    }
+}
index b88b054ad14f7fb6ee556a9f0a2092f66ccc65c5..db9de9c27879f6fe3429244b9dee78dafdacc984 100644 (file)
@@ -1,4 +1,7 @@
+using Content.Shared.Decals;
 using Content.Shared.DoAfter;
+using Content.Shared.SprayPainter.Prototypes;
+using Robust.Shared.Prototypes;
 using Robust.Shared.Serialization;
 
 namespace Content.Shared.SprayPainter;
@@ -10,46 +13,75 @@ public enum SprayPainterUiKey
 }
 
 [Serializable, NetSerializable]
-public sealed class SprayPainterSpritePickedMessage : BoundUserInterfaceMessage
+public sealed class SprayPainterSetDecalMessage(ProtoId<DecalPrototype> protoId) : BoundUserInterfaceMessage
 {
-    public readonly int Index;
+    public ProtoId<DecalPrototype> DecalPrototype = protoId;
+}
 
-    public SprayPainterSpritePickedMessage(int index)
-    {
-        Index = index;
-    }
+[Serializable, NetSerializable]
+public sealed class SprayPainterSetDecalColorMessage(Color? color) : BoundUserInterfaceMessage
+{
+    public Color? Color = color;
 }
 
 [Serializable, NetSerializable]
-public sealed class SprayPainterColorPickedMessage : BoundUserInterfaceMessage
+public sealed class SprayPainterSetDecalSnapMessage(bool snap) : BoundUserInterfaceMessage
 {
-    public readonly string? Key;
+    public bool Snap = snap;
+}
 
-    public SprayPainterColorPickedMessage(string? key)
-    {
-        Key = key;
-    }
+[Serializable, NetSerializable]
+public sealed class SprayPainterSetDecalAngleMessage(int angle) : BoundUserInterfaceMessage
+{
+    public int Angle = angle;
+}
+
+[Serializable, NetSerializable]
+public sealed class SprayPainterTabChangedMessage(int index, bool isSelectedTabWithDecals) : BoundUserInterfaceMessage
+{
+    public readonly int Index = index;
+    public readonly bool IsSelectedTabWithDecals = isSelectedTabWithDecals;
 }
 
 [Serializable, NetSerializable]
-public sealed partial class SprayPainterDoorDoAfterEvent : DoAfterEvent
+public sealed class SprayPainterSetPaintableStyleMessage(string group, string style) : BoundUserInterfaceMessage
 {
+    public readonly string Group = group;
+    public readonly string Style = style;
+}
+
+[Serializable, NetSerializable]
+public sealed class SprayPainterSetPipeColorMessage(string? key) : BoundUserInterfaceMessage
+{
+    public readonly string? Key = key;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class SprayPainterDoAfterEvent : DoAfterEvent
+{
+    /// <summary>
+    /// The prototype to use to repaint this object.
+    /// </summary>
+    [DataField]
+    public string Prototype;
+
     /// <summary>
-    /// Base RSI path to set for the door sprite.
+    /// The group ID of the object being painted.
     /// </summary>
     [DataField]
-    public string Sprite;
+    public string Group;
 
     /// <summary>
-    /// Department id to set for the door, if the style has one.
+    /// The cost, in charges, to paint this object.
     /// </summary>
     [DataField]
-    public string? Department;
+    public int Cost;
 
-    public SprayPainterDoorDoAfterEvent(string sprite, string? department)
+    public SprayPainterDoAfterEvent(string prototype, string group, int cost)
     {
-        Sprite = sprite;
-        Department = department;
+        Prototype = prototype;
+        Group = group;
+        Cost = cost;
     }
 
     public override DoAfterEvent Clone() => this;
@@ -71,3 +103,17 @@ public sealed partial class SprayPainterPipeDoAfterEvent : DoAfterEvent
 
     public override DoAfterEvent Clone() => this;
 }
+
+/// <summary>
+/// An action raised on an entity when it is spray painted.
+/// </summary>
+/// <param name="User">The entity painting this item.</param>
+/// <param name="Tool">The entity used to paint this item.</param>
+/// <param name="Prototype">The prototype used to generate the new painted appearance.</param>
+/// <param name="Group">The group of the entity being painted (e.g. airlocks with glass, canisters).</param>
+[ByRefEvent]
+public partial record struct EntityPaintedEvent(
+    EntityUid? User,
+    EntityUid Tool,
+    EntProtoId Prototype,
+    ProtoId<PaintableGroupPrototype> Group);
diff --git a/Resources/Locale/en-US/engineer-painter/engineer-painter.ftl b/Resources/Locale/en-US/engineer-painter/engineer-painter.ftl
deleted file mode 100644 (file)
index d3d3ccc..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-spray-painter-window-title = Spray painter
-
-spray-painter-style-not-available = Cannot apply the selected style to this type of airlock
-spray-painter-selected-style = Selected style:
-
-spray-painter-selected-color = Selected color:
-spray-painter-color-red = red
-spray-painter-color-yellow = yellow
-spray-painter-color-brown = brown
-spray-painter-color-green = green
-spray-painter-color-cyan = cyan
-spray-painter-color-blue = blue
-spray-painter-color-white = white
-spray-painter-color-black = black
diff --git a/Resources/Locale/en-US/spray-painter/spray-painter.ftl b/Resources/Locale/en-US/spray-painter/spray-painter.ftl
new file mode 100644 (file)
index 0000000..dc54c5c
--- /dev/null
@@ -0,0 +1,194 @@
+# Components
+spray-painter-ammo-on-examine = It holds {$charges} charges.
+spray-painter-ammo-after-interact-full = The spray painter is full!
+spray-painter-ammo-after-interact-refilled = You refill the spray painter.
+
+spray-painter-interact-no-charges = Not enough paint left.
+spray-painter-interact-nothing-to-remove = Nothing to remove!
+
+spray-painter-on-examined-painted-message = It seems to have been freshly painted.
+spray-painter-style-not-available = Cannot apply the selected style to this object.
+
+spray-painter-verb-toggle-decals = Toggle decal painting
+
+spray-painter-item-status-label = Decals: {$mode}
+spray-painter-item-status-add = [color=green]Add[/color]
+spray-painter-item-status-remove = [color=red]Remove[/color]
+spray-painter-item-status-off = [color=gray]Off[/color]
+
+# UI
+spray-painter-window-title = Spray Painter
+
+spray-painter-selected-style = Selected style:
+
+spray-painter-selected-decals = Selected decal:
+spray-painter-use-custom-color = Use custom color
+spray-painter-use-snap-to-tile = Snap to tile
+
+spray-painter-angle-rotation = Rotation:
+spray-painter-angle-rotation-90-sub = -90°
+spray-painter-angle-rotation-reset = 0°
+spray-painter-angle-rotation-90-add = +90°
+
+spray-painter-selected-color = Selected color:
+spray-painter-color-red = red
+spray-painter-color-yellow = yellow
+spray-painter-color-brown = brown
+spray-painter-color-green = green
+spray-painter-color-cyan = cyan
+spray-painter-color-blue = blue
+spray-painter-color-white = white
+spray-painter-color-black = black
+
+# Categories (tabs)
+spray-painter-tab-category-airlocks = Airlocks
+spray-painter-tab-category-canisters = Canisters
+spray-painter-tab-category-crates = Crates
+spray-painter-tab-category-lockers = Lockers
+spray-painter-tab-category-pipes = Pipes
+spray-painter-tab-category-decals = Decals
+
+# Groups (subtabs)
+spray-painter-tab-group-airlockstandard = Standard
+spray-painter-tab-group-airlockglass = Glass
+
+spray-painter-tab-group-cratesteel = Steel
+spray-painter-tab-group-crateplastic = Plastic
+spray-painter-tab-group-cratesecure = Secure
+
+spray-painter-tab-group-closet = Unlocked
+spray-painter-tab-group-locker = Secure
+spray-painter-tab-group-wallcloset = Unlocked (Wall)
+spray-painter-tab-group-walllocker = Secure (Wall)
+
+# Airlocks
+spray-painter-style-airlockstandard-atmospherics = Atmospheric
+spray-painter-style-airlockstandard-basic = Basic
+spray-painter-style-airlockstandard-cargo = Cargo
+spray-painter-style-airlockstandard-chemistry = Chemistry
+spray-painter-style-airlockstandard-command = Command
+spray-painter-style-airlockstandard-engineering = Engineering
+spray-painter-style-airlockstandard-freezer = Freezer
+spray-painter-style-airlockstandard-hydroponics = Hydroponics
+spray-painter-style-airlockstandard-maintenance = Maintenance
+spray-painter-style-airlockstandard-medical = Medical
+spray-painter-style-airlockstandard-salvage = Salvage
+spray-painter-style-airlockstandard-science = Science
+spray-painter-style-airlockstandard-security = Security
+spray-painter-style-airlockstandard-virology = Virology
+
+spray-painter-style-airlockglass-atmospherics = Atmospherics
+spray-painter-style-airlockglass-basic = Basic
+spray-painter-style-airlockglass-cargo = Cargo
+spray-painter-style-airlockglass-chemistry = Chemistry
+spray-painter-style-airlockglass-command = Command
+spray-painter-style-airlockglass-engineering = Engineering
+spray-painter-style-airlockglass-hydroponics = Hydroponics
+spray-painter-style-airlockglass-maintenance = Maintenance
+spray-painter-style-airlockglass-medical = Medical
+spray-painter-style-airlockglass-salvage = Salvage
+spray-painter-style-airlockglass-science = Science
+spray-painter-style-airlockglass-security = Security
+spray-painter-style-airlockglass-virology = Virology
+
+# Lockers
+spray-painter-style-locker-atmospherics = Atmospherics
+spray-painter-style-locker-basic = Basic
+spray-painter-style-locker-botanist = Botanist
+spray-painter-style-locker-brigmedic = Brigmedic
+spray-painter-style-locker-captain = Captain
+spray-painter-style-locker-ce = CE
+spray-painter-style-locker-chemical = Chemical
+spray-painter-style-locker-clown = Clown
+spray-painter-style-locker-cmo = CMO
+spray-painter-style-locker-doctor = Doctor
+spray-painter-style-locker-electrical = Electrical
+spray-painter-style-locker-engineer = Engineer
+spray-painter-style-locker-evac = Evac repair
+spray-painter-style-locker-hop = HOP
+spray-painter-style-locker-hos = HOS
+spray-painter-style-locker-medicine = Medicine
+spray-painter-style-locker-mime = Mime
+spray-painter-style-locker-paramedic = Paramedic
+spray-painter-style-locker-quartermaster = Quartermaster
+spray-painter-style-locker-rd = RD
+spray-painter-style-locker-representative = Representative
+spray-painter-style-locker-salvage = Salvage
+spray-painter-style-locker-scientist = Scientist
+spray-painter-style-locker-security = Security
+spray-painter-style-locker-welding = Welding
+
+spray-painter-style-closet-basic = Basic
+spray-painter-style-closet-biohazard = Biohazard
+spray-painter-style-closet-biohazard-science = Biohazard (science)
+spray-painter-style-closet-biohazard-virology = Biohazard (virology)
+spray-painter-style-closet-biohazard-security = Biohazard (security)
+spray-painter-style-closet-biohazard-janitor = Biohazard (janitor)
+spray-painter-style-closet-bomb = Bomb suit
+spray-painter-style-closet-bomb-janitor = Bomb suit (janitor)
+spray-painter-style-closet-chef = Chef
+spray-painter-style-closet-fire = Fire-safety
+spray-painter-style-closet-janitor = Janitor
+spray-painter-style-closet-legal = Lawyer
+spray-painter-style-closet-nitrogen = Internals (nitrogen)
+spray-painter-style-closet-oxygen = Internals (oxygen)
+spray-painter-style-closet-radiation = Radiation suit
+spray-painter-style-closet-tool = Tools
+
+spray-painter-style-wallcloset-atmospherics = Atmospherics
+spray-painter-style-wallcloset-basic = Basic
+spray-painter-style-wallcloset-black = Black
+spray-painter-style-wallcloset-blue = Blue
+spray-painter-style-wallcloset-fire = Fire-safety
+spray-painter-style-wallcloset-green = Green
+spray-painter-style-wallcloset-grey = Grey
+spray-painter-style-wallcloset-mixed = Mixed
+spray-painter-style-wallcloset-nitrogen = Internals (nitrogen)
+spray-painter-style-wallcloset-orange = Orange
+spray-painter-style-wallcloset-oxygen = Internals (oxygen)
+spray-painter-style-wallcloset-pink = Pink
+spray-painter-style-wallcloset-white = White
+spray-painter-style-wallcloset-yellow = Yellow
+
+spray-painter-style-walllocker-evac = Evac repair
+spray-painter-style-walllocker-medical = Medical
+
+# Crates
+spray-painter-style-cratesteel-basic = Basic
+spray-painter-style-cratesteel-electrical = Electrical
+spray-painter-style-cratesteel-engineering = Engineering
+spray-painter-style-cratesteel-radiation = Radiation
+spray-painter-style-cratesteel-science = Science
+spray-painter-style-cratesteel-surgery = Surgery
+
+spray-painter-style-crateplastic-basic = Basic
+spray-painter-style-crateplastic-chemistry = Chemistry
+spray-painter-style-crateplastic-command = Command
+spray-painter-style-crateplastic-hydroponics = Hydroponics
+spray-painter-style-crateplastic-medical = Medical
+spray-painter-style-crateplastic-oxygen = Oxygen
+
+spray-painter-style-cratesecure-basic = Basic
+spray-painter-style-cratesecure-chemistry = Chemistry
+spray-painter-style-cratesecure-command = Command
+spray-painter-style-cratesecure-engineering = Engineering
+spray-painter-style-cratesecure-hydroponics = Hydroponics
+spray-painter-style-cratesecure-medical = Medical
+spray-painter-style-cratesecure-plasma = Plasma
+spray-painter-style-cratesecure-private = Private
+spray-painter-style-cratesecure-science = Science
+spray-painter-style-cratesecure-secgear = Secgear
+spray-painter-style-cratesecure-weapon = Weapon
+
+# Canisters
+spray-painter-style-canisters-air = Air
+spray-painter-style-canisters-ammonia = Ammonia
+spray-painter-style-canisters-carbon-dioxide = Carbon dioxide
+spray-painter-style-canisters-frezon = Frezon
+spray-painter-style-canisters-nitrogen = Nitrogen
+spray-painter-style-canisters-nitrous-oxide = Nitrous oxide
+spray-painter-style-canisters-oxygen = Oxygen
+spray-painter-style-canisters-plasma = Plasma
+spray-painter-style-canisters-storage = Storage
+spray-painter-style-canisters-tritium = Tritium
+spray-painter-style-canisters-water-vapor = Water vapor
index 2231c714ff93241911bc91472e1bccb63ebd3570..71beff3cf5220da647e66d384eb9c2cc334cbbb2 100644 (file)
@@ -13,6 +13,7 @@
     FlashlightLantern: 5
     ClothingHandsGlovesColorYellowBudget: 3
     SprayPainter: 3
+    SprayPainterAmmo: 5
   # Some engineer forgot to take the multitool out the youtool when working on it, happens.
   contrabandInventory:
     Multitool: 1
index 61fe2c90e42f0b30a93dd495c779f226000ea914..29bbb9089d297c641cbc7cb326bb3bb149904022 100644 (file)
@@ -35,6 +35,7 @@
       components:
         - StationMap
         - SprayPainter
+        - SprayPainterAmmo
         - NetworkConfigurator
         - RCD
         - RCDAmmo
       components:
         - StationMap
         - SprayPainter
+        - SprayPainterAmmo
         - NetworkConfigurator
         - RCD
         - RCDAmmo
index 679c6f22fe2869cbba65ef34a0faf68e8f1b703c..23d7b68d37d9a8f223ff49acb9a1f222f2a5ba28 100644 (file)
@@ -2,7 +2,7 @@
   parent: BaseItem
   id: SprayPainter
   name: spray painter
-  description: A spray painter for painting airlocks and pipes.
+  description: A spray painter for painting airlocks, pipes, and other items.
   components:
   - type: Sprite
     sprite: Objects/Tools/spray_painter.rsi
       mix: '#947507'
   - type: StaticPrice
     price: 40
+  - type: LimitedCharges
+    maxCharges: 15
+    lastCharges: 15
   - type: PhysicalComposition
     materialComposition:
       Steel: 100
+
+- type: entity
+  parent: SprayPainter
+  id: SprayPainterRecharging
+  suffix: Admeme
+  components:
+  - type: AutoRecharge
+    rechargeDuration: 1
+
+- type: entity
+  parent: SprayPainter
+  id: SprayPainterEmpty
+  suffix: Empty
+  components:
+  - type: LimitedCharges
+    lastCharges: -1
+
+- type: entity
+  parent: BaseItem
+  id: SprayPainterAmmo
+  name: compressed paint
+  description: A cartridge of highly compressed paint, commonly used in spray painters.
+  components:
+  - type: SprayPainterAmmo
+  - type: Sprite
+    sprite: Objects/Tools/spray_painter.rsi
+    state: ammo
+  - type: Item
+    sprite: Objects/Tools/spray_painter.rsi
+    heldPrefix: ammo
+  - type: PhysicalComposition
+    materialComposition:
+      Steel: 10
+      Plastic: 10
+  - type: StaticPrice
+    price: 30
index f61b97076fbbd6b610a1e6accc98effe2e7ff702..67e46649efdc54e0f8080eac13ab65c9d4353e81 100644 (file)
@@ -15,8 +15,6 @@
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/engineering.rsi
-  - type: PaintableAirlock
-    department: Engineering
   - type: Wires
     layoutId: AirlockEngineering
 
@@ -35,8 +33,6 @@
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/cargo.rsi
-  - type: PaintableAirlock
-    department: Cargo
   - type: Wires
     layoutId: AirlockCargo
 
@@ -67,8 +63,6 @@
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/medical.rsi
-  - type: PaintableAirlock
-    department: Medical
   - type: Wires
     layoutId: AirlockMedical
 
@@ -95,8 +89,6 @@
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/science.rsi
-  - type: PaintableAirlock
-    department: Science
   - type: Wires
     layoutId: AirlockScience
 
     sprite: Structures/Doors/Airlocks/Standard/command.rsi
   - type: WiresPanelSecurity
     securityLevel: medSecurity
-  - type: PaintableAirlock
-    department: Command
   - type: Wires
     layoutId: AirlockCommand
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/security.rsi
-  - type: PaintableAirlock
-    department: Security
   - type: Wires
     layoutId: AirlockSecurity
 
     sprite: Structures/Doors/Airlocks/Standard/mining.rsi
   - type: Wires
     layoutId: AirlockCargo
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: AirlockCommand # if you get centcom door somehow it counts as command, also inherit panel
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/hatch.rsi
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: Airlock
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/hatch_maint.rsi
+  - type: Paintable
+    group: null
 
 # Glass
 - type: entity
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/engineering.rsi
-  - type: PaintableAirlock
-    department: Engineering
   - type: Wires
     layoutId: AirlockEngineering
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/cargo.rsi
-  - type: PaintableAirlock
-    department: Cargo
   - type: Wires
     layoutId: AirlockCargo
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/medical.rsi
-  - type: PaintableAirlock
-    department: Medical
   - type: Wires
     layoutId: AirlockMedical
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/science.rsi
-  - type: PaintableAirlock
-    department: Science
   - type: Wires
     layoutId: AirlockScience
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/command.rsi
-  - type: PaintableAirlock
-    department: Command
   - type: WiresPanelSecurity
     securityLevel: medSecurity
   - type: Wires
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/security.rsi
-  - type: PaintableAirlock
-    department: Security
   - type: Wires
     layoutId: AirlockSecurity
 
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/mining.rsi
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: AirlockCommandGlass # see standard
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/xeno.rsi
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: AirlockGlass
   components:
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/xeno.rsi
+  - type: Paintable
+    group: null
index 3e70b5ccbe36ecaa3ff95c3c549d7878cd431998..437076b0a2c782aa30492878809da07f108c5e1a 100644 (file)
     - board
   - type: PlacementReplacement
     key: walls
-  - type: PaintableAirlock
-    group: Standard
-    department: Civilian
+  - type: Paintable
+    group: AirlockStandard
   - type: StaticPrice
     price: 150
   - type: LightningTarget
   - type: Construction
     graph: Airlock
     node: glassAirlock
-  - type: PaintableAirlock
-    group: Glass
+  - type: Paintable
+    group: AirlockGlass
   - type: RadiationBlocker
     resistance: 2
   - type: Tag
index f6eeb9ee37d8fb968c6eae0c7e8393e2552ec3c8..123f1ef2c7f89499f97c423faa06e9654e05bc0b 100644 (file)
@@ -10,6 +10,8 @@
     node: airlock
     containers:
     - board
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: AirlockGlass
@@ -25,3 +27,5 @@
     - board
   - type: StaticPrice
     price: 165
+  - type: Paintable
+    group: null
index 7f5c01902505c136aae99e75c23764e5786855dd..773065bea04fdedc9c94058ffaadbd784f2ef50a 100644 (file)
       path: /Audio/Machines/airlock_deny.ogg
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Standard/external.rsi
-  - type: PaintableAirlock
-    group: External
-    department: null
   - type: Wires
     layoutId: AirlockExternal
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: AirlockExternal
@@ -33,8 +32,6 @@
     enabled: false
   - type: Sprite
     sprite: Structures/Doors/Airlocks/Glass/external.rsi
-  - type: PaintableAirlock
-    group: ExternalGlass
   - type: Fixtures
     fixtures:
       fix1:
index 3752821e46237c8126c8cfc426243a4c9560e5f1..cad40324c859550e450d202ae8a514bd3f5d8935 100644 (file)
   - type: Tag
     tags:
       - ForceNoFixRotations
-  - type: PaintableAirlock
-    group: Shuttle
-    department: null
   - type: Construction
     graph: AirlockShuttle
     node: airlock
   - type: StaticPrice
     price: 350
+  - type: Paintable
+    group: null
 
 - type: entity
   id: AirlockGlassShuttle
@@ -72,8 +71,6 @@
     sprite: Structures/Doors/Airlocks/Glass/shuttle.rsi
   - type: Occluder
     enabled: false
-  - type: PaintableAirlock
-    group: ShuttleGlass
   - type: Door
     occludes: false
   - type: Fixtures
diff --git a/Resources/Prototypes/Entities/Structures/Doors/airlock_groups.yml b/Resources/Prototypes/Entities/Structures/Doors/airlock_groups.yml
deleted file mode 100644 (file)
index 76b21ac..0000000
+++ /dev/null
@@ -1,87 +0,0 @@
-- type: AirlockGroup
-  id: Standard
-  iconPriority: 100
-  stylePaths:
-    atmospherics: Structures/Doors/Airlocks/Standard/atmospherics.rsi
-    basic:       Structures/Doors/Airlocks/Standard/basic.rsi
-    cargo:       Structures/Doors/Airlocks/Standard/cargo.rsi
-    chemistry:   Structures/Doors/Airlocks/Standard/chemistry.rsi
-    command:     Structures/Doors/Airlocks/Standard/command.rsi
-    engineering: Structures/Doors/Airlocks/Standard/engineering.rsi
-    freezer:     Structures/Doors/Airlocks/Standard/freezer.rsi
-    hydroponics: Structures/Doors/Airlocks/Standard/hydroponics.rsi
-    maintenance: Structures/Doors/Airlocks/Standard/maint.rsi
-    medical:     Structures/Doors/Airlocks/Standard/medical.rsi
-    salvage:     Structures/Doors/Airlocks/Standard/salvage.rsi
-    science:     Structures/Doors/Airlocks/Standard/science.rsi
-    security:    Structures/Doors/Airlocks/Standard/security.rsi
-    virology:    Structures/Doors/Airlocks/Standard/virology.rsi
-
-- type: AirlockGroup
-  id: Glass
-  iconPriority: 90
-  stylePaths:
-    atmospherics: Structures/Doors/Airlocks/Glass/atmospherics.rsi
-    basic:       Structures/Doors/Airlocks/Glass/basic.rsi
-    cargo:       Structures/Doors/Airlocks/Glass/cargo.rsi
-    command:     Structures/Doors/Airlocks/Glass/command.rsi
-    chemistry:   Structures/Doors/Airlocks/Glass/chemistry.rsi
-    science:     Structures/Doors/Airlocks/Glass/science.rsi
-    engineering: Structures/Doors/Airlocks/Glass/engineering.rsi
-    glass:       Structures/Doors/Airlocks/Glass/glass.rsi
-    hydroponics: Structures/Doors/Airlocks/Glass/hydroponics.rsi
-    maintenance: Structures/Doors/Airlocks/Glass/maint.rsi
-    medical:     Structures/Doors/Airlocks/Glass/medical.rsi
-    salvage:     Structures/Doors/Airlocks/Glass/salvage.rsi
-    security:    Structures/Doors/Airlocks/Glass/security.rsi
-    virology:    Structures/Doors/Airlocks/Glass/virology.rsi
-
-- type: AirlockGroup
-  id: Windoor
-  iconPriority: 80
-  stylePaths:
-    basic:       Structures/Doors/Airlocks/Glass/glass.rsi
-
-- type: AirlockGroup
-  id: External
-  iconPriority: 70
-  stylePaths:
-    external:    Structures/Doors/Airlocks/Standard/external.rsi
-
-- type: AirlockGroup
-  id: ExternalGlass
-  iconPriority: 60
-  stylePaths:
-    external:    Structures/Doors/Airlocks/Glass/external.rsi
-
-- type: AirlockGroup
-  id: Shuttle
-  iconPriority: 50
-  stylePaths:
-    shuttle:     Structures/Doors/Airlocks/Standard/shuttle.rsi
-
-- type: AirlockGroup
-  id: ShuttleGlass
-  iconPriority: 40
-  stylePaths:
-    shuttle:     Structures/Doors/Airlocks/Glass/shuttle.rsi
-
-# fun
-- type: airlockDepartments
-  id: Departments
-  departments:
-    atmospherics: Engineering
-    basic: Civilian
-    cargo: Cargo
-    chemistry: Medical
-    command: Command
-    engineering: Engineering
-    freezer: Civilian
-    glass: Civilian
-    hydroponics: Civilian
-    maintenance: Civilian
-    medical: Medical
-    salvage: Cargo
-    science: Science
-    security: Security
-    virology: Medical
index e7e1481c3ebdecfedc93683e5a02d4968151c115..0662094143f8ea47da8a0936744725133b260aa5 100644 (file)
     - type: GuideHelp
       guides:
       - GasCanisters
+    - type: Paintable
+      group: Canisters
 
 - type: entity
   parent: GasCanister
index 8e2d1a6e54c0da4d12f6219f0c3bd8f21881d09b..a30ae00e961ea0943b6483ef7e44ecb8ca59c4c0 100644 (file)
@@ -54,6 +54,8 @@
     node: done
     containers:
     - entity_storage
+  - type: Paintable
+    group: Locker
 
 - type: entity
   id: LockerBaseSecure
index 6713a8303d0840124b18bdaee196a96696381ce9..52f29168fdcba71f90228bf22ba88db45eda0618 100644 (file)
@@ -17,6 +17,8 @@
       path: /Audio/Effects/woodenclosetclose.ogg
     openSound:
       path: /Audio/Effects/woodenclosetopen.ogg
+  - type: Paintable
+    group: null # not shaped like other lockers
 
 # Basic
 - type: entity
     node: done
     containers:
     - entity_storage
+  - type: Paintable
+    group: null
 
 - type: entity
   id: LockerFreezer
index b47106acb80619d9f305e24b96e98f97fb50ffd5..c38a20a698c27ea1e8f1f4ea5801835b25637380 100644 (file)
     node: done
     containers:
     - entity_storage
+  - type: Paintable
+    group: Closet
 
 #Wall Closet
 - type: entity
     node: done
     containers:
     - entity_storage
+  - type: Paintable
+    group: WallCloset
 
 #Wall locker
 - type: entity
     - state: welded
       visible: false
       map: ["enum.WeldableLayers.BaseWelded"]
+  - type: Paintable
+    group: WallLocker
 
 #Base suit storage unit
 #I am terribly sorry for duplicating the closet almost-wholesale, but the game malds at me if I don't so here we are.
index 55f4dee0418f684d64222bd86aa2035c11742da5..635cdee16e5557af58890fa817e4ce0193e0ec33 100644 (file)
     - Energy
     reflectProb: 0.2
     spread: 90
+  - type: Paintable
+    group: CrateSecure
index c4f818287028f7fe4e3b0e2f14f2d22ad7a50bd9..70d79aa912a65761b7b2440fe49e3f545beec264 100644 (file)
@@ -12,6 +12,8 @@
     - Energy
     reflectProb: 0.2
     spread: 90
+  - type: Paintable
+    group: CrateSteel
   - type: RadiationBlockingContainer
     resistance: 2.5
 
@@ -31,6 +33,8 @@
     - entity_storage
   - type: StaticPrice
     price: 100
+  - type: Paintable
+    group: CratePlastic
 
 - type: entity
   parent: CratePlastic
@@ -49,6 +53,8 @@
     node: done
     containers:
     - entity_storage
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: CratePlastic
       sprite: Structures/Storage/Crates/labels.rsi
       offset: "0.0,0.03125"
       map: ["enum.PaperLabelVisuals.Layer"]
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: CrateBaseSecure
       map: ["enum.PaperLabelVisuals.Layer"]
   - type: AccessReader
     access: [["Janitor"]]
+  - type: Paintable
+    group: null
 
 - type: entity
   parent: CrateBaseWeldable
diff --git a/Resources/Prototypes/Paintables/airlock_groups.yml b/Resources/Prototypes/Paintables/airlock_groups.yml
new file mode 100644 (file)
index 0000000..58b54b2
--- /dev/null
@@ -0,0 +1,40 @@
+- type: paintableGroup
+  id: AirlockStandard
+  time: 3
+  cost: 3
+  defaultStyle: basic
+  styles:
+    atmospherics: AirlockAtmospherics
+    basic: Airlock
+    cargo: AirlockCargo
+    chemistry: AirlockChemistry
+    command: AirlockCommand
+    engineering: AirlockEngineering
+    freezer: AirlockFreezer
+    hydroponics: AirlockHydroponics
+    maintenance: AirlockMaint
+    medical: AirlockMedical
+    salvage: AirlockSalvage
+    science: AirlockScience
+    security: AirlockSecurity
+    virology: AirlockVirology
+
+- type: paintableGroup
+  id: AirlockGlass
+  time: 3
+  cost: 3
+  defaultStyle: basic
+  styles:
+    atmospherics: AirlockAtmosphericsGlass
+    basic: AirlockGlass
+    cargo: AirlockCargoGlass
+    chemistry: AirlockChemistryGlass
+    command: AirlockCommandGlass
+    engineering: AirlockEngineeringGlass
+    hydroponics: AirlockHydroponicsGlass
+    maintenance: AirlockMaintGlass
+    medical: AirlockMedicalGlass
+    salvage: AirlockSalvageGlass
+    science: AirlockScienceGlass
+    security: AirlockSecurityGlass
+    virology: AirlockVirologyGlass
diff --git a/Resources/Prototypes/Paintables/canister_groups.yml b/Resources/Prototypes/Paintables/canister_groups.yml
new file mode 100644 (file)
index 0000000..5485f07
--- /dev/null
@@ -0,0 +1,16 @@
+- type: paintableGroup
+  cost: 2
+  id: Canisters
+  defaultStyle: storage
+  styles:
+    air: AirCanister
+    ammonia: AmmoniaCanister
+    carbon-dioxide: CarbonDioxideCanister
+    frezon: FrezonCanister
+    nitrogen: NitrogenCanister
+    nitrous-oxide: NitrousOxideCanister
+    oxygen: OxygenCanister
+    plasma: PlasmaCanister
+    storage: StorageCanister
+    tritium: TritiumCanister
+    water-vapor: WaterVaporCanister
diff --git a/Resources/Prototypes/Paintables/categories.yml b/Resources/Prototypes/Paintables/categories.yml
new file mode 100644 (file)
index 0000000..75998c3
--- /dev/null
@@ -0,0 +1,25 @@
+- type: paintableGroupCategory
+  id: Airlocks
+  groups:
+  - AirlockStandard
+  - AirlockGlass
+
+- type: paintableGroupCategory
+  id: Canisters
+  groups:
+  - Canisters
+
+- type: paintableGroupCategory
+  id: Crates
+  groups:
+  - CrateSteel
+  - CratePlastic
+  - CrateSecure
+
+- type: paintableGroupCategory
+  id: Lockers
+  groups:
+  - Locker
+  - Closet
+  - WallLocker
+  - WallCloset
diff --git a/Resources/Prototypes/Paintables/crate_groups.yml b/Resources/Prototypes/Paintables/crate_groups.yml
new file mode 100644 (file)
index 0000000..117b1df
--- /dev/null
@@ -0,0 +1,38 @@
+- type: paintableGroup
+  id: CrateSteel
+  cost: 2
+  defaultStyle: basic
+  styles:
+    basic: CrateGenericSteel
+    electrical: CrateElectrical
+    engineering: CrateEngineering
+    radiation: CrateRadiation
+    science: CrateScience
+    surgery: CrateSurgery
+
+- type: paintableGroup
+  id: CratePlastic
+  cost: 2
+  defaultStyle: basic
+  styles:
+    basic: CratePlastic
+    hydroponics: CrateHydroponics
+    medical: CrateMedical
+    oxygen: CrateInternals
+
+- type: paintableGroup
+  id: CrateSecure
+  cost: 2
+  defaultStyle: basic
+  styles:
+    basic: CrateSecure
+    chemistry: CrateChemistrySecure
+    command: CrateCommandSecure
+    engineering: CrateEngineeringSecure
+    hydroponics: CrateHydroSecure
+    medical: CrateMedicalSecure
+    plasma: CratePlasma
+    private: CratePrivateSecure
+    science: CrateScienceSecure
+    secgear: CrateSecgear
+    weapon: CrateWeaponSecure
diff --git a/Resources/Prototypes/Paintables/locker_groups.yml b/Resources/Prototypes/Paintables/locker_groups.yml
new file mode 100644 (file)
index 0000000..b9bdf1c
--- /dev/null
@@ -0,0 +1,80 @@
+- type: paintableGroup
+  id: Locker
+  cost: 2
+  defaultStyle: basic
+  styles:
+    atmospherics: LockerAtmospherics
+    basic: ClosetSteelBase
+    botanist: LockerBotanist
+    brigmedic: LockerBrigmedic
+    captain: LockerCaptain
+    ce: LockerChiefEngineer
+    chemical: LockerChemistry
+    clown: LockerClown
+    cmo: LockerChiefMedicalOfficer
+    doctor: LockerMedical
+    electrical: LockerElectricalSupplies
+    engineer: LockerEngineer
+    evac: LockerEvacRepair
+    hop: LockerHeadOfPersonnel
+    hos: LockerHeadOfSecurity
+    mime: LockerMime
+    medicine: LockerMedicine
+    paramedic: LockerParamedic
+    quartermaster: LockerQuarterMaster
+    rd: LockerResearchDirector
+    representative: LockerRepresentative
+    salvage: LockerSalvageSpecialist
+    scientist: LockerScientist
+    security: LockerSecurity
+    welding: LockerWeldingSupplies
+
+- type: paintableGroup
+  id: Closet
+  cost: 2
+  defaultStyle: basic
+  styles:
+    basic: ClosetSteelBase
+    biohazard: ClosetL3
+    biohazard-janitor: ClosetL3Janitor
+    biohazard-science: ClosetL3Science
+    biohazard-security: ClosetL3Security
+    biohazard-virology: ClosetL3Virology
+    bomb: ClosetBomb
+    bomb-janitor: ClosetJanitorBomb
+    chef: ClosetChef
+    fire: ClosetFire
+    janitor: ClosetJanitor
+    legal: ClosetLegal
+    nitrogen: ClosetEmergencyN2
+    oxygen: ClosetEmergency
+    radiation: ClosetRadiationSuit
+    tool: ClosetTool
+
+- type: paintableGroup
+  id: WallCloset
+  cost: 2
+  defaultStyle: basic
+  styles:
+    atmospherics: ClosetWallAtmospherics
+    basic: ClosetWall
+    black: ClosetWallBlack
+    blue: ClosetWallBlue
+    fire: ClosetWallFire
+    green: ClosetWallGreen
+    grey: ClosetWallGrey
+    mixed: ClosetWallMixed
+    nitrogen: ClosetWallEmergencyN2
+    orange: ClosetWallOrange
+    oxygen: ClosetWallEmergency
+    pink: ClosetWallPink
+    white: ClosetWallWhite
+    yellow: ClosetWallYellow
+
+- type: paintableGroup
+  id: WallLocker
+  cost: 2
+  defaultStyle: medical
+  styles:
+    evac: LockerWallEvacRepair
+    medical: LockerWallMedical
index 3ecd39cc9887b7dbda43e6e887928c657734ebc0..1f504e3ae4a1076065b96226ecb96c53e4cd207b 100644 (file)
@@ -12,6 +12,7 @@
   - NetworkConfigurator
   - Signaller
   - SprayPainter
+  - SprayPainterAmmo
   - FlashlightLantern
   - HandheldGPSBasic
   - TRayScanner
index 8a8c88d1e6c82355ce4d8ba76dea153490f71756..f54da83d25545ffe7f4b19c0e5fc0f5de308ecaa 100644 (file)
 - type: latheRecipe
   parent: BaseToolRecipe
   id: SprayPainter
-  result: SprayPainter
+  result: SprayPainterEmpty
   materials:
     Steel: 300
     Plastic: 100
 
+- type: latheRecipe
+  parent: BaseToolRecipe
+  id: SprayPainterAmmo
+  result: SprayPainterAmmo
+  materials:
+    Steel: 150
+    Plastic: 50
+
 - type: latheRecipe
   parent: BaseToolRecipe
   id: UtilityBelt
diff --git a/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-left.png b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-left.png
new file mode 100644 (file)
index 0000000..d81e805
Binary files /dev/null and b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-left.png differ
diff --git a/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-right.png b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-right.png
new file mode 100644 (file)
index 0000000..b4083c8
Binary files /dev/null and b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo-inhand-right.png differ
diff --git a/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo.png b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo.png
new file mode 100644 (file)
index 0000000..ca64c2a
Binary files /dev/null and b/Resources/Textures/Objects/Tools/spray_painter.rsi/ammo.png differ
index 8a19107208efaa125d3e08b46839ad804b690d92..14af8406380074d9d9178065ad3835776299405a 100644 (file)
@@ -1,22 +1,33 @@
 {
-   "copyright" : "Taken from https://github.com/tgstation/tgstation at commit a21274e56ae84b2c96e8b6beeca805df3d5402e8, Inhand sprites by onesch",
-   "license" : "CC-BY-SA-3.0",
-   "size" : {
-      "x" : 32,
-      "y" : 32
-   },
-   "states" : [
-      {
-         "name" : "spray_painter"
-      },
-      {
-          "name": "inhand-left",
-          "directions": 4
-      },
-      {
-          "name": "inhand-right",
-          "directions": 4
-      }
-   ],
-   "version" : 1
+    "version": 1,
+    "license": "CC-BY-SA-3.0",
+    "copyright": "Taken from https://github.com/tgstation/tgstation at commit a21274e56ae84b2c96e8b6beeca805df3d5402e8, Inhand sprites by onesch, ammo by Paradoxmi (Discord).",
+    "size": {
+        "x": 32,
+        "y": 32
+    },
+    "states": [
+        {
+            "name": "spray_painter"
+        },
+        {
+            "name": "ammo"
+        },
+        {
+            "name": "inhand-left",
+            "directions": 4
+        },
+        {
+            "name": "inhand-right",
+            "directions": 4
+        },
+        {
+            "name": "ammo-inhand-left",
+            "directions": 4
+        },
+        {
+            "name": "ammo-inhand-right",
+            "directions": 4
+        }
+    ]
 }