diff --git a/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs b/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs
new file mode 100644
index 00000000000..076690c6007
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchAnnouncementOverlay.cs
@@ -0,0 +1,212 @@
+using System.Numerics;
+using System.Text;
+using Content.Client.Resources;
+using Robust.Client.Graphics;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Enums;
+using Robust.Shared.Timing;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Оверлей для отображения объявлений Overwatch.
+///
+public sealed class OverwatchAnnouncementOverlay : Overlay
+{
+ private const string FontPath = "/Fonts/Helvetica/Helvetica-Bold.ttf";
+ private const int TitleFontSize = 20;
+ private const int MessageFontSize = 25;
+ private const float TitleAnimationDuration = 1.5f;
+ private const float MessageAnimationDuration = 2.5f;
+ private const float AnnouncementDisplayDuration = 5f;
+
+ private readonly IResourceCache _cache;
+ private readonly IGameTiming _timing;
+
+ public override OverlaySpace Space => OverlaySpace.ScreenSpace;
+
+ private Font _titleFont = default!;
+ private Font _messageFont = default!;
+
+ private string? Title;
+ private int TitleIndex;
+ private bool TitleReverse;
+ private Vector2 TitlePosition;
+ private TimeSpan TitleCharInterval;
+ private Color TitleColor;
+ private TimeSpan _nextUpdTitle;
+
+ private string? Text;
+ private int Index;
+ private bool Reverse;
+ private Vector2 Position;
+ private TimeSpan CharInterval;
+ private Color TextColor;
+ private TimeSpan _nextUpd;
+
+ public OverwatchAnnouncementOverlay(IResourceCache cache, IGameTiming timing)
+ {
+ _cache = cache;
+ _timing = timing;
+ _titleFont = _cache.GetFont(FontPath, TitleFontSize);
+ _messageFont = _cache.GetFont(FontPath, MessageFontSize);
+ }
+
+ ///
+ /// Сбрасывает все параметры оверлея.
+ ///
+ public void Reset()
+ {
+ Title = null;
+ TitleIndex = 0;
+ TitleReverse = false;
+ TitlePosition = Vector2.Zero;
+ TitleColor = Color.White;
+ _nextUpdTitle = TimeSpan.Zero;
+
+ Text = null;
+ Index = 0;
+ Reverse = false;
+ Position = Vector2.Zero;
+ TextColor = Color.White;
+ _nextUpd = TimeSpan.Zero;
+ }
+
+ ///
+ /// Устанавливает текст объявления для отображения с эффектом печатной машинки.
+ ///
+ /// Заголовок объявления.
+ /// Текст сообщения.
+ /// Цвет текста.
+ public void SetText(string title, string message, Color color)
+ {
+ Title = title;
+ TitleIndex = 0;
+ TitleReverse = false;
+ TitlePosition = Vector2.Zero;
+ TitleColor = color;
+ _nextUpdTitle = TimeSpan.Zero;
+
+ TitleCharInterval = title.Length > 0
+ ? TimeSpan.FromSeconds(TitleAnimationDuration / title.Length)
+ : TimeSpan.Zero;
+
+ Text = message;
+ Index = 0;
+ Reverse = false;
+ Position = Vector2.Zero;
+ TextColor = color;
+ _nextUpd = TimeSpan.Zero;
+
+ CharInterval = message.Length > 0
+ ? TimeSpan.FromSeconds(MessageAnimationDuration / message.Length)
+ : TimeSpan.Zero;
+ }
+
+ ///
+ protected override void Draw(in OverlayDrawArgs args)
+ {
+ if (string.IsNullOrEmpty(Text))
+ return;
+
+ var viewport = new Vector2(args.ViewportBounds.Width, args.ViewportBounds.Height);
+
+ if (Position == Vector2.Zero)
+ Position = CalcPosition(_messageFont, Text, viewport, 180);
+
+ args.ScreenHandle.DrawString(_messageFont, Position, Text[..Index], TextColor);
+
+ if (TitlePosition == Vector2.Zero)
+ {
+ var titleSize = CalcTextSize(_titleFont, Title);
+ TitlePosition = new Vector2(Position.X, Position.Y - titleSize.Y - 10);
+ }
+
+ DrawTitle(args);
+
+ if (_nextUpd > _timing.CurTime)
+ return;
+
+ if (!Reverse && Index == Text.Length)
+ {
+ Reverse = true;
+ _nextUpd += TimeSpan.FromSeconds(AnnouncementDisplayDuration);
+ return;
+ }
+
+ if (Reverse && Index == 0)
+ {
+ Reset();
+ return;
+ }
+
+ Index = Reverse ? Index - 1 : Index + 1;
+
+ if (_nextUpd == TimeSpan.Zero)
+ _nextUpd = _timing.CurTime;
+ _nextUpd += CharInterval;
+ }
+
+ ///
+ /// Отрисовывает заголовок объявления с эффектом печатной машинки.
+ ///
+ private void DrawTitle(in OverlayDrawArgs args)
+ {
+ if (string.IsNullOrEmpty(Title))
+ return;
+
+ args.ScreenHandle.DrawString(_titleFont, TitlePosition, Title[..TitleIndex], TitleColor);
+
+ if (_nextUpdTitle > _timing.CurTime)
+ return;
+
+ if (!TitleReverse && TitleIndex == Title.Length)
+ {
+ TitleReverse = true;
+ _nextUpdTitle += TimeSpan.FromSeconds(AnnouncementDisplayDuration);
+ return;
+ }
+
+ if (TitleReverse && TitleIndex == 0)
+ {
+ Title = null;
+ return;
+ }
+
+ TitleIndex = TitleReverse ? TitleIndex - 1 : TitleIndex + 1;
+
+ if (_nextUpdTitle == TimeSpan.Zero)
+ _nextUpdTitle = _timing.CurTime;
+ _nextUpdTitle += TitleCharInterval;
+ }
+
+ ///
+ /// Вычисляет позицию центрирования текста с учётом размера вьюпорта.
+ ///
+ private Vector2 CalcPosition(Font font, string str, Vector2 viewport, int yOffset)
+ {
+ var strSize = CalcTextSize(font, str);
+
+ return new Vector2((viewport.X - strSize.X) / 2, strSize.Y + yOffset);
+ }
+
+ ///
+ /// Вычисляет размер текста в пикселях.
+ ///
+ private Vector2 CalcTextSize(Font font, string? str)
+ {
+ Vector2 strSize = new();
+ if (string.IsNullOrEmpty(str))
+ return strSize;
+
+ foreach (Rune r in str)
+ {
+ if (font.TryGetCharMetrics(r, 1, out var metrics))
+ {
+ strSize.X += metrics.Width;
+ strSize.Y = Math.Max(strSize.Y, metrics.Height);
+ }
+ }
+ return strSize;
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs b/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs
new file mode 100644
index 00000000000..4f3cb3d002e
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchBoundUserInterface.cs
@@ -0,0 +1,140 @@
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.UserInterface;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// BUI для консоли Overwatch.
+///
+public sealed class OverwatchBoundUserInterface : BoundUserInterface
+{
+ private OverwatchWindow? _window;
+
+ public OverwatchBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ ///
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow();
+ _window.Initialize(this);
+
+ _window.OnClose += () =>
+ {
+ _window = null;
+ };
+ }
+
+ ///
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ if (state is OverwatchUpdateState updateState)
+ {
+ _window?.UpdateState(updateState);
+ }
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ _window?.Dispose();
+ _window = null;
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Запрашивает переключение вида на камеру цели.
+ ///
+ /// Сущность цели для наблюдения.
+ public void ViewCamera(NetEntity target)
+ {
+ SendMessage(new OverwatchViewCameraMessage(target));
+ }
+
+ ///
+ /// Запрашивает остановку текущего наблюдения.
+ ///
+ public void StopWatching()
+ {
+ SendMessage(new OverwatchStopWatchingMessage());
+ }
+
+ ///
+ /// Запрашивает установку фильтра по статусу участника.
+ ///
+ /// Статус для фильтрации или null для сброса.
+ public void SetStatusFilter(OverwatchMemberStatus? status)
+ {
+ SendMessage(new OverwatchSetStatusFilterMessage(status));
+ }
+
+ ///
+ /// Запрашивает установку фильтра по отряду.
+ ///
+ /// ID отряда для фильтрации или null для сброса.
+ public void SetSquadFilter(int? squadId)
+ {
+ SendMessage(new OverwatchSetSquadFilterMessage(squadId));
+ }
+
+ ///
+ /// Запрашивает установку поискового запроса.
+ ///
+ /// Строка поискового запроса.
+ public void SetSearchQuery(string query)
+ {
+ SendMessage(new OverwatchSetSearchMessage(query));
+ }
+
+ ///
+ /// Запрашивает создание нового отряда.
+ ///
+ /// Название нового отряда.
+ public void CreateSquad(string squadName)
+ {
+ SendMessage(new OverwatchCreateSquadMessage(squadName));
+ }
+
+ ///
+ /// Запрашивает удаление отряда.
+ ///
+ /// ID отряда для удаления.
+ public void DeleteSquad(int squadId)
+ {
+ SendMessage(new OverwatchDeleteSquadMessage(squadId));
+ }
+
+ ///
+ /// Запрашивает назначение игрока в отряд.
+ ///
+ /// Сущность игрока.
+ /// ID отряда для назначения.
+ public void AssignSquad(NetEntity player, int squadId)
+ {
+ SendMessage(new OverwatchAssignSquadMessage(player, squadId));
+ }
+
+ ///
+ /// Запрашивает удаление игрока из отряда.
+ ///
+ /// Сущность игрока.
+ public void RemoveSquadMember(NetEntity player)
+ {
+ SendMessage(new OverwatchRemoveSquadMemberMessage(player));
+ }
+
+ ///
+ /// Запрашивает отправку объявления.
+ ///
+ /// Текст объявления.
+ /// ID целевого отряда или null для всей фракции.
+ public void SendAnnouncement(string message, int? targetSquadId = null)
+ {
+ SendMessage(new OverwatchSendMessageAnnouncement(message, targetSquadId));
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs b/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs
new file mode 100644
index 00000000000..ad4b143279f
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchConsoleSystem.cs
@@ -0,0 +1,259 @@
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.Audio;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Client.ResourceManagement;
+using Robust.Shared.Audio;
+using Robust.Shared.Audio.Components;
+using Robust.Shared.Map;
+using Robust.Shared.Timing;
+using System.Numerics;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Клиентская система для ретрансляции звуков при наблюдении через камеру Overwatch.
+///
+public sealed class OverwatchConsoleSystem : EntitySystem
+{
+ ///
+ /// Максимальное расстояние для проверки звуков (оптимизация производительности).
+ ///
+ private const float MaxSoundRelayDistance = 50f;
+
+ [Dependency] private AudioSystem _audio = default!;
+ [Dependency] private IEyeManager _eye = default!;
+ [Dependency] private IPlayerManager _player = default!;
+ [Dependency] private SharedTransformSystem _transform = default!;
+ [Dependency] private IOverlayManager _overlay = default!;
+ [Dependency] private IResourceCache _cache = default!;
+ [Dependency] private IGameTiming _timing = default!;
+
+ ///
+ /// Кэш ретранслируемых звуков для отслеживания активных ретрансляций.
+ ///
+ private readonly Dictionary _relayedSounds = new();
+
+ ///
+ /// Временный список звуков для ретрансляции в текущем кадре.
+ ///
+ private readonly List<(EntityUid Uid, AudioComponent Audio, RatOverwatchRelayedSoundComponent? Relay, EntityCoordinates Position)> _toRelay = new();
+
+ private OverwatchAnnouncementOverlay _announcementOverlay = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnAnnouncement);
+
+ SubscribeLocalEvent(OnRelayedRemove);
+ SubscribeLocalEvent(OnRelayedRemove);
+ SubscribeLocalEvent(OnLocalWatchingInit);
+ SubscribeLocalEvent(OnLocalWatchingRemoved);
+
+ _announcementOverlay = new(_cache, _timing);
+ _overlay.AddOverlay(_announcementOverlay);
+ }
+
+ public override void Shutdown()
+ {
+ base.Shutdown();
+ _overlay.RemoveOverlay(_announcementOverlay);
+ }
+
+ ///
+ /// Обработчик события объявления Overwatch.
+ ///
+ private void OnAnnouncement(OverwatchAnnouncementEvent ev)
+ {
+ var title = Loc.GetString("overwatch-announcement-title",
+ ("overwatchTitle", ev.OverwatchTitle),
+ ("targetName", ev.TargetName));
+ _announcementOverlay.SetText(title, ev.Message, ev.Color);
+ }
+
+ private void OnLocalWatchingInit(Entity ent, ref ComponentInit args)
+ {
+ if (_player.LocalEntity != ent.Owner || !ent.Comp.Watching.HasValue)
+ return;
+
+ var watchingNet = GetNetEntity(ent.Comp.Watching.Value);
+ _announcementOverlay?.Reset();
+ }
+
+ private void OnLocalWatchingRemoved(Entity ent, ref ComponentRemove args)
+ {
+ if (_player.LocalEntity != ent.Owner)
+ return;
+
+ _announcementOverlay.Reset();
+ CleanupAllRelayedSounds();
+ }
+
+ ///
+ /// Обработчик удаления компонента ретрансляции звука.
+ ///
+ private void OnRelayedRemove(Entity ent, ref T args)
+ {
+ TryDeleteRelayed(ent.Comp.Relay);
+ }
+
+ ///
+ /// Удаляет ретранслируемую сущность звука если она клиентская.
+ ///
+ private void TryDeleteRelayed(EntityUid? relay)
+ {
+ if (relay == null)
+ return;
+
+ if (IsClientSide(relay.Value))
+ QueueDel(relay);
+ }
+
+ ///
+ /// Удаляет ретранслируемый звук из кэша и очищает сущность.
+ ///
+ private void RemoveRelayedSound(EntityUid soundUid)
+ {
+ if (_relayedSounds.Remove(soundUid, out var relayedUid) && relayedUid.Valid)
+ {
+ if (IsClientSide(relayedUid))
+ QueueDel(relayedUid);
+ }
+ }
+
+ ///
+ /// Очищает все ретранслируемые звуки при выходе из режима наблюдения.
+ ///
+ private void CleanupAllRelayedSounds()
+ {
+ foreach (var relayedUid in _relayedSounds.Values)
+ {
+ if (relayedUid.Valid && IsClientSide(relayedUid))
+ QueueDel(relayedUid);
+ }
+ _relayedSounds.Clear();
+
+ var relayQuery = AllEntityQuery();
+ while (relayQuery.MoveNext(out var uid, out var relay))
+ {
+ TryDeleteRelayed(relay.Relay);
+ RemCompDeferred(uid);
+ }
+ }
+
+ ///
+ /// Очищает устаревшие ретранслируемые звуки которые больше не слышны.
+ ///
+ private void CleanupStaleRelayedSounds(HashSet activeSounds)
+ {
+ var toRemove = new List();
+ foreach (var soundUid in _relayedSounds.Keys)
+ {
+ if (!activeSounds.Contains(soundUid))
+ toRemove.Add(soundUid);
+ }
+
+ foreach (var soundUid in toRemove)
+ {
+ RemoveRelayedSound(soundUid);
+ }
+ }
+
+ ///
+ /// Создаёт или обновляет ретранслируемый звук на новой позиции.
+ ///
+ private void UpdateOrCreateRelayedSound(
+ EntityUid uid,
+ AudioComponent audio,
+ MapCoordinates eyePosition,
+ Vector2 delta,
+ EntityUid player)
+ {
+ var position = eyePosition.Offset(delta);
+
+ if (_relayedSounds.TryGetValue(uid, out var relayedUid) && relayedUid.Valid)
+ {
+ _transform.SetMapCoordinates(relayedUid, position);
+ return;
+ }
+
+ var entityPosition = _transform.ToCoordinates(position);
+ _toRelay.Add((uid, audio, null, entityPosition));
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ if (_player.LocalEntity is not { } player ||
+ !TryComp(player, out RatOverwatchWatchingComponent? watching) ||
+ !watching.Watching.HasValue ||
+ !TryComp(player, out TransformComponent? playerTransform))
+ {
+ CleanupAllRelayedSounds();
+ return;
+ }
+
+ _toRelay.Clear();
+
+ var eyePosition = _eye.CurrentEye.Position;
+ var listenerCoords = _transform.ToCoordinates(eyePosition);
+ var maxDistanceSquared = MaxSoundRelayDistance * MaxSoundRelayDistance;
+
+ var activeSounds = new HashSet();
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var uid, out var audio, out var xform))
+ {
+ if (IsClientSide(uid))
+ continue;
+
+ if (eyePosition.MapId != xform.MapID)
+ continue;
+
+ var audioCoords = xform.Coordinates;
+ if (!audioCoords.TryDelta(EntityManager, _transform, listenerCoords, out var delta))
+ continue;
+
+ var distanceSquared = delta.LengthSquared();
+
+ if (distanceSquared <= audio.MaxDistance * audio.MaxDistance)
+ {
+ RemoveRelayedSound(uid);
+ continue;
+ }
+
+ if (distanceSquared > maxDistanceSquared)
+ continue;
+
+ activeSounds.Add(uid);
+ UpdateOrCreateRelayedSound(uid, audio, eyePosition, delta, player);
+ }
+
+ foreach (var (uid, audio, _, coordinates) in _toRelay)
+ {
+ var relayedAudio = _audio.PlayStatic(
+ new SoundPathSpecifier(audio.FileName),
+ player,
+ coordinates,
+ audio.Params
+ );
+
+ if (relayedAudio is not { Entity: var relayedAudioEnt })
+ continue;
+
+ _audio.SetPlaybackPosition(relayedAudioEnt, audio.PlaybackPosition);
+
+ _relayedSounds[uid] = relayedAudioEnt;
+
+ if (TryComp(uid, out var relayedComp))
+ {
+ relayedComp.Relay = relayedAudioEnt;
+ }
+ }
+
+ CleanupStaleRelayedSounds(activeSounds);
+ }
+}
diff --git a/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml
new file mode 100644
index 00000000000..e7fbc90bf6a
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs
new file mode 100644
index 00000000000..0f52c127f86
--- /dev/null
+++ b/Content.Client/_Rat/Overwatch/OverwatchWindow.xaml.cs
@@ -0,0 +1,733 @@
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared._Rat.Overwatch;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client._Rat.Overwatch;
+
+///
+/// Окно консоли Overwatch для наблюдения за членами фракции.
+///
+[GenerateTypedNameReferences]
+public sealed partial class OverwatchWindow : FancyWindow
+{
+ private const int MinMembersGridWidth = 400;
+ private const int MinAdminPanelWidth = 350;
+ private const int WindowWidth = 1200;
+ private const int WindowHeight = 750;
+ private const int HeaderMarginTop = 5;
+ private const int HeaderMarginBottom = 2;
+ private const int SquadFilterAllId = 0;
+ private const int SquadFilterUnassignedId = -1;
+ private const int AnnouncementTargetAllId = 0;
+
+ private OverwatchBoundUserInterface? _ui;
+ private readonly Dictionary _memberRows = new();
+ private List _allMembers = new();
+
+ ///
+ /// Кэш для сохранения состояния наблюдения при обновлении UI.
+ ///
+ private readonly Dictionary _watchingStateCache = new();
+
+ private bool _anyDropdownOpen;
+ private bool _justOpenedSquadSelect;
+ private OverwatchUpdateState? _pendingState;
+
+ private OverwatchMemberStatus? _statusFilter;
+ private int? _squadFilterId;
+ private string _searchQuery = "";
+
+ private Dictionary _availableSquads = new();
+ private Color _factionColor;
+
+ ///
+ /// Инициализирует окно Overwatch и настраивает обработчики событий.
+ ///
+ public OverwatchWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ StopWatchingButton.OnPressed += _ =>
+ {
+ _ui?.StopWatching();
+ StopWatchingButton.Visible = false;
+ };
+
+ OnKeyBindDown += evt =>
+ {
+ if (_justOpenedSquadSelect)
+ {
+ _justOpenedSquadSelect = false;
+ return;
+ }
+ CloseAllSquadSelects();
+ };
+
+ InitializeFilters();
+ InitializeAdminPanel();
+ }
+
+ ///
+ /// Инициализирует фильтры UI (статус, отряд, поиск).
+ ///
+ private void InitializeFilters()
+ {
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-all"), SquadFilterAllId);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-alive"), (int)OverwatchMemberStatus.Alive + 1);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-ssd"), (int)OverwatchMemberStatus.SSD + 1);
+ StatusFilter.AddItem(Loc.GetString("overwatch-status-dead"), (int)OverwatchMemberStatus.Dead + 1);
+
+ StatusFilter.OnItemSelected += args =>
+ {
+ _statusFilter = args.Id == SquadFilterAllId
+ ? null
+ : (OverwatchMemberStatus)(args.Id - 1);
+ StatusFilter.SelectId(args.Id);
+ ApplyFilters();
+ };
+ StatusFilter.SelectId(SquadFilterAllId);
+
+ _squadFilterId = SquadFilterAllId;
+
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-all"), SquadFilterAllId);
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-unassigned"), SquadFilterUnassignedId);
+ SquadFilter.SelectId(SquadFilterAllId);
+
+ SquadFilter.OnItemSelected += args =>
+ {
+ _squadFilterId = args.Id;
+ SquadFilter.SelectId(args.Id);
+ ApplyFilters();
+ };
+
+ SearchInput.OnTextEntered += _ =>
+ {
+ _searchQuery = SearchInput.Text.Trim();
+ ApplyFilters();
+ };
+ SearchInput.OnTextChanged += _ =>
+ {
+ _searchQuery = SearchInput.Text.Trim();
+ ApplyFilters();
+ };
+ }
+
+ ///
+ /// Инициализирует BUI для взаимодействия с сервером.
+ ///
+ /// BUI консоли Overwatch.
+ public void Initialize(OverwatchBoundUserInterface ui)
+ {
+ _ui = ui;
+ }
+
+ ///
+ /// Обработчик начала наблюдения за участником - сбрасывает наблюдение у остальных.
+ ///
+ /// Выбранная строка участника.
+ private void OnMemberStartWatching(OverwatchMemberRow selectedRow)
+ {
+ foreach (var row in _memberRows.Values)
+ {
+ if (row != selectedRow)
+ {
+ row.SetWatching(false);
+ }
+ }
+ }
+
+ ///
+ /// Обновляет состояние UI на основе данных от сервера.
+ ///
+ /// Состояние от сервера.
+ public void UpdateState(OverwatchUpdateState state)
+ {
+ if (_ui == null)
+ return;
+
+ _allMembers = state.Members;
+ _factionColor = state.FactionColor;
+
+ if (!string.IsNullOrEmpty(state.SearchQuery) && string.IsNullOrEmpty(_searchQuery))
+ {
+ _searchQuery = state.SearchQuery;
+ SearchInput.Text = state.SearchQuery;
+ }
+
+ var squadsChanged = _availableSquads.Count != state.AvailableSquads.Count ||
+ state.AvailableSquads.Any(kvp =>
+ !_availableSquads.ContainsKey(kvp.Key) ||
+ _availableSquads[kvp.Key] != kvp.Value);
+
+ _availableSquads = state.AvailableSquads;
+
+ if (_anyDropdownOpen)
+ {
+ _pendingState = state;
+ return;
+ }
+
+ _pendingState = null;
+ ApplyStateToUi(squadsChanged);
+ }
+
+ private void ApplyStateToUi(bool squadsChanged)
+ {
+ AdminPanel.Visible = true;
+
+ if (squadsChanged)
+ UpdateSquadFilter(_availableSquads);
+
+ UpdateSquadsList(_availableSquads);
+ ApplyFilters();
+ StopWatchingButton.Visible = _memberRows.Values.Any(r => r.IsWatching);
+ }
+
+ ///
+ /// Закрывает все открытые селекторы отрядов.
+ ///
+ public void CloseAllSquadSelects()
+ {
+ var anyWasOpen = false;
+ foreach (var row in _memberRows.Values)
+ {
+ if (row.SquadSelect.Visible)
+ {
+ row.SquadSelect.Visible = false;
+ row.SquadButton.Visible = true;
+ anyWasOpen = true;
+ }
+ }
+ if (anyWasOpen)
+ {
+ _anyDropdownOpen = false;
+
+ if (_pendingState != null)
+ {
+ var pending = _pendingState;
+ _pendingState = null;
+ UpdateState(pending);
+ }
+ }
+ }
+
+ ///
+ /// Устанавливает флаг только что открытого селектора для предотвращения немедленного закрытия.
+ ///
+ public void SetJustOpenedSquadSelect()
+ {
+ _justOpenedSquadSelect = true;
+ }
+
+ ///
+ /// Обновляет фильтр отрядов на основе доступных отрядов.
+ ///
+ /// Словарь доступных отрядов (ID -> Название).
+ private void UpdateSquadFilter(Dictionary availableSquads)
+ {
+ var currentSelection = SquadFilter.SelectedId;
+ var currentAnnouncementSelection = AnnouncementTarget.SelectedId;
+
+ SquadFilter.Clear();
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-all"), SquadFilterAllId);
+ SquadFilter.AddItem(Loc.GetString("overwatch-squad-unassigned"), SquadFilterUnassignedId);
+
+ foreach (var squad in availableSquads.OrderBy(s => s.Value))
+ {
+ SquadFilter.AddItem(squad.Value, squad.Key);
+ }
+
+ _availableSquads = availableSquads;
+ SquadFilter.SelectId(currentSelection);
+ UpdateAnnouncementTargets(currentAnnouncementSelection);
+ }
+
+ ///
+ /// Обновляет список получателей объявлений.
+ ///
+ /// Текущий выбранный элемент.
+ private void UpdateAnnouncementTargets(int? currentSelection = null)
+ {
+ AnnouncementTarget.Clear();
+ AnnouncementTarget.AddItem(Loc.GetString("overwatch-admin-announcement-to-all"), AnnouncementTargetAllId);
+
+ foreach (var squad in _availableSquads.OrderBy(s => s.Value))
+ {
+ AnnouncementTarget.AddItem(squad.Value, squad.Key);
+ }
+
+ AnnouncementTarget.SelectId(AnnouncementTargetAllId);
+ }
+
+ ///
+ /// Обновляет список отрядов в админ-панели.
+ ///
+ /// Словарь отрядов (ID -> Название).
+ private void UpdateSquadsList(Dictionary squads)
+ {
+ SquadsList.DisposeAllChildren();
+
+ foreach (var squad in squads.OrderBy(s => s.Value))
+ {
+ var memberCount = _allMembers.Count(m => m.SquadId == squad.Key);
+
+ var squadBox = new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Horizontal,
+ SeparationOverride = 5,
+ HorizontalExpand = true
+ };
+
+ var squadLabel = new Label
+ {
+ Text = $"{squad.Value} {Loc.GetString("overwatch-squad-member-count", ("count", memberCount))}",
+ HorizontalExpand = true
+ };
+
+ var deleteButton = new Button
+ {
+ Text = Loc.GetString("overwatch-squad-delete-button"),
+ Disabled = memberCount > 0
+ };
+
+ if (memberCount == 0)
+ {
+ deleteButton.OnPressed += _ => _ui?.DeleteSquad(squad.Key);
+ }
+
+ squadBox.AddChild(squadLabel);
+ squadBox.AddChild(deleteButton);
+ SquadsList.AddChild(squadBox);
+ }
+ }
+
+ ///
+ /// Инициализирует админ-панель (объявления, создание/удаление отрядов).
+ ///
+ private void InitializeAdminPanel()
+ {
+ AnnouncementTarget.AddItem(Loc.GetString("overwatch-admin-announcement-to-all"), AnnouncementTargetAllId);
+
+ AnnouncementTarget.OnItemSelected += args =>
+ {
+ AnnouncementTarget.SelectId(args.Id);
+ };
+ AnnouncementTarget.SelectId(AnnouncementTargetAllId);
+
+ SendAnnouncementButton.OnPressed += _ =>
+ {
+ var message = AnnouncementInput.Text?.Trim();
+ if (string.IsNullOrEmpty(message))
+ return;
+
+ var targetSquadId = AnnouncementTarget.SelectedId == AnnouncementTargetAllId
+ ? null
+ : GetSquadIdByIndex(AnnouncementTarget.SelectedId);
+
+ _ui?.SendAnnouncement(message, targetSquadId);
+ AnnouncementInput.Text = "";
+ };
+
+ CreateSquadButton.OnPressed += _ =>
+ {
+ var squadName = NewSquadNameInput.Text?.Trim();
+
+ if (string.IsNullOrEmpty(squadName))
+ return;
+
+ _ui?.CreateSquad(squadName);
+ NewSquadNameInput.Text = "";
+ };
+ }
+
+ ///
+ /// Получает ID отряда по индексу.
+ ///
+ /// Индекс отряда.
+ /// ID отряда или null если выбрано "Всей фракции".
+ private int? GetSquadIdByIndex(int index)
+ {
+ if (index == AnnouncementTargetAllId)
+ return null;
+
+ return index;
+ }
+
+ ///
+ /// Применяет фильтры к списку участников и обновляет сетку.
+ ///
+ private void ApplyFilters()
+ {
+ int? squadFilterId = null;
+ bool filterUnassigned = false;
+
+ if (_squadFilterId.HasValue)
+ {
+ if (_squadFilterId.Value == SquadFilterUnassignedId)
+ {
+ filterUnassigned = true;
+ }
+ else if (_squadFilterId.Value != SquadFilterAllId)
+ {
+ squadFilterId = _squadFilterId.Value;
+ }
+ }
+
+ var filteredMembers = _allMembers.Where(m =>
+ {
+ if (_statusFilter.HasValue && m.Status != _statusFilter.Value)
+ return false;
+
+ if (filterUnassigned)
+ {
+ if (m.SquadId.HasValue)
+ return false;
+ }
+ else if (squadFilterId.HasValue)
+ {
+ if (!m.SquadId.HasValue || m.SquadId.Value != squadFilterId.Value)
+ return false;
+ }
+
+ if (!string.IsNullOrEmpty(_searchQuery) &&
+ !m.Name.Contains(_searchQuery, StringComparison.OrdinalIgnoreCase))
+ return false;
+
+ return true;
+ }).ToList();
+
+ UpdateMembersGrid(filteredMembers);
+ }
+
+ ///
+ /// Обновляет сетку участников с применением группировки по отрядам.
+ ///
+ /// Отфильтрованный список участников.
+ private void UpdateMembersGrid(List members)
+ {
+ var oldWatchingStates = new Dictionary();
+ foreach (var (member, row) in _memberRows)
+ {
+ if (row.IsWatching)
+ {
+ oldWatchingStates[member] = true;
+ }
+ }
+
+ MembersGrid.DisposeAllChildren();
+ _memberRows.Clear();
+
+ foreach (var (member, isWatching) in oldWatchingStates)
+ {
+ _watchingStateCache[member] = isWatching;
+ }
+
+ var membersBySquad = new Dictionary>();
+
+ foreach (var member in members)
+ {
+ var squadId = member.SquadId ?? SquadFilterUnassignedId;
+ if (!membersBySquad.ContainsKey(squadId))
+ {
+ membersBySquad[squadId] = new List();
+ }
+ membersBySquad[squadId].Add(member);
+ }
+
+ var sortedSquads = membersBySquad.OrderBy(kvp =>
+ {
+ if (kvp.Key == SquadFilterUnassignedId)
+ return "";
+ return _availableSquads.TryGetValue(kvp.Key, out var name) ? name : kvp.Key.ToString();
+ }).ToList();
+
+ foreach (var squad in sortedSquads)
+ {
+ string squadName;
+ if (squad.Key == SquadFilterUnassignedId)
+ {
+ squadName = Loc.GetString("overwatch-squad-unassigned");
+ }
+ else if (_availableSquads.TryGetValue(squad.Key, out var name))
+ {
+ squadName = $"{name} {Loc.GetString("overwatch-squad-member-count", ("count", squad.Value.Count))}";
+ }
+ else
+ {
+ squadName = $"{Loc.GetString("overwatch-squad-label")} {squad.Key} {Loc.GetString("overwatch-squad-member-count", ("count", squad.Value.Count))}";
+ }
+
+ var headerLabel = new Label
+ {
+ Text = squadName,
+ FontColorOverride = _factionColor,
+ Margin = new Thickness(0, HeaderMarginTop, 0, HeaderMarginBottom)
+ };
+ MembersGrid.AddChild(headerLabel);
+
+ foreach (var memberData in squad.Value)
+ {
+ if (_ui == null)
+ continue;
+
+ var row = new OverwatchMemberRow(memberData, _ui, OnMemberStartWatching, () => _availableSquads, _availableSquads, (isOpen) => _anyDropdownOpen = isOpen, this);
+
+ if (_watchingStateCache.TryGetValue(memberData.Member, out var isWatching))
+ {
+ row.SetWatching(isWatching);
+ }
+
+ MembersGrid.AddChild(row);
+ _memberRows[memberData.Member] = row;
+ }
+ }
+ }
+
+ ///
+ /// Устанавливает состояние наблюдения для участника.
+ ///
+ /// Сущность участника.
+ /// Флаг состояния наблюдения.
+ public void SetWatching(NetEntity member, bool isWatching)
+ {
+ _watchingStateCache[member] = isWatching;
+
+ if (_memberRows.TryGetValue(member, out var row))
+ {
+ row.SetWatching(isWatching);
+ StopWatchingButton.Visible = _memberRows.Values.Any(r => r.IsWatching);
+ }
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ foreach (var row in _memberRows.Values)
+ {
+ row.Dispose();
+ }
+ _memberRows.Clear();
+ base.Dispose(disposing);
+ }
+}
+
+///
+/// Строка с информацией о члене фракции в окне Overwatch.
+///
+public sealed class OverwatchMemberRow : BoxContainer
+{
+ private readonly Label _nameLabel;
+ private readonly Label _jobLabel;
+ private readonly Label _statusLabel;
+ private readonly Label _coordinatesLabel;
+ private readonly Button _viewButton;
+ private readonly Button _squadButton;
+ private readonly OptionButton _squadSelect;
+ private readonly OverwatchBoundUserInterface _ui;
+ private readonly Action _onStartWatching;
+ private readonly Func> _getSquadsFunc;
+ private readonly Dictionary? _availableSquadsCache;
+ private readonly Action? _onDropdownOpenChanged;
+ private readonly OverwatchWindow? _window;
+ private NetEntity _member;
+ private int? _currentSquadId;
+ private System.Numerics.Vector2? _coordinates;
+
+ ///
+ /// Флаг текущего состояния наблюдения за участником.
+ ///
+ public bool IsWatching { get; private set; }
+
+ ///
+ /// Селектор отрядов для назначения участника.
+ ///
+ public OptionButton SquadSelect => _squadSelect;
+
+ ///
+ /// Кнопка открытия селектора отрядов.
+ ///
+ public Button SquadButton => _squadButton;
+
+ ///
+ /// Инициализирует строку участника.
+ ///
+ public OverwatchMemberRow(
+ OverwatchMemberData data,
+ OverwatchBoundUserInterface ui,
+ Action onStartWatching,
+ Func> getSquadsFunc,
+ Dictionary? availableSquadsCache = null,
+ Action? onDropdownOpenChanged = null,
+ OverwatchWindow? window = null)
+ {
+ _ui = ui;
+ _member = data.Member;
+ _onStartWatching = onStartWatching;
+ _getSquadsFunc = getSquadsFunc;
+ _currentSquadId = data.SquadId;
+ _coordinates = data.Coordinates;
+ _availableSquadsCache = availableSquadsCache;
+ _onDropdownOpenChanged = onDropdownOpenChanged;
+ _window = window;
+
+ Orientation = LayoutOrientation.Horizontal;
+ SeparationOverride = 5;
+ HorizontalExpand = true;
+
+ _nameLabel = new Label{};
+ _jobLabel = new Label();
+ _statusLabel = new Label();
+ _coordinatesLabel = new Label();
+ _viewButton = new Button { Text = Loc.GetString("overwatch-member-view-camera-button"), MinSize = new Vector2i(120, 0) };
+ _squadButton = new Button { Text = Loc.GetString("overwatch-member-squad-assign"), MinSize = new Vector2i(60, 0) };
+ _squadSelect = new OptionButton { MinSize = new Vector2i(150, 0), Visible = false };
+
+ AddChild(_nameLabel);
+ AddChild(_jobLabel);
+ AddChild(new Label { Text = "|" });
+ AddChild(_statusLabel);
+ AddChild(_coordinatesLabel);
+ AddChild(new Label { Text = "|" });
+ AddChild(_viewButton);
+ AddChild(_squadButton);
+ AddChild(_squadSelect);
+
+ _viewButton.OnPressed += _ =>
+ {
+ _onStartWatching(this);
+ _ui.ViewCamera(_member);
+ _window?.SetWatching(_member, true);
+ };
+
+ _squadButton.OnPressed += _ =>
+ {
+ RefreshSquadSelect();
+ _squadSelect.Visible = true;
+ _squadButton.Visible = false;
+ _onDropdownOpenChanged?.Invoke(true);
+ _window?.SetJustOpenedSquadSelect();
+ };
+
+ _squadSelect.OnItemSelected += args =>
+ {
+ _squadSelect.Visible = false;
+ _squadButton.Visible = true;
+ _onDropdownOpenChanged?.Invoke(false);
+
+ if (args.Id == -1)
+ {
+ _ui?.RemoveSquadMember(_member);
+ _currentSquadId = null;
+ }
+ else
+ {
+ _ui?.AssignSquad(_member, args.Id);
+ _currentSquadId = args.Id;
+ }
+
+ _squadSelect.SelectId(args.Id);
+ };
+
+ Update(data);
+ }
+
+ ///
+ /// Обновляет список отрядов в селекторе.
+ ///
+ public void RefreshSquadSelect()
+ {
+ var squads = _availableSquadsCache ?? _getSquadsFunc();
+
+ _squadSelect.Clear();
+ _squadSelect.AddItem(Loc.GetString("overwatch-member-squad-no-squad"), -1);
+
+ foreach (var squad in squads.OrderBy(s => s.Value))
+ {
+ _squadSelect.AddItem(squad.Value, squad.Key);
+ }
+
+ if (_currentSquadId.HasValue && squads.ContainsKey(_currentSquadId.Value))
+ {
+ _squadSelect.SelectId(_currentSquadId.Value);
+ }
+ else
+ {
+ _squadSelect.SelectId(-1);
+ }
+ }
+
+ ///
+ /// Обновляет отображаемые данные участника.
+ ///
+ public void Update(OverwatchMemberData data)
+ {
+ if (_squadSelect.Visible)
+ {
+ _squadSelect.Visible = false;
+ _squadButton.Visible = true;
+ _onDropdownOpenChanged?.Invoke(false);
+ }
+
+ _nameLabel.Text = data.Name;
+ _jobLabel.Text = data.JobTitle;
+ _statusLabel.Text = data.Status switch
+ {
+ OverwatchMemberStatus.Alive => Loc.GetString("overwatch-member-status-alive"),
+ OverwatchMemberStatus.SSD => Loc.GetString("overwatch-member-status-ssd"),
+ OverwatchMemberStatus.Dead => Loc.GetString("overwatch-member-status-dead"),
+ _ => Loc.GetString("overwatch-member-status-unknown")
+ };
+
+ _statusLabel.FontColorOverride = data.Status switch
+ {
+ OverwatchMemberStatus.Alive => Color.Green,
+ OverwatchMemberStatus.SSD => Color.Yellow,
+ OverwatchMemberStatus.Dead => Color.Red,
+ _ => Color.White
+ };
+
+ _coordinates = data.Coordinates;
+ _coordinatesLabel.Text = data.Coordinates.HasValue
+ ? Loc.GetString("overwatch-member-coordinates", ("x", Math.Round(data.Coordinates.Value.X, 1)), ("y", Math.Round(data.Coordinates.Value.Y, 1)))
+ : Loc.GetString("overwatch-member-coordinates-none");
+
+ _viewButton.Disabled = !data.HasCamera;
+ _currentSquadId = data.SquadId;
+ UpdateButtonState();
+ }
+
+ ///
+ /// Устанавливает состояние наблюдения за участником.
+ ///
+ public void SetWatching(bool isWatching)
+ {
+ IsWatching = isWatching;
+ UpdateButtonState();
+ }
+
+ ///
+ /// Обновляет текст и стиль кнопки просмотра в зависимости от состояния.
+ ///
+ private void UpdateButtonState()
+ {
+ if (IsWatching)
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-watching-button");
+ _viewButton.StyleClasses.Clear();
+ _viewButton.StyleClasses.Add("openBoth");
+ }
+ else if (_viewButton.Disabled)
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-no-camera-button");
+ _viewButton.StyleClasses.Clear();
+ }
+ else
+ {
+ _viewButton.Text = Loc.GetString("overwatch-member-view-camera-button");
+ _viewButton.StyleClasses.Clear();
+ }
+ }
+}
diff --git a/Content.Server/_Rat/Overwatch/OverwatchSystem.cs b/Content.Server/_Rat/Overwatch/OverwatchSystem.cs
new file mode 100644
index 00000000000..22bfc3c7ccf
--- /dev/null
+++ b/Content.Server/_Rat/Overwatch/OverwatchSystem.cs
@@ -0,0 +1,800 @@
+using System.Linq;
+using System.Numerics;
+using Content.Server.Chat.Managers;
+using Content.Server.SurveillanceCamera;
+using Content.Server._Rat.Squad;
+using Content.Server.Shuttles.Components;
+using Content.Shared._Rat.Overwatch;
+using Content.Shared._Rat.Squad;
+using Content.Shared._Mono.Company;
+using Content.Shared.Access.Components;
+using Content.Shared.Access.Systems;
+using Content.Shared.Chat;
+using Content.Shared.GameTicking;
+using Content.Shared.Inventory;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Mobs.Systems;
+using Content.Shared.Movement.Events;
+using Content.Shared.Power;
+using Content.Shared.SurveillanceCamera.Components;
+using Content.Shared.UserInterface;
+using Robust.Server.GameObjects;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._Rat.Overwatch;
+
+///
+/// Система для управления Overwatch.
+///
+public sealed class OverwatchSystem : EntitySystem
+{
+ ///
+ /// Интервал инвалидации кэша в секундах.
+ /// Кэш сбрасывается при изменении состава фракции.
+ ///
+ private const float CacheInvalidationInterval = 2.0f;
+
+ ///
+ /// Интервал обновления UI в секундах.
+ ///
+ private const float UpdateInterval = 1.0f;
+
+ ///
+ /// ID слота экипировки для камеры наблюдения.
+ ///
+ private const string CameraSlotId = "neck";
+
+ [Dependency] private InventorySystem _inventorySystem = default!;
+ [Dependency] private MobStateSystem _mobStateSystem = default!;
+ [Dependency] private UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private AccessReaderSystem _accessReaderSystem = default!;
+ [Dependency] private SurveillanceCameraSystem _cameraSystem = default!;
+ [Dependency] private SharedIdCardSystem _idCardSystem = default!;
+ [Dependency] private SharedEyeSystem _eyeSystem = default!;
+ [Dependency] private ViewSubscriberSystem _viewSubscriberSystem = default!;
+ [Dependency] private SquadSystem _squadSystem = default!;
+ [Dependency] private IChatManager _chatManager = default!;
+ [Dependency] private SharedTransformSystem _transformSystem = default!;
+ [Dependency] private IPrototypeManager _prototypeManager = default!;
+
+ ///
+ /// Пары наблюдения: watcher -> target.
+ ///
+ private readonly Dictionary _watchingPairs = new();
+
+ ///
+ /// Кэш членов фракции для снижения аллокаций при частых обновлениях.
+ ///
+ private readonly Dictionary, List> _factionMembersCache = new();
+
+ ///
+ /// Кэш данных участников для UI.
+ ///
+ private readonly Dictionary _memberDataCache = new();
+
+ private float _cacheInvalidationTimer;
+ private float _updateTimer;
+
+ ///
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnUIOpenAttempt);
+ SubscribeLocalEvent(OnBeforeUIOpen);
+ SubscribeLocalEvent(OnRefresh);
+ SubscribeLocalEvent(OnPowerChanged);
+ SubscribeLocalEvent(OnWatchingMoveInput);
+ SubscribeLocalEvent(OnWatchingShutdown);
+ SubscribeLocalEvent(OnCameraShutdown);
+ SubscribeLocalEvent(OnWatchedEntityTerminating);
+ SubscribeLocalEvent(OnPlayerSpawn); // Обновление UI при спавне игрока для отображения новых членов фракции
+ // Инвалидация кэша при изменении состава фракции или отрядов
+ SubscribeLocalEvent(OnFactionComponentInit);
+ SubscribeLocalEvent(OnFactionComponentShutdown);
+ SubscribeLocalEvent(OnSquadComponentInit);
+ SubscribeLocalEvent(OnSquadComponentShutdown);
+
+ Subs.BuiEvents(OverwatchUiKey.Key, subs =>
+ {
+ subs.Event(OnViewCamera);
+ subs.Event(OnStopWatching);
+ subs.Event(OnSetStatusFilter);
+ subs.Event(OnSetSquadFilter);
+ subs.Event(OnSetSearch);
+ subs.Event(OnCreateSquad);
+ subs.Event(OnDeleteSquad);
+ subs.Event(OnAssignSquad);
+ subs.Event(OnRemoveSquadMember);
+ subs.Event(OnSendAnnouncement);
+ });
+ }
+
+ ///
+ /// Инвалидация кэша при инициализации компонента фракции.
+ ///
+ private void OnFactionComponentInit(EntityUid uid, CompanyComponent component, ComponentInit args)
+ {
+ _factionMembersCache.Clear();
+ }
+
+ ///
+ /// Инвалидация кэша при удалении компонента фракции.
+ ///
+ private void OnFactionComponentShutdown(EntityUid uid, CompanyComponent component, ComponentShutdown args)
+ {
+ _factionMembersCache.Clear();
+ _memberDataCache.Remove(uid);
+ }
+
+ ///
+ /// Инвалидация кэша при инициализации компонента отряда.
+ ///
+ private void OnSquadComponentInit(EntityUid uid, SquadComponent component, ComponentInit args)
+ {
+ _factionMembersCache.Clear();
+ }
+
+ ///
+ /// Инвалидация кэша при удалении компонента отряда.
+ ///
+ private void OnSquadComponentShutdown(EntityUid uid, SquadComponent component, ComponentShutdown args)
+ {
+ _factionMembersCache.Clear();
+ _memberDataCache.Remove(uid);
+ }
+
+ ///
+ /// Обработчик открытия UI консоли — останавливает наблюдение если игрок уже смотрит через камеру.
+ ///
+ private void OnBeforeUIOpen(Entity ent, ref BeforeActivatableUIOpenEvent args)
+ {
+ if (TryComp(args.User, out var watchingComp) && watchingComp.Watching.HasValue)
+ {
+ StopWatching(args.User, watchingComp);
+ }
+
+ RefreshData(ent);
+ }
+
+ ///
+ /// Обработчик попытки открытия UI — проверяет принадлежность к фракции.
+ ///
+ private void OnUIOpenAttempt(Entity ent, ref ActivatableUIOpenAttemptEvent args)
+ {
+ if (args.User is not { Valid: true } user)
+ return;
+ }
+
+ ///
+ /// Обработчик движения игрока — останавливает наблюдение при попытке движения.
+ ///
+ private void OnWatchingMoveInput(Entity ent, ref MoveInputEvent args)
+ {
+ if (!args.HasDirectionalMovement)
+ return;
+
+ StopWatching(ent.Owner, ent.Comp);
+ RemComp(ent.Owner);
+ }
+
+ ///
+ /// Обновляет данные UI консоли Overwatch.
+ ///
+ private void RefreshData(Entity ent)
+ {
+ var members = GetFactionMembers(ent.Comp.Faction);
+ var memberData = new List(members.Count);
+
+ var availableSquads = _squadSystem.GetFactionSquads(ent.Comp.Faction);
+
+ foreach (var member in members)
+ {
+ if (!_memberDataCache.TryGetValue(member, out var cachedData) ||
+ ShouldRefreshData(member, cachedData))
+ {
+ var newData = CreateMemberData(member);
+ _memberDataCache[member] = newData;
+ memberData.Add(newData);
+ }
+ else
+ {
+ memberData.Add(cachedData);
+ }
+ }
+
+ var color = Color.White;
+ if (_prototypeManager.TryIndex(ent.Comp.Faction, out var companyProto))
+ color = companyProto.Color;
+
+ _uiSystem.SetUiState(ent.Owner, OverwatchUiKey.Key,
+ new OverwatchUpdateState(
+ memberData,
+ availableSquads.ToDictionary(k => k.Key, v => v.Value.Name),
+ ent.Comp.StatusFilter,
+ ent.Comp.SquadFilter,
+ ent.Comp.SearchQuery,
+ color
+ ));
+ }
+
+ ///
+ /// Проверяет необходимость обновления данных участника.
+ ///
+ private bool ShouldRefreshData(EntityUid member, OverwatchMemberData cachedData)
+ {
+ if (!TryComp(member, out var actor))
+ return cachedData.Status != OverwatchMemberStatus.Dead;
+
+ if (actor.PlayerSession == null)
+ return cachedData.Status != OverwatchMemberStatus.SSD;
+
+ if (TryComp(member, out var squadComp))
+ {
+ if (cachedData.SquadId != squadComp.SquadId || cachedData.SquadName != squadComp.SquadName)
+ return true;
+ }
+ else
+ {
+ // Если компонента нет, а в кэше был отряд — нужно обновить
+ if (cachedData.SquadId.HasValue)
+ return true;
+ }
+
+ var currentCoords = GetMemberCoordinates(member);
+ if (cachedData.Coordinates.HasValue != currentCoords.HasValue)
+ return true;
+ if (cachedData.Coordinates.HasValue && currentCoords.HasValue)
+ {
+ // Проверяем, изменились ли координаты значительно (порог 0.5f)
+ var dx = cachedData.Coordinates.Value.X - currentCoords.Value.X;
+ var dy = cachedData.Coordinates.Value.Y - currentCoords.Value.Y;
+ if (dx * dx + dy * dy > 0.25f) // 0.5f^2
+ return true;
+ }
+
+ return false;
+ }
+
+ ///
+ /// Создаёт данные участника для отображения в UI.
+ ///
+ private OverwatchMemberData CreateMemberData(EntityUid member)
+ {
+ return new OverwatchMemberData(
+ GetNetEntity(member),
+ Name(member),
+ GetJobTitle(member),
+ GetMemberStatus(member),
+ HasWearableCamera(member),
+ GetSquadId(member),
+ GetSquadName(member) ?? "",
+ GetMemberCoordinates(member)
+ );
+ }
+
+ private void OnRefresh(Entity ent, ref OverwatchRefreshMessage args)
+ {
+ RefreshData(ent);
+ }
+
+ private void OnSetStatusFilter(Entity ent, ref OverwatchSetStatusFilterMessage args)
+ {
+ ent.Comp.StatusFilter = args.Status;
+ RefreshData(ent);
+ }
+
+ private void OnSetSquadFilter(Entity ent, ref OverwatchSetSquadFilterMessage args)
+ {
+ ent.Comp.SquadFilter = args.SquadId;
+ RefreshData(ent);
+ }
+
+ private void OnSetSearch(Entity ent, ref OverwatchSetSearchMessage args)
+ {
+ ent.Comp.SearchQuery = args.SearchQuery;
+ RefreshData(ent);
+ }
+
+ ///
+ /// Обработчик создания нового отряда.
+ ///
+ private void OnCreateSquad(Entity ent, ref OverwatchCreateSquadMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ var userFaction = GetUserFaction(actor);
+ if (string.IsNullOrEmpty(userFaction) || userFaction != ent.Comp.Faction)
+ return;
+
+ var created = _squadSystem.CreateSquad(ent.Comp.Faction, args.SquadName);
+ if (created)
+ {
+ RefreshData(ent);
+ }
+ }
+
+ ///
+ /// Обработчик удаления отряда.
+ ///
+ private void OnDeleteSquad(Entity ent, ref OverwatchDeleteSquadMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ var userFaction = GetUserFaction(actor);
+ if (string.IsNullOrEmpty(userFaction) || userFaction != ent.Comp.Faction)
+ return;
+
+ if (_squadSystem.RemoveSquad(ent.Comp.Faction, args.SquadId))
+ {
+ RefreshData(ent);
+ }
+ }
+
+ ///
+ /// Обработчик назначения игрока в отряд.
+ ///
+ private void OnAssignSquad(Entity ent, ref OverwatchAssignSquadMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ var userFaction = GetUserFaction(actor);
+ if (string.IsNullOrEmpty(userFaction) || userFaction != ent.Comp.Faction)
+ return;
+
+ var player = GetEntity(args.Player);
+ if (!player.Valid)
+ return;
+
+ if (_squadSystem.AssignToSquad(player, args.SquadId, ent.Comp.Faction))
+ {
+ RefreshData(ent);
+ }
+ }
+
+ ///
+ /// Обработчик удаления игрока из отряда.
+ ///
+ private void OnRemoveSquadMember(Entity ent, ref OverwatchRemoveSquadMemberMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ var userFaction = GetUserFaction(actor);
+ if (string.IsNullOrEmpty(userFaction) || userFaction != ent.Comp.Faction)
+ return;
+
+ var player = GetEntity(args.Player);
+ if (!player.Valid)
+ return;
+
+ _squadSystem.RemoveFromSquad(player);
+ RefreshData(ent);
+ }
+
+ ///
+ /// Обработчик отправки объявления Overwatch.
+ ///
+ private void OnSendAnnouncement(Entity ent, ref OverwatchSendMessageAnnouncement args)
+ {
+ if (string.IsNullOrEmpty(args.Message))
+ return;
+
+ var faction = ent.Comp.Faction;
+ var factionMembers = GetFactionMembers(faction);
+
+ var targetName = !args.TargetSquadId.HasValue
+ ? Loc.GetString("overwatch-announcement-target-all")
+ : GetSquadTargetName(faction, args.TargetSquadId.Value);
+
+ var overwatchTitle = GetOverwatchTitle(faction);
+ var color = Color.White;
+ if (_prototypeManager.TryIndex(faction, out var companyProto))
+ color = companyProto.Color;
+
+ var recipients = new List();
+
+ foreach (var member in factionMembers)
+ {
+ if (args.TargetSquadId.HasValue)
+ {
+ var memberSquadId = GetSquadId(member);
+ if (memberSquadId != args.TargetSquadId)
+ continue;
+ }
+
+ if (TryComp(member, out var actor) && actor.PlayerSession != null)
+ {
+ recipients.Add(actor.PlayerSession);
+ RaiseNetworkEvent(new OverwatchAnnouncementEvent(args.Message, targetName, overwatchTitle, color), actor.PlayerSession);
+ }
+ }
+
+ if (recipients.Count == 0)
+ return;
+
+ var wrappedMessage = $"{overwatchTitle}: {args.Message}";
+
+ var filter = Robust.Shared.Player.Filter.Empty();
+ foreach (var recipient in recipients)
+ {
+ filter.AddPlayer(recipient);
+ }
+
+ _chatManager.ChatMessageToManyFiltered(
+ filter,
+ ChatChannel.Local,
+ args.Message,
+ wrappedMessage,
+ EntityUid.Invalid,
+ false,
+ true,
+ color
+ );
+ }
+
+ ///
+ /// Получает название отряда.
+ ///
+ private string GetSquadTargetName(string faction, int squadId)
+ {
+ var squads = _squadSystem.GetFactionSquads(faction);
+ return squads.TryGetValue(squadId, out var squadInfo)
+ ? squadInfo.Name
+ : Loc.GetString("overwatch-announcement-target-squad");
+ }
+
+ ///
+ /// Обработчик переключения вида на камеру цели.
+ ///
+ private void OnViewCamera(Entity ent, ref OverwatchViewCameraMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ var target = GetEntity(args.Target);
+ if (!IsValidCameraTarget(target))
+ return;
+
+ if (!_inventorySystem.TryGetSlotEntity(target, CameraSlotId, out var neckItem))
+ return;
+
+ if (!TryComp(neckItem.Value, out var cameraComp))
+ return;
+
+ if (!TryComp(actor, out var actorComp) || actorComp.PlayerSession == null)
+ return;
+
+ if (_watchingPairs.TryGetValue(actor, out var currentTarget))
+ {
+ if (currentTarget == target)
+ return;
+
+ if (TryComp(actor, out var watchingComp))
+ {
+ StopWatching(actor, watchingComp);
+ }
+ }
+
+ if (!cameraComp.Active)
+ _cameraSystem.SetActive(neckItem.Value, true, cameraComp);
+
+ _cameraSystem.AddActiveViewer(neckItem.Value, actor, neckItem.Value, cameraComp);
+
+ var cameraCompTarget = EnsureComp(target);
+ cameraCompTarget.Watching.Add(actor);
+ Dirty(target, cameraCompTarget);
+
+ var watchingCompActor = EnsureComp(actor);
+ watchingCompActor.Watching = target;
+ watchingCompActor.Console = ent.Owner;
+
+ _watchingPairs[actor] = target;
+
+ _eyeSystem.SetTarget(actor, target);
+ _viewSubscriberSystem.AddViewSubscriber(target, actorComp.PlayerSession);
+ }
+
+ private void OnStopWatching(Entity ent, ref OverwatchStopWatchingMessage args)
+ {
+ if (args.Actor is not { Valid: true } actor)
+ return;
+
+ if (TryComp(actor, out var watchingComp) && watchingComp.Watching.HasValue)
+ {
+ StopWatching(actor, watchingComp);
+ }
+ }
+
+ ///
+ /// Останавливает наблюдение игрока за целью.
+ ///
+ private void StopWatching(EntityUid watcher, RatOverwatchWatchingComponent watchingComp)
+ {
+ if (!watchingComp.Watching.HasValue)
+ return;
+
+ var target = watchingComp.Watching.Value;
+
+ if (_inventorySystem.TryGetSlotEntity(target, CameraSlotId, out var neckItem) &&
+ TryComp(neckItem.Value, out var cameraComp))
+ {
+ _cameraSystem.RemoveActiveViewer(neckItem.Value, watcher, component: cameraComp);
+ }
+
+ if (TryComp(target, out var cameraCompTarget))
+ {
+ cameraCompTarget.Watching.Remove(watcher);
+ Dirty(target, cameraCompTarget);
+ }
+
+ if (TryComp(watcher, out var actorComp) && actorComp.PlayerSession != null)
+ {
+ _viewSubscriberSystem.RemoveViewSubscriber(target, actorComp.PlayerSession);
+ }
+
+ _eyeSystem.SetTarget(watcher, null);
+ _watchingPairs.Remove(watcher);
+ watchingComp.Watching = null;
+ watchingComp.Console = null;
+ }
+
+ ///
+ /// Обработчик удаления компонента наблюдения.
+ ///
+ private void OnWatchingShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ if (!ent.Comp.Watching.HasValue)
+ return;
+
+ var target = ent.Comp.Watching.Value;
+
+ if (_inventorySystem.TryGetSlotEntity(target, CameraSlotId, out var neckItem) &&
+ TryComp(neckItem.Value, out var cameraComp))
+ {
+ _cameraSystem.RemoveActiveViewer(neckItem.Value, ent.Owner, component: cameraComp);
+ }
+
+ if (TryComp(target, out var cameraCompTarget))
+ {
+ cameraCompTarget.Watching.Remove(ent.Owner);
+ }
+
+ if (TryComp(ent.Owner, out var actorComp))
+ {
+ _viewSubscriberSystem.RemoveViewSubscriber(target, actorComp.PlayerSession);
+ }
+
+ _eyeSystem.SetTarget(ent.Owner, null);
+ _watchingPairs.Remove(ent.Owner);
+ ent.Comp.Console = null;
+ }
+
+ ///
+ /// Обработчик удаления компонента камеры — очищает всех наблюдателей.
+ ///
+ private void OnCameraShutdown(Entity ent, ref ComponentShutdown args)
+ {
+ foreach (var watcher in ent.Comp.Watching.ToList())
+ {
+ if (TryComp(watcher, out var watchingComp) &&
+ watchingComp.Console == ent.Owner)
+ {
+ StopWatching(watcher, watchingComp);
+ }
+ }
+ }
+
+ ///
+ /// Обработчик удаления наблюдаемой сущности — очищает всех наблюдателей.
+ ///
+ private void OnWatchedEntityTerminating(Entity ent, ref EntityTerminatingEvent args)
+ {
+ foreach (var watcher in ent.Comp.Watching.ToList())
+ {
+ if (TryComp(watcher, out var watchingComp))
+ {
+ StopWatching(watcher, watchingComp);
+ }
+ }
+ }
+
+ ///
+ /// Обработчик отключения питания консоли — закрывает все наблюдения.
+ ///
+ private void OnPowerChanged(Entity ent, ref PowerChangedEvent args)
+ {
+ if (args.Powered)
+ return;
+
+ foreach (var (watcher, target) in _watchingPairs.ToList())
+ {
+ if (TryComp(watcher, out var watchingComp))
+ {
+ StopWatching(watcher, watchingComp);
+ }
+ }
+
+ _watchingPairs.Clear();
+ _uiSystem.CloseUi(ent.Owner, OverwatchUiKey.Key);
+ }
+
+ ///
+ /// Обработчик завершения спавна игрока — обновляет UI всех открытых консолей.
+ ///
+ private void OnPlayerSpawn(PlayerSpawnCompleteEvent args)
+ {
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (_uiSystem.IsUiOpen(uid, OverwatchUiKey.Key))
+ {
+ RefreshData((uid, comp));
+ }
+ }
+ }
+
+ ///
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ _updateTimer += frameTime;
+ if (_updateTimer < UpdateInterval)
+ return;
+
+ _updateTimer -= UpdateInterval;
+
+ if (_cacheInvalidationTimer >= CacheInvalidationInterval)
+ {
+ _cacheInvalidationTimer -= CacheInvalidationInterval;
+ _factionMembersCache.Clear();
+ _memberDataCache.Clear();
+ }
+
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (_uiSystem.IsUiOpen(uid, OverwatchUiKey.Key))
+ {
+ RefreshData((uid, comp));
+ }
+ }
+ }
+
+ ///
+ /// Получает фракцию игрока.
+ ///
+ private string? GetUserFaction(EntityUid user)
+ {
+ if (!TryComp(user, out var factionComp))
+ return null;
+
+ return factionComp.CompanyName;
+ }
+
+ ///
+ /// Получает кэшированный список членов фракции.
+ ///
+ private List GetFactionMembers(ProtoId faction)
+ {
+ if (_factionMembersCache.TryGetValue(faction, out var cached))
+ return cached;
+
+ var members = new List();
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var factionComp))
+ {
+ if (factionComp.CompanyName == faction && !HasComp(uid))
+ members.Add(uid);
+ }
+
+ _factionMembersCache[faction] = members;
+ return members;
+ }
+
+ ///
+ /// Определяет статус участника (жив/мёртв/SSD).
+ ///
+ private OverwatchMemberStatus GetMemberStatus(EntityUid member)
+ {
+ if (!TryComp(member, out var mobState))
+ return OverwatchMemberStatus.Dead;
+
+ if (_mobStateSystem.IsDead(member, mobState))
+ return OverwatchMemberStatus.Dead;
+
+ if (!TryComp(member, out var actor) || actor.PlayerSession == null)
+ return OverwatchMemberStatus.SSD;
+
+ return OverwatchMemberStatus.Alive;
+ }
+
+ ///
+ /// Проверяет наличие носимой камеры на участнике.
+ ///
+ private bool HasWearableCamera(EntityUid member)
+ {
+ if (!_inventorySystem.TryGetSlotEntity(member, CameraSlotId, out var neckItem))
+ return false;
+
+ return HasComp(neckItem.Value);
+ }
+
+ ///
+ /// Получает должность сущности.
+ ///
+ private string GetJobTitle(EntityUid entity)
+ {
+ if (TryComp(entity, out var idCard) && !string.IsNullOrEmpty(idCard.LocalizedJobTitle))
+ return idCard.LocalizedJobTitle;
+
+ if (_idCardSystem.TryFindIdCard(entity, out var foundIdCard) && !string.IsNullOrEmpty(foundIdCard.Comp.LocalizedJobTitle))
+ return foundIdCard.Comp.LocalizedJobTitle;
+
+ return Loc.GetString("overwatch-job-title-unknown");
+ }
+
+ ///
+ /// Получает ID отряда сущности.
+ ///
+ private int? GetSquadId(EntityUid entity)
+ {
+ if (TryComp(entity, out var squadComp))
+ return squadComp.SquadId;
+
+ return null;
+ }
+
+ ///
+ /// Получает название отряда сущности.
+ ///
+ private string GetSquadName(EntityUid entity)
+ {
+ if (TryComp(entity, out var squadComp) && !string.IsNullOrEmpty(squadComp.SquadName))
+ return squadComp.SquadName;
+
+ return "";
+ }
+
+ ///
+ /// Проверяет возможность использования сущности как цели для камеры.
+ ///
+ private bool IsValidCameraTarget(EntityUid target)
+ {
+ return HasWearableCamera(target);
+ }
+
+ ///
+ /// Получает название Overwatch для фракции.
+ ///
+ private string GetOverwatchTitle(string faction)
+ {
+ var key = faction switch
+ {
+ "TSF" => "overwatch-title-tsf",
+ "TSFHighComm" => "overwatch-title-tsfhighcomm",
+ "PDV" => "overwatch-title-pdv",
+ "USSP" => "overwatch-title-ussp",
+ "MD" => "overwatch-title-md",
+ "Colonial" => "overwatch-title-colonial",
+ "MMC" => "overwatch-title-mmc",
+ "TheViperGroup" => "overwatch-title-thevipergroup",
+ _ => "overwatch-title-default"
+ };
+ return Loc.GetString(key);
+ }
+
+ ///
+ /// Получает мировые координаты сущности.
+ ///
+ private Vector2? GetMemberCoordinates(EntityUid entity)
+ {
+ var worldPos = _transformSystem.GetWorldPosition(entity);
+ return new Vector2(worldPos.X, worldPos.Y);
+ }
+}
diff --git a/Content.Server/_Rat/Squad/SquadSystem.cs b/Content.Server/_Rat/Squad/SquadSystem.cs
new file mode 100644
index 00000000000..22283f7e60f
--- /dev/null
+++ b/Content.Server/_Rat/Squad/SquadSystem.cs
@@ -0,0 +1,132 @@
+using Content.Shared._Mono.Company;
+using Content.Shared._Rat.Squad;
+using Content.Shared.GameTicking;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server._Rat.Squad;
+
+///
+/// Система для управления отрядами.
+///
+public sealed class SquadSystem : EntitySystem
+{
+ // Храним список всех отрядов по фракциям: Faction -> (SquadId -> SquadInfo)
+ private readonly Dictionary> _squadsByFaction = new();
+ private int _nextSquadId = 1;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+ SubscribeLocalEvent(OnRoundRestartCleanup);
+ }
+
+ private void OnRoundRestartCleanup(RoundRestartCleanupEvent ev)
+ {
+ _squadsByFaction.Clear();
+ _nextSquadId = 1;
+ }
+
+ ///
+ /// Создать новый отряд.
+ ///
+ public bool CreateSquad(string faction, string squadName)
+ {
+ if (string.IsNullOrWhiteSpace(faction) || string.IsNullOrWhiteSpace(squadName))
+ return false;
+
+ if (!_squadsByFaction.ContainsKey(faction))
+ {
+ _squadsByFaction[faction] = new Dictionary();
+ }
+
+ var squadId = _nextSquadId++;
+
+ _squadsByFaction[faction][squadId] = new SquadInfo(squadId, squadName);
+ return true;
+ }
+
+ ///
+ /// Удалить отряд.
+ ///
+ public bool RemoveSquad(string faction, int squadId)
+ {
+ if (!_squadsByFaction.ContainsKey(faction))
+ return false;
+
+ if (!_squadsByFaction[faction].Remove(squadId))
+ return false;
+
+ var query = AllEntityQuery();
+ while (query.MoveNext(out var uid, out var squadComp))
+ {
+ if (squadComp.SquadId == squadId)
+ {
+ RemComp(uid);
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Назначить сущность в отряд.
+ ///
+ public bool AssignToSquad(EntityUid entity, int squadId, ProtoId faction)
+ {
+ if (!_squadsByFaction.TryGetValue(faction, out var factionSquads))
+ return false;
+
+ if (!factionSquads.TryGetValue(squadId, out var squadInfo))
+ return false;
+
+ if (!TryComp(entity, out var factionComp) ||
+ factionComp.CompanyName != faction)
+ return false;
+
+ var squadComp = EnsureComp(entity);
+ squadComp.SquadId = squadId;
+ squadComp.SquadName = squadInfo.Name;
+ Dirty(entity, squadComp, MetaData(entity));
+ return true;
+ }
+
+ ///
+ /// Удалить сущность из отряда.
+ ///
+ public void RemoveFromSquad(EntityUid entity)
+ {
+ RemComp(entity);
+ }
+
+ ///
+ /// Получить список всех отрядов фракции.
+ ///
+ public Dictionary GetFactionSquads(string faction)
+ {
+ if (!_squadsByFaction.ContainsKey(faction))
+ return new Dictionary();
+
+ return new Dictionary(_squadsByFaction[faction]);
+ }
+
+ ///
+ /// Получить количество членов в отряде.
+ ///
+ public int GetSquadMemberCount(int squadId)
+ {
+ var count = 0;
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var squadComp))
+ {
+ if (squadComp.SquadId == squadId)
+ count++;
+ }
+
+ return count;
+ }
+}
+
+///
+/// Информация об отряде.
+///
+public sealed record SquadInfo(int Id, string Name);
diff --git a/Content.Shared/_Mono/Company/CompanyAccessReader.cs b/Content.Shared/_Mono/Company/CompanyAccessReader.cs
index 97e81241fc2..58bbba18178 100644
--- a/Content.Shared/_Mono/Company/CompanyAccessReader.cs
+++ b/Content.Shared/_Mono/Company/CompanyAccessReader.cs
@@ -1,4 +1,5 @@
using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
namespace Content.Shared._Mono.Company;
@@ -11,12 +12,15 @@ public sealed partial class CompanyAccessReaderComponent : Component
///
/// The company ID that is required to access this entity.
///
- [DataField("requiredCompany")]
- public string RequiredCompany = string.Empty;
+ [DataField("requiredCompanies")]
+ public List> RequiredCompanies = [];
+
+ [DataField]
+ public bool Inverted = false;
///
/// Message to show when access is denied due to incorrect company.
///
[DataField("popupMessage")]
- public string? PopupMessage = "company-tsf-access-denied";
+ public string? PopupMessage = "company-neutral-access-denied";
}
diff --git a/Content.Shared/_Mono/Company/CompanyAccessReaderSystem.cs b/Content.Shared/_Mono/Company/CompanyAccessReaderSystem.cs
index 426921745ab..4ef02d5186d 100644
--- a/Content.Shared/_Mono/Company/CompanyAccessReaderSystem.cs
+++ b/Content.Shared/_Mono/Company/CompanyAccessReaderSystem.cs
@@ -32,8 +32,7 @@ private void OnUIOpenAttempt(Entity entity, ref Ac
return;
}
- // Check if user's company matches the required company
- if (userCompany.CompanyName != entity.Comp.RequiredCompany)
+ if (!entity.Comp.RequiredCompanies.Contains(userCompany.CompanyName) == !entity.Comp.Inverted)
{
args.Cancel();
if (entity.Comp.PopupMessage != null)
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchCameraComponent.cs b/Content.Shared/_Rat/Overwatch/OverwatchCameraComponent.cs
new file mode 100644
index 00000000000..86e08b5c033
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchCameraComponent.cs
@@ -0,0 +1,16 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Компонент для сущности, на которую смотрят через консоль Overwatch. - Comp for entity being viewed by overwatch
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class RatOverwatchCameraComponent : Component
+{
+ ///
+ /// Игроки, которые сейчас смотрят через камеру этой сущности. - Entities currently watching this one
+ ///
+ [DataField, AutoNetworkedField]
+ public HashSet Watching = new();
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchConsoleComponent.cs b/Content.Shared/_Rat/Overwatch/OverwatchConsoleComponent.cs
new file mode 100644
index 00000000000..1ca8cc844e6
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchConsoleComponent.cs
@@ -0,0 +1,36 @@
+using Content.Shared._Mono.Company;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Компонент консоли Overwatch для отслеживания членов фракции. - Comp for overwatch console
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class OverwatchConsoleComponent : Component
+{
+ ///
+ /// Фракция, которую отслеживает эта консоль. - Faction/company tracked by this console
+ ///
+ [DataField, AutoNetworkedField]
+ public ProtoId Faction = "None";
+
+ ///
+ /// Текущий фильтр по статусу. - Current filter for status
+ ///
+ [DataField, AutoNetworkedField]
+ public OverwatchMemberStatus? StatusFilter;
+
+ ///
+ /// Текущий фильтр по отряду. - Current filter for squads
+ ///
+ [DataField, AutoNetworkedField]
+ public int? SquadFilter;
+
+ ///
+ /// Текущий поисковый запрос. - Current search query
+ ///
+ [DataField, AutoNetworkedField]
+ public string SearchQuery = string.Empty;
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchMemberData.cs b/Content.Shared/_Rat/Overwatch/OverwatchMemberData.cs
new file mode 100644
index 00000000000..c7c2d4f07c4
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchMemberData.cs
@@ -0,0 +1,31 @@
+using System.Numerics;
+using Robust.Shared.Network;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Данные о члене фракции для отображения в консоли Overwatch.
+///
+[Serializable, NetSerializable]
+public sealed record OverwatchMemberData(
+ NetEntity Member,
+ string Name,
+ string JobTitle,
+ OverwatchMemberStatus Status,
+ bool HasCamera,
+ int? SquadId = null,
+ string SquadName = "",
+ Vector2? Coordinates = null
+);
+
+///
+/// Статус члена фракции для отображения в консоли Overwatch.
+///
+[Serializable, NetSerializable]
+public enum OverwatchMemberStatus : byte
+{
+ Alive,
+ Dead,
+ SSD
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchMessages.cs b/Content.Shared/_Rat/Overwatch/OverwatchMessages.cs
new file mode 100644
index 00000000000..30ab3483be1
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchMessages.cs
@@ -0,0 +1,272 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Запрос на обновление списка членов фракции.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchRefreshMessage : BoundUserInterfaceMessage
+{
+}
+
+///
+/// Состояние UI с данными о членах фракции.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchUpdateState : BoundUserInterfaceState
+{
+ ///
+ /// Список данных о членах фракции.
+ ///
+ public readonly List Members;
+
+ ///
+ /// Доступные отряды (ID -> Название).
+ ///
+ public readonly Dictionary AvailableSquads;
+
+ ///
+ /// Текущий фильтр по статусу.
+ ///
+ public readonly OverwatchMemberStatus? StatusFilter;
+
+ ///
+ /// Текущий фильтр по отряду.
+ ///
+ public readonly int? SquadFilter;
+
+ ///
+ /// Текущий поисковый запрос.
+ ///
+ public readonly string SearchQuery;
+
+ ///
+ /// Цвет фракции для UI.
+ ///
+ public readonly Color FactionColor;
+
+ public OverwatchUpdateState(
+ List members,
+ Dictionary? availableSquads = null,
+ OverwatchMemberStatus? statusFilter = null,
+ int? squadFilter = null,
+ string searchQuery = "",
+ Color factionColor = default)
+ {
+ Members = members;
+ AvailableSquads = availableSquads ?? new Dictionary();
+ StatusFilter = statusFilter;
+ SquadFilter = squadFilter;
+ SearchQuery = searchQuery;
+ FactionColor = factionColor;
+ }
+}
+
+///
+/// Запрос на просмотр камеры цели.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchViewCameraMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Сущность цели для наблюдения.
+ ///
+ public readonly NetEntity Target;
+
+ public OverwatchViewCameraMessage(NetEntity target)
+ {
+ Target = target;
+ }
+}
+
+///
+/// Запрос на остановку наблюдения.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchStopWatchingMessage : BoundUserInterfaceMessage
+{
+}
+
+///
+/// Запрос на установку фильтра по статусу.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchSetStatusFilterMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Статус для фильтрации.
+ ///
+ public readonly OverwatchMemberStatus? Status;
+
+ public OverwatchSetStatusFilterMessage(OverwatchMemberStatus? status)
+ {
+ Status = status;
+ }
+}
+
+///
+/// Запрос на установку фильтра по отряду.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchSetSquadFilterMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// ID отряда для фильтрации.
+ ///
+ public readonly int? SquadId;
+
+ public OverwatchSetSquadFilterMessage(int? squadId)
+ {
+ SquadId = squadId;
+ }
+}
+
+///
+/// Запрос на установку поискового запроса.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchSetSearchMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Строка поискового запроса.
+ ///
+ public readonly string SearchQuery;
+
+ public OverwatchSetSearchMessage(string searchQuery)
+ {
+ SearchQuery = searchQuery;
+ }
+}
+
+///
+/// Запрос на создание нового отряда.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchCreateSquadMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Название нового отряда.
+ ///
+ public readonly string SquadName;
+
+ public OverwatchCreateSquadMessage(string squadName)
+ {
+ SquadName = squadName;
+ }
+}
+
+///
+/// Запрос на удаление отряда.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchDeleteSquadMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// ID отряда для удаления.
+ ///
+ public readonly int SquadId;
+
+ public OverwatchDeleteSquadMessage(int squadId)
+ {
+ SquadId = squadId;
+ }
+}
+
+///
+/// Запрос на назначение игрока в отряд.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchAssignSquadMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Сущность игрока.
+ ///
+ public readonly NetEntity Player;
+
+ ///
+ /// ID отряда для назначения.
+ ///
+ public readonly int SquadId;
+
+ public OverwatchAssignSquadMessage(NetEntity player, int squadId)
+ {
+ Player = player;
+ SquadId = squadId;
+ }
+}
+
+///
+/// Запрос на удаление игрока из отряда.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchRemoveSquadMemberMessage : BoundUserInterfaceMessage
+{
+ ///
+ /// Сущность игрока.
+ ///
+ public readonly NetEntity Player;
+
+ public OverwatchRemoveSquadMemberMessage(NetEntity player)
+ {
+ Player = player;
+ }
+}
+
+///
+/// Запрос на отправку объявления.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchSendMessageAnnouncement : BoundUserInterfaceMessage
+{
+ ///
+ /// Текст объявления.
+ ///
+ public readonly string Message;
+
+ ///
+ /// ID целевого отряда или null для всей фракции.
+ ///
+ public readonly int? TargetSquadId;
+
+ public OverwatchSendMessageAnnouncement(string message, int? targetSquadId)
+ {
+ Message = message;
+ TargetSquadId = targetSquadId;
+ }
+}
+
+///
+/// Событие для отображения объявления Overwatch.
+///
+[Serializable, NetSerializable]
+public sealed class OverwatchAnnouncementEvent : EntityEventArgs
+{
+ ///
+ /// Текст объявления.
+ ///
+ public readonly string Message;
+
+ ///
+ /// Название получателя (фракция или отряд).
+ ///
+ public readonly string TargetName;
+
+ ///
+ /// Название Overwatch для фракции.
+ ///
+ public readonly string OverwatchTitle;
+
+ ///
+ /// Цвет текста объявления.
+ ///
+ public readonly Color Color;
+
+ public OverwatchAnnouncementEvent(string message, string targetName, string overwatchTitle, Color color)
+ {
+ Message = message;
+ TargetName = targetName;
+ OverwatchTitle = overwatchTitle;
+ Color = color;
+ }
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchRelayedSoundComponent.cs b/Content.Shared/_Rat/Overwatch/OverwatchRelayedSoundComponent.cs
new file mode 100644
index 00000000000..fb9be2e7a56
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchRelayedSoundComponent.cs
@@ -0,0 +1,13 @@
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Компонент для ретрансляции звуков при наблюдении через камеру Overwatch. - Comp for relaying sounds when viewing overwatch cameras
+///
+[RegisterComponent]
+public sealed partial class RatOverwatchRelayedSoundComponent : Component
+{
+ ///
+ /// Сущность ретранслируемого звука. - "The essence of the retransmitted sound." - thanks google translate i dont speak russian
+ ///
+ public EntityUid? Relay;
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchUiKey.cs b/Content.Shared/_Rat/Overwatch/OverwatchUiKey.cs
new file mode 100644
index 00000000000..9aa0402cac7
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchUiKey.cs
@@ -0,0 +1,12 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Ключ UI для консоли Overwatch.
+///
+[Serializable, NetSerializable]
+public enum OverwatchUiKey : byte
+{
+ Key
+}
diff --git a/Content.Shared/_Rat/Overwatch/OverwatchWatchingComponent.cs b/Content.Shared/_Rat/Overwatch/OverwatchWatchingComponent.cs
new file mode 100644
index 00000000000..314982e21ad
--- /dev/null
+++ b/Content.Shared/_Rat/Overwatch/OverwatchWatchingComponent.cs
@@ -0,0 +1,22 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Rat.Overwatch;
+
+///
+/// Компонент для игрока, который смотрит через камеру другого игрока через консоль Overwatch. - Component for viewing through worn cameras via overwatch.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class RatOverwatchWatchingComponent : Component
+{
+ ///
+ /// Сущность, за которой сейчас наблюдает игрок. - Currently observed entity
+ ///
+ [DataField, AutoNetworkedField]
+ public EntityUid? Watching;
+
+ ///
+ /// Сущность консоли Overwatch, которая управляет этим наблюдением. - Console being used
+ ///
+ [DataField, AutoNetworkedField]
+ public EntityUid? Console;
+}
diff --git a/Content.Shared/_Rat/Squad/SquadComponent.cs b/Content.Shared/_Rat/Squad/SquadComponent.cs
new file mode 100644
index 00000000000..f49bf1792a9
--- /dev/null
+++ b/Content.Shared/_Rat/Squad/SquadComponent.cs
@@ -0,0 +1,22 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared._Rat.Squad;
+
+///
+/// Компонент для принадлежности сущности к отряду. - Component for assigning an entity to a squad.
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+public sealed partial class SquadComponent : Component
+{
+ ///
+ /// ID отряда - Squad ID
+ ///
+ [DataField, AutoNetworkedField]
+ public int SquadId;
+
+ ///
+ /// Название отряда для отображения - Display name
+ ///
+ [DataField, AutoNetworkedField]
+ public string SquadName = string.Empty;
+}
diff --git a/Resources/Fonts/Fondamento-Regular.ttf b/Resources/Fonts/Fondamento-Regular.ttf
new file mode 100644
index 00000000000..2c5c7fb6901
Binary files /dev/null and b/Resources/Fonts/Fondamento-Regular.ttf differ
diff --git a/Resources/Locale/en-US/_Mono/company/company-access.ftl b/Resources/Locale/en-US/_Mono/company/company-access.ftl
index 701e5eea3c1..71186a00c54 100644
--- a/Resources/Locale/en-US/_Mono/company/company-access.ftl
+++ b/Resources/Locale/en-US/_Mono/company/company-access.ftl
@@ -1,3 +1,4 @@
# Company Access
company-tsf-access-denied = Access Denied: TSF biometrics required
company-pdv-access-denied = Access Denied: PDV biometrics required
+company-neutral-access-denied = Access Denied: invalid company biometrics
diff --git a/Resources/Locale/en-US/_Mono/overwatch/overwatch.ftl b/Resources/Locale/en-US/_Mono/overwatch/overwatch.ftl
new file mode 100644
index 00000000000..2f6a02f4926
--- /dev/null
+++ b/Resources/Locale/en-US/_Mono/overwatch/overwatch.ftl
@@ -0,0 +1,62 @@
+overwatch-window-title = Overwatch Console
+
+overwatch-filters-title = Filters
+overwatch-search-placeholder = Search by name...
+overwatch-status-label = STATUS:
+overwatch-squad-label = SQUAD:
+
+overwatch-status-all = All
+overwatch-status-alive = ALIVE
+overwatch-status-ssd = SSD
+overwatch-status-dead = DEAD
+
+overwatch-squad-all = ALL UNITS
+overwatch-squad-unassigned = UNASSIGNED
+
+overwatch-members-title = MEMBERS
+
+overwatch-admin-announcement-title = ANNOUNCEMENT
+overwatch-admin-announcement-to-label = TARGET:
+overwatch-admin-announcement-to-all = ALL
+overwatch-admin-announcement-placeholder = Type text...
+overwatch-admin-announcement-send = SEND ANNOUNCEMENT
+
+overwatch-admin-create-squad-title = CREATE SQUAD
+overwatch-admin-create-squad-placeholder = SQUAD NAME
+overwatch-admin-create-squad-button = +
+
+overwatch-admin-squads-title = SQUADS
+overwatch-squad-delete-button = DELETE
+overwatch-squad-member-count = ({ $count })
+
+overwatch-member-view-camera-button = VIEW CAM
+overwatch-member-watching-button = WATCHING
+overwatch-member-no-camera-button = NO CAMERA
+overwatch-member-squad-button = SQUAD
+overwatch-member-squad-assign = ASSIGN
+overwatch-member-squad-no-squad = UNASSIGNED
+overwatch-member-status-alive = ALIVE
+overwatch-member-status-ssd = SSD
+overwatch-member-status-dead = DEAD
+overwatch-member-status-unknown = UNK
+overwatch-member-coordinates = ({ $x }, { $y })
+overwatch-member-coordinates-none = —
+
+overwatch-stop-watching-button = STOP WATCHING
+
+overwatch-title-tsf = TSFMC HALCYON
+overwatch-title-tsfhighcomm = TSF HIGH COMMAND
+overwatch-title-pdv = PDV HELIOS
+overwatch-title-ussp = ST-NAV
+overwatch-title-md = CCTC MD
+overwatch-title-colonial = CCTC SC
+overwatch-title-mmc = COMP. NET
+overwatch-title-thevipergroup = V.NET
+overwatch-title-default = Overwatch
+
+overwatch-announcement-title = [{ $overwatchTitle }] - { $targetName }
+
+overwatch-announcement-target-all = ALL
+overwatch-announcement-target-squad = SQUAD
+
+overwatch-job-title-unknown = UNKNOWN
diff --git a/Resources/Locale/ru-RU/_Mono/company/company-access.ftl b/Resources/Locale/ru-RU/_Mono/company/company-access.ftl
index 57b87659d97..0f94f255015 100644
--- a/Resources/Locale/ru-RU/_Mono/company/company-access.ftl
+++ b/Resources/Locale/ru-RU/_Mono/company/company-access.ftl
@@ -1,3 +1,4 @@
# Company Access
company-tsf-access-denied = Доступ запрещён: биометрия ТСФ не распознана!
-company-pdv-access-denied = Доступ запрещён: биометрия Авангарда не распознана!
\ No newline at end of file
+company-pdv-access-denied = Доступ запрещён: биометрия Авангарда не распознана!
+company-neutral-access-denied = Доступ запрещён: биометрия не распознана.
diff --git a/Resources/Locale/ru-RU/_Mono/overwatch/overwatch.ftl b/Resources/Locale/ru-RU/_Mono/overwatch/overwatch.ftl
new file mode 100644
index 00000000000..6c61c58544e
--- /dev/null
+++ b/Resources/Locale/ru-RU/_Mono/overwatch/overwatch.ftl
@@ -0,0 +1,62 @@
+overwatch-window-title = Консоль наблюдения
+
+overwatch-filters-title = Фильтры
+overwatch-search-placeholder = Поиск по имени...
+overwatch-status-label = СТАТУС:
+overwatch-squad-label = ОТРЯД:
+
+overwatch-status-all = ВСЕ
+overwatch-status-alive = ЖИВОЙ
+overwatch-status-ssd = КРС
+overwatch-status-dead = МЁРТВ
+
+overwatch-squad-all = ВСЕ ЕДИНИЦЫ
+overwatch-squad-unassigned = НЕ НАЗНАЧЕН
+
+overwatch-members-title = УЧАСТНИКИ
+
+overwatch-admin-announcement-title = ОБЪЯВЛЕНИЕ
+overwatch-admin-announcement-to-label = ЦЕЛЬ:
+overwatch-admin-announcement-to-all = ВСЕ
+overwatch-admin-announcement-placeholder = Введите текст...
+overwatch-admin-announcement-send = ОТПРАВИТЬ ОБЪЯВЛЕНИЕ
+
+overwatch-admin-create-squad-title = СОЗДАТЬ ОТРЯД
+overwatch-admin-create-squad-placeholder = НАЗВАНИЕ ОТРЯДА
+overwatch-admin-create-squad-button = +
+
+overwatch-admin-squads-title = ОТРЯДЫ
+overwatch-squad-delete-button = УДАЛИТЬ
+overwatch-squad-member-count = ({ $count })
+
+overwatch-member-view-camera-button = СМОТРЕТЬ КАМЕРУ
+overwatch-member-watching-button = НАБЛЮДЕНИЕ
+overwatch-member-no-camera-button = НЕТ КАМЕРЫ
+overwatch-member-squad-button = ОТРЯД
+overwatch-member-squad-assign = НАЗНАЧИТЬ
+overwatch-member-squad-no-squad = НЕ НАЗНАЧЕН
+overwatch-member-status-alive = ЖИВОЙ
+overwatch-member-status-ssd = КРС
+overwatch-member-status-dead = МЁРТВ
+overwatch-member-status-unknown = Н/О
+overwatch-member-coordinates = ({ $x }, { $y })
+overwatch-member-coordinates-none = —
+
+overwatch-stop-watching-button = ПРЕКРАТИТЬ НАБЛЮДЕНИЕ
+
+overwatch-title-tsf = TSFMC HALCYON
+overwatch-title-tsfhighcomm = TSF ВЫСШЕЕ КОМАНДОВАНИЕ
+overwatch-title-pdv = NF HELIOS
+overwatch-title-ussp = ST-NAV
+overwatch-title-md = CCTC MD
+overwatch-title-colonial = CCTC SC
+overwatch-title-mmc = COMP. NET
+overwatch-title-thevipergroup = V.NET
+overwatch-title-default = Наблюдение
+
+overwatch-announcement-title = [{ $overwatchTitle }] - { $targetName }
+
+overwatch-announcement-target-all = ВСЕ
+overwatch-announcement-target-squad = ОТРЯД
+
+overwatch-job-title-unknown = НЕИЗВЕСТНО
diff --git a/Resources/Locale/ru-RU/ss14-ru/prototypes/_Mono/entities/structures/machines/computers/overwatch.ftl b/Resources/Locale/ru-RU/ss14-ru/prototypes/_Mono/entities/structures/machines/computers/overwatch.ftl
new file mode 100644
index 00000000000..e9027111964
--- /dev/null
+++ b/Resources/Locale/ru-RU/ss14-ru/prototypes/_Mono/entities/structures/machines/computers/overwatch.ftl
@@ -0,0 +1,49 @@
+ent-BaseComputerOverwatch = консоль наблюдения
+ .desc = Консоль, используемая для отслеживания членов фракции с помощью бодикамер.
+
+ent-TSFOverwatchComputer = консоль наблюдения "ТСФ"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-PDVOverwatchComputer = консоль наблюдения "Авангарда"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-MMCOverwatchComputer = консоль наблюдения MMC
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-MDOverwatchComputer = консоль наблюдения "Медицинского центра"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-CCTCOverwatchComputer = консоль наблюдения "Коллос централа"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-USSPOverwatchComputer = консоль наблюдения "УССП"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+ent-VGOverwatchComputer = консоль наблюдения "Вайперов"
+ .desc = { ent-BaseComputerOverwatch.desc }
+
+# handled
+
+ent-BaseHandheldOverwatchConsole = портативная консоль наблюдения
+ .desc = Отображает информацию о членах отряда фракции
+
+ent-TSFHandheldOverwatchConsole = портативная консоль наблюдения "ТСФ"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-PDVHandheldOverwatchConsole = портативная консоль наблюдения "Авангарда"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-MMCHandheldOverwatchConsole = портативная консоль наблюдения MMC
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-MDHandheldOverwatchConsole = портативная консоль наблюдения "Медицинского центра"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-CCHandheldOverwatchConsole = портативная консоль наблюдения "Коллос централа"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-USSPHandheldOverwatchConsole = портативная консоль наблюдения "УССП"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
+
+ent-VGHandheldOverwatchConsole = портативная консоль наблюдения "Вайперов"
+ .desc = { ent-BaseHandheldOverwatchConsole.desc }
diff --git a/Resources/Prototypes/_Mono/Entities/Structures/Machines/Computers/overwatch.yml b/Resources/Prototypes/_Mono/Entities/Structures/Machines/Computers/overwatch.yml
new file mode 100644
index 00000000000..cdfffef27e5
--- /dev/null
+++ b/Resources/Prototypes/_Mono/Entities/Structures/Machines/Computers/overwatch.yml
@@ -0,0 +1,219 @@
+- type: entity
+ parent: [BaseStructureDisableToolUse, BaseStructureDestructible, BaseComputerAiAccess]
+ id: BaseComputerOverwatch
+ name: overwatch console
+ description: A computer console used to track faction members via wearable cameras.
+ abstract: true
+ placement:
+ mode: SnapgridCenter
+ components:
+ - type: OverwatchConsole
+ - type: StationAiWhitelist
+ - type: Sprite
+ layers:
+ - map: ["computerLayerBody"]
+ state: computer
+ - map: ["computerLayerKeyboard"]
+ state: generic_keyboard
+ - map: ["computerLayerScreen"]
+ state: comm
+ - map: ["computerLayerKeys"]
+ state: generic_keys
+ - map: [ "enum.WiresVisualLayers.MaintenancePanel" ]
+ state: generic_panel_open
+ - type: ActivatableUI
+ key: enum.OverwatchUiKey.Key
+ - type: UserInterface
+ interfaces:
+ enum.OverwatchUiKey.Key:
+ type: OverwatchBoundUserInterface
+ enum.WiresUiKey.Key:
+ type: WiresBoundUserInterface
+ - type: PointLight
+ radius: 1.5
+ energy: 1.6
+ color: "#3c5eb5"
+ - type: Damageable
+ damageContainer: StructuralInorganic
+ damageModifierSet: StructuralMetallicStrong
+ - type: ShipRepairable
+ repairTime: 4
+ repairCost: 8
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: TSFOverwatchComputer
+ name: TSFMC overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: TSF
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - TSF
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: PDVOverwatchComputer
+ name: PDV overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: PDV
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - PDV
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: MMCOverwatchComputer
+ name: MMC overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: MMC
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - MMC
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: MDOverwatchComputer
+ name: MD overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: MD
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - MD
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: CCTCOverwatchComputer
+ name: CCTC overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: Colonial
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - Colonial
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: USSPOverwatchComputer
+ name: USSP overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: USSP
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - USSP
+
+- type: entity
+ parent: BaseComputerOverwatch
+ id: VGOverwatchComputer
+ name: VG overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: TheViperGroup
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - TheViperGroup
+
+# Handheld
+- type: entity
+ parent: [BaseHandheldComputer, BaseItem]
+ id: BaseHandheldOverwatchConsole
+ name: handheld overwatch console
+ description: Displays a readout of faction squad members.
+ abstract: true
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/tablets.rsi
+ layers:
+ - state: tablet
+ - state: overwatch
+ shader: unshaded
+ - type: OverwatchConsole
+ - type: ActivatableUI
+ inHandsOnly: true
+ singleUser: true
+ key: enum.OverwatchUiKey.Key
+ - type: UserInterface
+ interfaces:
+ enum.OverwatchUiKey.Key:
+ type: OverwatchBoundUserInterface
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: TSFHandheldOverwatchConsole
+ name: TSFMC handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: TSF
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - TSF
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: PDVHandheldOverwatchConsole
+ name: PDV handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: PDV
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - PDV
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: MMCHandheldOverwatchConsole
+ name: MMC handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: MMC
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - MMC
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: MDHandheldOverwatchConsole
+ name: MD handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: MD
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - MD
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: CCHandheldOverwatchConsole
+ name: CCTC handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: Colonial
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - Colonial
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: USSPHandheldOverwatchConsole
+ name: USSP handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: USSP
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - USSP
+
+- type: entity
+ parent: BaseHandheldOverwatchConsole
+ id: VGHandheldOverwatchConsole
+ name: VG handheld overwatch console
+ components:
+ - type: OverwatchConsole
+ faction: TheViperGroup
+ - type: CompanyAccessReader
+ requiredCompanies:
+ - TheViperGroup
diff --git a/Resources/Prototypes/_Mono/Entities/Structures/Machines/uplinks.yml b/Resources/Prototypes/_Mono/Entities/Structures/Machines/uplinks.yml
index 8c36591b883..ea5a7557d02 100644
--- a/Resources/Prototypes/_Mono/Entities/Structures/Machines/uplinks.yml
+++ b/Resources/Prototypes/_Mono/Entities/Structures/Machines/uplinks.yml
@@ -45,7 +45,8 @@
- type: AccessReader
access: [["Security"]]
- type: CompanyAccessReader
- requiredCompany: TSF
+ requiredCompanies:
+ - TSF
- type: Tag
tags:
- SecurityUplink
@@ -90,7 +91,8 @@
- type: AccessReader
access: [["Pirate"]]
- type: CompanyAccessReader
- requiredCompany: PDV
+ requiredCompanies:
+ - PDV
popupMessage: company-pdv-access-denied
- type: Tag
tags:
diff --git a/Resources/Prototypes/_Mono/Roles/Jobs/UnionOfSovietSocialistPlanets/commissar.yml b/Resources/Prototypes/_Mono/Roles/Jobs/UnionOfSovietSocialistPlanets/commissar.yml
index 6ebce24b1e9..1e9a795b596 100644
--- a/Resources/Prototypes/_Mono/Roles/Jobs/UnionOfSovietSocialistPlanets/commissar.yml
+++ b/Resources/Prototypes/_Mono/Roles/Jobs/UnionOfSovietSocialistPlanets/commissar.yml
@@ -51,3 +51,4 @@
storage:
back:
- RadioHandheldNF
+ - USSPHandheldOverwatchConsole
diff --git a/Resources/Prototypes/_Mono/companies.yml b/Resources/Prototypes/_Mono/companies.yml
index f313633db33..7de9beb8429 100644
--- a/Resources/Prototypes/_Mono/companies.yml
+++ b/Resources/Prototypes/_Mono/companies.yml
@@ -83,6 +83,12 @@
description: ussp-description
image: /Textures/_Mono/Companies/USSP.png
+- type: company
+ id: MMC
+ name: Mieyo Manufacturing Concern
+ color: "#EEEEEE"
+ disabled: true
+
# Major companies
- type: company
diff --git a/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_shipyard.yml b/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_shipyard.yml
index 1437a315413..8cbeea4b3eb 100644
--- a/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_shipyard.yml
+++ b/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_shipyard.yml
@@ -50,6 +50,11 @@
components:
- type: ShipyardConsole
newJobTitle: Captain
+ # - type: CompanyAccessReader # LuaM delete
+ # requiredCompanies:
+ # - TSF
+ # - PDV
+ # inverted: true
- type: entity
id: ComputerShipyardSecurity
@@ -190,6 +195,11 @@
state: shipyard_expedition # Mono
- map: ["computerLayerKeys"]
state: telesci_key # Mono
+ # - type: CompanyAccessReader # LuaM delete
+ # requiredCompanies:
+ # - TSF
+ # - PDV
+ # inverted: true
- type: entity
id: ComputerShipyardScrap
@@ -214,6 +224,11 @@
state: shipyard_scrapyard # Mono
- map: ["computerLayerKeys"]
state: telesci_key # Mono
+ # - type: CompanyAccessReader # LuaM delete
+ # requiredCompanies:
+ # - TSF
+ # - PDV
+ # inverted: true
- type: entity
id: ComputerShipyardMedical
@@ -238,6 +253,11 @@
state: shipyard_medical
- map: ["computerLayerKeys"]
state: telesci_key
+ # - type: CompanyAccessReader # LuaM delete
+ # requiredCompanies:
+ # - TSF
+ # - PDV
+ # inverted: true
- type: entity
id: ComputerShipyardSr
diff --git a/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_tabletop.yml b/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_tabletop.yml
index 3118fbc2f4c..d697691933e 100644
--- a/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_tabletop.yml
+++ b/Resources/Prototypes/_NF/Entities/Structures/Machines/Computers/computers_tabletop.yml
@@ -639,7 +639,8 @@
- type: StationBankATM
account: Frontier
- type: CompanyAccessReader
- requiredCompany: Colonial
+ requiredCompanies:
+ - Colonial
- type: AccessReader
access: [["HeadOfPersonnel"]]
@@ -652,7 +653,8 @@
- type: StationBankATM
account: Nfsd
- type: CompanyAccessReader
- requiredCompany: TSF
+ requiredCompanies:
+ - TSF
- type: AccessReader
access: [["HeadOfSecurity"]]
@@ -665,7 +667,8 @@
- type: StationBankATM
account: Medical
- type: CompanyAccessReader
- requiredCompany: MD
+ requiredCompanies:
+ - MD
- type: AccessReader
access: [["ChiefMedicalOfficer"]]
diff --git a/Resources/Prototypes/_NF/Entities/Structures/atm.yml b/Resources/Prototypes/_NF/Entities/Structures/atm.yml
index a2d53a18f8c..7ae7a23e34f 100644
--- a/Resources/Prototypes/_NF/Entities/Structures/atm.yml
+++ b/Resources/Prototypes/_NF/Entities/Structures/atm.yml
@@ -270,7 +270,8 @@
- type: StationBankATM
account: Frontier
- type: CompanyAccessReader
- requiredCompany: Colonial
+ requiredCompanies:
+ - Colonial
- type: AccessReader
access: [["HeadOfPersonnel"]]
@@ -283,7 +284,8 @@
- type: StationBankATM
account: Nfsd
- type: CompanyAccessReader
- requiredCompany: TSF
+ requiredCompanies:
+ - TSF
- type: AccessReader
access: [["HeadOfSecurity"]]
@@ -296,7 +298,8 @@
- type: StationBankATM
account: Medical
- type: CompanyAccessReader
- requiredCompany: MD
+ requiredCompanies:
+ - MD
- type: AccessReader
access: [["ChiefMedicalOfficer"]]
@@ -327,5 +330,6 @@
- type: AccessReader
access: [["Pirate"]]
- type: CompanyAccessReader
- requiredCompany: PDV
- popupMessage: company-pdv-access-denied # LuaM
\ No newline at end of file
+ popupMessage: company-pdv-access-denied # LuaM
+ requiredCompanies:
+ - PDV
diff --git a/Resources/Prototypes/_NF/Roles/Jobs/Frontier/sr.yml b/Resources/Prototypes/_NF/Roles/Jobs/Frontier/sr.yml
index 6a8576b3887..ccfa6dd2b6d 100644
--- a/Resources/Prototypes/_NF/Roles/Jobs/Frontier/sr.yml
+++ b/Resources/Prototypes/_NF/Roles/Jobs/Frontier/sr.yml
@@ -64,3 +64,4 @@
- EncryptionKeyDiplomacy
- ShipVoucherFrontierGuard
- PhoneInstrumentHigh
+ - CCHandheldOverwatchConsole
diff --git a/Resources/Prototypes/_NF/Roles/Jobs/Medical/doc.yml b/Resources/Prototypes/_NF/Roles/Jobs/Medical/doc.yml
index dabdfdab713..5fb6fca9077 100644
--- a/Resources/Prototypes/_NF/Roles/Jobs/Medical/doc.yml
+++ b/Resources/Prototypes/_NF/Roles/Jobs/Medical/doc.yml
@@ -57,3 +57,4 @@
- BoxFolderMedicalDoctor
- StationAdministrationConsoleFlatpack
- PhoneInstrumentHigh
+ - MDHandheldOverwatchConsole
diff --git a/Resources/Prototypes/_NF/Roles/Jobs/Nfsd/sheriff.yml b/Resources/Prototypes/_NF/Roles/Jobs/Nfsd/sheriff.yml
index 64a96e6ded7..315929cbf75 100644
--- a/Resources/Prototypes/_NF/Roles/Jobs/Nfsd/sheriff.yml
+++ b/Resources/Prototypes/_NF/Roles/Jobs/Nfsd/sheriff.yml
@@ -66,3 +66,4 @@
- BaseSecurityUplinkRadioSheriff
- PhoneInstrumentHigh
- RubberStampTSFColonel #Mono. Look, it had comments on the Bailiff/Captain file.
+ - TSFHandheldOverwatchConsole
diff --git a/Resources/Prototypes/_NF/Roles/Jobs/Pirates/pirate_captain.yml b/Resources/Prototypes/_NF/Roles/Jobs/Pirates/pirate_captain.yml
index 00c5e19076c..7333c432eb2 100644
--- a/Resources/Prototypes/_NF/Roles/Jobs/Pirates/pirate_captain.yml
+++ b/Resources/Prototypes/_NF/Roles/Jobs/Pirates/pirate_captain.yml
@@ -56,3 +56,4 @@
- RadioHandheldNF # mono
- PhoneInstrumentHigh
- RubberStampPDVVizier # mono
+ - PDVHandheldOverwatchConsole
diff --git a/Resources/Textures/Objects/Devices/tablets.rsi/meta.json b/Resources/Textures/Objects/Devices/tablets.rsi/meta.json
index 5af056f2915..b0154d493d8 100644
--- a/Resources/Textures/Objects/Devices/tablets.rsi/meta.json
+++ b/Resources/Textures/Objects/Devices/tablets.rsi/meta.json
@@ -21,6 +21,15 @@
0.3
]
]
+ },
+ {
+ "name": "overwatch",
+ "delays": [
+ [
+ 0.3,
+ 0.3
+ ]
+ ]
}
]
}
diff --git a/Resources/Textures/Objects/Devices/tablets.rsi/overwatch.png b/Resources/Textures/Objects/Devices/tablets.rsi/overwatch.png
new file mode 100644
index 00000000000..7a3420eb6f3
Binary files /dev/null and b/Resources/Textures/Objects/Devices/tablets.rsi/overwatch.png differ