diff --git a/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
new file mode 100644
index 0000000000..1427248c35
--- /dev/null
+++ b/Content.Client/VoiceMask/VoiceMaskBoundUserInterface.cs
@@ -0,0 +1,46 @@
+using Content.Shared.VoiceMask;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.VoiceMask;
+
+public sealed class VoiceMaskBoundUserInterface : BoundUserInterface
+{
+ public VoiceMaskBoundUserInterface(ClientUserInterfaceComponent owner, Enum uiKey) : base(owner, uiKey)
+ {
+ }
+
+ private VoiceMaskNameChangeWindow? _window;
+
+ protected override void Open()
+ {
+ base.Open();
+
+ _window = new();
+
+ _window.OpenCentered();
+ _window.OnNameChange += OnNameSelected;
+ _window.OnClose += Close;
+ }
+
+ private void OnNameSelected(string name)
+ {
+ SendMessage(new VoiceMaskChangeNameMessage(name));
+ }
+
+ protected override void UpdateState(BoundUserInterfaceState state)
+ {
+ if (state is not VoiceMaskBuiState cast || _window == null)
+ {
+ return;
+ }
+
+ _window.UpdateState(cast.Name);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ base.Dispose(disposing);
+
+ _window?.Close();
+ }
+}
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
new file mode 100644
index 0000000000..2316ec9c7d
--- /dev/null
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
new file mode 100644
index 0000000000..e373acbd0a
--- /dev/null
+++ b/Content.Client/VoiceMask/VoiceMaskNameChangeWindow.xaml.cs
@@ -0,0 +1,26 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.VoiceMask;
+
+[GenerateTypedNameReferences]
+public sealed partial class VoiceMaskNameChangeWindow : DefaultWindow
+{
+ public Action? OnNameChange;
+
+ public VoiceMaskNameChangeWindow()
+ {
+ RobustXamlLoader.Load(this);
+
+ NameSelectorSet.OnPressed += _ =>
+ {
+ OnNameChange!(NameSelector.Text);
+ };
+ }
+
+ public void UpdateState(string name)
+ {
+ NameSelector.Text = name;
+ }
+}
diff --git a/Content.Server/Chat/Systems/ChatSystem.cs b/Content.Server/Chat/Systems/ChatSystem.cs
index e6bb375f22..b973867665 100644
--- a/Content.Server/Chat/Systems/ChatSystem.cs
+++ b/Content.Server/Chat/Systems/ChatSystem.cs
@@ -259,12 +259,15 @@ public sealed partial class ChatSystem : SharedChatSystem
return;
}
+ var nameEv = new TransformSpeakerNameEvent(source, Name(source));
+ RaiseLocalEvent(source, nameEv);
+
message = TransformSpeech(source, message);
if (message.Length == 0)
return;
var messageWrap = Loc.GetString("chat-manager-entity-say-wrap-message",
- ("entityName", Name(source)));
+ ("entityName", nameEv.Name));
SendInVoiceRange(ChatChannel.Local, message, messageWrap, source, hideChat);
_listener.PingListeners(source, message, null);
@@ -295,8 +298,12 @@ public sealed partial class ChatSystem : SharedChatSystem
var transformSource = Transform(source);
var sourceCoords = transformSource.Coordinates;
+
+ var nameEv = new TransformSpeakerNameEvent(source, Name(source));
+ RaiseLocalEvent(source, nameEv);
+
var messageWrap = Loc.GetString("chat-manager-entity-whisper-wrap-message",
- ("entityName", Name(source)));
+ ("entityName", nameEv.Name));
var xforms = GetEntityQuery();
var ghosts = GetEntityQuery();
@@ -530,6 +537,18 @@ public sealed partial class ChatSystem : SharedChatSystem
#endregion
}
+public sealed class TransformSpeakerNameEvent : EntityEventArgs
+{
+ public EntityUid Sender;
+ public string Name;
+
+ public TransformSpeakerNameEvent(EntityUid sender, string name)
+ {
+ Sender = sender;
+ Name = name;
+ }
+}
+
///
/// Raised broadcast in order to transform speech.
///
diff --git a/Content.Server/Clothing/MaskSystem.cs b/Content.Server/Clothing/MaskSystem.cs
index c9f92a76b4..e1152bc690 100644
--- a/Content.Server/Clothing/MaskSystem.cs
+++ b/Content.Server/Clothing/MaskSystem.cs
@@ -13,6 +13,7 @@ using Content.Server.Disease.Components;
using Content.Server.IdentityManagement;
using Content.Server.Nutrition.EntitySystems;
using Content.Server.Popups;
+using Content.Server.VoiceMask;
using Content.Shared.Clothing.EntitySystems;
using Content.Shared.IdentityManagement.Components;
using Robust.Shared.Player;
@@ -87,6 +88,8 @@ namespace Content.Server.Clothing
_clothing.SetEquippedPrefix(uid, mask.IsToggled ? "toggled" : null, clothing);
}
+ // shouldn't this be an event?
+
// toggle ingestion blocking
if (TryComp(uid, out var blocker))
blocker.Enabled = !mask.IsToggled;
@@ -99,6 +102,10 @@ namespace Content.Server.Clothing
if (TryComp(uid, out var identity))
identity.Enabled = !mask.IsToggled;
+ // toggle voice masking
+ if (TryComp(uid, out var voiceMask))
+ voiceMask.Enabled = !mask.IsToggled;
+
// toggle breath tool connection (skip during equip since that is handled in LungSystem)
if (isEquip || !TryComp(uid, out var breathTool))
return;
diff --git a/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs b/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs
index 95310079d1..bcea08ecec 100644
--- a/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs
+++ b/Content.Server/Ghost/Components/IntrinsicRadioComponent.cs
@@ -1,5 +1,7 @@
using Content.Server.Radio.Components;
+using Content.Server.VoiceMask;
using Content.Shared.Chat;
+using Content.Shared.IdentityManagement;
using Content.Shared.Radio;
using Robust.Server.GameObjects;
using Robust.Shared.Network;
@@ -28,12 +30,19 @@ namespace Content.Server.Ghost.Components
var playerChannel = actor.PlayerSession.ConnectedClient;
+ var name = _entMan.GetComponent(speaker).EntityName;
+
+ if (_entMan.TryGetComponent(speaker, out VoiceMaskComponent? mask) && mask.Enabled)
+ {
+ name = Identity.Name(speaker, _entMan);
+ }
+
var msg = new MsgChatMessage
{
Channel = ChatChannel.Radio,
Message = message,
//Square brackets are added here to avoid issues with escaping
- MessageWrap = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", _entMan.GetComponent(speaker).EntityName))
+ MessageWrap = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", name))
};
_netManager.ServerSendMessage(msg, playerChannel);
diff --git a/Content.Server/Headset/HeadsetComponent.cs b/Content.Server/Headset/HeadsetComponent.cs
index 588440e58a..a834b82eba 100644
--- a/Content.Server/Headset/HeadsetComponent.cs
+++ b/Content.Server/Headset/HeadsetComponent.cs
@@ -1,7 +1,9 @@
using Content.Server.Chat.Systems;
using Content.Server.Radio.Components;
using Content.Server.Radio.EntitySystems;
+using Content.Server.VoiceMask;
using Content.Shared.Chat;
+using Content.Shared.IdentityManagement;
using Content.Shared.Radio;
using Robust.Server.GameObjects;
using Robust.Shared.Containers;
@@ -58,6 +60,13 @@ namespace Content.Server.Headset
var playerChannel = actor.PlayerSession.ConnectedClient;
+ var name = _entMan.GetComponent(source).EntityName;
+
+ if (_entMan.TryGetComponent(source, out VoiceMaskComponent? mask) && mask.Enabled)
+ {
+ name = Identity.Name(source, _entMan);
+ }
+
message = _chatSystem.TransformSpeech(source, message);
if (message.Length == 0)
return;
@@ -67,7 +76,7 @@ namespace Content.Server.Headset
Channel = ChatChannel.Radio,
Message = message,
//Square brackets are added here to avoid issues with escaping
- MessageWrap = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", _entMan.GetComponent(source).EntityName))
+ MessageWrap = Loc.GetString("chat-radio-message-wrap", ("color", channel.Color), ("channel", $"\\[{channel.LocalizedName}\\]"), ("name", name))
};
_netManager.ServerSendMessage(msg, playerChannel);
diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs
index 14b736791b..154b24c39c 100644
--- a/Content.Server/Radio/EntitySystems/RadioSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs
@@ -43,7 +43,6 @@ namespace Content.Server.Radio.EntitySystems
foreach (var radio in EntityManager.EntityQuery(true))
{
- //TODO: once voice identity gets added, pass into receiver via source.GetSpeakerVoice()
radio.Receive(message, channel, speaker);
}
diff --git a/Content.Server/VoiceMask/VoiceMaskComponent.cs b/Content.Server/VoiceMask/VoiceMaskComponent.cs
new file mode 100644
index 0000000000..32d2125e55
--- /dev/null
+++ b/Content.Server/VoiceMask/VoiceMaskComponent.cs
@@ -0,0 +1,9 @@
+namespace Content.Server.VoiceMask;
+
+[RegisterComponent]
+public sealed class VoiceMaskComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite)] public bool Enabled = true;
+
+ [ViewVariables(VVAccess.ReadWrite)] public string VoiceName = "Unknown";
+}
diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.Equip.cs b/Content.Server/VoiceMask/VoiceMaskSystem.Equip.cs
new file mode 100644
index 0000000000..47786b5cb3
--- /dev/null
+++ b/Content.Server/VoiceMask/VoiceMaskSystem.Equip.cs
@@ -0,0 +1,48 @@
+using Content.Server.Actions;
+using Content.Shared.Actions.ActionTypes;
+using Content.Shared.Inventory;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Speech;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.VoiceMask;
+
+// This partial deals with equipment, i.e., the syndicate voice mask.
+public sealed partial class VoiceMaskSystem
+{
+ [Dependency] private readonly InventorySystem _inventory = default!;
+ [Dependency] private readonly ActionsSystem _actions = default!;
+ [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+
+ private const string MaskSlot = "mask";
+
+ private void OnEquip(EntityUid uid, VoiceMaskerComponent component, GotEquippedEvent args)
+ {
+ var comp = EnsureComp(args.Equipee);
+ comp.VoiceName = component.LastSetName;
+
+ if (!_prototypeManager.TryIndex(component.Action, out var action))
+ {
+ throw new ArgumentException("Could not get voice masking prototype.");
+ }
+
+ _actions.AddAction(args.Equipee, (InstantAction) action.Clone(), uid);
+ }
+
+ private void OnUnequip(EntityUid uid, VoiceMaskerComponent compnent, GotUnequippedEvent args)
+ {
+ RemComp(args.Equipee);
+ }
+
+ private void TrySetLastKnownName(EntityUid maskWearer, string lastName)
+ {
+ if (!HasComp(maskWearer)
+ || !_inventory.TryGetSlotEntity(maskWearer, MaskSlot, out var maskEntity)
+ || !TryComp(maskEntity, out var maskComp))
+ {
+ return;
+ }
+
+ maskComp.LastSetName = lastName;
+ }
+}
diff --git a/Content.Server/VoiceMask/VoiceMaskSystem.cs b/Content.Server/VoiceMask/VoiceMaskSystem.cs
new file mode 100644
index 0000000000..43e2e1dbf9
--- /dev/null
+++ b/Content.Server/VoiceMask/VoiceMaskSystem.cs
@@ -0,0 +1,88 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Popups;
+using Content.Shared.Actions;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Preferences;
+using Content.Shared.Verbs;
+using Content.Shared.VoiceMask;
+using Robust.Server.GameObjects;
+using Robust.Shared.Player;
+
+namespace Content.Server.VoiceMask;
+
+public sealed partial class VoiceMaskSystem : EntitySystem
+{
+ [Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
+ [Dependency] private readonly PopupSystem _popupSystem = default!;
+
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnSpeakerNameTransform);
+ SubscribeLocalEvent(OnChangeName);
+ SubscribeLocalEvent(OnEquip);
+ SubscribeLocalEvent(OnUnequip);
+ SubscribeLocalEvent(OnSetName);
+ // SubscribeLocalEvent>(GetVerbs);
+ }
+
+ private void OnSetName(VoiceMaskSetNameEvent ev)
+ {
+ OpenUI(ev.Performer);
+ }
+
+ private void OnChangeName(EntityUid uid, VoiceMaskComponent component, VoiceMaskChangeNameMessage message)
+ {
+ if (message.Name.Length > HumanoidCharacterProfile.MaxNameLength || message.Name.Length <= 0)
+ {
+ _popupSystem.PopupCursor(Loc.GetString("voice-mask-popup-failure"), Filter.SinglePlayer(message.Session));
+ return;
+ }
+
+ component.VoiceName = message.Name;
+
+ _popupSystem.PopupCursor(Loc.GetString("voice-mask-popup-success"), Filter.SinglePlayer(message.Session));
+
+ TrySetLastKnownName(uid, message.Name);
+
+ UpdateUI(uid, component);
+ }
+
+ private void OnSpeakerNameTransform(EntityUid uid, VoiceMaskComponent component, TransformSpeakerNameEvent args)
+ {
+ if (component.Enabled)
+ {
+ /*
+ args.Name = _idCard.TryGetIdCard(uid, out var card) && !string.IsNullOrEmpty(card.FullName)
+ ? card.FullName
+ : Loc.GetString("voice-mask-unknown");
+ */
+
+ args.Name = component.VoiceName;
+ }
+ }
+
+ private void OpenUI(EntityUid player, ActorComponent? actor = null)
+ {
+ if (!Resolve(player, ref actor))
+ {
+ return;
+ }
+
+ _uiSystem.GetUiOrNull(player, VoiceMaskUIKey.Key)?.Open(actor.PlayerSession);
+ UpdateUI(player);
+ }
+
+ private void UpdateUI(EntityUid owner, VoiceMaskComponent? component = null)
+ {
+ if (!Resolve(owner, ref component))
+ {
+ return;
+ }
+
+ _uiSystem.GetUiOrNull(owner, VoiceMaskUIKey.Key)?.SetState(new VoiceMaskBuiState(component.VoiceName));
+ }
+}
+
+public sealed class VoiceMaskSetNameEvent : InstantActionEvent
+{
+}
diff --git a/Content.Server/VoiceMask/VoiceMaskerComponent.cs b/Content.Server/VoiceMask/VoiceMaskerComponent.cs
new file mode 100644
index 0000000000..53b6f92e2a
--- /dev/null
+++ b/Content.Server/VoiceMask/VoiceMaskerComponent.cs
@@ -0,0 +1,13 @@
+using Content.Shared.Actions.ActionTypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.VoiceMask;
+
+[RegisterComponent]
+public sealed class VoiceMaskerComponent : Component
+{
+ [ViewVariables(VVAccess.ReadWrite)] public string LastSetName = "Unknown";
+
+ [DataField("action", customTypeSerializer: typeof(PrototypeIdSerializer))]
+ public string Action = "ChangeVoiceMask";
+}
diff --git a/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs b/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
new file mode 100644
index 0000000000..2bb87819e3
--- /dev/null
+++ b/Content.Shared/VoiceMask/SharedVoiceMaskSystem.cs
@@ -0,0 +1,31 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.VoiceMask;
+
+[Serializable, NetSerializable]
+public enum VoiceMaskUIKey : byte
+{
+ Key
+}
+
+[Serializable, NetSerializable]
+public sealed class VoiceMaskBuiState : BoundUserInterfaceState
+{
+ public string Name { get; }
+
+ public VoiceMaskBuiState(string name)
+ {
+ Name = name;
+ }
+}
+
+[Serializable, NetSerializable]
+public sealed class VoiceMaskChangeNameMessage : BoundUserInterfaceMessage
+{
+ public string Name { get; }
+
+ public VoiceMaskChangeNameMessage(string name)
+ {
+ Name = name;
+ }
+}
diff --git a/Resources/Locale/en-US/voice-mask.ftl b/Resources/Locale/en-US/voice-mask.ftl
new file mode 100644
index 0000000000..cb6eb7768e
--- /dev/null
+++ b/Resources/Locale/en-US/voice-mask.ftl
@@ -0,0 +1,7 @@
+voice-mask-name-change-window = Voice Mask Name Change
+voice-mask-name-change-info = Type in the name you want to mimic.
+voice-mask-name-change-set = Set name
+voice-mask-name-change-set-description = Change the name others hear to something else.
+
+voice-mask-popup-success = Name set successfully.
+voice-mask-popup-failure = Name could not be set.
diff --git a/Resources/Prototypes/Actions/types.yml b/Resources/Prototypes/Actions/types.yml
index d3fa15fd68..ae05a26f7d 100644
--- a/Resources/Prototypes/Actions/types.yml
+++ b/Resources/Prototypes/Actions/types.yml
@@ -56,6 +56,13 @@
popupToggleSuffix: -enabled
event: !type:ToggleCombatActionEvent
+- type: instantAction
+ id: ChangeVoiceMask
+ name: voice-mask-name-change-set
+ icon: Interface/Actions/scream.png # somebody else can figure out a better icon for this
+ description: voice-mask-name-change-set-description
+ serverEvent: !type:VoiceMaskSetNameEvent
+
- type: instantAction
id: VendingThrow
name: vending-machine-action-name
diff --git a/Resources/Prototypes/Catalog/uplink_catalog.yml b/Resources/Prototypes/Catalog/uplink_catalog.yml
index 5233e6c172..02844149e4 100644
--- a/Resources/Prototypes/Catalog/uplink_catalog.yml
+++ b/Resources/Prototypes/Catalog/uplink_catalog.yml
@@ -260,6 +260,16 @@
categories:
- UplinkUtility
+- type: listing
+ id: UplinkVoiceMask
+ name: Voice Mask
+ description: A gas mask that lets you adjust your voice to whoever you can think of.
+ productEntity: ClothingMaskGasVoiceMasker
+ cost:
+ Telecrystal: 5
+ categories:
+ - UplinkUtility
+
# Bundles
- type: listing
@@ -641,7 +651,7 @@
Telecrystal: 4
categories:
- UplinkPointless
-
+
- type: listing
id: UplinkOperativeSuit
description: A suit given to our nuclear operatives with fine fabric to make sure you stand out, no other benefits aside from looking cool.
@@ -650,7 +660,7 @@
Telecrystal: 1
categories:
- UplinkPointless
-
+
- type: listing
id: UplinkOperativeSkirt
description: A skirt given to our nuclear operatives with fine fabric to make sure you stand out, no other benefits aside from looking cool.
diff --git a/Resources/Prototypes/Entities/Clothing/Masks/masks.yml b/Resources/Prototypes/Entities/Clothing/Masks/masks.yml
index 2cd0aeb434..7aa17b141c 100644
--- a/Resources/Prototypes/Entities/Clothing/Masks/masks.yml
+++ b/Resources/Prototypes/Entities/Clothing/Masks/masks.yml
@@ -102,6 +102,15 @@
Piercing: 0.95
Heat: 0.95
+- type: entity
+ parent: ClothingMaskGas
+ id: ClothingMaskGasVoiceMasker
+ name: gas mask
+ suffix: Voice Mask
+ description: A face-covering mask that can be connected to an air supply. There are switches and knobs underneath the mask.
+ components:
+ - type: VoiceMasker
+
- type: entity
parent: ClothingMaskPullableBase
id: ClothingMaskBreathMedical
diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml
index 2d3e97c99e..7ea89cf01e 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/base.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml
@@ -258,6 +258,8 @@
- type: Strippable
- type: UserInterface
interfaces:
+ - key: enum.VoiceMaskUIKey.Key
+ type: VoiceMaskBoundUserInterface
- key: enum.HumanoidMarkingModifierKey.Key
type: HumanoidMarkingModifierBoundUserInterface
- key: enum.StrippingUiKey.Key