]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Admin Tool: Observe entities in an extra viewport (#36969)
authorslarticodefast <161409025+slarticodefast@users.noreply.github.com>
Fri, 25 Jul 2025 16:53:01 +0000 (18:53 +0200)
committerGitHub <noreply@github.com>
Fri, 25 Jul 2025 16:53:01 +0000 (18:53 +0200)
* camera

* add console command

* change verb name to camera

* placeholder text

* add button to player panel

* orks are indeed the best

* visibility flag fix

* not a datafield

* more follower fixes

* more cleanup

* add zooming

* resizing real

* remove commented out code

* remove AddForceSend

* comment

* Use OS window and add some comments

* fix comments and variable name

* Needs RT update

* Minor grammarchange

* Fix warning

* Cleanup

* almost working...

* fix bug

* oswindow update

* Remove need for RequestClosed.

---------

Co-authored-by: beck-thompson <beck314159@hotmail.com>
Co-authored-by: PJB3005 <pieterjan.briers+git@gmail.com>
19 files changed:
Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml [new file with mode: 0644]
Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs [new file with mode: 0644]
Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs [new file with mode: 0644]
Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml [new file with mode: 0644]
Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs [new file with mode: 0644]
Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml
Content.Client/Administration/UI/PlayerPanel/PlayerPanel.xaml.cs
Content.Client/Administration/UI/PlayerPanel/PlayerPanelEui.cs
Content.Server/Administration/Commands/CameraCommand.cs [new file with mode: 0644]
Content.Server/Administration/Systems/AdminVerbSystem.cs
Content.Server/Administration/UI/AdminCameraEui.cs [new file with mode: 0644]
Content.Shared/Administration/AdminCameraEuiState.cs [new file with mode: 0644]
Content.Shared/Eye/VisibilityFlags.cs
Content.Shared/Follower/FollowerSystem.cs
Resources/Locale/en-US/administration/admin-verbs.ftl
Resources/Locale/en-US/administration/commands/camera.ftl [new file with mode: 0644]
Resources/Locale/en-US/administration/ui/admin-camera-window.ftl [new file with mode: 0644]
Resources/Locale/en-US/administration/ui/player-panel.ftl
Resources/Prototypes/Entities/Interface/admin_tools.yml [new file with mode: 0644]

diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml
new file mode 100644 (file)
index 0000000..1413eff
--- /dev/null
@@ -0,0 +1,20 @@
+<Control
+    xmlns="https://spacestation14.io"
+    xmlns:viewport="clr-namespace:Content.Client.Viewport"
+    MouseFilter="Stop">
+    <PanelContainer StyleClasses="BackgroundDark" Name="AdminCameraWindowRoot" Access="Public">
+        <BoxContainer Orientation="Vertical" Access="Public">
+            <!-- Camera -->
+            <Control VerticalExpand="True" Name="CameraViewBox">
+                <viewport:ScalingViewport Name="CameraView"
+                                          MinSize="100 100"
+                                          MouseFilter="Ignore" />
+            </Control>
+            <!-- Controller buttons -->
+            <BoxContainer Orientation="Horizontal" Margin="5 5 5 5">
+                <Button StyleClasses="OpenRight" Name="FollowButton" HorizontalExpand="True" Access="Public" Text="{Loc 'admin-camera-window-follow'}" />
+                <Button StyleClasses="OpenLeft" Name="PopControl" HorizontalExpand="True" Access="Public" Text="{Loc 'admin-camera-window-pop-out'}" />
+            </BoxContainer>
+        </BoxContainer>
+    </PanelContainer>
+</Control>
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraControl.xaml.cs
new file mode 100644 (file)
index 0000000..beb8344
--- /dev/null
@@ -0,0 +1,101 @@
+using System.Numerics;
+using Content.Client.Eye;
+using Content.Shared.Administration;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.Timing;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+[GenerateTypedNameReferences]
+public sealed partial class AdminCameraControl : Control
+{
+    [Dependency] private readonly IEntityManager _entManager = default!;
+    [Dependency] private readonly IClientGameTiming _timing = default!;
+
+    public event Action? OnFollow;
+    public event Action? OnPopoutControl;
+
+    private readonly EyeLerpingSystem _eyeLerpingSystem;
+    private readonly FixedEye _defaultEye = new();
+    private AdminCameraEuiState? _nextState;
+
+    private const float MinimumZoom = 0.1f;
+    private const float MaximumZoom = 2.0f;
+
+    public EntityUid? CurrentCamera;
+    public float Zoom = 1.0f;
+
+    public bool IsPoppedOut;
+
+    public AdminCameraControl()
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+
+        _eyeLerpingSystem = _entManager.System<EyeLerpingSystem>();
+
+        CameraView.Eye = _defaultEye;
+
+        FollowButton.OnPressed += _ => OnFollow?.Invoke();
+        PopControl.OnPressed += _ => OnPopoutControl?.Invoke();
+        CameraView.OnResized += OnResized;
+    }
+
+    private new void OnResized()
+    {
+        var width = Math.Max(CameraView.PixelWidth, (int)Math.Floor(CameraView.MinWidth));
+        var height = Math.Max(CameraView.PixelHeight, (int)Math.Floor(CameraView.MinHeight));
+
+        CameraView.ViewportSize = new Vector2i(width, height);
+    }
+
+    protected override void MouseWheel(GUIMouseWheelEventArgs args)
+    {
+        base.MouseWheel(args);
+
+        if (CameraView.Eye == null)
+            return;
+
+        Zoom = Math.Clamp(Zoom - args.Delta.Y * 0.15f * Zoom, MinimumZoom, MaximumZoom);
+        CameraView.Eye.Zoom = new Vector2(Zoom, Zoom);
+        args.Handle();
+    }
+
+    public void SetState(AdminCameraEuiState state)
+    {
+        _nextState = state;
+    }
+
+    // I know that this is awful, but I copied this from the solution editor anyways.
+    // This is needed because EUIs update before the gamestate is applied, which means it will fail to get the uid from the net entity.
+    // The suggestion from the comment in the solution editor saying to use a BUI is not ideal either:
+    // - We would need to bind the UI to an entity, but with how BUIs currently work we cannot open it in the same tick as we spawn that entity on the server.
+    // - We want the UI opened by the user session, not by their currently attached entity. Otherwise it would close in cases where admins move from one entity to another, for example when ghosting.
+    protected override void FrameUpdate(FrameEventArgs args)
+    {
+        if (_nextState == null || _timing.LastRealTick < _nextState.Tick) // make sure the last gamestate has been applied
+            return;
+
+        if (!_entManager.TryGetEntity(_nextState.Camera, out var cameraUid))
+            return;
+
+        if (CurrentCamera == null)
+        {
+            _eyeLerpingSystem.AddEye(cameraUid.Value);
+            CurrentCamera = cameraUid;
+        }
+        else if (CurrentCamera != cameraUid)
+        {
+            _eyeLerpingSystem.RemoveEye(CurrentCamera.Value);
+            _eyeLerpingSystem.AddEye(cameraUid.Value);
+            CurrentCamera = cameraUid;
+        }
+
+        if (_entManager.TryGetComponent<EyeComponent>(CurrentCamera, out var eye))
+            CameraView.Eye = eye.Eye ?? _defaultEye;
+    }
+}
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraEui.cs
new file mode 100644 (file)
index 0000000..908fb27
--- /dev/null
@@ -0,0 +1,117 @@
+using System.Numerics;
+using Content.Client.Eui;
+using Content.Shared.Administration;
+using Content.Shared.Eui;
+using JetBrains.Annotations;
+using Robust.Client.UserInterface.Controls;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+/// <summary>
+/// Admin Eui for opening a viewport window to observe entities.
+/// Use the "Open Camera" admin verb or the "camera" command to open.
+/// </summary>
+[UsedImplicitly]
+public sealed partial class AdminCameraEui : BaseEui
+{
+    private readonly AdminCameraWindow _window;
+    private readonly AdminCameraControl _control;
+
+    // If not null the camera is in "popped out" mode and is in an external window.
+    private OSWindow? _OSWindow;
+
+    // The last location the window was located at in game.
+    // Is used for getting knowing where to "pop in" external windows.
+    private Vector2 _lastLocation;
+
+    public AdminCameraEui()
+    {
+        _window = new AdminCameraWindow();
+        _control = new AdminCameraControl();
+
+        _window.Contents.AddChild(_control);
+
+        _control.OnFollow += () => SendMessage(new AdminCameraFollowMessage());
+        _window.OnClose += () =>
+        {
+            if (!_control.IsPoppedOut)
+                SendMessage(new CloseEuiMessage());
+        };
+
+        _control.OnPopoutControl += () =>
+        {
+            if (_control.IsPoppedOut)
+                PopIn();
+            else
+                PopOut();
+        };
+    }
+
+    // Pop the window out into an external OS window
+    private void PopOut()
+    {
+        _lastLocation = _window.Position;
+
+        // TODO: When there is a way to have a minimum window size, enforce something!
+        _OSWindow = new OSWindow
+        {
+            SetSize = _window.Size,
+            Title = _window.Title ?? Loc.GetString("admin-camera-window-title-placeholder"),
+        };
+
+        _OSWindow.Show();
+
+        if (_OSWindow.Root == null)
+            return;
+
+        _control.Orphan();
+        _OSWindow.Root.AddChild(_control);
+
+        _OSWindow.Closed += () =>
+        {
+            if (_control.IsPoppedOut)
+                SendMessage(new CloseEuiMessage());
+        };
+
+        _control.IsPoppedOut = true;
+        _control.PopControl.Text = Loc.GetString("admin-camera-window-pop-in");
+
+        _window.Close();
+    }
+
+    // Pop the window back into the in game window.
+    private void PopIn()
+    {
+        _control.Orphan();
+        _window.Contents.AddChild(_control);
+
+        _window.Open(_lastLocation);
+
+        _control.IsPoppedOut = false;
+        _control.PopControl.Text = Loc.GetString("admin-camera-window-pop-out");
+
+        _OSWindow?.Close();
+        _OSWindow = null;
+    }
+
+    public override void Opened()
+    {
+        base.Opened();
+        _window.OpenCentered();
+    }
+
+    public override void Closed()
+    {
+        base.Closed();
+        _window.Close();
+    }
+
+    public override void HandleState(EuiStateBase baseState)
+    {
+        if (baseState is not AdminCameraEuiState state)
+            return;
+
+        _window.SetState(state);
+        _control.SetState(state);
+    }
+}
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml
new file mode 100644 (file)
index 0000000..87583ce
--- /dev/null
@@ -0,0 +1,6 @@
+<DefaultWindow xmlns="https://spacestation14.io"
+    Title="{Loc admin-camera-window-title-placeholder}"
+    SetSize="425 550"
+    MinSize="200 225"
+    Name="Window">
+</DefaultWindow>
diff --git a/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs b/Content.Client/Administration/UI/AdminCamera/AdminCameraWindow.xaml.cs
new file mode 100644 (file)
index 0000000..07a6e21
--- /dev/null
@@ -0,0 +1,23 @@
+using Content.Shared.Administration;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.Administration.UI.AdminCamera;
+
+[GenerateTypedNameReferences]
+public sealed partial class AdminCameraWindow : DefaultWindow
+{
+    public AdminCameraWindow()
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+
+        ContentsContainer.Margin = new Thickness(5, 0, 5, 0);
+    }
+
+    public void SetState(AdminCameraEuiState state)
+    {
+        Title = Loc.GetString("admin-camera-window-title", ("name", state.Name));
+    }
+}
index 4791dcf6aaefe0410907ca949e552f19c7ba31ff..9c54049da36f24ad29666326077299fc80b8eb96 100644 (file)
@@ -31,6 +31,7 @@
             <Button Name="FreezeAndMuteToggleButton" Text="{Loc player-panel-freeze-and-mute}" Disabled="True"/>
             <Button Name="BanButton" Text="{Loc player-panel-ban}" Disabled="True"/>
             <controls:ConfirmButton Name="RejuvenateButton" Text="{Loc player-panel-rejuvenate}" Disabled="True"/>
