]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Holopads (#32711)
authorchromiumboy <50505512+chromiumboy@users.noreply.github.com>
Tue, 17 Dec 2024 19:18:15 +0000 (13:18 -0600)
committerGitHub <noreply@github.com>
Tue, 17 Dec 2024 19:18:15 +0000 (20:18 +0100)
* Initial resources commit

* Initial code commit

* Added additional resources

* Continuing to build holopad and telephone systems

* Added hologram shader

* Added hologram system and entity

* Holo calls now have a hologram of the user appear on them

* Initial implementation of holopads transmitting nearby chatter

* Added support for linking across multiple telephones/holopads/entities

* Fixed a bunch of bugs

* Tried simplifying holopad entity dependence, added support for mid-call user switching

* Replaced PVS expansion with manually networked sprite states

* Adjusted volume of ring tone

* Added machine board

* Minor features and tweaks

* Resolving merge conflict

* Recommit audio attributions

* Telephone chat adjustments

* Added support for AI interactions with holopads

* Building the holopad UI

* Holopad UI finished

* Further UI tweaks

* Station AI can hear local chatter when being projected from a holopad

* Minor bug fixes

* Added wire panels to holopads

* Basic broadcasting

* Start of emergency broadcasting code

* Fixing issues with broadcasting

* More work on emergency broadcasting

* Updated holopad visuals

* Added cooldown text to emergency broadcast and control lock out screen

* Code clean up

* Fixed issue with timing

* Broadcasting now requires command access

* Fixed some bugs

* Added multiple holopad prototypes with different ranges

* The AI no longer requires power to interact with holopads

* Fixed some additional issues

* Addressing more issues

* Added emote support for holograms

* Changed the broadcast lockout durations to their proper values

* Added AI vision wire to holopads

* Bug fixes

* AI vision and interaction wires can be added to the same wire panel

* Fixed error

* More bug fixes

* Fixed test fail

* Embellished the emergency call lock out window

* Holopads play borg sounds when speaking

* Borg and AI names are listed as the caller ID on the holopad

* Borg chassis can now be seen on holopad holograms

* Holopad returns to a machine frame when badly damaged

* Clarified some text

* Fix merge conflict

* Fixed merge conflict

* Fixing merge conflict

* Fixing merge conflict

* Fixing merge conflict

* Offset menu on open

* AI can alt click on holopads to activate the projector

* Bug fixes for intellicard interactions

* Fixed speech issue with intellicards

* The UI automatically opens for the AI when it alt-clicks on the holopad

* Simplified shader math

* Telephones will auto hang up 60 seconds after the last person on a call stops speaking

* Added better support for AI requests when multiple AI cores are on the station

* The call controls pop up for the AI when they accept a summons from a holopad

* Compatibility mode fix for the hologram shader

* Further shader fixes for compatibility mode

* File clean up

* More cleaning up

* Removed access requirements from quantum holopads so they can used by nukies

* The title of the holopad window now reflects the name of the device

* Linked telephones will lose their connection if both move out of range of each other

43 files changed:
Content.Client/Chat/UI/SpeechBubble.cs
Content.Client/Holopad/HolopadBoundUserInterface.cs [new file with mode: 0644]
Content.Client/Holopad/HolopadSystem.cs [new file with mode: 0644]
Content.Client/Holopad/HolopadWindow.xaml [new file with mode: 0644]
Content.Client/Holopad/HolopadWindow.xaml.cs [new file with mode: 0644]
Content.Client/Stylesheets/StyleNano.cs
Content.Client/Telephone/TelephoneSystem.cs [new file with mode: 0644]
Content.Server/Holopad/HolopadSystem.cs [new file with mode: 0644]
Content.Server/Silicons/StationAi/AiVisionWireAction.cs
Content.Server/Silicons/StationAi/StationAiSystem.cs
Content.Server/Telephone/TelephoneSystem.cs [new file with mode: 0644]
Content.Shared/Doors/AirlockWireStatus.cs
Content.Shared/Holopad/HolographicAvatarComponent.cs [new file with mode: 0644]
Content.Shared/Holopad/HolopadComponent.cs [new file with mode: 0644]
Content.Shared/Holopad/HolopadHologramComponent.cs [new file with mode: 0644]
Content.Shared/Holopad/HolopadUserComponent.cs [new file with mode: 0644]
Content.Shared/Holopad/SharedHolopadSystem.cs [new file with mode: 0644]
Content.Shared/Silicons/StationAi/SharedStationAiSystem.cs
Content.Shared/Silicons/StationAi/StationAiCoreComponent.cs
Content.Shared/Speech/SpeechComponent.cs
Content.Shared/Telephone/SharedTelephoneSystem.cs [new file with mode: 0644]
Content.Shared/Telephone/TelephoneComponent.cs [new file with mode: 0644]
Resources/Audio/Machines/attributions.yml
Resources/Audio/Machines/double_ring.ogg [new file with mode: 0644]
Resources/Locale/en-US/holopad/holopad.ftl [new file with mode: 0644]
Resources/Locale/en-US/telephone/telephone.ftl [new file with mode: 0644]
Resources/Locale/en-US/wires/wire-names.ftl
Resources/Prototypes/Entities/Mobs/Player/silicon.yml
Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/holopad.yml [new file with mode: 0644]
Resources/Prototypes/Entities/Structures/Machines/holopad.yml [new file with mode: 0644]
Resources/Prototypes/Shaders/shaders.yml
Resources/Prototypes/Wires/layouts.yml
Resources/Textures/Shaders/hologram.swsl [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/base.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/blank.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/icon_in_call.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/lights_calling.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/lights_hanging_up.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/lights_in_call.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/lights_ringing.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/meta.json [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/panel_open.png [new file with mode: 0644]
Resources/Textures/Structures/Machines/holopad.rsi/unpowered.png [new file with mode: 0644]

index 32e9f4ae9be5304c3422406afdf7ba750bf7612d..aa61e73e31c85a6e769cef996e695d444c9a6e49 100644 (file)
@@ -2,6 +2,7 @@ using System.Numerics;
 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;
@@ -141,7 +142,12 @@ namespace Content.Client.Chat.UI
                 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;
diff --git a/Content.Client/Holopad/HolopadBoundUserInterface.cs b/Content.Client/Holopad/HolopadBoundUserInterface.cs
new file mode 100644 (file)
index 0000000..20b55ea
--- /dev/null
@@ -0,0 +1,101 @@
+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());
+    }
+}
diff --git a/Content.Client/Holopad/HolopadSystem.cs b/Content.Client/Holopad/HolopadSystem.cs
new file mode 100644 (file)
index 0000000..3bd556f
--- /dev/null
@@ -0,0 +1,172 @@
+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;
+    }
+}
diff --git a/Content.Client/Holopad/HolopadWindow.xaml b/Content.Client/Holopad/HolopadWindow.xaml
new file mode 100644 (file)
index 0000000..9c3dfab
--- /dev/null
@@ -0,0 +1,107 @@
+<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>
diff --git a/Content.Client/Holopad/HolopadWindow.xaml.cs b/Content.Client/Holopad/HolopadWindow.xaml.cs
new file mode 100644 (file)
index 0000000..bcab0d4
--- /dev/null
@@ -0,0 +1,338 @@
+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);
+    }
+}
index ccd36c35e82be307ddf75fdbece36420cbfc63f5..55e7ec094989c2fbf5b6e35f487ff73489525bb6 100644 (file)
@@ -110,6 +110,7 @@ namespace Content.Client.Stylesheets
 
         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");
