Feature/door remote radial (#36378)

* it works! kinda

* so it works now

* minor cleanup

* central button now is useful too

* more cleanup

* minor cleanup

* more cleanup

* refactor: migrated code from toolbox (as it was rejected as too specific)

* feat: moved border drawing for radial menu into RadialMenuTextureButton. Radial menu position setting into was moved to OverrideArrange to not being called on every frame

* refactor: major reworks!

* renamed DrawBagleSector to DrawAnnulusSector

* Remove strange indexing

* Regularize math

* refactor: re-orienting segment elements to be Y-mirrored

* refactor: extracted radial menu radius multiplier property, changed color pallet for radial menu button

* refactor: removed icon backgrounds on textures used in current radial menu buttons with sectors, RadialContainer Radius renamed and now actually changed control radius.

* refactor: in RadialMenuTextureButtonWithSector all sector colors are converted to and from sRGB in property getter-setters

* refactor: renamed srgb to include Srgb suffix so devs gonna see that its srgb clearly

* fix: enabled any functional keys pressed when pushing radial menu buttons

* fix: radial menu sector now scales with UIScale

* fix: accept only one event when clicking on radial menu ContextualButton

* fix: now radial menu buttons accepts only click/alt-click, now clicks outside menu closes menu always

* feat: simple radial menu prototype for easier creation

* refactor: cleanup, restored emote filtering, button models now have class hierarchy

* refactor: remove usage of closure from 'outside code'

* refactor: remove non existing type from UiControlTest

* refactor: remove unused using

* refactor: revert ability to declare radial menu layers in xaml, scale 32px sprites using scale in radial menu

* refactor: whitespaces

* feat: now door remote have some kind of ui to switch mode

* refactor: subscribe for dispose on existing radial menus

* feat: now simple radial menu button models can have custom color for each sector background (and hover background color). Also added OpenOverMouseScreenPosition inside SimpleRadialMenu

* fix: AI door menu now can be closed by verb if it gets unpowered

* refactor: simplify code for DoorRemoteBoundUserInterface

* fix open/close mode sprite

* remove broken merge changes

* refactor: changed DoorRemoteSystem to be fully in shared

* refactor: localize DoorRemoteBoundUserInterface

* refactor: fix multiple invocation for  TryToggleDoor inside DoorRemoteSystem on prediction

* refactor: extracted sprites and loc strings into prototype for cleaner code. Currently selected mode now have different background.

* refactor: changed hover selected color to recommmended

* refactor: reuse stylenano colors!

* review

* refactor: remove StyleNano reference

* refactor: revert removal of item status for door remote

* refactor: fix status control misprediction

* refactor: remove invalid comments, rename client DoorRemoteSystem comp after handle method

* refactor: fix DoorRemoteStatusControl not displaying status on entity pickup

---------

Co-authored-by: pa.pecherskij <pa.pecherskij@interfax.ru>
Co-authored-by: Eoin Mcloughlin <helloworld@eoinrul.es>
Co-authored-by: slarticodefast <161409025+slarticodefast@users.noreply.github.com>
This commit is contained in:
Fildrance
2025-10-21 15:16:38 +03:00
committed by GitHub
parent 3bbc1e1dde
commit 0a0806ac78
8 changed files with 281 additions and 157 deletions

View File

@@ -1,4 +1,4 @@
using Content.Client.Stylesheets;
using Content.Client.Stylesheets.Palette;
using Content.Client.UserInterface.Controls;
using Content.Shared.Changeling.Components;
using Content.Shared.Changeling.Systems;
@@ -11,8 +11,8 @@ namespace Content.Client.Changeling.UI;
public sealed partial class ChangelingTransformBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{
private SimpleRadialMenu? _menu;
private static readonly Color SelectedOptionBackground = StyleNano.ButtonColorGoodDefault.WithAlpha(128);
private static readonly Color SelectedOptionHoverBackground = StyleNano.ButtonColorGoodHovered.WithAlpha(128);
private static readonly Color SelectedOptionBackground = Palettes.Green.Element.WithAlpha(128);
private static readonly Color SelectedOptionHoverBackground = Palettes.Green.HoveredElement.WithAlpha(128);
protected override void Open()
{

View File

@@ -1,9 +1,9 @@
using Content.Client.Remote.UI;
using Content.Client.Items;
using Content.Shared.Remotes.EntitySystems;
using Content.Client.Remotes.UI;
using Content.Shared.Remotes.Components;
using Content.Shared.Remotes.EntitySystems;
namespace Content.Client.Remotes.EntitySystems;
namespace Content.Client.Remotes.Systems;
public sealed class DoorRemoteSystem : SharedDoorRemoteSystem
{
@@ -12,5 +12,11 @@ public sealed class DoorRemoteSystem : SharedDoorRemoteSystem
base.Initialize();
Subs.ItemStatus<DoorRemoteComponent>(ent => new DoorRemoteStatusControl(ent));
SubscribeLocalEvent<DoorRemoteComponent, AfterAutoHandleStateEvent>(OnAutoHandleState);
}
private void OnAutoHandleState(Entity<DoorRemoteComponent> ent, ref AfterAutoHandleStateEvent args)
{
ent.Comp.IsStatusControlUpdateRequired = true;
}
}

View File

@@ -0,0 +1,63 @@
using Content.Client.Stylesheets.Palette;
using Content.Client.UserInterface.Controls;
using Content.Shared.Remotes.Components;
using Content.Shared.Remotes.EntitySystems;
using Robust.Client.UserInterface;
namespace Content.Client.Remotes.UI;
public sealed class DoorRemoteBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey)
{
private static readonly Color SelectedOptionColor = Palettes.Green.Element.WithAlpha(128);
private static readonly Color SelectedOptionHoverColor = Palettes.Green.HoveredElement.WithAlpha(128);
private SimpleRadialMenu? _menu;
protected override void Open()
{
base.Open();
if (!EntMan.TryGetComponent<DoorRemoteComponent>(Owner, out var remote))
return;
_menu = this.CreateWindow<SimpleRadialMenu>();
var models = CreateButtons(remote.Mode, remote.Options);
_menu.SetButtons(models);
_menu.OpenOverMouseScreenPosition();
}
private IEnumerable<RadialMenuOptionBase> CreateButtons(OperatingMode selectedMode, List<DoorRemoteModeInfo> modeOptions)
{
var options = new List<RadialMenuOptionBase>();
for (var i = 0; i < modeOptions.Count; i++)
{
var modeOption = modeOptions[i];
Color? optionCustomColor = null;
Color? optionHoverCustomColor = null;
if (modeOption.Mode == selectedMode)
{
optionCustomColor = SelectedOptionColor;
optionHoverCustomColor = SelectedOptionHoverColor;
}
var option = new RadialMenuActionOption<OperatingMode>(HandleRadialMenuClick, modeOption.Mode)
{
IconSpecifier = RadialMenuIconSpecifier.With(modeOption.Icon),
ToolTip = Loc.GetString(modeOption.Tooltip),
BackgroundColor = optionCustomColor,
HoverBackgroundColor = optionHoverCustomColor
};
options.Add(option);
}
return options;
}
private void HandleRadialMenuClick(OperatingMode mode)
{
var msg = new DoorRemoteModeChangeMessage { Mode = mode };
SendPredictedMessage(msg);
}
}

View File

@@ -1,39 +1,36 @@
using Content.Client.Message;
using Content.Client.Stylesheets;
using Content.Shared.Remotes.Components;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface;
using Robust.Shared.Timing;
namespace Content.Client.Remote.UI;
namespace Content.Client.Remotes.UI;
public sealed class DoorRemoteStatusControl : Control
public sealed class DoorRemoteStatusControl(Entity<DoorRemoteComponent> ent) : Control
{
private readonly Entity<DoorRemoteComponent> _entity;
private readonly RichTextLabel _label;
// set to toggle bolts initially just so that it updates on first pickup of remote
private OperatingMode PrevOperatingMode = OperatingMode.placeholderForUiUpdates;
public DoorRemoteStatusControl(Entity<DoorRemoteComponent> entity)
{
_entity = entity;
_label = new RichTextLabel { StyleClasses = { StyleClass.ItemStatus } };
AddChild(_label);
}
private RichTextLabel? _label;
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// only updates the UI if any of the details are different than they previously were
if (PrevOperatingMode == _entity.Comp.Mode)
if (_label == null)
{
_label = new RichTextLabel { StyleClasses = { StyleClass.ItemStatus } };
AddChild(_label);
}
else if (!ent.Comp.IsStatusControlUpdateRequired)
return;
PrevOperatingMode = _entity.Comp.Mode;
UpdateLabel(_label);
// Update current volume and injector state
var modeStringLocalized = Loc.GetString(_entity.Comp.Mode switch
ent.Comp.IsStatusControlUpdateRequired = false;
}
private void UpdateLabel(RichTextLabel label)
{
var modeStringLocalized = Loc.GetString(ent.Comp.Mode switch
{
OperatingMode.OpenClose => "door-remote-open-close-text",
OperatingMode.ToggleBolts => "door-remote-toggle-bolt-text",
@@ -41,6 +38,6 @@ public sealed class DoorRemoteStatusControl : Control
_ => "door-remote-invalid-text"
});
_label.SetMarkup(Loc.GetString("door-remote-mode-label", ("modeString", modeStringLocalized)));
label.SetMarkup(Loc.GetString("door-remote-mode-label", ("modeString", modeStringLocalized)));
}
}

View File

@@ -1,108 +1,5 @@
using Content.Server.Administration.Logs;
using Content.Server.Doors.Systems;
using Content.Server.Power.EntitySystems;
using Content.Shared.Access.Components;
using Content.Shared.Database;
using Content.Shared.Doors.Components;
using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Remotes.Components;
using Content.Shared.Remotes.EntitySystems;
namespace Content.Shared.Remotes
{
public sealed class DoorRemoteSystem : SharedDoorRemoteSystem
{
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
[Dependency] private readonly AirlockSystem _airlock = default!;
[Dependency] private readonly DoorSystem _doorSystem = default!;
[Dependency] private readonly ExamineSystemShared _examine = default!;
namespace Content.Server.Remotes;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<DoorRemoteComponent, BeforeRangedInteractEvent>(OnBeforeInteract);
}
private void OnBeforeInteract(Entity<DoorRemoteComponent> entity, ref BeforeRangedInteractEvent args)
{
bool isAirlock = TryComp<AirlockComponent>(args.Target, out var airlockComp);
if (args.Handled
|| args.Target == null
|| !TryComp<DoorComponent>(args.Target, out var doorComp) // If it isn't a door we don't use it
// Only able to control doors if they are within your vision and within your max range.
// Not affected by mobs or machines anymore.
|| !_examine.InRangeUnOccluded(args.User,
args.Target.Value,
SharedInteractionSystem.MaxRaycastRange,
null))
{
return;
}
args.Handled = true;
if (!this.IsPowered(args.Target.Value, EntityManager))
{
Popup.PopupEntity(Loc.GetString("door-remote-no-power"), args.User, args.User);
return;
}
var accessTarget = args.Used;
// This covers the accesses the REMOTE has, and is not effected by the user's ID card.
if (entity.Comp.IncludeUserAccess) // Allows some door remotes to inherit the user's access.
{
accessTarget = args.User;
// This covers the accesses the USER has, which always includes the remote's access since holding a remote acts like holding an ID card.
}
if (TryComp<AccessReaderComponent>(args.Target, out var accessComponent)
&& !_doorSystem.HasAccess(args.Target.Value, accessTarget, doorComp, accessComponent))
{
if (isAirlock)
_doorSystem.Deny(args.Target.Value, doorComp, accessTarget);
Popup.PopupEntity(Loc.GetString("door-remote-denied"), args.User, args.User);
return;
}
switch (entity.Comp.Mode)
{
case OperatingMode.OpenClose:
if (_doorSystem.TryToggleDoor(args.Target.Value, doorComp, accessTarget))
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)}: {doorComp.State}");
break;
case OperatingMode.ToggleBolts:
if (TryComp<DoorBoltComponent>(args.Target, out var boltsComp))
{
if (!boltsComp.BoltWireCut)
{
_doorSystem.SetBoltsDown((args.Target.Value, boltsComp), !boltsComp.BoltsDown, accessTarget);
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to {(boltsComp.BoltsDown ? "" : "un")}bolt it");
}
}
break;
case OperatingMode.ToggleEmergencyAccess:
if (airlockComp != null)
{
_airlock.SetEmergencyAccess((args.Target.Value, airlockComp), !airlockComp.EmergencyAccess);
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to set emergency access {(airlockComp.EmergencyAccess ? "on" : "off")}");
}
break;
default:
throw new InvalidOperationException(
$"{nameof(DoorRemoteComponent)} had invalid mode {entity.Comp.Mode}");
}
}
}
}
public sealed class DoorRemoteSystem : SharedDoorRemoteSystem;