+            <Button Name="CameraButton" Text="{Loc player-panel-camera}" Disabled="True"/>
            </GridContainer>
        </BoxContainer>
     </BoxContainer>
index 14311851b450545f59d25568aa872952e18e476f..62cb64050eacb28a1fb746a59f3c7ef4c62a0177 100644 (file)
@@ -18,6 +18,7 @@ public sealed partial class PlayerPanel : FancyWindow
     public event Action<NetUserId?>? OnOpenBans;
     public event Action<NetUserId?>? OnAhelp;
     public event Action<string?>? OnKick;
+    public event Action<string?>? OnCamera;
     public event Action<NetUserId?>? OnOpenBanPanel;
     public event Action<NetUserId?, bool>? OnWhitelistToggle;
     public event Action? OnFollow;
@@ -33,26 +34,27 @@ public sealed partial class PlayerPanel : FancyWindow
 
     public PlayerPanel(IClientAdminManager adminManager)
     {
-            RobustXamlLoader.Load(this);
-            _adminManager = adminManager;
-
-            UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
-            BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
-            KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
-            NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
-            ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
-            AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
-            WhitelistToggle.OnPressed += _ =>
-            {
-                OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
-                SetWhitelisted(!_isWhitelisted);
-            };
-            FollowButton.OnPressed += _ => OnFollow?.Invoke();
-            FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
-            FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
-            LogsButton.OnPressed += _ => OnLogs?.Invoke();
-            DeleteButton.OnPressed += _ => OnDelete?.Invoke();
-            RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
+        RobustXamlLoader.Load(this);
+        _adminManager = adminManager;
+
+        UsernameCopyButton.OnPressed += _ => OnUsernameCopy?.Invoke(TargetUsername ?? "");
+        BanButton.OnPressed += _ => OnOpenBanPanel?.Invoke(TargetPlayer);
+        KickButton.OnPressed += _ => OnKick?.Invoke(TargetUsername);
+        CameraButton.OnPressed += _ => OnCamera?.Invoke(TargetUsername);
+        NotesButton.OnPressed += _ => OnOpenNotes?.Invoke(TargetPlayer);
+        ShowBansButton.OnPressed += _ => OnOpenBans?.Invoke(TargetPlayer);
+        AhelpButton.OnPressed += _ => OnAhelp?.Invoke(TargetPlayer);
+        WhitelistToggle.OnPressed += _ =>
+        {
+            OnWhitelistToggle?.Invoke(TargetPlayer, _isWhitelisted);
+            SetWhitelisted(!_isWhitelisted);
+        };
+        FollowButton.OnPressed += _ => OnFollow?.Invoke();
+        FreezeButton.OnPressed += _ => OnFreeze?.Invoke();
+        FreezeAndMuteToggleButton.OnPressed += _ => OnFreezeAndMuteToggle?.Invoke();
+        LogsButton.OnPressed += _ => OnLogs?.Invoke();
+        DeleteButton.OnPressed += _ => OnDelete?.Invoke();
+        RejuvenateButton.OnPressed += _ => OnRejuvenate?.Invoke();
     }
 
     public void SetUsername(string player)
