]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Sentry turrets - Part 6: Sentry turret control panels (#35235)
authorchromiumboy <50505512+chromiumboy@users.noreply.github.com>
Mon, 26 May 2025 13:00:50 +0000 (08:00 -0500)
committerGitHub <noreply@github.com>
Mon, 26 May 2025 13:00:50 +0000 (16:00 +0300)
18 files changed:
Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml [new file with mode: 0644]
Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml.cs [new file with mode: 0644]
Content.Client/TurretController/DeployableTurretControllerSystem.cs [new file with mode: 0644]
Content.Client/TurretController/TurretControllerWindow.xaml [new file with mode: 0644]
Content.Client/TurretController/TurretControllerWindow.xaml.cs [new file with mode: 0644]
Content.Client/TurretController/TurretControllerWindowBoundUserInterface.cs [new file with mode: 0644]
Content.Client/Turrets/DeployableTurretSystem.cs
Content.Client/UserInterface/Controls/MonotoneButton.cs
Content.Server/TurretController/DeployableTurretControllerSystem.cs [new file with mode: 0644]
Content.Server/Turrets/DeployableTurretSystem.cs
Content.Shared/Access/AccessGroupPrototype.cs
Content.Shared/TurretController/DeployableTurretControllerComponent.cs [new file with mode: 0644]
Content.Shared/TurretController/SharedDeployableTurretControllerSystem.cs [new file with mode: 0644]
Content.Shared/Turrets/TurretTargetSettingsSystem.cs
Resources/Locale/en-US/ui/turret-controls.ftl [new file with mode: 0644]
Resources/Prototypes/Access/misc.yml
Resources/Prototypes/Entities/Objects/Weapons/Guns/Turrets/turrets_energy.yml
Resources/Prototypes/Entities/Structures/Wallmounts/turret_controls.yml [new file with mode: 0644]

diff --git a/Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml b/Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml
new file mode 100644 (file)
index 0000000..84d5814
--- /dev/null
@@ -0,0 +1,26 @@
+<BoxContainer xmlns="https://spacestation14.io"
+              xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+              Orientation="Horizontal"
+              Margin="10 10 10 10"
+              VerticalExpand="True"
+              HorizontalExpand="True"
+              MinHeight="70">
+
+    <!-- Access groups -->
+    <BoxContainer Name="AccessGroupList" Access="Public" Orientation="Vertical" HorizontalExpand="True" SizeFlagsStretchRatio="0.5" Margin="0 0 10 0">
+        <!-- Populated with C# code -->
+    </BoxContainer>
+
+    <PanelContainer StyleClasses="LowDivider" VerticalExpand="True" Margin="0 0 0 0" SetWidth="2">
+        <PanelContainer.PanelOverride>
+            <gfx:StyleBoxFlat BackgroundColor="#FFFFFF" />
+        </PanelContainer.PanelOverride>
+    </PanelContainer>
+
+    <!-- Access levels -->
+    <ScrollContainer HorizontalExpand="True" VerticalExpand="True" Margin="10 0 0 0">
+        <BoxContainer Name="AccessLevelChecklist" Access="Public" Orientation="Vertical" HorizontalAlignment="Left">
+            <!-- Populated with C# code -->
+        </BoxContainer>
+    </ScrollContainer>
+</BoxContainer>
diff --git a/Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml.cs b/Content.Client/Access/UI/GroupedAccessLevelChecklist.xaml.cs
new file mode 100644 (file)
index 0000000..da68653
--- /dev/null
@@ -0,0 +1,449 @@
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Access;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using System.Linq;
+using System.Numerics;
+
+namespace Content.Client.Access.UI;
+
+[GenerateTypedNameReferences]
+public sealed partial class GroupedAccessLevelChecklist : BoxContainer
+{
+    [Dependency] private readonly IPrototypeManager _protoManager = default!;
+
+    private bool _isMonotone;
+    private string? _labelStyleClass;
+
+    // Access data
+    private HashSet<ProtoId<AccessGroupPrototype>> _accessGroups = new();
+    private HashSet<ProtoId<AccessLevelPrototype>> _accessLevels = new();
+    private HashSet<ProtoId<AccessLevelPrototype>> _activeAccessLevels = new();
+
+    // Button groups
+    private readonly ButtonGroup _accessGroupsButtons = new();
+
+    // Temp values
+    private int _accessGroupTabIndex = 0;
+    private bool _canInteract = false;
+    private List<AccessLevelPrototype> _accessLevelsForTab = new();
+    private readonly List<AccessLevelEntry> _accessLevelEntries = new();
+    private readonly Dictionary<AccessGroupPrototype, List<AccessLevelPrototype>> _groupedAccessLevels = new();
+
+    // Events
+    public event Action<HashSet<ProtoId<AccessLevelPrototype>>, bool>? OnAccessLevelsChangedEvent;
+
+    /// <summary>
+    /// Creates a UI control for changing access levels.
+    /// Access levels are organized under a list of tabs by their associated access group.
+    /// </summary>
+    public GroupedAccessLevelChecklist()
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+    }
+
+    private void ArrangeAccessControls()
+    {
+        // Create a list of known access groups with which to populate the UI
+        _groupedAccessLevels.Clear();
+
+        foreach (var accessGroup in _accessGroups)
+        {
+            if (!_protoManager.TryIndex(accessGroup, out var accessGroupProto))
+                continue;
+
+            _groupedAccessLevels.Add(accessGroupProto, new());
+        }
+
+        // Ensure that the 'general' access group is added to handle
+        // misc. access levels that aren't associated with any group
+        if (_protoManager.TryIndex<AccessGroupPrototype>("General", out var generalAccessProto))
+            _groupedAccessLevels.TryAdd(generalAccessProto, new());
+
+        // Assign known access levels with their associated groups
+        foreach (var accessLevel in _accessLevels)
+        {
+            if (!_protoManager.TryIndex(accessLevel, out var accessLevelProto))
+                continue;
+
+            var assigned = false;
+
+            foreach (var (accessGroup, accessLevels) in _groupedAccessLevels)
+            {
+                if (!accessGroup.Tags.Contains(accessLevelProto.ID))
+                    continue;
+
+                assigned = true;
+                _groupedAccessLevels[accessGroup].Add(accessLevelProto);
+            }
+
+            if (!assigned && generalAccessProto != null)
+                _groupedAccessLevels[generalAccessProto].Add(accessLevelProto);
+        }
+
+        // Remove access groups that have no assigned access levels
+        foreach (var (group, accessLevels) in _groupedAccessLevels)
+        {
+            if (accessLevels.Count == 0)
+                _groupedAccessLevels.Remove(group);
+        }
+    }
+
+    private bool TryRebuildAccessGroupControls()
+    {
+        AccessGroupList.DisposeAllChildren();
+        AccessLevelChecklist.DisposeAllChildren();
+
+        // No access level prototypes were assigned to any of the access level groups.
+        // Either the turret controller has no assigned access levels or their names were invalid.
+        if (_groupedAccessLevels.Count == 0)
+            return false;
+
+        // Reorder the access groups alphabetically
+        var orderedAccessGroups = _groupedAccessLevels.Keys.OrderBy(x => x.GetAccessGroupName()).ToList();
+
+        // Add group access buttons to the UI
+        foreach (var accessGroup in orderedAccessGroups)
+        {
+            var accessGroupButton = CreateAccessGroupButton();
+
+            // Button styling
+            if (_groupedAccessLevels.Count > 1)
+            {
+                if (AccessGroupList.ChildCount == 0)
+                    accessGroupButton.AddStyleClass(StyleBase.ButtonOpenLeft);
+                else if (_groupedAccessLevels.Count > 1 && AccessGroupList.ChildCount == (_groupedAccessLevels.Count - 1))
+                    accessGroupButton.AddStyleClass(StyleBase.ButtonOpenRight);
+                else
+                    accessGroupButton.AddStyleClass(StyleBase.ButtonOpenBoth);
+            }
+
+            accessGroupButton.Pressed = _accessGroupTabIndex == orderedAccessGroups.IndexOf(accessGroup);
+
+            // Label text and styling
+            if (_labelStyleClass != null)
+                accessGroupButton.Label.SetOnlyStyleClass(_labelStyleClass);
+
+            var accessLevelPrototypes = _groupedAccessLevels[accessGroup];
+            var prefix = accessLevelPrototypes.All(x => _activeAccessLevels.Contains(x))
+                ? "»"
+                : accessLevelPrototypes.Any(x => _activeAccessLevels.Contains(x))
+                    ? "›"
+                    : " ";
+
+            var text = Loc.GetString(
+                "turret-controls-window-access-group-label",
+                ("prefix", prefix),
+                ("label", accessGroup.GetAccessGroupName())
+            );
+
+            accessGroupButton.Text = text;
+
+            // Button events
+            accessGroupButton.OnPressed += _ => OnAccessGroupChanged(accessGroupButton.GetPositionInParent());
+
+            AccessGroupList.AddChild(accessGroupButton);
+        }
+
+        // Adjust the current tab index so it remains in range
+        if (_accessGroupTabIndex >= _groupedAccessLevels.Count)
+            _accessGroupTabIndex = _groupedAccessLevels.Count - 1;
+
+        return true;
+    }
+
+    /// <summary>
+    /// Rebuilds the checkbox list for the access level controls.
+    /// </summary>
+    public void RebuildAccessLevelsControls()
+    {
+        AccessLevelChecklist.DisposeAllChildren();
+        _accessLevelEntries.Clear();
+
+        // No access level prototypes were assigned to any of the access level groups
+        // Either turret controller has no assigned access levels, or their names were invalid
+        if (_groupedAccessLevels.Count == 0)
+            return;
+
+        // Reorder the access groups alphabetically
+        var orderedAccessGroups = _groupedAccessLevels.Keys.OrderBy(x => x.GetAccessGroupName()).ToList();
+
+        // Get the access levels associated with the current tab
+        var selectedAccessGroupTabProto = orderedAccessGroups[_accessGroupTabIndex];
+        _accessLevelsForTab = _groupedAccessLevels[selectedAccessGroupTabProto];
+        _accessLevelsForTab = _accessLevelsForTab.OrderBy(x => x.GetAccessLevelName()).ToList();
+
+        // Add an 'all' checkbox as the first child of the list if it has more than one access level
+        // Toggling this checkbox on will mark all other boxes below it on/off
+        var allCheckBox = CreateAccessLevelCheckbox();
+        allCheckBox.Text = Loc.GetString("turret-controls-window-all-checkbox");
+
+        if (_labelStyleClass != null)
+            allCheckBox.Label.SetOnlyStyleClass(_labelStyleClass);
+
+        // Add the 'all' checkbox events
+        allCheckBox.OnPressed += args =>
+        {
+            SetCheckBoxPressedState(_accessLevelEntries, allCheckBox.Pressed);
+
+            var accessLevels = new HashSet<ProtoId<AccessLevelPrototype>>();
+
+            foreach (var accessLevel in _accessLevelsForTab)
+            {
+                accessLevels.Add(accessLevel);
+            }
+
+            OnAccessLevelsChangedEvent?.Invoke(accessLevels, allCheckBox.Pressed);
+        };
+
+        AccessLevelChecklist.AddChild(allCheckBox);
+
+        // Hide the 'all' checkbox if the tab has only one access level
+        var allCheckBoxVisible = _accessLevelsForTab.Count > 1;
+
+        allCheckBox.Visible = allCheckBoxVisible;
+        allCheckBox.Disabled = !_canInteract;
+
+        // Add any remaining missing access level buttons to the UI
+        foreach (var accessLevel in _accessLevelsForTab)
+        {
+            // Create the entry
+            var accessLevelEntry = new AccessLevelEntry(_isMonotone);
+
+            accessLevelEntry.AccessLevel = accessLevel;
+            accessLevelEntry.CheckBox.Text = accessLevel.GetAccessLevelName();
+            accessLevelEntry.CheckBox.Pressed = _activeAccessLevels.Contains(accessLevel);
+            accessLevelEntry.CheckBox.Disabled = !_canInteract;
+
+            if (_labelStyleClass != null)
+                accessLevelEntry.CheckBox.Label.SetOnlyStyleClass(_labelStyleClass);
+
+            // Set the checkbox linkage lines
+            var isEndOfList = _accessLevelsForTab.IndexOf(accessLevel) == (_accessLevelsForTab.Count - 1);
+
+            var lines = new List<(Vector2, Vector2)>
+            {
+                (new Vector2(0.5f, 0f), new Vector2(0.5f, isEndOfList ? 0.5f : 1f)),
+                (new Vector2(0.5f, 0.5f), new Vector2(1f, 0.5f)),
+            };
+
+            accessLevelEntry.UpdateCheckBoxLink(lines);
+            accessLevelEntry.CheckBoxLink.Visible = allCheckBoxVisible;
+            accessLevelEntry.CheckBoxLink.Modulate = !_canInteract ? Color.Gray : Color.White;
+
+            // Add checkbox events
+            accessLevelEntry.CheckBox.OnPressed += args =>
+            {
+                // If the checkbox and its siblings are checked, check the 'all' checkbox too
+                allCheckBox.Pressed = AreAllCheckBoxesPressed(_accessLevelEntries.Select(x => x.CheckBox));
+
+                OnAccessLevelsChangedEvent?.Invoke([accessLevelEntry.AccessLevel], accessLevelEntry.CheckBox.Pressed);
+            };
+
+            AccessLevelChecklist.AddChild(accessLevelEntry);
+            _accessLevelEntries.Add(accessLevelEntry);
+        }
+
+        // Press the 'all' checkbox if all others are pressed
+        allCheckBox.Pressed = AreAllCheckBoxesPressed(_accessLevelEntries.Select(x => x.CheckBox));
+    }
+
+    private bool AreAllCheckBoxesPressed(IEnumerable<CheckBox> checkBoxes)
+    {
+        foreach (var checkBox in checkBoxes)
+        {
+            if (!checkBox.Pressed)
+                return false;
+        }
+
+        return true;
+    }
+
+    private void SetCheckBoxPressedState(List<AccessLevelEntry> accessLevelEntries, bool pressed)
+    {
+        foreach (var accessLevelEntry in accessLevelEntries)
+        {
+            accessLevelEntry.CheckBox.Pressed = pressed;
+        }
+    }
+
+
+    /// <summary>
+    /// Provides the UI with a list of access groups using which list of tabs should be populated.
+    /// </summary>
+    public void SetAccessGroups(HashSet<ProtoId<AccessGroupPrototype>> accessGroups)
+    {
+        _accessGroups = accessGroups;
+
+        ArrangeAccessControls();
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    /// <summary>
+    /// Provides the UI with a list of access levels with which it can populate the currently selected tab.
+    /// </summary>
+    public void SetAccessLevels(HashSet<ProtoId<AccessLevelPrototype>> accessLevels)
+    {
+        _accessLevels = accessLevels;
+
+        ArrangeAccessControls();
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    /// <summary>
+    /// Sets which access level checkboxes should be marked on the UI.
+    /// </summary>
+    public void SetActiveAccessLevels(HashSet<ProtoId<AccessLevelPrototype>> activeAccessLevels)
+    {
+        _activeAccessLevels = activeAccessLevels;
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    /// <summary>
+    /// Sets whether the local player can interact with the checkboxes.
+    /// </summary>
+    public void SetLocalPlayerAccessibility(bool canInteract)
+    {
+        _canInteract = canInteract;
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    /// <summary>
+    /// Sets whether the UI should use monotone buttons and checkboxes.
+    /// </summary>
+    public void SetMonotone(bool monotone)
+    {
+        _isMonotone = monotone;
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    /// <summary>
+    /// Applies the specified style to the labels on the UI buttons and checkboxes.
+    /// </summary>
+    public void SetLabelStyleClass(string? styleClass)
+    {
+        _labelStyleClass = styleClass;
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    private void OnAccessGroupChanged(int newTabIndex)
+    {
+        if (newTabIndex == _accessGroupTabIndex)
+            return;
+
+        _accessGroupTabIndex = newTabIndex;
+
+        if (TryRebuildAccessGroupControls())
+            RebuildAccessLevelsControls();
+    }
+
+    private Button CreateAccessGroupButton()
+    {
+        var button = _isMonotone ? new MonotoneButton() : new Button();
+
+        button.ToggleMode = true;
+        button.Group = _accessGroupsButtons;
+        button.Label.HorizontalAlignment = HAlignment.Left;
+
+        return button;
+    }
+
+    private CheckBox CreateAccessLevelCheckbox()
+    {
+        var checkbox = _isMonotone ? new MonotoneCheckBox() : new CheckBox();
+
+        checkbox.Margin = new Thickness(0, 0, 0, 3);
+        checkbox.ToggleMode = true;
+        checkbox.ReservesSpace = false;
+
+        return checkbox;
+    }
+
+    private sealed class AccessLevelEntry : BoxContainer
+    {
+        public ProtoId<AccessLevelPrototype> AccessLevel;
+        public readonly CheckBox CheckBox;
+        public readonly LineRenderer CheckBoxLink;
+
+        public AccessLevelEntry(bool monotone)
+        {
+            HorizontalExpand = true;
+
+            CheckBoxLink = new LineRenderer
+            {
+                SetWidth = 22,
+                VerticalExpand = true,
+                Margin = new Thickness(0, -1),
+                ReservesSpace = false,
+            };
+
+            AddChild(CheckBoxLink);
+
+            CheckBox = monotone ? new MonotoneCheckBox() : new CheckBox();
+            CheckBox.ToggleMode = true;
+            CheckBox.Margin = new Thickness(0f, 0f, 0f, 3f);
+
+            AddChild(CheckBox);
+        }
+
+        public void UpdateCheckBoxLink(List<(Vector2, Vector2)> lines)
+        {
+            CheckBoxLink.Lines = lines;
+        }
+    }
+
+    private sealed class LineRenderer : Control
+    {
+        /// <summary>
+        /// List of lines to render (their start and end x-y coordinates).
+        /// Position (0,0) is the top left corner of the control and
+        /// position (1,1) is the bottom right corner.
+        /// </summary>
+        /// <remarks>
+        /// The color of the lines is inherited from the control.
+        /// </remarks>
+        public List<(Vector2, Vector2)> Lines;
+
+        public LineRenderer()
+        {
+            Lines = new List<(Vector2, Vector2)>();
+        }
+
+        public LineRenderer(List<(Vector2, Vector2)> lines)
+        {
+            Lines = lines;
+        }
+
+        protected override void Draw(DrawingHandleScreen handle)
+        {
+            foreach (var line in Lines)
+            {
+                var start = PixelPosition +
+                            new Vector2(PixelWidth * line.Item1.X, PixelHeight * line.Item1.Y);
+
+                var end = PixelPosition +
+                          new Vector2(PixelWidth * line.Item2.X, PixelHeight * line.Item2.Y);
+
+                handle.DrawLine(start, end, ActualModulateSelf);
+            }
+        }
+    }
+}
diff --git a/Content.Client/TurretController/DeployableTurretControllerSystem.cs b/Content.Client/TurretController/DeployableTurretControllerSystem.cs
new file mode 100644 (file)
index 0000000..c3b305f
--- /dev/null
@@ -0,0 +1,9 @@
+using Content.Shared.TurretController;
+
+namespace Content.Client.TurretController;
+
+/// <inheritdoc/>
+public sealed class DeployableTurretControllerSystem : SharedDeployableTurretControllerSystem
+{
+
+}
diff --git a/Content.Client/TurretController/TurretControllerWindow.xaml b/Content.Client/TurretController/TurretControllerWindow.xaml
new file mode 100644 (file)
index 0000000..5f4af68
--- /dev/null
@@ -0,0 +1,125 @@
+<ui:TurretControllerWindow xmlns="https://spacestation14.io"
+                        xmlns:ui="clr-namespace:Content.Client.TurretController"
+                        xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+                        xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+                        xmlns:access="clr-namespace:Content.Client.Access.UI"
+                        SetWidth="550"
+                        Resizable="False"
+                        MouseFilter="Stop">
+
+    <PanelContainer Name="Background" StyleClasses="PdaBackgroundRect" ModulateSelfOverride="#4a5466"/>
+    <PanelContainer Name="Border" StyleClasses="PdaBorderRect" />
+
+    <BoxContainer Orientation="Vertical" HorizontalExpand="True">
+
+        <!--Header-->
+        <BoxContainer SetHeight="26" Margin="4 2 8 0" Orientation="Horizontal" HorizontalAlignment="Right">
+            <TextureButton Name="CloseButton" StyleClasses="windowCloseButton" Modulate="#646464" VerticalAlignment="Center" Margin="0 4 4 0"/>
+        </BoxContainer>
+
+        <!--Content-->
+        <Control Margin="18 0" RectClipContent="True" VerticalExpand="true"
+                 HorizontalExpand="True">
+            <PanelContainer Name="ContentBorder" StyleClasses="PdaBackground"/>
+            <Control Name="ContentsContainer" Margin="3 3" Modulate="#FFFFFF">
+
+                <!-- Screen Background -->
+                <PanelContainer Name="ContentBackground" StyleClasses="PdaContentBackground"/>
+
+                <!-- Screen foreground -->
+                <BoxContainer Orientation="Vertical">
+
+                    <Label Text="{Loc 'turret-controls-window-title'}" StyleClasses="ConsoleHeading"
+                           HorizontalAlignment="Center" Margin="0 5 0 0" />
+
+                    <!-- Linked devices -->
+                    <PanelContainer Margin="10 5 10 5">
+                        <PanelContainer.PanelOverride>
+                            <gfx:StyleBoxFlat BorderColor="#FFFFFF" BorderThickness="2" />
+                        </PanelContainer.PanelOverride>
+
+                        <BoxContainer Orientation="Vertical" MinHeight="195" Margin="5 5 5 5">
+                            <Label Name="TurretStatusHeader" Text="{Loc 'turret-controls-window-turret-status-label'}" StyleClasses="ConsoleSubHeading"
+                                   HorizontalAlignment="Center" />
+
+                            <PanelContainer StyleClasses="LowDivider" HorizontalExpand="True" Margin="-5 5 -5 5" SetHeight="2">
+                                <PanelContainer.PanelOverride>
+                                    <gfx:StyleBoxFlat BackgroundColor="#FFFFFF" />
+                                </PanelContainer.PanelOverride>
+                            </PanelContainer>
+
+                            <BoxContainer Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True">
+                                <Label Name="NoLinkedTurretsText" Text="{Loc 'turret-controls-window-no-turrets'}" StyleClasses="ConsoleText"
+                                       HorizontalAlignment="Center" ReservesSpace="False"/>
+
+                                <ScrollContainer VerticalExpand="True" HorizontalExpand="True">
+                                    <BoxContainer Name="LinkedTurretsContainer" Orientation="Vertical" Visible="False" ReservesSpace="False">
+                                        <!-- Populated with C# code -->
+                                    </BoxContainer>
+                                </ScrollContainer>
+                            </BoxContainer>
+                        </BoxContainer>
+                    </PanelContainer>
+
+                    <!-- Armament controls -->
+                    <PanelContainer Margin="10 0 10 5">
+                        <PanelContainer.PanelOverride>
+                            <gfx:StyleBoxFlat BorderColor="#FFFFFF" BorderThickness="2" />
+                        </PanelContainer.PanelOverride>
+
+                        <BoxContainer Orientation="Vertical">
+                            <Label Text="{Loc 'turret-controls-window-armament-controls-label'}" StyleClasses="ConsoleSubHeading"
+                                   HorizontalAlignment="Center" Margin="0 5 0 5" />
+
+                            <PanelContainer StyleClasses="LowDivider" HorizontalExpand="True" SetHeight="2">
+                                <PanelContainer.PanelOverride>
+                                    <gfx:StyleBoxFlat BackgroundColor="#FFFFFF" />
+                                </PanelContainer.PanelOverride>
+                            </PanelContainer>
+
+                            <BoxContainer Orientation="Horizontal" Margin="10 10 10 10">
+                                <controls:MonotoneButton Name="SafeButton" Text="{Loc 'turret-controls-window-safe'}"
+                                                   StyleClasses="OpenRight" Pressed="False" ToggleMode="True" HorizontalExpand="True"/>
+                                <controls:MonotoneButton Name="StunButton" Text="{Loc 'turret-controls-window-stun'}"
+                                                   StyleClasses="OpenBoth" Pressed="False" ToggleMode="True" HorizontalExpand="True"/>
+                                <controls:MonotoneButton Name="LethalButton" Text="{Loc 'turret-controls-window-lethal'}"
+                                                   StyleClasses="OpenLeft" Pressed="False" ToggleMode="True" HorizontalExpand="True"/>
+                            </BoxContainer>
+
+                        </BoxContainer>
+                    </PanelContainer>
+
+                    <!-- Targeting controls -->
+                    <PanelContainer Name="TargetingControlsPanel" Margin="10 0 10 10" VerticalExpand="True" HorizontalExpand="True">
+                        <PanelContainer.PanelOverride>
+                            <gfx:StyleBoxFlat BorderColor="#FFFFFF" BorderThickness="2" />
+                        </PanelContainer.PanelOverride>
+
+                        <BoxContainer Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True">
+                            <Label Text="{Loc 'turret-controls-window-targeting-controls-label'}" StyleClasses="ConsoleSubHeading"
+                                   HorizontalAlignment="Center" Margin="0 5 0 5" />
+
+                            <PanelContainer StyleClasses="LowDivider" HorizontalExpand="True" SetHeight="2">
+                                <PanelContainer.PanelOverride>
+                                    <gfx:StyleBoxFlat BackgroundColor="#FFFFFF" />
+                                </PanelContainer.PanelOverride>
+                            </PanelContainer>
+
+                            <!-- Access configuration -->
+                            <access:GroupedAccessLevelChecklist Name="AccessConfiguration"/>
+
+                        </BoxContainer>
+                    </PanelContainer>
+                </BoxContainer>
+            </Control>
+        </Control>
+
+        <!--Footer-->
+        <BoxContainer Orientation="Horizontal" SetHeight="28">
+            <Label Text="⚠" Margin="0 0 4 4" HorizontalExpand="True" HorizontalAlignment="Right"/>
+            <Label Name="Footer" Text="{Loc 'turret-controls-window-footer'}"
+                   HorizontalAlignment="Center" Margin="0 0 0 4"/>
+            <Label Text="⚠" Margin="4 0 0 4" HorizontalExpand="True" HorizontalAlignment="Left"/>
+        </BoxContainer>
+    </BoxContainer>
+</ui:TurretControllerWindow>
diff --git a/Content.Client/TurretController/TurretControllerWindow.xaml.cs b/Content.Client/TurretController/TurretControllerWindow.xaml.cs
new file mode 100644 (file)
index 0000000..020c894
--- /dev/null
@@ -0,0 +1,201 @@
+using Content.Client.Stylesheets;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Access;
+using Content.Shared.Access.Systems;
+using Content.Shared.TurretController;
+using Content.Shared.Turrets;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Player;
+using Robust.Client.ResourceManagement;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Prototypes;
+using System.Numerics;
+
+namespace Content.Client.TurretController;
+
+[GenerateTypedNameReferences]
+public sealed partial class TurretControllerWindow : BaseWindow
+{
+    [Dependency] private readonly IEntityManager _entManager = default!;
+    [Dependency] private readonly IPlayerManager _playerManager = default!;
+    [Dependency] private readonly IResourceCache _cache = default!;
+
+    private readonly AccessReaderSystem _accessReaderSystem;
+
+    private EntityUid? _owner;
+
+    // Button groups
+    private readonly ButtonGroup _armamentButtons = new();
+
+    // Events
+    public event Action<HashSet<ProtoId<AccessLevelPrototype>>, bool>? OnAccessLevelsChangedEvent;
+    public event Action<TurretArmamentSetting>? OnArmamentSettingChangedEvent;
+
+    // Colors
+    private static readonly Dictionary<TurretArmamentSetting, Color> ThemeColors = new()
+    {
+        [TurretArmamentSetting.Safe] = Color.FromHex("#33e633"),
+        [TurretArmamentSetting.Stun] = Color.FromHex("#dfb827"),
+        [TurretArmamentSetting.Lethal] = Color.FromHex("#da2a2a")
+    };
+
+    public TurretControllerWindow()
+    {
+        RobustXamlLoader.Load(this);
+        IoCManager.InjectDependencies(this);
+
+        _accessReaderSystem = _entManager.System<AccessReaderSystem>();
+
+        CloseButton.OnPressed += _ => Close();
+
+        // Set up armament buttons
+        SafeButton.OnToggled += args => OnArmamentButtonPressed(SafeButton, TurretArmamentSetting.Safe);
+        StunButton.OnToggled += args => OnArmamentButtonPressed(StunButton, TurretArmamentSetting.Stun);
+        LethalButton.OnToggled += args => OnArmamentButtonPressed(LethalButton, TurretArmamentSetting.Lethal);
+
+        SafeButton.Group = _armamentButtons;
+        StunButton.Group = _armamentButtons;
+        LethalButton.Group = _armamentButtons;
+
+        SafeButton.Label.AddStyleClass("ConsoleText");
+        StunButton.Label.AddStyleClass("ConsoleText");
+        LethalButton.Label.AddStyleClass("ConsoleText");
+
+        // Set up access configuration buttons
+        AccessConfiguration.SetMonotone(true);
+        AccessConfiguration.SetLabelStyleClass("ConsoleText");
+        AccessConfiguration.OnAccessLevelsChangedEvent += OnAccessLevelsChanged;
+
+        // Override footer font
+        var smallFont = _cache.NotoStack(size: 8);
+        Footer.FontOverride = smallFont;
+    }
+
+    private void OnAccessLevelsChanged(HashSet<ProtoId<AccessLevelPrototype>> accessLevels, bool isPressed)
+    {
+        OnAccessLevelsChangedEvent?.Invoke(accessLevels, isPressed);
+    }
+
+    private void OnArmamentButtonPressed(MonotoneButton pressedButton, TurretArmamentSetting setting)
+    {
+        UpdateTheme(setting);
+        OnArmamentSettingChangedEvent?.Invoke(setting);
+    }
+
+    private void Initialize()
+    {
+        RefreshLinkedTurrets(new());
+
+        if (_entManager.TryGetComponent<DeployableTurretControllerComponent>(_owner, out var turretController))
+        {
+            AccessConfiguration.SetAccessGroups(turretController.AccessGroups);
+            AccessConfiguration.SetAccessLevels(turretController.AccessLevels);
+            UpdateTheme((TurretArmamentSetting)turretController.ArmamentState);
+        }
+
+        if (_entManager.TryGetComponent<TurretTargetSettingsComponent>(_owner, out var turretTargetSettings))
+        {
+            RefreshAccessControls(turretTargetSettings.ExemptAccessLevels);
+        }
+    }
+
+    public void SetOwner(EntityUid owner)
+    {
+        _owner = owner;
+
+        Initialize();
+    }
+
+    private void UpdateTheme(TurretArmamentSetting setting)
+    {
+        var setPressedOn = setting switch
+        {
+            TurretArmamentSetting.Safe => SafeButton,
+            TurretArmamentSetting.Stun => StunButton,
+            TurretArmamentSetting.Lethal => LethalButton,
+        };
+        setPressedOn.Pressed = true;
+
+        var canInteract = IsLocalPlayerAllowedToInteract();
+
+        SafeButton.Disabled = !SafeButton.Pressed && !canInteract;
+        StunButton.Disabled = !StunButton.Pressed && !canInteract;
+        LethalButton.Disabled = !LethalButton.Pressed && !canInteract;
+
+        ContentsContainer.Modulate = ThemeColors[setting];
+    }
+
+    public void UpdateState(DeployableTurretControllerBoundInterfaceState state)
+    {
+        if (_entManager.TryGetComponent<DeployableTurretControllerComponent>(_owner, out var turretController))
+            UpdateTheme((TurretArmamentSetting)turretController.ArmamentState);
+
+        if (_entManager.TryGetComponent<TurretTargetSettingsComponent>(_owner, out var turretTargetSettings))
+            RefreshAccessControls(turretTargetSettings.ExemptAccessLevels);
+
+        RefreshLinkedTurrets(state.TurretStateByAddress);
+    }
+
+    public void RefreshLinkedTurrets(Dictionary<string, string> turretStates)
+    {
+        var turretCount = turretStates.Count;
+        var hasTurrets = turretCount > 0;
+
+        NoLinkedTurretsText.Visible = !hasTurrets;
+        LinkedTurretsContainer.Visible = hasTurrets;
+
+        LinkedTurretsContainer.RemoveAllChildren();
+
+        foreach (var (address, state) in turretStates)
+        {
+            var text = Loc.GetString(
+                "turret-controls-window-turret-status",
+                ("device", address),
+                ("status", Loc.GetString(state))
+            );
+
+            var label = new Label
+            {
+                Text = text,
+                HorizontalAlignment = HAlignment.Left,
+                Margin = new Thickness(10f, 0f, 10f, 0f),
+                HorizontalExpand = true,
+                SetHeight = 20f,
+            };
+
+            label.AddStyleClass("ConsoleText");
+
+            LinkedTurretsContainer.AddChild(label);
+        }
+
+        TurretStatusHeader.Text = Loc.GetString("turret-controls-window-turret-status-label", ("count", turretCount));
+    }
+
+    public void RefreshAccessControls(HashSet<ProtoId<AccessLevelPrototype>> exemptAccessLevels)
+    {
+        AccessConfiguration.SetActiveAccessLevels(exemptAccessLevels);
+        AccessConfiguration.SetLocalPlayerAccessibility(IsLocalPlayerAllowedToInteract());
+    }
+
+    protected override DragMode GetDragModeFor(Vector2 relativeMousePos)
+    {
+        return DragMode.Move;
+    }
+
+    private bool IsLocalPlayerAllowedToInteract()
+    {
+        if (_owner == null || _playerManager.LocalSession?.AttachedEntity == null)
+            return false;
+
+        return _accessReaderSystem.IsAllowed(_playerManager.LocalSession.AttachedEntity.Value, _owner.Value);
+    }
+
+    public enum TurretArmamentSetting
+    {
+        Safe = -1,
+        Stun = 0,
+        Lethal = 1,
+    }
+}
diff --git a/Content.Client/TurretController/TurretControllerWindowBoundUserInterface.cs b/Content.Client/TurretController/TurretControllerWindowBoundUserInterface.cs
new file mode 100644 (file)
index 0000000..ab1635b
--- /dev/null
@@ -0,0 +1,44 @@
+using Content.Shared.Access;
+using Content.Shared.TurretController;
+using Robust.Client.UserInterface;
+using Robust.Shared.Prototypes;
+
+namespace Content.Client.TurretController;
+
+public sealed class TurretControllerWindowBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
+{
+    [ViewVariables]
+    private TurretControllerWindow? _window;
+
+    protected override void Open()
+    {
+        base.Open();
+
+        _window = this.CreateWindow<TurretControllerWindow>();
+        _window.SetOwner(Owner);
+        _window.OpenCentered();
+
+        _window.OnAccessLevelsChangedEvent += OnAccessLevelChanged;
+        _window.OnArmamentSettingChangedEvent += OnArmamentSettingChanged;
+    }
+
+    protected override void UpdateState(BoundUserInterfaceState state)
+    {
+        base.UpdateState(state);
+
+        if (state is not DeployableTurretControllerBoundInterfaceState { } castState)
+            return;
+
+        _window?.UpdateState(castState);
+    }
+
+    private void OnAccessLevelChanged(HashSet<ProtoId<AccessLevelPrototype>> accessLevels, bool enabled)
+    {
+        SendPredictedMessage(new DeployableTurretExemptAccessLevelChangedMessage(accessLevels, enabled));
+    }
+
+    private void OnArmamentSettingChanged(TurretControllerWindow.TurretArmamentSetting setting)
+    {
+        SendPredictedMessage(new DeployableTurretArmamentSettingChangedMessage((int)setting));
+    }
+}
index 5e84b1e01ac13441b1d1dac75d14db8b651a5aab..05cacba6f1e97dcd9b8e676dfcf656e7dbd31e72 100644 (file)
@@ -84,9 +84,6 @@ public sealed partial class DeployableTurretSystem : SharedDeployableTurretSyste
         if (_animation.HasRunningAnimation(ent, animPlayer, DeployableTurretComponent.AnimationKey))
             return;
 
-        if (state == ent.Comp.VisualState)
-            return;
-
         var targetState = state & DeployableTurretState.Deployed;
         var destinationState = ent.Comp.VisualState & DeployableTurretState.Deployed;
 
index 7271ee7de7e6dcb5b264e1abe278286d5d96079e..b19a2c640f8285650fcf0b01e787bbc3f510b8d8 100644 (file)
@@ -7,7 +7,7 @@ namespace Content.Client.UserInterface.Controls;
 /// <summary>
 /// A button intended for use with a monotone color palette
 /// </summary>
-public sealed class MonotoneButton : ContainerButton
+public sealed class MonotoneButton : Button
 {
     /// <summary>
     /// Specifies the color of the label text when the button is pressed.
@@ -15,43 +15,9 @@ public sealed class MonotoneButton : ContainerButton
     [ViewVariables]
     public Color AltTextColor { set; get; } = new Color(0.2f, 0.2f, 0.2f);
 
-    /// <summary>
-    /// The label that holds the button text.
-    /// </summary>
-    public Label Label { get; }
-
-    /// <summary>
-    /// The text displayed by the button.
-    /// </summary>
-    [PublicAPI, ViewVariables]
-    public string? Text { get => Label.Text; set => Label.Text = value; }
-
-    /// <summary>
-    /// How to align the text inside the button.
-    /// </summary>
-    [PublicAPI, ViewVariables]
-    public AlignMode TextAlign { get => Label.Align; set => Label.Align = value; }
-
-    /// <summary>
-    /// If true, the button will allow shrinking and clip text
-    /// to prevent the text from going outside the bounds of the button.
-    /// If false, the minimum size will always fit the contained text.
-    /// </summary>
-    [PublicAPI, ViewVariables]
-    public bool ClipText
-    {
-        get => Label.ClipText;
-        set => Label.ClipText = value;
-    }
-
     public MonotoneButton()
     {
-        Label = new Label
-        {
-            StyleClasses = { StyleClassButton }
-        };
-
-        AddChild(Label);
+        RemoveStyleClass("button");
         UpdateAppearance();
     }
 
diff --git a/Content.Server/TurretController/DeployableTurretControllerSystem.cs b/Content.Server/TurretController/DeployableTurretControllerSystem.cs
new file mode 100644 (file)
index 0000000..f0b6881
--- /dev/null
@@ -0,0 +1,152 @@
+using Content.Server.DeviceNetwork.Systems;
+using Content.Shared.Access;
+using Content.Shared.DeviceNetwork;
+using Content.Shared.DeviceNetwork.Components;
+using Content.Shared.DeviceNetwork.Events;
+using Content.Shared.DeviceNetwork.Systems;
+using Content.Shared.TurretController;
+using Content.Shared.Turrets;
+using Robust.Server.GameObjects;
+using Robust.Shared.Prototypes;
+using System.Linq;
+
+namespace Content.Server.TurretController;
+
+/// <inheritdoc/>
+public sealed partial class DeployableTurretControllerSystem : SharedDeployableTurretControllerSystem
+{
+    [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
+    [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+
+    /// Keys for the device network. See <see cref="DeviceNetworkConstants"/> for further examples.
+    public const string CmdSetArmamemtState = "set_armament_state";
+    public const string CmdSetAccessExemptions = "set_access_exemption";
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<DeployableTurretControllerComponent, BoundUIOpenedEvent>(OnBUIOpened);
+        SubscribeLocalEvent<DeployableTurretControllerComponent, DeviceListUpdateEvent>(OnDeviceListUpdate);
+        SubscribeLocalEvent<DeployableTurretControllerComponent, DeviceNetworkPacketEvent>(OnPacketReceived);
+    }
+
+    private void OnBUIOpened(Entity<DeployableTurretControllerComponent> ent, ref BoundUIOpenedEvent args)
+    {
+        UpdateUIState(ent);
+    }
+
+    private void OnDeviceListUpdate(Entity<DeployableTurretControllerComponent> ent, ref DeviceListUpdateEvent args)
+    {
+        if (!TryComp<DeviceNetworkComponent>(ent, out var deviceNetwork))
+            return;
+
+        // List of new added turrets
+        var turretsToAdd = args.Devices.Except(args.OldDevices);
+
+        // Request data from newly linked devices
+        var payload = new NetworkPayload
+        {
+            [DeviceNetworkConstants.Command] = DeviceNetworkConstants.CmdUpdatedState,
+        };
+
+        foreach (var turretUid in turretsToAdd)
+        {
+            if (!HasComp<DeployableTurretComponent>(turretUid))
+                continue;
+
+            if (!TryComp<DeviceNetworkComponent>(turretUid, out var turretDeviceNetwork))
+                continue;
+
+            _deviceNetwork.QueuePacket(ent, turretDeviceNetwork.Address, payload, device: deviceNetwork);
+        }
+
+        // Remove newly unlinked devices
+        var turretsToRemove = args.OldDevices.Except(args.Devices);
+        var refreshUi = false;
+
+        foreach (var turretUid in turretsToRemove)
+        {
+            if (!TryComp<DeviceNetworkComponent>(turretUid, out var turretDeviceNetwork))
+                continue;
+
+            if (ent.Comp.LinkedTurrets.Remove(turretDeviceNetwork.Address))
+                refreshUi = true;
+        }
+
+        if (refreshUi)
+            UpdateUIState(ent);
+    }
+
+    private void OnPacketReceived(Entity<DeployableTurretControllerComponent> ent, ref DeviceNetworkPacketEvent args)
+    {
+        if (!args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+            return;
+
+        if (!TryComp<DeviceNetworkComponent>(ent, out var deviceNetwork) || deviceNetwork.ReceiveFrequency != args.Frequency)
+            return;
+
+        // If an update was received from a turret, connect to it and update the UI
+        if (command == DeviceNetworkConstants.CmdUpdatedState &&
+            args.Data.TryGetValue(command, out DeployableTurretState updatedState))
+        {
+            ent.Comp.LinkedTurrets[args.SenderAddress] = updatedState;
+            UpdateUIState(ent);
+        }
+    }
+
+    protected override void ChangeArmamentSetting(Entity<DeployableTurretControllerComponent> ent, int armamentState, EntityUid? user = null)
+    {
+        base.ChangeArmamentSetting(ent, armamentState, user);
+
+        if (!TryComp<DeviceNetworkComponent>(ent, out var device))
+            return;
+
+        // Update linked turrets' armament statuses
+        var payload = new NetworkPayload
+        {
+            [DeviceNetworkConstants.Command] = CmdSetArmamemtState,
+            [CmdSetArmamemtState] = armamentState,
+        };
+
+        _deviceNetwork.QueuePacket(ent, null, payload, device: device);
+    }
+
+    protected override void ChangeExemptAccessLevels(
+        Entity<DeployableTurretControllerComponent> ent,
+        HashSet<ProtoId<AccessLevelPrototype>> exemptions,
+        bool enabled,
+        EntityUid? user = null
+    )
+    {
+        base.ChangeExemptAccessLevels(ent, exemptions, enabled, user);
+
+        if (!TryComp<DeviceNetworkComponent>(ent, out var device) ||
+            !TryComp<TurretTargetSettingsComponent>(ent, out var turretTargetingSettings))
+            return;
+
+        // Update linked turrets' target selection exemptions
+        var payload = new NetworkPayload
+        {
+            [DeviceNetworkConstants.Command] = CmdSetAccessExemptions,
+            [CmdSetAccessExemptions] = turretTargetingSettings.ExemptAccessLevels,
+        };
+
+        _deviceNetwork.QueuePacket(ent, null, payload, device: device);
+    }
+
+    private void UpdateUIState(Entity<DeployableTurretControllerComponent> ent)
+    {
+        var turretStates = new Dictionary<string, string>();
+
+        foreach (var (address, state) in ent.Comp.LinkedTurrets)
+        {
+            var stateName = state.ToString().ToLower();
+            var stateDesc = Loc.GetString("turret-controls-window-turret-" + stateName);
+            turretStates.Add(address, stateDesc);
+        }
+
+        var uiState = new DeployableTurretControllerBoundInterfaceState(turretStates);
+        _userInterfaceSystem.SetUiState(ent.Owner, DeployableTurretControllerUiKey.Key, uiState);
+    }
+}
index 72c011bc9059f6852b7b0acce51493a8805084e2..9bb382badf00e80a0c076716fc4a42ccccaeac6d 100644 (file)
@@ -1,20 +1,23 @@
 using Content.Server.Destructible;
-using Content.Server.DeviceNetwork;
-using Content.Server.DeviceNetwork.Components;
 using Content.Server.DeviceNetwork.Systems;
 using Content.Server.NPC.HTN;
 using Content.Server.NPC.HTN.PrimitiveTasks.Operators.Combat.Ranged;
 using Content.Server.Power.Components;
 using Content.Server.Repairable;
+using Content.Server.TurretController;
+using Content.Shared.Access;
 using Content.Shared.Destructible;
 using Content.Shared.DeviceNetwork;
 using Content.Shared.DeviceNetwork.Components;
 using Content.Shared.DeviceNetwork.Events;
 using Content.Shared.Power;
 using Content.Shared.Turrets;
+using Content.Shared.Weapons.Ranged.Components;
 using Content.Shared.Weapons.Ranged.Events;
+using Content.Shared.Weapons.Ranged.Systems;
 using Robust.Shared.Audio;
 using Robust.Shared.Audio.Systems;
+using Robust.Shared.Prototypes;
 using Robust.Shared.Timing;
 
 namespace Content.Server.Turrets;
@@ -25,6 +28,8 @@ public sealed partial class DeployableTurretSystem : SharedDeployableTurretSyste
     [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
     [Dependency] private readonly SharedAudioSystem _audio = default!;
     [Dependency] private readonly DeviceNetworkSystem _deviceNetwork = default!;
+    [Dependency] private readonly BatteryWeaponFireModesSystem _fireModes = default!;
+    [Dependency] private readonly TurretTargetSettingsSystem _turretTargetingSettings = default!;
     [Dependency] private readonly IGameTiming _timing = default!;
 
     public override void Initialize()
@@ -36,6 +41,7 @@ public sealed partial class DeployableTurretSystem : SharedDeployableTurretSyste
         SubscribeLocalEvent<DeployableTurretComponent, PowerChangedEvent>(OnPowerChanged);
         SubscribeLocalEvent<DeployableTurretComponent, BreakageEventArgs>(OnBroken);
         SubscribeLocalEvent<DeployableTurretComponent, RepairedEvent>(OnRepaired);
+        SubscribeLocalEvent<DeployableTurretComponent, DeviceNetworkPacketEvent>(OnPacketReceived);
         SubscribeLocalEvent<DeployableTurretComponent, BeforeBroadcastAttemptEvent>(OnBeforeBroadcast);
     }
 
@@ -68,6 +74,39 @@ public sealed partial class DeployableTurretSystem : SharedDeployableTurretSyste
             _appearance.SetData(ent, DeployableTurretVisuals.Broken, false, appearance);
     }
 
+    private void OnPacketReceived(Entity<DeployableTurretComponent> ent, ref DeviceNetworkPacketEvent args)
+    {
+        if (!args.Data.TryGetValue(DeviceNetworkConstants.Command, out string? command))
+            return;
+
+        // Received a command to change armament state
+        if (command == DeployableTurretControllerSystem.CmdSetArmamemtState &&
+            args.Data.TryGetValue(command, out int? armamentState))
+        {
+            if (TryComp<BatteryWeaponFireModesComponent>(ent, out var batteryWeaponFireModes))
+                _fireModes.TrySetFireMode(ent, batteryWeaponFireModes, armamentState.Value);
+
+            TrySetState(ent, armamentState.Value >= 0);
+            return;
+        }
+
+        // Received a command to change access exemptions
+        if (command == DeployableTurretControllerSystem.CmdSetAccessExemptions &&
+            args.Data.TryGetValue(command, out HashSet<ProtoId<AccessLevelPrototype>>? accessExemptions) &&
+            TryComp<TurretTargetSettingsComponent>(ent, out var turretTargetSettings))
+        {
+            _turretTargetingSettings.SyncAccessLevelExemptions((ent, turretTargetSettings), accessExemptions);
+            return;
+        }
+
+        // Received a command to update the device network
+        if (command == DeviceNetworkConstants.CmdUpdatedState)
+        {
+            SendStateUpdateToDeviceNetwork(ent);
+            return;
+        }
+    }
+
     private void OnBeforeBroadcast(Entity<DeployableTurretComponent> ent, ref BeforeBroadcastAttemptEvent args)
     {
         if (!TryComp<DeviceNetworkComponent>(ent, out var deviceNetwork))
index ca73aa3dd035c1ddee2bc037c4867476ebcecdf0..78292df44da7f24474e235e300668dd3dd483860 100644 (file)
@@ -1,6 +1,5 @@
-using Content.Shared.Access.Components;
+using Content.Shared.Access.Components;
 using Robust.Shared.Prototypes;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.Set;
 
 namespace Content.Shared.Access;
 
@@ -14,6 +13,23 @@ public sealed partial class AccessGroupPrototype : IPrototype
     [IdDataField]
     public string ID { get; private set; } = default!;
 
-    [DataField("tags", required: true)]
+    /// <summary>
+    /// The player-visible name of the access level group
+    /// </summary>
+    [DataField]
+    public string? Name { get; set; }
+
+    /// <summary>
+    /// The access levels associated with this group
+    /// </summary>
+    [DataField(required: true)]
     public HashSet<ProtoId<AccessLevelPrototype>> Tags = default!;
+
+    public string GetAccessGroupName()
+    {
+        if (Name is { } name)
+            return Loc.GetString(name);
+
+        return ID;
+    }
 }
diff --git a/Content.Shared/TurretController/DeployableTurretControllerComponent.cs b/Content.Shared/TurretController/DeployableTurretControllerComponent.cs
new file mode 100644 (file)
index 0000000..3bada93
--- /dev/null
@@ -0,0 +1,102 @@
+using Content.Shared.Access;
+using Content.Shared.Turrets;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.TurretController;
+
+/// <summary>
+/// Attached to entities that can set data on linked turret-based entities
+/// </summary>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedDeployableTurretControllerSystem))]
+public sealed partial class DeployableTurretControllerComponent : Component
+{
+    /// <summary>
+    /// The states of the turrets linked to this entity, indexed by their device address.
+    /// This is used to populate the controller UI with the address and state of linked turrets.
+    /// </summary>
+    [ViewVariables]
+    public Dictionary<string, DeployableTurretState> LinkedTurrets = new();
+
+    /// <summary>
+    /// The last armament state index applied to any linked turrets.
+    /// Values greater than zero have no additional effect if the linked turrets
+    /// do not have the <see cref="BatteryWeaponFireModesComponent"/>
+    /// </summary>
+    /// <remarks>
+    /// -1: Inactive, 0: weapon mode A, 1: weapon mode B, etc.
+    /// </remarks>
+    [DataField, AutoNetworkedField]
+    public int ArmamentState = -1;
+
+    /// <summary>
+    /// Access level prototypes that are known to the entity.
+    /// Determines what access permissions can be adjusted.
+    /// It is also used to populate the controller UI.
+    /// </summary>
+    [DataField]
+    public HashSet<ProtoId<AccessLevelPrototype>> AccessLevels = new();
+
+    /// <summary>
+    /// Access group prototypes that are known to the entity.
+    /// Determines how access permissions are organized on the controller UI.
+    /// </summary>
+    [DataField]
+    public HashSet<ProtoId<AccessGroupPrototype>> AccessGroups = new();
+
+    /// <summary>
+    /// Sound to play when denying access to the device.
+    /// </summary>
+    [DataField]
+    public SoundSpecifier AccessDeniedSound = new SoundPathSpecifier("/Audio/Machines/custom_deny.ogg");
+}
+
+[Serializable, NetSerializable]
+public sealed class DeployableTurretControllerBoundInterfaceState : BoundUserInterfaceState
+{
+    public Dictionary<string, string> TurretStateByAddress;
+
+    public DeployableTurretControllerBoundInterfaceState(Dictionary<string, string> turretStateByAddress)
+    {
+        TurretStateByAddress = turretStateByAddress;
+    }
+}
+
+[Serializable, NetSerializable]
+public sealed class DeployableTurretArmamentSettingChangedMessage : BoundUserInterfaceMessage
+{
+    public int ArmamentState;
+
+    public DeployableTurretArmamentSettingChangedMessage(int armamentState)
+    {
+        ArmamentState = armamentState;
+    }
+}
+
+[Serializable, NetSerializable]
+public sealed class DeployableTurretExemptAccessLevelChangedMessage : BoundUserInterfaceMessage
+{
+    public HashSet<ProtoId<AccessLevelPrototype>> AccessLevels;
+    public bool Enabled;
+
+    public DeployableTurretExemptAccessLevelChangedMessage(HashSet<ProtoId<AccessLevelPrototype>> accessLevels, bool enabled)
+    {
+        AccessLevels = accessLevels;
+        Enabled = enabled;
+    }
+}
+
+[Serializable, NetSerializable]
+public enum TurretControllerVisuals : byte
+{
+    ControlPanel,
+}
+
+[Serializable, NetSerializable]
+public enum DeployableTurretControllerUiKey : byte
+{
+    Key,
+}
diff --git a/Content.Shared/TurretController/SharedDeployableTurretControllerSystem.cs b/Content.Shared/TurretController/SharedDeployableTurretControllerSystem.cs
new file mode 100644 (file)
index 0000000..314a980
--- /dev/null
@@ -0,0 +1,96 @@
+using Content.Shared.Access;
+using Content.Shared.Access.Systems;
+using Content.Shared.Popups;
+using Content.Shared.Turrets;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.TurretController;
+
+/// <summary>
+/// Oversees entities that can change the component values of linked deployable turrets,
+/// specifically their armament and access level exemptions, via an associated UI
+/// </summary>
+public abstract partial class SharedDeployableTurretControllerSystem : EntitySystem
+{
+    [Dependency] private readonly AccessReaderSystem _accessreader = default!;
+    [Dependency] private readonly TurretTargetSettingsSystem _turretTargetingSettings = default!;
+    [Dependency] private readonly SharedUserInterfaceSystem _userInterfaceSystem = default!;
+    [Dependency] private readonly SharedPopupSystem _popup = default!;
+    [Dependency] private readonly SharedAudioSystem _audio = default!;
+    [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        // Handling of client messages
+        SubscribeLocalEvent<DeployableTurretControllerComponent, DeployableTurretArmamentSettingChangedMessage>(OnArmamentSettingChanged);
+        SubscribeLocalEvent<DeployableTurretControllerComponent, DeployableTurretExemptAccessLevelChangedMessage>(OnExemptAccessLevelsChanged);
+    }
+
+    private void OnArmamentSettingChanged(Entity<DeployableTurretControllerComponent> ent, ref DeployableTurretArmamentSettingChangedMessage args)
+    {
+        if (IsUserAllowedAccess(ent, args.Actor))
+            ChangeArmamentSetting(ent, args.ArmamentState, args.Actor);
+
+        if (_userInterfaceSystem.TryGetOpenUi(ent.Owner, DeployableTurretControllerUiKey.Key, out var bui))
+            bui.Update<DeployableTurretControllerBoundInterfaceState>();
+    }
+
+    private void OnExemptAccessLevelsChanged(Entity<DeployableTurretControllerComponent> ent, ref DeployableTurretExemptAccessLevelChangedMessage args)
+    {
+        if (IsUserAllowedAccess(ent, args.Actor))
+            ChangeExemptAccessLevels(ent, args.AccessLevels, args.Enabled, args.Actor);
+
+        if (_userInterfaceSystem.TryGetOpenUi(ent.Owner, DeployableTurretControllerUiKey.Key, out var bui))
+            bui.Update<DeployableTurretControllerBoundInterfaceState>();
+    }
+
+    protected virtual void ChangeArmamentSetting(Entity<DeployableTurretControllerComponent> ent, int armamentState, EntityUid? user = null)
+    {
+        ent.Comp.ArmamentState = armamentState;
+        Dirty(ent);
+
+        _appearance.SetData(ent, TurretControllerVisuals.ControlPanel, armamentState);
+
+        // Linked turrets are updated on the server side
+    }
+
+    protected virtual void ChangeExemptAccessLevels(
+        Entity<DeployableTurretControllerComponent> ent,
+        HashSet<ProtoId<AccessLevelPrototype>> exemptions,
+        bool enabled,
+        EntityUid? user = null
+    )
+    {
+        // Update the controller
+        if (!TryComp<TurretTargetSettingsComponent>(ent, out var targetSettings))
+            return;
+
+        var controller = new Entity<TurretTargetSettingsComponent>(ent, targetSettings);
+
+        foreach (var accessLevel in exemptions)
+        {
+            if (!ent.Comp.AccessLevels.Contains(accessLevel))
+                continue;
+
+            _turretTargetingSettings.SetAccessLevelExemption(controller, accessLevel, enabled);
+        }
+
+        Dirty(controller);
+
+        // Linked turrets are updated on the server side
+    }
+
+    public bool IsUserAllowedAccess(Entity<DeployableTurretControllerComponent> ent, EntityUid user)
+    {
+        if (_accessreader.IsAllowed(user, ent))
+            return true;
+
+        _popup.PopupClient(Loc.GetString("turret-controls-access-denied"), ent, user);
+        _audio.PlayPredicted(ent.Comp.AccessDeniedSound, ent, user);
+
+        return false;
+    }
+}
index 56f60e0e695805a50638cbfc294c53041858f133..3a8edc9955b4ec5a814b114eae64774e10d140d6 100644 (file)
@@ -23,13 +23,17 @@ public sealed partial class TurretTargetSettingsSystem : EntitySystem
     /// <param name="ent">The entity and its <see cref="TurretTargetSettingsComponent"/></param>
     /// <param name="exemption">The proto ID for the access level</param>
     /// <param name="enabled">Set 'true' to add the exemption, or 'false' to remove it</param>
+    /// <param name="dirty">Set 'true' to dirty the component</param>
     [PublicAPI]
-    public void SetAccessLevelExemption(Entity<TurretTargetSettingsComponent> ent, ProtoId<AccessLevelPrototype> exemption, bool enabled)
+    public void SetAccessLevelExemption(Entity<TurretTargetSettingsComponent> ent, ProtoId<AccessLevelPrototype> exemption, bool enabled, bool dirty = true)
     {
         if (enabled)
             ent.Comp.ExemptAccessLevels.Add(exemption);
         else
             ent.Comp.ExemptAccessLevels.Remove(exemption);
+
+        if (dirty)
+            Dirty(ent);
     }
 
     /// <summary>
@@ -42,7 +46,9 @@ public sealed partial class TurretTargetSettingsSystem : EntitySystem
     public void SetAccessLevelExemptions(Entity<TurretTargetSettingsComponent> ent, ICollection<ProtoId<AccessLevelPrototype>> exemptions, bool enabled)
     {
         foreach (var exemption in exemptions)
-            SetAccessLevelExemption(ent, exemption, enabled);
+            SetAccessLevelExemption(ent, exemption, enabled, false);
+
+        Dirty(ent);
     }
 
     /// <summary>
diff --git a/Resources/Locale/en-US/ui/turret-controls.ftl b/Resources/Locale/en-US/ui/turret-controls.ftl
new file mode 100644 (file)
index 0000000..549781a
--- /dev/null
@@ -0,0 +1,31 @@
+# Headings
+turret-controls-window-title = Autonomous Defense Control System
+turret-controls-window-turret-status-label = Linked devices [{$count}]
+turret-controls-window-armament-controls-label = Armament setting
+turret-controls-window-targeting-controls-label = Authorized personnel
+
+# Status reports
+turret-controls-window-no-turrets = <! No linked devices !>
+turret-controls-window-turret-status = » {$device} - Status: {$status}
+turret-controls-window-turret-disabled = ***OFFLINE***
+turret-controls-window-turret-retracted = INACTIVE
+turret-controls-window-turret-retracting = DEACTIVATING
+turret-controls-window-turret-deployed = SEARCHING...
+turret-controls-window-turret-deploying = ACTIVATING
+turret-controls-window-turret-firing = ENGAGING TARGET
+turret-controls-window-turret-error = ERROR [404]
+
+# Buttons
+turret-controls-window-safe = Inactive
+turret-controls-window-stun = Stun
+turret-controls-window-lethal = Lethal
+turret-controls-window-ignore = Ignore
+turret-controls-window-target = Target
+turret-controls-window-access-group-label = {$prefix} {$label}
+turret-controls-window-all-checkbox = All
+
+# Flavor
+turret-controls-window-footer = Unauthorized personnel should ensure defenses are inactive before proceeding
+
+# Warnings
+turret-controls-access-denied = Access denied
\ No newline at end of file
index d3f6df775b104dddaad7b39511dcc38660454650..5c8d5e3660cab945665b90d973326cab9151d583 100644 (file)
@@ -34,3 +34,8 @@
   - Atmospherics
   - GenpopEnter
   - GenpopLeave
+
+- type: accessGroup
+  id: General
+  tags:
+  - Maintenance
index 66860ae98c1590a9ecd83192f7ba087396fd57c1..a8b3c9a88ccde0a11c0d5f518420ff9e14ee29cb 100644 (file)
@@ -1,11 +1,9 @@
 - type: entity
   parent: [BaseWeaponEnergyTurret, ConstructibleMachine]
   id: WeaponEnergyTurretStation
-  name: sentry turret
+  name: security turret
   description: A high-tech autonomous weapons system designed to keep unauthorized personnel out of sensitive areas.
   components:
-  
-  # Physics
   - type: Fixtures
     fixtures:
       body:
@@ -25,8 +23,6 @@
         layer:
           - MachineLayer
         hard: false
-        
-  # Sprites and appearance
   - type: Sprite
     sprite: Objects/Weapons/Guns/Turrets/sentry_turret.rsi
     drawdepth: HighFloorObjects
         enum.WiresVisualLayers.MaintenancePanel:
           True: { visible: false }
           False: { visible: true }
-  
-  # HTN
   - type: HTN
     enabled: false
-  
-  # Faction / control
   - type: StationAiWhitelist
   - type: NpcFactionMember
     factions:
     - AllHostile
   - type: AccessReader
     access: [["Security"]]
-    
-  # Weapon systems
   - type: ProjectileBatteryAmmoProvider
     proto: BulletEnergyTurretDisabler
     fireCost: 100
@@ -98,8 +88,6 @@
     - Security
     - Borg
     - BasicSilicon
-    
-  # Defenses / destruction
   - type: DeployableTurret
     retractedDamageModifierSetId: Metallic
     deployedDamageModifierSetId: FlimsyMetallic
         node: machineFrame
       - !type:DoActsBehavior
         acts: ["Destruction"]
-      
-  # Device network
   - type: DeviceNetwork
     deviceNetId: Wired
     receiveFrequencyId: TurretControl
     examinableAddress: true
   - type: DeviceNetworkRequiresPower
   - type: WiredNetworkConnection
-  
-  # Wires
   - type: UserInterface
     interfaces:
       enum.WiresUiKey.Key:
     locked: true
     unlockOnClick: false
   - type: LockedWiresPanel
-  
-  # General properties  
   - type: Machine
     board: WeaponEnergyTurretStationMachineCircuitboard
   - type: UseDelay
   description: A high-tech autonomous weapons system under the direct control of a local artifical intelligence.
   components:
   - type: AccessReader
-    access: [["StationAi"]]
+    access: [["StationAi"], ["ResearchDirector"]]
   - type: TurretTargetSettings
     exemptAccessLevels:
     - Borg
   - type: DeviceNetwork
     receiveFrequencyId: TurretControlAI
     transmitFrequencyId: TurretAI
+
diff --git a/Resources/Prototypes/Entities/Structures/Wallmounts/turret_controls.yml b/Resources/Prototypes/Entities/Structures/Wallmounts/turret_controls.yml
new file mode 100644 (file)
index 0000000..95a3e74
--- /dev/null
@@ -0,0 +1,198 @@
+- type: entity
+  id: WeaponEnergyTurretControlPanelFrame
+  name: sentry turret control panel assembly
+  description: An incomplete wall-mounted assembly for a sentry turret control panel.
+  categories: [ HideSpawnMenu ]
+  components:
+  - type: Sprite
+    noRot: false
+    drawdepth: SmallObjects
+    sprite: Structures/Wallmounts/turret_controls.rsi
+    layers:
+    - state: base
+  - type: Damageable
+    damageContainer: StructuralInorganic
+    damageModifierSet: StructuralMetallic
+  - type: Destructible
+    thresholds:
+    - trigger:
+        !type:DamageTrigger
+        damage: 200
+      behaviors:
+      - !type:PlaySoundBehavior
+        sound:
+          collection: MetalGlassBreak
+          params:
+            volume: -4
+      #- !type:ChangeConstructionNodeBehavior - To be added in a later PR
+      #  node: machineFrame
+      - !type:DoActsBehavior
+        acts: [ "Destruction" ]
+  - type: Transform
+    anchored: true
+  - type: WallMount
+  - type: Clickable
+  - type: InteractionOutline
+  - type: ContainerContainer
+    containers:
+      board: !type:Container
+  #- type: Construction - To be added in a later PR
+  #  graph: WeaponEnergyTurretControlPanel
+  #  node: frame
+  placement:
+    mode: SnapgridCenter
+    snap:
+    - Wallmount
+
+- type: entity
+  parent: WeaponEnergyTurretControlPanelFrame
+  id: WeaponEnergyTurretStationControlPanel
+  name: security turret control panel
+  description: A wall-mounted interface for remotely configuring the operational parameters of linked security turrets.
+  components:
+  - type: Appearance
+  - type: Sprite
+    noRot: false
+    drawdepth: SmallObjects
+    sprite: Structures/Wallmounts/turret_controls.rsi
+    layers:
+    - state: base
+    - state: safe
+      map: ["enum.PowerDeviceVisualLayers.Powered"]
+      shader: unshaded
+    - state: wires
+      map: ["enum.WiresVisualLayers.MaintenancePanel"]
+      visible: false
+  - type: GenericVisualizer
+    visuals:
+      enum.WiresVisualLayers.MaintenancePanel:
+        enum.WiresVisualLayers.MaintenancePanel:
+          True: { visible: true }
+          False: { visible: false }
+      enum.PowerDeviceVisuals.Powered:
+        enum.PowerDeviceVisualLayers.Powered:
+          True: { visible: true }
+          False: { visible: false }
+      enum.TurretControllerVisuals.ControlPanel:
+        enum.PowerDeviceVisualLayers.Powered:
+          -1: { state: safe }
+          0: { state: stun }
+          1: { state: lethal }
+  - type: StationAiWhitelist
+  - type: AccessReader
+    access: [["Security"]]
+  - type: TurretTargetSettings
+    exemptAccessLevels:
+    - Security
+    - Borg
+    - BasicSilicon
+  - type: DeployableTurretController
+    accessGroups:
+    - Cargo
+    - Command
+    - Engineering
+    - General
+    - Medical
+    - Research
+    - Security
+    - Service
+    - Silicon
+    accessLevels:
+    - Armory
+    - Atmospherics
+    - Bar
+    - BasicSilicon
+    - Borg
+    - Brig
+    - Detective
+    - Captain
+    - Cargo
+    - Chapel
+    - Chemistry
+    - ChiefEngineer
+    - ChiefMedicalOfficer
+    - Command
+    - Cryogenics
+    - Engineering
+    - External
+    - HeadOfPersonnel
+    - HeadOfSecurity
+    - Hydroponics
+    - Janitor
+    - Kitchen
+    - Lawyer
+    - Maintenance
+    - Medical
+    - Quartermaster
+    - Research
+    - ResearchDirector
+    - Salvage
+    - Security
+    - Service
+    - Theatre
+  - type: DeviceList
+    isAllowList: true
+  - type: DeviceNetwork
+    deviceNetId: Wired
+    receiveFrequencyId: Turret
+    transmitFrequencyId: TurretControl
+    sendBroadcastAttemptEvent: true
+    prefix: device-address-prefix-console
+  - type: DeviceNetworkRequiresPower
+  - type: WiredNetworkConnection
+  - type: ActivatableUI
+    key: enum.DeployableTurretControllerUiKey.Key
+  - type: ActivatableUIRequiresPower
+  - type: UserInterface
+    interfaces:
+      enum.DeployableTurretControllerUiKey.Key:
+        type: TurretControllerWindowBoundUserInterface
+      enum.WiresUiKey.Key:
+        type: WiresBoundUserInterface
+  - type: WiresPanel
+  - type: WiresVisuals
+  - type: Wires
+    boardName: wires-board-name-turret-controls
+    layoutId: TurretControls
+  - type: Lock
+    locked: true
+    unlockOnClick: false
+  - type: LockedWiresPanel
+  - type: ApcPowerReceiver
+  - type: ExtensionCableReceiver
+  - type: Electrified
+    enabled: false
+    usesApcPower: true
+  #- type: ContainerFill - Will be added in a later PR
+  #  containers:
+  #    board:
+  #    - WeaponEnergyTurretStationControlPanelElectronics
+  #- type: Construction - Will be added in a later PR
+  #  graph: WeaponEnergyTurretControlPanel
+  #  node: finish
+
+- type: entity
+  parent: WeaponEnergyTurretStationControlPanel
+  id: WeaponEnergyTurretAIControlPanel
+  name: AI sentry turret control panel
+  description: A wall-mounted interface that allows a local artifical intelligence to adjust the operational parameters of linked sentry turrets.
+  components:
+  - type: AccessReader
+    access: [["StationAi"], ["ResearchDirector"]]
+  #- type: ContainerFill - Will be added in a later PR
+  #  containers:
+  #    board:
+  #    - WeaponEnergyTurretAIControlPanelElectronics
+  - type: DeviceNetwork
+    receiveFrequencyId: TurretAI
+    transmitFrequencyId: TurretControlAI
+  - type: TurretTargetSettings
+    exemptAccessLevels:
+    - BasicSilicon
+    - Borg
+  - type: DeployableTurretController
+    accessGroups:
+    - Silicon
+    accessLevels:
+    - BasicSilicon
+    - Borg