@@ -1499,6 +1500,20 @@ namespace Content.Client.Stylesheets
 
                 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 ---
diff --git a/Content.Client/Telephone/TelephoneSystem.cs b/Content.Client/Telephone/TelephoneSystem.cs
new file mode 100644 (file)
index 0000000..8fad9f7
--- /dev/null
@@ -0,0 +1,8 @@
+using Content.Shared.Telephone;
+
+namespace Content.Client.Telephone;
+
+public sealed class TelephoneSystem : SharedTelephoneSystem
+{
+
+}
diff --git a/Content.Server/Holopad/HolopadSystem.cs b/Content.Server/Holopad/HolopadSystem.cs
new file mode 100644 (file)
index 0000000..bd36d38
--- /dev/null
@@ -0,0 +1,761 @@
+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);
+    }
+}
index 3523f4d38f033df9655bc2a40d13427043dbc69c..c3d4a565bb48960a50a4f244e40ca4a556941982 100644 (file)
@@ -12,8 +12,8 @@ namespace Content.Server.Silicons.StationAi;
 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)
     {
index a10833dc63b0bae43123965cb05ed73dfe9bb9d4..9c15112b305eb5cec6c7c9476525d4b12c39d45a 100644 (file)
@@ -1,5 +1,6 @@
 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;
@@ -8,6 +9,7 @@ using Content.Shared.StationAi;
 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;
 
@@ -15,11 +17,50 @@ public sealed class StationAiSystem : SharedStationAiSystem
 {
     [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))
diff --git a/Content.Server/Telephone/TelephoneSystem.cs b/Content.Server/Telephone/TelephoneSystem.cs
new file mode 100644 (file)
index 0000000..8507f4d
--- /dev/null
@@ -0,0 +1,468 @@
+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;
+    }
+}
index d3fa15ed1b650a478c8f191a5447b299c7d395d4..3a8570ebd1bf4980c77785627bb81390acef5559 100644 (file)
@@ -1,4 +1,4 @@
-using Robust.Shared.Serialization;
+using Robust.Shared.Serialization;
 
 namespace Content.Shared.Doors
 {
@@ -9,6 +9,7 @@ namespace Content.Shared.Doors
         BoltIndicator,
         BoltLightIndicator,
         AiControlIndicator,
+        AiVisionIndicator,
         TimingIndicator,
         SafetyIndicator,
     }
