From d7eb3bfb4492c86d505ffe013fc62a7db7a89eec Mon Sep 17 00:00:00 2001 From: c4llv07e <38111072+c4llv07e@users.noreply.github.com> Date: Mon, 14 Aug 2023 12:06:21 +0000 Subject: [PATCH] Pipe painter (now with airlock painter) (#19031) * Add a pipe painting function to the airlock painter Signed-off-by: c4llv07e * Rename engineer painter to omnipainter Signed-off-by: c4llv07e * review changes Signed-off-by: c4llv07e * fix migration duplicate Signed-off-by: c4llv07e --------- Signed-off-by: c4llv07e --- .../AirlockPainter/AirlockPainterSystem.cs | 54 ------ .../UI/AirlockPainterBoundUserInterface.cs | 53 ----- .../UI/AirlockPainterWindow.xaml | 14 -- .../UI/AirlockPainterWindow.xaml.cs | 39 ---- .../SprayPainter/SprayPainterSystem.cs | 53 +++++ .../UI/SprayPainterBoundUserInterface.cs | 69 +++++++ .../SprayPainter/UI/SprayPainterWindow.xaml | 34 ++++ .../UI/SprayPainterWindow.xaml.cs | 96 +++++++++ .../AirlockPainter/AirlockPainterComponent.cs | 19 -- .../AirlockPainter/AirlockPainterSystem.cs | 118 ------------ .../SprayPainter/SprayPainterComponent.cs | 28 +++ .../SprayPainter/SprayPainterSystem.cs | 182 ++++++++++++++++++ .../AirlockPainter/AirlockPainterEvents.cs | 51 ----- .../Components/PaintableAirlockComponent.cs | 12 -- .../Prototypes/AirlockGroupPrototype.cs | 20 -- .../SharedAirlockPainterSystem.cs | 30 --- .../Components/PaintableAirlockComponent.cs | 11 ++ .../Prototypes/AirlockGroupPrototype.cs | 19 ++ .../SprayPainter/SharedDevicePainterSystem.cs | 29 +++ .../SprayPainter/SprayPainterEvents.cs | 69 +++++++ .../en-US/airlock-painter/airlock-painter.ftl | 3 - .../engineer-painter/engineer-painter.ftl | 14 ++ Resources/Maps/aspid.yml | 2 +- Resources/Maps/fland.yml | 2 +- Resources/Maps/kettle.yml | 2 +- .../Catalog/Fills/Lockers/engineer.yml | 2 +- .../VendingMachines/Inventories/youtool.yml | 2 +- .../Entities/Clothing/Belt/belts.yml | 4 +- .../Markers/Spawners/Random/maintenance.yml | 2 +- .../Objects/Tools/airlock_painter.yml | 21 -- .../Entities/Objects/Tools/spray_painter.yml | 30 +++ .../Entities/Structures/Machines/lathe.yml | 2 +- Resources/Prototypes/Recipes/Lathes/tools.yml | 4 +- .../meta.json | 2 +- .../spray_painter.png} | Bin Resources/migration.yml | 3 + 36 files changed, 649 insertions(+), 446 deletions(-) delete mode 100644 Content.Client/AirlockPainter/AirlockPainterSystem.cs delete mode 100644 Content.Client/AirlockPainter/UI/AirlockPainterBoundUserInterface.cs delete mode 100644 Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml delete mode 100644 Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml.cs create mode 100644 Content.Client/SprayPainter/SprayPainterSystem.cs create mode 100644 Content.Client/SprayPainter/UI/SprayPainterBoundUserInterface.cs create mode 100644 Content.Client/SprayPainter/UI/SprayPainterWindow.xaml create mode 100644 Content.Client/SprayPainter/UI/SprayPainterWindow.xaml.cs delete mode 100644 Content.Server/AirlockPainter/AirlockPainterComponent.cs delete mode 100644 Content.Server/AirlockPainter/AirlockPainterSystem.cs create mode 100644 Content.Server/SprayPainter/SprayPainterComponent.cs create mode 100644 Content.Server/SprayPainter/SprayPainterSystem.cs delete mode 100644 Content.Shared/AirlockPainter/AirlockPainterEvents.cs delete mode 100644 Content.Shared/AirlockPainter/Components/PaintableAirlockComponent.cs delete mode 100644 Content.Shared/AirlockPainter/Prototypes/AirlockGroupPrototype.cs delete mode 100644 Content.Shared/AirlockPainter/SharedAirlockPainterSystem.cs create mode 100644 Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs create mode 100644 Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs create mode 100644 Content.Shared/SprayPainter/SharedDevicePainterSystem.cs create mode 100644 Content.Shared/SprayPainter/SprayPainterEvents.cs delete mode 100644 Resources/Locale/en-US/airlock-painter/airlock-painter.ftl create mode 100644 Resources/Locale/en-US/engineer-painter/engineer-painter.ftl delete mode 100644 Resources/Prototypes/Entities/Objects/Tools/airlock_painter.yml create mode 100644 Resources/Prototypes/Entities/Objects/Tools/spray_painter.yml rename Resources/Textures/Objects/Tools/{airlock_painter.rsi => spray_painter.rsi}/meta.json (87%) rename Resources/Textures/Objects/Tools/{airlock_painter.rsi/airlock_painter.png => spray_painter.rsi/spray_painter.png} (100%) diff --git a/Content.Client/AirlockPainter/AirlockPainterSystem.cs b/Content.Client/AirlockPainter/AirlockPainterSystem.cs deleted file mode 100644 index b0ee4a970d..0000000000 --- a/Content.Client/AirlockPainter/AirlockPainterSystem.cs +++ /dev/null @@ -1,54 +0,0 @@ -using Content.Shared.AirlockPainter; -using Robust.Client.Graphics; -using Robust.Client.ResourceManagement; -using Robust.Shared.Utility; -using System.Linq; -using Robust.Shared.Serialization.TypeSerializers.Implementations; - -namespace Content.Client.AirlockPainter -{ - public sealed class AirlockPainterSystem : SharedAirlockPainterSystem - { - [Dependency] private readonly IResourceCache _resourceCache = default!; - - public List Entries { get; private set; } = new(); - - public override void Initialize() - { - base.Initialize(); - - foreach (string style in Styles) - { - string? iconPath = Groups - .FindAll(x => x.StylePaths.ContainsKey(style))? - .MaxBy(x => x.IconPriority)?.StylePaths[style]; - if (iconPath == null) - { - Entries.Add(new AirlockPainterEntry(style, null)); - continue; - } - - RSIResource doorRsi = _resourceCache.GetResource(SpriteSpecifierSerializer.TextureRoot / new ResPath(iconPath)); - if (!doorRsi.RSI.TryGetState("closed", out var icon)) - { - Entries.Add(new AirlockPainterEntry(style, null)); - continue; - } - - Entries.Add(new AirlockPainterEntry(style, icon.Frame0)); - } - } - } - - public sealed class AirlockPainterEntry - { - public string Name; - public Texture? Icon; - - public AirlockPainterEntry(string name, Texture? icon) - { - Name = name; - Icon = icon; - } - } -} diff --git a/Content.Client/AirlockPainter/UI/AirlockPainterBoundUserInterface.cs b/Content.Client/AirlockPainter/UI/AirlockPainterBoundUserInterface.cs deleted file mode 100644 index 019718c7b5..0000000000 --- a/Content.Client/AirlockPainter/UI/AirlockPainterBoundUserInterface.cs +++ /dev/null @@ -1,53 +0,0 @@ -using Content.Shared.AirlockPainter; -using Robust.Client.GameObjects; -using Robust.Client.UserInterface.Controls; - -namespace Content.Client.AirlockPainter.UI -{ - public sealed class AirlockPainterBoundUserInterface : BoundUserInterface - { - [ViewVariables] - private AirlockPainterWindow? _window; - - [ViewVariables] - private AirlockPainterSystem? _painter; - - public AirlockPainterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) - { - } - - protected override void Open() - { - base.Open(); - - _window = new AirlockPainterWindow(); - - _painter = EntMan.System(); - - _window.OpenCentered(); - _window.OnClose += Close; - _window.OnSpritePicked = OnSpritePicked; - } - - protected override void UpdateState(BoundUserInterfaceState state) - { - base.UpdateState(state); - - if (_window == null) - return; - - if (_painter == null) - return; - - if (state is not AirlockPainterBoundUserInterfaceState stateCast) - return; - - _window.Populate(_painter.Entries, stateCast.SelectedStyle); - } - - private void OnSpritePicked(ItemList.ItemListSelectedEventArgs args) - { - SendMessage(new AirlockPainterSpritePickedMessage(args.ItemIndex)); - } - } -} diff --git a/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml b/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml deleted file mode 100644 index 564eccb38f..0000000000 --- a/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml.cs b/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml.cs deleted file mode 100644 index 403b83e9e5..0000000000 --- a/Content.Client/AirlockPainter/UI/AirlockPainterWindow.xaml.cs +++ /dev/null @@ -1,39 +0,0 @@ -using Robust.Client.AutoGenerated; -using Robust.Client.UserInterface.Controls; -using Robust.Client.UserInterface.CustomControls; -using Robust.Client.UserInterface.XAML; - -namespace Content.Client.AirlockPainter.UI -{ - [GenerateTypedNameReferences] - public sealed partial class AirlockPainterWindow : DefaultWindow - { - public Action? OnSpritePicked; - - private List CurrentEntries = new List(); - - public AirlockPainterWindow() - { - RobustXamlLoader.Load(this); - } - - public void Populate(List entries, int selected) - { - // Only clear if the entries change. Otherwise the list would "jump" after selecting an item - if (!CurrentEntries.Equals(entries)) - { - CurrentEntries = entries; - SpriteList.Clear(); - foreach (var entry in entries) - { - SpriteList.AddItem(entry.Name, entry.Icon); - } - } - - // Disable event so we don't send a new event for pre-selected entry and end up in a loop - SpriteList.OnItemSelected -= OnSpritePicked; - SpriteList[selected].Selected = true; - SpriteList.OnItemSelected += OnSpritePicked; - } - } -} diff --git a/Content.Client/SprayPainter/SprayPainterSystem.cs b/Content.Client/SprayPainter/SprayPainterSystem.cs new file mode 100644 index 0000000000..b625f4a667 --- /dev/null +++ b/Content.Client/SprayPainter/SprayPainterSystem.cs @@ -0,0 +1,53 @@ +using Content.Shared.SprayPainter; +using Robust.Client.Graphics; +using Robust.Client.ResourceManagement; +using Robust.Shared.Serialization.TypeSerializers.Implementations; +using Robust.Shared.Utility; +using System.Linq; + +namespace Content.Client.SprayPainter; + +public sealed class SprayPainterSystem : SharedSprayPainterSystem +{ + [Dependency] private readonly IResourceCache _resourceCache = default!; + + public List Entries { get; private set; } = new(); + + public override void Initialize() + { + base.Initialize(); + + foreach (string style in Styles) + { + string? iconPath = Groups + .FindAll(x => x.StylePaths.ContainsKey(style))? + .MaxBy(x => x.IconPriority)?.StylePaths[style]; + if (iconPath == null) + { + Entries.Add(new SprayPainterEntry(style, null)); + continue; + } + + RSIResource doorRsi = _resourceCache.GetResource(SpriteSpecifierSerializer.TextureRoot / new ResPath(iconPath)); + if (!doorRsi.RSI.TryGetState("closed", out var icon)) + { + Entries.Add(new SprayPainterEntry(style, null)); + continue; + } + + Entries.Add(new SprayPainterEntry(style, icon.Frame0)); + } + } +} + +public sealed class SprayPainterEntry +{ + public string Name; + public Texture? Icon; + + public SprayPainterEntry(string name, Texture? icon) + { + Name = name; + Icon = icon; + } +} diff --git a/Content.Client/SprayPainter/UI/SprayPainterBoundUserInterface.cs b/Content.Client/SprayPainter/UI/SprayPainterBoundUserInterface.cs new file mode 100644 index 0000000000..d5c57a601f --- /dev/null +++ b/Content.Client/SprayPainter/UI/SprayPainterBoundUserInterface.cs @@ -0,0 +1,69 @@ +using Content.Shared.SprayPainter; +using Robust.Client.GameObjects; +using Robust.Client.UserInterface.Controls; + +namespace Content.Client.SprayPainter.UI; + +public sealed class SprayPainterBoundUserInterface : BoundUserInterface +{ + [ViewVariables] + private SprayPainterWindow? _window; + + [ViewVariables] + private SprayPainterSystem? _painter; + + public SprayPainterBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) + { + } + + protected override void Open() + { + base.Open(); + + _window = new SprayPainterWindow(); + + _painter = EntMan.System(); + + _window.OpenCentered(); + _window.OnClose += Close; + _window.OnSpritePicked = OnSpritePicked; + _window.OnColorPicked = OnColorPicked; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + _window?.Dispose(); + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (_window == null) + return; + + if (_painter == null) + return; + + if (state is not SprayPainterBoundUserInterfaceState stateCast) + return; + + _window.Populate(_painter.Entries, + stateCast.SelectedStyle, + stateCast.SelectedColorKey, + stateCast.Palette); + } + + private void OnSpritePicked(ItemList.ItemListSelectedEventArgs args) + { + SendMessage(new SprayPainterSpritePickedMessage(args.ItemIndex)); + } + + private void OnColorPicked(ItemList.ItemListSelectedEventArgs args) + { + var key = _window?.IndexToColorKey(args.ItemIndex); + SendMessage(new SprayPainterColorPickedMessage(key)); + } +} diff --git a/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml b/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml new file mode 100644 index 0000000000..13e500c46c --- /dev/null +++ b/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml @@ -0,0 +1,34 @@ + + + + + + + + + + diff --git a/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml.cs b/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml.cs new file mode 100644 index 0000000000..e799775bc6 --- /dev/null +++ b/Content.Client/SprayPainter/UI/SprayPainterWindow.xaml.cs @@ -0,0 +1,96 @@ +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.Utility; + +namespace Content.Client.SprayPainter.UI; + +[GenerateTypedNameReferences] +public sealed partial class SprayPainterWindow : DefaultWindow +{ + [Dependency] private readonly IEntitySystemManager _sysMan = default!; + private readonly SpriteSystem _spriteSystem; + + public Action? OnSpritePicked; + public Action? OnColorPicked; + public Dictionary ItemColorIndex = new(); + + private Dictionary currentPalette = new(); + private const string colorLocKeyPrefix = "pipe-painter-color-"; + private List CurrentEntries = new List(); + + private readonly SpriteSpecifier _colorEntryIconTexture = new SpriteSpecifier.Rsi( + new ResPath("Structures/Piping/Atmospherics/pipe.rsi"), + "pipeStraight"); + + public SprayPainterWindow() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + _spriteSystem = _sysMan.GetEntitySystem(); + } + + private static string GetColorLocString(string? colorKey) + { + if (string.IsNullOrEmpty(colorKey)) + return Loc.GetString("pipe-painter-no-color-selected"); + var locKey = colorLocKeyPrefix + colorKey; + + if (!Loc.TryGetString(locKey, out var locString)) + locString = colorKey; + + return locString; + } + + public string? IndexToColorKey(int index) + { + return (string?) ColorList[index].Metadata; + } + + public void Populate(List entries, int selectedStyle, string? selectedColorKey, Dictionary palette) + { + // Only clear if the entries change. Otherwise the list would "jump" after selecting an item + if (!CurrentEntries.Equals(entries)) + { + CurrentEntries = entries; + SpriteList.Clear(); + foreach (var entry in entries) + { + SpriteList.AddItem(entry.Name, entry.Icon); + } + } + + if (!currentPalette.Equals(palette)) + { + currentPalette = palette; + ItemColorIndex.Clear(); + ColorList.Clear(); + + foreach (var color in palette) + { + var locString = GetColorLocString(color.Key); + var item = ColorList.AddItem(locString, _spriteSystem.Frame0(_colorEntryIconTexture)); + item.IconModulate = color.Value; + item.Metadata = color.Key; + + ItemColorIndex.Add(color.Key, ColorList.IndexOf(item)); + } + } + + // Disable event so we don't send a new event for pre-selectedStyle entry and end up in a loop + + if (selectedColorKey != null) + { + var index = ItemColorIndex[selectedColorKey]; + ColorList.OnItemSelected -= OnColorPicked; + ColorList[index].Selected = true; + ColorList.OnItemSelected += OnColorPicked; + } + + SpriteList.OnItemSelected -= OnSpritePicked; + SpriteList[selectedStyle].Selected = true; + SpriteList.OnItemSelected += OnSpritePicked; + } +} diff --git a/Content.Server/AirlockPainter/AirlockPainterComponent.cs b/Content.Server/AirlockPainter/AirlockPainterComponent.cs deleted file mode 100644 index cd88795c12..0000000000 --- a/Content.Server/AirlockPainter/AirlockPainterComponent.cs +++ /dev/null @@ -1,19 +0,0 @@ -using Robust.Shared.Audio; - -namespace Content.Server.AirlockPainter -{ - [RegisterComponent] - public sealed class AirlockPainterComponent : Component - { - [DataField("spraySound")] - public SoundSpecifier SpraySound = new SoundPathSpecifier("/Audio/Effects/spray2.ogg"); - - [DataField("sprayTime")] - public float SprayTime = 3.0f; - - [DataField("isSpraying")] - public bool IsSpraying = false; - - public int Index = default!; - } -} diff --git a/Content.Server/AirlockPainter/AirlockPainterSystem.cs b/Content.Server/AirlockPainter/AirlockPainterSystem.cs deleted file mode 100644 index 85723fc14e..0000000000 --- a/Content.Server/AirlockPainter/AirlockPainterSystem.cs +++ /dev/null @@ -1,118 +0,0 @@ -using Content.Server.Administration.Logs; -using Content.Server.Popups; -using Content.Server.UserInterface; -using Content.Shared.AirlockPainter; -using Content.Shared.AirlockPainter.Prototypes; -using Content.Shared.DoAfter; -using Content.Shared.Database; -using Content.Shared.Doors.Components; -using Content.Shared.Interaction; -using JetBrains.Annotations; -using Robust.Server.GameObjects; - -namespace Content.Server.AirlockPainter -{ - /// - /// A system for painting airlocks using airlock painter - /// - [UsedImplicitly] - public sealed class AirlockPainterSystem : SharedAirlockPainterSystem - { - [Dependency] private readonly IAdminLogManager _adminLogger = default!; - [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!; - [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!; - [Dependency] private readonly PopupSystem _popupSystem = default!; - [Dependency] private readonly SharedAudioSystem _audio = default!; - [Dependency] private readonly SharedAppearanceSystem _appearance = default!; - - public override void Initialize() - { - base.Initialize(); - - SubscribeLocalEvent(AfterInteractOn); - SubscribeLocalEvent(OnActivate); - SubscribeLocalEvent(OnSpritePicked); - SubscribeLocalEvent(OnDoAfter); - } - - private void OnDoAfter(EntityUid uid, AirlockPainterComponent component, AirlockPainterDoAfterEvent args) - { - component.IsSpraying = false; - - if (args.Handled || args.Cancelled) - return; - - if (args.Args.Target != null) - { - _audio.PlayPvs(component.SpraySound, uid); - _appearance.SetData(args.Args.Target.Value, DoorVisuals.BaseRSI, args.Sprite); - _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.Args.User):user} painted {ToPrettyString(args.Args.Target.Value):target}"); - } - - args.Handled = true; - } - - private void OnActivate(EntityUid uid, AirlockPainterComponent component, ActivateInWorldEvent args) - { - if (!EntityManager.TryGetComponent(args.User, out ActorComponent? actor)) - return; - DirtyUI(uid, component); - - if (_userInterfaceSystem.TryGetUi(uid, AirlockPainterUiKey.Key, out var bui)) - _userInterfaceSystem.OpenUi(bui, actor.PlayerSession); - args.Handled = true; - } - - private void AfterInteractOn(EntityUid uid, AirlockPainterComponent component, AfterInteractEvent args) - { - if (component.IsSpraying || args.Target is not { Valid: true } target || !args.CanReach) - return; - - if (!EntityManager.TryGetComponent(target, out var airlock)) - return; - - if (!_prototypeManager.TryIndex(airlock.Group, out var grp)) - { - Log.Error("Group not defined: %s", airlock.Group); - return; - } - - string style = Styles[component.Index]; - if (!grp.StylePaths.TryGetValue(style, out var sprite)) - { - string msg = Loc.GetString("airlock-painter-style-not-available"); - _popupSystem.PopupEntity(msg, args.User, args.User); - return; - } - component.IsSpraying = true; - - var doAfterEventArgs = new DoAfterArgs(args.User, component.SprayTime, new AirlockPainterDoAfterEvent(sprite), uid, target: target, used: uid) - { - BreakOnTargetMove = true, - BreakOnUserMove = true, - BreakOnDamage = true, - NeedHand = true, - }; - _doAfterSystem.TryStartDoAfter(doAfterEventArgs); - - // Log attempt - _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.User):user} is painting {ToPrettyString(uid):target} to '{style}' at {Transform(uid).Coordinates:targetlocation}"); - } - - private void OnSpritePicked(EntityUid uid, AirlockPainterComponent component, AirlockPainterSpritePickedMessage args) - { - component.Index = args.Index; - DirtyUI(uid, component); - } - - private void DirtyUI(EntityUid uid, - AirlockPainterComponent? component = null) - { - if (!Resolve(uid, ref component)) - return; - - _userInterfaceSystem.TrySetUiState(uid, AirlockPainterUiKey.Key, - new AirlockPainterBoundUserInterfaceState(component.Index)); - } - } -} diff --git a/Content.Server/SprayPainter/SprayPainterComponent.cs b/Content.Server/SprayPainter/SprayPainterComponent.cs new file mode 100644 index 0000000000..c67b793891 --- /dev/null +++ b/Content.Server/SprayPainter/SprayPainterComponent.cs @@ -0,0 +1,28 @@ +using Robust.Shared.Audio; + +namespace Content.Server.SprayPainter; + +[RegisterComponent] +public sealed class SprayPainterComponent : Component +{ + [DataField("spraySound")] + public SoundSpecifier SpraySound = new SoundPathSpecifier("/Audio/Effects/spray2.ogg"); + + [DataField("airlockSprayTime")] + public float AirlockSprayTime = 3.0f; + + [DataField("pipeSprayTime")] + public float PipeSprayTime = 1.0f; + + [DataField("isSpraying")] + public bool IsSpraying = false; + + [ViewVariables(VVAccess.ReadWrite)] + public string? PickedColor; + + [ViewVariables(VVAccess.ReadWrite)] + [DataField("colorPalette")] + public Dictionary ColorPalette = new(); + + public int Index = default!; +} diff --git a/Content.Server/SprayPainter/SprayPainterSystem.cs b/Content.Server/SprayPainter/SprayPainterSystem.cs new file mode 100644 index 0000000000..7fe6ecfb37 --- /dev/null +++ b/Content.Server/SprayPainter/SprayPainterSystem.cs @@ -0,0 +1,182 @@ +using System.Linq; +using Content.Server.Administration.Logs; +using Content.Server.Atmos.Piping.Components; +using Content.Server.Atmos.Piping.EntitySystems; +using Content.Server.Popups; +using Content.Shared.Database; +using Content.Shared.DoAfter; +using Content.Shared.Doors.Components; +using Content.Shared.SprayPainter.Prototypes; +using Content.Shared.SprayPainter; +using Content.Shared.Interaction; +using JetBrains.Annotations; +using Robust.Server.GameObjects; + +namespace Content.Server.SprayPainter; + +/// +/// A system for painting airlocks and pipes using enginner painter +/// +[UsedImplicitly] +public sealed class SprayPainterSystem : SharedSprayPainterSystem +{ + [Dependency] private readonly IAdminLogManager _adminLogger = default!; + [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!; + [Dependency] private readonly PopupSystem _popupSystem = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly AtmosPipeColorSystem _pipeColorSystem = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnInit); + SubscribeLocalEvent(AfterInteractOn); + SubscribeLocalEvent(OnActivate); + SubscribeLocalEvent(OnSpritePicked); + SubscribeLocalEvent(OnColorPicked); + SubscribeLocalEvent(OnDoAfter); + } + + private void OnInit(EntityUid uid, SprayPainterComponent component, ComponentInit args) + { + if (component.ColorPalette.Count == 0) + return; + + SetColor(uid, component, component.ColorPalette.First().Key); + } + + private void OnDoAfter(EntityUid uid, SprayPainterComponent component, SprayPainterDoAfterEvent args) + { + component.IsSpraying = false; + + if (args.Handled || args.Cancelled) + return; + + if (args.Args.Target == null) + return; + + EntityUid target = (EntityUid) args.Args.Target; + + _audio.PlayPvs(component.SpraySound, uid); + + if (TryComp(target, out var atmosPipeColorComp)) + { + _pipeColorSystem.SetColor(target, atmosPipeColorComp, args.Color ?? Color.White); + } else { // Target is an airlock + if (args.Sprite != null) + { + _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}"); + } + } + + args.Handled = true; + } + + private void OnActivate(EntityUid uid, SprayPainterComponent component, ActivateInWorldEvent args) + { + if (!EntityManager.TryGetComponent(args.User, out ActorComponent? actor)) + return; + DirtyUI(uid, component); + + _userInterfaceSystem.TryOpen(uid, SprayPainterUiKey.Key, actor.PlayerSession); + args.Handled = true; + } + + private void AfterInteractOn(EntityUid uid, SprayPainterComponent component, AfterInteractEvent args) + { + if (component.IsSpraying || args.Target is not { Valid: true } target || !args.CanReach) + return; + + if (EntityManager.TryGetComponent(target, out var airlock)) + { + if (!_prototypeManager.TryIndex(airlock.Group, out var grp)) + { + Log.Error("Group not defined: %s", airlock.Group); + return; + } + + string style = Styles[component.Index]; + if (!grp.StylePaths.TryGetValue(style, out var sprite)) + { + string msg = Loc.GetString("spray-painter-style-not-available"); + _popupSystem.PopupEntity(msg, args.User, args.User); + return; + } + component.IsSpraying = true; + + var doAfterEventArgs = new DoAfterArgs(args.User, component.AirlockSprayTime, new SprayPainterDoAfterEvent(sprite, null), uid, target: target, used: uid) + { + BreakOnTargetMove = true, + BreakOnUserMove = true, + BreakOnDamage = true, + NeedHand = true, + }; + _doAfterSystem.TryStartDoAfter(doAfterEventArgs); + + // Log attempt + _adminLogger.Add(LogType.Action, LogImpact.Low, $"{ToPrettyString(args.User):user} is painting {ToPrettyString(uid):target} to '{style}' at {Transform(uid).Coordinates:targetlocation}"); + } else { // Painting pipes + if(component.PickedColor is null) + return; + + if (!EntityManager.HasComponent(target)) + return; + + if(!component.ColorPalette.TryGetValue(component.PickedColor, out var color)) + return; + + var doAfterEventArgs = new DoAfterArgs(args.User, component.PipeSprayTime, new SprayPainterDoAfterEvent(null, color), uid, target, uid) + { + BreakOnTargetMove = true, + BreakOnUserMove = true, + BreakOnDamage = true, + CancelDuplicate = true, + DuplicateCondition = DuplicateConditions.SameTarget, + NeedHand = true, + }; + + _doAfterSystem.TryStartDoAfter(doAfterEventArgs); + } + } + + private void OnColorPicked(EntityUid uid, SprayPainterComponent component, SprayPainterColorPickedMessage args) + { + SetColor(uid, component, args.Key); + } + + private void OnSpritePicked(EntityUid uid, SprayPainterComponent component, SprayPainterSpritePickedMessage args) + { + component.Index = args.Index; + DirtyUI(uid, component); + } + + private void SetColor(EntityUid uid, SprayPainterComponent component, string? paletteKey) + { + if (paletteKey == null) + return; + + if (!component.ColorPalette.ContainsKey(paletteKey) || paletteKey == component.PickedColor) + return; + + component.PickedColor = paletteKey; + DirtyUI(uid, component); + } + + private void DirtyUI(EntityUid uid, SprayPainterComponent? component = null) + { + if (!Resolve(uid, ref component)) + return; + + _userInterfaceSystem.TrySetUiState( + uid, + SprayPainterUiKey.Key, + new SprayPainterBoundUserInterfaceState( + component.Index, + component.PickedColor, + component.ColorPalette)); + } +} diff --git a/Content.Shared/AirlockPainter/AirlockPainterEvents.cs b/Content.Shared/AirlockPainter/AirlockPainterEvents.cs deleted file mode 100644 index d2223313bc..0000000000 --- a/Content.Shared/AirlockPainter/AirlockPainterEvents.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Content.Shared.DoAfter; -using Robust.Shared.Serialization; - -namespace Content.Shared.AirlockPainter -{ - [Serializable, NetSerializable] - public enum AirlockPainterUiKey - { - Key, - } - - [Serializable, NetSerializable] - public sealed class AirlockPainterSpritePickedMessage : BoundUserInterfaceMessage - { - public int Index { get; } - - public AirlockPainterSpritePickedMessage(int index) - { - Index = index; - } - } - - [Serializable, NetSerializable] - public sealed class AirlockPainterBoundUserInterfaceState : BoundUserInterfaceState - { - public int SelectedStyle { get; } - - public AirlockPainterBoundUserInterfaceState(int selectedStyle) - { - SelectedStyle = selectedStyle; - } - } - - [Serializable, NetSerializable] - public sealed class AirlockPainterDoAfterEvent : DoAfterEvent - { - [DataField("sprite", required: true)] - public readonly string Sprite = default!; - - private AirlockPainterDoAfterEvent() - { - } - - public AirlockPainterDoAfterEvent(string sprite) - { - Sprite = sprite; - } - - public override DoAfterEvent Clone() => this; - } -} diff --git a/Content.Shared/AirlockPainter/Components/PaintableAirlockComponent.cs b/Content.Shared/AirlockPainter/Components/PaintableAirlockComponent.cs deleted file mode 100644 index 0453a0d081..0000000000 --- a/Content.Shared/AirlockPainter/Components/PaintableAirlockComponent.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Content.Shared.AirlockPainter.Prototypes; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; - -namespace Content.Server.AirlockPainter -{ - [RegisterComponent] - public sealed class PaintableAirlockComponent : Component - { - [DataField("group", customTypeSerializer:typeof(PrototypeIdSerializer))] - public string Group = default!; - } -} diff --git a/Content.Shared/AirlockPainter/Prototypes/AirlockGroupPrototype.cs b/Content.Shared/AirlockPainter/Prototypes/AirlockGroupPrototype.cs deleted file mode 100644 index a7d8a15f2a..0000000000 --- a/Content.Shared/AirlockPainter/Prototypes/AirlockGroupPrototype.cs +++ /dev/null @@ -1,20 +0,0 @@ -using Robust.Shared.Prototypes; - -namespace Content.Shared.AirlockPainter.Prototypes -{ - [Prototype("AirlockGroup")] - public sealed class AirlockGroupPrototype : IPrototype - { - [IdDataField] - public string ID { get; } = default!; - - [DataField("stylePaths")] - public Dictionary StylePaths = default!; - - // The priority determines, which sprite is used when showing - // the icon for a style in the airlock painter UI. The highest priority - // gets shown. - [DataField("iconPriority")] - public int IconPriority = 0; - } -} diff --git a/Content.Shared/AirlockPainter/SharedAirlockPainterSystem.cs b/Content.Shared/AirlockPainter/SharedAirlockPainterSystem.cs deleted file mode 100644 index 0cb8c6e5c0..0000000000 --- a/Content.Shared/AirlockPainter/SharedAirlockPainterSystem.cs +++ /dev/null @@ -1,30 +0,0 @@ -using System.Linq; -using Content.Shared.AirlockPainter.Prototypes; -using Robust.Shared.Prototypes; - -namespace Content.Shared.AirlockPainter -{ - public abstract class SharedAirlockPainterSystem : EntitySystem - { - [Dependency] protected readonly IPrototypeManager _prototypeManager = default!; - - public List Styles { get; private set; } = new(); - public List Groups { get; private set; } = new(); - - public override void Initialize() - { - base.Initialize(); - - SortedSet styles = new(); - foreach (AirlockGroupPrototype grp in _prototypeManager.EnumeratePrototypes()) - { - Groups.Add(grp); - foreach (string style in grp.StylePaths.Keys) - { - styles.Add(style); - } - } - Styles = styles.ToList(); - } - } -} diff --git a/Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs b/Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs new file mode 100644 index 0000000000..78707a4090 --- /dev/null +++ b/Content.Shared/SprayPainter/Components/PaintableAirlockComponent.cs @@ -0,0 +1,11 @@ +using Content.Shared.SprayPainter.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; + +namespace Content.Server.SprayPainter; + +[RegisterComponent] +public sealed class PaintableAirlockComponent : Component +{ + [DataField("group", customTypeSerializer:typeof(PrototypeIdSerializer))] + public string Group = default!; +} diff --git a/Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs b/Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs new file mode 100644 index 0000000000..81691ae83f --- /dev/null +++ b/Content.Shared/SprayPainter/Prototypes/AirlockGroupPrototype.cs @@ -0,0 +1,19 @@ +using Robust.Shared.Prototypes; + +namespace Content.Shared.SprayPainter.Prototypes; + +[Prototype("AirlockGroup")] +public sealed class AirlockGroupPrototype : IPrototype +{ + [IdDataField] + public string ID { get; } = default!; + + [DataField("stylePaths")] + public Dictionary 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/SharedDevicePainterSystem.cs b/Content.Shared/SprayPainter/SharedDevicePainterSystem.cs new file mode 100644 index 0000000000..ff43b119f6 --- /dev/null +++ b/Content.Shared/SprayPainter/SharedDevicePainterSystem.cs @@ -0,0 +1,29 @@ +using System.Linq; +using Content.Shared.SprayPainter.Prototypes; +using Robust.Shared.Prototypes; + +namespace Content.Shared.SprayPainter; + +public abstract class SharedSprayPainterSystem : EntitySystem +{ + [Dependency] protected readonly IPrototypeManager _prototypeManager = default!; + + public List Styles { get; private set; } = new(); + public List Groups { get; private set; } = new(); + + public override void Initialize() + { + base.Initialize(); + + SortedSet styles = new(); + foreach (AirlockGroupPrototype grp in _prototypeManager.EnumeratePrototypes()) + { + Groups.Add(grp); + foreach (string style in grp.StylePaths.Keys) + { + styles.Add(style); + } + } + Styles = styles.ToList(); + } +} diff --git a/Content.Shared/SprayPainter/SprayPainterEvents.cs b/Content.Shared/SprayPainter/SprayPainterEvents.cs new file mode 100644 index 0000000000..3c2c9b95ae --- /dev/null +++ b/Content.Shared/SprayPainter/SprayPainterEvents.cs @@ -0,0 +1,69 @@ +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared.SprayPainter; + +[Serializable, NetSerializable] +public enum SprayPainterUiKey +{ + Key, +} + +[Serializable, NetSerializable] +public sealed class SprayPainterSpritePickedMessage : BoundUserInterfaceMessage +{ + public int Index { get; } + + public SprayPainterSpritePickedMessage(int index) + { + Index = index; + } +} + +[Serializable, NetSerializable] +public sealed class SprayPainterColorPickedMessage : BoundUserInterfaceMessage +{ + public string? Key { get; } + + public SprayPainterColorPickedMessage(string? key) + { + Key = key; + } +} + +[Serializable, NetSerializable] +public sealed class SprayPainterBoundUserInterfaceState : BoundUserInterfaceState +{ + public int SelectedStyle { get; } + public string? SelectedColorKey { get; } + public Dictionary Palette { get; } + + public SprayPainterBoundUserInterfaceState(int selectedStyle, string? selectedColorKey, Dictionary palette) + { + SelectedStyle = selectedStyle; + SelectedColorKey = selectedColorKey; + Palette = palette; + } +} + +[Serializable, NetSerializable] +public sealed class SprayPainterDoAfterEvent : DoAfterEvent +{ + [DataField("sprite")] + public readonly string? Sprite = null; + + [DataField("color")] + public readonly Color? Color = null; + + private SprayPainterDoAfterEvent() + { + } + + public SprayPainterDoAfterEvent(string? sprite, Color? color) + { + Sprite = sprite; + Color = color; + } + + public override DoAfterEvent Clone() => this; +} diff --git a/Resources/Locale/en-US/airlock-painter/airlock-painter.ftl b/Resources/Locale/en-US/airlock-painter/airlock-painter.ftl deleted file mode 100644 index 3fe6566868..0000000000 --- a/Resources/Locale/en-US/airlock-painter/airlock-painter.ftl +++ /dev/null @@ -1,3 +0,0 @@ -airlock-painter-style-not-available = Cannot apply the selected style to this type of airlock -airlock-painter-window-title = Airlock painter -airlock-painter-selected-style = Selected style diff --git a/Resources/Locale/en-US/engineer-painter/engineer-painter.ftl b/Resources/Locale/en-US/engineer-painter/engineer-painter.ftl new file mode 100644 index 0000000000..d3d3ccc444 --- /dev/null +++ b/Resources/Locale/en-US/engineer-painter/engineer-painter.ftl @@ -0,0 +1,14 @@ +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/Maps/aspid.yml b/Resources/Maps/aspid.yml index ec55ab072b..42a5ddcccc 100644 --- a/Resources/Maps/aspid.yml +++ b/Resources/Maps/aspid.yml @@ -11893,7 +11893,7 @@ entities: - pos: -14.5,29.5 parent: 1 type: Transform -- proto: AirlockPainter +- proto: SprayPainter entities: - uid: 9885 components: diff --git a/Resources/Maps/fland.yml b/Resources/Maps/fland.yml index 7e85a3bcd6..bb9de6bfb3 100644 --- a/Resources/Maps/fland.yml +++ b/Resources/Maps/fland.yml @@ -19872,7 +19872,7 @@ entities: - pos: 1.5,49.5 parent: 13329 type: Transform -- proto: AirlockPainter +- proto: SprayPainter entities: - uid: 29081 components: diff --git a/Resources/Maps/kettle.yml b/Resources/Maps/kettle.yml index 3706dcf1fa..b9acd22947 100644 --- a/Resources/Maps/kettle.yml +++ b/Resources/Maps/kettle.yml @@ -13912,7 +13912,7 @@ entities: - pos: -55.5,-29.5 parent: 82 type: Transform -- proto: AirlockPainter +- proto: SprayPainter entities: - uid: 11279 components: diff --git a/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml b/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml index da73c32978..468692fd62 100644 --- a/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml +++ b/Resources/Prototypes/Catalog/Fills/Lockers/engineer.yml @@ -37,7 +37,7 @@ prob: 0.3 - id: CableApcStack prob: 0.3 - - id: AirlockPainter + - id: SprayPainter prob: 0.7 - type: entity diff --git a/Resources/Prototypes/Catalog/VendingMachines/Inventories/youtool.yml b/Resources/Prototypes/Catalog/VendingMachines/Inventories/youtool.yml index ec639f873b..2231c714ff 100644 --- a/Resources/Prototypes/Catalog/VendingMachines/Inventories/youtool.yml +++ b/Resources/Prototypes/Catalog/VendingMachines/Inventories/youtool.yml @@ -12,7 +12,7 @@ GasAnalyzer: 3 FlashlightLantern: 5 ClothingHandsGlovesColorYellowBudget: 3 - AirlockPainter: 3 + SprayPainter: 3 # Some engineer forgot to take the multitool out the youtool when working on it, happens. contrabandInventory: Multitool: 1 diff --git a/Resources/Prototypes/Entities/Clothing/Belt/belts.yml b/Resources/Prototypes/Entities/Clothing/Belt/belts.yml index 84470d9e10..fa1a5e951f 100644 --- a/Resources/Prototypes/Entities/Clothing/Belt/belts.yml +++ b/Resources/Prototypes/Entities/Clothing/Belt/belts.yml @@ -30,7 +30,7 @@ - Multitool - AppraisalTool components: - - AirlockPainter + - SprayPainter - NetworkConfigurator - RCD - RCDAmmo @@ -103,7 +103,7 @@ - Multitool - AppraisalTool components: - - AirlockPainter + - SprayPainter - NetworkConfigurator - RCD - RCDAmmo diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Random/maintenance.yml b/Resources/Prototypes/Entities/Markers/Spawners/Random/maintenance.yml index 3b805dda20..9ff97ad040 100644 --- a/Resources/Prototypes/Entities/Markers/Spawners/Random/maintenance.yml +++ b/Resources/Prototypes/Entities/Markers/Spawners/Random/maintenance.yml @@ -161,7 +161,7 @@ - NetworkConfigurator - trayScanner - GasAnalyzer - - AirlockPainter + - SprayPainter - AppraisalTool - Flare - HandheldGPSBasic diff --git a/Resources/Prototypes/Entities/Objects/Tools/airlock_painter.yml b/Resources/Prototypes/Entities/Objects/Tools/airlock_painter.yml deleted file mode 100644 index 8e387db340..0000000000 --- a/Resources/Prototypes/Entities/Objects/Tools/airlock_painter.yml +++ /dev/null @@ -1,21 +0,0 @@ -- type: entity - parent: BaseItem - id: AirlockPainter - name: airlock painter - description: An airlock painter for painting airlocks. - components: - - type: Sprite - sprite: Objects/Tools/airlock_painter.rsi - state: airlock_painter - - type: Item - sprite: Objects/Tools/airlock_painter.rsi - - type: UserInterface - interfaces: - - key: enum.AirlockPainterUiKey.Key - type: AirlockPainterBoundUserInterface - - type: AirlockPainter - whitelist: - tags: - - PaintableAirlock - - type: StaticPrice - price: 40 diff --git a/Resources/Prototypes/Entities/Objects/Tools/spray_painter.yml b/Resources/Prototypes/Entities/Objects/Tools/spray_painter.yml new file mode 100644 index 0000000000..0ff3c65270 --- /dev/null +++ b/Resources/Prototypes/Entities/Objects/Tools/spray_painter.yml @@ -0,0 +1,30 @@ +- type: entity + parent: BaseItem + id: SprayPainter + name: Spray painter + description: A spray painter for painting airlocks and pipes. + components: + - type: Sprite + sprite: Objects/Tools/spray_painter.rsi + state: spray_painter + - type: Item + sprite: Objects/Tools/spray_painter.rsi + - type: UserInterface + interfaces: + - key: enum.SprayPainterUiKey.Key + type: SprayPainterBoundUserInterface + - type: SprayPainter + whitelist: + tags: + - PaintableAirlock + colorPalette: + red: '#FF1212FF' + yellow: '#B3A234FF' + brown: '#947507FF' + green: '#3AB334FF' + cyan: '#03FCD3FF' + blue: '#0335FCFF' + white: '#FFFFFFFF' + black: '#333333FF' + - type: StaticPrice + price: 40 diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml index 5252f9e429..49efcb3711 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml @@ -88,7 +88,7 @@ - Crowbar - Multitool - NetworkConfigurator - - AirlockPainter + - SprayPainter - FlashlightLantern - CableStack - CableMVStack diff --git a/Resources/Prototypes/Recipes/Lathes/tools.yml b/Resources/Prototypes/Recipes/Lathes/tools.yml index 6ae6aaadea..df38db13d7 100644 --- a/Resources/Prototypes/Recipes/Lathes/tools.yml +++ b/Resources/Prototypes/Recipes/Lathes/tools.yml @@ -143,8 +143,8 @@ Glass: 300 - type: latheRecipe - id: AirlockPainter - result: AirlockPainter + id: SprayPainter + result: SprayPainter completetime: 2 materials: Steel: 300 diff --git a/Resources/Textures/Objects/Tools/airlock_painter.rsi/meta.json b/Resources/Textures/Objects/Tools/spray_painter.rsi/meta.json similarity index 87% rename from Resources/Textures/Objects/Tools/airlock_painter.rsi/meta.json rename to Resources/Textures/Objects/Tools/spray_painter.rsi/meta.json index 6312e9b261..056ba0a856 100644 --- a/Resources/Textures/Objects/Tools/airlock_painter.rsi/meta.json +++ b/Resources/Textures/Objects/Tools/spray_painter.rsi/meta.json @@ -7,7 +7,7 @@ }, "states" : [ { - "name" : "airlock_painter" + "name" : "spray_painter" } ], "version" : 1 diff --git a/Resources/Textures/Objects/Tools/airlock_painter.rsi/airlock_painter.png b/Resources/Textures/Objects/Tools/spray_painter.rsi/spray_painter.png similarity index 100% rename from Resources/Textures/Objects/Tools/airlock_painter.rsi/airlock_painter.png rename to Resources/Textures/Objects/Tools/spray_painter.rsi/spray_painter.png diff --git a/Resources/migration.yml b/Resources/migration.yml index 9fae5aead9..9234ce5497 100644 --- a/Resources/migration.yml +++ b/Resources/migration.yml @@ -78,3 +78,6 @@ WindowTintedDirectional: WindowFrostedDirectional # 2023-08-10 SyringeSpaceacillin: null + +# 2023-08-13 +AirlockPainter: SprayPainter