View File

@@ -1,26 +1,74 @@
using Robust.Shared.GameStates;
using Robust.Shared.Utility;
using Robust.Shared.Serialization;
namespace Content.Shared.Remotes.Components;
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
/// <summary>
/// Component for door remote devices, that allow you to control doors from a distance.
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState(true)]
public sealed partial class DoorRemoteComponent : Component
{
[AutoNetworkedField]
[DataField]
/// <summary>
/// Currently selected mode. The mode dictates what device would do upon
/// interaction with door.
/// </summary>
[DataField, AutoNetworkedField]
public OperatingMode Mode = OperatingMode.OpenClose;
/// <summary>
/// Does the remote allow the user to manipulate doors that they have access to, even if the remote itself does not?
/// Modes with metadata that could be displayed in the device mode change menu.
/// </summary>
[AutoNetworkedField]
[DataField]
public bool IncludeUserAccess = false;
public List<DoorRemoteModeInfo> Options;
/// <summary>
/// Does the remote allow the user to control doors that they have access to, even if the remote itself does not?
/// </summary>
[DataField, AutoNetworkedField]
public bool IncludeUserAccess;
/// <summary>
/// Client-side only field for checking if StatusControl requires update.
/// </summary>
/// <remarks>
/// StatusControl is updated inside loop and cannot understand
/// when state is of component it looks for is restored, thus mispredicting. To avoid that,
/// client-side system basically controls behaviour of StatusControl updates using this field.
/// </remarks>
public bool IsStatusControlUpdateRequired;
}
/// <summary>
/// Remote door device mode with data that is required for menu display.
/// </summary>
[DataDefinition]
public sealed partial class DoorRemoteModeInfo
{
/// <summary>
/// Icon that should represent the option in the radial menu.
/// </summary>
[DataField(required: true)]
public SpriteSpecifier Icon = default!;
/// <summary>
/// Tooltip describing the option in the radial menu.
/// </summary>
[DataField(required: true)]
public LocId Tooltip;
/// <summary>
/// Mode option.
/// </summary>
[DataField(required: true)]
public OperatingMode Mode;
}
[Serializable, NetSerializable]
public enum OperatingMode : byte
{
OpenClose,
ToggleBolts,
ToggleEmergencyAccess,
placeholderForUiUpdates
ToggleEmergencyAccess
}