@@ -122,6 +124,7 @@ public sealed partial class PlayerPanel : FancyWindow
     {
         BanButton.Disabled = !_adminManager.CanCommand("banpanel");
         KickButton.Disabled = !_adminManager.CanCommand("kick");
+        CameraButton.Disabled = !_adminManager.CanCommand("camera");
         NotesButton.Disabled = !_adminManager.CanCommand("adminnotes");
         ShowBansButton.Disabled = !_adminManager.CanCommand("banlist");
         WhitelistToggle.Disabled =
index 2129fa5b0c19da749899816f0098d20f793e20a6..8c8183ef22abc82fd5eb9c370ed76c4f9642a7e2 100644 (file)
@@ -15,7 +15,7 @@ public sealed class PlayerPanelEui : BaseEui
     [Dependency] private readonly IClientAdminManager _admin = default!;
     [Dependency] private readonly IClipboardManager _clipboard = default!;
 
-    private PlayerPanel PlayerPanel { get;  }
+    private PlayerPanel PlayerPanel { get; }
 
     public PlayerPanelEui()
     {
@@ -25,6 +25,7 @@ public sealed class PlayerPanelEui : BaseEui
         PlayerPanel.OnOpenNotes += id => _console.ExecuteCommand($"adminnotes \"{id}\"");
         // Kick command does not support GUIDs
         PlayerPanel.OnKick += username => _console.ExecuteCommand($"kick \"{username}\"");
+        PlayerPanel.OnCamera += username => _console.ExecuteCommand($"camera \"{username}\"");
         PlayerPanel.OnOpenBanPanel += id => _console.ExecuteCommand($"banpanel \"{id}\"");
         PlayerPanel.OnOpenBans += id => _console.ExecuteCommand($"banlist \"{id}\"");
         PlayerPanel.OnAhelp += id => _console.ExecuteCommand($"openahelp \"{id}\"");
@@ -37,7 +38,7 @@ public sealed class PlayerPanelEui : BaseEui
         PlayerPanel.OnFreeze += () => SendMessage(new PlayerPanelFreezeMessage());
         PlayerPanel.OnLogs += () => SendMessage(new PlayerPanelLogsMessage());
         PlayerPanel.OnRejuvenate += () => SendMessage(new PlayerPanelRejuvenationMessage());
-        PlayerPanel.OnDelete+= () => SendMessage(new PlayerPanelDeleteMessage());
+        PlayerPanel.OnDelete += () => SendMessage(new PlayerPanelDeleteMessage());
         PlayerPanel.OnFollow += () => SendMessage(new PlayerPanelFollowMessage());
 
         PlayerPanel.OnClose += () => SendMessage(new CloseEuiMessage());
diff --git a/Content.Server/Administration/Commands/CameraCommand.cs b/Content.Server/Administration/Commands/CameraCommand.cs
new file mode 100644 (file)
index 0000000..25837ac
--- /dev/null
@@ -0,0 +1,58 @@
+using Content.Server.Administration.UI;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Robust.Server.Player;
+using Robust.Shared.Console;
+
+namespace Content.Server.Administration.Commands;
+
+[AdminCommand(AdminFlags.Admin)]
+public sealed class CameraCommand : LocalizedCommands
+{
+    [Dependency] private readonly EuiManager _eui = default!;
+    [Dependency] private readonly IEntityManager _entManager = default!;
+    [Dependency] private readonly IPlayerManager _playerManager = default!;
+
+    public override string Command => "camera";
+
+    public override void Execute(IConsoleShell shell, string argStr, string[] args)
+    {
+        if (shell.Player is not { } user)
+        {
+            shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+            return;
+        }
+
+        if (args.Length != 1)
+        {
+            shell.WriteError(Loc.GetString("shell-wrong-arguments-number"));
+            return;
+        }
+
+        if (!NetEntity.TryParse(args[0], out var targetNetId) || !_entManager.TryGetEntity(targetNetId, out var targetUid))
+        {
+            if (!_playerManager.TryGetSessionByUsername(args[0], out var player)
+                || player.AttachedEntity == null)
+            {
+                shell.WriteError(Loc.GetString("cmd-camera-wrong-argument"));
+                return;
+            }
+            targetUid = player.AttachedEntity.Value;
+        }
+
+        var ui = new AdminCameraEui(targetUid.Value);
+        _eui.OpenEui(ui, user);
+    }
+
+    public override CompletionResult GetCompletion(IConsoleShell shell, string[] args)
+    {
+        if (args.Length == 1)
+        {
+            return CompletionResult.FromHintOptions(
+                CompletionHelper.SessionNames(players: _playerManager),
+                Loc.GetString("cmd-camera-hint"));
+        }
+
+        return CompletionResult.Empty;
+    }
+}
index 19bcfd26f699722bb81a9df260d748da96203983..61e3013bd98daa3b2876f0c8611ea3f2e2af985f 100644 (file)
@@ -388,6 +388,22 @@ namespace Content.Server.Administration.Systems
                         Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Interface/Actions/actions_borg.rsi"), "state-laws"),
                     });
                 }
