* 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
This commit is contained in:
chromiumboy
2024-12-17 13:18:15 -06:00
committed by GitHub
parent ab8447956c
commit 7780b867ac
43 changed files with 3129 additions and 8 deletions

View 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;

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -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);
}
}

View 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 ---

View File

@@ -0,0 +1,8 @@
using Content.Shared.Telephone;
namespace Content.Client.Telephone;
public sealed class TelephoneSystem : SharedTelephoneSystem
{
}

View File

@@ -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);
}
}

View 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)
{

View 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))

View File

@@ -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;
}
}

View 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,
}

View File

@@ -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;
}

View File

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

View File

@@ -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;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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

View 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";
}

View 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;
}
}

View File

@@ -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;
}
}

View File

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

View File

@@ -173,6 +173,11 @@
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
- airlock_emergencyon.ogg

Binary file not shown.

View File

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

View File

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

View 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.

View File

@@ -70,6 +70,10 @@
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
@@ -364,6 +368,19 @@
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
@@ -446,6 +463,28 @@
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

View File

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

View File

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

View File

@@ -104,3 +104,8 @@
id: Cataracts
kind: source
path: "/Textures/Shaders/cataracts.swsl"
- type: shader
id: Hologram
kind: source
path: "/Textures/Shaders/hologram.swsl"

View File

@@ -190,6 +190,14 @@
- !type:PowerWireAction
- !type:AiInteractWireAction
- type: wireLayout
id: Holopad
dummyWires: 2
wires:
- !type:PowerWireAction
- !type:AiInteractWireAction
- !type:AiVisionWireAction
- type: wireLayout
id: BarSign
dummyWires: 2

View File

@@ -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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 457 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -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
]
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 441 B