diff --git a/Content.Shared/Holopad/HolographicAvatarComponent.cs b/Content.Shared/Holopad/HolographicAvatarComponent.cs
new file mode 100644 (file)
index 0000000..be7f5bc
--- /dev/null
@@ -0,0 +1,13 @@
+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;
+}
diff --git a/Content.Shared/Holopad/HolopadComponent.cs b/Content.Shared/Holopad/HolopadComponent.cs
new file mode 100644 (file)
index 0000000..98f05b0
--- /dev/null
@@ -0,0 +1,133 @@
+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
+}
diff --git a/Content.Shared/Holopad/HolopadHologramComponent.cs b/Content.Shared/Holopad/HolopadHologramComponent.cs
new file mode 100644 (file)
index 0000000..a75ae27
--- /dev/null
@@ -0,0 +1,71 @@
+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;
+}
diff --git a/Content.Shared/Holopad/HolopadUserComponent.cs b/Content.Shared/Holopad/HolopadUserComponent.cs
new file mode 100644 (file)
index 0000000..9ff20c2
--- /dev/null
@@ -0,0 +1,104 @@
+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;
+    }
+}
diff --git a/Content.Shared/Holopad/SharedHolopadSystem.cs b/Content.Shared/Holopad/SharedHolopadSystem.cs
new file mode 100644 (file)
index 0000000..ea12282
--- /dev/null
@@ -0,0 +1,43 @@
+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;
+    }
+}
index 5fca5cad280d9de7604acfba28702efb0f6909e4..4937e6e84c270792a42c8f143e430811a2ed7c21 100644 (file)
@@ -20,12 +20,15 @@ using Content.Shared.Verbs;
 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;
 
