]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add mapping editor (#23427)
authorDrSmugleaf <10968691+DrSmugleaf@users.noreply.github.com>
Sun, 4 Aug 2024 03:31:45 +0000 (20:31 -0700)
committerGitHub <noreply@github.com>
Sun, 4 Aug 2024 03:31:45 +0000 (13:31 +1000)
* Add mapping editor (#757)

* Remove mapping actions, never again

* Cleanup actions system

* Jarvis, remove all references to CM14

* Fix InventoryUIController crashing when an InventoryGui is not found

* Rename mapping1 to mapping

* Clean up context calls

* Add doc comments

* Add delegate for hiding decals in the mapping screen

* Jarvis mission failed

* a

* Add test

* Fix not flushing save stream in mapping manager

* change

* Fix verbs

* fixes

* localise

---------

Co-authored-by: DrSmugleaf <DrSmugleaf@users.noreply.github.com>
Co-authored-by: metalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Co-authored-by: metalgearsloth <comedian_vs_clown@hotmail.com>
Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
37 files changed:
Content.Client/Actions/ActionsSystem.cs
Content.Client/Commands/ActionsCommands.cs
Content.Client/Commands/MappingClientSideSetupCommand.cs
Content.Client/ContextMenu/UI/ContextMenuUIController.cs
Content.Client/Decals/Overlays/DecalPlacementOverlay.cs
Content.Client/IoC/ClientContentIoC.cs
Content.Client/Mapping/MappingActionsButton.xaml [new file with mode: 0644]
Content.Client/Mapping/MappingActionsButton.xaml.cs [new file with mode: 0644]
Content.Client/Mapping/MappingDoNotMeasure.xaml [new file with mode: 0644]
Content.Client/Mapping/MappingDoNotMeasure.xaml.cs [new file with mode: 0644]
Content.Client/Mapping/MappingManager.cs [new file with mode: 0644]
Content.Client/Mapping/MappingOverlay.cs [new file with mode: 0644]
Content.Client/Mapping/MappingPrototype.cs [new file with mode: 0644]
Content.Client/Mapping/MappingPrototypeList.xaml [new file with mode: 0644]
Content.Client/Mapping/MappingPrototypeList.xaml.cs [new file with mode: 0644]
Content.Client/Mapping/MappingScreen.xaml [new file with mode: 0644]
Content.Client/Mapping/MappingScreen.xaml.cs [new file with mode: 0644]
Content.Client/Mapping/MappingSpawnButton.xaml [new file with mode: 0644]
Content.Client/Mapping/MappingSpawnButton.xaml.cs [new file with mode: 0644]
Content.Client/Mapping/MappingState.cs [new file with mode: 0644]
Content.Client/Mapping/MappingSystem.cs
Content.Client/UserInterface/Systems/Actions/ActionUIController.cs
Content.Client/Verbs/UI/VerbMenuUIController.cs
Content.IntegrationTests/Tests/MappingEditorTest.cs [new file with mode: 0644]
Content.Server/IoC/ServerContentIoC.cs
Content.Server/Mapping/MappingManager.cs [new file with mode: 0644]
Content.Shared/Decals/DecalPrototype.cs
Content.Shared/Input/ContentKeyFunctions.cs
Content.Shared/Mapping/MappingMapDataMessage.cs [new file with mode: 0644]
Content.Shared/Mapping/MappingSaveMapErrorMessage.cs [new file with mode: 0644]
Content.Shared/Mapping/MappingSaveMapMessage.cs [new file with mode: 0644]
Resources/Locale/en-US/mapping/editor.ftl [new file with mode: 0644]
Resources/Textures/Interface/eraser.svg [new file with mode: 0644]
Resources/Textures/Interface/eraser.svg.png [new file with mode: 0644]
Resources/Textures/Interface/eyedropper.svg [new file with mode: 0644]
Resources/Textures/Interface/eyedropper.svg.png [new file with mode: 0644]
Resources/keybinds.yml

index 7f261f5df2d2859db540d8788126f1e82b1da2c0..30f657a2b5f38fe31612407b5ab31d7067a4457a 100644 (file)
@@ -293,7 +293,7 @@ namespace Content.Client.Actions
                     continue;
 
                 var action = _serialization.Read<BaseActionComponent>(actionNode, notNullableOverride: true);
-                var actionId = Spawn(null);
+                var actionId = Spawn();
                 AddComp(actionId, action);
                 AddActionDirect(user, actionId);
 
index 3d8e906e09e0dd97573ed5e779cadb505a1b5091..593b8e8256977e0a8200f5c05e5ba52801df42a6 100644 (file)
@@ -1,3 +1,4 @@
+using Content.Client.Actions;
 using Content.Client.Actions;
 using Content.Client.Mapping;
 using Content.Shared.Administration;
@@ -61,27 +62,3 @@ public sealed class LoadActionsCommand : LocalizedCommands
         }
     }
 }
-
-[AnyCommand]
-public sealed class LoadMappingActionsCommand : LocalizedCommands
-{
-    [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
-
-    public const string CommandName = "loadmapacts";
-
-    public override string Command => CommandName;
-
-    public override string Help => LocalizationManager.GetString($"cmd-{Command}-help", ("command", Command));
-
-    public override void Execute(IConsoleShell shell, string argStr, string[] args)
-    {
-        try
-        {
-            _entitySystemManager.GetEntitySystem<MappingSystem>().LoadMappingActions();
-        }
-        catch
-        {
-            shell.WriteError(LocalizationManager.GetString($"cmd-{Command}-error"));
-        }
-    }
-}
index 39268c6284782c7b609cce7be16ebc79f0d9b25e..eb2d13c95409a53edac7b8ed5b7672baafdcf19e 100644 (file)
@@ -1,6 +1,8 @@
+using Content.Client.Mapping;
 using Content.Client.Markers;
 using JetBrains.Annotations;
 using Robust.Client.Graphics;
+using Robust.Client.State;
 using Robust.Shared.Console;
 
 namespace Content.Client.Commands;
@@ -10,6 +12,7 @@ internal sealed class MappingClientSideSetupCommand : LocalizedCommands
 {
     [Dependency] private readonly IEntitySystemManager _entitySystemManager = default!;
     [Dependency] private readonly ILightManager _lightManager = default!;
+    [Dependency] private readonly IStateManager _stateManager = default!;
 
     public override string Command => "mappingclientsidesetup";
 
@@ -21,8 +24,8 @@ internal sealed class MappingClientSideSetupCommand : LocalizedCommands
         {
             _entitySystemManager.GetEntitySystem<MarkerSystem>().MarkersVisible = true;
             _lightManager.Enabled = false;
-            shell.ExecuteCommand(ShowSubFloorForever.CommandName);
-            shell.ExecuteCommand(LoadMappingActionsCommand.CommandName);
+            shell.ExecuteCommand("showsubfloorforever");
+            _stateManager.RequestStateChange<MappingState>();
         }
     }
 }
index 5b156644a731ae61133e5c497a5382dfc4fbe86f..2d94034bb9c0df2f08330f6e8d9cbb2ec96edd06 100644 (file)
@@ -2,6 +2,7 @@ using System.Numerics;
 using System.Threading;
 using Content.Client.CombatMode;
 using Content.Client.Gameplay;
+using Content.Client.Mapping;
 using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.Controllers;
 using Timer = Robust.Shared.Timing.Timer;
@@ -16,7 +17,7 @@ namespace Content.Client.ContextMenu.UI
     /// <remarks>
     ///     This largely involves setting up timers to open and close sub-menus when hovering over other menu elements.
     /// </remarks>
-    public sealed class ContextMenuUIController : UIController, IOnStateEntered<GameplayState>, IOnStateExited<GameplayState>, IOnSystemChanged<CombatModeSystem>
+    public sealed class ContextMenuUIController : UIController, IOnStateEntered<GameplayState>, IOnStateExited<GameplayState>, IOnSystemChanged<CombatModeSystem>, IOnStateEntered<MappingState>, IOnStateExited<MappingState>
     {
         public static readonly TimeSpan HoverDelay = TimeSpan.FromSeconds(0.2);
 
@@ -42,18 +43,51 @@ namespace Content.Client.ContextMenu.UI
         public Action<ContextMenuElement>? OnSubMenuOpened;
         public Action<ContextMenuElement, GUIBoundKeyEventArgs>? OnContextKeyEvent;
 
+        private bool _setup;
+
         public void OnStateEntered(GameplayState state)
         {
+            Setup();
+        }
+
+        public void OnStateExited(GameplayState state)
+        {
+            Shutdown();
+        }
+
+        public void OnStateEntered(MappingState state)
+        {
+            Setup();
+        }
+
+        public void OnStateExited(MappingState state)
+        {
+            Shutdown();
+        }
+
+        public void Setup()
+        {
+            if (_setup)
+                return;
+
+            _setup = true;
+
             RootMenu = new(this, null);
             RootMenu.OnPopupHide += Close;
             Menus.Push(RootMenu);
         }
 
-        public void OnStateExited(GameplayState state)
+        public void Shutdown()
         {
+            if (!_setup)
+                return;
+
+            _setup = false;
+
             Close();
             RootMenu.OnPopupHide -= Close;
             RootMenu.Dispose();
+            RootMenu = default!;
         }
 
         /// <summary>
index 845bd7c03d27a50af2c817b7e5414155e29cac65..07b6f57bdb986e98f4e733fcefde93b340bf14b1 100644 (file)
@@ -4,6 +4,7 @@ using Robust.Client.Graphics;
 using Robust.Client.Input;
 using Robust.Shared.Enums;
 using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
 
 namespace Content.Client.Decals.Overlays;
 
@@ -16,7 +17,7 @@ public sealed class DecalPlacementOverlay : Overlay
     private readonly SharedTransformSystem _transform;
     private readonly SpriteSystem _sprite;
 
-    public override OverlaySpace Space => OverlaySpace.WorldSpace;
+    public override OverlaySpace Space => OverlaySpace.WorldSpaceEntities;
 
     public DecalPlacementOverlay(DecalPlacementSystem placement, SharedTransformSystem transform, SpriteSystem sprite)
     {
@@ -24,6 +25,7 @@ public sealed class DecalPlacementOverlay : Overlay
         _placement = placement;
         _transform = transform;
         _sprite = sprite;
+        ZIndex = 1000;
     }
 
     protected override void Draw(in OverlayDrawArgs args)
@@ -55,7 +57,7 @@ public sealed class DecalPlacementOverlay : Overlay
 
         if (snap)
         {
-            localPos = (Vector2) localPos.Floored() + grid.TileSizeHalfVector;
+            localPos = localPos.Floored() + grid.TileSizeHalfVector;
         }
 
         // Nothing uses snap cardinals so probably don't need preview?
index 328cf41d0d4f74f2972375cb152369d6ba843720..1fd237cf3e32fb2adebe0b4bbc2e53b41f0c758b 100644 (file)
@@ -4,23 +4,23 @@ using Content.Client.Chat.Managers;
 using Content.Client.Clickable;
 using Content.Client.DebugMon;
 using Content.Client.Eui;
+using Content.Client.Fullscreen;
 using Content.Client.GhostKick;
+using Content.Client.Guidebook;
 using Content.Client.Launcher;
+using Content.Client.Mapping;
 using Content.Client.Parallax.Managers;
 using Content.Client.Players.PlayTimeTracking;
+using Content.Client.Replay;
 using Content.Client.Screenshot;
-using Content.Client.Fullscreen;
 using Content.Client.Stylesheets;
 using Content.Client.Viewport;
 using Content.Client.Voting;
 using Content.Shared.Administration.Logs;
-using Content.Client.Guidebook;
 using Content.Client.Lobby;
-using Content.Client.Replay;
 using Content.Shared.Administration.Managers;
 using Content.Shared.Players.PlayTimeTracking;
 
-
 namespace Content.Client.IoC
 {
     internal static class ClientContentIoC
@@ -49,6 +49,7 @@ namespace Content.Client.IoC
             collection.Register<DocumentParsingManager>();
             collection.Register<ContentReplayPlaybackManager, ContentReplayPlaybackManager>();
             collection.Register<ISharedPlaytimeManager, JobRequirementsManager>();
+            collection.Register<MappingManager>();
             collection.Register<DebugMonitorManager>();
         }
     }
