using Content.Client.Chat.Managers;
using Content.Shared.CCVar;
using Content.Shared.Chat;
+using Content.Shared.Speech;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
Modulate = Color.White;
}
- var offset = (-_eyeManager.CurrentEye.Rotation).ToWorldVec() * -EntityVerticalOffset;
+ var baseOffset = 0f;
+
+ if (_entityManager.TryGetComponent<SpeechComponent>(_senderEntity, out var speech))
+ baseOffset = speech.SpeechBubbleOffset;
+
+ var offset = (-_eyeManager.CurrentEye.Rotation).ToWorldVec() * -(EntityVerticalOffset + baseOffset);
var worldPos = _transformSystem.GetWorldPosition(xform) + offset;
var lowerCenter = _eyeManager.WorldToScreen(worldPos) / UIScale;
--- /dev/null
+using Content.Shared.Holopad;
+using Content.Shared.Silicons.StationAi;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface;
+using Robust.Shared.Player;
+using System.Numerics;
+
+namespace Content.Client.Holopad;
+
+public sealed class HolopadBoundUserInterface : BoundUserInterface
+{
+ [Dependency] private readonly ISharedPlayerManager _playerManager = default!;
+ [Dependency] private readonly IClyde _displayManager = default!;
+
+ [ViewVariables]
+ private HolopadWindow? _window;
+
+ public HolopadBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
+ {
+ IoCManager.InjectDependencies(this);
+ }
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = this.CreateWindow<HolopadWindow>();
+ _window.Title = Loc.GetString("holopad-window-title", ("title", EntMan.GetComponent<MetaDataComponent>(Owner).EntityName));
+
+ if (this.UiKey is not HolopadUiKey)
+ {
+ Close();
+ return;
+ }
+
+ var uiKey = (HolopadUiKey)this.UiKey;
+
+ // AIs will see a different holopad interface to crew when interacting with them in the world
+ if (uiKey == HolopadUiKey.InteractionWindow && EntMan.HasComponent<StationAiHeldComponent>(_playerManager.LocalEntity))
+ uiKey = HolopadUiKey.InteractionWindowForAi;
+
+ _window.SetState(Owner, uiKey);
+ _window.UpdateState(new Dictionary<NetEntity, string>());
+
+ // Set message actions
+ _window.SendHolopadStartNewCallMessageAction += SendHolopadStartNewCallMessage;
+ _window.SendHolopadAnswerCallMessageAction += SendHolopadAnswerCallMessage;
+ _window.SendHolopadEndCallMessageAction += SendHolopadEndCallMessage;
+ _window.SendHolopadStartBroadcastMessageAction += SendHolopadStartBroadcastMessage;
+ _window.SendHolopadActivateProjectorMessageAction += SendHolopadActivateProjectorMessage;
+ _window.SendHolopadRequestStationAiMessageAction += SendHolopadRequestStationAiMessage;
+
+ // If this call is addressed to an AI, open the window in the bottom right hand corner of the screen
+ if (uiKey == HolopadUiKey.AiRequestWindow)
+ _window.OpenCenteredAt(new Vector2(1f, 1f));
+
+ // Otherwise offset to the left so the holopad can still be seen
+ else
+ _window.OpenCenteredAt(new Vector2(0.3333f, 0.50f));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ base.UpdateState(state);
+
+ var castState = (HolopadBoundInterfaceState)state;
+ EntMan.TryGetComponent<TransformComponent>(Owner, out var xform);
+
+ _window?.UpdateState(castState.Holopads);
+ }
+
+ public void SendHolopadStartNewCallMessage(NetEntity receiver)
+ {
+ SendMessage(new HolopadStartNewCallMessage(receiver));
+ }
+
+ public void SendHolopadAnswerCallMessage()
+ {
+ SendMessage(new HolopadAnswerCallMessage());
+ }
+
+ public void SendHolopadEndCallMessage()
+ {
+ SendMessage(new HolopadEndCallMessage());
+ }
+
+ public void SendHolopadStartBroadcastMessage()
+ {
+ SendMessage(new HolopadStartBroadcastMessage());
+ }
+
+ public void SendHolopadActivateProjectorMessage()
+ {
+ SendMessage(new HolopadActivateProjectorMessage());
+ }
+
+ public void SendHolopadRequestStationAiMessage()
+ {
+ SendMessage(new HolopadStationAiRequestMessage());
+ }
+}
--- /dev/null
+using Content.Shared.Chat.TypingIndicator;
+using Content.Shared.Holopad;
+using Robust.Client.GameObjects;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+using System.Linq;
+
+namespace Content.Client.Holopad;
+
+public sealed class HolopadSystem : SharedHolopadSystem
+{
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<HolopadHologramComponent, ComponentInit>(OnComponentInit);
+ SubscribeLocalEvent<HolopadHologramComponent, BeforePostShaderRenderEvent>(OnShaderRender);
+ SubscribeAllEvent<TypingChangedEvent>(OnTypingChanged);
+
+ SubscribeNetworkEvent<PlayerSpriteStateRequest>(OnPlayerSpriteStateRequest);
+ SubscribeNetworkEvent<PlayerSpriteStateMessage>(OnPlayerSpriteStateMessage);
+ }
+
+ private void OnComponentInit(EntityUid uid, HolopadHologramComponent component, ComponentInit ev)
+ {
+ if (!TryComp<SpriteComponent>(uid, out var sprite))
+ return;
+
+ UpdateHologramSprite(uid);
+ }
+
+ private void OnShaderRender(EntityUid uid, HolopadHologramComponent component, BeforePostShaderRenderEvent ev)
+ {
+ if (ev.Sprite.PostShader == null)
+ return;
+
+ ev.Sprite.PostShader.SetParameter("t", (float)_timing.CurTime.TotalSeconds * component.ScrollRate);
+ }
+
+ private void OnTypingChanged(TypingChangedEvent ev, EntitySessionEventArgs args)
+ {
+ var uid = args.SenderSession.AttachedEntity;
+
+ if (!Exists(uid))
+ return;
+
+ if (!HasComp<HolopadUserComponent>(uid))
+ return;
+
+ var netEv = new HolopadUserTypingChangedEvent(GetNetEntity(uid.Value), ev.IsTyping);
+ RaiseNetworkEvent(netEv);
+ }
+
+ private void OnPlayerSpriteStateRequest(PlayerSpriteStateRequest ev)
+ {
+ var targetPlayer = GetEntity(ev.TargetPlayer);
+ var player = _playerManager.LocalSession?.AttachedEntity;
+
+ // Ignore the request if received by a player who isn't the target
+ if (targetPlayer != player)
+ return;
+
+ if (!TryComp<SpriteComponent>(player, out var playerSprite))
+ return;
+
+ var spriteLayerData = new List<PrototypeLayerData>();
+
+ if (playerSprite.Visible)
+ {
+ // Record the RSI paths, state names and shader paramaters of all visible layers
+ for (int i = 0; i < playerSprite.AllLayers.Count(); i++)
+ {
+ if (!playerSprite.TryGetLayer(i, out var layer))
+ continue;
+
+ if (!layer.Visible ||
+ string.IsNullOrEmpty(layer.ActualRsi?.Path.ToString()) ||
+ string.IsNullOrEmpty(layer.State.Name))
+ continue;
+
+ var layerDatum = new PrototypeLayerData();
+ layerDatum.RsiPath = layer.ActualRsi.Path.ToString();
+ layerDatum.State = layer.State.Name;
+
+ if (layer.CopyToShaderParameters != null)
+ {
+ var key = (string)layer.CopyToShaderParameters.LayerKey;
+
+ if (playerSprite.LayerMapTryGet(key, out var otherLayerIdx) &&
+ playerSprite.TryGetLayer(otherLayerIdx, out var otherLayer) &&
+ otherLayer.Visible)
+ {
+ layerDatum.MapKeys = new() { key };
+
+ layerDatum.CopyToShaderParameters = new PrototypeCopyToShaderParameters()
+ {
+ LayerKey = key,
+ ParameterTexture = layer.CopyToShaderParameters.ParameterTexture,
+ ParameterUV = layer.CopyToShaderParameters.ParameterUV
+ };
+ }
+ }
+
+ spriteLayerData.Add(layerDatum);
+ }
+ }
+
+ // Return the recorded data to the server
+ var evResponse = new PlayerSpriteStateMessage(ev.TargetPlayer, spriteLayerData.ToArray());
+ RaiseNetworkEvent(evResponse);
+ }
+
+ private void OnPlayerSpriteStateMessage(PlayerSpriteStateMessage ev)
+ {
+ UpdateHologramSprite(GetEntity(ev.SpriteEntity), ev.SpriteLayerData);
+ }
+
+ private void UpdateHologramSprite(EntityUid uid, PrototypeLayerData[]? layerData = null)
+ {
+ if (!TryComp<SpriteComponent>(uid, out var hologramSprite))
+ return;
+
+ if (!TryComp<HolopadHologramComponent>(uid, out var holopadhologram))
+ return;
+
+ for (int i = hologramSprite.AllLayers.Count() - 1; i >= 0; i--)
+ hologramSprite.RemoveLayer(i);
+
+ if (layerData == null || layerData.Length == 0)
+ {
+ layerData = new PrototypeLayerData[1];
+ layerData[0] = new PrototypeLayerData()
+ {
+ RsiPath = holopadhologram.RsiPath,
+ State = holopadhologram.RsiState
+ };
+ }
+
+ for (int i = 0; i < layerData.Length; i++)
+ {
+ var layer = layerData[i];
+ layer.Shader = "unshaded";
+
+ hologramSprite.AddLayer(layerData[i], i);
+ }
+
+ UpdateHologramShader(uid, hologramSprite, holopadhologram);
+ }
+
+ private void UpdateHologramShader(EntityUid uid, SpriteComponent sprite, HolopadHologramComponent holopadHologram)
+ {
+ // Find the texture height of the largest layer
+ float texHeight = sprite.AllLayers.Max(x => x.PixelSize.Y);
+
+ var instance = _prototypeManager.Index<ShaderPrototype>(holopadHologram.ShaderName).InstanceUnique();
+ instance.SetParameter("color1", new Vector3(holopadHologram.Color1.R, holopadHologram.Color1.G, holopadHologram.Color1.B));
+ instance.SetParameter("color2", new Vector3(holopadHologram.Color2.R, holopadHologram.Color2.G, holopadHologram.Color2.B));
+ instance.SetParameter("alpha", holopadHologram.Alpha);
+ instance.SetParameter("intensity", holopadHologram.Intensity);
+ instance.SetParameter("texHeight", texHeight);
+ instance.SetParameter("t", (float)_timing.CurTime.TotalSeconds * holopadHologram.ScrollRate);
+
+ sprite.PostShader = instance;
+ sprite.RaiseShaderEvent = true;
+ }
+}
--- /dev/null
+<controls:FancyWindow xmlns="https://spacestation14.io"
+ xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+ xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
+ Resizable="False"
+ MaxSize="400 800"
+ MinSize="400 150">
+ <BoxContainer Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True">
+
+ <BoxContainer Name="ControlsLockOutContainer" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True" ReservesSpace="False" Visible="False">
+ <!-- Header text -->
+ <controls:StripeBack>
+ <PanelContainer>
+ <RichTextLabel Name="EmergencyBroadcastText" VerticalAlignment="Center" HorizontalAlignment="Center" Margin="10 10 10 10" ReservesSpace="False"/>
+ </PanelContainer>
+ </controls:StripeBack>
+
+ <Label Text="{Loc 'holopad-window-controls-locked-out'}" HorizontalAlignment="Center" Margin="10 5 10 0" ReservesSpace="False"/>
+ <RichTextLabel Name="LockOutIdText" HorizontalAlignment="Center" Margin="10 5 10 0" ReservesSpace="False"/>
+ <Label Name="LockOutCountDownText" Text="{Loc 'holopad-window-controls-unlock-countdown'}" HorizontalAlignment="Center" Margin="10 15 10 10" ReservesSpace="False"/>
+ </BoxContainer>
+
+ <BoxContainer Name="ControlsContainer" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True" ReservesSpace="False">
+
+ <!-- Active call controls (either this or the call placement controls will be active) -->
+ <BoxContainer Name="ActiveCallControlsContainer" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True" ReservesSpace="False">
+
+ <!-- Header text -->
+ <BoxContainer MinHeight="60" Orientation="Vertical" VerticalAlignment="Center">
+ <Label Name="CallStatusText" Margin="10 5 10 0" ReservesSpace="False"/>
+ <RichTextLabel Name="CallerIdText" HorizontalAlignment="Center" Margin="0 0 0 0" ReservesSpace="False"/>
+ </BoxContainer>
+
+ <!-- Controls (the answer call button is absent when the phone is not ringing) -->
+ <GridContainer Columns="2" ReservesSpace="False">
+ <Control HorizontalExpand="True" Margin="10 0 2 5">
+ <Button Name="AnswerCallButton" Text="{Loc 'holopad-window-answer-call'}" StyleClasses="OpenRight" Margin="0 0 0 5" Disabled="True"/>
+ </Control>
+ <Control HorizontalExpand="True" Margin="2 0 10 5">
+ <Button Name="EndCallButton" Text="{Loc 'holopad-window-end-call'}" StyleClasses="OpenLeft" Margin="0 0 0 5" Disabled="True"/>
+ </Control>
+ </GridContainer>
+
+ </BoxContainer>
+
+ <!-- Call placement controls (either this or the active call controls will be active) -->
+ <BoxContainer Name="CallPlacementControlsContainer" Orientation="Vertical" VerticalExpand="True" HorizontalExpand="True" ReservesSpace="False">
+
+ <controls:StripeBack>
+ <PanelContainer>
+ <BoxContainer Orientation="Vertical">
+ <RichTextLabel Name="SubtitleText" HorizontalAlignment="Center" Margin="0 5 0 0"/>
+ <RichTextLabel Name="OptionsText" HorizontalAlignment="Center" Margin="0 0 0 5"/>
+ </BoxContainer>
+ </PanelContainer>
+ </controls:StripeBack>
+
+ <!-- Request the station AI or activate the holopad projector (only one of these should be active at a time) -->
+ <BoxContainer Name="RequestStationAiContainer" Orientation="Vertical" ReservesSpace="False" Visible="False">
+ <Button Name="RequestStationAiButton" Text="{Loc 'holopad-window-request-station-ai'}" Margin="10 5 10 5" Disabled="False"/>
+ </BoxContainer>
+
+ <BoxContainer Name="ActivateProjectorContainer" Orientation="Vertical" ReservesSpace="False" Visible="False">
+ <Button Name="ActivateProjectorButton" Text="{Loc 'holopad-window-activate-projector'}" Margin="10 5 10 5" Disabled="False"/>
+ </BoxContainer>
+
+ <!-- List of contactable holopads (the list is created in C#) -->
+ <BoxContainer Name="HolopadContactListContainer" Orientation="Vertical" Margin="10 0 10 5" ReservesSpace="False" Visible="False">
+ <PanelContainer Name="HolopadContactListHeaderPanel">
+ <Label Text="{Loc 'holopad-window-select-contact-from-list'}" HorizontalAlignment="Center" Margin="0 3 0 3"/>
+ </PanelContainer>
+
+ <PanelContainer Name="HolopadContactListPanel">
+ <ScrollContainer HorizontalExpand="True" VerticalExpand="True" Margin="8, 8, 8, 8" MinHeight="256">
+
+ <!-- If there is no data yet, this will be displayed -->
+ <BoxContainer Name="FetchingAvailableHolopadsContainer" HorizontalAlignment="Center" HorizontalExpand="True" VerticalExpand="True" ReservesSpace="False">
+ <Label Text="{Loc 'holopad-window-fetching-contacts-list'}" HorizontalAlignment="Center" VerticalAlignment="Center"/>
+ </BoxContainer>
+
+ <!-- Container for the contacts -->
+ <BoxContainer Name="ContactsList" Orientation="Vertical" HorizontalExpand="True" VerticalExpand="True" Margin="10 0 10 0"/>
+ </ScrollContainer>
+ </PanelContainer>
+ </BoxContainer>
+
+ <!-- Button to start an emergency broadcast (the user requires a certain level of access to interact with it) -->
+ <BoxContainer Name="StartBroadcastContainer" Orientation="Vertical" ReservesSpace="False" Visible="False">
+ <Button Name="StartBroadcastButton" Text="{Loc 'holopad-window-emergency-broadcast'}" Margin="10 0 10 5" Disabled="False" ReservesSpace="False"/>
+ </BoxContainer>
+
+ </BoxContainer>
+ </BoxContainer>
+
+ <!-- Footer -->
+ <BoxContainer Orientation="Vertical">
+ <PanelContainer StyleClasses="LowDivider" />
+ <BoxContainer Orientation="Horizontal" Margin="10 2 5 0" VerticalAlignment="Bottom">
+ <Label Text="{Loc 'holopad-window-flavor-left'}" StyleClasses="WindowFooterText" />
+ <Label Text="{Loc 'holopad-window-flavor-right'}" StyleClasses="WindowFooterText"
+ HorizontalAlignment="Right" HorizontalExpand="True" Margin="0 0 5 0" />
+ <TextureRect StyleClasses="NTLogoDark" Stretch="KeepAspectCentered"
+ VerticalAlignment="Center" HorizontalAlignment="Right" SetSize="19 19"/>
+ </BoxContainer>
+ </BoxContainer>
+ </BoxContainer>
+
+</controls:FancyWindow>
--- /dev/null
+using Content.Client.Popups;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Access.Systems;
+using Content.Shared.Holopad;
+using Content.Shared.Telephone;
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.Player;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using System.Linq;
+
+namespace Content.Client.Holopad;
+
+[GenerateTypedNameReferences]
+public sealed partial class HolopadWindow : FancyWindow
+{
+ [Dependency] private readonly IEntityManager _entManager = default!;
+ [Dependency] private readonly IPlayerManager _playerManager = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private readonly SharedHolopadSystem _holopadSystem = default!;
+ private readonly SharedTelephoneSystem _telephoneSystem = default!;
+ private readonly AccessReaderSystem _accessReaderSystem = default!;
+ private readonly PopupSystem _popupSystem = default!;
+
+ private EntityUid? _owner = null;
+ private HolopadUiKey _currentUiKey;
+ private TelephoneState _currentState;
+ private TelephoneState _previousState;
+ private TimeSpan _buttonUnlockTime;
+ private float _updateTimer = 0.25f;
+
+ private const float UpdateTime = 0.25f;
+ private TimeSpan _buttonUnlockDelay = TimeSpan.FromSeconds(0.5f);
+
+ public event Action<NetEntity>? SendHolopadStartNewCallMessageAction;
+ public event Action? SendHolopadAnswerCallMessageAction;
+ public event Action? SendHolopadEndCallMessageAction;
+ public event Action? SendHolopadStartBroadcastMessageAction;
+ public event Action? SendHolopadActivateProjectorMessageAction;
+ public event Action? SendHolopadRequestStationAiMessageAction;
+
+ public HolopadWindow()
+ {
+ RobustXamlLoader.Load(this);
+ IoCManager.InjectDependencies(this);
+
+ _holopadSystem = _entManager.System<SharedHolopadSystem>();
+ _telephoneSystem = _entManager.System<SharedTelephoneSystem>();
+ _accessReaderSystem = _entManager.System<AccessReaderSystem>();
+ _popupSystem = _entManager.System<PopupSystem>();
+
+ _buttonUnlockTime = _timing.CurTime + _buttonUnlockDelay;
+
+ // Assign button actions
+ AnswerCallButton.OnPressed += args => { OnHolopadAnswerCallMessage(); };
+ EndCallButton.OnPressed += args => { OnHolopadEndCallMessage(); };
+ StartBroadcastButton.OnPressed += args => { OnHolopadStartBroadcastMessage(); };
+ ActivateProjectorButton.OnPressed += args => { OnHolopadActivateProjectorMessage(); };
+ RequestStationAiButton.OnPressed += args => { OnHolopadRequestStationAiMessage(); };
+
+ // XML formatting
+ AnswerCallButton.AddStyleClass("ButtonAccept");
+ EndCallButton.AddStyleClass("Caution");
+ StartBroadcastButton.AddStyleClass("Caution");
+
+ HolopadContactListPanel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(47, 47, 59) * Color.DarkGray,
+ BorderColor = new Color(82, 82, 82), //new Color(70, 73, 102),
+ BorderThickness = new Thickness(2),
+ };
+
+ HolopadContactListHeaderPanel.PanelOverride = new StyleBoxFlat
+ {
+ BackgroundColor = new Color(82, 82, 82),
+ };
+
+ EmergencyBroadcastText.SetMessage(FormattedMessage.FromMarkupOrThrow(Loc.GetString("holopad-window-emergency-broadcast-in-progress")));
+ SubtitleText.SetMessage(FormattedMessage.FromMarkupOrThrow(Loc.GetString("holopad-window-subtitle")));
+ OptionsText.SetMessage(FormattedMessage.FromMarkupOrThrow(Loc.GetString("holopad-window-options")));
+ }
+
+ #region: Button actions
+
+ private void OnSendHolopadStartNewCallMessage(NetEntity receiver)
+ {
+ SendHolopadStartNewCallMessageAction?.Invoke(receiver);
+ }
+
+ private void OnHolopadAnswerCallMessage()
+ {
+ SendHolopadAnswerCallMessageAction?.Invoke();
+ }
+
+ private void OnHolopadEndCallMessage()
+ {
+ SendHolopadEndCallMessageAction?.Invoke();
+
+ if (_currentUiKey == HolopadUiKey.AiRequestWindow)
+ Close();
+ }
+
+ private void OnHolopadStartBroadcastMessage()
+ {
+ if (_playerManager.LocalSession?.AttachedEntity == null || _owner == null)
+ return;
+
+ var player = _playerManager.LocalSession.AttachedEntity;
+
+ if (!_accessReaderSystem.IsAllowed(player.Value, _owner.Value))
+ {
+ _popupSystem.PopupClient(Loc.GetString("holopad-window-access-denied"), _owner.Value, player.Value);
+ return;
+ }
+
+ SendHolopadStartBroadcastMessageAction?.Invoke();
+ }
+
+ private void OnHolopadActivateProjectorMessage()
+ {
+ SendHolopadActivateProjectorMessageAction?.Invoke();
+ }
+
+ private void OnHolopadRequestStationAiMessage()
+ {
+ SendHolopadRequestStationAiMessageAction?.Invoke();
+ }
+
+ #endregion
+
+ public void SetState(EntityUid owner, HolopadUiKey uiKey)
+ {
+ _owner = owner;
+ _currentUiKey = uiKey;
+
+ // Determines what UI containers are available to the user.
+ // Components of these will be toggled on and off when
+ // UpdateAppearance() is called
+
+ switch (uiKey)
+ {
+ case HolopadUiKey.InteractionWindow:
+ RequestStationAiContainer.Visible = true;
+ HolopadContactListContainer.Visible = true;
+ StartBroadcastContainer.Visible = true;
+ break;
+
+ case HolopadUiKey.InteractionWindowForAi:
+ ActivateProjectorContainer.Visible = true;
+ StartBroadcastContainer.Visible = true;
+ break;
+
+ case HolopadUiKey.AiActionWindow:
+ HolopadContactListContainer.Visible = true;
+ StartBroadcastContainer.Visible = true;
+ break;
+
+ case HolopadUiKey.AiRequestWindow:
+ break;
+ }
+ }
+
+ public void UpdateState(Dictionary<NetEntity, string> holopads)
+ {
+ if (_owner == null || !_entManager.TryGetComponent<TelephoneComponent>(_owner.Value, out var telephone))
+ return;
+
+ // Caller ID text
+ var callerId = _telephoneSystem.GetFormattedCallerIdForEntity(telephone.LastCallerId.Item1, telephone.LastCallerId.Item2, Color.LightGray, "Default", 11);
+
+ CallerIdText.SetMessage(FormattedMessage.FromMarkupOrThrow(callerId));
+ LockOutIdText.SetMessage(FormattedMessage.FromMarkupOrThrow(callerId));
+
+ // Sort holopads alphabetically
+ var holopadArray = holopads.ToArray();
+ Array.Sort(holopadArray, AlphabeticalSort);
+
+ // Clear excess children from the contact list
+ while (ContactsList.ChildCount > holopadArray.Length)
+ ContactsList.RemoveChild(ContactsList.GetChild(ContactsList.ChildCount - 1));
+
+ // Make / update required children
+ for (int i = 0; i < holopadArray.Length; i++)
+ {
+ var (netEntity, label) = holopadArray[i];
+
+ if (i >= ContactsList.ChildCount)
+ {
+ var newContactButton = new HolopadContactButton();
+ newContactButton.OnPressed += args => { OnSendHolopadStartNewCallMessage(newContactButton.NetEntity); };
+
+ ContactsList.AddChild(newContactButton);
+ }
+
+ var child = ContactsList.GetChild(i);
+
+ if (child is not HolopadContactButton)
+ continue;
+
+ var contactButton = (HolopadContactButton)child;
+ contactButton.UpdateValues(netEntity, label);
+ }
+
+ // Update buttons
+ UpdateAppearance();
+ }
+
+ private void UpdateAppearance()
+ {
+ if (_owner == null || !_entManager.TryGetComponent<TelephoneComponent>(_owner.Value, out var telephone))
+ return;
+
+ if (_owner == null || !_entManager.TryGetComponent<HolopadComponent>(_owner.Value, out var holopad))
+ return;
+
+ var hasBroadcastAccess = !_holopadSystem.IsHolopadBroadcastOnCoolDown((_owner.Value, holopad));
+ var localPlayer = _playerManager.LocalSession?.AttachedEntity;
+
+ ControlsLockOutContainer.Visible = _holopadSystem.IsHolopadControlLocked((_owner.Value, holopad), localPlayer);
+ ControlsContainer.Visible = !ControlsLockOutContainer.Visible;
+
+ // Temporarily disable the interface buttons when the call state changes to prevent any misclicks
+ if (_currentState != telephone.CurrentState)
+ {
+ _previousState = _currentState;
+ _currentState = telephone.CurrentState;
+ _buttonUnlockTime = _timing.CurTime + _buttonUnlockDelay;
+ }
+
+ var lockButtons = _timing.CurTime < _buttonUnlockTime;
+
+ // Make / update required children
+ foreach (var child in ContactsList.Children)
+ {
+ if (child is not HolopadContactButton)
+ continue;
+
+ var contactButton = (HolopadContactButton)child;
+ contactButton.Disabled = (_currentState != TelephoneState.Idle || lockButtons);
+ }
+
+ // Update control text
+ var cooldown = _holopadSystem.GetHolopadBroadcastCoolDown((_owner.Value, holopad));
+ var cooldownString = $"{cooldown.Minutes:00}:{cooldown.Seconds:00}";
+
+ StartBroadcastButton.Text = _holopadSystem.IsHolopadBroadcastOnCoolDown((_owner.Value, holopad)) ?
+ Loc.GetString("holopad-window-emergency-broadcast-with-countdown", ("countdown", cooldownString)) :
+ Loc.GetString("holopad-window-emergency-broadcast");
+
+ var lockout = _holopadSystem.GetHolopadControlLockedPeriod((_owner.Value, holopad));
+ var lockoutString = $"{lockout.Minutes:00}:{lockout.Seconds:00}";
+
+ LockOutCountDownText.Text = Loc.GetString("holopad-window-controls-unlock-countdown", ("countdown", lockoutString));
+
+ switch (_currentState)
+ {
+ case TelephoneState.Idle:
+ CallStatusText.Text = Loc.GetString("holopad-window-no-calls-in-progress"); break;
+
+ case TelephoneState.Calling:
+ CallStatusText.Text = Loc.GetString("holopad-window-outgoing-call"); break;
+
+ case TelephoneState.Ringing:
+ CallStatusText.Text = (_currentUiKey == HolopadUiKey.AiRequestWindow) ?
+ Loc.GetString("holopad-window-ai-request") : Loc.GetString("holopad-window-incoming-call"); break;
+
+ case TelephoneState.InCall:
+ CallStatusText.Text = Loc.GetString("holopad-window-call-in-progress"); break;
+
+ case TelephoneState.EndingCall:
+ if (_previousState == TelephoneState.Calling || _previousState == TelephoneState.Idle)
+ CallStatusText.Text = Loc.GetString("holopad-window-call-rejected");
+ else
+ CallStatusText.Text = Loc.GetString("holopad-window-call-ending");
+ break;
+ }
+
+ // Update control disability
+ AnswerCallButton.Disabled = (_currentState != TelephoneState.Ringing || lockButtons);
+ EndCallButton.Disabled = (_currentState == TelephoneState.Idle || _currentState == TelephoneState.EndingCall || lockButtons);
+ StartBroadcastButton.Disabled = (_currentState != TelephoneState.Idle || !hasBroadcastAccess || lockButtons);
+ RequestStationAiButton.Disabled = (_currentState != TelephoneState.Idle || lockButtons);
+ ActivateProjectorButton.Disabled = (_currentState != TelephoneState.Idle || lockButtons);
+
+ // Update control visibility
+ FetchingAvailableHolopadsContainer.Visible = (ContactsList.ChildCount == 0);
+ ActiveCallControlsContainer.Visible = (_currentState != TelephoneState.Idle || _currentUiKey == HolopadUiKey.AiRequestWindow);
+ CallPlacementControlsContainer.Visible = !ActiveCallControlsContainer.Visible;
+ CallerIdText.Visible = (_currentState == TelephoneState.Ringing);
+ AnswerCallButton.Visible = (_currentState == TelephoneState.Ringing);
+ }
+
+ protected override void FrameUpdate(FrameEventArgs args)
+ {
+ base.FrameUpdate(args);
+
+ _updateTimer += args.DeltaSeconds;
+
+ if (_updateTimer >= UpdateTime)
+ {
+ _updateTimer -= UpdateTime;
+ UpdateAppearance();
+ }
+ }
+
+ private sealed class HolopadContactButton : Button
+ {
+ public NetEntity NetEntity;
+
+ public HolopadContactButton()
+ {
+ HorizontalExpand = true;
+ SetHeight = 32;
+ Margin = new Thickness(0f, 1f, 0f, 1f);
+ }
+
+ public void UpdateValues(NetEntity netEntity, string label)
+ {
+ NetEntity = netEntity;
+ Text = Loc.GetString("holopad-window-contact-label", ("label", label));
+ }
+ }
+
+ private int AlphabeticalSort(KeyValuePair<NetEntity, string> x, KeyValuePair<NetEntity, string> y)
+ {
+ if (string.IsNullOrEmpty(x.Value))
+ return -1;
+
+ if (string.IsNullOrEmpty(y.Value))
+ return 1;
+
+ return x.Value.CompareTo(y.Value);
+ }
+}
public static readonly Color ButtonColorGoodDefault = Color.FromHex("#3E6C45");
public static readonly Color ButtonColorGoodHovered = Color.FromHex("#31843E");
+ public static readonly Color ButtonColorGoodDisabled = Color.FromHex("#164420");
//NavMap
public static readonly Color PointRed = Color.FromHex("#B02E26");
Element<Button>().Class("ButtonColorGreen").Pseudo(ContainerButton.StylePseudoClassHover)
.Prop(Control.StylePropertyModulateSelf, ButtonColorGoodHovered),
+
+ // Accept button (merge with green button?) ---
+ Element<Button>().Class("ButtonAccept")
+ .Prop(Control.StylePropertyModulateSelf, ButtonColorGoodDefault),
+
+ Element<Button>().Class("ButtonAccept").Pseudo(ContainerButton.StylePseudoClassNormal)
+ .Prop(Control.StylePropertyModulateSelf, ButtonColorGoodDefault),
+
+ Element<Button>().Class("ButtonAccept").Pseudo(ContainerButton.StylePseudoClassHover)
+ .Prop(Control.StylePropertyModulateSelf, ButtonColorGoodHovered),
+
+ Element<Button>().Class("ButtonAccept").Pseudo(ContainerButton.StylePseudoClassDisabled)
+ .Prop(Control.StylePropertyModulateSelf, ButtonColorGoodDisabled),
+
// ---
// Small Button ---
--- /dev/null
+using Content.Shared.Telephone;
+
+namespace Content.Client.Telephone;
+
+public sealed class TelephoneSystem : SharedTelephoneSystem
+{
+
+}
--- /dev/null
+using Content.Server.Chat.Systems;
+using Content.Server.Power.EntitySystems;
+using Content.Server.Speech.Components;
+using Content.Server.Telephone;
+using Content.Shared.Access.Systems;
+using Content.Shared.Audio;
+using Content.Shared.Chat.TypingIndicator;
+using Content.Shared.Holopad;
+using Content.Shared.IdentityManagement;
+using Content.Shared.Labels.Components;
+using Content.Shared.Silicons.StationAi;
+using Content.Shared.Telephone;
+using Content.Shared.UserInterface;
+using Content.Shared.Verbs;
+using Robust.Server.GameObjects;
+using Robust.Shared.Containers;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using System.Linq;
+
+namespace Content.Server.Holopad;
+
+public sealed class HolopadSystem : SharedHolopadSystem
+{
+ [Dependency] private readonly TelephoneSystem _telephoneSystem = default!;
+ [Dependency] private readonly UserInterfaceSystem _userInterfaceSystem = default!;
+ [Dependency] private readonly TransformSystem _xformSystem = default!;
+ [Dependency] private readonly AppearanceSystem _appearanceSystem = default!;
+ [Dependency] private readonly SharedPointLightSystem _pointLightSystem = default!;
+ [Dependency] private readonly SharedAmbientSoundSystem _ambientSoundSystem = default!;
+ [Dependency] private readonly SharedStationAiSystem _stationAiSystem = default!;
+ [Dependency] private readonly AccessReaderSystem _accessReaderSystem = default!;
+ [Dependency] private readonly ChatSystem _chatSystem = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ private float _updateTimer = 1.0f;
+
+ private const float UpdateTime = 1.0f;
+ private const float MinTimeBetweenSyncRequests = 0.5f;
+ private TimeSpan _minTimeSpanBetweenSyncRequests;
+
+ private HashSet<EntityUid> _pendingRequestsForSpriteState = new();
+ private HashSet<EntityUid> _recentlyUpdatedHolograms = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ _minTimeSpanBetweenSyncRequests = TimeSpan.FromSeconds(MinTimeBetweenSyncRequests);
+
+ // Holopad UI and bound user interface messages
+ SubscribeLocalEvent<HolopadComponent, BeforeActivatableUIOpenEvent>(OnUIOpen);
+ SubscribeLocalEvent<HolopadComponent, HolopadStartNewCallMessage>(OnHolopadStartNewCall);
+ SubscribeLocalEvent<HolopadComponent, HolopadAnswerCallMessage>(OnHolopadAnswerCall);
+ SubscribeLocalEvent<HolopadComponent, HolopadEndCallMessage>(OnHolopadEndCall);
+ SubscribeLocalEvent<HolopadComponent, HolopadActivateProjectorMessage>(OnHolopadActivateProjector);
+ SubscribeLocalEvent<HolopadComponent, HolopadStartBroadcastMessage>(OnHolopadStartBroadcast);
+ SubscribeLocalEvent<HolopadComponent, HolopadStationAiRequestMessage>(OnHolopadStationAiRequest);
+
+ // Holopad telephone events
+ SubscribeLocalEvent<HolopadComponent, TelephoneStateChangeEvent>(OnTelephoneStateChange);
+ SubscribeLocalEvent<HolopadComponent, TelephoneCallCommencedEvent>(OnHoloCallCommenced);
+ SubscribeLocalEvent<HolopadComponent, TelephoneCallEndedEvent>(OnHoloCallEnded);
+ SubscribeLocalEvent<HolopadComponent, TelephoneMessageSentEvent>(OnTelephoneMessageSent);
+
+ // Networked events
+ SubscribeNetworkEvent<HolopadUserTypingChangedEvent>(OnTypingChanged);
+ SubscribeNetworkEvent<PlayerSpriteStateMessage>(OnPlayerSpriteStateMessage);
+
+ // Component start/shutdown events
+ SubscribeLocalEvent<HolopadComponent, ComponentInit>(OnHolopadInit);
+ SubscribeLocalEvent<HolopadComponent, ComponentShutdown>(OnHolopadShutdown);
+ SubscribeLocalEvent<HolopadUserComponent, ComponentInit>(OnHolopadUserInit);
+ SubscribeLocalEvent<HolopadUserComponent, ComponentShutdown>(OnHolopadUserShutdown);
+
+ // Misc events
+ SubscribeLocalEvent<HolopadUserComponent, EmoteEvent>(OnEmote);
+ SubscribeLocalEvent<HolopadUserComponent, JumpToCoreEvent>(OnJumpToCore);
+ SubscribeLocalEvent<HolopadComponent, GetVerbsEvent<AlternativeVerb>>(AddToggleProjectorVerb);
+ SubscribeLocalEvent<HolopadComponent, EntRemovedFromContainerMessage>(OnAiRemove);
+ }
+
+ #region: Holopad UI bound user interface messages
+
+ private void OnUIOpen(Entity<HolopadComponent> entity, ref BeforeActivatableUIOpenEvent args)
+ {
+ UpdateUIState(entity);
+ }
+
+ private void OnHolopadStartNewCall(Entity<HolopadComponent> source, ref HolopadStartNewCallMessage args)
+ {
+ if (IsHolopadControlLocked(source, args.Actor))
+ return;
+
+ if (!TryComp<TelephoneComponent>(source, out var sourceTelephone))
+ return;
+
+ var receiver = GetEntity(args.Receiver);
+
+ if (!TryComp<TelephoneComponent>(receiver, out var receiverTelephone))
+ return;
+
+ LinkHolopadToUser(source, args.Actor);
+ _telephoneSystem.CallTelephone((source, sourceTelephone), (receiver, receiverTelephone), args.Actor);
+ }
+
+ private void OnHolopadAnswerCall(Entity<HolopadComponent> receiver, ref HolopadAnswerCallMessage args)
+ {
+ if (IsHolopadControlLocked(receiver, args.Actor))
+ return;
+
+ if (!TryComp<TelephoneComponent>(receiver, out var receiverTelephone))
+ return;
+
+ if (TryComp<StationAiHeldComponent>(args.Actor, out var userAiHeld))
+ {
+ var source = GetLinkedHolopads(receiver).FirstOrNull();
+
+ if (source != null)
+ ActivateProjector(source.Value, args.Actor);
+
+ return;
+ }
+
+ LinkHolopadToUser(receiver, args.Actor);
+ _telephoneSystem.AnswerTelephone((receiver, receiverTelephone), args.Actor);
+ }
+
+ private void OnHolopadEndCall(Entity<HolopadComponent> entity, ref HolopadEndCallMessage args)
+ {
+ if (!TryComp<TelephoneComponent>(entity, out var entityTelephone))
+ return;
+
+ if (IsHolopadControlLocked(entity, args.Actor))
+ return;
+
+ _telephoneSystem.EndTelephoneCalls((entity, entityTelephone));
+
+ // If the user is an AI, end all calls originating from its
+ // associated core to ensure that any broadcasts will end
+ if (!TryComp<StationAiHeldComponent>(args.Actor, out var stationAiHeld) ||
+ !_stationAiSystem.TryGetStationAiCore((args.Actor, stationAiHeld), out var stationAiCore))
+ return;
+
+ if (TryComp<TelephoneComponent>(stationAiCore, out var telephone))
+ _telephoneSystem.EndTelephoneCalls((stationAiCore.Value, telephone));
+ }
+
+ private void OnHolopadActivateProjector(Entity<HolopadComponent> entity, ref HolopadActivateProjectorMessage args)
+ {
+ ActivateProjector(entity, args.Actor);
+ }
+
+ private void OnHolopadStartBroadcast(Entity<HolopadComponent> source, ref HolopadStartBroadcastMessage args)
+ {
+ if (IsHolopadControlLocked(source, args.Actor) || IsHolopadBroadcastOnCoolDown(source))
+ return;
+
+ if (!_accessReaderSystem.IsAllowed(args.Actor, source))
+ return;
+
+ // AI broadcasting
+ if (TryComp<StationAiHeldComponent>(args.Actor, out var stationAiHeld))
+ {
+ if (!_stationAiSystem.TryGetStationAiCore((args.Actor, stationAiHeld), out var stationAiCore) ||
+ stationAiCore.Value.Comp.RemoteEntity == null ||
+ !TryComp<HolopadComponent>(stationAiCore, out var stationAiCoreHolopad))
+ return;
+
+ ExecuteBroadcast((stationAiCore.Value, stationAiCoreHolopad), args.Actor);
+
+ // Switch the AI's perspective from free roaming to the target holopad
+ _xformSystem.SetCoordinates(stationAiCore.Value.Comp.RemoteEntity.Value, Transform(source).Coordinates);
+ _stationAiSystem.SwitchRemoteEntityMode(stationAiCore.Value, false);
+
+ return;
+ }
+
+ // Crew broadcasting
+ ExecuteBroadcast(source, args.Actor);
+ }
+
+ private void OnHolopadStationAiRequest(Entity<HolopadComponent> entity, ref HolopadStationAiRequestMessage args)
+ {
+ if (IsHolopadControlLocked(entity, args.Actor))
+ return;
+
+ if (!TryComp<TelephoneComponent>(entity, out var telephone))
+ return;
+
+ var source = new Entity<TelephoneComponent>(entity, telephone);
+ var query = AllEntityQuery<StationAiCoreComponent, TelephoneComponent>();
+ var reachableAiCores = new HashSet<Entity<TelephoneComponent>>();
+
+ while (query.MoveNext(out var receiverUid, out var receiverStationAiCore, out var receiverTelephone))
+ {
+ var receiver = new Entity<TelephoneComponent>(receiverUid, receiverTelephone);
+
+ if (!_telephoneSystem.IsSourceAbleToReachReceiver(source, receiver))
+ continue;
+
+ if (_telephoneSystem.IsTelephoneEngaged(receiver))
+ continue;
+
+ reachableAiCores.Add((receiverUid, receiverTelephone));
+
+ if (!_stationAiSystem.TryGetInsertedAI((receiver, receiverStationAiCore), out var insertedAi))
+ continue;
+
+ if (_userInterfaceSystem.TryOpenUi(receiverUid, HolopadUiKey.AiRequestWindow, insertedAi.Value.Owner))
+ LinkHolopadToUser(entity, args.Actor);
+ }
+
+ if (!reachableAiCores.Any())
+ return;
+
+ _telephoneSystem.BroadcastCallToTelephones(source, reachableAiCores, args.Actor);
+ }
+
+ #endregion
+
+ #region: Holopad telephone events
+
+ private void OnTelephoneStateChange(Entity<HolopadComponent> holopad, ref TelephoneStateChangeEvent args)
+ {
+ // Update holopad visual and ambient states
+ switch (args.NewState)
+ {
+ case TelephoneState.Idle:
+ ShutDownHolopad(holopad);
+ SetHolopadAmbientState(holopad, false);
+ break;
+
+ case TelephoneState.EndingCall:
+ ShutDownHolopad(holopad);
+ break;
+
+ default:
+ SetHolopadAmbientState(holopad, this.IsPowered(holopad, EntityManager));
+ break;
+ }
+ }
+
+ private void OnHoloCallCommenced(Entity<HolopadComponent> source, ref TelephoneCallCommencedEvent args)
+ {
+ if (source.Comp.Hologram == null)
+ GenerateHologram(source);
+
+ // Receiver holopad holograms have to be generated now instead of waiting for their own event
+ // to fire because holographic avatars get synced immediately
+ if (TryComp<HolopadComponent>(args.Receiver, out var receivingHolopad) && receivingHolopad.Hologram == null)
+ GenerateHologram((args.Receiver, receivingHolopad));
+
+ if (source.Comp.User != null)
+ {
+ // Re-link the user to refresh the sprite data
+ LinkHolopadToUser(source, source.Comp.User.Value);
+ }
+ }
+
+ private void OnHoloCallEnded(Entity<HolopadComponent> entity, ref TelephoneCallEndedEvent args)
+ {
+ if (!TryComp<StationAiCoreComponent>(entity, out var stationAiCore))
+ return;
+
+ // Auto-close the AI request window
+ if (_stationAiSystem.TryGetInsertedAI((entity, stationAiCore), out var insertedAi))
+ _userInterfaceSystem.CloseUi(entity.Owner, HolopadUiKey.AiRequestWindow, insertedAi.Value.Owner);
+ }
+
+ private void OnTelephoneMessageSent(Entity<HolopadComponent> holopad, ref TelephoneMessageSentEvent args)
+ {
+ LinkHolopadToUser(holopad, args.MessageSource);
+ }
+
+ #endregion
+
+ #region: Networked events
+
+ private void OnTypingChanged(HolopadUserTypingChangedEvent ev, EntitySessionEventArgs args)
+ {
+ var uid = args.SenderSession.AttachedEntity;
+
+ if (!Exists(uid))
+ return;
+
+ if (!TryComp<HolopadUserComponent>(uid, out var holopadUser))
+ return;
+
+ foreach (var linkedHolopad in holopadUser.LinkedHolopads)
+ {
+ var receiverHolopads = GetLinkedHolopads(linkedHolopad);
+
+ foreach (var receiverHolopad in receiverHolopads)
+ {
+ if (receiverHolopad.Comp.Hologram == null)
+ continue;
+
+ _appearanceSystem.SetData(receiverHolopad.Comp.Hologram.Value.Owner, TypingIndicatorVisuals.IsTyping, ev.IsTyping);
+ }
+ }
+ }
+
+ private void OnPlayerSpriteStateMessage(PlayerSpriteStateMessage ev, EntitySessionEventArgs args)
+ {
+ var uid = args.SenderSession.AttachedEntity;
+
+ if (!Exists(uid))
+ return;
+
+ if (!_pendingRequestsForSpriteState.Remove(uid.Value))
+ return;
+
+ if (!TryComp<HolopadUserComponent>(uid, out var holopadUser))
+ return;
+
+ SyncHolopadUserWithLinkedHolograms((uid.Value, holopadUser), ev.SpriteLayerData);
+ }
+
+ #endregion
+
+ #region: Component start/shutdown events
+
+ private void OnHolopadInit(Entity<HolopadComponent> entity, ref ComponentInit args)
+ {
+ if (entity.Comp.User != null)
+ LinkHolopadToUser(entity, entity.Comp.User.Value);
+ }
+
+ private void OnHolopadUserInit(Entity<HolopadUserComponent> entity, ref ComponentInit args)
+ {
+ foreach (var linkedHolopad in entity.Comp.LinkedHolopads)
+ LinkHolopadToUser(linkedHolopad, entity);
+ }
+
+ private void OnHolopadShutdown(Entity<HolopadComponent> entity, ref ComponentShutdown args)
+ {
+ ShutDownHolopad(entity);
+ SetHolopadAmbientState(entity, false);
+ }
+
+ private void OnHolopadUserShutdown(Entity<HolopadUserComponent> entity, ref ComponentShutdown args)
+ {
+ foreach (var linkedHolopad in entity.Comp.LinkedHolopads)
+ UnlinkHolopadFromUser(linkedHolopad, entity);
+ }
+
+ #endregion
+
+ #region: Misc events
+
+ private void OnEmote(Entity<HolopadUserComponent> entity, ref EmoteEvent args)
+ {
+ foreach (var linkedHolopad in entity.Comp.LinkedHolopads)
+ {
+ // Treat the ability to hear speech as the ability to also perceive emotes
+ // (these are almost always going to be linked)
+ if (!HasComp<ActiveListenerComponent>(linkedHolopad))
+ continue;
+
+ if (TryComp<TelephoneComponent>(linkedHolopad, out var linkedHolopadTelephone) && linkedHolopadTelephone.Muted)
+ continue;
+
+ foreach (var receiver in GetLinkedHolopads(linkedHolopad))
+ {
+ if (receiver.Comp.Hologram == null)
+ continue;
+
+ // Name is based on the physical identity of the user
+ var ent = Identity.Entity(entity, EntityManager);
+ var name = Loc.GetString("holopad-hologram-name", ("name", ent));
+
+ // Force the emote, because if the user can do it, the hologram can too
+ _chatSystem.TryEmoteWithChat(receiver.Comp.Hologram.Value, args.Emote, ChatTransmitRange.Normal, false, name, true, true);
+ }
+ }
+ }
+
+ private void OnJumpToCore(Entity<HolopadUserComponent> entity, ref JumpToCoreEvent args)
+ {
+ if (!TryComp<StationAiHeldComponent>(entity, out var entityStationAiHeld))
+ return;
+
+ if (!_stationAiSystem.TryGetStationAiCore((entity, entityStationAiHeld), out var stationAiCore))
+ return;
+
+ if (!TryComp<TelephoneComponent>(stationAiCore, out var stationAiCoreTelephone))
+ return;
+
+ _telephoneSystem.EndTelephoneCalls((stationAiCore.Value, stationAiCoreTelephone));
+ }
+
+ private void AddToggleProjectorVerb(Entity<HolopadComponent> entity, ref GetVerbsEvent<AlternativeVerb> args)
+ {
+ if (!args.CanAccess || !args.CanInteract)
+ return;
+
+ if (!this.IsPowered(entity, EntityManager))
+ return;
+
+ if (!TryComp<TelephoneComponent>(entity, out var entityTelephone) ||
+ _telephoneSystem.IsTelephoneEngaged((entity, entityTelephone)))
+ return;
+
+ var user = args.User;
+
+ if (!TryComp<StationAiHeldComponent>(user, out var userAiHeld))
+ return;
+
+ if (!_stationAiSystem.TryGetStationAiCore((user, userAiHeld), out var stationAiCore) ||
+ stationAiCore.Value.Comp.RemoteEntity == null)
+ return;
+
+ AlternativeVerb verb = new()
+ {
+ Act = () => ActivateProjector(entity, user),
+ Text = Loc.GetString("activate-holopad-projector-verb"),
+ Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/VerbIcons/vv.svg.192dpi.png")),
+ };
+
+ args.Verbs.Add(verb);
+ }
+
+ private void OnAiRemove(Entity<HolopadComponent> entity, ref EntRemovedFromContainerMessage args)
+ {
+ if (!HasComp<StationAiCoreComponent>(entity))
+ return;
+
+ if (!TryComp<TelephoneComponent>(entity, out var entityTelephone))
+ return;
+
+ _telephoneSystem.EndTelephoneCalls((entity, entityTelephone));
+ }
+
+ #endregion
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ _updateTimer += frameTime;
+
+ if (_updateTimer >= UpdateTime)
+ {
+ _updateTimer -= UpdateTime;
+
+ var query = AllEntityQuery<HolopadComponent, TelephoneComponent, TransformComponent>();
+ while (query.MoveNext(out var uid, out var holopad, out var telephone, out var xform))
+ {
+ UpdateUIState((uid, holopad), telephone);
+
+ if (holopad.User != null &&
+ !HasComp<IgnoreUIRangeComponent>(holopad.User) &&
+ !_xformSystem.InRange((holopad.User.Value, Transform(holopad.User.Value)), (uid, xform), telephone.ListeningRange))
+ {
+ UnlinkHolopadFromUser((uid, holopad), holopad.User.Value);
+ }
+ }
+ }
+
+ _recentlyUpdatedHolograms.Clear();
+ }
+
+ public void UpdateUIState(Entity<HolopadComponent> entity, TelephoneComponent? telephone = null)
+ {
+ if (!Resolve(entity.Owner, ref telephone, false))
+ return;
+
+ var source = new Entity<TelephoneComponent>(entity, telephone);
+ var holopads = new Dictionary<NetEntity, string>();
+
+ var query = AllEntityQuery<HolopadComponent, TelephoneComponent>();
+ while (query.MoveNext(out var receiverUid, out var _, out var receiverTelephone))
+ {
+ var receiver = new Entity<TelephoneComponent>(receiverUid, receiverTelephone);
+
+ if (receiverTelephone.UnlistedNumber)
+ continue;
+
+ if (source == receiver)
+ continue;
+
+ if (!_telephoneSystem.IsSourceInRangeOfReceiver(source, receiver))
+ continue;
+
+ var name = MetaData(receiverUid).EntityName;
+
+ if (TryComp<LabelComponent>(receiverUid, out var label) && !string.IsNullOrEmpty(label.CurrentLabel))
+ name = label.CurrentLabel;
+
+ holopads.Add(GetNetEntity(receiverUid), name);
+ }
+
+ var uiKey = HasComp<StationAiCoreComponent>(entity) ? HolopadUiKey.AiActionWindow : HolopadUiKey.InteractionWindow;
+ _userInterfaceSystem.SetUiState(entity.Owner, uiKey, new HolopadBoundInterfaceState(holopads));
+ }
+
+ private void GenerateHologram(Entity<HolopadComponent> entity)
+ {
+ if (entity.Comp.Hologram != null ||
+ entity.Comp.HologramProtoId == null)
+ return;
+
+ var uid = Spawn(entity.Comp.HologramProtoId, Transform(entity).Coordinates);
+
+ // Safeguard - spawned holograms must have this component
+ if (!TryComp<HolopadHologramComponent>(uid, out var component))
+ {
+ Del(uid);
+ return;
+ }
+
+ entity.Comp.Hologram = new Entity<HolopadHologramComponent>(uid, component);
+ }
+
+ private void DeleteHologram(Entity<HolopadHologramComponent> hologram, Entity<HolopadComponent> attachedHolopad)
+ {
+ attachedHolopad.Comp.Hologram = null;
+
+ QueueDel(hologram);
+ }
+
+ private void LinkHolopadToUser(Entity<HolopadComponent> entity, EntityUid user)
+ {
+ if (!TryComp<HolopadUserComponent>(user, out var holopadUser))
+ holopadUser = AddComp<HolopadUserComponent>(user);
+
+ if (user != entity.Comp.User?.Owner)
+ {
+ // Removes the old user from the holopad
+ UnlinkHolopadFromUser(entity, entity.Comp.User);
+
+ // Assigns the new user in their place
+ holopadUser.LinkedHolopads.Add(entity);
+ entity.Comp.User = (user, holopadUser);
+ }
+
+ if (TryComp<HolographicAvatarComponent>(user, out var avatar))
+ {
+ SyncHolopadUserWithLinkedHolograms((user, holopadUser), avatar.LayerData);
+ return;
+ }
+
+ // We have no apriori sprite data for the hologram, request
+ // the current appearance of the user from the client
+ RequestHolopadUserSpriteUpdate((user, holopadUser));
+ }
+
+ private void UnlinkHolopadFromUser(Entity<HolopadComponent> entity, Entity<HolopadUserComponent>? user)
+ {
+ if (user == null)
+ return;
+
+ entity.Comp.User = null;
+
+ foreach (var linkedHolopad in GetLinkedHolopads(entity))
+ {
+ if (linkedHolopad.Comp.Hologram != null)
+ {
+ _appearanceSystem.SetData(linkedHolopad.Comp.Hologram.Value.Owner, TypingIndicatorVisuals.IsTyping, false);
+
+ // Send message with no sprite data to the client
+ // This will set the holgram sprite to a generic icon
+ var ev = new PlayerSpriteStateMessage(GetNetEntity(linkedHolopad.Comp.Hologram.Value));
+ RaiseNetworkEvent(ev);
+ }
+ }
+
+ if (!HasComp<HolopadUserComponent>(user))
+ return;
+
+ user.Value.Comp.LinkedHolopads.Remove(entity);
+
+ if (!user.Value.Comp.LinkedHolopads.Any())
+ {
+ _pendingRequestsForSpriteState.Remove(user.Value);
+
+ if (user.Value.Comp.LifeStage < ComponentLifeStage.Stopping)
+ RemComp<HolopadUserComponent>(user.Value);
+ }
+ }
+
+ private void ShutDownHolopad(Entity<HolopadComponent> entity)
+ {
+ entity.Comp.ControlLockoutOwner = null;
+
+ if (entity.Comp.Hologram != null)
+ DeleteHologram(entity.Comp.Hologram.Value, entity);
+
+ if (entity.Comp.User != null)
+ UnlinkHolopadFromUser(entity, entity.Comp.User.Value);
+
+ if (TryComp<StationAiCoreComponent>(entity, out var stationAiCore))
+ {
+ _stationAiSystem.SwitchRemoteEntityMode((entity.Owner, stationAiCore), true);
+
+ if (TryComp<TelephoneComponent>(entity, out var stationAiCoreTelphone))
+ _telephoneSystem.EndTelephoneCalls((entity, stationAiCoreTelphone));
+ }
+
+ Dirty(entity);
+ }
+
+ private void RequestHolopadUserSpriteUpdate(Entity<HolopadUserComponent> user)
+ {
+ if (!_pendingRequestsForSpriteState.Add(user))
+ return;
+
+ var ev = new PlayerSpriteStateRequest(GetNetEntity(user));
+ RaiseNetworkEvent(ev);
+ }
+
+ private void SyncHolopadUserWithLinkedHolograms(Entity<HolopadUserComponent> entity, PrototypeLayerData[]? spriteLayerData)
+ {
+ foreach (var linkedHolopad in entity.Comp.LinkedHolopads)
+ {
+ foreach (var receivingHolopad in GetLinkedHolopads(linkedHolopad))
+ {
+ if (receivingHolopad.Comp.Hologram == null || !_recentlyUpdatedHolograms.Add(receivingHolopad.Comp.Hologram.Value))
+ continue;
+
+ var netHologram = GetNetEntity(receivingHolopad.Comp.Hologram.Value);
+ var ev = new PlayerSpriteStateMessage(netHologram, spriteLayerData);
+ RaiseNetworkEvent(ev);
+ }
+ }
+ }
+
+ private void ActivateProjector(Entity<HolopadComponent> entity, EntityUid user)
+ {
+ if (!TryComp<TelephoneComponent>(entity, out var receiverTelephone))
+ return;
+
+ var receiver = new Entity<TelephoneComponent>(entity, receiverTelephone);
+
+ if (!TryComp<StationAiHeldComponent>(user, out var userAiHeld))
+ return;
+
+ if (!_stationAiSystem.TryGetStationAiCore((user, userAiHeld), out var stationAiCore) ||
+ stationAiCore.Value.Comp.RemoteEntity == null)
+ return;
+
+ if (!TryComp<TelephoneComponent>(stationAiCore, out var stationAiTelephone))
+ return;
+
+ if (!TryComp<HolopadComponent>(stationAiCore, out var stationAiHolopad))
+ return;
+
+ var source = new Entity<TelephoneComponent>(stationAiCore.Value, stationAiTelephone);
+
+ // Terminate any calls that the core is hosting and immediately connect to the receiver
+ _telephoneSystem.TerminateTelephoneCalls(source);
+
+ var callOptions = new TelephoneCallOptions()
+ {
+ ForceConnect = true,
+ MuteReceiver = true
+ };
+
+ _telephoneSystem.CallTelephone(source, receiver, user, callOptions);
+
+ if (!_telephoneSystem.IsSourceConnectedToReceiver(source, receiver))
+ return;
+
+ LinkHolopadToUser((stationAiCore.Value, stationAiHolopad), user);
+
+ // Switch the AI's perspective from free roaming to the target holopad
+ _xformSystem.SetCoordinates(stationAiCore.Value.Comp.RemoteEntity.Value, Transform(entity).Coordinates);
+ _stationAiSystem.SwitchRemoteEntityMode(stationAiCore.Value, false);
+
+ // Open the holopad UI if it hasn't been opened yet
+ if (TryComp<UserInterfaceComponent>(entity, out var entityUserInterfaceComponent))
+ _userInterfaceSystem.OpenUi((entity, entityUserInterfaceComponent), HolopadUiKey.InteractionWindow, user);
+ }
+
+ private void ExecuteBroadcast(Entity<HolopadComponent> source, EntityUid user)
+ {
+ if (!TryComp<TelephoneComponent>(source, out var sourceTelephone))
+ return;
+
+ var sourceTelephoneEntity = new Entity<TelephoneComponent>(source, sourceTelephone);
+ _telephoneSystem.TerminateTelephoneCalls(sourceTelephoneEntity);
+
+ // Find all holopads in range of the source
+ var sourceXform = Transform(source);
+ var receivers = new HashSet<Entity<TelephoneComponent>>();
+
+ var query = AllEntityQuery<HolopadComponent, TelephoneComponent, TransformComponent>();
+ while (query.MoveNext(out var receiver, out var receiverHolopad, out var receiverTelephone, out var receiverXform))
+ {
+ var receiverTelephoneEntity = new Entity<TelephoneComponent>(receiver, receiverTelephone);
+
+ if (sourceTelephoneEntity == receiverTelephoneEntity ||
+ receiverTelephone.UnlistedNumber ||
+ !_telephoneSystem.IsSourceAbleToReachReceiver(sourceTelephoneEntity, receiverTelephoneEntity))
+ continue;
+
+ // If any holopads in range are on broadcast cooldown, exit
+ if (IsHolopadBroadcastOnCoolDown((receiver, receiverHolopad)))
+ return;
+
+ receivers.Add(receiverTelephoneEntity);
+ }
+
+ var options = new TelephoneCallOptions()
+ {
+ ForceConnect = true,
+ MuteReceiver = true,
+ };
+
+ _telephoneSystem.BroadcastCallToTelephones(sourceTelephoneEntity, receivers, user, options);
+
+ if (!_telephoneSystem.IsTelephoneEngaged(sourceTelephoneEntity))
+ return;
+
+ // Link to the user after all the calls have been placed,
+ // so we only need to sync all the holograms once
+ LinkHolopadToUser(source, user);
+
+ // Lock out the controls of all involved holopads for a set duration
+ source.Comp.ControlLockoutOwner = user;
+ source.Comp.ControlLockoutStartTime = _timing.CurTime;
+
+ Dirty(source);
+
+ foreach (var receiver in GetLinkedHolopads(source))
+ {
+ receiver.Comp.ControlLockoutOwner = user;
+ receiver.Comp.ControlLockoutStartTime = _timing.CurTime;
+
+ Dirty(receiver);
+ }
+ }
+
+ private HashSet<Entity<HolopadComponent>> GetLinkedHolopads(Entity<HolopadComponent> entity)
+ {
+ var linkedHolopads = new HashSet<Entity<HolopadComponent>>();
+
+ if (!TryComp<TelephoneComponent>(entity, out var holopadTelephone))
+ return linkedHolopads;
+
+ foreach (var linkedEnt in holopadTelephone.LinkedTelephones)
+ {
+ if (!TryComp<HolopadComponent>(linkedEnt, out var linkedHolopad))
+ continue;
+
+ linkedHolopads.Add((linkedEnt, linkedHolopad));
+ }
+
+ return linkedHolopads;
+ }
+
+ private void SetHolopadAmbientState(Entity<HolopadComponent> entity, bool isEnabled)
+ {
+ if (TryComp<PointLightComponent>(entity, out var pointLight))
+ _pointLightSystem.SetEnabled(entity, isEnabled, pointLight);
+
+ if (TryComp<AmbientSoundComponent>(entity, out var ambientSound))
+ _ambientSoundSystem.SetAmbience(entity, isEnabled, ambientSound);
+ }
+}
public sealed partial class AiVisionWireAction : ComponentWireAction<StationAiVisionComponent>
{
public override string Name { get; set; } = "wire-name-ai-vision-light";
- public override Color Color { get; set; } = Color.DeepSkyBlue;
- public override object StatusKey => AirlockWireStatus.AiControlIndicator;
+ public override Color Color { get; set; } = Color.White;
+ public override object StatusKey => AirlockWireStatus.AiVisionIndicator;
public override StatusLightState? GetLightState(Wire wire, StationAiVisionComponent component)
{
using System.Linq;
using Content.Server.Chat.Managers;
+using Content.Server.Chat.Systems;
using Content.Shared.Chat;
using Content.Shared.Mind;
using Content.Shared.Roles;
using Robust.Shared.Audio;
using Robust.Shared.Map.Components;
using Robust.Shared.Player;
+using static Content.Server.Chat.Systems.ChatSystem;
namespace Content.Server.Silicons.StationAi;
{
[Dependency] private readonly IChatManager _chats = default!;
[Dependency] private readonly EntityLookupSystem _lookup = default!;
+ [Dependency] private readonly SharedTransformSystem _xforms = default!;
[Dependency] private readonly SharedMindSystem _mind = default!;
[Dependency] private readonly SharedRoleSystem _roles = default!;
private readonly HashSet<Entity<StationAiCoreComponent>> _ais = new();
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<ExpandICChatRecipientsEvent>(OnExpandICChatRecipients);
+ }
+
+ private void OnExpandICChatRecipients(ExpandICChatRecipientsEvent ev)
+ {
+ var xformQuery = GetEntityQuery<TransformComponent>();
+ var sourceXform = Transform(ev.Source);
+ var sourcePos = _xforms.GetWorldPosition(sourceXform, xformQuery);
+
+ // This function ensures that chat popups appear on camera views that have connected microphones.
+ var query = EntityManager.EntityQueryEnumerator<StationAiCoreComponent, TransformComponent>();
+ while (query.MoveNext(out var ent, out var entStationAiCore, out var entXform))
+ {
+ var stationAiCore = new Entity<StationAiCoreComponent>(ent, entStationAiCore);
+
+ if (!TryGetInsertedAI(stationAiCore, out var insertedAi) || !TryComp(insertedAi, out ActorComponent? actor))
+ return;
+
+ if (stationAiCore.Comp.RemoteEntity == null || stationAiCore.Comp.Remote)
+ return;
+
+ var xform = Transform(stationAiCore.Comp.RemoteEntity.Value);
+
+ var range = (xform.MapID != sourceXform.MapID)
+ ? -1
+ : (sourcePos - _xforms.GetWorldPosition(xform, xformQuery)).Length();
+
+ if (range < 0 || range > ev.VoiceRange)
+ continue;
+
+ ev.Recipients.TryAdd(actor.PlayerSession, new ICChatRecipientData(range, false));
+ }
+ }
+
public override bool SetVisionEnabled(Entity<StationAiVisionComponent> entity, bool enabled, bool announce = false)
{
if (!base.SetVisionEnabled(entity, enabled, announce))
--- /dev/null
+using Content.Server.Access.Systems;
+using Content.Server.Administration.Logs;
+using Content.Server.Chat.Systems;
+using Content.Server.Interaction;
+using Content.Server.Power.EntitySystems;
+using Content.Server.Speech;
+using Content.Server.Speech.Components;
+using Content.Shared.Chat;
+using Content.Shared.Database;
+using Content.Shared.Mind.Components;
+using Content.Shared.Power;
+using Content.Shared.Speech;
+using Content.Shared.Telephone;
+using Robust.Server.GameObjects;
+using Robust.Shared.Audio.Systems;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Replays;
+using System.Linq;
+using Content.Shared.Silicons.StationAi;
+using Content.Shared.Silicons.Borgs.Components;
+
+namespace Content.Server.Telephone;
+
+public sealed class TelephoneSystem : SharedTelephoneSystem
+{
+ [Dependency] private readonly AppearanceSystem _appearanceSystem = default!;
+ [Dependency] private readonly InteractionSystem _interaction = default!;
+ [Dependency] private readonly IdCardSystem _idCardSystem = default!;
+ [Dependency] private readonly SharedAudioSystem _audio = default!;
+ [Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly IPrototypeManager _prototype = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly IReplayRecordingManager _replay = default!;
+
+ // Has set used to prevent telephone feedback loops
+ private HashSet<(EntityUid, string, Entity<TelephoneComponent>)> _recentChatMessages = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent<TelephoneComponent, ComponentShutdown>(OnComponentShutdown);
+ SubscribeLocalEvent<TelephoneComponent, PowerChangedEvent>(OnPowerChanged);
+ SubscribeLocalEvent<TelephoneComponent, ListenAttemptEvent>(OnAttemptListen);
+ SubscribeLocalEvent<TelephoneComponent, ListenEvent>(OnListen);
+ SubscribeLocalEvent<TelephoneComponent, TelephoneMessageReceivedEvent>(OnTelephoneMessageReceived);
+ }
+
+ #region: Events
+
+ private void OnComponentShutdown(Entity<TelephoneComponent> entity, ref ComponentShutdown ev)
+ {
+ TerminateTelephoneCalls(entity);
+ }
+
+ private void OnPowerChanged(Entity<TelephoneComponent> entity, ref PowerChangedEvent ev)
+ {
+ if (!ev.Powered)
+ TerminateTelephoneCalls(entity);
+ }
+
+ private void OnAttemptListen(Entity<TelephoneComponent> entity, ref ListenAttemptEvent args)
+ {
+ if (!IsTelephonePowered(entity) ||
+ !IsTelephoneEngaged(entity) ||
+ entity.Comp.Muted ||
+ !_interaction.InRangeUnobstructed(args.Source, entity.Owner, 0))
+ {
+ args.Cancel();
+ }
+ }
+
+ private void OnListen(Entity<TelephoneComponent> entity, ref ListenEvent args)
+ {
+ if (args.Source == entity.Owner)
+ return;
+
+ // Ignore background chatter from non-player entities
+ if (!HasComp<MindContainerComponent>(args.Source))
+ return;
+
+ // Simple check to make sure that we haven't sent this message already this frame
+ if (!_recentChatMessages.Add((args.Source, args.Message, entity)))
+ return;
+
+ SendTelephoneMessage(args.Source, args.Message, entity);
+ }
+
+ private void OnTelephoneMessageReceived(Entity<TelephoneComponent> entity, ref TelephoneMessageReceivedEvent args)
+ {
+ // Prevent message feedback loops
+ if (entity == args.TelephoneSource)
+ return;
+
+ if (!IsTelephonePowered(entity) ||
+ !IsSourceConnectedToReceiver(args.TelephoneSource, entity))
+ return;
+
+ var nameEv = new TransformSpeakerNameEvent(args.MessageSource, Name(args.MessageSource));
+ RaiseLocalEvent(args.MessageSource, nameEv);
+
+ var name = Loc.GetString("speech-name-relay",
+ ("speaker", Name(entity)),
+ ("originalName", nameEv.VoiceName));
+
+ var volume = entity.Comp.SpeakerVolume == TelephoneVolume.Speak ? InGameICChatType.Speak : InGameICChatType.Whisper;
+ _chat.TrySendInGameICMessage(entity, args.Message, volume, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false);
+ }
+
+ #endregion
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ var query = EntityManager.EntityQueryEnumerator<TelephoneComponent>();
+ while (query.MoveNext(out var uid, out var telephone))
+ {
+ var entity = new Entity<TelephoneComponent>(uid, telephone);
+
+ if (IsTelephoneEngaged(entity))
+ {
+ foreach (var receiver in telephone.LinkedTelephones)
+ {
+ if (!IsSourceInRangeOfReceiver(entity, receiver) &&
+ !IsSourceInRangeOfReceiver(receiver, entity))
+ {
+ EndTelephoneCall(entity, receiver);
+ }
+ }
+ }
+
+ switch (telephone.CurrentState)
+ {
+ // Try to play ring tone if ringing
+ case TelephoneState.Ringing:
+ if (_timing.CurTime > telephone.StateStartTime + TimeSpan.FromSeconds(telephone.RingingTimeout))
+ EndTelephoneCalls(entity);
+
+ else if (telephone.RingTone != null &&
+ _timing.CurTime > telephone.NextRingToneTime)
+ {
+ _audio.PlayPvs(telephone.RingTone, uid);
+ telephone.NextRingToneTime = _timing.CurTime + TimeSpan.FromSeconds(telephone.RingInterval);
+ }
+
+ break;
+
+ // Try to hang up if their has been no recent in-call activity
+ case TelephoneState.InCall:
+ if (_timing.CurTime > telephone.StateStartTime + TimeSpan.FromSeconds(telephone.IdlingTimeout))
+ EndTelephoneCalls(entity);
+
+ break;
+
+ // Try to terminate if the telephone has finished hanging up
+ case TelephoneState.EndingCall:
+ if (_timing.CurTime > telephone.StateStartTime + TimeSpan.FromSeconds(telephone.HangingUpTimeout))
+ TerminateTelephoneCalls(entity);
+
+ break;
+ }
+ }
+
+ _recentChatMessages.Clear();
+ }
+
+ public void BroadcastCallToTelephones(Entity<TelephoneComponent> source, HashSet<Entity<TelephoneComponent>> receivers, EntityUid user, TelephoneCallOptions? options = null)
+ {
+ if (IsTelephoneEngaged(source))
+ return;
+
+ foreach (var receiver in receivers)
+ TryCallTelephone(source, receiver, user, options);
+
+ // If no connections could be made, hang up the telephone
+ if (!IsTelephoneEngaged(source))
+ EndTelephoneCalls(source);
+ }
+
+ public void CallTelephone(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver, EntityUid user, TelephoneCallOptions? options = null)
+ {
+ if (IsTelephoneEngaged(source))
+ return;
+
+ if (!TryCallTelephone(source, receiver, user, options))
+ EndTelephoneCalls(source);
+ }
+
+ private bool TryCallTelephone(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver, EntityUid user, TelephoneCallOptions? options = null)
+ {
+ if (!IsSourceAbleToReachReceiver(source, receiver))
+ return false;
+
+ if (IsTelephoneEngaged(receiver) &&
+ options?.ForceConnect != true &&
+ options?.ForceJoin != true)
+ return false;
+
+ var evCallAttempt = new TelephoneCallAttemptEvent(source, receiver, user);
+ RaiseLocalEvent(source, ref evCallAttempt);
+
+ if (evCallAttempt.Cancelled)
+ return false;
+
+ if (options?.ForceConnect == true)
+ TerminateTelephoneCalls(receiver);
+
+ source.Comp.LinkedTelephones.Add(receiver);
+ source.Comp.Muted = options?.MuteSource == true;
+
+ receiver.Comp.LastCallerId = GetNameAndJobOfCallingEntity(user); // This will be networked when the state changes
+ receiver.Comp.LinkedTelephones.Add(source);
+ receiver.Comp.Muted = options?.MuteReceiver == true;
+
+ // Try to open a line of communication immediately
+ if (options?.ForceConnect == true ||
+ (options?.ForceJoin == true && receiver.Comp.CurrentState == TelephoneState.InCall))
+ {
+ CommenceTelephoneCall(source, receiver);
+ return true;
+ }
+
+ // Otherwise start ringing the receiver
+ SetTelephoneState(source, TelephoneState.Calling);
+ SetTelephoneState(receiver, TelephoneState.Ringing);
+
+ return true;
+ }
+
+ public void AnswerTelephone(Entity<TelephoneComponent> receiver, EntityUid user)
+ {
+ if (receiver.Comp.CurrentState != TelephoneState.Ringing)
+ return;
+
+ // If the telephone isn't linked, or is linked to more than one telephone,
+ // you shouldn't need to answer the call. If you do need to answer it,
+ // you'll need to be handled this a different way
+ if (receiver.Comp.LinkedTelephones.Count != 1)
+ return;
+
+ var source = receiver.Comp.LinkedTelephones.First();
+ CommenceTelephoneCall(source, receiver);
+ }
+
+ private void CommenceTelephoneCall(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver)
+ {
+ SetTelephoneState(source, TelephoneState.InCall);
+ SetTelephoneState(receiver, TelephoneState.InCall);
+
+ SetTelephoneMicrophoneState(source, true);
+ SetTelephoneMicrophoneState(receiver, true);
+
+ var evSource = new TelephoneCallCommencedEvent(receiver);
+ var evReceiver = new TelephoneCallCommencedEvent(source);
+
+ RaiseLocalEvent(source, ref evSource);
+ RaiseLocalEvent(receiver, ref evReceiver);
+ }
+
+ public void EndTelephoneCall(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver)
+ {
+ source.Comp.LinkedTelephones.Remove(receiver);
+ receiver.Comp.LinkedTelephones.Remove(source);
+
+ if (!IsTelephoneEngaged(source))
+ EndTelephoneCalls(source);
+
+ if (!IsTelephoneEngaged(receiver))
+ EndTelephoneCalls(receiver);
+ }
+
+ public void EndTelephoneCalls(Entity<TelephoneComponent> entity)
+ {
+ HandleEndingTelephoneCalls(entity, TelephoneState.EndingCall);
+
+ var ev = new TelephoneCallEndedEvent();
+ RaiseLocalEvent(entity, ref ev);
+ }
+
+ public void TerminateTelephoneCalls(Entity<TelephoneComponent> entity)
+ {
+ HandleEndingTelephoneCalls(entity, TelephoneState.Idle);
+ }
+
+ private void HandleEndingTelephoneCalls(Entity<TelephoneComponent> entity, TelephoneState newState)
+ {
+ if (entity.Comp.CurrentState == newState)
+ return;
+
+ foreach (var linkedTelephone in entity.Comp.LinkedTelephones)
+ {
+ if (!linkedTelephone.Comp.LinkedTelephones.Remove(entity))
+ continue;
+
+ if (!IsTelephoneEngaged(linkedTelephone))
+ EndTelephoneCalls(linkedTelephone);
+ }
+
+ entity.Comp.LinkedTelephones.Clear();
+ entity.Comp.Muted = false;
+
+ SetTelephoneState(entity, newState);
+ SetTelephoneMicrophoneState(entity, false);
+ }
+
+ private void SendTelephoneMessage(EntityUid messageSource, string message, Entity<TelephoneComponent> source, bool escapeMarkup = true)
+ {
+ // This method assumes that you've already checked that this
+ // telephone is able to transmit messages and that it can
+ // send messages to any telephones linked to it
+
+ var ev = new TransformSpeakerNameEvent(messageSource, MetaData(messageSource).EntityName);
+ RaiseLocalEvent(messageSource, ev);
+
+ var name = ev.VoiceName;
+ name = FormattedMessage.EscapeText(name);
+
+ SpeechVerbPrototype speech;
+ if (ev.SpeechVerb != null && _prototype.TryIndex(ev.SpeechVerb, out var evntProto))
+ speech = evntProto;
+ else
+ speech = _chat.GetSpeechVerb(messageSource, message);
+
+ var content = escapeMarkup
+ ? FormattedMessage.EscapeText(message)
+ : message;
+
+ var wrappedMessage = Loc.GetString(speech.Bold ? "chat-telephone-message-wrap-bold" : "chat-telephone-message-wrap",
+ ("color", Color.White),
+ ("fontType", speech.FontId),
+ ("fontSize", speech.FontSize),
+ ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
+ ("name", name),
+ ("message", content));
+
+ var chat = new ChatMessage(
+ ChatChannel.Local,
+ message,
+ wrappedMessage,
+ NetEntity.Invalid,
+ null);
+
+ var chatMsg = new MsgChatMessage { Message = chat };
+
+ var evSentMessage = new TelephoneMessageSentEvent(message, chatMsg, messageSource);
+ RaiseLocalEvent(source, ref evSentMessage);
+ source.Comp.StateStartTime = _timing.CurTime;
+
+ var evReceivedMessage = new TelephoneMessageReceivedEvent(message, chatMsg, messageSource, source);
+
+ foreach (var receiver in source.Comp.LinkedTelephones)
+ {
+ RaiseLocalEvent(receiver, ref evReceivedMessage);
+ receiver.Comp.StateStartTime = _timing.CurTime;
+ }
+
+ if (name != Name(messageSource))
+ _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Telephone message from {ToPrettyString(messageSource):user} as {name} on {source}: {message}");
+ else
+ _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Telephone message from {ToPrettyString(messageSource):user} on {source}: {message}");
+
+ _replay.RecordServerMessage(chat);
+ }
+
+ private void SetTelephoneState(Entity<TelephoneComponent> entity, TelephoneState newState)
+ {
+ var oldState = entity.Comp.CurrentState;
+
+ entity.Comp.CurrentState = newState;
+ entity.Comp.StateStartTime = _timing.CurTime;
+ Dirty(entity);
+
+ _appearanceSystem.SetData(entity, TelephoneVisuals.Key, entity.Comp.CurrentState);
+
+ var ev = new TelephoneStateChangeEvent(oldState, newState);
+ RaiseLocalEvent(entity, ref ev);
+ }
+
+ private void SetTelephoneMicrophoneState(Entity<TelephoneComponent> entity, bool microphoneOn)
+ {
+ if (microphoneOn && !HasComp<ActiveListenerComponent>(entity))
+ {
+ var activeListener = AddComp<ActiveListenerComponent>(entity);
+ activeListener.Range = entity.Comp.ListeningRange;
+ }
+
+ if (!microphoneOn && HasComp<ActiveListenerComponent>(entity))
+ {
+ RemComp<ActiveListenerComponent>(entity);
+ }
+ }
+
+ private (string?, string?) GetNameAndJobOfCallingEntity(EntityUid uid)
+ {
+ string? presumedName = null;
+ string? presumedJob = null;
+
+ if (HasComp<StationAiHeldComponent>(uid) || HasComp<BorgChassisComponent>(uid))
+ {
+ presumedName = Name(uid);
+ return (presumedName, presumedJob);
+ }
+
+ if (_idCardSystem.TryFindIdCard(uid, out var idCard))
+ {
+ presumedName = string.IsNullOrWhiteSpace(idCard.Comp.FullName) ? null : idCard.Comp.FullName;
+ presumedJob = idCard.Comp.LocalizedJobTitle;
+ }
+
+ return (presumedName, presumedJob);
+ }
+
+ public bool IsSourceAbleToReachReceiver(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver)
+ {
+ if (source == receiver ||
+ !IsTelephonePowered(source) ||
+ !IsTelephonePowered(receiver) ||
+ !IsSourceInRangeOfReceiver(source, receiver))
+ {
+ return false;
+ }
+
+ return true;
+ }
+
+ public bool IsSourceInRangeOfReceiver(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver)
+ {
+ var sourceXform = Transform(source);
+ var receiverXform = Transform(receiver);
+
+ switch (source.Comp.TransmissionRange)
+ {
+ case TelephoneRange.Grid:
+ return sourceXform.GridUid != null &&
+ receiverXform.GridUid == sourceXform.GridUid &&
+ receiver.Comp.TransmissionRange != TelephoneRange.Long;
+
+ case TelephoneRange.Map:
+ return sourceXform.MapID == receiverXform.MapID &&
+ receiver.Comp.TransmissionRange != TelephoneRange.Long;
+
+ case TelephoneRange.Long:
+ return sourceXform.MapID != receiverXform.MapID &&
+ receiver.Comp.TransmissionRange == TelephoneRange.Long;
+
+ case TelephoneRange.Unlimited:
+ return true;
+ }
+
+ return false;
+ }
+
+ public bool IsSourceConnectedToReceiver(Entity<TelephoneComponent> source, Entity<TelephoneComponent> receiver)
+ {
+ return source.Comp.LinkedTelephones.Contains(receiver);
+ }
+
+ public bool IsTelephonePowered(Entity<TelephoneComponent> entity)
+ {
+ return this.IsPowered(entity, EntityManager) || !entity.Comp.RequiresPower;
+ }
+}
-using Robust.Shared.Serialization;
+using Robust.Shared.Serialization;
namespace Content.Shared.Doors
{
BoltIndicator,
BoltLightIndicator,
AiControlIndicator,
+ AiVisionIndicator,
TimingIndicator,
SafetyIndicator,
}
--- /dev/null
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Holopad;
+
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HolographicAvatarComponent : Component
+{
+ /// <summary>
+ /// The prototype sprite layer data for the hologram
+ /// </summary>
+ [DataField]
+ public PrototypeLayerData[] LayerData;
+}
--- /dev/null
+using Content.Shared.Telephone;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Holopad;
+
+/// <summary>
+/// Holds data pertaining to holopads
+/// </summary>
+/// <remarks>
+/// Holopads also require a <see cref="TelephoneComponent"/> to function
+/// </remarks>
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedHolopadSystem))]
+public sealed partial class HolopadComponent : Component
+{
+ /// <summary>
+ /// The entity being projected by the holopad
+ /// </summary>
+ [ViewVariables]
+ public Entity<HolopadHologramComponent>? Hologram;
+
+ /// <summary>
+ /// The entity using the holopad
+ /// </summary>
+ [ViewVariables]
+ public Entity<HolopadUserComponent>? User;
+
+ /// <summary>
+ /// Proto ID for the user's hologram
+ /// </summary>
+ [DataField]
+ public EntProtoId? HologramProtoId;
+
+ /// <summary>
+ /// The entity that has locked out the controls of this device
+ /// </summary>
+ [ViewVariables, AutoNetworkedField]
+ public EntityUid? ControlLockoutOwner = null;
+
+ /// <summary>
+ /// The game tick the control lockout was initiated
+ /// </summary>
+ [ViewVariables, AutoNetworkedField]
+ public TimeSpan ControlLockoutStartTime;
+
+ /// <summary>
+ /// The duration that the control lockout will last in seconds
+ /// </summary>
+ [DataField]
+ public float ControlLockoutDuration { get; private set; } = 90f;
+
+ /// <summary>
+ /// The duration before the controls can be lockout again in seconds
+ /// </summary>
+ [DataField]
+ public float ControlLockoutCoolDown { get; private set; } = 180f;
+}
+
+#region: Event messages
+
+/// <summary>
+/// Data from by the server to the client for the holopad UI
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadBoundInterfaceState : BoundUserInterfaceState
+{
+ public readonly Dictionary<NetEntity, string> Holopads;
+
+ public HolopadBoundInterfaceState(Dictionary<NetEntity, string> holopads)
+ {
+ Holopads = holopads;
+ }
+}
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadStartNewCallMessage : BoundUserInterfaceMessage
+{
+ public readonly NetEntity Receiver;
+
+ public HolopadStartNewCallMessage(NetEntity receiver)
+ {
+ Receiver = receiver;
+ }
+}
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadAnswerCallMessage : BoundUserInterfaceMessage { }
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadEndCallMessage : BoundUserInterfaceMessage { }
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadStartBroadcastMessage : BoundUserInterfaceMessage { }
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadActivateProjectorMessage : BoundUserInterfaceMessage { }
+
+/// <summary>
+/// Triggers the server to send updated power monitoring console data to the client for the single player session
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadStationAiRequestMessage : BoundUserInterfaceMessage { }
+
+#endregion
+
+/// <summary>
+/// Key to the Holopad UI
+/// </summary>
+[Serializable, NetSerializable]
+public enum HolopadUiKey : byte
+{
+ InteractionWindow,
+ InteractionWindowForAi,
+ AiActionWindow,
+ AiRequestWindow
+}
--- /dev/null
+using Robust.Shared.GameStates;
+using System.Numerics;
+
+namespace Content.Shared.Holopad;
+
+/// <summary>
+/// Holds data pertaining to holopad holograms
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed partial class HolopadHologramComponent : Component
+{
+ /// <summary>
+ /// Default RSI path
+ /// </summary>
+ [DataField]
+ public string RsiPath = string.Empty;
+
+ /// <summary>
+ /// Default RSI state
+ /// </summary>
+ [DataField]
+ public string RsiState = string.Empty;
+
+ /// <summary>
+ /// Name of the shader to use
+ /// </summary>
+ [DataField]
+ public string ShaderName = string.Empty;
+
+ /// <summary>
+ /// The primary color
+ /// </summary>
+ [DataField]
+ public Color Color1 = Color.White;
+
+ /// <summary>
+ /// The secondary color
+ /// </summary>
+ [DataField]
+ public Color Color2 = Color.White;
+
+ /// <summary>
+ /// The shared color alpha
+ /// </summary>
+ [DataField]
+ public float Alpha = 1f;
+
+ /// <summary>
+ /// The color brightness
+ /// </summary>
+ [DataField]
+ public float Intensity = 1f;
+
+ /// <summary>
+ /// The scroll rate of the hologram shader
+ /// </summary>
+ [DataField]
+ public float ScrollRate = 1f;
+
+ /// <summary>
+ /// The sprite offset
+ /// </summary>
+ [DataField]
+ public Vector2 Offset = new Vector2();
+
+ /// <summary>
+ /// A user that are linked to this hologram
+ /// </summary>
+ [ViewVariables]
+ public Entity<HolopadComponent>? LinkedHolopad;
+}
--- /dev/null
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Holopad;
+
+/// <summary>
+/// Holds data pertaining to entities that are using holopads
+/// </summary>
+/// <remarks>
+/// This component is added and removed automatically from entities
+/// </remarks>
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(SharedHolopadSystem))]
+public sealed partial class HolopadUserComponent : Component
+{
+ /// <summary>
+ /// A list of holopads that the user is interacting with
+ /// </summary>
+ [ViewVariables]
+ public HashSet<Entity<HolopadComponent>> LinkedHolopads = new();
+}
+
+/// <summary>
+/// A networked event raised when the visual state of a hologram is being updated
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadHologramVisualsUpdateEvent : EntityEventArgs
+{
+ /// <summary>
+ /// The hologram being updated
+ /// </summary>
+ public readonly NetEntity Hologram;
+
+ /// <summary>
+ /// The target the hologram is copying
+ /// </summary>
+ public readonly NetEntity? Target;
+
+ public HolopadHologramVisualsUpdateEvent(NetEntity hologram, NetEntity? target = null)
+ {
+ Hologram = hologram;
+ Target = target;
+ }
+}
+
+/// <summary>
+/// A networked event raised when the visual state of a hologram is being updated
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class HolopadUserTypingChangedEvent : EntityEventArgs
+{
+ /// <summary>
+ /// The hologram being updated
+ /// </summary>
+ public readonly NetEntity User;
+
+ /// <summary>
+ /// The typing indicator state
+ /// </summary>
+ public readonly bool IsTyping;
+
+ public HolopadUserTypingChangedEvent(NetEntity user, bool isTyping)
+ {
+ User = user;
+ IsTyping = isTyping;
+ }
+}
+
+/// <summary>
+/// A networked event raised by the server to request the current visual state of a target player entity
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class PlayerSpriteStateRequest : EntityEventArgs
+{
+ /// <summary>
+ /// The player entity in question
+ /// </summary>
+ public readonly NetEntity TargetPlayer;
+
+ public PlayerSpriteStateRequest(NetEntity targetPlayer)
+ {
+ TargetPlayer = targetPlayer;
+ }
+}
+
+/// <summary>
+/// The client's response to a <see cref="PlayerSpriteStateRequest"/>
+/// </summary>
+[Serializable, NetSerializable]
+public sealed class PlayerSpriteStateMessage : EntityEventArgs
+{
+ public readonly NetEntity SpriteEntity;
+
+ /// <summary>
+ /// Data needed to reconstruct the player's sprite component layers
+ /// </summary>
+ public readonly PrototypeLayerData[]? SpriteLayerData;
+
+ public PlayerSpriteStateMessage(NetEntity spriteEntity, PrototypeLayerData[]? spriteLayerData = null)
+ {
+ SpriteEntity = spriteEntity;
+ SpriteLayerData = spriteLayerData;
+ }
+}
--- /dev/null
+using Robust.Shared.Timing;
+
+namespace Content.Shared.Holopad;
+
+public abstract class SharedHolopadSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+
+ public bool IsHolopadControlLocked(Entity<HolopadComponent> entity, EntityUid? user = null)
+ {
+ if (entity.Comp.ControlLockoutStartTime == TimeSpan.Zero)
+ return false;
+
+ if (entity.Comp.ControlLockoutStartTime + TimeSpan.FromSeconds(entity.Comp.ControlLockoutDuration) < _timing.CurTime)
+ return false;
+
+ if (entity.Comp.ControlLockoutOwner == null || entity.Comp.ControlLockoutOwner == user)
+ return false;
+
+ return true;
+ }
+
+ public TimeSpan GetHolopadControlLockedPeriod(Entity<HolopadComponent> entity)
+ {
+ return entity.Comp.ControlLockoutStartTime + TimeSpan.FromSeconds(entity.Comp.ControlLockoutDuration) - _timing.CurTime;
+ }
+
+ public bool IsHolopadBroadcastOnCoolDown(Entity<HolopadComponent> entity)
+ {
+ if (entity.Comp.ControlLockoutStartTime == TimeSpan.Zero)
+ return false;
+
+ if (entity.Comp.ControlLockoutStartTime + TimeSpan.FromSeconds(entity.Comp.ControlLockoutCoolDown) < _timing.CurTime)
+ return false;
+
+ return true;
+ }
+
+ public TimeSpan GetHolopadBroadcastCoolDown(Entity<HolopadComponent> entity)
+ {
+ return entity.Comp.ControlLockoutStartTime + TimeSpan.FromSeconds(entity.Comp.ControlLockoutCoolDown) - _timing.CurTime;
+ }
+}
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Containers;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
using Robust.Shared.Map.Components;
using Robust.Shared.Network;
using Robust.Shared.Physics;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
+using System.Diagnostics.CodeAnalysis;
namespace Content.Shared.Silicons.StationAi;
[ValidatePrototypeId<EntityPrototype>]
private static readonly EntProtoId DefaultAi = "StationAiBrain";
+ private const float MaxVisionMultiplier = 5f;
+
public override void Initialize()
{
base.Initialize();
AttachEye(ent);
}
- private bool SetupEye(Entity<StationAiCoreComponent> ent)
+ public void SwitchRemoteEntityMode(Entity<StationAiCoreComponent> ent, bool isRemote)
+ {
+ if (isRemote == ent.Comp.Remote)
+ return;
+
+ ent.Comp.Remote = isRemote;
+
+ EntityCoordinates? coords = ent.Comp.RemoteEntity != null ? Transform(ent.Comp.RemoteEntity.Value).Coordinates : null;
+
+ // Attach new eye
+ ClearEye(ent);
+
+ if (SetupEye(ent, coords))
+ AttachEye(ent);
+
+ // Adjust user FoV
+ var user = GetInsertedAI(ent);
+
+ if (TryComp<EyeComponent>(user, out var eye))
+ _eye.SetDrawFov(user.Value, !isRemote);
+ }
+
+ private bool SetupEye(Entity<StationAiCoreComponent> ent, EntityCoordinates? coords = null)
{
if (_net.IsClient)
return false;
+
if (ent.Comp.RemoteEntity != null)
return false;
- if (ent.Comp.RemoteEntityProto != null)
+ var proto = ent.Comp.RemoteEntityProto;
+
+ if (coords == null)
+ coords = Transform(ent.Owner).Coordinates;
+
+ if (!ent.Comp.Remote)
+ proto = ent.Comp.PhysicalEntityProto;
+
+ if (proto != null)
{
- ent.Comp.RemoteEntity = SpawnAtPosition(ent.Comp.RemoteEntityProto, Transform(ent.Owner).Coordinates);
+ ent.Comp.RemoteEntity = SpawnAtPosition(proto, coords.Value);
Dirty(ent);
}
{
if (_net.IsClient)
return;
+
QueueDel(ent.Comp.RemoteEntity);
ent.Comp.RemoteEntity = null;
Dirty(ent);
_mover.SetRelay(user, ent.Comp.RemoteEntity.Value);
}
+ private EntityUid? GetInsertedAI(Entity<StationAiCoreComponent> ent)
+ {
+ if (!_containers.TryGetContainer(ent.Owner, StationAiHolderComponent.Container, out var container) ||
+ container.ContainedEntities.Count != 1)
+ {
+ return null;
+ }
+
+ return container.ContainedEntities[0];
+ }
+
private void OnAiInsert(Entity<StationAiCoreComponent> ent, ref EntInsertedIntoContainerMessage args)
{
if (args.Container.ID != StationAiCoreComponent.Container)
if (_timing.ApplyingState)
return;
+ ent.Comp.Remote = true;
SetupEye(ent);
// Just so text and the likes works properly
if (_timing.ApplyingState)
return;
+ ent.Comp.Remote = true;
+
// Reset name to whatever
_metadata.SetEntityName(ent.Owner, Prototype(ent.Owner)?.Name ?? string.Empty);
_eye.SetDrawFov(args.Entity, true, eyeComp);
_eye.SetTarget(args.Entity, null, eyeComp);
}
+
ClearEye(ent);
}
return _blocker.CanComplexInteract(entity.Owner);
}
+
+ public bool TryGetStationAiCore(Entity<StationAiHeldComponent?> ent, [NotNullWhen(true)] out Entity<StationAiCoreComponent>? parentEnt)
+ {
+ parentEnt = null;
+ var parent = Transform(ent).ParentUid;
+
+ if (!parent.IsValid())
+ return false;
+
+ if (!TryComp<StationAiCoreComponent>(parent, out var stationAiCore))
+ return false;
+
+ parentEnt = new Entity<StationAiCoreComponent>(parent, stationAiCore);
+
+ return true;
+ }
+
+ public bool TryGetInsertedAI(Entity<StationAiCoreComponent> ent, [NotNullWhen(true)] out Entity<StationAiHeldComponent>? insertedAi)
+ {
+ insertedAi = null;
+ var insertedEnt = GetInsertedAI(ent);
+
+ if (TryComp<StationAiHeldComponent>(insertedEnt, out var stationAiHeld))
+ {
+ insertedAi = (insertedEnt.Value, stationAiHeld);
+ return true;
+ }
+
+ return false;
+ }
}
public sealed partial class JumpToCoreEvent : InstantActionEvent
/// <summary>
/// Can it move its camera around and interact remotely with things.
+ /// When false, the AI is being projected into a local area, such as a holopad
/// </summary>
[DataField, AutoNetworkedField]
public bool Remote = true;
[DataField, AutoNetworkedField]
public EntityUid? RemoteEntity;
+ /// <summary>
+ /// Prototype that represents the 'eye' of the AI
+ /// </summary>
[DataField(readOnly: true)]
public EntProtoId? RemoteEntityProto = "StationAiHolo";
+ /// <summary>
+ /// Prototype that represents the physical avatar of the AI
+ /// </summary>
+ [DataField(readOnly: true)]
+ public EntProtoId? PhysicalEntityProto = "StationAiHoloLocal";
+
public const string Container = "station_ai_mind_slot";
}
public float SoundCooldownTime { get; set; } = 0.5f;
public TimeSpan LastTimeSoundPlayed = TimeSpan.Zero;
+
+ /// <summary>
+ /// Additional vertical offset for speech bubbles generated by this entity
+ /// </summary>
+ [DataField]
+ public float SpeechBubbleOffset = 0f;
}
}
--- /dev/null
+using System.Linq;
+
+namespace Content.Shared.Telephone;
+
+public abstract class SharedTelephoneSystem : EntitySystem
+{
+ public bool IsTelephoneEngaged(Entity<TelephoneComponent> entity)
+ {
+ return entity.Comp.LinkedTelephones.Any();
+ }
+
+ public string GetFormattedCallerIdForEntity(string? presumedName, string? presumedJob, Color fontColor, string fontType = "Default", int fontSize = 12)
+ {
+ var callerId = Loc.GetString("chat-telephone-unknown-caller",
+ ("color", fontColor),
+ ("fontType", fontType),
+ ("fontSize", fontSize));
+
+ if (presumedName == null)
+ return callerId;
+
+ if (presumedJob != null)
+ callerId = Loc.GetString("chat-telephone-caller-id-with-job",
+ ("callerName", presumedName),
+ ("callerJob", presumedJob),
+ ("color", fontColor),
+ ("fontType", fontType),
+ ("fontSize", fontSize));
+
+ else
+ callerId = Loc.GetString("chat-telephone-caller-id-without-job",
+ ("callerName", presumedName),
+ ("color", fontColor),
+ ("fontType", fontType),
+ ("fontSize", fontSize));
+
+ return callerId;
+ }
+}
--- /dev/null
+using Content.Shared.Chat;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Telephone;
+
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
+[Access(typeof(SharedTelephoneSystem))]
+public sealed partial class TelephoneComponent : Component
+{
+ /// <summary>
+ /// Sets how long the telephone will ring before it automatically hangs up
+ /// </summary>
+ [DataField]
+ public float RingingTimeout = 30;
+
+ /// <summary>
+ /// Sets how long the telephone can remain idle in-call before it automatically hangs up
+ /// </summary>
+ [DataField]
+ public float IdlingTimeout = 60;
+
+ /// <summary>
+ /// Sets how long the telephone will stay in the hanging up state before return to idle
+ /// </summary>
+ [DataField]
+ public float HangingUpTimeout = 2;
+
+ /// <summary>
+ /// Tone played while the phone is ringing
+ /// </summary>
+ [DataField]
+ public SoundSpecifier? RingTone = null;
+
+ /// <summary>
+ /// Sets the number of seconds before the next ring tone is played
+ /// </summary>
+ [DataField]
+ public float RingInterval = 2f;
+
+ /// <summary>
+ /// The time at which the next tone will be played
+ /// </summary>
+ [DataField]
+ public TimeSpan NextRingToneTime;
+
+ /// <summary>
+ /// The volume at which relayed messages are played
+ /// </summary>
+ [DataField]
+ public TelephoneVolume SpeakerVolume = TelephoneVolume.Whisper;
+
+ /// <summary>
+ /// The range at which the telephone can connect to another
+ /// </summary>
+ [DataField]
+ public TelephoneRange TransmissionRange = TelephoneRange.Grid;
+
+ /// <summary>
+ /// The range at which the telephone picks up voices
+ /// </summary>
+ [DataField]
+ public float ListeningRange = 2;
+
+ /// <summary>
+ /// Specifies whether this telephone require power to fucntion
+ /// </summary>
+ [DataField]
+ public bool RequiresPower = true;
+
+ /// <summary>
+ /// This telephone does not appear on public telephone directories
+ /// </summary>
+ [DataField]
+ public bool UnlistedNumber = false;
+
+ /// <summary>
+ /// Telephone number for this device
+ /// </summary>
+ /// <remarks>
+ /// For future use - a system for generating and handling telephone numbers has not been implemented yet
+ /// </remarks>
+ [ViewVariables]
+ public int TelephoneNumber = -1;
+
+ /// <summary>
+ /// Linked telephone
+ /// </summary>
+ [ViewVariables]
+ public HashSet<Entity<TelephoneComponent>> LinkedTelephones = new();
+
+ /// <summary>
+ /// Defines the current state the telephone is in
+ /// </summary>
+ [ViewVariables, AutoNetworkedField]
+ public TelephoneState CurrentState = TelephoneState.Idle;
+
+ /// <summary>
+ /// The game tick the current state started
+ /// </summary>
+ [ViewVariables]
+ public TimeSpan StateStartTime;
+
+ /// <summary>
+ /// Sets whether the telphone can pick up nearby speech
+ /// </summary>
+ [ViewVariables]
+ public bool Muted = false;
+
+ /// <summary>
+ /// The presumed name and/or job of the last person to call this telephone
+ /// </summary>
+ [ViewVariables, AutoNetworkedField]
+ public (string?, string?) LastCallerId;
+}
+
+#region: Telephone events
+
+/// <summary>
+/// Raised when one telephone is attempting to call another
+/// </summary>
+[ByRefEvent]
+public record struct TelephoneCallAttemptEvent(Entity<TelephoneComponent> Source, Entity<TelephoneComponent> Receiver, EntityUid? User)
+{
+ public bool Cancelled = false;
+}
+
+/// <summary>
+/// Raised when a telephone's state changes
+/// </summary>
+[ByRefEvent]
+public record struct TelephoneStateChangeEvent(TelephoneState OldState, TelephoneState NewState);
+
+/// <summary>
+/// Raised when communication between one telephone and another begins
+/// </summary>
+[ByRefEvent]
+public record struct TelephoneCallCommencedEvent(Entity<TelephoneComponent> Receiver);
+
+/// <summary>
+/// Raised when a telephone hangs up
+/// </summary>
+[ByRefEvent]
+public record struct TelephoneCallEndedEvent();
+
+/// <summary>
+/// Raised when a chat message is sent by a telephone to another
+/// </summary>
+[ByRefEvent]
+public readonly record struct TelephoneMessageSentEvent(string Message, MsgChatMessage ChatMsg, EntityUid MessageSource);
+
+/// <summary>
+/// Raised when a chat message is received by a telephone from another
+/// </summary>
+[ByRefEvent]
+public readonly record struct TelephoneMessageReceivedEvent(string Message, MsgChatMessage ChatMsg, EntityUid MessageSource, Entity<TelephoneComponent> TelephoneSource);
+
+#endregion
+
+/// <summary>
+/// Options for tailoring telephone calls
+/// </summary>
+[Serializable, NetSerializable]
+public struct TelephoneCallOptions
+{
+ public bool ForceConnect; // The source immediately starts a call with the receiver, potentially interrupting a call that is already in progress
+ public bool ForceJoin; // The source smoothly joins a call in progress, or starts a normal call with the receiver if there is none
+ public bool MuteSource; // Chatter from the source is not transmitted - could be used for eavesdropping when combined with 'ForceJoin'
+ public bool MuteReceiver; // Chatter from the receiver is not transmitted - useful for broadcasting messages to multiple receivers
+}
+
+[Serializable, NetSerializable]
+public enum TelephoneVisuals : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public enum TelephoneState : byte
+{
+ Idle,
+ Calling,
+ Ringing,
+ InCall,
+ EndingCall
+}
+
+[Serializable, NetSerializable]
+public enum TelephoneVolume : byte
+{
+ Whisper,
+ Speak
+}
+
+[Serializable, NetSerializable]
+public enum TelephoneRange : byte
+{
+ Grid, // Can call grid/map range telephones that are on the same grid
+ Map, // Can call grid/map range telephones that are on the same map
+ Long, // Can only long range telephones that are on a different map
+ Unlimited // Can call any telephone
+}
license: "CC0-1.0"
copyright: "by Ko4erga"
source: "https://github.com/space-wizards/space-station-14/pull/30431"
+
+- files: ["double_ring.ogg"]
+ license: "CC0-1.0"
+ copyright: "Created by fspera, converted to OGG and modified by chromiumboy."
+ source: "https://freesound.org/people/fspera/sounds/528111/"
- files:
- airlock_emergencyoff.ogg
--- /dev/null
+# Window headers
+holopad-window-title = {CAPITALIZE($title)}
+holopad-window-subtitle = [color=white][bold]Holographic communication system[/bold][/color]
+holopad-window-options = [color=darkgray][font size=10][italic]Please select an option from the list below[/italic][/font][/color]
+
+# Call status
+holopad-window-no-calls-in-progress = No holo-calls in progress
+holopad-window-incoming-call = Incoming holo-call from:
+holopad-window-outgoing-call = Attempting to establish a connection...
+holopad-window-call-in-progress = Holo-call in progress
+holopad-window-call-ending = Disconnecting...
+holopad-window-call-rejected = Unable to establish a connection
+holopad-window-ai-request = Your presence is requested by:
+holopad-window-emergency-broadcast-in-progress = [color=#cf2f2f][bold]Emergency broadcast in progress[/bold][/color]
+holopad-window-controls-locked-out = Control of this device has been locked to:
+holopad-window-controls-unlock-countdown = It will automatically unlock in: {$countdown}
+
+# Buttons
+holopad-window-answer-call = Answer call
+holopad-window-end-call = End call
+holopad-window-request-station-ai = Request station AI
+holopad-window-activate-projector = Activate projector
+holopad-window-emergency-broadcast = Emergency broadcast
+holopad-window-emergency-broadcast-with-countdown = Emergency broadcast ({$countdown})
+holopad-window-access-denied = Access denied
+
+# Contact list
+holopad-window-select-contact-from-list = Select a contact to initiate a holo-call
+holopad-window-fetching-contacts-list = No holopads are currently contactable
+holopad-window-contact-label = {CAPITALIZE($label)}
+
+# Flavor
+holopad-window-flavor-left = ⚠ Do not enter while projector is active
+holopad-window-flavor-right = v3.0.9
+
+# Holograms
+holopad-hologram-name = hologram of {THE($name)}
+
+# Holopad actions
+activate-holopad-projector-verb = Activate holopad projector
\ No newline at end of file
--- /dev/null
+# Chat window telephone wrap (prefix and postfix)
+chat-telephone-message-wrap = [color={$color}][bold]{$name}[/bold] {$verb}, [font={$fontType} size={$fontSize}]"{$message}"[/font][/color]
+chat-telephone-message-wrap-bold = [color={$color}][bold]{$name}[/bold] {$verb}, [font={$fontType} size={$fontSize}][bold]"{$message}"[/bold][/font][/color]
+
+# Caller ID
+chat-telephone-unknown-caller = [color={$color}][font={$fontType} size={$fontSize}][bolditalic]Unknown caller[/bolditalic][/font][/color]
+chat-telephone-caller-id-with-job = [color={$color}][font={$fontType} size={$fontSize}][bold]{CAPITALIZE($callerName)} ({CAPITALIZE($callerJob)})[/bold][/font][/color]
+chat-telephone-caller-id-without-job = [color={$color}][font={$fontType} size={$fontSize}][bold]{CAPITALIZE($callerName)}[/bold][/font][/color]
\ No newline at end of file
wires-board-name-spaceheater = Space Heater
wires-board-name-jukebox = Jukebox
wires-board-name-computer = Computer
+wires-board-name-holopad = Holopad
wires-board-name-barsign = Bar Sign
# names that get displayed in the wire hacking hud & admin logs.
canShuttle: false
title: comms-console-announcement-title-station-ai
color: "#5ed7aa"
+ - type: HolographicAvatar
+ layerData:
+ - sprite: Mobs/Silicon/station_ai.rsi
+ state: default
- type: ShowJobIcons
- type: entity
unshaded:
Empty: { state: ai_empty }
Occupied: { state: ai }
+ - type: Telephone
+ listeningRange: 0
+ speakerVolume: Speak
+ unlistedNumber: true
+ requiresPower: false
+ - type: Holopad
+ - type: StationAiWhitelist
+ - type: UserInterface
+ interfaces:
+ enum.HolopadUiKey.AiRequestWindow:
+ type: HolopadBoundUserInterface
+ enum.HolopadUiKey.AiActionWindow:
+ type: HolopadBoundUserInterface
# The job-ready version of an AI spawn.
- type: entity
shader: unshaded
map: ["base"]
+# The holographic representation of the AI that is projected from a holopad.
+- type: entity
+ id: StationAiHoloLocal
+ name: AI hologram
+ description: A holographic representation of an AI.
+ categories: [ HideSpawnMenu ]
+ suffix: DO NOT MAP
+ components:
+ - type: Transform
+ anchored: true
+ - type: WarpPoint
+ follow: true
+ - type: Eye
+ - type: ContentEye
+ - type: Examiner
+ - type: Actions
+ - type: Alerts
+ - type: FTLSmashImmune
+ - type: CargoSellBlacklist
+ - type: StationAiVision
+ range: 20
+
# Borgs
- type: entity
id: PlayerBorgBattery
--- /dev/null
+- type: entity
+ id: HolopadMachineCircuitboard
+ parent: BaseMachineCircuitboard
+ name: holopad machine board
+ description: A machine printed circuit board for a holopad.
+ components:
+ - type: MachineBoard
+ prototype: Holopad
+ stackRequirements:
+ Capacitor: 4
+ Cable: 4
+ Glass: 2
\ No newline at end of file
--- /dev/null
+- type: entity
+ parent: [ BaseMachinePowered, ConstructibleMachine ]
+ id: Holopad
+ name: holopad
+ description: "A floor-mounted device for projecting holographic images."
+ components:
+ - type: Transform
+ anchored: true
+ - type: Fixtures
+ fixtures:
+ fix1:
+ shape:
+ !type:PhysShapeCircle
+ radius: 0.25
+ mask:
+ - SubfloorMask
+ layer:
+ - LowImpassable
+ hard: false
+ - type: ApcPowerReceiver
+ powerLoad: 300
+ - type: StationAiVision
+ - type: Sprite
+ sprite: Structures/Machines/holopad.rsi
+ snapCardinals: true
+ layers:
+ - state: base
+ - map: [ "lights" ]
+ state: blank
+ shader: unshaded
+ - map: [ "enum.PowerDeviceVisualLayers.Powered" ]
+ state: unpowered
+ - map: [ "enum.WiresVisualLayers.MaintenancePanel" ]
+ state: panel_open
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.TelephoneVisuals.Key:
+ lights:
+ Idle: { state: blank }
+ Calling: { state: lights_calling }
+ Ringing: { state: lights_ringing }
+ InCall: { state: lights_in_call }
+ EndingCall: { state: lights_hanging_up }
+ enum.PowerDeviceVisuals.Powered:
+ enum.PowerDeviceVisualLayers.Powered:
+ False: { visible: true }
+ True: { visible: false }
+ enum.WiresVisuals.MaintenancePanelState:
+ enum.WiresVisualLayers.MaintenancePanel:
+ True: { visible: false }
+ False: { visible: true }
+ - type: Machine
+ board: HolopadMachineCircuitboard
+ - type: StationAiWhitelist
+ - type: PointLight
+ radius: 1.3
+ energy: 1.8
+ color: "#afe1fe"
+ enabled: false
+ - type: AmbientSound
+ enabled: false
+ volume: -5
+ range: 3
+ sound:
+ path: /Audio/Ambience/Objects/buzzing.ogg
+ - type: Holopad
+ hologramProtoId: HolopadHologram
+ - type: Speech
+ speechVerb: Robotic
+ speechSounds: Borg
+ speechBubbleOffset: 0.45
+ - type: Telephone
+ transmissionRange: Map
+ ringTone: /Audio/Machines/double_ring.ogg
+ listeningRange: 4
+ speakerVolume: Speak
+ - type: AccessReader
+ access: [[ "Command" ]]
+ - type: ActivatableUI
+ key: enum.HolopadUiKey.InteractionWindow
+ - type: ActivatableUIRequiresPower
+ - type: UserInterface
+ interfaces:
+ enum.HolopadUiKey.InteractionWindow:
+ type: HolopadBoundUserInterface
+ enum.WiresUiKey.Key:
+ type: WiresBoundUserInterface
+ - type: WiresPanel
+ - type: WiresVisuals
+ - type: Wires
+ boardName: wires-board-name-holopad
+ layoutId: Holopad
+ - type: Destructible
+ thresholds:
+ - trigger:
+ !type:DamageTrigger
+ damage: 100
+ behaviors:
+ - !type:PlaySoundBehavior
+ sound:
+ collection: MetalBreak
+ - !type:ChangeConstructionNodeBehavior
+ node: machineFrame
+ - !type:DoActsBehavior
+ acts: ["Destruction"]
+
+- type: entity
+ name: long-range holopad
+ description: "A floor-mounted device for projecting holographic images to other devices that are far away."
+ parent: Holopad
+ id: HolopadLongRange
+ suffix: For calls between maps
+ components:
+ - type: Telephone
+ transmissionRange: Long
+
+- type: entity
+ name: quantum entangling holopad
+ description: "An experimental floor-mounted device for projecting holographic images at extreme distances."
+ parent: Holopad
+ id: HolopadUnlimitedRange
+ suffix: Unlimited range
+ components:
+ - type: Telephone
+ transmissionRange: Unlimited
+ - type: AccessReader
+ access: [[]]
+
+# These are spawned by holopads
+- type: entity
+ id: HolopadHologram
+ categories: [ HideSpawnMenu ]
+ suffix: DO NOT MAP
+ components:
+ - type: Transform
+ anchored: true
+ - type: Sprite
+ noRot: true
+ drawdepth: Mobs
+ offset: -0.02, 0.45
+ overrideDir: South
+ enableOverrideDir: true
+ - type: Appearance
+ - type: TypingIndicator
+ proto: robot
+ - type: HolopadHologram
+ rsiPath: Structures/Machines/holopad.rsi
+ rsiState: icon_in_call
+ shaderName: Hologram
+ color1: "#65b8e2"
+ color2: "#3a6981"
+ alpha: 0.9
+ intensity: 2
+ scrollRate: 0.125
+ - type: Tag
+ tags:
+ - HideContextMenu
\ No newline at end of file
id: Cataracts
kind: source
path: "/Textures/Shaders/cataracts.swsl"
+
+- type: shader
+ id: Hologram
+ kind: source
+ path: "/Textures/Shaders/hologram.swsl"
\ No newline at end of file
- !type:PowerWireAction
- !type:AiInteractWireAction
+- type: wireLayout
+ id: Holopad
+ dummyWires: 2
+ wires:
+ - !type:PowerWireAction
+ - !type:AiInteractWireAction
+ - !type:AiVisionWireAction
+
- type: wireLayout
id: BarSign
dummyWires: 2
wires:
- !type:PowerWireAction
- !type:AiInteractWireAction
- - !type:AccessWireAction
+ - !type:AccessWireAction
\ No newline at end of file
--- /dev/null
+light_mode unshaded;
+
+uniform highp vec3 color1;
+uniform highp vec3 color2;
+uniform highp float alpha;
+uniform highp float intensity;
+uniform highp float texHeight;
+uniform highp float t;
+
+const highp float PI = 3.14159265;
+
+void fragment() {
+ highp vec4 base = texture2D(TEXTURE, UV);
+ highp float bw = zGrayscale(base.rgb * intensity);
+ highp vec4 color = vec4(vec3(color1), alpha);
+
+ if (sin(PI * (UV.y + t) * texHeight) < 0.0)
+ {
+ color = vec4(vec3(color2), alpha);
+ }
+
+ COLOR = vec4(vec3(bw), base.a) * color;
+}
\ No newline at end of file
--- /dev/null
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/pull/80025/commits/f0cc8856d4c1b6b3933524a2d37581cc81c3c05b, /icons/obj/machines/floor.dmi. Edited by chromiumboy",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "base"
+ },
+ {
+ "name": "unpowered"
+ },
+ {
+ "name": "panel_open"
+ },
+ {
+ "name": "blank"
+ },
+ {
+ "name": "icon_in_call"
+ },
+ {
+ "name": "lights_calling",
+ "delays": [
+ [
+ 0.2,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.5,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "lights_in_call",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1
+ ]
+ ]
+ },
+ {
+ "name": "lights_ringing",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.2,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.8
+ ]
+ ]
+ },
+ {
+ "name": "lights_hanging_up",
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 99
+ ]
+ ]
+ }
+ ]
+}
\ No newline at end of file