View File

@@ -1,44 +1,134 @@
using Content.Shared.Access.Components;
using Content.Shared.Administration.Logs;
using Content.Shared.Database;
using Content.Shared.Doors.Components;
using Content.Shared.Doors.Systems;
using Content.Shared.Examine;
using Content.Shared.Interaction;
using Content.Shared.Popups;
using Content.Shared.Interaction.Events;
using Content.Shared.Power.EntitySystems;
using Content.Shared.Remotes.Components;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Remotes.EntitySystems;
public abstract class SharedDoorRemoteSystem : EntitySystem
{
[Dependency] protected readonly SharedPopupSystem Popup = default!;
[Dependency] private readonly SharedAirlockSystem _airlock = default!;
[Dependency] private readonly SharedDoorSystem _doorSystem = default!;
[Dependency] private readonly ExamineSystemShared _examine = default!;
[Dependency] private readonly SharedPowerReceiverSystem _powerReceiver = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] protected readonly IGameTiming Timing = default!;
public override void Initialize()
{
SubscribeLocalEvent<DoorRemoteComponent, UseInHandEvent>(OnInHandActivation);
SubscribeLocalEvent<DoorRemoteComponent, DoorRemoteModeChangeMessage>(OnDoorRemoteModeChange);
SubscribeLocalEvent<DoorRemoteComponent, BeforeRangedInteractEvent>(OnBeforeInteract);
}
private void OnInHandActivation(Entity<DoorRemoteComponent> entity, ref UseInHandEvent args)
private void OnDoorRemoteModeChange(Entity<DoorRemoteComponent> ent, ref DoorRemoteModeChangeMessage args)
{
string switchMessageId;
ent.Comp.Mode = args.Mode;
Dirty(ent);
}
private void OnBeforeInteract(Entity<DoorRemoteComponent> entity, ref BeforeRangedInteractEvent args)
{
if (!Timing.IsFirstTimePredicted)
return;
var isAirlock = TryComp<AirlockComponent>(args.Target, out var airlockComp);
if (args.Handled
|| args.Target == null
|| !TryComp<DoorComponent>(args.Target, out var doorComp) // If it isn't a door we don't use it
// Only able to control doors if they are within your vision and within your max range.
// Not affected by mobs or machines anymore.
|| !_examine.InRangeUnOccluded(args.User,
args.Target.Value,
SharedInteractionSystem.MaxRaycastRange,
null))
{
return;
}
args.Handled = true;
if (!_powerReceiver.IsPowered(args.Target.Value))
{
_popup.PopupClient(Loc.GetString("door-remote-no-power"), args.User, args.User);
return;
}
var accessTarget = args.Used;
// This covers the accesses the REMOTE has, and is not effected by the user's ID card.
if (entity.Comp.IncludeUserAccess) // Allows some door remotes to inherit the user's access.
{
accessTarget = args.User;
// This covers the accesses the USER has, which always includes the remote's access since holding a remote acts like holding an ID card.
}
if (TryComp<AccessReaderComponent>(args.Target, out var accessComponent)
&& !_doorSystem.HasAccess(args.Target.Value, accessTarget, doorComp, accessComponent))
{
if (isAirlock)
_doorSystem.Deny(args.Target.Value, doorComp, user: args.User, predicted: true);
_popup.PopupClient(Loc.GetString("door-remote-denied"), args.User, args.User);
return;
}
switch (entity.Comp.Mode)
{
case OperatingMode.OpenClose:
entity.Comp.Mode = OperatingMode.ToggleBolts;
switchMessageId = "door-remote-switch-state-toggle-bolts";
if (_doorSystem.TryToggleDoor(args.Target.Value, doorComp, user: args.User, predicted: true))
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)}: {doorComp.State}");
break;
// Skip toggle bolts mode and move on from there (to emergency access)
case OperatingMode.ToggleBolts:
entity.Comp.Mode = OperatingMode.ToggleEmergencyAccess;
switchMessageId = "door-remote-switch-state-toggle-emergency-access";
break;
if (TryComp<DoorBoltComponent>(args.Target, out var boltsComp))
{
if (!boltsComp.BoltWireCut)
{
_doorSystem.SetBoltsDown((args.Target.Value, boltsComp), !boltsComp.BoltsDown, user: args.User, predicted: true);
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to {(boltsComp.BoltsDown ? "" : "un")}bolt it");
}
}
// Skip ToggleEmergencyAccess mode and move on from there (to door toggle)
break;
case OperatingMode.ToggleEmergencyAccess:
entity.Comp.Mode = OperatingMode.OpenClose;
switchMessageId = "door-remote-switch-state-open-close";
if (airlockComp != null)
{
_airlock.SetEmergencyAccess((args.Target.Value, airlockComp), !airlockComp.EmergencyAccess, user: args.User, predicted: true);
_adminLogger.Add(LogType.Action,
LogImpact.Medium,
$"{ToPrettyString(args.User):player} used {ToPrettyString(args.Used)} on {ToPrettyString(args.Target.Value)} to set emergency access {(airlockComp.EmergencyAccess ? "on" : "off")}");
}
break;
default:
throw new InvalidOperationException(
$"{nameof(DoorRemoteComponent)} had invalid mode {entity.Comp.Mode}");
}
Dirty(entity);
Popup.PopupClient(Loc.GetString(switchMessageId), entity, args.User);
}
}
[Serializable, NetSerializable]
public sealed class DoorRemoteModeChangeMessage : BoundUserInterfaceMessage
{
public OperatingMode Mode;
}
[Serializable, NetSerializable]
public enum DoorRemoteUiKey : byte
{
Key
}

View File

@@ -11,8 +11,31 @@
storedRotation: -90
- type: Access
- type: DoorRemote
options:
- mode: OpenClose
tooltip: door-remote-open-close-text
icon:
sprite: /Textures/Structures/Doors/Airlocks/Standard/basic.rsi
state: assembly
- mode: ToggleBolts
tooltip: door-remote-toggle-bolt-text
icon:
sprite: /Textures/Interface/Actions/actions_ai.rsi
state: bolt_door
- mode: ToggleEmergencyAccess
tooltip: door-remote-emergency-access-text
icon:
sprite: /Textures/Interface/Actions/actions_ai.rsi
state: emergency_on
- type: StealTarget
stealGroup: DoorRemote
- type: ActivatableUI
inHandsOnly: true
key: enum.DoorRemoteUiKey.Key
- type: UserInterface
interfaces:
enum.DoorRemoteUiKey.Key:
type: DoorRemoteBoundUserInterface
- type: entity
parent: [DoorRemoteDefault, BaseCommandContraband]