diff --git a/Content.Client/Mapping/MappingActionsButton.xaml b/Content.Client/Mapping/MappingActionsButton.xaml
new file mode 100644 (file)
index 0000000..099719a
--- /dev/null
@@ -0,0 +1,8 @@
+<mapping:MappingActionsButton
+    xmlns="https://spacestation14.io"
+    xmlns:mapping="clr-namespace:Content.Client.Mapping"
+    StyleClasses="ButtonSquare" ToggleMode="True" SetSize="32 32" Margin="0 0 5 0"
+    TooltipDelay="0">
+    <TextureRect Name="Texture" Access="Public" Stretch="Scale" SetSize="16 16"
+                 HorizontalAlignment="Center" VerticalAlignment="Center" />
+</mapping:MappingActionsButton>
diff --git a/Content.Client/Mapping/MappingActionsButton.xaml.cs b/Content.Client/Mapping/MappingActionsButton.xaml.cs
new file mode 100644 (file)
index 0000000..1a2f2c0
--- /dev/null
@@ -0,0 +1,15 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Mapping;
+
+[GenerateTypedNameReferences]
+public sealed partial class MappingActionsButton : Button
+{
+    public MappingActionsButton()
+    {
+        RobustXamlLoader.Load(this);
+    }
+}
+
diff --git a/Content.Client/Mapping/MappingDoNotMeasure.xaml b/Content.Client/Mapping/MappingDoNotMeasure.xaml
new file mode 100644 (file)
index 0000000..0890963
--- /dev/null
@@ -0,0 +1,4 @@
+<mapping:MappingDoNotMeasure
+    xmlns="https://spacestation14.io"
+    xmlns:mapping="clr-namespace:Content.Client.Mapping">
+</mapping:MappingDoNotMeasure>
diff --git a/Content.Client/Mapping/MappingDoNotMeasure.xaml.cs b/Content.Client/Mapping/MappingDoNotMeasure.xaml.cs
new file mode 100644 (file)
index 0000000..c4cb560
--- /dev/null
@@ -0,0 +1,21 @@
+using System.Numerics;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Mapping;
+
+[GenerateTypedNameReferences]
+public sealed partial class MappingDoNotMeasure : Control
+{
+    public MappingDoNotMeasure()
+    {
+        RobustXamlLoader.Load(this);
+    }
+
+    protected override Vector2 MeasureOverride(Vector2 availableSize)
+    {
+        return Vector2.Zero;
+    }
+}
+
diff --git a/Content.Client/Mapping/MappingManager.cs b/Content.Client/Mapping/MappingManager.cs
new file mode 100644 (file)
index 0000000..1aac02b
--- /dev/null
@@ -0,0 +1,69 @@
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using Content.Shared.Mapping;
+using Robust.Client.UserInterface;
+using Robust.Shared.Network;
+
+namespace Content.Client.Mapping;
+
+public sealed class MappingManager : IPostInjectInit
+{
+    [Dependency] private readonly IFileDialogManager _file = default!;
+    [Dependency] private readonly IClientNetManager _net = default!;
+
+    private Stream? _saveStream;
+    private MappingMapDataMessage? _mapData;
+
+    public void PostInject()
+    {
+        _net.RegisterNetMessage<MappingSaveMapMessage>();
+        _net.RegisterNetMessage<MappingSaveMapErrorMessage>(OnSaveError);
+        _net.RegisterNetMessage<MappingMapDataMessage>(OnMapData);
+    }
+
+    private void OnSaveError(MappingSaveMapErrorMessage message)
+    {
+        _saveStream?.DisposeAsync();
+        _saveStream = null;
+    }
+
+    private async void OnMapData(MappingMapDataMessage message)
+    {
+        if (_saveStream == null)
+        {
+            _mapData = message;
+            return;
+        }
+
+        await _saveStream.WriteAsync(Encoding.ASCII.GetBytes(message.Yml));
+        await _saveStream.DisposeAsync();
+
+        _saveStream = null;
+        _mapData = null;
+    }
+
+    public async Task SaveMap()
+    {
+        if (_saveStream != null)
+            await _saveStream.DisposeAsync();
+
+        var request = new MappingSaveMapMessage();
+        _net.ClientSendMessage(request);
+
+        var path = await _file.SaveFile();
+        if (path is not { fileStream: var stream })
+            return;
+
+        if (_mapData != null)
+        {
+            await stream.WriteAsync(Encoding.ASCII.GetBytes(_mapData.Yml));
+            _mapData = null;
+            await stream.FlushAsync();
+            await stream.DisposeAsync();
+            return;
+        }
+
+        _saveStream = stream;
+    }
+}
diff --git a/Content.Client/Mapping/MappingOverlay.cs b/Content.Client/Mapping/MappingOverlay.cs
new file mode 100644 (file)
index 0000000..ef9f3e7
--- /dev/null
@@ -0,0 +1,84 @@
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Input;
+using Robust.Client.Player;
+using Robust.Client.UserInterface;
+using Robust.Shared.Enums;
+using Robust.Shared.Prototypes;
+using static Content.Client.Mapping.MappingState;
+
+namespace Content.Client.Mapping;
+
+public sealed class MappingOverlay : Overlay
+{
+    [Dependency] private readonly IEntityManager _entities = default!;
+    [Dependency] private readonly IPlayerManager _player = default!;
+    [Dependency] private readonly IPrototypeManager _prototypes = default!;
+
+    // 1 off in case something else uses these colors since we use them to compare
+    private static readonly Color PickColor = new(1, 255, 0);
+    private static readonly Color DeleteColor = new(255, 1, 0);
+
+    private readonly Dictionary<EntityUid, Color> _oldColors = new();
+
+    private readonly MappingState _state;
+    private readonly ShaderInstance _shader;
+
+    public override OverlaySpace Space => OverlaySpace.WorldSpace;
+
+    public MappingOverlay(MappingState state)
+    {
+        IoCManager.InjectDependencies(this);
+
+        _state = state;
+        _shader = _prototypes.Index<ShaderPrototype>("unshaded").Instance();
+    }
+
+    protected override void Draw(in OverlayDrawArgs args)
+    {
+        foreach (var (id, color) in _oldColors)
+        {
+            if (!_entities.TryGetComponent(id, out SpriteComponent? sprite))
+                continue;
+
+            if (sprite.Color == DeleteColor || sprite.Color == PickColor)
+                sprite.Color = color;
+        }
+
+        _oldColors.Clear();
+
+        if (_player.LocalEntity == null)
+            return;
+
+        var handle = args.WorldHandle;
+        handle.UseShader(_shader);
+
+        switch (_state.State)
+        {
+            case CursorState.Pick:
+            {
+                if (_state.GetHoveredEntity() is { } entity &&
+                    _entities.TryGetComponent(entity, out SpriteComponent? sprite))
+                {
+                    _oldColors[entity] = sprite.Color;
+                    sprite.Color = PickColor;
+                }
+
+                break;
+            }
+            case CursorState.Delete:
+            {
+                if (_state.GetHoveredEntity() is { } entity &&
+                    _entities.TryGetComponent(entity, out SpriteComponent? sprite))
+                {
+                    _oldColors[entity] = sprite.Color;
+                    sprite.Color = DeleteColor;
+                }
+
+                break;
+            }
+        }
+
+        handle.UseShader(null);
+    }
+}
diff --git a/Content.Client/Mapping/MappingPrototype.cs b/Content.Client/Mapping/MappingPrototype.cs
new file mode 100644 (file)
index 0000000..eff2dfa
--- /dev/null
@@ -0,0 +1,39 @@
+using Content.Shared.Decals;
+using Content.Shared.Maps;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.Mapping;
+
+/// <summary>
+///     Used to represent a button's data in the mapping editor.
+/// </summary>
+public sealed class MappingPrototype
+{
+    /// <summary>
+    ///     The prototype instance, if any.
+    ///     Can be one of <see cref="EntityPrototype"/>, <see cref="ContentTileDefinition"/> or <see cref="DecalPrototype"/>
+    ///     If null, this is a top-level button (such as Entities, Tiles or Decals)
+    /// </summary>
+    public readonly IPrototype? Prototype;
+
+    /// <summary>
+    ///     The text to display on the UI for this button.
+    /// </summary>
+    public readonly string Name;
+
+    /// <summary>
+    ///     Which other prototypes (buttons) this one is nested inside of.
+    /// </summary>
+    public List<MappingPrototype>? Parents;
+
+    /// <summary>
+    ///     Which other prototypes (buttons) are nested inside this one.
+    /// </summary>
+    public List<MappingPrototype>? Children;
+
+    public MappingPrototype(IPrototype? prototype, string name)
+    {
+        Prototype = prototype;
+        Name = name;
+    }
+}
diff --git a/Content.Client/Mapping/MappingPrototypeList.xaml b/Content.Client/Mapping/MappingPrototypeList.xaml
new file mode 100644 (file)
index 0000000..de31124
--- /dev/null
@@ -0,0 +1,21 @@
+<mapping:MappingPrototypeList
+    xmlns="https://spacestation14.io"
+    xmlns:mapping="clr-namespace:Content.Client.Mapping">
+    <BoxContainer Orientation="Vertical">
+        <BoxContainer Orientation="Horizontal">
+            <Button Name="CollapseAllButton" Access="Public" Text="-" SetSize="48 48"
+                    StyleClasses="ButtonSquare" ToolTip="Collapse All" TooltipDelay="0" />
+            <LineEdit Name="SearchBar" SetHeight="48" HorizontalExpand="True" Access="Public" />
+            <Button Name="ClearSearchButton" Access="Public" Text="X" SetSize="48 48"
+                    StyleClasses="ButtonSquare" />
+        </BoxContainer>
+        <ScrollContainer Name="ScrollContainer" Access="Public" VerticalExpand="True"
+                         ReserveScrollbarSpace="True">
+            <BoxContainer Name="PrototypeList" Access="Public" Orientation="Vertical" />
+            <PrototypeListContainer Name="SearchList" Access="Public" Visible="False" />
+        </ScrollContainer>
+        <mapping:MappingDoNotMeasure Visible="False">
+            <mapping:MappingSpawnButton Name="MeasureButton" Access="Public" />
+        </mapping:MappingDoNotMeasure>
+    </BoxContainer>
+</mapping:MappingPrototypeList>
diff --git a/Content.Client/Mapping/MappingPrototypeList.xaml.cs b/Content.Client/Mapping/MappingPrototypeList.xaml.cs
new file mode 100644 (file)
index 0000000..8b59e6e
--- /dev/null
@@ -0,0 +1,170 @@
+using System.Numerics;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using static Robust.Client.UserInterface.Controls.BaseButton;
+
+namespace Content.Client.Mapping;
+
+[GenerateTypedNameReferences]
+public sealed partial class MappingPrototypeList : Control
+{
+    private (int start, int end) _lastIndices;
+    private readonly List<MappingPrototype> _prototypes = new();
+    private readonly List<Texture> _insertTextures = new();
+    private readonly List<MappingPrototype> _search = new();
+
+    public MappingSpawnButton? Selected;
+    public Action<IPrototype, List<Texture>>? GetPrototypeData;
+    public event Action<MappingSpawnButton, IPrototype?>? SelectionChanged;
+    public event Action<MappingSpawnButton, ButtonToggledEventArgs>? CollapseToggled;
+
+    public MappingPrototypeList()
+    {
+        RobustXamlLoader.Load(this);
+
+        MeasureButton.Measure(Vector2Helpers.Infinity);
+
+        ScrollContainer.OnScrolled += UpdateSearch;
+        OnResized += UpdateSearch;
+    }
+
+    public void UpdateVisible(List<MappingPrototype> prototypes)
+    {
+        _prototypes.Clear();
+
+        PrototypeList.DisposeAllChildren();
+
+        _prototypes.AddRange(prototypes);
+
+        Selected = null;
+        ScrollContainer.SetScrollValue(new Vector2(0, 0));
+
+        foreach (var prototype in _prototypes)
+        {
+            Insert(PrototypeList, prototype, true);
+        }
+    }
+
+    public MappingSpawnButton Insert(Container list, MappingPrototype mapping, bool includeChildren)
+    {
+        var prototype = mapping.Prototype;
+
+        _insertTextures.Clear();
+
+        if (prototype != null)
+            GetPrototypeData?.Invoke(prototype, _insertTextures);
+
+        var button = new MappingSpawnButton { Prototype = mapping };
+        button.Label.Text = mapping.Name;
+
+        if (_insertTextures.Count > 0)
+        {
+            button.Texture.Textures.AddRange(_insertTextures);
+            button.Texture.InvalidateMeasure();
+        }
+        else
+        {
+            button.Texture.Visible = false;
+        }
+
+        if (prototype != null && button.Prototype == Selected?.Prototype)
+        {
+            Selected = button;
+            button.Button.Pressed = true;
+        }
+
+        list.AddChild(button);
+
+        button.Button.OnToggled += _ => SelectionChanged?.Invoke(button, prototype);
+
+        if (includeChildren && mapping.Children?.Count > 0)
+        {
+            button.CollapseButton.Visible = true;
+            button.CollapseButton.OnToggled += args => CollapseToggled?.Invoke(button, args);
+        }
+        else
+        {
+            button.CollapseButtonWrapper.Visible = false;
+            button.CollapseButton.Visible = false;
+        }
+
+        return button;
+    }
+
+    public void Search(List<MappingPrototype> prototypes)
+    {
+        _search.Clear();
+        SearchList.DisposeAllChildren();
+        _lastIndices = (0, -1);
+
+        _search.AddRange(prototypes);
+        SearchList.TotalItemCount = _search.Count;
+        ScrollContainer.SetScrollValue(new Vector2(0, 0));
+
+        UpdateSearch();
+    }
+
+    /// <summary>
+    ///     Constructs a virtual list where not all buttons exist at one time, since there may be thousands of them.
+    /// </summary>
+    private void UpdateSearch()
+    {
+        if (!SearchList.Visible)
+            return;
+
+        var height = MeasureButton.DesiredSize.Y + PrototypeListContainer.Separation;
+        var offset = Math.Max(-SearchList.Position.Y, 0);
+        var startIndex = (int) Math.Floor(offset / height);
+        SearchList.ItemOffset = startIndex;
+
+        var (prevStart, prevEnd) = _lastIndices;
+        var endIndex = startIndex - 1;
+        var spaceUsed = -height;
+
+        // calculate how far down we are scrolled
+        while (spaceUsed < SearchList.Parent!.Height)
+        {
+            spaceUsed += height;
+            endIndex += 1;
+        }
+
+        endIndex = Math.Min(endIndex, _search.Count - 1);
+
+        // nothing changed in terms of which buttons are visible now and before
+        if (endIndex == prevEnd && startIndex == prevStart)
+            return;
+
+        _lastIndices = (startIndex, endIndex);
+
+        // remove previously seen but now unseen buttons from the top
+        for (var i = prevStart; i < startIndex && i <= prevEnd; i++)
+        {
+            var control = SearchList.GetChild(0);
+            SearchList.RemoveChild(control);
+        }
+
+        // remove previously seen but now unseen buttons from the bottom
+        for (var i = prevEnd; i > endIndex && i >= prevStart; i--)
+        {
+            var control = SearchList.GetChild(SearchList.ChildCount - 1);
+            SearchList.RemoveChild(control);
+        }
+
+        // insert buttons that can now be seen, from the start
+        for (var i = Math.Min(prevStart - 1, endIndex); i >= startIndex; i--)
+        {
+            Insert(SearchList, _search[i], false).SetPositionInParent(0);
+        }
+
+        // insert buttons that can now be seen, from the end
+        for (var i = Math.Max(prevEnd + 1, startIndex); i <= endIndex; i++)
+        {
+            Insert(SearchList, _search[i], false);
+        }
+    }
+}
diff --git a/Content.Client/Mapping/MappingScreen.xaml b/Content.Client/Mapping/MappingScreen.xaml
new file mode 100644 (file)
index 0000000..b641360
--- /dev/null
@@ -0,0 +1,85 @@
+<mapping:MappingScreen
+    xmlns="https://spacestation14.io"
+    xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+    xmlns:widgets="clr-namespace:Content.Client.UserInterface.Systems.Chat.Widgets"
+    xmlns:hotbar="clr-namespace:Content.Client.UserInterface.Systems.Hotbar.Widgets"
+    xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+    xmlns:mapping="clr-namespace:Content.Client.Mapping"
+    VerticalExpand="False"
+    VerticalAlignment="Bottom"
+    HorizontalAlignment="Center">
+    <controls:RecordedSplitContainer Name="ScreenContainer" HorizontalExpand="True"
+                                     VerticalExpand="True" SplitWidth="0"
+                                     StretchDirection="TopLeft">
+        <BoxContainer Orientation="Vertical" VerticalExpand="True" Name="SpawnContainer" MinWidth="200" SetWidth="600">
+            <mapping:MappingPrototypeList Name="Prototypes" Access="Public" VerticalExpand="True" />
+            <BoxContainer Name="DecalContainer" Access="Public" Orientation="Horizontal"
+                          Visible="False">
+                <BoxContainer Orientation="Vertical" HorizontalExpand="True">
+                    <ColorSelectorSliders Name="DecalColorPicker" IsAlphaVisible="True" />
+                    <Button Name="DecalPickerOpen" Text="{Loc decal-placer-window-palette}"
+                            StyleClasses="ButtonSquare" />
+                </BoxContainer>
+                <BoxContainer Orientation="Vertical" HorizontalExpand="True">
+                    <CheckBox Name="DecalEnableAuto" Margin="0 0 0 10"
+                              Text="{Loc decal-placer-window-enable-auto}" />
+                    <CheckBox Name="DecalEnableSnap"
+                              Text="{Loc decal-placer-window-enable-snap}" />
+                    <CheckBox Name="DecalEnableCleanable"
+                              Text="{Loc decal-placer-window-enable-cleanable}" />
+                    <BoxContainer Name="DecalSpinBoxContainer" Orientation="Horizontal">
+                        <Label Text="{Loc decal-placer-window-rotation}" Margin="0 0 0 1" />
+                    </BoxContainer>
+                    <BoxContainer Orientation="Horizontal">
+                        <Label Text="{Loc decal-placer-window-zindex}" Margin="0 0 0 1" />
+                        <SpinBox Name="DecalZIndexSpinBox" HorizontalExpand="True" />
+                    </BoxContainer>
+                </BoxContainer>
+            </BoxContainer>
+            <BoxContainer Name="EntityContainer" Access="Public" Orientation="Horizontal"
+                          Visible="False">
+                <Button Name="EntityReplaceButton" Access="Public" ToggleMode="True"
+                        SetHeight="48"
+                        StyleClasses="ButtonSquare" Text="{Loc 'mapping-replace'}" HorizontalExpand="True" />
+                <OptionButton Name="EntityPlacementMode" Access="Public"
+                              SetHeight="48"
+                              StyleClasses="ButtonSquare" TooltipDelay="0"
+                              ToolTip="{Loc entity-spawn-window-override-menu-tooltip}"
+                              HorizontalExpand="True" />
+            </BoxContainer>
+            <BoxContainer Orientation="Horizontal">
+                <Button Name="EraseEntityButton" Access="Public" HorizontalExpand="True"
+                        SetHeight="48"
+                        ToggleMode="True" Text="{Loc 'mapping-erase-entity'}" StyleClasses="ButtonSquare" />
+                <Button Name="EraseDecalButton" Access="Public" HorizontalExpand="True"
+                        SetHeight="48"
+                        ToggleMode="True" Text="{Loc 'mapping-erase-decal'}" StyleClasses="ButtonSquare" />
+            </BoxContainer>
+            <widgets:ChatBox Visible="False" />
+        </BoxContainer>
+        <LayoutContainer Name="ViewportContainer" HorizontalExpand="True" VerticalExpand="True">
+            <controls:MainViewport Name="MainViewport"/>
+            <hotbar:HotbarGui Name="Hotbar" />
+            <PanelContainer Name="Actions" VerticalExpand="True" HorizontalExpand="True"
+                            MaxHeight="48">
+                <PanelContainer.PanelOverride>
+                    <graphics:StyleBoxFlat BackgroundColor="#222222AA" />
+                </PanelContainer.PanelOverride>
+                <BoxContainer Orientation="Horizontal" Margin="15 10">
+                    <mapping:MappingActionsButton
+                        Name="Add" Access="Public" Disabled="True" ToolTip="" Visible="False" />
+                    <mapping:MappingActionsButton Name="Fill" Access="Public"
+                                                  ToolTip="" Visible="False" />
+                    <mapping:MappingActionsButton Name="Grab" Access="Public"
+                                                  ToolTip="" Visible="False" />
+                    <mapping:MappingActionsButton Name="Move" Access="Public"
+                                                  ToolTip="" Visible="False" />
+                    <mapping:MappingActionsButton Name="Pick" Access="Public"
+                                                  ToolTip="Pick (Hold 5)" />
+                    <mapping:MappingActionsButton Name="Delete" Access="Public"
+                                                  ToolTip="Delete (Hold 6)" />
+                </BoxContainer>
+            </PanelContainer>
+        </LayoutContainer>
+    </controls:RecordedSplitContainer>
+</mapping:MappingScreen>
diff --git a/Content.Client/Mapping/MappingScreen.xaml.cs b/Content.Client/Mapping/MappingScreen.xaml.cs
new file mode 100644 (file)
index 0000000..b2ad2fd
--- /dev/null
@@ -0,0 +1,197 @@
+using System.Linq;
+using System.Numerics;
+using Content.Client.Decals;
+using Content.Client.Decals.UI;
+using Content.Client.UserInterface.Screens;
+using Content.Client.UserInterface.Systems.Chat.Widgets;
+using Content.Shared.Decals;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using static Robust.Client.UserInterface.Controls.BaseButton;
+
+namespace Content.Client.Mapping;
+
+[GenerateTypedNameReferences]
+public sealed partial class MappingScreen : InGameScreen
+{
+    [Dependency] private readonly IPrototypeManager _prototype = default!;
+
+    public DecalPlacementSystem DecalSystem = default!;
+
+    private PaletteColorPicker? _picker;
+
+    private ProtoId<DecalPrototype>? _id;
+    private Color _decalColor = Color.White;
+    private float _decalRotation;
+    private bool _decalSnap;
+    private int _decalZIndex;
+    private bool _decalCleanable;
+
+    private bool _decalAuto;
+
+    public override ChatBox ChatBox => GetWidget<ChatBox>()!;
+
+    public event Func<MappingSpawnButton, bool>? IsDecalVisible;
+
+    public MappingScreen()
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+
+        AutoscaleMaxResolution = new Vector2i(1080, 770);
+
+        SetAnchorPreset(ScreenContainer, LayoutPreset.Wide);
+        SetAnchorPreset(ViewportContainer, LayoutPreset.Wide);
+        SetAnchorPreset(SpawnContainer, LayoutPreset.Wide);
+        SetAnchorPreset(MainViewport, LayoutPreset.Wide);
+        SetAnchorAndMarginPreset(Hotbar, LayoutPreset.BottomWide, margin: 5);
+        SetAnchorAndMarginPreset(Actions, LayoutPreset.TopWide, margin: 5);
+
+        ScreenContainer.OnSplitResizeFinished += () =>
+            OnChatResized?.Invoke(new Vector2(ScreenContainer.SplitFraction, 0));
+
+        var rotationSpinBox = new FloatSpinBox(90.0f, 0)
+        {
+            HorizontalExpand = true
+        };
+        DecalSpinBoxContainer.AddChild(rotationSpinBox);
+
+        DecalColorPicker.OnColorChanged += OnDecalColorPicked;
+        DecalPickerOpen.OnPressed += OnDecalPickerOpenPressed;
+        rotationSpinBox.OnValueChanged += args =>
+        {
+            _decalRotation = args.Value;
+            UpdateDecal();
+        };
+        DecalEnableAuto.OnToggled += args =>
+        {
+            _decalAuto = args.Pressed;
+            if (_id is { } id)
+                SelectDecal(id);
+        };
+        DecalEnableSnap.OnToggled += args =>
+        {
+            _decalSnap = args.Pressed;
+            UpdateDecal();
+        };
+        DecalEnableCleanable.OnToggled += args =>
+        {
+            _decalCleanable = args.Pressed;
+            UpdateDecal();
+        };
+        DecalZIndexSpinBox.ValueChanged += args =>
+        {
+            _decalZIndex = args.Value;
+            UpdateDecal();
+        };
+
+        for (var i = 0; i < EntitySpawnWindow.InitOpts.Length; i++)
+        {
+            EntityPlacementMode.AddItem(EntitySpawnWindow.InitOpts[i], i);
+        }
+
+        Pick.Texture.TexturePath = "/Textures/Interface/eyedropper.svg.png";
+        Delete.Texture.TexturePath = "/Textures/Interface/eraser.svg.png";
+    }
+
+    private void OnDecalColorPicked(Color color)
+    {
+        _decalColor = color;
+        DecalColorPicker.Color = color;
+        UpdateDecal();
+    }
+
+    private void OnDecalPickerOpenPressed(ButtonEventArgs obj)
+    {
+        if (_picker == null)
+        {
+            _picker = new PaletteColorPicker();
+            _picker.OpenToLeft();
+            _picker.PaletteList.OnItemSelected += args =>
+            {
+                var color = ((Color?) args.ItemList.GetSelected().First().Metadata)!.Value;
+                OnDecalColorPicked(color);
+            };
+
+            return;
+        }
+
+        if (_picker.IsOpen)
+            _picker.Close();
+        else
+            _picker.Open();
+    }
+
+    private void UpdateDecal()
+    {
+        if (_id is not { } id)
+            return;
+
+        DecalSystem.UpdateDecalInfo(id, _decalColor, _decalRotation, _decalSnap, _decalZIndex, _decalCleanable);
+    }
+
+    public void SelectDecal(string decalId)
+    {
+        if (!_prototype.TryIndex<DecalPrototype>(decalId, out var decal))
+            return;
+
+        _id = decalId;
+
+        if (_decalAuto)
+        {
+            _decalColor = Color.White;
+            _decalCleanable = decal.DefaultCleanable;
+            _decalSnap = decal.DefaultSnap;
+
+            DecalColorPicker.Color = _decalColor;
+            DecalEnableCleanable.Pressed = _decalCleanable;
+            DecalEnableSnap.Pressed = _decalSnap;
+        }
+
+        UpdateDecal();
+        RefreshList();
+    }
+
+    private void RefreshList()
+    {
+        foreach (var control in Prototypes.Children)
+        {
+            if (control is not MappingSpawnButton button ||
+                button.Prototype?.Prototype is not DecalPrototype)
+            {
+                continue;
+            }
+
+            foreach (var child in button.Children)
+            {
+                if (child is not MappingSpawnButton { Prototype.Prototype: DecalPrototype } childButton)
+                {
+                    continue;
+                }
+
+                childButton.Texture.Modulate = _decalColor;
+                childButton.Visible = IsDecalVisible?.Invoke(childButton) ?? true;
+            }
+        }
+    }
+
+    public override void SetChatSize(Vector2 size)
+    {
+        ScreenContainer.DesiredSplitCenter = size.X;
+        ScreenContainer.ResizeMode = SplitContainer.SplitResizeMode.RespectChildrenMinSize;
+    }
+
+    public void UnPressActionsExcept(Control except)
+    {
+        Add.Pressed = Add == except;
+        Fill.Pressed = Fill == except;
+        Grab.Pressed = Grab == except;
+        Move.Pressed = Move == except;
+        Pick.Pressed = Pick == except;
+        Delete.Pressed = Delete == except;
+    }
+}
diff --git a/Content.Client/Mapping/MappingSpawnButton.xaml b/Content.Client/Mapping/MappingSpawnButton.xaml
new file mode 100644 (file)
index 0000000..a944d5e
--- /dev/null
@@ -0,0 +1,26 @@
+<mapping:MappingSpawnButton
+    xmlns="https://spacestation14.io"
+    xmlns:mapping="clr-namespace:Content.Client.Mapping">
+    <BoxContainer Orientation="Vertical">
+        <Control>
+            <Button Name="Button" Access="Public" ToggleMode="True" StyleClasses="ButtonSquare" />
+            <BoxContainer Orientation="Horizontal">
+                <LayeredTextureRect Name="Texture" Access="Public" MinSize="48 48"
+                                    HorizontalAlignment="Center" VerticalAlignment="Center"
+                                    Stretch="KeepAspectCentered" CanShrink="True" />
+                <Control SetSize="48 48" Access="Public" Name="CollapseButtonWrapper">
+                    <Button Name="CollapseButton" Access="Public" Text="▶"
+                            ToggleMode="True" StyleClasses="ButtonSquare" SetSize="48 48" />
+                </Control>
+                <Label Name="Label" Access="Public"
+                       VAlign="Center"
+                       VerticalExpand="True"
+                       MinHeight="48"
+                       Margin="5 0"
+                       HorizontalExpand="True" ClipText="True" />
+            </BoxContainer>
+        </Control>
+        <BoxContainer Name="ChildrenPrototypes" Access="Public" Orientation="Vertical"
+                      Margin="24 0 0 0" />
+    </BoxContainer>
+</mapping:MappingSpawnButton>
diff --git a/Content.Client/Mapping/MappingSpawnButton.xaml.cs b/Content.Client/Mapping/MappingSpawnButton.xaml.cs
new file mode 100644 (file)
index 0000000..29fb884
--- /dev/null
@@ -0,0 +1,16 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Mapping;
+
+[GenerateTypedNameReferences]
+public sealed partial class MappingSpawnButton : Control
+{
+    public MappingPrototype? Prototype;
+
+    public MappingSpawnButton()
+    {
+        RobustXamlLoader.Load(this);
+    }
+}
diff --git a/Content.Client/Mapping/MappingState.cs b/Content.Client/Mapping/MappingState.cs
new file mode 100644 (file)
index 0000000..bcc739f
--- /dev/null
@@ -0,0 +1,936 @@
+using System.Linq;
+using System.Numerics;
+using Content.Client.Administration.Managers;
+using Content.Client.ContextMenu.UI;
+using Content.Client.Decals;
+using Content.Client.Gameplay;
+using Content.Client.UserInterface.Controls;
+using Content.Client.UserInterface.Systems.Gameplay;
+using Content.Client.Verbs;
+using Content.Shared.Administration;
+using Content.Shared.Decals;
+using Content.Shared.Input;
+using Content.Shared.Maps;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Input;
+using Robust.Client.Placement;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Shared.Enums;
+using Robust.Shared.Input.Binding;
+using Robust.Shared.Map;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.Markdown.Sequence;
+using Robust.Shared.Serialization.Markdown.Value;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using static System.StringComparison;
+using static Robust.Client.UserInterface.Controls.BaseButton;
+using static Robust.Client.UserInterface.Controls.LineEdit;
+using static Robust.Client.UserInterface.Controls.OptionButton;
+using static Robust.Shared.Input.Binding.PointerInputCmdHandler;
+
+namespace Content.Client.Mapping;
+
+public sealed class MappingState : GameplayStateBase
+{
+    [Dependency] private readonly IClientAdminManager _admin = default!;
+    [Dependency] private readonly IEntityManager _entityManager = default!;
+    [Dependency] private readonly IEntityNetworkManager _entityNetwork = default!;
+    [Dependency] private readonly IInputManager _input = default!;
+    [Dependency] private readonly ILogManager _log = default!;
+    [Dependency] private readonly IMapManager _mapMan = default!;
+    [Dependency] private readonly MappingManager _mapping = default!;
+    [Dependency] private readonly IOverlayManager _overlays = default!;
+    [Dependency] private readonly IPlacementManager _placement = default!;
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+    [Dependency] private readonly IResourceCache _resources = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+
+    private EntityMenuUIController _entityMenuController = default!;
+
+    private DecalPlacementSystem _decal = default!;
+    private SpriteSystem _sprite = default!;
+    private TransformSystem _transform = default!;
+    private VerbSystem _verbs = default!;
+
+    private readonly ISawmill _sawmill;
+    private readonly GameplayStateLoadController _loadController;
+    private bool _setup;
+    private readonly List<MappingPrototype> _allPrototypes = new();
+    private readonly Dictionary<IPrototype, MappingPrototype> _allPrototypesDict = new();
+    private readonly Dictionary<Type, Dictionary<string, MappingPrototype>> _idDict = new();
+    private readonly List<MappingPrototype> _prototypes = new();
+    private (TimeSpan At, MappingSpawnButton Button)? _lastClicked;
+    private Control? _scrollTo;
+    private bool _updatePlacement;
+    private bool _updateEraseDecal;
+
+    private MappingScreen Screen => (MappingScreen) UserInterfaceManager.ActiveScreen!;
+    private MainViewport Viewport => UserInterfaceManager.ActiveScreen!.GetWidget<MainViewport>()!;
+
+    public CursorState State { get; set; }
+
+    public MappingState()
+    {
+        IoCManager.InjectDependencies(this);
+
+        _sawmill = _log.GetSawmill("mapping");
+        _loadController = UserInterfaceManager.GetUIController<GameplayStateLoadController>();
+    }
+
+    protected override void Startup()
+    {
+        EnsureSetup();
+        base.Startup();
+
+        UserInterfaceManager.LoadScreen<MappingScreen>();
+        _loadController.LoadScreen();
+
+        var context = _input.Contexts.GetContext("common");
+        context.AddFunction(ContentKeyFunctions.MappingUnselect);
+        context.AddFunction(ContentKeyFunctions.SaveMap);
+        context.AddFunction(ContentKeyFunctions.MappingEnablePick);
+        context.AddFunction(ContentKeyFunctions.MappingEnableDelete);
+        context.AddFunction(ContentKeyFunctions.MappingPick);
+        context.AddFunction(ContentKeyFunctions.MappingRemoveDecal);
+        context.AddFunction(ContentKeyFunctions.MappingCancelEraseDecal);
+        context.AddFunction(ContentKeyFunctions.MappingOpenContextMenu);
+
+        Screen.DecalSystem = _decal;
+        Screen.Prototypes.SearchBar.OnTextChanged += OnSearch;
+        Screen.Prototypes.CollapseAllButton.OnPressed += OnCollapseAll;
+        Screen.Prototypes.ClearSearchButton.OnPressed += OnClearSearch;
+        Screen.Prototypes.GetPrototypeData += OnGetData;
+        Screen.Prototypes.SelectionChanged += OnSelected;
+        Screen.Prototypes.CollapseToggled += OnCollapseToggled;
+        Screen.Pick.OnPressed += OnPickPressed;
+        Screen.Delete.OnPressed += OnDeletePressed;
+        Screen.EntityReplaceButton.OnToggled += OnEntityReplacePressed;
+        Screen.EntityPlacementMode.OnItemSelected += OnEntityPlacementSelected;
+        Screen.EraseEntityButton.OnToggled += OnEraseEntityPressed;
+        Screen.EraseDecalButton.OnToggled += OnEraseDecalPressed;
+        _placement.PlacementChanged += OnPlacementChanged;
+
+        CommandBinds.Builder
+            .Bind(ContentKeyFunctions.MappingUnselect, new PointerInputCmdHandler(HandleMappingUnselect, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.SaveMap, new PointerInputCmdHandler(HandleSaveMap, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingEnablePick, new PointerStateInputCmdHandler(HandleEnablePick, HandleDisablePick, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingEnableDelete, new PointerStateInputCmdHandler(HandleEnableDelete, HandleDisableDelete, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingPick, new PointerInputCmdHandler(HandlePick, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingRemoveDecal, new PointerInputCmdHandler(HandleEditorCancelPlace, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingCancelEraseDecal, new PointerInputCmdHandler(HandleCancelEraseDecal, outsidePrediction: true))
+            .Bind(ContentKeyFunctions.MappingOpenContextMenu, new PointerInputCmdHandler(HandleOpenContextMenu, outsidePrediction: true))
+            .Register<MappingState>();
+
+        _overlays.AddOverlay(new MappingOverlay(this));
+
+        _prototypeManager.PrototypesReloaded += OnPrototypesReloaded;
+
+        Screen.Prototypes.UpdateVisible(_prototypes);
+    }
+
+    private void OnPrototypesReloaded(PrototypesReloadedEventArgs obj)
+    {
+        if (!obj.WasModified<EntityPrototype>() &&
+            !obj.WasModified<ContentTileDefinition>() &&
+            !obj.WasModified<DecalPrototype>())
+        {
+            return;
+        }
+
+        ReloadPrototypes();
+    }
+
+    private bool HandleOpenContextMenu(in PointerInputCmdArgs args)
+    {
+        Deselect();
+
+        var coords = args.Coordinates.ToMap(_entityManager, _transform);
+        if (_verbs.TryGetEntityMenuEntities(coords, out var entities))
+            _entityMenuController.OpenRootMenu(entities);
+
+        return true;
+    }
+
+    protected override void Shutdown()
+    {
+        CommandBinds.Unregister<MappingState>();
+
+        Screen.Prototypes.SearchBar.OnTextChanged -= OnSearch;
+        Screen.Prototypes.CollapseAllButton.OnPressed -= OnCollapseAll;
+        Screen.Prototypes.ClearSearchButton.OnPressed -= OnClearSearch;
+        Screen.Prototypes.GetPrototypeData -= OnGetData;
+        Screen.Prototypes.SelectionChanged -= OnSelected;
+        Screen.Prototypes.CollapseToggled -= OnCollapseToggled;
+        Screen.Pick.OnPressed -= OnPickPressed;
+        Screen.Delete.OnPressed -= OnDeletePressed;
+        Screen.EntityReplaceButton.OnToggled -= OnEntityReplacePressed;
+        Screen.EntityPlacementMode.OnItemSelected -= OnEntityPlacementSelected;
+        Screen.EraseEntityButton.OnToggled -= OnEraseEntityPressed;
+        Screen.EraseDecalButton.OnToggled -= OnEraseDecalPressed;
+        _placement.PlacementChanged -= OnPlacementChanged;
+        _prototypeManager.PrototypesReloaded -= OnPrototypesReloaded;
+
+        UserInterfaceManager.ClearWindows();
+        _loadController.UnloadScreen();
+        UserInterfaceManager.UnloadScreen();
+
+        var context = _input.Contexts.GetContext("common");
+        context.RemoveFunction(ContentKeyFunctions.MappingUnselect);
+        context.RemoveFunction(ContentKeyFunctions.SaveMap);
+        context.RemoveFunction(ContentKeyFunctions.MappingEnablePick);
+        context.RemoveFunction(ContentKeyFunctions.MappingEnableDelete);
+        context.RemoveFunction(ContentKeyFunctions.MappingPick);
+        context.RemoveFunction(ContentKeyFunctions.MappingRemoveDecal);
+        context.RemoveFunction(ContentKeyFunctions.MappingCancelEraseDecal);
+        context.RemoveFunction(ContentKeyFunctions.MappingOpenContextMenu);
+
+        _overlays.RemoveOverlay<MappingOverlay>();
+
+        base.Shutdown();
+    }
+
+    private void EnsureSetup()
+    {
+        if (_setup)
+            return;
+
+        _setup = true;
+
+        _entityMenuController = UserInterfaceManager.GetUIController<EntityMenuUIController>();
+
+        _decal = _entityManager.System<DecalPlacementSystem>();
+        _sprite = _entityManager.System<SpriteSystem>();
+        _transform = _entityManager.System<TransformSystem>();
+        _verbs = _entityManager.System<VerbSystem>();
+        ReloadPrototypes();
+    }
+
+    private void ReloadPrototypes()
+    {
+        var entities = new MappingPrototype(null, Loc.GetString("mapping-entities")) { Children = new List<MappingPrototype>() };
+        _prototypes.Add(entities);
+
+        var mappings = new Dictionary<string, MappingPrototype>();
+        foreach (var entity in _prototypeManager.EnumeratePrototypes<EntityPrototype>())
+        {
+            Register(entity, entity.ID, entities);
+        }
+
+        Sort(mappings, entities);
+        mappings.Clear();
+
+        var tiles = new MappingPrototype(null, Loc.GetString("mapping-tiles")) { Children = new List<MappingPrototype>() };
+        _prototypes.Add(tiles);
+
+        foreach (var tile in _prototypeManager.EnumeratePrototypes<ContentTileDefinition>())
+        {
+            Register(tile, tile.ID, tiles);
+        }
+
+        Sort(mappings, tiles);
+        mappings.Clear();
+
+        var decals = new MappingPrototype(null, Loc.GetString("mapping-decals")) { Children = new List<MappingPrototype>() };
+        _prototypes.Add(decals);
+
+        foreach (var decal in _prototypeManager.EnumeratePrototypes<DecalPrototype>())
+        {
+            Register(decal, decal.ID, decals);
+        }
+
+        Sort(mappings, decals);
+        mappings.Clear();
+    }
+
+    private void Sort(Dictionary<string, MappingPrototype> prototypes, MappingPrototype topLevel)
+    {
+        static int Compare(MappingPrototype a, MappingPrototype b)
+        {
+            return string.Compare(a.Name, b.Name, OrdinalIgnoreCase);
+        }
+
+        topLevel.Children ??= new List<MappingPrototype>();
+
+        foreach (var prototype in prototypes.Values)
+        {
+            if (prototype.Parents == null && prototype != topLevel)
+            {
+                prototype.Parents = new List<MappingPrototype> { topLevel };
+                topLevel.Children.Add(prototype);
+            }
+
+            prototype.Parents?.Sort(Compare);
+            prototype.Children?.Sort(Compare);
+        }
+
+        topLevel.Children.Sort(Compare);
+    }
+
+    private MappingPrototype? Register<T>(T? prototype, string id, MappingPrototype topLevel) where T : class, IPrototype, IInheritingPrototype
+    {
+        {
+            if (prototype == null &&
+                _prototypeManager.TryIndex(id, out prototype) &&
+                prototype is EntityPrototype entity)
+            {
+                if (entity.HideSpawnMenu || entity.Abstract)
+                    prototype = null;
+            }
+        }
+
+        if (prototype == null)
+        {
+            if (!_prototypeManager.TryGetMapping(typeof(T), id, out var node))
+            {
+                _sawmill.Error($"No {nameof(T)} found with id {id}");
+                return null;
+            }
+
+            var ids = _idDict.GetOrNew(typeof(T));
+            if (ids.TryGetValue(id, out var mapping))
+            {
+                return mapping;
+            }
+            else
+            {
+                var name = node.TryGet("name", out ValueDataNode? nameNode)
+                    ? nameNode.Value
+                    : id;
+
+                if (node.TryGet("suffix", out ValueDataNode? suffix))
+                    name = $"{name} [{suffix.Value}]";
+
+                mapping = new MappingPrototype(prototype, name);
+                _allPrototypes.Add(mapping);
+                ids.Add(id, mapping);
+
+                if (node.TryGet("parent", out ValueDataNode? parentValue))
+                {
+                    var parent = Register<T>(null, parentValue.Value, topLevel);
+
+                    if (parent != null)
+                    {
+                        mapping.Parents ??= new List<MappingPrototype>();
+                        mapping.Parents.Add(parent);
+                        parent.Children ??= new List<MappingPrototype>();
+                        parent.Children.Add(mapping);
+                    }
+                }
+                else if (node.TryGet("parent", out SequenceDataNode? parentSequence))
+                {
+                    foreach (var parentNode in parentSequence.Cast<ValueDataNode>())
+                    {
+                        var parent = Register<T>(null, parentNode.Value, topLevel);
+
+                        if (parent != null)
+                        {
+                            mapping.Parents ??= new List<MappingPrototype>();
+                            mapping.Parents.Add(parent);
+                            parent.Children ??= new List<MappingPrototype>();
+                            parent.Children.Add(mapping);
+                        }
+                    }
+                }
+                else
+                {
+                    topLevel.Children ??= new List<MappingPrototype>();
+                    topLevel.Children.Add(mapping);
+                    mapping.Parents ??= new List<MappingPrototype>();
+                    mapping.Parents.Add(topLevel);
+                }
+
+                return mapping;
+            }
+        }
+        else
+        {
+            var ids = _idDict.GetOrNew(typeof(T));
+            if (ids.TryGetValue(id, out var mapping))
+            {
+                return mapping;
+            }
+            else
+            {
+                var entity = prototype as EntityPrototype;
+                var name = entity?.Name ?? prototype.ID;
+
+                if (!string.IsNullOrWhiteSpace(entity?.EditorSuffix))
+                    name = $"{name} [{entity.EditorSuffix}]";
+
+                mapping = new MappingPrototype(prototype, name);
+                _allPrototypes.Add(mapping);
+                _allPrototypesDict.Add(prototype, mapping);
+                ids.Add(prototype.ID, mapping);
+            }
+
+            if (prototype.Parents == null)
+            {
+                topLevel.Children ??= new List<MappingPrototype>();
+                topLevel.Children.Add(mapping);
+                mapping.Parents ??= new List<MappingPrototype>();
+                mapping.Parents.Add(topLevel);
+                return mapping;
+            }
+
+            foreach (var parentId in prototype.Parents)
+            {
+                var parent = Register<T>(null, parentId, topLevel);
+
+                if (parent != null)
+                {
+                    mapping.Parents ??= new List<MappingPrototype>();
+                    mapping.Parents.Add(parent);
+                    parent.Children ??= new List<MappingPrototype>();
+                    parent.Children.Add(mapping);
+                }
+            }
+
+            return mapping;
+        }
+    }
+
+    private void OnPlacementChanged(object? sender, EventArgs e)
+    {
+        _updatePlacement = true;
+    }
+
+    protected override void OnKeyBindStateChanged(ViewportBoundKeyEventArgs args)
+    {
+        if (args.Viewport == null)
+            base.OnKeyBindStateChanged(new ViewportBoundKeyEventArgs(args.KeyEventArgs, Viewport.Viewport));
+        else
+            base.OnKeyBindStateChanged(args);
+    }
+
+    private void OnSearch(LineEditEventArgs args)
+    {
+        if (string.IsNullOrEmpty(args.Text))
+        {
+            Screen.Prototypes.PrototypeList.Visible = true;
+            Screen.Prototypes.SearchList.Visible = false;
+            return;
+        }
+
+        var matches = new List<MappingPrototype>();
+        foreach (var prototype in _allPrototypes)
+        {
+            if (prototype.Name.Contains(args.Text, OrdinalIgnoreCase))
+                matches.Add(prototype);
+        }
+
+        matches.Sort(static (a, b) => string.Compare(a.Name, b.Name, OrdinalIgnoreCase));
+
+        Screen.Prototypes.PrototypeList.Visible = false;
+        Screen.Prototypes.SearchList.Visible = true;
+        Screen.Prototypes.Search(matches);
+    }
+
+    private void OnCollapseAll(ButtonEventArgs args)
+    {
+        foreach (var child in Screen.Prototypes.PrototypeList.Children)
+        {
+            if (child is not MappingSpawnButton button)
+                continue;
+
+            Collapse(button);
+        }
+
+        Screen.Prototypes.ScrollContainer.SetScrollValue(new Vector2(0, 0));
+    }
+
+    private void OnClearSearch(ButtonEventArgs obj)
+    {
+        Screen.Prototypes.SearchBar.Text = string.Empty;
+        OnSearch(new LineEditEventArgs(Screen.Prototypes.SearchBar, string.Empty));
+    }
+
+    private void OnGetData(IPrototype prototype, List<Texture> textures)
+    {
+        switch (prototype)
+        {
+            case EntityPrototype entity:
+                textures.AddRange(SpriteComponent.GetPrototypeTextures(entity, _resources).Select(t => t.Default));
+                break;
+            case DecalPrototype decal:
+                textures.Add(_sprite.Frame0(decal.Sprite));
+                break;
+            case ContentTileDefinition tile:
+                if (tile.Sprite?.ToString() is { } sprite)
+                    textures.Add(_resources.GetResource<TextureResource>(sprite).Texture);
+                break;
+        }
+    }
+
+    private void OnSelected(MappingPrototype mapping)
+    {
+        if (mapping.Prototype == null)
+            return;
+
+        var chain = new Stack<MappingPrototype>();
+        chain.Push(mapping);
+
+        var parent = mapping.Parents?.FirstOrDefault();
+        while (parent != null)
+        {
+            chain.Push(parent);
+            parent = parent.Parents?.FirstOrDefault();
+        }
+
+        _lastClicked = null;
+
+        Control? last = null;
+        var children = Screen.Prototypes.PrototypeList.Children;
+        foreach (var prototype in chain)
+        {
+            foreach (var child in children)
+            {
+                if (child is MappingSpawnButton button &&
+                    button.Prototype == prototype)
+                {
+                    UnCollapse(button);
+                    OnSelected(button, prototype.Prototype);
+                    children = button.ChildrenPrototypes.Children;
+                    last = child;
+                    break;
+                }
+            }
+        }
+
+        if (last != null && Screen.Prototypes.PrototypeList.Visible)
+            _scrollTo = last;
+    }
+
+    private void OnSelected(MappingSpawnButton button, IPrototype? prototype)
+    {
+        var time = _timing.CurTime;
+        if (prototype is DecalPrototype)
+            Screen.SelectDecal(prototype.ID);
+
+        // Double-click functionality if it's collapsible.
+        if (_lastClicked is { } lastClicked &&
+            lastClicked.Button == button &&
+            lastClicked.At > time - TimeSpan.FromSeconds(0.333) &&
+            string.IsNullOrEmpty(Screen.Prototypes.SearchBar.Text) &&
+            button.CollapseButton.Visible)
+        {
+            button.CollapseButton.Pressed = !button.CollapseButton.Pressed;
+            ToggleCollapse(button);
+            button.Button.Pressed = true;
+            Screen.Prototypes.Selected = button;
+            _lastClicked = null;
+            return;
+        }
+
+        // Toggle if it's the same button (at least if we just unclicked it).
+        if (!button.Button.Pressed && button.Prototype?.Prototype != null && _lastClicked?.Button == button)
+        {
+            _lastClicked = null;
+            Deselect();
+            return;
+        }
+
+        _lastClicked = (time, button);
+
+        if (button.Prototype == null)
+            return;
+
+        if (Screen.Prototypes.Selected is { } oldButton &&
+            oldButton != button)
+        {
+            Deselect();
+        }
+
+        Screen.EntityContainer.Visible = false;
+        Screen.DecalContainer.Visible = false;
+
+        switch (prototype)
+        {
+            case EntityPrototype entity:
+            {
+                var placementId = Screen.EntityPlacementMode.SelectedId;
+
+                var placement = new PlacementInformation
+                {
+                    PlacementOption = placementId > 0 ? EntitySpawnWindow.InitOpts[placementId] : entity.PlacementMode,
+                    EntityType = entity.ID,
+                    IsTile = false
+                };
+
+                Screen.EntityContainer.Visible = true;
+                _decal.SetActive(false);
+                _placement.BeginPlacing(placement);
+                break;
+            }
+            case DecalPrototype decal:
+                _placement.Clear();
+
+                _decal.SetActive(true);
+                _decal.UpdateDecalInfo(decal.ID, Color.White, 0, true, 0, false);
+                Screen.DecalContainer.Visible = true;
+                break;
+            case ContentTileDefinition tile:
+            {
+                var placement = new PlacementInformation
+                {
+                    PlacementOption = "AlignTileAny",
+                    TileType = tile.TileId,
+                    IsTile = true
+                };
+
+                _decal.SetActive(false);
+                _placement.BeginPlacing(placement);
+                break;
+            }
+            default:
+                _placement.Clear();
+                break;
+        }
+
+        Screen.Prototypes.Selected = button;
+
+        button.Button.Pressed = true;
+    }
+
+    private void Deselect()
+    {
+        if (Screen.Prototypes.Selected is { } selected)
+        {
+            selected.Button.Pressed = false;
+            Screen.Prototypes.Selected = null;
+
+            if (selected.Prototype?.Prototype is DecalPrototype)
+            {
+                _decal.SetActive(false);
+                Screen.DecalContainer.Visible = false;
+            }
+
+            if (selected.Prototype?.Prototype is EntityPrototype)
+            {
+                _placement.Clear();
+            }
+
+            if (selected.Prototype?.Prototype is ContentTileDefinition)
+            {
+                _placement.Clear();
+            }
+        }
+    }
+
+    private void OnCollapseToggled(MappingSpawnButton button, ButtonToggledEventArgs args)
+    {
+        ToggleCollapse(button);
+    }
+
+    private void OnPickPressed(ButtonEventArgs args)
+    {
+        if (args.Button.Pressed)
+            EnablePick();
+        else
+            DisablePick();
+    }
+
+    private void OnDeletePressed(ButtonEventArgs obj)
+    {
+        if (obj.Button.Pressed)
+            EnableDelete();
+        else
+            DisableDelete();
+    }
+
+    private void OnEntityReplacePressed(ButtonToggledEventArgs args)
+    {
+        _placement.Replacement = args.Pressed;
+    }
+
+    private void OnEntityPlacementSelected(ItemSelectedEventArgs args)
+    {
+        Screen.EntityPlacementMode.SelectId(args.Id);
+
+        if (_placement.CurrentMode != null)
+        {
+            var placement = new PlacementInformation
+            {
+                PlacementOption = EntitySpawnWindow.InitOpts[args.Id],
+                EntityType = _placement.CurrentPermission!.EntityType,
+                TileType = _placement.CurrentPermission.TileType,
+                Range = 2,
+                IsTile = _placement.CurrentPermission.IsTile,
+            };
+
+            _placement.BeginPlacing(placement);
+        }
+    }
+
+    private void OnEraseEntityPressed(ButtonEventArgs args)
+    {
+        if (args.Button.Pressed == _placement.Eraser)
+            return;
+
+        if (args.Button.Pressed)
+            EnableEraser();
+        else
+            DisableEraser();
+    }
+
+    private void OnEraseDecalPressed(ButtonToggledEventArgs args)
+    {
+        _placement.Clear();
+        Deselect();
+        Screen.EraseEntityButton.Pressed = false;
+        _updatePlacement = true;
+        _updateEraseDecal = args.Pressed;
+    }
+
+    private void EnableEraser()
+    {
+        if (_placement.Eraser)
+            return;
+
+        _placement.Clear();
+        _placement.ToggleEraser();
+        Screen.EntityPlacementMode.Disabled = true;
+        Screen.EraseDecalButton.Pressed = false;
+        Deselect();
+    }
+
+    private void DisableEraser()
+    {
+        if (!_placement.Eraser)
+            return;
+
+        _placement.ToggleEraser();
+        Screen.EntityPlacementMode.Disabled = false;
+    }
+
+    private void EnablePick()
+    {
+        Screen.UnPressActionsExcept(Screen.Pick);
+        State = CursorState.Pick;
+    }
+
+    private void DisablePick()
+    {
+        Screen.Pick.Pressed = false;
+        State = CursorState.None;
+    }
+
+    private void EnableDelete()
+    {
+        Screen.UnPressActionsExcept(Screen.Delete);
+        State = CursorState.Delete;
+        EnableEraser();
+    }
+
+    private void DisableDelete()
+    {
+        Screen.Delete.Pressed = false;
+        State = CursorState.None;
+        DisableEraser();
+    }
+
+    private bool HandleMappingUnselect(in PointerInputCmdArgs args)
+    {
+        if (Screen.Prototypes.Selected is not { Prototype.Prototype: DecalPrototype })
+            return false;
+
+        Deselect();
+        return true;
+    }
+
+    private bool HandleSaveMap(in PointerInputCmdArgs args)
+    {
+#if FULL_RELEASE
+        return false;
+#endif
+        if (!_admin.IsAdmin(true) || !_admin.HasFlag(AdminFlags.Host))
+            return false;
+
+        SaveMap();
+        return true;
+    }
+
+    private bool HandleEnablePick(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        EnablePick();
+        return true;
+    }
+
+    private bool HandleDisablePick(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        DisablePick();
+        return true;
+    }
+
+    private bool HandleEnableDelete(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        EnableDelete();
+        return true;
+    }
+
+    private bool HandleDisableDelete(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        DisableDelete();
+        return true;
+    }
+
+    private bool HandlePick(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        if (State != CursorState.Pick)
+            return false;
+
+        MappingPrototype? button = null;
+
+        // Try and get tile under it
+        // TODO: Separate mode for decals.
+        if (!uid.IsValid())
+        {
+            var mapPos = _transform.ToMapCoordinates(coords);
+
+            if (_mapMan.TryFindGridAt(mapPos, out var gridUid, out var grid) &&
+                _entityManager.System<SharedMapSystem>().TryGetTileRef(gridUid, grid, coords, out var tileRef) &&
+                _allPrototypesDict.TryGetValue(tileRef.GetContentTileDefinition(), out button))
+            {
+                OnSelected(button);
+                return true;
+            }
+        }
+
+        if (button == null)
+        {
+            if (uid == EntityUid.Invalid ||
+                _entityManager.GetComponentOrNull<MetaDataComponent>(uid) is not { EntityPrototype: { } prototype } ||
+                !_allPrototypesDict.TryGetValue(prototype, out button))
+            {
+                // we always block other input handlers if pick mode is enabled
+                // this makes you not accidentally place something in space because you
+                // miss-clicked while holding down the pick hotkey
+                return true;
+            }
+
+            // Selected an entity
+            OnSelected(button);
+
+            // Match rotation
+            _placement.Direction = _entityManager.GetComponent<TransformComponent>(uid).LocalRotation.GetDir();
+        }
+
+        return true;
+    }
+
+    private bool HandleEditorCancelPlace(ICommonSession? session, EntityCoordinates coords, EntityUid uid)
+    {
+        if (!Screen.EraseDecalButton.Pressed)
+            return false;
+
+        _entityNetwork.SendSystemNetworkMessage(new RequestDecalRemovalEvent(_entityManager.GetNetCoordinates(coords)));
+        return true;
+    }
+
+    private bool HandleCancelEraseDecal(in PointerInputCmdArgs args)
+    {
+        if (!Screen.EraseDecalButton.Pressed)
+            return false;
+
+        Screen.EraseDecalButton.Pressed = false;
+        return true;
+    }
+
+    private async void SaveMap()
+    {
+        await _mapping.SaveMap();
+    }
+
+    private void ToggleCollapse(MappingSpawnButton button)
+    {
+        if (button.CollapseButton.Pressed)
+        {
+            if (button.Prototype?.Children != null)
+            {
+                foreach (var child in button.Prototype.Children)
+                {
+                    Screen.Prototypes.Insert(button.ChildrenPrototypes, child, true);
+                }
+            }
+
+            button.CollapseButton.Label.Text = "▼";
+        }
+        else
+        {
+            button.ChildrenPrototypes.DisposeAllChildren();
+            button.CollapseButton.Label.Text = "▶";
+        }
+    }
+
+    private void Collapse(MappingSpawnButton button)
+    {
+        if (!button.CollapseButton.Pressed)
+            return;
+
+        button.CollapseButton.Pressed = false;
+        ToggleCollapse(button);
+    }
+
+
+    private void UnCollapse(MappingSpawnButton button)
+    {
+        if (button.CollapseButton.Pressed)
+            return;
+
+        button.CollapseButton.Pressed = true;
+        ToggleCollapse(button);
+    }
+
+    public EntityUid? GetHoveredEntity()
+    {
+        if (UserInterfaceManager.CurrentlyHovered is not IViewportControl viewport ||
+            _input.MouseScreenPosition is not { IsValid: true } position)
+        {
+            return null;
+        }
+
+        var mapPos = viewport.PixelToMap(position.Position);
+        return GetClickedEntity(mapPos);
+    }
+
+    public override void FrameUpdate(FrameEventArgs e)
+    {
+        if (_updatePlacement)
+        {
+            _updatePlacement = false;
+
+            if (!_placement.IsActive && _decal.GetActiveDecal().Decal == null)
+                Deselect();
+
+            Screen.EraseEntityButton.Pressed = _placement.Eraser;
+            Screen.EraseDecalButton.Pressed = _updateEraseDecal;
+            Screen.EntityPlacementMode.Disabled = _placement.Eraser;
+        }
+
+        if (_scrollTo is not { } scrollTo)
+            return;
+
+        // this is not ideal but we wait until the control's height is computed to use
+        // its position to scroll to
+        if (scrollTo.Height > 0 && Screen.Prototypes.PrototypeList.Visible)
+        {
+            var y = scrollTo.GlobalPosition.Y - Screen.Prototypes.ScrollContainer.Height / 2 + scrollTo.Height;
+            var scroll = Screen.Prototypes.ScrollContainer;
+            scroll.SetScrollValue(scroll.GetScrollValue() + new Vector2(0, y));
+            _scrollTo = null;
+        }
+    }
+
+
+    // TODO this doesn't handle pressing down multiple state hotkeys at the moment
+    public enum CursorState
+    {
+        None,
+        Pick,
+        Delete
+    }
+}
index 8daf193dfeb16872a2a4d2c68b29eac343e216d6..80189fbdfc1fbcb9a7ade502124c2e51edac27ea 100644 (file)
@@ -13,7 +13,6 @@ public sealed partial class MappingSystem : EntitySystem
 {
     [Dependency] private readonly IPlacementManager _placementMan = default!;
     [Dependency] private readonly ITileDefinitionManager _tileMan = default!;
-    [Dependency] private readonly ActionsSystem _actionsSystem = default!;
     [Dependency] private readonly MetaDataSystem _metaData = default!;
 
     /// <summary>
@@ -26,8 +25,6 @@ public sealed partial class MappingSystem : EntitySystem
     /// </summary>
     private readonly SpriteSpecifier _deleteIcon = new Texture(new ("Interface/VerbIcons/delete.svg.192dpi.png"));
 
-    public string DefaultMappingActions = "/mapping_actions.yml";
-
     public override void Initialize()
     {
         base.Initialize();
@@ -36,11 +33,6 @@ public sealed partial class MappingSystem : EntitySystem
         SubscribeLocalEvent<StartPlacementActionEvent>(OnStartPlacementAction);
     }
 
-    public void LoadMappingActions()
-    {
-        _actionsSystem.LoadActionAssignments(DefaultMappingActions, false);
-    }
-
     /// <summary>
     ///     This checks if the placement manager is currently active, and attempts to copy the placement information for
     ///     some entity or tile into an action. This is somewhat janky, but it seem to work well enough. Though I'd
index 69ac3ab0230f496aea0cb0df8bc9434f4097693f..4d2ac20d12058a59740a36c609e5953834c92f3b 100644 (file)
@@ -736,7 +736,7 @@ public sealed class ActionUIController : UIController, IOnStateChanged<GameplayS
 
     private void LoadGui()
     {
-        DebugTools.Assert(_window == null);
+        UnloadGui();
         _window = UIManager.CreateWindow<ActionsWindow>();
         LayoutContainer.SetAnchorPreset(_window, LayoutContainer.LayoutPreset.CenterTop);
 
index e9c3f90641f013a6e296c9ee943c3d912f2a90ed..1bbf44ac2c8c8946103e425448400f87464f9315 100644 (file)
@@ -3,6 +3,7 @@ using System.Numerics;
 using Content.Client.CombatMode;
 using Content.Client.ContextMenu.UI;
 using Content.Client.Gameplay;
+using Content.Client.Mapping;
 using Content.Shared.Input;
 using Content.Shared.Verbs;
 using Robust.Client.Player;
@@ -22,7 +23,9 @@ namespace Content.Client.Verbs.UI
     ///     open a verb menu for a given entity, add verbs to it, and add server-verbs when the server response is
     ///     received.
     /// </remarks>
-    public sealed class VerbMenuUIController : UIController, IOnStateEntered<GameplayState>, IOnStateExited<GameplayState>
+    public sealed class VerbMenuUIController : UIController,
+        IOnStateEntered<GameplayState>, IOnStateExited<GameplayState>,
+        IOnStateEntered<MappingState>, IOnStateExited<MappingState>
     {
         [Dependency] private readonly IPlayerManager _playerManager = default!;
         [Dependency] private readonly ContextMenuUIController _context = default!;
@@ -44,7 +47,6 @@ namespace Content.Client.Verbs.UI
         {
             _context.OnContextKeyEvent += OnKeyBindDown;
             _context.OnContextClosed += Close;
-            _verbSystem.OnVerbsResponse += HandleVerbsResponse;
         }
 
         public void OnStateExited(GameplayState state)
@@ -56,6 +58,17 @@ namespace Content.Client.Verbs.UI
             Close();
         }
 
+        public void OnStateEntered(MappingState state)
+        {
+            _verbSystem.OnVerbsResponse += HandleVerbsResponse;
+        }
+
+        public void OnStateExited(MappingState state)
+        {
+            if (_verbSystem != null)
+                _verbSystem.OnVerbsResponse -= HandleVerbsResponse;
+        }
+
         /// <summary>
         ///     Open a verb menu and fill it with verbs applicable to the given target entity.
         /// </summary>
diff --git a/Content.IntegrationTests/Tests/MappingEditorTest.cs b/Content.IntegrationTests/Tests/MappingEditorTest.cs
new file mode 100644 (file)
index 0000000..bd930ae
--- /dev/null
@@ -0,0 +1,41 @@
+using Content.Client.Gameplay;
+using Content.Client.Mapping;
+using Robust.Client.State;
+
+namespace Content.IntegrationTests.Tests;
+
+[TestFixture]
+public sealed class MappingEditorTest
+{
+    [Test]
+    public async Task StopHardCodingWidgetsJesusChristTest()
+    {
+        await using var pair = await PoolManager.GetServerClient(new PoolSettings
+        {
+            Connected = true
+        });
+        var client = pair.Client;
+        var state = client.ResolveDependency<IStateManager>();
+
+        await client.WaitPost(() =>
+        {
+            Assert.DoesNotThrow(() =>
+            {
+                state.RequestStateChange<MappingState>();
+            });
+        });
+
+        // arbitrary short time
+        await client.WaitRunTicks(30);
+
+        await client.WaitPost(() =>
+        {
+            Assert.DoesNotThrow(() =>
+            {
+                state.RequestStateChange<GameplayState>();
+            });
+        });
+
+        await pair.CleanReturnAsync();
+    }
+}
index 858ad2fe264f4e903df9f1a6f38f7a4f975b3019..62a37e5db29611d2da90ae5651453b38a2562a6b 100644 (file)
@@ -10,6 +10,7 @@ using Content.Server.Discord;
 using Content.Server.EUI;
 using Content.Server.GhostKick;
 using Content.Server.Info;
+using Content.Server.Mapping;
 using Content.Server.Maps;
 using Content.Server.MoMMI;
 using Content.Server.NodeContainer.NodeGroups;
@@ -66,6 +67,7 @@ namespace Content.Server.IoC
             IoCManager.Register<ServerApi>();
             IoCManager.Register<JobWhitelistManager>();
             IoCManager.Register<PlayerRateLimitManager>();
+            IoCManager.Register<MappingManager>();
         }
     }
 }
diff --git a/Content.Server/Mapping/MappingManager.cs b/Content.Server/Mapping/MappingManager.cs
new file mode 100644 (file)
index 0000000..e8c6eca
--- /dev/null
@@ -0,0 +1,76 @@
+using System.IO;
+using Content.Server.Administration.Managers;
+using Content.Shared.Administration;
+using Content.Shared.Mapping;
+using Robust.Server.GameObjects;
+using Robust.Server.Player;
+using Robust.Shared.Map;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+using YamlDotNet.Core;
+using YamlDotNet.RepresentationModel;
+
+namespace Content.Server.Mapping;
+
+public sealed class MappingManager : IPostInjectInit
+{
+    [Dependency] private readonly IAdminManager _admin = default!;
+    [Dependency] private readonly ILogManager _log = default!;
+    [Dependency] private readonly IMapManager _map = default!;
+    [Dependency] private readonly IServerNetManager _net = default!;
+    [Dependency] private readonly IPlayerManager _players = default!;
+    [Dependency] private readonly IEntitySystemManager _systems = default!;
+
+    private ISawmill _sawmill = default!;
+    private ZStdCompressionContext _zstd = default!;
+
+    public void PostInject()
+    {
+#if !FULL_RELEASE
+        _net.RegisterNetMessage<MappingSaveMapMessage>(OnMappingSaveMap);
+        _net.RegisterNetMessage<MappingSaveMapErrorMessage>();
+        _net.RegisterNetMessage<MappingMapDataMessage>();
+
+        _sawmill = _log.GetSawmill("mapping");
+        _zstd = new ZStdCompressionContext();
+#endif
+    }
+
+    private void OnMappingSaveMap(MappingSaveMapMessage message)
+    {
+#if !FULL_RELEASE
+        try
+        {
+            if (!_players.TryGetSessionByChannel(message.MsgChannel, out var session) ||
+                !_admin.IsAdmin(session, true) ||
+                !_admin.HasAdminFlag(session, AdminFlags.Host) ||
+                session.AttachedEntity is not { } player)
+            {
+                return;
+            }
+
+            var mapId = _systems.GetEntitySystem<TransformSystem>().GetMapCoordinates(player).MapId;
+            var mapEntity = _map.GetMapEntityIdOrThrow(mapId);
+            var data = _systems.GetEntitySystem<MapLoaderSystem>().GetSaveData(mapEntity);
+            var document = new YamlDocument(data.ToYaml());
+            var stream = new YamlStream { document };
+            var writer = new StringWriter();
+            stream.Save(new YamlMappingFix(new Emitter(writer)), false);
+
+            var msg = new MappingMapDataMessage()
+            {
+                Context = _zstd,
+                Yml = writer.ToString()
+            };
+            _net.ServerSendMessage(msg, message.MsgChannel);
+        }
+        catch (Exception e)
+        {
+            _sawmill.Error($"Error saving map in mapping mode:\n{e}");
+            var msg = new MappingSaveMapErrorMessage();
+            _net.ServerSendMessage(msg, message.MsgChannel);
+        }
+#endif
+    }
+}
index 2721f5d2d257a28c63d96aa672a2f944804fe1a4..543537f1bf7937e00b2b11327d1b95bc56e55c4d 100644 (file)
@@ -1,10 +1,11 @@
 using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Array;
 using Robust.Shared.Utility;
 
 namespace Content.Shared.Decals
 {
     [Prototype("decal")]
-    public sealed partial class DecalPrototype : IPrototype
+    public sealed partial class DecalPrototype : IPrototype, IInheritingPrototype
     {
         [IdDataField] public string ID { get; } = null!;
         [DataField("sprite")] public SpriteSpecifier Sprite { get; private set; } = SpriteSpecifier.Invalid;
@@ -33,5 +34,13 @@ namespace Content.Shared.Decals
         /// </summary>
         [DataField]
         public bool DefaultSnap = true;
+
+        [ParentDataField(typeof(AbstractPrototypeIdArraySerializer<DecalPrototype>))]
+        public string[]? Parents { get; }
+
+        [NeverPushInheritance]
+        [AbstractDataField]
+        public bool Abstract { get; }
+
     }
 }
index 2dd671816fd53e43532e5d83c155c3a76d587125..97b3f9ea04150f71b74f1cc62b59913cc81bd6b6 100644 (file)
@@ -104,5 +104,14 @@ namespace Content.Shared.Input
         public static readonly BoundKeyFunction EditorCopyObject = "EditorCopyObject";
         public static readonly BoundKeyFunction EditorFlipObject = "EditorFlipObject";
         public static readonly BoundKeyFunction InspectEntity = "InspectEntity";
+
+        public static readonly BoundKeyFunction MappingUnselect = "MappingUnselect";
+        public static readonly BoundKeyFunction SaveMap = "SaveMap";
+        public static readonly BoundKeyFunction MappingEnablePick = "MappingEnablePick";
+        public static readonly BoundKeyFunction MappingEnableDelete = "MappingEnableDelete";
+        public static readonly BoundKeyFunction MappingPick = "MappingPick";
+        public static readonly BoundKeyFunction MappingRemoveDecal = "MappingRemoveDecal";
+        public static readonly BoundKeyFunction MappingCancelEraseDecal = "MappingCancelEraseDecal";
+        public static readonly BoundKeyFunction MappingOpenContextMenu = "MappingOpenContextMenu";
     }
 }
diff --git a/Content.Shared/Mapping/MappingMapDataMessage.cs b/Content.Shared/Mapping/MappingMapDataMessage.cs
new file mode 100644 (file)
index 0000000..a3961a6
--- /dev/null
@@ -0,0 +1,46 @@
+using System.IO;
+using Lidgren.Network;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Mapping;
+
+public sealed class MappingMapDataMessage : NetMessage
+{
+    public override MsgGroups MsgGroup => MsgGroups.Command;
+    public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;
+
+    public ZStdCompressionContext Context = default!;
+    public string Yml = default!;
+
+    public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
+    {
+        MsgSize = buffer.LengthBytes;
+
+        var uncompressedLength = buffer.ReadVariableInt32();
+        var compressedLength = buffer.ReadVariableInt32();
+        var stream = new MemoryStream(compressedLength);
+        buffer.ReadAlignedMemory(stream, compressedLength);
+        using var decompress = new ZStdDecompressStream(stream);
+        using var decompressed = new MemoryStream(uncompressedLength);
+
+        decompress.CopyTo(decompressed, uncompressedLength);
+        decompressed.Position = 0;
+        serializer.DeserializeDirect(decompressed, out Yml);
+    }
+
+    public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
+    {
+        var stream = new MemoryStream();
+        serializer.SerializeDirect(stream, Yml);
+        buffer.WriteVariableInt32((int) stream.Length);
+
+        stream.Position = 0;
+        var buf = new byte[ZStd.CompressBound((int) stream.Length)];
+        var length = Context.Compress2(buf, stream.AsSpan());
+
+        buffer.WriteVariableInt32(length);
+        buffer.Write(buf.AsSpan(0, length));
+    }
+}
diff --git a/Content.Shared/Mapping/MappingSaveMapErrorMessage.cs b/Content.Shared/Mapping/MappingSaveMapErrorMessage.cs
new file mode 100644 (file)
index 0000000..e30e257
--- /dev/null
@@ -0,0 +1,19 @@
+using Lidgren.Network;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Mapping;
+
+public sealed class MappingSaveMapErrorMessage : NetMessage
+{
+    public override MsgGroups MsgGroup => MsgGroups.Command;
+    public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;
+
+    public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
+    {
+    }
+
+    public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
+    {
+    }
+}
diff --git a/Content.Shared/Mapping/MappingSaveMapMessage.cs b/Content.Shared/Mapping/MappingSaveMapMessage.cs
new file mode 100644 (file)
index 0000000..f80b866
--- /dev/null
@@ -0,0 +1,19 @@
+using Lidgren.Network;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Mapping;
+
+public sealed class MappingSaveMapMessage : NetMessage
+{
+    public override MsgGroups MsgGroup => MsgGroups.Command;
+    public override NetDeliveryMethod DeliveryMethod => NetDeliveryMethod.ReliableUnordered;
+
+    public override void ReadFromBuffer(NetIncomingMessage buffer, IRobustSerializer serializer)
+    {
+    }
+
+    public override void WriteToBuffer(NetOutgoingMessage buffer, IRobustSerializer serializer)
+    {
+    }
+}
diff --git a/Resources/Locale/en-US/mapping/editor.ftl b/Resources/Locale/en-US/mapping/editor.ftl
new file mode 100644 (file)
index 0000000..153df53
--- /dev/null
@@ -0,0 +1,7 @@
+mapping-entities = Entities
+mapping-tiles = Tiles
+mapping-decals = Decals
+
+mapping-replace = Replace
+mapping-erase-entity = Erase Entity
+mapping-erase-decal = Erase Decal
\ No newline at end of file
diff --git a/Resources/Textures/Interface/eraser.svg b/Resources/Textures/Interface/eraser.svg
new file mode 100644 (file)
index 0000000..e7060e5
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eraser" viewBox="0 0 16 16">
+  <path d="M8.086 2.207a2 2 0 0 1 2.828 0l3.879 3.879a2 2 0 0 1 0 2.828l-5.5 5.5A2 2 0 0 1 7.879 15H5.12a2 2 0 0 1-1.414-.586l-2.5-2.5a2 2 0 0 1 0-2.828l6.879-6.879zm2.121.707a1 1 0 0 0-1.414 0L4.16 7.547l5.293 5.293 4.633-4.633a1 1 0 0 0 0-1.414l-3.879-3.879zM8.746 13.547 3.453 8.254 1.914 9.793a1 1 0 0 0 0 1.414l2.5 2.5a1 1 0 0 0 .707.293H7.88a1 1 0 0 0 .707-.293l.16-.16z"/>
+</svg>
\ No newline at end of file
diff --git a/Resources/Textures/Interface/eraser.svg.png b/Resources/Textures/Interface/eraser.svg.png
new file mode 100644 (file)
index 0000000..de3ed8b
Binary files /dev/null and b/Resources/Textures/Interface/eraser.svg.png differ
diff --git a/Resources/Textures/Interface/eyedropper.svg b/Resources/Textures/Interface/eyedropper.svg
new file mode 100644 (file)
index 0000000..601caaf
--- /dev/null
@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eyedropper" viewBox="0 0 16 16">
+  <path d="M13.354.646a1.207 1.207 0 0 0-1.708 0L8.5 3.793l-.646-.647a.5.5 0 1 0-.708.708L8.293 5l-7.147 7.146A.5.5 0 0 0 1 12.5v1.793l-.854.853a.5.5 0 1 0 .708.707L1.707 15H3.5a.5.5 0 0 0 .354-.146L11 7.707l1.146 1.147a.5.5 0 0 0 .708-.708l-.647-.646 3.147-3.146a1.207 1.207 0 0 0 0-1.708l-2-2zM2 12.707l7-7L10.293 7l-7 7H2z"/>
+</svg>
\ No newline at end of file
diff --git a/Resources/Textures/Interface/eyedropper.svg.png b/Resources/Textures/Interface/eyedropper.svg.png
new file mode 100644 (file)
index 0000000..ce3e41a
Binary files /dev/null and b/Resources/Textures/Interface/eyedropper.svg.png differ
index 0b38a64922735540df6531df41a4c6d41bc63bcc..e9d63c2a904512d3a78abc311c0b44c823dc3b39 100644 (file)
@@ -540,3 +540,33 @@ binds:
 - function: Hotbar9
   type: State
   key: Num9
+- function: MappingUnselect
+  type: State
+  key: MouseRight
+  canFocus: true
+- function: SaveMap
+  type: State
+  key: S
+  mod1: Control
+- function: MappingEnablePick
+  type: State
+  key: Num5
+- function: MappingEnableDelete
+  type: State
+  key: Num6
+- function: MappingPick
+  type: State
+  key: MouseLeft
+  canFocus: true
+- function: MappingRemoveDecal
+  type: State
+  key: MouseLeft
+  canFocus: true
+- function: MappingCancelEraseDecal
+  type: State
+  key: MouseRight
+  canFocus: true
+- function: MappingOpenContextMenu
+  type: State
+  key: MouseRight
+  canFocus: true