* 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

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