+
+                // open camera
+                args.Verbs.Add(new Verb()
+                {
+                    Priority = 10,
+                    Text = Loc.GetString("admin-verbs-camera"),
+                    Message = Loc.GetString("admin-verbs-camera-description"),
+                    Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")),
+                    Category = VerbCategory.Admin,
+                    Act = () =>
+                    {
+                        var ui = new AdminCameraEui(args.Target);
+                        _euiManager.OpenEui(ui, player);
+                    },
+                    Impact = LogImpact.Low
+                });
             }
         }
 
diff --git a/Content.Server/Administration/UI/AdminCameraEui.cs b/Content.Server/Administration/UI/AdminCameraEui.cs
new file mode 100644 (file)
index 0000000..5230933
--- /dev/null
@@ -0,0 +1,97 @@
+using Content.Server.Administration.Managers;
+using Content.Server.EUI;
+using Content.Shared.Administration;
+using Content.Shared.Eui;
+using Content.Shared.Follower;
+using Content.Shared.Coordinates;
+using Robust.Server.GameStates;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using JetBrains.Annotations;
+
+namespace Content.Server.Administration.UI;
+
+/// <summary>
+/// Admin Eui for opening a viewport window to observe entities.
+/// Use the "Open Camera" admin verb or the "camera" command to open.
+/// </summary>
+[UsedImplicitly]
+public sealed partial class AdminCameraEui : BaseEui
+{
+    [Dependency] private readonly IAdminManager _admin = default!;
+    [Dependency] private readonly IEntityManager _entityManager = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+
+    private readonly FollowerSystem _follower = default!;
+    private readonly PvsOverrideSystem _pvs = default!;
+    private readonly SharedViewSubscriberSystem _viewSubscriber = default!;
+
+    private static readonly EntProtoId CameraProtoId = "AdminCamera";
+
+    private readonly EntityUid _target;
+    private EntityUid? _camera;
+
+
+    public AdminCameraEui(EntityUid target)
+    {
+        IoCManager.InjectDependencies(this);
+        _follower = _entityManager.System<FollowerSystem>();
+        _pvs = _entityManager.System<PvsOverrideSystem>();
+        _viewSubscriber = _entityManager.System<SharedViewSubscriberSystem>();
+
+        _target = target;
+    }
+
+    public override void Opened()
+    {
+        base.Opened();
+
+        _camera = CreateCamera(_target, Player);
+        StateDirty();
+    }
+
+    public override void Closed()
+    {
+        base.Closed();
+
+        _entityManager.DeleteEntity(_camera);
+    }
+
+    public override void HandleMessage(EuiMessageBase msg)
+    {
+        base.HandleMessage(msg);
+
+        switch (msg)
+        {
+            case AdminCameraFollowMessage:
+                if (!_admin.HasAdminFlag(Player, AdminFlags.Admin) || Player.AttachedEntity == null)
+                    return;
+                _follower.StartFollowingEntity(Player.AttachedEntity.Value, _target);
+                break;
+            default:
+                break;
+        }
+    }
+
+    public override EuiStateBase GetNewState()
+    {
+        var name = _entityManager.GetComponent<MetaDataComponent>(_target).EntityName;
+        var netEnt = _entityManager.GetNetEntity(_camera);
+        return new AdminCameraEuiState(netEnt, name, _timing.CurTick);
+    }
+
+    private EntityUid CreateCamera(EntityUid target, ICommonSession observer)
+    {
+        // Spawn a camera entity attached to the target.
+        var coords = target.ToCoordinates();
+        var camera = _entityManager.SpawnAttachedTo(CameraProtoId, coords);
+
+        // Allow the user to see the entities near the camera.
+        // This also force sends the camera entity to the user, overriding the visibility flags.
+        // (The camera entity has its visibility flags set to VisibilityFlags.Admin so that cheat clients can't see it)
+        _viewSubscriber.AddViewSubscriber(camera, observer);
+
+        return camera;
+    }
+}
diff --git a/Content.Shared/Administration/AdminCameraEuiState.cs b/Content.Shared/Administration/AdminCameraEuiState.cs
new file mode 100644 (file)
index 0000000..ae41f3a
--- /dev/null
@@ -0,0 +1,27 @@
+using Content.Shared.Eui;
+using Robust.Shared.Serialization;
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Administration;
+
+[Serializable, NetSerializable]
+public sealed partial class AdminCameraEuiState(NetEntity? camera, string name, GameTick tick) : EuiStateBase
+{
+    /// <summary>
+    /// The camera entity we will use for the window.
+    /// </summary>
+    public readonly NetEntity? Camera = camera;
+
+    /// <summary>
+    /// The name of the observed entity.
+    /// </summary>
+    public readonly string Name = name;
+
+    /// <summary>
+    /// The current tick time, needed for cursed reasons.
+    /// </summary>
+    public readonly GameTick Tick = tick;
+}
+
+[Serializable, NetSerializable]
+public sealed partial class AdminCameraFollowMessage : EuiMessageBase;
index 432e80dd58ab5b83b7768ef582d1df4218a84ca1..6cf8b18fcaa68f16e88667644732de2db133280c 100644 (file)
@@ -6,9 +6,10 @@ namespace Content.Shared.Eye
     [FlagsFor(typeof(VisibilityMaskLayer))]
     public enum VisibilityFlags : int
     {
-        None   = 0,
+        None = 0,
         Normal = 1 << 0,
-        Ghost  = 1 << 1,
-        Subfloor = 1 << 2,
+        Ghost = 1 << 1, // Observers and revenants.
+        Subfloor = 1 << 2, // Pipes, disposal chutes, cables etc. while hidden under tiles. Can be revealed with a t-ray.
+        Admin = 1 << 3, // Reserved for admins in stealth mode and admin tools.
     }
 }
