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 @@ + + + + + + + + + + + +