@@ -68,6 +71,8 @@ public abstract partial class SharedStationAiSystem : EntitySystem
     [ValidatePrototypeId<EntityPrototype>]
     private static readonly EntProtoId DefaultAi = "StationAiBrain";
 
+    private const float MaxVisionMultiplier = 5f;
+
     public override void Initialize()
     {
         base.Initialize();
@@ -344,16 +349,47 @@ public abstract partial class SharedStationAiSystem : EntitySystem
         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);
         }
 
@@ -364,6 +400,7 @@ public abstract partial class SharedStationAiSystem : EntitySystem
     {
         if (_net.IsClient)
             return;
+
         QueueDel(ent.Comp.RemoteEntity);
         ent.Comp.RemoteEntity = null;
         Dirty(ent);
@@ -392,6 +429,17 @@ public abstract partial class SharedStationAiSystem : EntitySystem
         _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)
@@ -400,6 +448,7 @@ public abstract partial class SharedStationAiSystem : EntitySystem
         if (_timing.ApplyingState)
             return;
 
+        ent.Comp.Remote = true;
         SetupEye(ent);
 
         // Just so text and the likes works properly
@@ -413,6 +462,8 @@ public abstract partial class SharedStationAiSystem : EntitySystem
         if (_timing.ApplyingState)
             return;
 
+        ent.Comp.Remote = true;
+
         // Reset name to whatever
         _metadata.SetEntityName(ent.Owner, Prototype(ent.Owner)?.Name ?? string.Empty);
 
@@ -424,6 +475,7 @@ public abstract partial class SharedStationAiSystem : EntitySystem
             _eye.SetDrawFov(args.Entity, true, eyeComp);
             _eye.SetTarget(args.Entity, null, eyeComp);
         }
+
         ClearEye(ent);
     }
 
@@ -478,6 +530,36 @@ public abstract partial class SharedStationAiSystem : EntitySystem
 
         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
index b7a8b4cd5fa70fa1c1e6bae1abbee42cbb33368e..e97e7626b88348320271248e19f30cc88da7188f 100644 (file)
@@ -15,6 +15,7 @@ public sealed partial class StationAiCoreComponent : Component
 
     /// <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;
@@ -25,8 +26,17 @@ public sealed partial class StationAiCoreComponent : Component
     [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";
 }
index 0882120718d7f3bd6c82bc988923c027d5390c31..8c12fc918a01cafaac4257f0a9c357a2617a61f8 100644 (file)
@@ -56,5 +56,11 @@ namespace Content.Shared.Speech
         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;
     }
 }
diff --git a/Content.Shared/Telephone/SharedTelephoneSystem.cs b/Content.Shared/Telephone/SharedTelephoneSystem.cs
new file mode 100644 (file)
index 0000000..ab42362
--- /dev/null
@@ -0,0 +1,39 @@
+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;
+    }
+}
diff --git a/Content.Shared/Telephone/TelephoneComponent.cs b/Content.Shared/Telephone/TelephoneComponent.cs
new file mode 100644 (file)
index 0000000..7eacdb0
--- /dev/null
@@ -0,0 +1,203 @@
+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
+}
index bcbf1036c3279e49b2068753b407254e999c4eae..fe144d43381ff8b129ac753c88c762d1e8785d04 100644 (file)
   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
diff --git a/Resources/Audio/Machines/double_ring.ogg b/Resources/Audio/Machines/double_ring.ogg
new file mode 100644 (file)
index 0000000..bfe1b21
Binary files /dev/null and b/Resources/Audio/Machines/double_ring.ogg differ
diff --git a/Resources/Locale/en-US/holopad/holopad.ftl b/Resources/Locale/en-US/holopad/holopad.ftl
new file mode 100644 (file)
index 0000000..01a1e13
--- /dev/null
@@ -0,0 +1,40 @@
+# 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
diff --git a/Resources/Locale/en-US/telephone/telephone.ftl b/Resources/Locale/en-US/telephone/telephone.ftl
new file mode 100644 (file)
index 0000000..915d548
--- /dev/null
@@ -0,0 +1,8 @@
+# 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
index 670f01d73641307424ddd24f1343bae297ee0355..08e5af4000b06df998923eb11c137c19af515b87 100644 (file)
@@ -41,6 +41,7 @@ wires-board-name-flatpacker = Flatpacker
 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.