index d8e34fb3ee9fd8fb19576cc8b64b203ced93fccc..b9c2f4bece05ad7ad965c0fa21f2f3df4ec8be08 100644 (file)
@@ -189,6 +189,9 @@ public sealed class FollowerSystem : EntitySystem
     /// <param name="entity">The entity to be followed</param>
     public void StartFollowingEntity(EntityUid follower, EntityUid entity)
     {
+        if (follower == entity || TerminatingOrDeleted(entity))
+            return;
+
         // No recursion for you
         var targetXform = Transform(entity);
         while (targetXform.ParentUid.IsValid())
index a89397bd65d5c43fee2b8919eeaf48d2a6a816e5..13be0a876da225782033a2a768350e62919e8794 100644 (file)
@@ -8,6 +8,8 @@ admin-verbs-teleport-here = Teleport Here
 admin-verbs-freeze = Freeze
 admin-verbs-freeze-and-mute = Freeze And Mute
 admin-verbs-unfreeze = Unfreeze
+admin-verbs-camera = Open Camera
+admin-verbs-camera-description = Open a camera window that follows the selected entity.
 admin-verbs-erase = Erase
 admin-verbs-erase-description = Removes the player from the round and crew manifest and deletes their chat messages.
     Their items are dropped on the ground.
diff --git a/Resources/Locale/en-US/administration/commands/camera.ftl b/Resources/Locale/en-US/administration/commands/camera.ftl
new file mode 100644 (file)
index 0000000..9883968
--- /dev/null
@@ -0,0 +1,5 @@
+cmd-camera-desc = Opens a remote camera window for an entity.
+cmd-camera-help = Usage: camera <entityUid or player>
+
+cmd-camera-hint = <entityUid or player>
+cmd-camera-wrong-argument = Argument must be a valid netUid or a player name.
diff --git a/Resources/Locale/en-US/administration/ui/admin-camera-window.ftl b/Resources/Locale/en-US/administration/ui/admin-camera-window.ftl
new file mode 100644 (file)
index 0000000..bf9f45a
--- /dev/null
@@ -0,0 +1,5 @@
+admin-camera-window-title = Observing { $name }
+admin-camera-window-title-placeholder = Observing
+admin-camera-window-follow = Follow
+admin-camera-window-pop-out = Pop out
+admin-camera-window-pop-in = Pop in
index f2d89fe2c2edd859ccca2bf0990b24104341e13b..7b3571e380e74be09e1fcec5748caecf7874acdc 100644 (file)
@@ -22,3 +22,4 @@ player-panel-rejuvenate = Rejuvenate
 player-panel-false = False
 player-panel-true = True
 player-panel-follow = Follow
+player-panel-camera = Camera
diff --git a/Resources/Prototypes/Entities/Interface/admin_tools.yml b/Resources/Prototypes/Entities/Interface/admin_tools.yml
new file mode 100644 (file)
index 0000000..a8c978e
--- /dev/null
@@ -0,0 +1,14 @@
+# dummy entity for the admin camera EUI
+# this gets parented to the person being observed
+
+- type: entity
+  id: AdminCamera
+  categories: [ HideSpawnMenu ]
+  name: admin camera
+  description: We are watching you.
+  components:
+  - type: Visibility
+    layer: 8 # Don't network this to anyone so cheat clients can't see it. We are adding a PVS override to the user.
+  - type: Eye # for the camera itself
+    drawFov: false
+    pvsScale: 0.8 # we don't need the full range