diff --git a/Content.Client/Medical/CrewMonitoring/CrewMonitoringBoundUserInterface.cs b/Content.Client/Medical/CrewMonitoring/CrewMonitoringBoundUserInterface.cs index 550d0fcdfe8d1..ace163b0ce396 100644 --- a/Content.Client/Medical/CrewMonitoring/CrewMonitoringBoundUserInterface.cs +++ b/Content.Client/Medical/CrewMonitoring/CrewMonitoringBoundUserInterface.cs @@ -1,21 +1,30 @@ using Content.Shared.Medical.CrewMonitoring; +using Content.Shared.Silicons.StationAi; using Robust.Client.UserInterface; +using Robust.Shared.Map; +using Robust.Shared.Player; namespace Content.Client.Medical.CrewMonitoring; public sealed class CrewMonitoringBoundUserInterface : BoundUserInterface { + [Dependency] private readonly ISharedPlayerManager _playerManager = default!; + [ViewVariables] private CrewMonitoringWindow? _menu; public CrewMonitoringBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey) { + IoCManager.InjectDependencies(this); } protected override void Open() { base.Open(); + if (_menu != null) + _menu.MapClicked -= OnMapClicked; + EntityUid? gridUid = null; var stationName = string.Empty; @@ -31,6 +40,7 @@ protected override void Open() _menu = this.CreateWindow(); _menu.Set(stationName, gridUid); + _menu.MapClicked += OnMapClicked; } protected override void UpdateState(BoundUserInterfaceState state) @@ -45,4 +55,29 @@ protected override void UpdateState(BoundUserInterfaceState state) break; } } + + private void OnMapClicked(EntityCoordinates coordinates) + { + var local = _playerManager.LocalEntity; + + if (local is null || !EntMan.HasComponent(local.Value)) + return; + + var netCoordinates = EntMan.GetNetCoordinates(coordinates); + SendMessage(new CrewMonitoringWarpRequestMessage(netCoordinates)); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + if (_menu != null) + { + _menu.MapClicked -= OnMapClicked; + _menu = null; + } + } + + base.Dispose(disposing); + } } diff --git a/Content.Client/Medical/CrewMonitoring/CrewMonitoringNavMapControl.cs b/Content.Client/Medical/CrewMonitoring/CrewMonitoringNavMapControl.cs index 1a68099de0207..b15553fc60a1e 100644 --- a/Content.Client/Medical/CrewMonitoring/CrewMonitoringNavMapControl.cs +++ b/Content.Client/Medical/CrewMonitoring/CrewMonitoringNavMapControl.cs @@ -2,6 +2,8 @@ using Robust.Client.Graphics; using Robust.Client.UserInterface.Controls; using Robust.Shared.Timing; +using Robust.Shared.Map; +using Robust.Shared.Localization; namespace Content.Client.Medical.CrewMonitoring; @@ -9,6 +11,7 @@ public sealed partial class CrewMonitoringNavMapControl : NavMapControl { public NetEntity? Focus; public Dictionary LocalizedNames = new(); + public event Action? MapClicked; private Label _trackedEntityLabel; private PanelContainer _trackedEntityPanel; @@ -42,6 +45,7 @@ public CrewMonitoringNavMapControl() : base() _trackedEntityPanel.AddChild(_trackedEntityLabel); this.AddChild(_trackedEntityPanel); + MapClickedAction += coords => MapClicked?.Invoke(coords); } protected override void FrameUpdate(FrameEventArgs args) @@ -62,7 +66,7 @@ protected override void FrameUpdate(FrameEventArgs args) continue; if (!LocalizedNames.TryGetValue(netEntity, out var name)) - name = Loc.GetString("navmap-unknown-entity"); + name = Loc.GetString("navmap-unknown-target"); var message = name + "\n" + Loc.GetString("navmap-location", ("x", MathF.Round(blip.Coordinates.X)), diff --git a/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml.cs b/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml.cs index 7b69d08267730..3eedd1ac88717 100644 --- a/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml.cs +++ b/Content.Client/Medical/CrewMonitoring/CrewMonitoringWindow.xaml.cs @@ -31,6 +31,7 @@ public sealed partial class CrewMonitoringWindow : FancyWindow private NetEntity? _trackedEntity; private bool _tryToScrollToListFocus; private Texture? _blipTexture; + public event Action? MapClicked; public CrewMonitoringWindow() { @@ -41,6 +42,7 @@ public CrewMonitoringWindow() _spriteSystem = _entManager.System(); NavMap.TrackedEntitySelectedAction += SetTrackedEntityFromNavMap; + NavMap.MapClicked += OnNavMapClicked; } public void Set(string stationName, EntityUid? mapUid) @@ -354,6 +356,22 @@ private void SetTrackedEntityFromNavMap(NetEntity? netEntity) UpdateSensorsTable(_trackedEntity, prevTrackedEntity); } + private void OnNavMapClicked(EntityCoordinates coordinates) + { + MapClicked?.Invoke(coordinates); + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + NavMap.TrackedEntitySelectedAction -= SetTrackedEntityFromNavMap; + NavMap.MapClicked -= OnNavMapClicked; + } + + base.Dispose(disposing); + } + private void UpdateSensorsTable(NetEntity? currTrackedEntity, NetEntity? prevTrackedEntity) { foreach (var sensor in SensorsTable.Children) diff --git a/Content.Client/Pinpointer/UI/NavMapControl.cs b/Content.Client/Pinpointer/UI/NavMapControl.cs index b774b7d8b566e..de74ca751b477 100644 --- a/Content.Client/Pinpointer/UI/NavMapControl.cs +++ b/Content.Client/Pinpointer/UI/NavMapControl.cs @@ -39,6 +39,7 @@ public partial class NavMapControl : MapGridControl // Actions public event Action? TrackedEntitySelectedAction; + public event Action? MapClickedAction; public event Action? PostWallDrawingAction; // Tracked data @@ -203,10 +204,7 @@ protected override void KeyBindUp(GUIBoundKeyEventArgs args) if (args.Function == EngineKeyFunctions.UIClick) { - if (TrackedEntitySelectedAction == null) - return; - - if (_xform == null || _physics == null || TrackedEntities.Count == 0) + if (_xform == null || _physics == null) return; // If the cursor has moved a significant distance, exit @@ -221,28 +219,50 @@ protected override void KeyBindUp(GUIBoundKeyEventArgs args) var unscaledPosition = (localPosition - MidPointVector) / MinimapScale; var worldPosition = Vector2.Transform(new Vector2(unscaledPosition.X, -unscaledPosition.Y) + offset, _transformSystem.GetWorldMatrix(_xform)); - // Find closest tracked entity in range - var closestEntity = NetEntity.Invalid; - var closestDistance = float.PositiveInfinity; + EntityCoordinates? clickCoords = null; + if (MapClickedAction != null) + { + var mapCoordinates = new MapCoordinates(worldPosition, _xform.MapID); + var coordinates = _transformSystem.ToCoordinates(mapCoordinates); + + if (_transformSystem.IsValid(coordinates)) + clickCoords = coordinates; + } + + var invokedSelection = false; - foreach ((var currentEntity, var blip) in TrackedEntities) + if (TrackedEntitySelectedAction != null && TrackedEntities.Count != 0) { - if (!blip.Selectable) - continue; + // Find closest tracked entity in range + var closestEntity = NetEntity.Invalid; + var closestDistance = float.PositiveInfinity; + + foreach ((var currentEntity, var blip) in TrackedEntities) + { + if (!blip.Selectable) + continue; - var currentDistance = (_transformSystem.ToMapCoordinates(blip.Coordinates).Position - worldPosition).Length(); + var currentDistance = (_transformSystem.ToMapCoordinates(blip.Coordinates).Position - worldPosition).Length(); - if (closestDistance < currentDistance || currentDistance * MinimapScale > MaxSelectableDistance) - continue; + if (closestDistance < currentDistance || currentDistance * MinimapScale > MaxSelectableDistance) + continue; + + closestEntity = currentEntity; + closestDistance = currentDistance; + } - closestEntity = currentEntity; - closestDistance = currentDistance; + if (closestEntity.IsValid() && closestDistance <= MaxSelectableDistance) + { + TrackedEntitySelectedAction?.Invoke(closestEntity); + invokedSelection = true; + } } - if (closestDistance > MaxSelectableDistance || !closestEntity.IsValid()) - return; + if (clickCoords != null) + MapClickedAction?.Invoke(clickCoords.Value); - TrackedEntitySelectedAction.Invoke(closestEntity); + if (!invokedSelection && clickCoords == null) + return; } else if (args.Function == EngineKeyFunctions.UIRightClick) diff --git a/Content.Client/Silicons/StationAi/StationAiSystem.Warp.cs b/Content.Client/Silicons/StationAi/StationAiSystem.Warp.cs new file mode 100644 index 0000000000000..a1587560b7127 --- /dev/null +++ b/Content.Client/Silicons/StationAi/StationAiSystem.Warp.cs @@ -0,0 +1,80 @@ +using System; +using Content.Shared.Silicons.StationAi; +using Robust.Client.Player; +using Robust.Shared.Player; + +namespace Content.Client.Silicons.StationAi; + +public sealed partial class StationAiSystem +{ + private StationAiWarpWindow? _warpWindow; + + private void InitializeWarp() + { + SubscribeNetworkEvent(OnWarpTargets); + } + + private void ShutdownWarp() + { + if (_warpWindow != null) + { + _warpWindow.TargetSelected -= OnWarpTargetSelected; + _warpWindow.OnClose -= OnWarpWindowClosed; + _warpWindow.Close(); + _warpWindow = null; + } + + } + + protected override void OnOpenWarpAction(Entity ent, ref StationAiOpenWarpActionEvent args) + { + base.OnOpenWarpAction(ent, ref args); + + if (_player.LocalEntity != ent.Owner) + return; + + EnsureWarpWindow(); + _warpWindow?.SetLoading(true); + RaiseNetworkEvent(new StationAiWarpRequestEvent()); + } + + private void OnWarpTargets(StationAiWarpTargetsEvent msg, EntitySessionEventArgs args) + { + if (_player.LocalEntity is not { } local || !HasComp(local)) + return; + + EnsureWarpWindow(); + _warpWindow?.SetTargets(msg.Targets); + } + + private void OnWarpTargetSelected(StationAiWarpTarget target) + { + RaiseNetworkEvent(new StationAiWarpToTargetEvent(target.Target)); + _warpWindow?.Close(); + } + + private void OnWarpWindowClosed() + { + if (_warpWindow == null) + return; + + _warpWindow.TargetSelected -= OnWarpTargetSelected; + _warpWindow.OnClose -= OnWarpWindowClosed; + _warpWindow = null; + } + + private void EnsureWarpWindow() + { + if (_warpWindow != null) + { + if (!_warpWindow.IsOpen) + _warpWindow.OpenCentered(); + return; + } + + _warpWindow = new StationAiWarpWindow(); + _warpWindow.TargetSelected += OnWarpTargetSelected; + _warpWindow.OnClose += OnWarpWindowClosed; + _warpWindow.OpenCentered(); + } +} diff --git a/Content.Client/Silicons/StationAi/StationAiSystem.cs b/Content.Client/Silicons/StationAi/StationAiSystem.cs index d4a8b9dbd816f..c578d02d30413 100644 --- a/Content.Client/Silicons/StationAi/StationAiSystem.cs +++ b/Content.Client/Silicons/StationAi/StationAiSystem.cs @@ -20,6 +20,7 @@ public override void Initialize() base.Initialize(); InitializeAirlock(); InitializePowerToggle(); + InitializeWarp(); SubscribeLocalEvent(OnAiAttached); SubscribeLocalEvent(OnAiDetached); @@ -90,6 +91,7 @@ private void OnAppearanceChange(Entity entity, ref Appea public override void Shutdown() { base.Shutdown(); + ShutdownWarp(); _overlayMgr.RemoveOverlay(); } } diff --git a/Content.Client/Silicons/StationAi/StationAiWarpWindow.cs b/Content.Client/Silicons/StationAi/StationAiWarpWindow.cs new file mode 100644 index 0000000000000..c6de916c2cd61 --- /dev/null +++ b/Content.Client/Silicons/StationAi/StationAiWarpWindow.cs @@ -0,0 +1,152 @@ +using System; +using System.Linq; +using System.Numerics; +using Content.Shared.Silicons.StationAi; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.CustomControls; +using Robust.Shared.Localization; +using Robust.Shared.Maths; + +namespace Content.Client.Silicons.StationAi; + +public sealed class StationAiWarpWindow : DefaultWindow +{ + public event Action? TargetSelected; + + private readonly LineEdit _searchBar; + private readonly BoxContainer _listContainer; + + private List _targets = new(); + private string _searchText = string.Empty; + private bool _isLoading; + + public StationAiWarpWindow() + { + Title = Loc.GetString("station-ai-warp-window-title"); + MinSize = new Vector2(380f, 420f); + + var root = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + HorizontalExpand = true, + VerticalExpand = true, + SeparationOverride = 6, + }; + + _searchBar = new LineEdit + { + PlaceHolder = Loc.GetString("station-ai-warp-search-placeholder"), + }; + _searchBar.OnTextChanged += OnSearchTextChanged; + root.AddChild(_searchBar); + + _listContainer = new BoxContainer + { + Orientation = BoxContainer.LayoutOrientation.Vertical, + HorizontalExpand = true, + VerticalExpand = true, + SeparationOverride = 2, + }; + + var scroll = new ScrollContainer + { + VerticalExpand = true, + HorizontalExpand = true, + }; + scroll.AddChild(_listContainer); + root.AddChild(scroll); + + Contents.AddChild(root); + + PopulateList(); + } + + public void SetLoading(bool loading) + { + _isLoading = loading; + if (loading) + _targets.Clear(); + + PopulateList(); + } + + public void SetTargets(IEnumerable targets) + { + var nameComparer = Comparer.Create((x, y) => string.Compare(x, y, StringComparison.CurrentCultureIgnoreCase)); + + _targets = targets + .OrderBy(t => t.Type) + .ThenBy(t => t.DisplayName, nameComparer) + .ToList(); + _isLoading = false; + PopulateList(); + } + + private void PopulateList() + { + _listContainer.DisposeAllChildren(); + + if (_isLoading) + { + _listContainer.AddChild(new Label + { + Text = Loc.GetString("station-ai-warp-loading"), + HorizontalAlignment = HAlignment.Center, + Margin = new Thickness(0, 8), + }); + return; + } + + var filtered = string.IsNullOrWhiteSpace(_searchText) + ? _targets + : _targets.Where(t => t.DisplayName.Contains(_searchText, StringComparison.OrdinalIgnoreCase)).ToList(); + + if (filtered.Count == 0) + { + _listContainer.AddChild(new Label + { + Text = Loc.GetString("station-ai-warp-no-results"), + HorizontalAlignment = HAlignment.Center, + Margin = new Thickness(0, 8), + }); + return; + } + + StationAiWarpTargetType? currentSection = null; + + foreach (var target in filtered) + { + if (currentSection != target.Type) + { + currentSection = target.Type; + var headerText = currentSection == StationAiWarpTargetType.Crew + ? Loc.GetString("station-ai-warp-section-crew") + : Loc.GetString("station-ai-warp-section-locations"); + + _listContainer.AddChild(new Label + { + Text = headerText, + Margin = new Thickness(0, 6, 0, 2), + Modulate = Color.LightSkyBlue, + }); + } + + var capturedTarget = target; + var button = new Button + { + Text = capturedTarget.DisplayName, + HorizontalAlignment = HAlignment.Stretch, + ClipText = true, + }; + + button.OnPressed += _ => TargetSelected?.Invoke(capturedTarget); + _listContainer.AddChild(button); + } + } + + private void OnSearchTextChanged(LineEdit.LineEditEventArgs args) + { + _searchText = args.Text; + PopulateList(); + } +} diff --git a/Content.Server/Medical/CrewMonitoring/CrewMonitoringConsoleSystem.cs b/Content.Server/Medical/CrewMonitoring/CrewMonitoringConsoleSystem.cs index 0e1d27a3c5562..38da42ea497a3 100644 --- a/Content.Server/Medical/CrewMonitoring/CrewMonitoringConsoleSystem.cs +++ b/Content.Server/Medical/CrewMonitoring/CrewMonitoringConsoleSystem.cs @@ -1,3 +1,4 @@ +using System; using System.Linq; using Content.Server.DeviceNetwork; using Content.Server.DeviceNetwork.Systems; @@ -7,7 +8,11 @@ using Content.Shared.Medical.CrewMonitoring; using Content.Shared.Medical.SuitSensor; using Content.Shared.Pinpointer; +using Content.Server.Silicons.StationAi; using Robust.Server.GameObjects; +using Robust.Shared.Log; +using Content.Shared.Silicons.StationAi; +using Robust.Shared.Map; namespace Content.Server.Medical.CrewMonitoring; @@ -15,6 +20,9 @@ public sealed class CrewMonitoringConsoleSystem : EntitySystem { [Dependency] private readonly PowerCellSystem _cell = default!; [Dependency] private readonly UserInterfaceSystem _uiSystem = default!; + [Dependency] private readonly StationAiSystem _stationAiSystem = default!; + + private readonly ISawmill _sawmill = Logger.GetSawmill("crewmonitoring"); public override void Initialize() { @@ -22,6 +30,7 @@ public override void Initialize() SubscribeLocalEvent(OnRemove); SubscribeLocalEvent(OnPacketReceived); SubscribeLocalEvent(OnUIOpened); + SubscribeLocalEvent(OnWarpRequest); } private void OnRemove(EntityUid uid, CrewMonitoringConsoleComponent component, ComponentRemove args) @@ -73,4 +82,35 @@ private void UpdateUserInterface(EntityUid uid, CrewMonitoringConsoleComponent? var allSensors = component.ConnectedSensors.Values.ToList(); _uiSystem.SetUiState(uid, CrewMonitoringUIKey.Key, new CrewMonitoringState(allSensors)); } + + private void OnWarpRequest(EntityUid uid, CrewMonitoringConsoleComponent component, ref CrewMonitoringWarpRequestMessage args) + { + if (args.Actor is not { Valid: true } actor) + { + _sawmill.Warning($"Received crew monitor warp request with no valid actor for console {uid}."); + return; + } + + if (!HasComp(actor)) + { + _sawmill.Warning($"Entity {Name(actor)} ({actor}) attempted to warp via crew monitor {uid} without StationAiHeldComponent."); + return; + } + + EntityCoordinates coordinates; + try + { + coordinates = GetCoordinates(args.Coordinates); + } + catch (Exception e) + { + _sawmill.Error($"Failed to convert network coordinates {args.Coordinates} for crew monitor warp request from {Name(actor)} ({actor}).", e); + return; + } + + if (!_stationAiSystem.TryWarpEyeToCoordinates(actor, coordinates)) + { + _sawmill.Debug($"Crew monitor warp request from {Name(actor)} ({actor}) to {coordinates} was rejected."); + } + } } diff --git a/Content.Server/Silicons/StationAi/StationAiSystem.cs b/Content.Server/Silicons/StationAi/StationAiSystem.cs index 4ee2a07d72b6d..a2d0d296548e1 100644 --- a/Content.Server/Silicons/StationAi/StationAiSystem.cs +++ b/Content.Server/Silicons/StationAi/StationAiSystem.cs @@ -1,8 +1,10 @@ +using System.Collections.Generic; using Content.Server.Chat.Systems; using Content.Server.Construction; using Content.Server.Destructible; using Content.Server.Ghost; using Content.Server.Mind; +using Content.Server.Medical.SuitSensors; using Content.Server.Power.Components; using Content.Server.Power.EntitySystems; using Content.Server.Roles; @@ -16,8 +18,13 @@ using Content.Shared.Destructible; using Content.Shared.DeviceNetwork.Components; using Content.Shared.DoAfter; +using Content.Shared.Follower; +using Content.Shared.Follower.Components; using Content.Shared.Mobs; +using Content.Shared.Mobs.Components; using Content.Shared.Mobs.Systems; +using Content.Shared.Medical.SuitSensor; +using Content.Shared.Medical.SuitSensors; using Content.Shared.Popups; using Content.Shared.Power; using Content.Shared.Power.Components; @@ -28,9 +35,13 @@ using Content.Shared.StationAi; using Content.Shared.Turrets; using Content.Shared.Weapons.Ranged.Events; +using Content.Shared.Warps; using Robust.Server.Containers; using Robust.Shared.Containers; +using Robust.Shared.Map; using Robust.Shared.Map.Components; +using Robust.Shared.Log; +using Robust.Shared.Localization; using Robust.Shared.Player; using Robust.Shared.Prototypes; using static Content.Server.Chat.Systems.ChatSystem; @@ -56,6 +67,11 @@ public sealed class StationAiSystem : SharedStationAiSystem [Dependency] private readonly IPrototypeManager _proto = default!; [Dependency] private readonly MobStateSystem _mobState = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly IMapManager _map = default!; + [Dependency] private readonly SuitSensorSystem _suitSensors = default!; + [Dependency] private readonly FollowerSystem _followerSystem = default!; + + private readonly ISawmill _warpSawmill = Logger.GetSawmill("stationai.warp"); private readonly HashSet> _stationAiCores = new(); @@ -85,6 +101,217 @@ public override void Initialize() SubscribeLocalEvent(OnExpandICChatRecipients); SubscribeLocalEvent(OnAmmoShot); + SubscribeNetworkEvent(OnStationAiWarpRequest); + SubscribeNetworkEvent(OnStationAiWarpToTarget); + } + + private void OnStationAiWarpRequest(StationAiWarpRequestEvent msg, EntitySessionEventArgs args) + { + if (args.SenderSession.AttachedEntity is not { Valid: true } actor || !HasComp(actor)) + { + _warpSawmill.Debug($"Ignoring warp target request from session {args.SenderSession.UserId} without a valid Station AI entity."); + return; + } + + if (!TryGetCore(actor, out var coreEntity) || coreEntity.Comp == null) + { + _warpSawmill.Warning($"Station AI {Name(actor)} ({actor}) requested warp targets but is not inserted into a core."); + return; + } + + var aiStation = _station.GetOwningStation(coreEntity.Owner); + var targets = new List(); + + CollectCrewWarpTargets(actor, aiStation, targets); + CollectLocationWarpTargets(actor, aiStation, coreEntity.Comp.RemoteEntity, targets); + + if (targets.Count == 0) + _warpSawmill.Debug($"No warp targets available for Station AI {Name(actor)} ({actor})."); + + RaiseNetworkEvent(new StationAiWarpTargetsEvent(targets), args.SenderSession.Channel); + } + + private void OnStationAiWarpToTarget(StationAiWarpToTargetEvent msg, EntitySessionEventArgs args) + { + if (args.SenderSession.AttachedEntity is not { Valid: true } actor || !HasComp(actor)) + { + _warpSawmill.Debug($"Ignoring warp request from session {args.SenderSession.UserId} without a valid Station AI entity."); + return; + } + + var target = GetEntity(msg.Target); + if (!Exists(target)) + { + _warpSawmill.Warning($"Station AI {Name(actor)} ({actor}) attempted to warp to missing entity {msg.Target}."); + return; + } + + if (!TryWarpEyeToEntity(actor, target)) + _warpSawmill.Debug($"Station AI {Name(actor)} ({actor}) warp to {Name(target)} ({target}) rejected by TryWarpEyeToEntity."); + } + + /// + /// Populates the warp target buffer with crew members whose suit sensors are broadcasting coordinates. + /// + private void CollectCrewWarpTargets(EntityUid actor, EntityUid? aiStation, List buffer) + { + var processed = new HashSet(); + var enumerator = EntityQueryEnumerator(); + + while (enumerator.MoveNext(out var sensorUid, out var sensor, out var xform)) + { + if (sensor.Mode != SuitSensorMode.SensorCords) + continue; + + var status = _suitSensors.GetSensorState((sensorUid, sensor, xform)); + if (status?.Coordinates == null) + continue; + + var ownerUid = GetEntity(status.OwnerUid); + if (!Exists(ownerUid)) + continue; + + if (!processed.Add(ownerUid)) + continue; + + if (aiStation is { } station) + { + var ownerStation = _station.GetOwningStation(ownerUid); + if (ownerStation != station) + continue; + } + + var display = string.IsNullOrWhiteSpace(status.Job) + ? status.Name + : $"{status.Name} ({status.Job})"; + + buffer.Add(new StationAiWarpTarget(GetNetEntity(ownerUid), display, StationAiWarpTargetType.Crew)); + } + } + + private void CollectLocationWarpTargets(EntityUid actor, EntityUid? aiStation, EntityUid? remoteEntity, List buffer) + { + var query = AllEntityQuery(); + + while (query.MoveNext(out var uid, out var warp, out var _)) + { + if (remoteEntity == uid) + { + _warpSawmill.Debug($"Skipping remote entity {uid} while building warp locations for Station AI {Name(actor)} ({actor})."); + continue; + } + + if (aiStation is { } station) + { + var warpStation = _station.GetOwningStation(uid); + if (warpStation != station) + { + _warpSawmill.Debug($"Skipping warp point {Name(uid)} ({uid}) outside AI station {station}."); + continue; + } + } + + var name = warp.Location ?? Name(uid); + buffer.Add(new StationAiWarpTarget(GetNetEntity(uid), name, StationAiWarpTargetType.Location)); + } + } + + public bool TryWarpEyeToCoordinates(EntityUid user, EntityCoordinates coordinates, bool popupOnFailure = true) + { + bool Fail() + { + if (popupOnFailure) + _popups.PopupClient(Loc.GetString("ai-device-not-responding"), user, PopupType.MediumCaution); + + return false; + } + + if (!HasComp(user)) + return Fail(); + + if (!TryGetCore(user, out var coreEntity) || coreEntity.Comp == null) + return Fail(); + + var coreUid = coreEntity.Owner; + + if (!TryComp(coreUid, out var core)) + return Fail(); + + if (!core.Remote) + SwitchRemoteEntityMode((coreUid, core), true); + + if (!TryComp(coreUid, out core) || core.RemoteEntity is not { Valid: true } remoteEye) + return Fail(); + + if (!_xforms.IsValid(coordinates)) + return Fail(); + + var mapCoordinates = _xforms.ToMapCoordinates(coordinates, logError: false); + + if (mapCoordinates == MapCoordinates.Nullspace) + return Fail(); + + if (!_map.TryFindGridAt(mapCoordinates, out var gridUid, out _)) + return Fail(); + + var aiStation = _station.GetOwningStation(coreUid); + + if (aiStation != null) + { + var targetStation = _station.GetOwningStation(gridUid); + + if (targetStation != aiStation) + return Fail(); + } + + var targetCoords = _xforms.ToCoordinates((gridUid, Transform(gridUid)), mapCoordinates); + + if (!_xforms.IsValid(targetCoords)) + return Fail(); + + _xforms.SetCoordinates(remoteEye, targetCoords); + _xforms.AttachToGridOrMap(remoteEye, Transform(remoteEye)); + return true; + } + + public bool TryWarpEyeToEntity(EntityUid user, EntityUid target, bool popupOnFailure = true) + { + bool Fail() + { + if (popupOnFailure) + _popups.PopupClient(Loc.GetString("ai-device-not-responding"), user, PopupType.MediumCaution); + + return false; + } + + if (!TryGetCore(user, out var coreEntity) || coreEntity.Comp == null) + return Fail(); + + if (!TryComp(coreEntity.Owner, out var core)) + return Fail(); + + if (!core.Remote) + SwitchRemoteEntityMode((coreEntity.Owner, core), true); + + if (!TryComp(coreEntity.Owner, out core) || core.RemoteEntity is not { Valid: true } remoteEye) + return Fail(); + + var remoteXform = Transform(remoteEye); + + if ((TryComp(target, out WarpPointComponent? warp) && warp.Follow) || HasComp(target)) + { + _followerSystem.StartFollowingEntity(remoteEye, target); + return true; + } + + if (HasComp(remoteEye)) + { + var parent = remoteXform.ParentUid; + if (parent.IsValid()) + _followerSystem.StopFollowingEntity(remoteEye, parent); + } + + return TryWarpEyeToCoordinates(user, Transform(target).Coordinates, popupOnFailure); } private void AfterConstructionChangeEntity(Entity ent, ref AfterConstructionChangeEntityEvent args) diff --git a/Content.Shared/Medical/CrewMonitoring/CrewMonitoringShared.cs b/Content.Shared/Medical/CrewMonitoring/CrewMonitoringShared.cs index 5b788396787aa..278f2f1bafbf0 100644 --- a/Content.Shared/Medical/CrewMonitoring/CrewMonitoringShared.cs +++ b/Content.Shared/Medical/CrewMonitoring/CrewMonitoringShared.cs @@ -1,4 +1,5 @@ using Content.Shared.Medical.SuitSensor; +using Robust.Shared.Map; using Robust.Shared.Serialization; namespace Content.Shared.Medical.CrewMonitoring; @@ -19,3 +20,14 @@ public CrewMonitoringState(List sensors) Sensors = sensors; } } + +[Serializable, NetSerializable] +public sealed partial class CrewMonitoringWarpRequestMessage : BoundUserInterfaceMessage +{ + public NetCoordinates Coordinates; + + public CrewMonitoringWarpRequestMessage(NetCoordinates coordinates) + { + Coordinates = coordinates; + } +} diff --git a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs index 91dd61d33405f..584a6322e8ee1 100644 --- a/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs +++ b/Content.Shared/Silicons/StationAi/SharedStationAiSystem.Held.cs @@ -3,6 +3,7 @@ using Content.Shared.Interaction.Events; using Content.Shared.Popups; using Content.Shared.Verbs; +using Robust.Shared.Localization; using Robust.Shared.Serialization; using Robust.Shared.Utility; using System.Diagnostics.CodeAnalysis; @@ -27,6 +28,7 @@ private void InitializeHeld() SubscribeLocalEvent(OnHeldInteraction); SubscribeLocalEvent(OnHeldRelay); SubscribeLocalEvent(OnCoreJump); + SubscribeLocalEvent(OnOpenWarpAction); SubscribeLocalEvent(OnTryGetIdentityShortInfo); } @@ -118,6 +120,11 @@ private void OnHeldRelay(Entity ent, ref AttemptRelayAct args.Target = core.Comp?.RemoteEntity; } + protected virtual void OnOpenWarpAction(Entity ent, ref StationAiOpenWarpActionEvent args) + { + args.Handled = true; + } + private void OnRadialMessage(StationAiRadialMessage ev) { if (!TryGetEntity(ev.Entity, out var target)) diff --git a/Content.Shared/Silicons/StationAi/StationAiWarpShared.cs b/Content.Shared/Silicons/StationAi/StationAiWarpShared.cs new file mode 100644 index 0000000000000..f7ddc0a168ba0 --- /dev/null +++ b/Content.Shared/Silicons/StationAi/StationAiWarpShared.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using Robust.Shared.Network; +using Robust.Shared.Serialization; + +using Content.Shared.Actions; + +namespace Content.Shared.Silicons.StationAi; + +public sealed partial class StationAiOpenWarpActionEvent : InstantActionEvent +{ +} + + +[Serializable, NetSerializable] +public sealed partial class StationAiWarpRequestEvent : EntityEventArgs +{ +} + +[Serializable, NetSerializable] +public sealed partial class StationAiWarpTargetsEvent : EntityEventArgs +{ + public List Targets { get; } + + public StationAiWarpTargetsEvent(List targets) + { + Targets = targets; + } +} + +[Serializable, NetSerializable] +public readonly record struct StationAiWarpTarget(NetEntity Target, string DisplayName, StationAiWarpTargetType Type); + +[Serializable, NetSerializable] +public enum StationAiWarpTargetType : byte +{ + Crew, + Location +} + +[Serializable, NetSerializable] +public sealed partial class StationAiWarpToTargetEvent : EntityEventArgs +{ + public StationAiWarpToTargetEvent(NetEntity target) + { + Target = target; + } + + public NetEntity Target { get; } +} diff --git a/Resources/Locale/en-US/silicons/station-ai.ftl b/Resources/Locale/en-US/silicons/station-ai.ftl index e3451452e4654..ec7c95e136344 100644 --- a/Resources/Locale/en-US/silicons/station-ai.ftl +++ b/Resources/Locale/en-US/silicons/station-ai.ftl @@ -50,3 +50,11 @@ station-ai-hologram-male = Male appearance station-ai-hologram-face = Disembodied head station-ai-hologram-cat = Cat form station-ai-hologram-dog = Corgi form + +station-ai-warp-window-title = Warp +station-ai-warp-search-placeholder = Search crew or locations... +station-ai-warp-loading = Loading warp destinations... +station-ai-warp-no-results = No destinations found. +station-ai-warp-section-crew = Crew +station-ai-warp-section-locations = Locations +station-ai-warp-radial-tooltip = Warp diff --git a/Resources/Locale/en-US/ui/navmap.ftl b/Resources/Locale/en-US/ui/navmap.ftl index c5d7a1e61268f..da0f1915e3cba 100644 --- a/Resources/Locale/en-US/ui/navmap.ftl +++ b/Resources/Locale/en-US/ui/navmap.ftl @@ -1,5 +1,5 @@ -navmap-zoom = Zoom: {$value}x +navmap-zoom = Zoom: {$value}x navmap-recenter = Recenter navmap-toggle-beacons = Show departments navmap-location = Location: [x = {$x}, y = {$y}] -navmap-unknown-entity = Unknown +navmap-unknown-target = Unknown diff --git a/Resources/Prototypes/Actions/station_ai.yml b/Resources/Prototypes/Actions/station_ai.yml index 4dbaf07aabe8a..93298881e555c 100644 --- a/Resources/Prototypes/Actions/station_ai.yml +++ b/Resources/Prototypes/Actions/station_ai.yml @@ -14,6 +14,20 @@ - type: InstantAction event: !type:JumpToCoreEvent +- type: entity + parent: BaseAction + id: ActionAIWarp + name: Warp + description: Open a list of crew and locations to warp to. + components: + - type: Action + itemIconStyle: BigAction + icon: + sprite: Interface/Actions/actions_ai.rsi + state: warp + - type: InstantAction + event: !type:StationAiOpenWarpActionEvent + - type: entity parent: BaseAction id: ActionSurvCameraLights @@ -41,7 +55,6 @@ layer: - GhostImpassable - - type: entity parent: BaseMentalAction id: ActionAIViewLaws diff --git a/Resources/Prototypes/Entities/Mobs/Player/silicon.yml b/Resources/Prototypes/Entities/Mobs/Player/silicon.yml index a7b90be0f7a3d..1c690fcef794a 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/silicon.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/silicon.yml @@ -34,6 +34,7 @@ - type: ActionGrant actions: - ActionJumpToCore + - ActionAIWarp - ActionSurvCameraLights - ActionAIViewLaws - type: UserInterface diff --git a/Resources/Textures/Interface/Actions/actions_ai.rsi/meta.json b/Resources/Textures/Interface/Actions/actions_ai.rsi/meta.json index 434b9052e0410..ef48cf678ea48 100644 --- a/Resources/Textures/Interface/Actions/actions_ai.rsi/meta.json +++ b/Resources/Textures/Interface/Actions/actions_ai.rsi/meta.json @@ -48,6 +48,9 @@ }, { "name": "door_overcharge_off" + }, + { + "name": "warp" } ] } diff --git a/Resources/Textures/Interface/Actions/actions_ai.rsi/warp.png b/Resources/Textures/Interface/Actions/actions_ai.rsi/warp.png new file mode 100644 index 0000000000000..60c34815335d8 Binary files /dev/null and b/Resources/Textures/Interface/Actions/actions_ai.rsi/warp.png differ