index 7b03455a0814e9a6fc955f60b036bcf6faff4360..9a9d66c42d8d0463c5ba4784dd62b44d7757fbd1 100644 (file)
     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
diff --git a/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/holopad.yml b/Resources/Prototypes/Entities/Objects/Devices/Circuitboards/Machine/holopad.yml
new file mode 100644 (file)
index 0000000..450a43c
--- /dev/null
@@ -0,0 +1,12 @@
+- 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
diff --git a/Resources/Prototypes/Entities/Structures/Machines/holopad.yml b/Resources/Prototypes/Entities/Structures/Machines/holopad.yml
new file mode 100644 (file)
index 0000000..f59120f
--- /dev/null
@@ -0,0 +1,158 @@
+- 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
index 136821efbb8411d5426a9da1a77b6fda55b30011..6e0bbd55b43348291c4fcefc714fcc1d8ebb364d 100644 (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
index 32b01cba811297d9e6c5440b933d0f2164a6f389..32c14886835a4749573d83ce143fc4951148261b 100644 (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
diff --git a/Resources/Textures/Shaders/hologram.swsl b/Resources/Textures/Shaders/hologram.swsl
new file mode 100644 (file)
index 0000000..06fdccc
--- /dev/null
@@ -0,0 +1,23 @@
+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
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/base.png b/Resources/Textures/Structures/Machines/holopad.rsi/base.png
new file mode 100644 (file)
index 0000000..4d274e1
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/base.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/blank.png b/Resources/Textures/Structures/Machines/holopad.rsi/blank.png
new file mode 100644 (file)
index 0000000..7bee0a0
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/blank.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/icon_in_call.png b/Resources/Textures/Structures/Machines/holopad.rsi/icon_in_call.png
new file mode 100644 (file)
index 0000000..dc55561
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/icon_in_call.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/lights_calling.png b/Resources/Textures/Structures/Machines/holopad.rsi/lights_calling.png
new file mode 100644 (file)
index 0000000..298087e
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/lights_calling.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/lights_hanging_up.png b/Resources/Textures/Structures/Machines/holopad.rsi/lights_hanging_up.png
new file mode 100644 (file)
index 0000000..860712c
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/lights_hanging_up.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/lights_in_call.png b/Resources/Textures/Structures/Machines/holopad.rsi/lights_in_call.png
new file mode 100644 (file)
index 0000000..de20437
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/lights_in_call.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/lights_ringing.png b/Resources/Textures/Structures/Machines/holopad.rsi/lights_ringing.png
new file mode 100644 (file)
index 0000000..7faf902
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/lights_ringing.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/meta.json b/Resources/Textures/Structures/Machines/holopad.rsi/meta.json
new file mode 100644 (file)
index 0000000..b7601b1
--- /dev/null
@@ -0,0 +1,100 @@
+{
+  "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
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/panel_open.png b/Resources/Textures/Structures/Machines/holopad.rsi/panel_open.png
new file mode 100644 (file)
index 0000000..22947c8
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/panel_open.png differ
diff --git a/Resources/Textures/Structures/Machines/holopad.rsi/unpowered.png b/Resources/Textures/Structures/Machines/holopad.rsi/unpowered.png
new file mode 100644 (file)
index 0000000..7b4b5dd
Binary files /dev/null and b/Resources/Textures/Structures/Machines/holopad.rsi/unpowered.png differ