diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs
index 03f4f3f38b..fa63193810 100644
--- a/Content.Client/Input/ContentContexts.cs
+++ b/Content.Client/Input/ContentContexts.cs
@@ -55,6 +55,7 @@ public static void SetupContexts(IInputContextContainer contexts)
human.AddFunction(ContentKeyFunctions.UseItemInHand);
human.AddFunction(ContentKeyFunctions.AltUseItemInHand);
human.AddFunction(ContentKeyFunctions.OpenCharacterMenu);
+ human.AddFunction(ContentKeyFunctions.OpenLanguageMenu);
human.AddFunction(ContentKeyFunctions.ActivateItemInWorld);
human.AddFunction(ContentKeyFunctions.ThrowItemInHand);
human.AddFunction(ContentKeyFunctions.AltActivateItemInWorld);
diff --git a/Content.Client/Language/LanguageMenuWindow.xaml b/Content.Client/Language/LanguageMenuWindow.xaml
new file mode 100644
index 0000000000..ff33a6ddf5
--- /dev/null
+++ b/Content.Client/Language/LanguageMenuWindow.xaml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Content.Client/Language/LanguageMenuWindow.xaml.cs b/Content.Client/Language/LanguageMenuWindow.xaml.cs
new file mode 100644
index 0000000000..312814aca3
--- /dev/null
+++ b/Content.Client/Language/LanguageMenuWindow.xaml.cs
@@ -0,0 +1,134 @@
+using Content.Client.Language.Systems;
+using Content.Shared.Language;
+using Content.Shared.Language.Systems;
+using Robust.Client.AutoGenerated;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.CustomControls;
+using Robust.Client.UserInterface.XAML;
+using Robust.Shared.Console;
+using Robust.Shared.Utility;
+using Serilog;
+using static Content.Shared.Language.Systems.SharedLanguageSystem;
+
+namespace Content.Client.Language;
+
+[GenerateTypedNameReferences]
+public sealed partial class LanguageMenuWindow : DefaultWindow
+{
+ private readonly LanguageSystem _clientLanguageSystem;
+ private readonly List _entries = new();
+
+
+ public LanguageMenuWindow()
+ {
+ RobustXamlLoader.Load(this);
+ _clientLanguageSystem = IoCManager.Resolve().GetEntitySystem();
+ }
+
+ protected override void Opened()
+ {
+ // Refresh the window when it gets opened.
+ // This actually causes two refreshes: one immediately, and one after the server sends a state message.
+ UpdateState(_clientLanguageSystem.CurrentLanguage, _clientLanguageSystem.SpokenLanguages);
+ _clientLanguageSystem.RequestStateUpdate();
+ }
+
+
+ public void UpdateState(string currentLanguage, List spokenLanguages)
+ {
+ var langName = Loc.GetString($"language-{currentLanguage}-name");
+ CurrentLanguageLabel.Text = Loc.GetString("language-menu-current-language", ("language", langName));
+
+ OptionsList.RemoveAllChildren();
+ _entries.Clear();
+
+ foreach (var language in spokenLanguages)
+ {
+ AddLanguageEntry(language);
+ }
+
+ // Disable the button for the currently chosen language
+ foreach (var entry in _entries)
+ {
+ if (entry.button != null)
+ entry.button.Disabled = entry.language == currentLanguage;
+ }
+ }
+
+ private void AddLanguageEntry(string language)
+ {
+ var proto = _clientLanguageSystem.GetLanguagePrototype(language);
+ var state = new EntryState { language = language };
+
+ var container = new BoxContainer { Orientation = BoxContainer.LayoutOrientation.Vertical };
+
+ #region Header
+ var header = new BoxContainer
+ {
+ Orientation = BoxContainer.LayoutOrientation.Horizontal,
+ HorizontalExpand = true,
+ SeparationOverride = 2
+ };
+
+ var name = new Label
+ {
+ Text = proto?.Name ?? Loc.GetString("generic-error"),
+ MinWidth = 50,
+ HorizontalExpand = true
+ };
+
+ var button = new Button { Text = "Choose" };
+ button.OnPressed += _ => OnLanguageChosen(language);
+ state.button = button;
+
+ header.AddChild(name);
+ header.AddChild(button);
+
+ container.AddChild(header);
+ #endregion
+
+ #region Collapsible description
+ var body = new CollapsibleBody
+ {
+ HorizontalExpand = true,
+ Margin = new Thickness(4f, 4f)
+ };
+
+ var description = new RichTextLabel { HorizontalExpand = true };
+ description.SetMessage(proto?.Description ?? Loc.GetString("generic-error"));
+ body.AddChild(description);
+
+ var collapser = new Collapsible(Loc.GetString("language-menu-description-header"), body)
+ {
+ Orientation = BoxContainer.LayoutOrientation.Vertical,
+ HorizontalExpand = true
+ };
+
+ container.AddChild(collapser);
+ #endregion
+
+ // Before adding, wrap the new container in a PanelContainer to give it a distinct look
+ var wrapper = new PanelContainer();
+ wrapper.StyleClasses.Add("PdaBorderRect");
+
+ wrapper.AddChild(container);
+ OptionsList.AddChild(wrapper);
+
+ _entries.Add(state);
+ }
+
+
+ private void OnLanguageChosen(string id)
+ {
+ var proto = _clientLanguageSystem.GetLanguagePrototype(id);
+ if (proto != null)
+ _clientLanguageSystem.RequestSetLanguage(proto);
+ }
+
+
+ private struct EntryState
+ {
+ public string language;
+ public Button? button;
+ }
+}
diff --git a/Content.Client/Language/Systems/LanguageSystem.cs b/Content.Client/Language/Systems/LanguageSystem.cs
new file mode 100644
index 0000000000..9714078b2c
--- /dev/null
+++ b/Content.Client/Language/Systems/LanguageSystem.cs
@@ -0,0 +1,76 @@
+using Content.Shared.Language;
+using Content.Shared.Language.Events;
+using Content.Shared.Language.Systems;
+using Robust.Client;
+using Robust.Shared.Console;
+
+namespace Content.Client.Language.Systems;
+
+///
+/// Client-side language system.
+///
+///
+/// Unlike the server, the client is not aware of other entities' languages; it's only notified about the entity that it posesses.
+/// Due to that, this system stores such information in a static manner.
+///
+public sealed class LanguageSystem : SharedLanguageSystem
+{
+ [Dependency] private readonly IBaseClient _client = default!;
+
+ ///
+ /// The current language of the entity currently possessed by the player.
+ ///
+ public string CurrentLanguage { get; private set; } = default!;
+ ///
+ /// The list of languages the currently possessed entity can speak.
+ ///
+ public List SpokenLanguages { get; private set; } = new();
+ ///
+ /// The list of languages the currently possessed entity can understand.
+ ///
+ public List UnderstoodLanguages { get; private set; } = new();
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnLanguagesUpdated);
+ _client.RunLevelChanged += OnRunLevelChanged;
+ }
+
+ private void OnLanguagesUpdated(LanguagesUpdatedMessage message)
+ {
+ CurrentLanguage = message.CurrentLanguage;
+ SpokenLanguages = message.Spoken;
+ UnderstoodLanguages = message.Understood;
+ }
+
+ private void OnRunLevelChanged(object? sender, RunLevelChangedEventArgs args)
+ {
+ // Request an update when entering a game
+ if (args.NewLevel == ClientRunLevel.InGame)
+ RequestStateUpdate();
+ }
+
+ ///
+ /// Sends a network request to the server to update this system's state.
+ /// The server may ignore the said request if the player is not possessing an entity.
+ ///
+ public void RequestStateUpdate()
+ {
+ RaiseNetworkEvent(new RequestLanguagesMessage());
+ }
+
+ public void RequestSetLanguage(LanguagePrototype language)
+ {
+ if (language.ID == CurrentLanguage)
+ return;
+
+ RaiseNetworkEvent(new LanguagesSetMessage(language.ID));
+
+ // May cause some minor desync...
+ // So to reduce the probability of desync, we replicate the change locally too
+ if (SpokenLanguages.Contains(language.ID))
+ CurrentLanguage = language.ID;
+ }
+}
diff --git a/Content.Client/Language/Systems/TranslatorImplanterSystem.cs b/Content.Client/Language/Systems/TranslatorImplanterSystem.cs
new file mode 100644
index 0000000000..da19b3decf
--- /dev/null
+++ b/Content.Client/Language/Systems/TranslatorImplanterSystem.cs
@@ -0,0 +1,8 @@
+using Content.Shared.Language.Systems;
+
+namespace Content.Client.Language.Systems;
+
+public sealed class TranslatorImplanterSystem : SharedTranslatorImplanterSystem
+{
+
+}
diff --git a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
index f0537079b9..49e8099e0f 100644
--- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
+++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs
@@ -212,6 +212,7 @@ void AddCheckBox(string checkBoxName, bool currentState, Action, IOnStateExited
+{
+ public LanguageMenuWindow? LanguageWindow;
+ private MenuButton? LanguageButton => UIManager.GetActiveUIWidgetOrNull()?.LanguageButton;
+
+ public override void Initialize()
+ {
+ SubscribeNetworkEvent((LanguagesUpdatedMessage message, EntitySessionEventArgs _) =>
+ LanguageWindow?.UpdateState(message.CurrentLanguage, message.Spoken));
+ }
+
+ public void OnStateEntered(GameplayState state)
+ {
+ DebugTools.Assert(LanguageWindow == null);
+
+ LanguageWindow = UIManager.CreateWindow();
+ LayoutContainer.SetAnchorPreset(LanguageWindow, LayoutContainer.LayoutPreset.CenterTop);
+
+ CommandBinds.Builder.Bind(ContentKeyFunctions.OpenLanguageMenu,
+ InputCmdHandler.FromDelegate(_ => ToggleWindow())).Register();
+ }
+
+ public void OnStateExited(GameplayState state)
+ {
+ if (LanguageWindow != null)
+ {
+ LanguageWindow.Dispose();
+ LanguageWindow = null;
+ }
+
+ CommandBinds.Unregister();
+ }
+
+ public void UnloadButton()
+ {
+ if (LanguageButton == null)
+ return;
+
+ LanguageButton.OnPressed -= LanguageButtonPressed;
+ }
+
+ public void LoadButton()
+ {
+ if (LanguageButton == null)
+ return;
+
+ LanguageButton.OnPressed += LanguageButtonPressed;
+
+ if (LanguageWindow == null)
+ return;
+
+ LanguageWindow.OnClose += () => LanguageButton.Pressed = false;
+ LanguageWindow.OnOpen += () => LanguageButton.Pressed = true;
+ }
+
+ private void LanguageButtonPressed(ButtonEventArgs args)
+ {
+ ToggleWindow();
+ }
+
+ private void ToggleWindow()
+ {
+ if (LanguageWindow == null)
+ return;
+
+ if (LanguageButton != null)
+ LanguageButton.SetClickPressed(!LanguageWindow.IsOpen);
+
+ if (LanguageWindow.IsOpen)
+ LanguageWindow.Close();
+ else
+ LanguageWindow.Open();
+ }
+}
diff --git a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs
index 1505db48a7..156fa63884 100644
--- a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs
+++ b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs
@@ -9,6 +9,7 @@
using Content.Client.UserInterface.Systems.MenuBar.Widgets;
using Content.Client.UserInterface.Systems.Sandbox;
using Robust.Client.UserInterface.Controllers;
+using Content.Client.UserInterface.Systems.Language;
namespace Content.Client.UserInterface.Systems.MenuBar;
@@ -22,6 +23,7 @@ public sealed class GameTopMenuBarUIController : UIController
[Dependency] private readonly ActionUIController _action = default!;
[Dependency] private readonly SandboxUIController _sandbox = default!;
[Dependency] private readonly GuidebookUIController _guidebook = default!;
+ [Dependency] private readonly LanguageMenuUIController _language = default!;
private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull();
@@ -44,6 +46,7 @@ public void UnloadButtons()
_ahelp.UnloadButton();
_action.UnloadButton();
_sandbox.UnloadButton();
+ _language.UnloadButton();
}
public void LoadButtons()
@@ -56,5 +59,6 @@ public void LoadButtons()
_ahelp.LoadButton();
_action.LoadButton();
_sandbox.LoadButton();
+ _language.LoadButton();
}
}
diff --git a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml
index 3c8cd1d164..a76943ace8 100644
--- a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml
+++ b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml
@@ -63,6 +63,16 @@
HorizontalExpand="True"
AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}"
/>
+
(source))
@@ -249,10 +255,10 @@ public void TrySendInGameICMessage(
switch (desiredType)
{
case InGameICChatType.Speak:
- SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker);
+ SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride);
break;
case InGameICChatType.Whisper:
- SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker);
+ SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride);
break;
case InGameICChatType.Emote:
SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker);
@@ -382,12 +388,14 @@ private void SendEntitySpeak(
ChatTransmitRange range,
string? nameOverride,
bool hideLog = false,
- bool ignoreActionBlocker = false
+ bool ignoreActionBlocker = false,
+ LanguagePrototype? languageOverride = null
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
return;
+ // The original message
var message = TransformSpeech(source, FormattedMessage.RemoveMarkup(originalMessage));
if (message.Length == 0)
@@ -411,18 +419,19 @@ private void SendEntitySpeak(
speech = proto;
}
- name = FormattedMessage.EscapeText(name);
+ var language = languageOverride ?? _language.GetLanguage(source);
- var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
- ("entityName", name),
- ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
- ("fontType", speech.FontId),
- ("fontSize", speech.FontSize),
- ("message", FormattedMessage.EscapeText(message)));
+ name = FormattedMessage.EscapeText(name);
+ // The chat message wrapped in a "x says y" string
+ var wrappedMessage = WrapPublicMessage(source, name, message);
+ // The chat message obfuscated via language obfuscation
+ var obfuscated = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en"));
+ // The language-obfuscated message wrapped in a "x says y" string
+ var wrappedObfuscated = WrapPublicMessage(source, name, obfuscated);
- SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range);
+ SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range, languageOverride: language);
- var ev = new EntitySpokeEvent(source, message, null, null);
+ var ev = new EntitySpokeEvent(source, message, null, false, language);
RaiseLocalEvent(source, ev, true);
// To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc.
@@ -455,7 +464,8 @@ private void SendEntityWhisper(
RadioChannelPrototype? channel,
string? nameOverride,
bool hideLog = false,
- bool ignoreActionBlocker = false
+ bool ignoreActionBlocker = false,
+ LanguagePrototype? languageOverride = null
)
{
if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker)
@@ -465,8 +475,6 @@ private void SendEntityWhisper(
if (message.Length == 0)
return;
- var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f);
-
// get the entity's name by visual identity (if no override provided).
string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager));
// get the entity's name by voice (if no override provided).
@@ -483,41 +491,57 @@ private void SendEntityWhisper(
}
name = FormattedMessage.EscapeText(name);
- var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
- ("entityName", name), ("message", FormattedMessage.EscapeText(message)));
-
- var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
- ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
-
- var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
- ("message", FormattedMessage.EscapeText(obfuscatedMessage)));
-
+ var language = languageOverride ?? _language.GetLanguage(source);
+ var languageObfuscatedMessage = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en"));
foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange))
{
- EntityUid listener;
-
- if (session.AttachedEntity is not { Valid: true } playerEntity)
+ if (session.AttachedEntity is not { Valid: true } listener)
continue;
- listener = session.AttachedEntity.Value;
if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full)
continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them.
+ var canUnderstandLanguage = _language.CanUnderstand(listener, language);
+ // How the entity perceives the message depends on whether it can understand its language
+ var perceivedMessage = canUnderstandLanguage ? message : languageObfuscatedMessage;
+
+ // Result is the intermediate message derived from the perceived one via obfuscation
+ // Wrapped message is the result wrapped in an "x says y" string
+ string result, wrappedMessage;
if (data.Range <= WhisperClearRange)
- _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel);
- //If listener is too far, they only hear fragments of the message
- //Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind
- else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) //Shared.Physics.CollisionGroup.Opaque
- _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel);
- //If listener is too far and has no line of sight, they can't identify the whisperer's identity
+ {
+ // Scenario 1: the listener can clearly understand the message
+ result = perceivedMessage;
+ wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
+ ("entityName", name),
+ ("message", FormattedMessage.EscapeText(result)));
+ }
+ else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque))
+ {
+ // Scenerio 2: if the listener is too far, they only hear fragments of the message
+ // Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind
+ result = ObfuscateMessageReadability(perceivedMessage);
+ wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message",
+ ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(result)));
+ }
else
- _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel);
+ {
+ // Scenario 3: If listener is too far and has no line of sight, they can't identify the whisperer's identity
+ result = ObfuscateMessageReadability(perceivedMessage);
+ wrappedMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message",
+ ("message", FormattedMessage.EscapeText(result)));
+ }
+
+ _chatManager.ChatMessageToOne(ChatChannel.Whisper, result, wrappedMessage, source, false, session.Channel);
}
- _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
+ var replayWrap = Loc.GetString("chat-manager-entity-whisper-wrap-message",
+ ("entityName", name),
+ ("message", FormattedMessage.EscapeText(message)));
+ _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, replayWrap, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
- var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage);
+ var ev = new EntitySpokeEvent(source, message, channel, true, language);
RaiseLocalEvent(source, ev, true);
if (!hideLog)
if (originalMessage == message)
@@ -564,7 +588,7 @@ private void SendEntityEmote(
if (checkEmote)
TryEmoteChatInput(source, action);
- SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author);
+ SendInVoiceRange(ChatChannel.Emotes, name, action, wrappedMessage, obfuscated: "", obfuscatedWrappedMessage: "", source, range, author);
if (!hideLog)
if (name != Name(source))
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}");
@@ -591,7 +615,13 @@ private void SendLOOC(EntityUid source, ICommonSession player, string message, b
("entityName", name),
("message", FormattedMessage.EscapeText(message)));
- SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId);
+ SendInVoiceRange(ChatChannel.LOOC, name, message, wrappedMessage,
+ obfuscated: string.Empty,
+ obfuscatedWrappedMessage: string.Empty, // will be skipped anyway
+ source,
+ hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal,
+ player.UserId,
+ languageOverride: LanguageSystem.Universal);
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}");
}
@@ -672,15 +702,29 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat
///
/// Sends a chat message to the given players in range of the source entity.
///
- private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null)
+ private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, LanguagePrototype? languageOverride = null)
{
+ var language = languageOverride ?? _language.GetLanguage(source);
foreach (var (session, data) in GetRecipients(source, VoiceRange))
{
var entRange = MessageRangeCheck(session, data, range);
if (entRange == MessageRangeCheckResult.Disallowed)
continue;
var entHideChat = entRange == MessageRangeCheckResult.HideChat;
- _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author);
+ if (session.AttachedEntity is not { Valid: true } playerEntity)
+ continue;
+ EntityUid listener = session.AttachedEntity.Value;
+
+
+ // If the channel does not support languages, or the entity can understand the message, send the original message, otherwise send the obfuscated version
+ if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(listener, language))
+ {
+ _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author);
+ }
+ else
+ {
+ _chatManager.ChatMessageToOne(channel, obfuscated, obfuscatedWrappedMessage, source, entHideChat, session.Channel, author: author);
+ }
}
_replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range)));
@@ -790,6 +834,21 @@ public string SanitizeMessageReplaceWords(string message)
return msg;
}
+ ///
+ /// Wraps a message sent by the specified entity into an "x says y" string.
+ ///
+ public string WrapPublicMessage(EntityUid source, string name, string message)
+ {
+ var speech = GetSpeechVerb(source, message);
+ var verbName = Loc.GetString(_random.Pick(speech.SpeechVerbStrings));
+ return Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message",
+ ("entityName", name),
+ ("verb", verbName),
+ ("fontType", speech.FontId),
+ ("fontSize", speech.FontSize),
+ ("message", FormattedMessage.EscapeText(message)));
+ }
+
///
/// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1.
///
@@ -836,7 +895,7 @@ public readonly record struct ICChatRecipientData(float Range, bool Observer, bo
{
}
- private string ObfuscateMessageReadability(string message, float chance)
+ public string ObfuscateMessageReadability(string message, float chance = DefaultObfuscationFactor)
{
var modifiedMessage = new StringBuilder(message);
@@ -925,7 +984,8 @@ public sealed class EntitySpokeEvent : EntityEventArgs
{
public readonly EntityUid Source;
public readonly string Message;
- public readonly string? ObfuscatedMessage; // not null if this was a whisper
+ public readonly bool IsWhisper;
+ public readonly LanguagePrototype Language;
///
/// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio
@@ -933,12 +993,13 @@ public sealed class EntitySpokeEvent : EntityEventArgs
///
public RadioChannelPrototype? Channel;
- public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage)
+ public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, bool isWhisper, LanguagePrototype language)
{
Source = source;
Message = message;
Channel = channel;
- ObfuscatedMessage = obfuscatedMessage;
+ IsWhisper = isWhisper;
+ Language = language;
}
}
diff --git a/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs b/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs
index bf7691fe37..da16529d51 100644
--- a/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs
+++ b/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs
@@ -1,10 +1,14 @@
+using System.Linq;
using Content.Server.Ghost.Roles.Components;
using Content.Server.Speech.Components;
using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Language;
+using Content.Shared.Language.Systems;
using Content.Shared.Mind.Components;
using Robust.Shared.Prototypes;
using Content.Server.Psionics; //Nyano - Summary: pulls in the ability for the sentient creature to become psionic.
using Content.Shared.Humanoid; //Delta-V - Banning humanoids from becoming ghost roles.
+using Content.Shared.Language.Events;
namespace Content.Server.Chemistry.ReagentEffects;
@@ -24,6 +28,20 @@ public override void Effect(ReagentEffectArgs args)
entityManager.RemoveComponent(uid);
entityManager.RemoveComponent(uid);
+ var speaker = entityManager.EnsureComponent(uid);
+ var fallback = SharedLanguageSystem.FallbackLanguagePrototype;
+
+ if (!speaker.UnderstoodLanguages.Contains(fallback))
+ speaker.UnderstoodLanguages.Add(fallback);
+
+ if (!speaker.SpokenLanguages.Contains(fallback))
+ {
+ speaker.CurrentLanguage = fallback;
+ speaker.SpokenLanguages.Add(fallback);
+ }
+
+ args.EntityManager.EventBus.RaiseLocalEvent(uid, new LanguagesUpdateEvent(), true);
+
// Stops from adding a ghost role to things like people who already have a mind
if (entityManager.TryGetComponent(uid, out var mindContainer) && mindContainer.HasMind)
{
@@ -47,7 +65,7 @@ public override void Effect(ReagentEffectArgs args)
ghostRole = entityManager.AddComponent(uid);
entityManager.EnsureComponent(uid);
- entityManager.EnsureComponent(uid); //Nyano - Summary:. Makes the animated body able to get psionics.
+ entityManager.EnsureComponent(uid); //Nyano - Summary:. Makes the animated body able to get psionics.
var entityData = entityManager.GetComponent(uid);
ghostRole.RoleName = entityData.EntityName;
diff --git a/Content.Server/Language/Commands/ListLanguagesCommand.cs b/Content.Server/Language/Commands/ListLanguagesCommand.cs
new file mode 100644
index 0000000000..6698e1b645
--- /dev/null
+++ b/Content.Server/Language/Commands/ListLanguagesCommand.cs
@@ -0,0 +1,39 @@
+using System.Linq;
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+
+namespace Content.Server.Language.Commands;
+
+[AnyCommand]
+public sealed class ListLanguagesCommand : IConsoleCommand
+{
+ public string Command => "languagelist";
+ public string Description => Loc.GetString("command-list-langs-desc");
+ public string Help => Loc.GetString("command-list-langs-help", ("command", Command));
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (shell.Player is not { } player)
+ {
+ shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+ return;
+ }
+
+ if (player.Status != SessionStatus.InGame)
+ return;
+
+ if (player.AttachedEntity is not {} playerEntity)
+ {
+ shell.WriteError(Loc.GetString("shell-must-be-attached-to-entity"));
+ return;
+ }
+
+ var languages = IoCManager.Resolve().GetEntitySystem();
+
+ var (spokenLangs, knownLangs) = languages.GetAllLanguages(playerEntity);
+
+ shell.WriteLine("Spoken:\n" + string.Join("\n", spokenLangs));
+ shell.WriteLine("Understood:\n" + string.Join("\n", knownLangs));
+ }
+}
diff --git a/Content.Server/Language/Commands/SayLanguageCommand.cs b/Content.Server/Language/Commands/SayLanguageCommand.cs
new file mode 100644
index 0000000000..2e4a27b1dc
--- /dev/null
+++ b/Content.Server/Language/Commands/SayLanguageCommand.cs
@@ -0,0 +1,53 @@
+using Content.Server.Chat.Systems;
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+
+namespace Content.Server.Language.Commands;
+
+[AnyCommand]
+public sealed class SayLanguageCommand : IConsoleCommand
+{
+ public string Command => "saylang";
+ public string Description => Loc.GetString("command-saylang-desc");
+ public string Help => Loc.GetString("command-saylang-help", ("command", Command));
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (shell.Player is not { } player)
+ {
+ shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+ return;
+ }
+
+ if (player.Status != SessionStatus.InGame)
+ return;
+
+ if (player.AttachedEntity is not {} playerEntity)
+ {
+ shell.WriteError(Loc.GetString("shell-must-be-attached-to-entity"));
+ return;
+ }
+
+ if (args.Length < 2)
+ return;
+
+ var languageId = args[0];
+ var message = string.Join(" ", args, startIndex: 1, count: args.Length - 1).Trim();
+
+ if (string.IsNullOrEmpty(message))
+ return;
+
+ var languages = IoCManager.Resolve().GetEntitySystem();
+ var chats = IoCManager.Resolve().GetEntitySystem();
+
+ var language = languages.GetLanguagePrototype(languageId);
+ if (language == null || !languages.CanSpeak(playerEntity, language.ID))
+ {
+ shell.WriteError($"Language {languageId} is invalid or you cannot speak it!");
+ return;
+ }
+
+ chats.TrySendInGameICMessage(playerEntity, message, InGameICChatType.Speak, ChatTransmitRange.Normal, false, shell, player, languageOverride: language);
+ }
+}
diff --git a/Content.Server/Language/Commands/SelectLanguageCommand.cs b/Content.Server/Language/Commands/SelectLanguageCommand.cs
new file mode 100644
index 0000000000..e336384653
--- /dev/null
+++ b/Content.Server/Language/Commands/SelectLanguageCommand.cs
@@ -0,0 +1,48 @@
+using System.Linq;
+using Content.Shared.Administration;
+using Robust.Shared.Console;
+using Robust.Shared.Enums;
+
+namespace Content.Server.Language.Commands;
+
+[AnyCommand]
+public sealed class SelectLanguageCommand : IConsoleCommand
+{
+ public string Command => "languageselect";
+ public string Description => Loc.GetString("command-language-select-desc");
+ public string Help => Loc.GetString("command-language-select-help", ("command", Command));
+
+ public void Execute(IConsoleShell shell, string argStr, string[] args)
+ {
+ if (shell.Player is not { } player)
+ {
+ shell.WriteError(Loc.GetString("shell-cannot-run-command-from-server"));
+ return;
+ }
+
+ if (player.Status != SessionStatus.InGame)
+ return;
+
+ if (player.AttachedEntity is not { } playerEntity)
+ {
+ shell.WriteError(Loc.GetString("shell-must-be-attached-to-entity"));
+ return;
+ }
+
+ if (args.Length < 1)
+ return;
+
+ var languageId = args[0];
+
+ var languages = IoCManager.Resolve().GetEntitySystem();
+
+ var language = languages.GetLanguagePrototype(languageId);
+ if (language == null || !languages.CanSpeak(playerEntity, language.ID))
+ {
+ shell.WriteError($"Language {languageId} is invalid or you cannot speak it!");
+ return;
+ }
+
+ languages.SetLanguage(playerEntity, language.ID);
+ }
+}
diff --git a/Content.Server/Language/DetermineEntityLanguagesEvent.cs b/Content.Server/Language/DetermineEntityLanguagesEvent.cs
new file mode 100644
index 0000000000..13ab2cac27
--- /dev/null
+++ b/Content.Server/Language/DetermineEntityLanguagesEvent.cs
@@ -0,0 +1,29 @@
+namespace Content.Server.Language;
+
+///
+/// Raised in order to determine the language an entity speaks at the current moment,
+/// as well as the list of all languages the entity may speak and understand.
+///
+public sealed class DetermineEntityLanguagesEvent : EntityEventArgs
+{
+ ///
+ /// The default language of this entity. If empty, remain unchanged.
+ /// This field has no effect if the entity decides to speak in a concrete language.
+ ///
+ public string CurrentLanguage;
+ ///
+ /// The list of all languages the entity may speak. Must NOT be held as a reference!
+ ///
+ public List SpokenLanguages;
+ ///
+ /// The list of all languages the entity may understand. Must NOT be held as a reference!
+ ///
+ public List UnderstoodLanguages;
+
+ public DetermineEntityLanguagesEvent(string currentLanguage, List spokenLanguages, List understoodLanguages)
+ {
+ CurrentLanguage = currentLanguage;
+ SpokenLanguages = spokenLanguages;
+ UnderstoodLanguages = understoodLanguages;
+ }
+}
diff --git a/Content.Server/Language/LanguageSystem.Networking.cs b/Content.Server/Language/LanguageSystem.Networking.cs
new file mode 100644
index 0000000000..7517b4185e
--- /dev/null
+++ b/Content.Server/Language/LanguageSystem.Networking.cs
@@ -0,0 +1,59 @@
+using Content.Server.Mind;
+using Content.Shared.Language;
+using Content.Shared.Language.Events;
+using Content.Shared.Mind;
+using Content.Shared.Mind.Components;
+using Robust.Shared.Player;
+
+namespace Content.Server.Language;
+
+///
+/// LanguageSystem Networking
+/// This is used to update client state when mind change entity.
+///
+
+public sealed partial class LanguageSystem
+{
+ [Dependency] private readonly MindSystem _mind = default!;
+
+
+ public void InitializeNet()
+ {
+ // Refresh the client's state when its mind hops to a different entity
+ SubscribeLocalEvent((uid, _, _) => SendLanguageStateToClient(uid));
+ SubscribeLocalEvent((_, _, args) =>
+ {
+ if (args.Mind.Comp.Session != null)
+ SendLanguageStateToClient(args.Mind.Comp.Session);
+ });
+
+ SubscribeLocalEvent((uid, comp, _) => SendLanguageStateToClient(uid, comp));
+ SubscribeNetworkEvent((_, session) => SendLanguageStateToClient(session.SenderSession));
+ }
+
+
+ private void SendLanguageStateToClient(EntityUid uid, LanguageSpeakerComponent? comp = null)
+ {
+ // Try to find a mind inside the entity and notify its session
+ if (!_mind.TryGetMind(uid, out _, out var mindComp) || mindComp.Session == null)
+ return;
+
+ SendLanguageStateToClient(uid, mindComp.Session, comp);
+ }
+
+ private void SendLanguageStateToClient(ICommonSession session, LanguageSpeakerComponent? comp = null)
+ {
+ // Try to find an entity associated with the session and resolve the languages from it
+ if (session.AttachedEntity is not { Valid: true } entity)
+ return;
+
+ SendLanguageStateToClient(entity, session, comp);
+ }
+
+ private void SendLanguageStateToClient(EntityUid uid, ICommonSession session, LanguageSpeakerComponent? component = null)
+ {
+ var langs = GetLanguages(uid, component);
+ var message = new LanguagesUpdatedMessage(langs.CurrentLanguage, langs.SpokenLanguages, langs.UnderstoodLanguages);
+ RaiseNetworkEvent(message, session);
+ }
+}
diff --git a/Content.Server/Language/LanguageSystem.cs b/Content.Server/Language/LanguageSystem.cs
new file mode 100644
index 0000000000..f1bf44c1f4
--- /dev/null
+++ b/Content.Server/Language/LanguageSystem.cs
@@ -0,0 +1,289 @@
+using System.Linq;
+using System.Text;
+using Content.Server.GameTicking.Events;
+using Content.Shared.Language;
+using Content.Shared.Language.Events;
+using Content.Shared.Language.Systems;
+using Robust.Shared.Random;
+using UniversalLanguageSpeakerComponent = Content.Shared.Language.Components.UniversalLanguageSpeakerComponent;
+
+namespace Content.Server.Language;
+
+public sealed partial class LanguageSystem : SharedLanguageSystem
+{
+ // Static and re-used event instances used to minimize memory allocations during language processing, which can happen many times per tick.
+ // These are used in the method GetLanguages and returned from it. They should never be mutated outside of that method or returned outside this system.
+ private readonly DetermineEntityLanguagesEvent
+ _determineLanguagesEvent = new(string.Empty, new(), new()),
+ _universalLanguagesEvent = new(UniversalPrototype, [UniversalPrototype], [UniversalPrototype]); // Returned for universal speakers only
+
+ ///
+ /// A random number added to each pseudo-random number's seed. Changes every round.
+ ///
+ public int RandomRoundSeed { get; private set; }
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeNetworkEvent(OnClientSetLanguage);
+ SubscribeLocalEvent(OnInitLanguageSpeaker);
+ SubscribeLocalEvent(_ => RandomRoundSeed = _random.Next());
+
+ InitializeNet();
+ }
+
+
+ #region public api
+ ///
+ /// Obfuscate a message using an entity's default language.
+ ///
+ public string ObfuscateSpeech(EntityUid source, string message)
+ {
+ var language = GetLanguage(source) ?? Universal;
+ return ObfuscateSpeech(message, language);
+ }
+
+ ///
+ /// Obfuscate a message using the given language.
+ ///
+ public string ObfuscateSpeech(string message, LanguagePrototype language)
+ {
+ var builder = new StringBuilder();
+ if (language.ObfuscateSyllables)
+ ObfuscateSyllables(builder, message, language);
+ else
+ ObfuscatePhrases(builder, message, language);
+
+ return builder.ToString();
+ }
+
+ public bool CanUnderstand(EntityUid listener, LanguagePrototype language, LanguageSpeakerComponent? listenerLanguageComp = null)
+ {
+ if (language.ID == UniversalPrototype || HasComp(listener))
+ return true;
+
+ var listenerLanguages = GetLanguages(listener, listenerLanguageComp)?.UnderstoodLanguages;
+
+ return listenerLanguages?.Contains(language.ID, StringComparer.Ordinal) ?? false;
+ }
+
+ public bool CanSpeak(EntityUid speaker, string language, LanguageSpeakerComponent? speakerComp = null)
+ {
+ if (HasComp(speaker))
+ return true;
+
+ var langs = GetLanguages(speaker, speakerComp)?.UnderstoodLanguages;
+ return langs?.Contains(language, StringComparer.Ordinal) ?? false;
+ }
+
+ ///
+ /// Returns the current language of the given entity.
+ /// Assumes Universal if not specified.
+ ///
+ public LanguagePrototype GetLanguage(EntityUid speaker, LanguageSpeakerComponent? languageComp = null)
+ {
+ var id = GetLanguages(speaker, languageComp)?.CurrentLanguage;
+ if (id == null)
+ return Universal; // Fallback
+
+ _prototype.TryIndex(id, out LanguagePrototype? proto);
+
+ return proto ?? Universal;
+ }
+
+ public void SetLanguage(EntityUid speaker, string language, LanguageSpeakerComponent? languageComp = null)
+ {
+ if (!CanSpeak(speaker, language) || HasComp(speaker))
+ return;
+
+ if (languageComp == null && !TryComp(speaker, out languageComp))
+ return;
+
+ if (languageComp.CurrentLanguage == language)
+ return;
+
+ languageComp.CurrentLanguage = language;
+
+ RaiseLocalEvent(speaker, new LanguagesUpdateEvent(), true);
+ }
+
+ ///
+ /// Adds a new language to the lists of understood and/or spoken languages of the given component.
+ ///
+ public void AddLanguage(LanguageSpeakerComponent comp, string language, bool addSpoken = true, bool addUnderstood = true)
+ {
+ if (addSpoken && !comp.SpokenLanguages.Contains(language))
+ comp.SpokenLanguages.Add(language);
+
+ if (addUnderstood && !comp.UnderstoodLanguages.Contains(language))
+ comp.UnderstoodLanguages.Add(language);
+
+ RaiseLocalEvent(comp.Owner, new LanguagesUpdateEvent(), true);
+ }
+
+ public (List spoken, List understood) GetAllLanguages(EntityUid speaker)
+ {
+ var languages = GetLanguages(speaker);
+ // The lists need to be copied because the internal ones are re-used for performance reasons.
+ return (new List(languages.SpokenLanguages), new List(languages.UnderstoodLanguages));
+ }
+
+ ///
+ /// Ensures the given entity has a valid language as its current language.
+ /// If not, sets it to the first entry of its SpokenLanguages list, or universal if it's empty.
+ ///
+ public void EnsureValidLanguage(EntityUid entity, LanguageSpeakerComponent? comp = null)
+ {
+ if (comp == null && !TryComp(entity, out comp))
+ return;
+
+ var langs = GetLanguages(entity, comp);
+ if (!langs.SpokenLanguages.Contains(comp!.CurrentLanguage, StringComparer.Ordinal))
+ {
+ comp.CurrentLanguage = langs.SpokenLanguages.FirstOrDefault(UniversalPrototype);
+ RaiseLocalEvent(comp.Owner, new LanguagesUpdateEvent(), true);
+ }
+ }
+ #endregion
+
+ #region event handling
+ private void OnInitLanguageSpeaker(EntityUid uid, LanguageSpeakerComponent component, ComponentInit args)
+ {
+ if (string.IsNullOrEmpty(component.CurrentLanguage))
+ component.CurrentLanguage = component.SpokenLanguages.FirstOrDefault(UniversalPrototype);
+ }
+ #endregion
+
+ #region internal api - obfuscation
+ private void ObfuscateSyllables(StringBuilder builder, string message, LanguagePrototype language)
+ {
+ // Go through each word. Calculate its hash sum and count the number of letters.
+ // Replicate it with pseudo-random syllables of pseudo-random (but similar) length. Use the hash code as the seed.
+ // This means that identical words will be obfuscated identically. Simple words like "hello" or "yes" in different langs can be memorized.
+ var wordBeginIndex = 0;
+ var hashCode = 0;
+ for (var i = 0; i < message.Length; i++)
+ {
+ var ch = char.ToLower(message[i]);
+ // A word ends when one of the following is found: a space, a sentence end, or EOM
+ if (char.IsWhiteSpace(ch) || IsSentenceEnd(ch) || i == message.Length - 1)
+ {
+ var wordLength = i - wordBeginIndex;
+ if (wordLength > 0)
+ {
+ var newWordLength = PseudoRandomNumber(hashCode, 1, 4);
+
+ for (var j = 0; j < newWordLength; j++)
+ {
+ var index = PseudoRandomNumber(hashCode + j, 0, language.Replacement.Count);
+ builder.Append(language.Replacement[index]);
+ }
+ }
+
+ builder.Append(ch);
+ hashCode = 0;
+ wordBeginIndex = i + 1;
+ }
+ else
+ hashCode = hashCode * 31 + ch;
+ }
+ }
+
+ private void ObfuscatePhrases(StringBuilder builder, string message, LanguagePrototype language)
+ {
+ // In a similar manner, each phrase is obfuscated with a random number of conjoined obfuscation phrases.
+ // However, the number of phrases depends on the number of characters in the original phrase.
+ var sentenceBeginIndex = 0;
+ for (var i = 0; i < message.Length; i++)
+ {
+ var ch = char.ToLower(message[i]);
+ if (IsSentenceEnd(ch) || i == message.Length - 1)
+ {
+ var length = i - sentenceBeginIndex;
+ if (length > 0)
+ {
+ var newLength = (int) Math.Clamp(Math.Cbrt(length) - 1, 1, 4); // 27+ chars for 2 phrases, 64+ for 3, 125+ for 4.
+
+ for (var j = 0; j < newLength; j++)
+ {
+ var phrase = _random.Pick(language.Replacement);
+ builder.Append(phrase);
+ }
+ }
+ sentenceBeginIndex = i + 1;
+
+ if (IsSentenceEnd(ch))
+ builder.Append(ch).Append(" ");
+ }
+ }
+ }
+
+ private static bool IsSentenceEnd(char ch)
+ {
+ return ch is '.' or '!' or '?';
+ }
+ #endregion
+
+ #region internal api - misc
+ ///
+ /// Dynamically resolves the current language of the entity and the list of all languages it speaks.
+ ///
+ /// If the entity is not a language speaker, or is a universal language speaker, then it's assumed to speak Universal,
+ /// aka all languages at once and none at the same time.
+ ///
+ ///
+ /// The returned event is reused and thus must not be held as a reference anywhere but inside the caller function.
+ ///
+ private DetermineEntityLanguagesEvent GetLanguages(EntityUid speaker, LanguageSpeakerComponent? comp = null)
+ {
+ // This is a shortcut for ghosts and entities that should not speak normally (admemes)
+ if (HasComp(speaker) || !TryComp(speaker, out comp))
+ return _universalLanguagesEvent;
+
+ var ev = _determineLanguagesEvent;
+ ev.SpokenLanguages.Clear();
+ ev.UnderstoodLanguages.Clear();
+
+ ev.CurrentLanguage = comp.CurrentLanguage;
+ ev.SpokenLanguages.AddRange(comp.SpokenLanguages);
+ ev.UnderstoodLanguages.AddRange(comp.UnderstoodLanguages);
+
+ RaiseLocalEvent(speaker, ev, true);
+
+ if (ev.CurrentLanguage.Length == 0)
+ ev.CurrentLanguage = !string.IsNullOrEmpty(comp.CurrentLanguage) ? comp.CurrentLanguage : UniversalPrototype; // Fall back to account for admemes like admins possessing a bread
+ return ev;
+ }
+
+ ///
+ /// Generates a stable pseudo-random number in the range (min, max) for the given seed.
+ /// Each input seed corresponds to exactly one random number.
+ ///
+ private int PseudoRandomNumber(int seed, int min, int max)
+ {
+ // This is not a uniform distribution, but it shouldn't matter given there's 2^31 possible random numbers,
+ // the bias of this function should be so tiny it will never be noticed.
+ seed += RandomRoundSeed;
+ var random = ((seed * 1103515245) + 12345) & 0x7fffffff; // Source: http://cs.uccs.edu/~cs591/bufferOverflow/glibc-2.2.4/stdlib/random_r.c
+ return random % (max - min) + min;
+ }
+
+ ///
+ /// Set CurrentLanguage of the client, the client must be able to Understand the language requested.
+ ///
+ private void OnClientSetLanguage(LanguagesSetMessage message, EntitySessionEventArgs args)
+ {
+ if (args.SenderSession.AttachedEntity is not {Valid: true} speaker)
+ return;
+
+ var language = GetLanguagePrototype(message.CurrentLanguage);
+
+ if (language == null || !CanSpeak(speaker, language.ID))
+ return;
+
+ SetLanguage(speaker, language.ID);
+ }
+ #endregion
+}
diff --git a/Content.Server/Language/TranslatorImplanterSystem.cs b/Content.Server/Language/TranslatorImplanterSystem.cs
new file mode 100644
index 0000000000..1e0c13375e
--- /dev/null
+++ b/Content.Server/Language/TranslatorImplanterSystem.cs
@@ -0,0 +1,72 @@
+using System.Linq;
+using Content.Server.Administration.Logs;
+using Content.Server.Popups;
+using Content.Shared.Database;
+using Content.Shared.Interaction;
+using Content.Shared.Language;
+using Content.Shared.Language.Components;
+using Content.Shared.Language.Events;
+using Content.Shared.Language.Systems;
+using Content.Shared.Mobs.Components;
+using Content.Shared.Language.Components.Translators;
+
+namespace Content.Server.Language;
+
+public sealed class TranslatorImplanterSystem : SharedTranslatorImplanterSystem
+{
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly IAdminLogManager _adminLogger = default!;
+ [Dependency] private readonly LanguageSystem _language = default!;
+
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnImplant);
+ }
+
+
+ private void OnImplant(EntityUid implanter, TranslatorImplanterComponent component, AfterInteractEvent args)
+ {
+ if (component.Used || !args.CanReach || args.Target is not { Valid: true } target)
+ return;
+
+ if (!TryComp(target, out var speaker))
+ return;
+
+ if (component.MobsOnly && !HasComp(target))
+ {
+ _popup.PopupEntity("translator-implanter-refuse", component.Owner);
+ return;
+ }
+
+ var understood = _language.GetAllLanguages(target).understood;
+ if (component.RequiredLanguages.Count > 0 && !component.RequiredLanguages.Any(lang => understood.Contains(lang)))
+ {
+ _popup.PopupEntity(Loc.GetString("translator-implanter-refuse",
+ ("implanter", implanter), ("target", target)), implanter);
+ return;
+ }
+
+ var intrinsic = EnsureComp(target);
+ intrinsic.Enabled = true;
+
+ foreach (var lang in component.SpokenLanguages.Where(lang => !intrinsic.SpokenLanguages.Contains(lang)))
+ intrinsic.SpokenLanguages.Add(lang);
+
+ foreach (var lang in component.UnderstoodLanguages.Where(lang => !intrinsic.UnderstoodLanguages.Contains(lang)))
+ intrinsic.UnderstoodLanguages.Add(lang);
+
+ component.Used = true;
+ _popup.PopupEntity(Loc.GetString("translator-implanter-success",
+ ("implanter", implanter), ("target", target)), implanter);
+
+ _adminLogger.Add(LogType.Action, LogImpact.Medium,
+ $"{ToPrettyString(args.User):player} used {ToPrettyString(implanter):implanter} to give {ToPrettyString(target):target} the following languages:"
+ + $"\nSpoken: {string.Join(", ", component.SpokenLanguages)}; Understood: {string.Join(", ", component.UnderstoodLanguages)}");
+
+ OnAppearanceChange(implanter, component);
+ RaiseLocalEvent(target, new LanguagesUpdateEvent(), true);
+ }
+}
diff --git a/Content.Server/Language/TranslatorSystem.cs b/Content.Server/Language/TranslatorSystem.cs
new file mode 100644
index 0000000000..3b7704b9a7
--- /dev/null
+++ b/Content.Server/Language/TranslatorSystem.cs
@@ -0,0 +1,225 @@
+using System.Linq;
+using Content.Server.Popups;
+using Content.Server.PowerCell;
+using Content.Shared.Interaction;
+using Content.Shared.Interaction.Events;
+using Content.Shared.Language;
+using Content.Shared.Language.Events;
+using Content.Shared.Language.Systems;
+using Content.Shared.PowerCell;
+using Content.Shared.Language.Components.Translators;
+
+namespace Content.Server.Language;
+
+// This does not support holding multiple translators at once.
+// That shouldn't be an issue for now, but it needs to be fixed later.
+public sealed class TranslatorSystem : SharedTranslatorSystem
+{
+ [Dependency] private readonly PopupSystem _popup = default!;
+ [Dependency] private readonly LanguageSystem _language = default!;
+ [Dependency] private readonly PowerCellSystem _powerCell = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ // I wanna die. But my death won't help us discover polymorphism.
+ SubscribeLocalEvent(OnDetermineLanguages);
+ SubscribeLocalEvent(OnDetermineLanguages);
+ SubscribeLocalEvent(OnDetermineLanguages);
+
+ SubscribeLocalEvent(OnTranslatorToggle);
+ SubscribeLocalEvent(OnPowerCellSlotEmpty);
+
+ // TODO: why does this use InteractHandEvent??
+ SubscribeLocalEvent(OnTranslatorInteract);
+ SubscribeLocalEvent(OnTranslatorDropped);
+ }
+
+ private void OnDetermineLanguages(EntityUid uid, IntrinsicTranslatorComponent component,
+ DetermineEntityLanguagesEvent ev)
+ {
+ if (!component.Enabled)
+ return;
+
+ if (!_powerCell.HasActivatableCharge(uid))
+ return;
+
+ var addUnderstood = true;
+ var addSpoken = true;
+ if (component.RequiredLanguages.Count > 0)
+ {
+ if (component.RequiresAllLanguages)
+ {
+ // Add langs when the wielder has all of the required languages
+ foreach (var language in component.RequiredLanguages)
+ {
+ if (!ev.SpokenLanguages.Contains(language, StringComparer.Ordinal))
+ addSpoken = false;
+
+ if (!ev.UnderstoodLanguages.Contains(language, StringComparer.Ordinal))
+ addUnderstood = false;
+ }
+ }
+ else
+ {
+ // Add langs when the wielder has at least one of the required languages
+ addUnderstood = false;
+ addSpoken = false;
+ foreach (var language in component.RequiredLanguages)
+ {
+ if (ev.SpokenLanguages.Contains(language, StringComparer.Ordinal))
+ addSpoken = true;
+
+ if (ev.UnderstoodLanguages.Contains(language, StringComparer.Ordinal))
+ addUnderstood = true;
+ }
+ }
+ }
+
+ if (addSpoken)
+ {
+ foreach (var language in component.SpokenLanguages)
+ AddIfNotExists(ev.SpokenLanguages, language);
+
+ if (component.DefaultLanguageOverride != null && ev.CurrentLanguage.Length == 0)
+ ev.CurrentLanguage = component.DefaultLanguageOverride;
+ }
+
+ if (addUnderstood)
+ foreach (var language in component.UnderstoodLanguages)
+ AddIfNotExists(ev.UnderstoodLanguages, language);
+ }
+
+ private void OnTranslatorInteract( EntityUid translator, HandheldTranslatorComponent component, InteractHandEvent args)
+ {
+ var holder = args.User;
+ if (!EntityManager.HasComponent(holder))
+ return;
+
+ var intrinsic = EnsureComp(holder);
+ UpdateBoundIntrinsicComp(component, intrinsic, component.Enabled);
+
+ RaiseLocalEvent(holder, new LanguagesUpdateEvent(), true);
+ }
+
+ private void OnTranslatorDropped(EntityUid translator, HandheldTranslatorComponent component, DroppedEvent args)
+ {
+ var holder = args.User;
+ if (!EntityManager.TryGetComponent(holder, out var intrinsic))
+ return;
+
+ if (intrinsic.Issuer == component)
+ {
+ intrinsic.Enabled = false;
+ RemCompDeferred(holder, intrinsic);
+ }
+
+ _language.EnsureValidLanguage(holder);
+
+ RaiseLocalEvent(holder, new LanguagesUpdateEvent(), true);
+ }
+
+ private void OnTranslatorToggle(EntityUid translator, HandheldTranslatorComponent component, ActivateInWorldEvent args)
+ {
+ if (!component.ToggleOnInteract)
+ return;
+
+ var hasPower = _powerCell.HasDrawCharge(translator);
+
+ if (Transform(args.Target).ParentUid is { Valid: true } holder
+ && EntityManager.HasComponent(holder))
+ {
+ // This translator is held by a language speaker and thus has an intrinsic counterpart bound to it.
+ // Make sure it's up-to-date.
+ var intrinsic = EnsureComp(holder);
+ var isEnabled = !component.Enabled;
+ if (intrinsic.Issuer != component)
+ {
+ // The intrinsic comp wasn't owned by this handheld component, so this comp wasn't the active translator.
+ // Thus it needs to be turned on regardless of its previous state.
+ intrinsic.Issuer = component;
+ isEnabled = true;
+ }
+
+ isEnabled &= hasPower;
+ UpdateBoundIntrinsicComp(component, intrinsic, isEnabled);
+ component.Enabled = isEnabled;
+ _powerCell.SetPowerCellDrawEnabled(translator, isEnabled);
+
+ _language.EnsureValidLanguage(holder);
+ RaiseLocalEvent(holder, new LanguagesUpdateEvent(), true);
+ }
+ else
+ {
+ // This is a standalone translator (e.g. lying on the ground), toggle its state.
+ component.Enabled = !component.Enabled && hasPower;
+ _powerCell.SetPowerCellDrawEnabled(translator, !component.Enabled && hasPower);
+ }
+
+ OnAppearanceChange(translator, component);
+
+ // HasPower shows a popup when there's no power, so we do not proceed in that case
+ if (hasPower)
+ {
+ var message = Loc.GetString(
+ component.Enabled
+ ? "translator-component-turnon"
+ : "translator-component-shutoff",
+ ("translator", component.Owner));
+ _popup.PopupEntity(message, component.Owner, args.User);
+ }
+ }
+
+ private void OnPowerCellSlotEmpty(EntityUid translator, HandheldTranslatorComponent component, PowerCellSlotEmptyEvent args)
+ {
+ component.Enabled = false;
+ _powerCell.SetPowerCellDrawEnabled(translator, false);
+ OnAppearanceChange(translator, component);
+
+ if (Transform(translator).ParentUid is { Valid: true } holder
+ && EntityManager.HasComponent(holder))
+ {
+ if (!EntityManager.TryGetComponent(holder, out var intrinsic))
+ return;
+
+ if (intrinsic.Issuer == component)
+ {
+ intrinsic.Enabled = false;
+ EntityManager.RemoveComponent(holder, intrinsic);
+ }
+
+ _language.EnsureValidLanguage(holder);
+ RaiseLocalEvent(holder, new LanguagesUpdateEvent(), true);
+ }
+ }
+
+ ///
+ /// Copies the state from the handheld to the intrinsic component
+ ///
+ private void UpdateBoundIntrinsicComp(HandheldTranslatorComponent comp, HoldsTranslatorComponent intrinsic, bool isEnabled)
+ {
+ if (isEnabled)
+ {
+ intrinsic.SpokenLanguages = new List(comp.SpokenLanguages);
+ intrinsic.UnderstoodLanguages = new List(comp.UnderstoodLanguages);
+ intrinsic.DefaultLanguageOverride = comp.DefaultLanguageOverride;
+ }
+ else
+ {
+ intrinsic.SpokenLanguages.Clear();
+ intrinsic.UnderstoodLanguages.Clear();
+ intrinsic.DefaultLanguageOverride = null;
+ }
+
+ intrinsic.Enabled = isEnabled;
+ intrinsic.Issuer = comp;
+ }
+
+ private static void AddIfNotExists(List list, string item)
+ {
+ if (list.Contains(item))
+ return;
+ list.Add(item);
+ }
+}
diff --git a/Content.Server/Mind/Commands/MakeSentientCommand.cs b/Content.Server/Mind/Commands/MakeSentientCommand.cs
index 5e19d135b6..cacd499ab8 100644
--- a/Content.Server/Mind/Commands/MakeSentientCommand.cs
+++ b/Content.Server/Mind/Commands/MakeSentientCommand.cs
@@ -1,7 +1,10 @@
using Content.Server.Administration;
+using Content.Server.Language;
using Content.Shared.Administration;
using Content.Shared.Emoting;
using Content.Shared.Examine;
+using Content.Shared.Language;
+using Content.Shared.Language.Systems;
using Content.Shared.Mind.Components;
using Content.Shared.Movement.Components;
using Content.Shared.Speech;
@@ -55,6 +58,13 @@ public static void MakeSentient(EntityUid uid, IEntityManager entityManager, boo
{
entityManager.EnsureComponent(uid);
entityManager.EnsureComponent(uid);
+
+ var language = IoCManager.Resolve().GetEntitySystem();
+ var speaker = entityManager.EnsureComponent(uid);
+ // If the speaker knows any language (like monkey or robot), they keep those
+ // Otherwise, we give them the fallback
+ if (speaker.SpokenLanguages.Count == 0)
+ language.AddLanguage(speaker, SharedLanguageSystem.FallbackLanguagePrototype);
}
entityManager.EnsureComponent(uid);
diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
index d18b044205..53517da6cb 100644
--- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
+++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs
@@ -1,6 +1,9 @@
using Content.Server.Chat.Systems;
using Content.Server.Emp;
+using Content.Server.Language;
using Content.Server.Radio.Components;
+using Content.Server.Speech;
+using Content.Shared.Chat;
using Content.Shared.Inventory.Events;
using Content.Shared.Radio;
using Content.Shared.Radio.Components;
@@ -14,6 +17,7 @@ public sealed class HeadsetSystem : SharedHeadsetSystem
{
[Dependency] private readonly INetManager _netMan = default!;
[Dependency] private readonly RadioSystem _radio = default!;
+ [Dependency] private readonly LanguageSystem _language = default!;
public override void Initialize()
{
@@ -99,8 +103,16 @@ public void SetEnabled(EntityUid uid, bool value, HeadsetComponent? component =
private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, ref RadioReceiveEvent args)
{
- if (TryComp(Transform(uid).ParentUid, out ActorComponent? actor))
- _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
+ var parent = Transform(uid).ParentUid;
+ if (TryComp(parent, out ActorComponent? actor))
+ {
+ var canUnderstand = _language.CanUnderstand(parent, args.Language);
+ var msg = new MsgChatMessage
+ {
+ Message = canUnderstand ? args.OriginalChatMsg : args.LanguageObfuscatedChatMsg
+ };
+ _netMan.ServerSendMessage(msg, actor.PlayerSession.Channel);
+ }
}
private void OnEmpPulse(EntityUid uid, HeadsetComponent component, ref EmpPulseEvent args)
diff --git a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs
index ace7d8ae31..fc3f69a3ba 100644
--- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs
@@ -1,5 +1,6 @@
using Content.Server.Chat.Systems;
using Content.Server.Interaction;
+using Content.Server.Language;
using Content.Server.Popups;
using Content.Server.Power.Components;
using Content.Server.Power.EntitySystems;
@@ -29,6 +30,7 @@ public sealed class RadioDeviceSystem : EntitySystem
[Dependency] private readonly InteractionSystem _interaction = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly UserInterfaceSystem _ui = default!;
+ [Dependency] private readonly LanguageSystem _language = default!;
// Used to prevent a shitter from using a bunch of radios to spam chat.
private HashSet<(string, EntityUid)> _recentlySent = new();
@@ -208,7 +210,8 @@ private void OnReceiveRadio(EntityUid uid, RadioSpeakerComponent component, ref
("originalName", nameEv.Name));
// log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios
- _chat.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false);
+ var message = args.OriginalChatMsg.Message; // The chat system will handle the rest and re-obfuscate if needed.
+ _chat.TrySendInGameICMessage(uid, message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false, languageOverride: args.Language);
}
private void OnBeforeIntercomUiOpen(EntityUid uid, IntercomComponent component, BeforeActivatableUIOpenEvent args)
diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs
index e2a61b5022..60aa7c2f4f 100644
--- a/Content.Server/Radio/EntitySystems/RadioSystem.cs
+++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs
@@ -1,10 +1,13 @@
using Content.Server.Administration.Logs;
using Content.Server.Chat.Systems;
+using Content.Server.Language;
using Content.Server.Power.Components;
using Content.Server.Radio.Components;
+using Content.Server.Speech;
using Content.Server.VoiceMask;
using Content.Shared.Chat;
using Content.Shared.Database;
+using Content.Shared.Language;
using Content.Shared.Radio;
using Content.Shared.Radio.Components;
using Content.Shared.Speech;
@@ -29,6 +32,7 @@ public sealed class RadioSystem : EntitySystem
[Dependency] private readonly IPrototypeManager _prototype = default!;
[Dependency] private readonly IRobustRandom _random = default!;
[Dependency] private readonly ChatSystem _chat = default!;
+ [Dependency] private readonly LanguageSystem _language = default!;
// set used to prevent radio feedback loops.
private readonly HashSet _messages = new();
@@ -44,7 +48,7 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent
{
if (args.Channel != null && component.Channels.Contains(args.Channel.ID))
{
- SendRadioMessage(uid, args.Message, args.Channel, uid);
+ SendRadioMessage(uid, args.Message, args.Channel, uid, args.Language);
args.Channel = null; // prevent duplicate messages from other listeners.
}
}
@@ -52,15 +56,23 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent
private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, ref RadioReceiveEvent args)
{
if (TryComp(uid, out ActorComponent? actor))
- _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
+ {
+ // Einstein-Engines - languages mechanic
+ var listener = component.Owner;
+ var msg = args.OriginalChatMsg;
+ if (listener != null && !_language.CanUnderstand(listener, args.Language))
+ msg = args.LanguageObfuscatedChatMsg;
+
+ _netMan.ServerSendMessage(new MsgChatMessage { Message = msg}, actor.PlayerSession.Channel);
+ }
}
///
/// Send radio message to all active radio listeners
///
- public void SendRadioMessage(EntityUid messageSource, string message, ProtoId channel, EntityUid radioSource, bool escapeMarkup = true)
+ public void SendRadioMessage(EntityUid messageSource, string message, ProtoId channel, EntityUid radioSource, LanguagePrototype? language = null, bool escapeMarkup = true)
{
- SendRadioMessage(messageSource, message, _prototype.Index(channel), radioSource, escapeMarkup: escapeMarkup);
+ SendRadioMessage(messageSource, message, _prototype.Index(channel), radioSource, escapeMarkup: escapeMarkup, language: language);
}
///
@@ -68,8 +80,11 @@ public void SendRadioMessage(EntityUid messageSource, string message, ProtoId
/// Entity that spoke the message
/// Entity that picked up the message and will send it, e.g. headset
- public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, bool escapeMarkup = true)
+ public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, LanguagePrototype? language = null, bool escapeMarkup = true)
{
+ if (language == null)
+ language = _language.GetLanguage(messageSource);
+
// TODO if radios ever garble / modify messages, feedback-prevention needs to be handled better than this.
if (!_messages.Add(message))
return;
@@ -84,6 +99,7 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann
name = FormattedMessage.EscapeText(name);
+ // most radios are relayed to chat, so lets parse the chat message beforehand
SpeechVerbPrototype speech;
if (mask != null
&& mask.Enabled
@@ -99,24 +115,15 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann
? FormattedMessage.EscapeText(message)
: message;
- var wrappedMessage = Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap",
- ("color", channel.Color),
- ("fontType", speech.FontId),
- ("fontSize", speech.FontSize),
- ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
- ("channel", $"\\[{channel.LocalizedName}\\]"),
- ("name", name),
- ("message", content));
+ var wrappedMessage = WrapRadioMessage(messageSource, channel, name, content);
+ var msg = new ChatMessage(ChatChannel.Radio, content, wrappedMessage, NetEntity.Invalid, null);
- // most radios are relayed to chat, so lets parse the chat message beforehand
- var chat = new ChatMessage(
- ChatChannel.Radio,
- message,
- wrappedMessage,
- NetEntity.Invalid,
- null);
- var chatMsg = new MsgChatMessage { Message = chat };
- var ev = new RadioReceiveEvent(message, messageSource, channel, chatMsg);
+ // ... you guess it
+ var obfuscated = _language.ObfuscateSpeech(content, language);
+ var obfuscatedWrapped = WrapRadioMessage(messageSource, channel, name, obfuscated);
+ var notUdsMsg = new ChatMessage(ChatChannel.Radio, obfuscated, obfuscatedWrapped, NetEntity.Invalid, null);
+
+ var ev = new RadioReceiveEvent(messageSource, channel, msg, notUdsMsg, language);
var sendAttemptEv = new RadioSendAttemptEvent(channel, radioSource);
RaiseLocalEvent(ref sendAttemptEv);
@@ -162,10 +169,23 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann
else
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Radio message from {ToPrettyString(messageSource):user} on {channel.LocalizedName}: {message}");
- _replay.RecordServerMessage(chat);
+ _replay.RecordServerMessage(msg);
_messages.Remove(message);
}
+ private string WrapRadioMessage(EntityUid source, RadioChannelPrototype channel, string name, string message)
+ {
+ var speech = _chat.GetSpeechVerb(source, message);
+ return Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap",
+ ("color", channel.Color),
+ ("fontType", speech.FontId),
+ ("fontSize", speech.FontSize),
+ ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))),
+ ("channel", $"\\[{channel.LocalizedName}\\]"),
+ ("name", name),
+ ("message", FormattedMessage.EscapeText(message)));
+ }
+
///
private bool HasActiveServer(MapId mapId, string channelId)
{
diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs
index 69d764ffe6..35220d1d75 100644
--- a/Content.Server/Radio/RadioEvent.cs
+++ b/Content.Server/Radio/RadioEvent.cs
@@ -1,10 +1,22 @@
using Content.Shared.Chat;
+using Content.Shared.Language;
using Content.Shared.Radio;
namespace Content.Server.Radio;
+///
+/// The message to display when the speaker can understand "language"
+/// The message to display when the speaker cannot understand "language"
+///
[ByRefEvent]
-public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, MsgChatMessage ChatMsg);
+public readonly record struct RadioReceiveEvent(
+ // Einstein-Engines - languages mechanic
+ EntityUid MessageSource,
+ RadioChannelPrototype Channel,
+ ChatMessage OriginalChatMsg,
+ ChatMessage LanguageObfuscatedChatMsg,
+ LanguagePrototype Language
+);
///
/// Use this event to cancel sending message per receiver
diff --git a/Content.Server/Speech/EntitySystems/ListeningSystem.cs b/Content.Server/Speech/EntitySystems/ListeningSystem.cs
index ea3569e055..f2a625600c 100644
--- a/Content.Server/Speech/EntitySystems/ListeningSystem.cs
+++ b/Content.Server/Speech/EntitySystems/ListeningSystem.cs
@@ -8,6 +8,7 @@ namespace Content.Server.Speech.EntitySystems;
///
public sealed class ListeningSystem : EntitySystem
{
+ [Dependency] private readonly ChatSystem _chat = default!;
[Dependency] private readonly SharedTransformSystem _xforms = default!;
public override void Initialize()
@@ -18,10 +19,10 @@ public override void Initialize()
private void OnSpeak(EntitySpokeEvent ev)
{
- PingListeners(ev.Source, ev.Message, ev.ObfuscatedMessage);
+ PingListeners(ev.Source, ev.Message, ev.IsWhisper);
}
- public void PingListeners(EntityUid source, string message, string? obfuscatedMessage)
+ public void PingListeners(EntityUid source, string message, bool isWhisper)
{
// TODO whispering / audio volume? Microphone sensitivity?
// for now, whispering just arbitrarily reduces the listener's max range.
@@ -32,7 +33,7 @@ public void PingListeners(EntityUid source, string message, string? obfuscatedMe
var attemptEv = new ListenAttemptEvent(source);
var ev = new ListenEvent(message, source);
- var obfuscatedEv = obfuscatedMessage == null ? null : new ListenEvent(obfuscatedMessage, source);
+ var obfuscatedEv = !isWhisper ? null : new ListenEvent(_chat.ObfuscateMessageReadability(message), source);
var query = EntityQueryEnumerator();
while(query.MoveNext(out var listenerUid, out var listener, out var xform))
diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs
index ee4a4e9023..0b72ac5ee7 100644
--- a/Content.Shared/Input/ContentKeyFunctions.cs
+++ b/Content.Shared/Input/ContentKeyFunctions.cs
@@ -25,6 +25,7 @@ public static class ContentKeyFunctions
public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward";
public static readonly BoundKeyFunction EscapeContext = "EscapeContext";
public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu";
+ public static readonly BoundKeyFunction OpenLanguageMenu = "OpenLanguageMenu";
public static readonly BoundKeyFunction OpenCraftingMenu = "OpenCraftingMenu";
public static readonly BoundKeyFunction OpenGuidebook = "OpenGuidebook";
public static readonly BoundKeyFunction OpenInventoryMenu = "OpenInventoryMenu";
diff --git a/Content.Shared/Language/Components/LanguageSpeakerComponent.cs b/Content.Shared/Language/Components/LanguageSpeakerComponent.cs
new file mode 100644
index 0000000000..95232ffe6f
--- /dev/null
+++ b/Content.Shared/Language/Components/LanguageSpeakerComponent.cs
@@ -0,0 +1,29 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
+
+namespace Content.Shared.Language;
+
+[RegisterComponent, AutoGenerateComponentState]
+public sealed partial class LanguageSpeakerComponent : Component
+{
+ ///
+ /// The current language the entity may use to speak.
+ /// Other listeners will hear the entity speak in this language.
+ ///
+ [ViewVariables(VVAccess.ReadWrite)]
+ [AutoNetworkedField]
+ public string CurrentLanguage = default!;
+
+ ///
+ /// List of languages this entity can speak.
+ ///
+ [ViewVariables]
+ [DataField("speaks", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)]
+ public List SpokenLanguages = new();
+
+ ///
+ /// List of languages this entity can understand.
+ ///
+ [ViewVariables]
+ [DataField("understands", customTypeSerializer: typeof(PrototypeIdListSerializer), required: true)]
+ public List UnderstoodLanguages = new();
+}
diff --git a/Content.Shared/Language/Components/TranslatorImplanterComponent.cs b/Content.Shared/Language/Components/TranslatorImplanterComponent.cs
new file mode 100644
index 0000000000..401e8a8b8a
--- /dev/null
+++ b/Content.Shared/Language/Components/TranslatorImplanterComponent.cs
@@ -0,0 +1,35 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
+
+namespace Content.Shared.Language.Components;
+
+///
+/// An item that, when used on a mob, adds an intrinsic translator to it.
+///
+[RegisterComponent]
+public sealed partial class TranslatorImplanterComponent : Component
+{
+ [DataField("spoken", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List SpokenLanguages = new();
+
+ [DataField("understood", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List UnderstoodLanguages = new();
+
+ ///
+ /// The list of languages the mob must understand in order for this translator to have effect.
+ /// Knowing one language is enough.
+ ///
+ [DataField("requires", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List RequiredLanguages = new();
+
+ ///
+ /// If true, only allows to use this implanter on mobs.
+ ///
+ [DataField]
+ public bool MobsOnly = true;
+
+ ///
+ /// Whether this implant has been used already.
+ ///
+ [DataField]
+ public bool Used = false;
+}
diff --git a/Content.Shared/Language/Components/Translators/BaseTranslatorComponent.cs b/Content.Shared/Language/Components/Translators/BaseTranslatorComponent.cs
new file mode 100644
index 0000000000..a66c9be082
--- /dev/null
+++ b/Content.Shared/Language/Components/Translators/BaseTranslatorComponent.cs
@@ -0,0 +1,47 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype.List;
+
+namespace Content.Shared.Language.Components.Translators;
+
+public abstract partial class BaseTranslatorComponent : Component
+{
+ // TODO may need to be removed completely, it's a part of legacy code that never ended up being used.
+ ///
+ /// The language this translator changes the speaker's language to when they don't specify one.
+ /// If null, does not modify the default language.
+ ///
+ [DataField("defaultLanguage")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public string? DefaultLanguageOverride = null;
+
+ ///
+ /// The list of additional languages this translator allows the wielder to speak.
+ ///
+ [DataField("spoken", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List SpokenLanguages = new();
+
+ ///
+ /// The list of additional languages this translator allows the wielder to understand.
+ ///
+ [DataField("understood", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List UnderstoodLanguages = new();
+
+ ///
+ /// The languages the wielding MUST know in order for this translator to have effect.
+ /// The field [RequiresAllLanguages] indicates whether all of them are required, or just one.
+ ///
+ [DataField("requires", customTypeSerializer: typeof(PrototypeIdListSerializer))]
+ public List RequiredLanguages = new();
+
+ ///
+ /// If true, the wielder must understand all languages in [RequiredLanguages] to speak [SpokenLanguages],
+ /// and understand all languages in [RequiredLanguages] to understand [UnderstoodLanguages].
+ ///
+ /// Otherwise, at least one language must be known (or the list must be empty).
+ ///
+ [DataField("requiresAll")]
+ [ViewVariables(VVAccess.ReadWrite)]
+ public bool RequiresAllLanguages = false;
+
+ [DataField("enabled"), ViewVariables(VVAccess.ReadWrite)]
+ public bool Enabled = true;
+}
diff --git a/Content.Shared/Language/Components/Translators/HandheldTranslatorComponent.cs b/Content.Shared/Language/Components/Translators/HandheldTranslatorComponent.cs
new file mode 100644
index 0000000000..f900603f01
--- /dev/null
+++ b/Content.Shared/Language/Components/Translators/HandheldTranslatorComponent.cs
@@ -0,0 +1,15 @@
+namespace Content.Shared.Language.Components.Translators;
+
+///
+/// A translator that must be held in a hand or a pocket of an entity in order ot have effect.
+///
+[RegisterComponent]
+public sealed partial class HandheldTranslatorComponent : Translators.BaseTranslatorComponent
+{
+ ///
+ /// Whether or not interacting with this translator
+ /// toggles it on or off.
+ ///
+ [DataField("toggleOnInteract")]
+ public bool ToggleOnInteract = true;
+}
diff --git a/Content.Shared/Language/Components/Translators/HoldsTranslatorComponent.cs b/Content.Shared/Language/Components/Translators/HoldsTranslatorComponent.cs
new file mode 100644
index 0000000000..caea9b9a94
--- /dev/null
+++ b/Content.Shared/Language/Components/Translators/HoldsTranslatorComponent.cs
@@ -0,0 +1,11 @@
+namespace Content.Shared.Language.Components.Translators;
+
+///
+/// Applied internally to the holder of [HandheldTranslatorComponent].
+/// Do not use directly. Use [HandheldTranslatorComponent] instead.
+///
+[RegisterComponent]
+public sealed partial class HoldsTranslatorComponent : IntrinsicTranslatorComponent
+{
+ public Component? Issuer = null;
+}
diff --git a/Content.Shared/Language/Components/Translators/ImplantedTranslatorComponent.cs b/Content.Shared/Language/Components/Translators/ImplantedTranslatorComponent.cs
new file mode 100644
index 0000000000..d1d72e83ed
--- /dev/null
+++ b/Content.Shared/Language/Components/Translators/ImplantedTranslatorComponent.cs
@@ -0,0 +1,9 @@
+namespace Content.Shared.Language.Components.Translators;
+
+///
+/// Applied to entities who were injected with a translator implant.
+///
+[RegisterComponent]
+public sealed partial class ImplantedTranslatorComponent : IntrinsicTranslatorComponent
+{
+}
diff --git a/Content.Shared/Language/Components/Translators/IntrinsicTranslatorComponent.cs b/Content.Shared/Language/Components/Translators/IntrinsicTranslatorComponent.cs
new file mode 100644
index 0000000000..d8def4ac1d
--- /dev/null
+++ b/Content.Shared/Language/Components/Translators/IntrinsicTranslatorComponent.cs
@@ -0,0 +1,10 @@
+namespace Content.Shared.Language.Components.Translators;
+
+///
+/// A translator attached to an entity that translates its speech.
+/// An example is a translator implant that allows the speaker to speak another language.
+///
+[RegisterComponent, Virtual]
+public partial class IntrinsicTranslatorComponent : Translators.BaseTranslatorComponent
+{
+}
diff --git a/Content.Shared/Language/Components/UniversalLanguageSpeakerComponent.cs b/Content.Shared/Language/Components/UniversalLanguageSpeakerComponent.cs
new file mode 100644
index 0000000000..6f5ad1178b
--- /dev/null
+++ b/Content.Shared/Language/Components/UniversalLanguageSpeakerComponent.cs
@@ -0,0 +1,11 @@
+namespace Content.Shared.Language.Components;
+
+//
+// Signifies that this entity can speak and understand any language.
+// Applies to such entities as ghosts.
+//
+[RegisterComponent]
+public sealed partial class UniversalLanguageSpeakerComponent : Component
+{
+
+}
diff --git a/Content.Shared/Language/Events/LanguagesSetMessage.cs b/Content.Shared/Language/Events/LanguagesSetMessage.cs
new file mode 100644
index 0000000000..f7a78210aa
--- /dev/null
+++ b/Content.Shared/Language/Events/LanguagesSetMessage.cs
@@ -0,0 +1,13 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Language.Events;
+
+///
+/// Sent from the client to the server when it needs to want to set his currentLangauge.
+/// Yeah im using this instead of ExecuteCommand... Better right?
+///
+[Serializable, NetSerializable]
+public sealed class LanguagesSetMessage(string currentLanguage) : EntityEventArgs
+{
+ public string CurrentLanguage = currentLanguage;
+}
diff --git a/Content.Shared/Language/Events/LanguagesUpdateEvent.cs b/Content.Shared/Language/Events/LanguagesUpdateEvent.cs
new file mode 100644
index 0000000000..90ce2f4446
--- /dev/null
+++ b/Content.Shared/Language/Events/LanguagesUpdateEvent.cs
@@ -0,0 +1,8 @@
+namespace Content.Shared.Language.Events;
+
+///
+/// Raised on an entity when its list of languages changes.
+///
+public sealed class LanguagesUpdateEvent : EntityEventArgs
+{
+}
diff --git a/Content.Shared/Language/Events/LanguagesUpdatedMessage.cs b/Content.Shared/Language/Events/LanguagesUpdatedMessage.cs
new file mode 100644
index 0000000000..563f036df6
--- /dev/null
+++ b/Content.Shared/Language/Events/LanguagesUpdatedMessage.cs
@@ -0,0 +1,15 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Language.Events;
+
+///
+/// Sent to the client when its list of languages changes.
+/// The client should in turn update its HUD and relevant systems.
+///
+[Serializable, NetSerializable]
+public sealed class LanguagesUpdatedMessage(string currentLanguage, List spoken, List understood) : EntityEventArgs
+{
+ public string CurrentLanguage = currentLanguage;
+ public List Spoken = spoken;
+ public List Understood = understood;
+}
diff --git a/Content.Shared/Language/Events/RequestLanguagesMessage.cs b/Content.Shared/Language/Events/RequestLanguagesMessage.cs
new file mode 100644
index 0000000000..aead1f4cd1
--- /dev/null
+++ b/Content.Shared/Language/Events/RequestLanguagesMessage.cs
@@ -0,0 +1,10 @@
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Language.Events;
+
+///
+/// Sent from the client to the server when it needs to learn the list of languages its entity knows.
+/// This event should always be followed by a , unless the client doesn't have an entity.
+///
+[Serializable, NetSerializable]
+public sealed class RequestLanguagesMessage : EntityEventArgs;
diff --git a/Content.Shared/Language/LanguagePrototype.cs b/Content.Shared/Language/LanguagePrototype.cs
new file mode 100644
index 0000000000..801ab8a393
--- /dev/null
+++ b/Content.Shared/Language/LanguagePrototype.cs
@@ -0,0 +1,37 @@
+using System.Runtime.CompilerServices;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared.Language;
+
+[Prototype("language")]
+public sealed class LanguagePrototype : IPrototype
+{
+ [IdDataField]
+ public string ID { get; private set; } = default!;
+
+ ///
+ /// If true, obfuscated phrases of creatures speaking this language will have their syllables replaced with "replacement" syllables.
+ /// Otherwise entire sentences will be replaced.
+ ///
+ [DataField(required: true)]
+ public bool ObfuscateSyllables;
+
+ ///
+ /// Lists all syllables that are used to obfuscate a message a listener cannot understand if obfuscateSyllables is true.
+ /// Otherwise uses all possible phrases the creature can make when trying to say anything.
+ ///
+ [DataField(required: true)]
+ public List Replacement = [];
+
+ #region utility
+ ///
+ /// The in-world name of this language, localized.
+ ///
+ public string Name => Loc.GetString($"language-{ID}-name");
+
+ ///
+ /// The in-world description of this language, localized.
+ ///
+ public string Description => Loc.GetString($"language-{ID}-description");
+ #endregion utility
+}
diff --git a/Content.Shared/Language/Systems/SharedLanguageSystem.cs b/Content.Shared/Language/Systems/SharedLanguageSystem.cs
new file mode 100644
index 0000000000..e2eeb8bb49
--- /dev/null
+++ b/Content.Shared/Language/Systems/SharedLanguageSystem.cs
@@ -0,0 +1,39 @@
+using Content.Shared.Actions;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Shared.Language.Systems;
+
+public abstract class SharedLanguageSystem : EntitySystem
+{
+ ///
+ /// The language used as a fallback in cases where an entity suddenly becomes a language speaker (e.g. the usage of make-sentient)
+ ///
+ [ValidatePrototypeId]
+ public static readonly string FallbackLanguagePrototype = "GalacticCommon";
+
+ ///
+ /// The language whose speakers are assumed to understand and speak every language. Should never be added directly.
+ ///
+ [ValidatePrototypeId]
+ public static readonly string UniversalPrototype = "Universal";
+
+ ///
+ /// A cached instance of
+ ///
+ public static LanguagePrototype Universal { get; private set; } = default!;
+
+ [Dependency] protected readonly IPrototypeManager _prototype = default!;
+ [Dependency] protected readonly IRobustRandom _random = default!;
+
+ public override void Initialize()
+ {
+ Universal = _prototype.Index("Universal");
+ }
+
+ public LanguagePrototype? GetLanguagePrototype(string id)
+ {
+ _prototype.TryIndex(id, out var proto);
+ return proto;
+ }
+}
diff --git a/Content.Shared/Language/Systems/SharedTranslatorImplanterSystem.cs b/Content.Shared/Language/Systems/SharedTranslatorImplanterSystem.cs
new file mode 100644
index 0000000000..a13225378c
--- /dev/null
+++ b/Content.Shared/Language/Systems/SharedTranslatorImplanterSystem.cs
@@ -0,0 +1,36 @@
+using Content.Shared.Examine;
+using Content.Shared.Implants.Components;
+using Content.Shared.Language.Components;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Language.Systems;
+
+public abstract class SharedTranslatorImplanterSystem : EntitySystem
+{
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ private void OnExamined(EntityUid uid, TranslatorImplanterComponent component, ExaminedEvent args)
+ {
+ if (!args.IsInDetailsRange)
+ return;
+
+ var text = !component.Used
+ ? Loc.GetString("translator-implanter-ready")
+ : Loc.GetString("translator-implanter-used");
+
+ args.PushText(text);
+ }
+
+ protected void OnAppearanceChange(EntityUid implanter, TranslatorImplanterComponent component)
+ {
+ var used = component.Used;
+ _appearance.SetData(implanter, ImplanterVisuals.Full, !used);
+ }
+}
diff --git a/Content.Shared/Language/Systems/SharedTranslatorSystem.cs b/Content.Shared/Language/Systems/SharedTranslatorSystem.cs
new file mode 100644
index 0000000000..08a016efa9
--- /dev/null
+++ b/Content.Shared/Language/Systems/SharedTranslatorSystem.cs
@@ -0,0 +1,34 @@
+using Content.Shared.Examine;
+using Content.Shared.Toggleable;
+using Content.Shared.Language.Components.Translators;
+
+namespace Content.Shared.Language.Systems;
+
+public abstract class SharedTranslatorSystem : EntitySystem
+{
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnExamined);
+ }
+
+ private void OnExamined(EntityUid uid, HandheldTranslatorComponent component, ExaminedEvent args)
+ {
+ var state = Loc.GetString(component.Enabled
+ ? "translator-enabled"
+ : "translator-disabled");
+
+ args.PushMarkup(state);
+ }
+
+ protected void OnAppearanceChange(EntityUid translator, HandheldTranslatorComponent? comp = null)
+ {
+ if (comp == null && !TryComp(translator, out comp))
+ return;
+
+ _appearance.SetData(translator, ToggleVisuals.Toggled, comp.Enabled);
+ }
+}
diff --git a/Resources/Locale/en-US/language/commands.ftl b/Resources/Locale/en-US/language/commands.ftl
new file mode 100644
index 0000000000..32fa5415b8
--- /dev/null
+++ b/Resources/Locale/en-US/language/commands.ftl
@@ -0,0 +1,8 @@
+command-list-langs-desc = List languages your current entity can speak at the current moment.
+command-list-langs-help = Usage: {$command}
+
+command-saylang-desc = Send a message in a specific language.
+command-saylang-help = Usage: {$command} . Example: {$command} GalacticCommon "Hello World!"
+
+command-language-select-desc = Select the currently spoken language of your entity.
+command-language-select-help = Usage: {$command} . Example: {$command} GalacticCommon
diff --git a/Resources/Locale/en-US/language/language-menu.ftl b/Resources/Locale/en-US/language/language-menu.ftl
new file mode 100644
index 0000000000..83687d0f1a
--- /dev/null
+++ b/Resources/Locale/en-US/language/language-menu.ftl
@@ -0,0 +1,4 @@
+language-menu-window-title = Language Menu
+language-menu-current-language = Current Language: {$language}
+language-menu-description-header = Description
+ui-options-function-open-language-menu = Open language Menu
diff --git a/Resources/Locale/en-US/language/languages.ftl b/Resources/Locale/en-US/language/languages.ftl
new file mode 100644
index 0000000000..69c5d0a4a7
--- /dev/null
+++ b/Resources/Locale/en-US/language/languages.ftl
@@ -0,0 +1,71 @@
+language-Universal-name = Universal
+language-Universal-description = What are you?
+
+language-GalacticCommon-name = Galactic common
+language-GalacticCommon-description = The standard Galatic language, most commonly used for inter-species communications and legal work.
+
+language-Bubblish-name = Bubblish
+language-Bubblish-description = The language of Slimes. Being a mixture of bubbling noises and pops it's very difficult to speak for humans without the use of mechanical aids.
+
+language-RootSpeak-name = Rootspeak
+language-RootSpeak-description = The strange whistling-style language spoken by the Diona.
+
+language-Nekomimetic-name = Nekomimetic
+language-Nekomimetic-description = To the casual observer, this language is an incomprehensible mess of broken Japanese. To the felinids, it's somehow comprehensible.
+
+language-Draconic-name = Draconic
+language-Draconic-description = The common language of lizard-people, composed of sibilant hisses and rattles.
+
+language-SolCommon-name = Sol common
+language-SolCommon-description = The language common to species from the Sol System.
+
+language-Canilunzt-name = Canilunzt
+language-Canilunzt-description = The guttural language spoken and utilized by the inhabitants of the Vazzend system, composed of growls, barks, yaps, and heavy utilization of ears and tail movements. Vulpkanin speak this language with ease.
+
+language-Moffic-name = Moffic
+language-Moffic-description = The language of the mothpeople borders on complete unintelligibility.
+
+language-RobotTalk-name = RobotTalk
+language-RobotTalk-description = A language consisting of harsh binary chirps, whistles, hisses, and whines. Organic tongues cannot speak it without aid from special translators.
+
+language-Cat-name = Cat
+language-Cat-description = Meow
+
+language-Dog-name = Dog
+language-Dog-description = Bark!
+
+language-Fox-name = Fox
+language-Fox-description = Yeeps!
+
+language-Xeno-name = Xeno
+language-Xeno-description = Sssss!
+
+language-Monkey-name = Monkey
+language-Monkey-description = oooook!
+
+language-Mouse-name = Mouse
+language-Mouse-description = Squeeek!
+
+language-Chicken-name = Chicken
+language-Chicken-description = Coot!
+
+language-Duck-name = Duck
+language-Duck-description = Quack!
+
+language-Cow-name = Cow
+language-Cow-description = Moooo!
+
+language-Sheep-name = Sheep
+language-Sheep-description = Baaah!
+
+language-Kangaroo-name = Kangaroo
+language-Kangaroo-description = Chuu!
+
+language-Pig-name = Pig
+language-Pig-description = Oink!
+
+language-Crab-name = Crab
+language-Crab-description = Click!
+
+language-Kobold-name = Kobold
+language-Kobold-description = Hiss!
diff --git a/Resources/Locale/en-US/language/technologies.ftl b/Resources/Locale/en-US/language/technologies.ftl
new file mode 100644
index 0000000000..901a48061c
--- /dev/null
+++ b/Resources/Locale/en-US/language/technologies.ftl
@@ -0,0 +1,2 @@
+research-technology-basic-translation = Basic Translation
+research-technology-advanced-translation = Advanced Translation
diff --git a/Resources/Locale/en-US/language/translator.ftl b/Resources/Locale/en-US/language/translator.ftl
new file mode 100644
index 0000000000..b2a1e9b2b8
--- /dev/null
+++ b/Resources/Locale/en-US/language/translator.ftl
@@ -0,0 +1,8 @@
+translator-component-shutoff = The {$translator} shuts off.
+translator-component-turnon = The {$translator} turns on.
+translator-enabled = It appears to be active.
+translator-disabled = It appears to be disabled.
+translator-implanter-refuse = The {$implanter} has no effect on {$target}.
+translator-implanter-success = The {$implanter} successfully injected {$target}.
+translator-implanter-ready = This implanter appears to be ready to use.
+translator-implanter-used = This implanter seems empty.
diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml
index 92615131f0..e932974a0f 100644
--- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml
@@ -66,6 +66,11 @@
- type: Tag
tags:
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Fox
+ understands:
+ - Fox
- type: entity
name: security dog
@@ -154,8 +159,6 @@
spawned:
- id: FoodMeat
amount: 2
- - type: ReplacementAccent
- accent: dog
- type: InteractionPopup
successChance: 0.5
interactSuccessString: petting-success-dog
@@ -176,3 +179,9 @@
tags:
- DoorBumpOpener
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Dog
+ understands:
+ - Dog
+ - GalacticCommon
diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml
index 771da36719..fa51b99325 100644
--- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml
@@ -95,6 +95,11 @@
factions:
- PsionicInterloper
- NanoTrasen
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ understands:
+ - GalacticCommon
- type: GhostTakeoverAvailable
- type: GhostRole
makeSentient: true
diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml
index 96950317c1..c2ae33ec0b 100644
--- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml
@@ -96,8 +96,12 @@
spawned:
- id: FoodMeat
amount: 1
- - type: ReplacementAccent
- accent: mouse
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - Mouse
+ - GalacticCommon
- type: Tag
tags:
- VimPilot
@@ -163,4 +167,4 @@
interactFailureString: petting-failure-nukie-mouse
interactSuccessSpawn: EffectHearts
interactSuccessSound:
- path: /Audio/Animals/mouse_squeak.ogg
\ No newline at end of file
+ path: /Audio/Animals/mouse_squeak.ogg
diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml
index a4498299c9..18437e074d 100644
--- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml
@@ -122,12 +122,19 @@
- type: MovementSpeedModifier
baseWalkSpeed: 2.5
baseSprintSpeed: 5.0
- - type: Inventory
+ - type: Inventory
speciesId: harpy
templateId: digitigrade
- type: HarpyVisuals
- type: UltraVision
-
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - SolCommon
+ understands:
+ - GalacticCommon
+ - SolCommon
+
- type: entity
save: false
name: Urist McHands
@@ -138,7 +145,7 @@
components:
- type: HumanoidAppearance
species: Harpy
- - type: Inventory
+ - type: Inventory
speciesId: harpy
- type: Sprite
scale: 0.9, 0.9
diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml
index 4a187d51b3..52853d696a 100644
--- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml
+++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml
@@ -97,6 +97,13 @@
Female: FemaleVulpkanin
Unsexed: MaleVulpkanin
- type: DogVision
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Canilunzt
+ understands:
+ - GalacticCommon
+ - Canilunzt
- type: entity
save: false
diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
index dec46df0b5..0645e451af 100644
--- a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
+++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml
@@ -213,6 +213,13 @@
visMask:
- PsionicInvisibility
- Normal
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - RobotTalk
+ understands:
+ - GalacticCommon
+ - RobotTalk
- type: entity
id: BaseBorgChassisNT
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
index 378b3f8a9d..369544fdc1 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml
@@ -48,8 +48,11 @@
flavorKind: station-event-random-sentience-flavor-organic
- type: Bloodstream
bloodMaxVolume: 50
- - type: ReplacementAccent
- accent: mouse
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - Mouse
- type: MeleeWeapon
soundHit:
path: /Audio/Effects/bite.ogg
@@ -229,8 +232,11 @@
- type: EggLayer
eggSpawn:
- id: FoodEgg
- - type: ReplacementAccent
- accent: chicken
+ - type: LanguageSpeaker
+ speaks:
+ - Chicken
+ understands:
+ - Chicken
- type: SentienceTarget
flavorKind: station-event-random-sentience-flavor-organic
- type: NpcFactionMember
@@ -504,8 +510,11 @@
prob: 0.5
- type: Extractable
grindableSolutionName: food
- - type: ReplacementAccent
- accent: mothroach
+ - type: LanguageSpeaker
+ speaks:
+ - Moffic
+ understands:
+ - Moffic
- type: ZombieAccentOverride
accent: zombieMoth
- type: Vocal
@@ -601,8 +610,11 @@
- type: EggLayer
eggSpawn:
- id: FoodEgg
- - type: ReplacementAccent
- accent: duck
+ - type: LanguageSpeaker
+ speaks:
+ - Duck
+ understands:
+ - Duck
- type: SentienceTarget
flavorKind: station-event-random-sentience-flavor-organic
- type: NpcFactionMember
@@ -839,8 +851,11 @@
interactSuccessSpawn: EffectHearts
interactSuccessSound:
path: /Audio/Voice/Arachnid/arachnid_chitter.ogg
- - type: ReplacementAccent
- accent: crab
+ - type: LanguageSpeaker
+ speaks:
+ - Crab
+ understands:
+ - Crab
- type: Bloodstream
bloodMaxVolume: 50
bloodReagent: CopperBlood
@@ -1076,8 +1091,11 @@
- type: Inventory
speciesId: kangaroo
templateId: kangaroo
- - type: ReplacementAccent
- accent: kangaroo
+ - type: LanguageSpeaker
+ speaks:
+ - Kangaroo
+ understands:
+ - Kangaroo
- type: InventorySlots
- type: Strippable
- type: Butcherable
@@ -1266,7 +1284,12 @@
- type: Speech
speechSounds: Monkey
speechVerb: Monkey
- - type: MonkeyAccent
+ - type: LanguageSpeaker
+ speaks:
+ - Monkey
+ understands:
+ - Monkey
+ - Kobold
- type: SentienceTarget
flavorKind: station-event-random-sentience-flavor-primate
- type: AlwaysRevolutionaryConvertible
@@ -1300,7 +1323,13 @@
- type: Speech
speechSounds: Monkey
speechVerb: Monkey
- - type: MonkeyAccent
+ - type: LanguageSpeaker
+ speaks:
+ - Monkey
+ understands:
+ - Monkey
+ - Kobold
+ - GalacticCommon
- type: NpcFactionMember
factions:
- Syndicate
@@ -1339,8 +1368,12 @@
- type: NameIdentifier
group: Kobold
- type: LizardAccent
- - type: ReplacementAccent
- accent: kobold
+ - type: LanguageSpeaker
+ speaks:
+ - Kobold
+ understands:
+ - Kobold
+ - Monkey
- type: Speech
speechSounds: Lizard
speechVerb: Reptilian
@@ -1568,8 +1601,11 @@
spawned:
- id: FoodMeatRat
amount: 1
- - type: ReplacementAccent
- accent: mouse
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - Mouse
- type: Tag
tags:
- Trash
@@ -1894,6 +1930,11 @@
path: /Audio/Animals/parrot_raught.ogg
- type: Bloodstream
bloodMaxVolume: 50
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ understands:
+ - GalacticCommon
- type: entity
name: penguin
@@ -2140,8 +2181,11 @@
- type: MeleeChemicalInjector
transferAmount: 0.75
solution: melee
- - type: ReplacementAccent
- accent: xeno
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
- type: InteractionPopup
successChance: 0.5
interactSuccessString: petting-success-tarantula
@@ -2472,6 +2516,11 @@
- type: Tag
tags:
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Fox
+ understands:
+ - Fox
- type: entity
name: corgi
@@ -2518,8 +2567,11 @@
spawned:
- id: FoodMeat
amount: 2
- - type: ReplacementAccent
- accent: dog
+ - type: LanguageSpeaker
+ speaks:
+ - Dog
+ understands:
+ - Dog
- type: InteractionPopup
interactSuccessString: petting-success-dog
interactFailureString: petting-failure-generic
@@ -2671,8 +2723,11 @@
spawned:
- id: FoodMeat
amount: 3
- - type: ReplacementAccent
- accent: cat
+ - type: LanguageSpeaker
+ speaks:
+ - Cat
+ understands:
+ - Cat
- type: InteractionPopup
successChance: 0.7
interactSuccessString: petting-success-cat
@@ -2739,6 +2794,12 @@
- type: NpcFactionMember
factions:
- Syndicate
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
+ - GalacticCommon
- type: entity
name: space cat
@@ -3034,8 +3095,11 @@
spawned:
- id: FoodMeat
amount: 1
- - type: ReplacementAccent
- accent: mouse
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - Mouse
- type: Tag
tags:
- VimPilot
@@ -3141,8 +3205,11 @@
interactSuccessSpawn: EffectHearts
interactSuccessSound:
path: /Audio/Animals/pig_oink.ogg
- - type: ReplacementAccent
- accent: pig
+ - type: LanguageSpeaker
+ speaks:
+ - Pig
+ understands:
+ - Pig
- type: SentienceTarget
flavorKind: station-event-random-sentience-flavor-organic
- type: NpcFactionMember
@@ -3228,6 +3295,12 @@
reformTime: 10
popupText: diona-reform-attempt
reformPrototype: MobDionaReformed
+ - type: LanguageSpeaker
+ speaks:
+ - RootSpeak
+ understands:
+ - GalacticCommon
+ - RootSpeak
- type: entity
parent: MobDionaNymph
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml b/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml
index 39e68b63a7..3bcf8e7a16 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml
@@ -15,8 +15,11 @@
- type: Sprite
sprite: Mobs/Aliens/Argocyte/argocyte_common.rsi
- type: SolutionContainerManager
- - type: ReplacementAccent
- accent: xeno
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
- type: Bloodstream
bloodReagent: FerrochromicAcid
bloodMaxVolume: 75 #we don't want the map to become pools of blood
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
index 9981bb8bd9..8ca1b2d2f0 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml
@@ -36,6 +36,12 @@
- VimPilot
- type: StealTarget
stealGroup: AnimalIan
+ - type: LanguageSpeaker
+ speaks:
+ - Dog
+ understands:
+ - GalacticCommon
+ - Dog
- type: entity
name: Old Ian
@@ -121,6 +127,12 @@
tags:
- CannotSuicide
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Cat
+ understands:
+ - GalacticCommon
+ - Cat
- type: entity
name: Exception
@@ -139,6 +151,12 @@
tags:
- CannotSuicide
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Cat
+ understands:
+ - GalacticCommon
+ - Cat
- type: entity
name: Floppa
@@ -288,8 +306,12 @@
spawned:
- id: FoodMeat
amount: 2
- - type: ReplacementAccent
- accent: dog
+ - type: LanguageSpeaker
+ speaks:
+ - Dog
+ understands:
+ - GalacticCommon
+ - Dog
- type: InteractionPopup
successChance: 0.5
interactSuccessString: petting-success-dog
@@ -387,8 +409,12 @@
spawned:
- id: FoodMeat
amount: 3
- - type: ReplacementAccent
- accent: dog
+ - type: LanguageSpeaker
+ speaks:
+ - Dog
+ understands:
+ - GalacticCommon
+ - Dog
- type: InteractionPopup
successChance: 0.7
interactSuccessString: petting-success-dog
@@ -546,6 +572,12 @@
- VimPilot
- type: StealTarget
stealGroup: AnimalRenault
+ - type: LanguageSpeaker
+ speaks:
+ - Fox
+ understands:
+ - GalacticCommon
+ - Fox
- type: entity
name: Hamlet
@@ -593,6 +625,12 @@
- CannotSuicide
- Hamster
- VimPilot
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - GalacticCommon
+ - Mouse
- type: entity
name: Shiva
@@ -765,6 +803,12 @@
attributes:
proper: true
gender: female
+ - type: LanguageSpeaker
+ speaks:
+ - Bubblish
+ understands:
+ - GalacticCommon
+ - Bubblish
- type: entity
name: Pun Pun
@@ -799,6 +843,13 @@
attributes:
proper: true
gender: male
+ - type: LanguageSpeaker
+ speaks:
+ - Monkey
+ understands:
+ - GalacticCommon
+ - Monkey
+ - Kobold
- type: entity
name: Tropico
@@ -826,3 +877,9 @@
# - type: AlwaysRevolutionaryConvertible
- type: StealTarget
stealGroup: AnimalTropico
+ - type: LanguageSpeaker
+ speaks:
+ - Crab
+ understands:
+ - GalacticCommon
+ - Crab
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml b/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml
index 89a6f16e52..50fe3b6765 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml
@@ -119,6 +119,13 @@
attributes:
gender: male
- type: PotentialPsionic # Nyano
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Mouse
+ understands:
+ - GalacticCommon
+ - Mouse
- type: entity
id: MobRatKingBuff
@@ -289,6 +296,12 @@
- type: Food
- type: Item
size: Tiny # Delta V - Make them eatable and pickable.
+ - type: LanguageSpeaker
+ speaks:
+ - Mouse
+ understands:
+ - GalacticCommon
+ - Mouse
- type: weightedRandomEntity
id: RatKingLoot
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml b/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml
index ec1ed3a58f..1316aefc50 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml
@@ -97,6 +97,7 @@
- RevenantTheme
- type: Speech
speechVerb: Ghost
+ - type: UniversalLanguageSpeaker
- type: Tag
tags:
- NoPaint
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml b/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml
index f08fe36544..9559ae3a0c 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml
@@ -36,7 +36,7 @@
speedModifierThresholds:
60: 0.7
80: 0.5
-
+
- type: entity
name: shadow cat
parent: BaseShadowMob
@@ -50,8 +50,11 @@
- map: ["enum.DamageStateVisualLayers.Base"]
state: cat
- type: Physics
- - type: ReplacementAccent
- accent: cat
+ - type: LanguageSpeaker
+ speaks:
+ - Cat
+ understands:
+ - Cat
- type: InteractionPopup
successChance: 0.01 # you cant pet shadow cat... almost
interactSuccessString: petting-success-cat
@@ -64,4 +67,4 @@
gender: epicene
- type: Tag
tags:
- - VimPilot
\ No newline at end of file
+ - VimPilot
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
index 42b7ff9e21..e3166c15f6 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml
@@ -107,6 +107,13 @@
- type: TypingIndicator
proto: robot
- type: ZombieImmune
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - RobotTalk
+ understands:
+ - GalacticCommon
+ - RobotTalk
- type: entity
parent: MobSiliconBase
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml b/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml
index c64479369a..901bf149cb 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml
@@ -111,8 +111,11 @@
successChance: 0.5
interactSuccessString: petting-success-slimes
interactFailureString: petting-failure-generic
- - type: ReplacementAccent
- accent: slimes
+ - type: LanguageSpeaker
+ speaks:
+ - Bubblish
+ understands:
+ - Bubblish
- type: GhostTakeoverAvailable
- type: GhostRole
makeSentient: true
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/space.yml b/Resources/Prototypes/Entities/Mobs/NPCs/space.yml
index 0a294805cf..9ea2d784db 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/space.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/space.yml
@@ -165,8 +165,11 @@
- type: FootstepModifier
footstepSoundCollection:
collection: FootstepBounce
- - type: ReplacementAccent
- accent: kangaroo
+ - type: LanguageSpeaker
+ speaks:
+ - Kangaroo
+ understands:
+ - Kangaroo
- type: InventorySlots
- type: Strippable
- type: UserInterface
@@ -248,8 +251,11 @@
- type: MeleeChemicalInjector
solution: melee
transferAmount: 4
- - type: ReplacementAccent
- accent: xeno
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
- type: InteractionPopup
successChance: 0.20
interactSuccessString: petting-success-tarantula
@@ -351,8 +357,11 @@
- type: MeleeChemicalInjector
solution: melee
transferAmount: 6
- - type: ReplacementAccent
- accent: xeno
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
- type: InteractionPopup
successChance: 0.2
interactSuccessString: petting-success-snake
@@ -373,4 +382,4 @@
parent: MobCobraSpace
suffix: "Salvage Ruleset"
components:
- - type: SalvageMobRestrictions
\ No newline at end of file
+ - type: SalvageMobRestrictions
diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml
index d0ac6fc026..26553a2f1f 100644
--- a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml
+++ b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml
@@ -125,6 +125,11 @@
chance: -2
- type: Psionic #Nyano - Summary: makes psionic by default.
removable: false
+ - type: LanguageSpeaker
+ speaks:
+ - Xeno
+ understands:
+ - Xeno
- type: entity
name: Praetorian
@@ -234,6 +239,13 @@
- type: Tag
tags:
- CannotSuicide
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Xeno
+ understands:
+ - GalacticCommon
+ - Xeno
- type: entity
name: Ravager
diff --git a/Resources/Prototypes/Entities/Mobs/Player/observer.yml b/Resources/Prototypes/Entities/Mobs/Player/observer.yml
index 8f3e6c1346..0086be81d9 100644
--- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml
+++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml
@@ -53,6 +53,7 @@
- type: Tag
tags:
- BypassInteractionRangeChecks
+ - type: UniversalLanguageSpeaker # Ghosts should understand any language.
- type: entity
id: ActionGhostBoo
diff --git a/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml b/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml
index ad9b37f63e..07deef857c 100644
--- a/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml
+++ b/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml
@@ -7,3 +7,4 @@
- type: MovementSpeedModifier
baseSprintSpeed: 24
baseWalkSpeed: 16
+ - type: UniversalLanguageSpeaker
diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml
index a610e04d6d..67212d416f 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/base.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml
@@ -141,7 +141,7 @@
- Pacified
- StaminaModifier
- PsionicsDisabled #Nyano - Summary: PCs can have psionics disabled.
- - PsionicallyInsulated #Nyano - Summary: PCs can be made insulated from psionic powers.
+ - PsionicallyInsulated #Nyano - Summary: PCs can be made insulated from psionic powers.
- type: Reflect
enabled: false
reflectProb: 0
@@ -218,7 +218,12 @@
- type: MobPrice
price: 1500 # Kidnapping a living person and selling them for cred is a good move.
deathPenalty: 0.01 # However they really ought to be living and intact, otherwise they're worth 100x less.
- - type: CanEscapeInventory # Carrying system from nyanotrasen.
+ - type: CanEscapeInventory # Carrying system from nyanotrasen.
+ - type: LanguageSpeaker # This is here so all with no LanguageSpeaker at least spawn with the default languages.
+ speaks:
+ - GalacticCommon
+ understands:
+ - GalacticCommon
- type: Tag
tags:
- CanPilot
diff --git a/Resources/Prototypes/Entities/Mobs/Species/diona.yml b/Resources/Prototypes/Entities/Mobs/Species/diona.yml
index 3d405c4dd9..5cb3de6f16 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/diona.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/diona.yml
@@ -102,6 +102,13 @@
actionPrototype: DionaGibAction
allowedStates:
- Dead
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - RootSpeak
+ understands:
+ - GalacticCommon
+ - RootSpeak
- type: entity
parent: BaseSpeciesDummy
diff --git a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml
index fe36754b9b..7afc5cddd7 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml
@@ -52,6 +52,13 @@
accent: dwarf
- type: Speech
speechSounds: Bass
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - SolCommon
+ understands:
+ - GalacticCommon
+ - SolCommon
- type: entity
parent: BaseSpeciesDummy
diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml
index 7bf96efe2c..7c3f857c00 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/human.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml
@@ -17,6 +17,13 @@
- id: FoodMeatHuman
amount: 5
- type: PotentialPsionic #Nyano - Summary: makes potentially psionic.
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - SolCommon
+ understands:
+ - GalacticCommon
+ - SolCommon
- type: entity
parent: BaseSpeciesDummy
diff --git a/Resources/Prototypes/Entities/Mobs/Species/moth.yml b/Resources/Prototypes/Entities/Mobs/Species/moth.yml
index 1680dd6cda..39aa0ab8de 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/moth.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/moth.yml
@@ -23,6 +23,13 @@
accent: zombieMoth
- type: Speech
speechVerb: Moth
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Moffic
+ understands:
+ - GalacticCommon
+ - Moffic
- type: TypingIndicator
proto: moth
- type: Butcherable
diff --git a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml
index 09e86b1996..bdea4499ed 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml
@@ -59,6 +59,13 @@
types:
Heat : 1.5 #per second, scales with temperature & other constants
- type: Wagging
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Draconic
+ understands:
+ - GalacticCommon
+ - Draconic
- type: entity
parent: BaseSpeciesDummy
diff --git a/Resources/Prototypes/Entities/Mobs/Species/slime.yml b/Resources/Prototypes/Entities/Mobs/Species/slime.yml
index 481afd06a3..a601010ef9 100644
--- a/Resources/Prototypes/Entities/Mobs/Species/slime.yml
+++ b/Resources/Prototypes/Entities/Mobs/Species/slime.yml
@@ -74,6 +74,13 @@
types:
Asphyxiation: -1.0
maxSaturation: 15
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Bubblish
+ understands:
+ - GalacticCommon
+ - Bubblish
- type: entity
parent: MobHumanDummy
diff --git a/Resources/Prototypes/Entities/Objects/Devices/translator_implants.yml b/Resources/Prototypes/Entities/Objects/Devices/translator_implants.yml
new file mode 100644
index 0000000000..fc947efe9a
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/translator_implants.yml
@@ -0,0 +1,132 @@
+- type: entity
+ abstract: true
+ id: BaseTranslatorImplanter
+ parent: [ BaseItem ]
+ name: basic translator implant
+ description: Translates speech.
+ components:
+ - type: Sprite
+ sprite: Objects/Specific/Medical/implanter.rsi
+ state: implanter0
+ layers:
+ - state: implanter1
+ map: [ "implantFull" ]
+ visible: true
+ - state: implanter0
+ map: [ "implantBroken" ]
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.ImplanterVisuals.Full:
+ implantFull:
+ True: {visible: true}
+ False: {visible: false}
+ implantBroken:
+ True: {visible: false}
+ False: {visible: true}
+
+- type: entity
+ id: BasicGalaticCommonTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: basic Galactic Common translator implant
+ description: An implant giving the ability to understand Galactic Common.
+ components:
+ - type: TranslatorImplanter
+ understood:
+ - GalacticCommon
+
+- type: entity
+ id: AdvancedGalaticCommonTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: advanced Galactic Common translator implant
+ description: An implant giving the ability to understand and speak Galactic Common.
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - GalacticCommon
+ understood:
+ - GalacticCommon
+
+- type: entity
+ id: BubblishTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: Bubblish translator implant
+ description: An implant giving the ability to understand and speak Bubblish.
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - Bubblish
+ understood:
+ - Bubblish
+
+- type: entity
+ id: NekomimeticTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: Nekomimetic translator implant
+ description: An implant giving the ability to understand and speak Nekomimetic. Nya~!
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - Nekomimetic
+ understood:
+ - Nekomimetic
+
+- type: entity
+ id: DraconicTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: Draconic translator implant
+ description: An implant giving the ability to understand and speak Draconic.
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - Draconic
+ understood:
+ - Draconic
+
+- type: entity
+ id: CanilunztTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: Canilunzt translator implant
+ description: An implant giving the ability to understand and speak Canilunzt. Yeeps!
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - Canilunzt
+ understood:
+ - Canilunzt
+
+- type: entity
+ id: SolCommonTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: SolCommon translator implant
+ description: An implant giving the ability to understand and speak SolCommon. Raaagh!
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - SolCommon
+ understood:
+ - SolCommon
+
+- type: entity
+ id: RootSpeakTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: RootSpeak translator implant
+ description: An implant giving the ability to understand and speak RootSpeak.
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - RootSpeak
+ understood:
+ - RootSpeak
+
+- type: entity
+ id: MofficTranslatorImplanter
+ parent: [ BaseTranslatorImplanter ]
+ name: Moffic translator implant
+ description: An implant giving the ability to understand and speak Moffic.
+ components:
+ - type: TranslatorImplanter
+ spoken:
+ - Moffic
+ understood:
+ - Moffic
diff --git a/Resources/Prototypes/Entities/Objects/Devices/translators.yml b/Resources/Prototypes/Entities/Objects/Devices/translators.yml
new file mode 100644
index 0000000000..e5ad824c5d
--- /dev/null
+++ b/Resources/Prototypes/Entities/Objects/Devices/translators.yml
@@ -0,0 +1,205 @@
+- type: entity
+ abstract: true
+ id: TranslatorUnpowered
+ parent: [ BaseItem ]
+ name: translator
+ description: Translates speech.
+ components:
+ - type: Sprite
+ sprite: Objects/Devices/translator.rsi
+ state: icon
+ layers:
+ - state: icon
+ - state: translator
+ shader: unshaded
+ visible: false
+ map: [ "enum.ToggleVisuals.Layer", "enum.PowerDeviceVisualLayers.Powered" ]
+ - type: Appearance
+ - type: GenericVisualizer
+ visuals:
+ enum.ToggleVisuals.Toggled:
+ enum.ToggleVisuals.Layer:
+ True: { visible: true }
+ False: { visible: false }
+ - type: HandheldTranslator
+ enabled: false
+
+- type: entity
+ abstract: true
+ id: Translator
+ parent: [ TranslatorUnpowered, PowerCellSlotMediumItem ]
+ suffix: Powered
+ components:
+ - type: PowerCellDraw
+ drawRate: 1
+
+- type: entity
+ abstract: true
+ id: TranslatorEmpty
+ parent: [ Translator ]
+ suffix: Empty
+ components:
+ - type: ItemSlots
+ slots:
+ cell_slot:
+ name: power-cell-slot-component-slot-name-default
+
+
+- type: entity
+ id: CanilunztTranslator
+ parent: [ TranslatorEmpty ]
+ name: Canilunzt translator
+ description: Translates speech between Canilunzt and Galactic Common.
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Canilunzt
+ understood:
+ - GalacticCommon
+ - Canilunzt
+ requires:
+ - GalacticCommon
+ - Canilunzt
+
+- type: entity
+ id: BubblishTranslator
+ parent: [ TranslatorEmpty ]
+ name: Bubblish translator
+ description: Translates speech between Bubblish and Galactic Common.
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Bubblish
+ understood:
+ - GalacticCommon
+ - Bubblish
+ requires:
+ - GalacticCommon
+ - Bubblish
+
+- type: entity
+ id: NekomimeticTranslator
+ parent: [ TranslatorEmpty ]
+ name: Nekomimetic translator
+ description: Translates speech between Nekomimetic and Galactic Common. Why would you want that?
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Nekomimetic
+ understood:
+ - GalacticCommon
+ - Nekomimetic
+ requires:
+ - GalacticCommon
+ - Nekomimetic
+
+- type: entity
+ id: DraconicTranslator
+ parent: [ TranslatorEmpty ]
+ name: Draconic translator
+ description: Translates speech between Draconic and Galactic Common.
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Draconic
+ understood:
+ - GalacticCommon
+ - Draconic
+ requires:
+ - GalacticCommon
+ - Draconic
+
+- type: entity
+ id: SolCommonTranslator
+ parent: [ TranslatorEmpty ]
+ name: Sol Common translator
+ description: Translates speech between Sol Common and Galactic Common. Like a true Earthman!
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - SolCommon
+ understood:
+ - GalacticCommon
+ - SolCommon
+ requires:
+ - GalacticCommon
+ - SolCommon
+
+- type: entity
+ id: RootSpeakTranslator
+ parent: [ TranslatorEmpty ]
+ name: RootSpeak translator
+ description: Translates speech between RootSpeak and Galactic Common. Like a true plant?
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - RootSpeak
+ understood:
+ - GalacticCommon
+ - RootSpeak
+ requires:
+ - GalacticCommon
+ - RootSpeak
+
+- type: entity
+ id: MofficTranslator
+ parent: [ TranslatorEmpty ]
+ name: Moffic translator
+ description: Translates speech between Moffic and Galactic Common. Like a true moth... or bug?
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Moffic
+ understood:
+ - GalacticCommon
+ - Moffic
+ requires:
+ - GalacticCommon
+ - Moffic
+
+- type: entity
+ id: XenoTranslator
+ parent: [ TranslatorEmpty ]
+ name: Xeno translator
+ description: Translates speech between Xeno and Galactic Common. Not sure if that will help.
+ components:
+ - type: HandheldTranslator
+ spoken:
+ - GalacticCommon
+ - Xeno
+ understood:
+ - GalacticCommon
+ - Xeno
+ requires:
+ - GalacticCommon
+
+- type: entity
+ id: AnimalTranslator
+ parent: [ TranslatorEmpty ]
+ name: Animal translator
+ description: Translates all the cutes noises that animals make into a more understandable form!
+ components:
+ - type: HandheldTranslator
+ understood:
+ - Cat
+ - Dog
+ - Fox
+ - Monkey
+ - Mouse
+ - Chicken
+ - Duck
+ - Cow
+ - Sheep
+ - Kangaroo
+ - Pig
+ - Crab
+ - Kobold
+ requires:
+ - GalacticCommon
diff --git a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
index 011f2a3b64..7300c0b9ec 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml
@@ -341,6 +341,24 @@
- FauxTileAstroSnow
- OreBagOfHolding
- DeviceQuantumSpinInverter
+ - CanilunztTranslator
+ - BubblishTranslator
+ - NekomimeticTranslator
+ - DraconicTranslator
+ - SolCommonTranslator
+ - RootSpeakTranslator
+ - XenoTranslator
+ - BasicGalaticCommonTranslatorImplanter
+ - AdvancedGalaticCommonTranslatorImplanter
+ - BubblishTranslatorImplanter
+ - NekomimeticTranslatorImplanter
+ - DraconicTranslatorImplanter
+ - CanilunztTranslatorImplanter
+ - SolCommonTranslatorImplanter
+ - RootSpeakTranslatorImplanter
+ - AnimalTranslator
+ - MofficTranslatorImplanter
+ - MofficTranslator
- type: EmagLatheRecipes
emagDynamicRecipes:
- ExplosivePayload
diff --git a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
index 64b6b068c7..6efa5a6371 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
@@ -101,6 +101,13 @@
price: 100
- type: Appearance
- type: WiresVisuals
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - RobotTalk
+ understands:
+ - GalacticCommon
+ - RobotTalk
- type: entity
parent: VendingMachine
diff --git a/Resources/Prototypes/Language/languages.yml b/Resources/Prototypes/Language/languages.yml
new file mode 100644
index 0000000000..90bce1baed
--- /dev/null
+++ b/Resources/Prototypes/Language/languages.yml
@@ -0,0 +1,493 @@
+# The universal language, assumed if the entity has a UniversalLanguageSpeakerComponent.
+# Do not use otherwise. Try to use the respective component instead of this language.
+- type: language
+ id: Universal
+ obfuscateSyllables: false
+ replacement:
+ - "*incomprehensible*"
+
+# The common galactic tongue.
+- type: language
+ id: GalacticCommon
+ obfuscateSyllables: true
+ replacement:
+ - Blah
+ - Blah
+ - Blah
+ - dingle-doingle
+ - dingle
+ - dangle
+ - jibber-jabber
+ - jubber
+ - bleh
+ - zippity
+ - zoop
+ - wibble
+ - wobble
+ - wiggle
+ - yada
+ - meh
+ - neh
+ - nah
+ - wah
+
+# Spoken by slimes.
+- type: language
+ id: Bubblish
+ obfuscateSyllables: true
+ replacement:
+ - blob
+ - plop
+ - pop
+ - bop
+ - boop
+
+# Spoken by moths.
+- type: language
+ id: Moffic
+ obfuscateSyllables: true
+ replacement:
+ - år
+ - i
+ - går
+ - sek
+ - mo
+ - ff
+ - ok
+ - gj
+ - ø
+ - gå
+ - la
+ - le
+ - lit
+ - ygg
+ - van
+ - dår
+ - næ
+ - møt
+ - idd
+ - hvo
+ - ja
+ - på
+ - han
+ - så
+ - ån
+ - det
+ - att
+ - nå
+ - gö
+ - bra
+ - int
+ - tyc
+ - om
+ - när
+ - två
+ - må
+ - dag
+ - sjä
+ - vii
+ - vuo
+ - eil
+ - tun
+ - käyt
+ - teh
+ - vä
+ - hei
+ - huo
+ - suo
+ - ää
+ - ten
+ - ja
+ - heu
+ - stu
+ - uhr
+ - kön
+ - we
+ - hön
+
+ # Spoken by dionas.
+- type: language
+ id: RootSpeak
+ obfuscateSyllables: true
+ replacement:
+ - hs
+ - zt
+ - kr
+ - st
+ - sh
+
+# A mess of broken Japanese, spoken by Felinds and Oni
+- type: language
+ id: Nekomimetic
+ obfuscateSyllables: true
+ replacement:
+ - neko
+ - nyan
+ - mimi
+ - moe
+ - mofu
+ - fuwa
+ - kyaa
+ - kawaii
+ - poka
+ - munya
+ - puni
+ - munyu
+ - ufufu
+ - icha
+ - doki
+ - kyun
+ - kusu
+ - nya
+ - nyaa
+ - desu
+ - kis
+ - ama
+ - chuu
+ - baka
+ - hewo
+ - boop
+ - gato
+ - kit
+ - sune
+ - yori
+ - sou
+ - baka
+ - chan
+ - san
+ - kun
+ - mahou
+ - yatta
+ - suki
+ - usagi
+ - domo
+ - ori
+ - uwa
+ - zaazaa
+ - shiku
+ - puru
+ - ira
+ - heto
+ - etto
+
+# Spoken by the Lizard race.
+- type: language
+ id: Draconic
+ obfuscateSyllables: true
+ replacement:
+ - za
+ - az
+ - ze
+ - ez
+ - zi
+ - iz
+ - zo
+ - oz
+ - zu
+ - uz
+ - zs
+ - sz
+ - ha
+ - ah
+ - he
+ - eh
+ - hi
+ - ih
+ - ho
+ - oh
+ - hu
+ - uh
+ - hs
+ - sh
+ - la
+ - al
+ - le
+ - el
+ - li
+ - il
+ - lo
+ - ol
+ - lu
+ - ul
+ - ls
+ - sl
+ - ka
+ - ak
+ - ke
+ - ek
+ - ki
+ - ik
+ - ko
+ - ok
+ - ku
+ - uk
+ - ks
+ - sk
+ - sa
+ - as
+ - se
+ - es
+ - si
+ - is
+ - so
+ - os
+ - su
+ - us
+ - ss
+ - ss
+ - ra
+ - ar
+ - re
+ - er
+ - ri
+ - ir
+ - ro
+ - or
+ - ru
+ - ur
+ - rs
+ - sr
+ - a
+ - a
+ - e
+ - e
+ - i
+ - i
+ - o
+ - o
+ - u
+ - u
+ - s
+ - s
+
+# Spoken by the Vulpkanin race.
+- type: language
+ id: Canilunzt
+ obfuscateSyllables: true
+ replacement:
+ - rur
+ - ya
+ - cen
+ - rawr
+ - bar
+ - kuk
+ - tek
+ - qat
+ - uk
+ - wu
+ - vuh
+ - tah
+ - tch
+ - schz
+ - auch
+ - ist
+ - ein
+ - entch
+ - zwichs
+ - tut
+ - mir
+ - wo
+ - bis
+ - es
+ - vor
+ - nic
+ - gro
+ - lll
+ - enem
+ - zandt
+ - tzch
+ - noch
+ - hel
+ - ischt
+ - far
+ - wa
+ - baram
+ - iereng
+ - tech
+ - lach
+ - sam
+ - mak
+ - lich
+ - gen
+ - or
+ - ag
+ - eck
+ - gec
+ - stag
+ - onn
+ - bin
+ - ket
+ - jarl
+ - vulf
+ - einech
+ - cresthz
+ - azunein
+ - ghzth
+
+# The common language of the Sol system.
+- type: language
+ id: SolCommon
+ obfuscateSyllables: true
+ replacement:
+ - tao
+ - shi
+ - tzu
+ - yi
+ - com
+ - be
+ - is
+ - i
+ - op
+ - vi
+ - ed
+ - lec
+ - mo
+ - cle
+ - te
+ - dis
+ - e
+
+- type: language
+ id: RobotTalk
+ obfuscateSyllables: true
+ replacement:
+ - 0
+ - 1
+ - 01
+ - 10
+ - 001
+ - 100
+ - 011
+ - 110
+ - 101
+ - 010
+
+# Languages spoken by various critters.
+- type: language
+ id: Cat
+ obfuscateSyllables: true
+ replacement:
+ - murr
+ - meow
+ - purr
+ - mrow
+
+- type: language
+ id: Dog
+ obfuscateSyllables: true
+ replacement:
+ - woof
+ - bark
+ - ruff
+ - bork
+ - raff
+ - garr
+
+- type: language
+ id: Fox
+ obfuscateSyllables: true
+ replacement:
+ - bark
+ - gecker
+ - ruff
+ - raff
+ - garr
+
+- type: language
+ id: Xeno
+ obfuscateSyllables: true
+ replacement:
+ - sss
+ - sSs
+ - SSS
+
+- type: language
+ id: Monkey
+ obfuscateSyllables: true
+ replacement:
+ - ok
+ - ook
+ - oook
+ - ooook
+ - oooook
+
+- type: language
+ id: Mouse
+ obfuscateSyllables: true
+ replacement:
+ - Squeak
+ - Piep
+ - Chuu
+ - Eeee
+ - Pip
+ - Fwiep
+ - Heep
+
+- type: language
+ id: Chicken
+ obfuscateSyllables: true
+ replacement:
+ - Coo
+ - Coot
+ - Cooot
+
+- type: language
+ id: Duck
+ obfuscateSyllables: true
+ replacement:
+ - Quack
+ - Quack quack
+
+- type: language
+ id: Cow
+ obfuscateSyllables: true
+ replacement:
+ - Moo
+ - Mooo
+
+- type: language
+ id: Sheep
+ obfuscateSyllables: true
+ replacement:
+ - Ba
+ - Baa
+ - Baaa
+
+- type: language
+ id: Kangaroo
+ obfuscateSyllables: true
+ replacement:
+ - Shreak
+ - Chuu
+
+- type: language
+ id: Pig
+ obfuscateSyllables: true
+ replacement:
+ - Oink
+ - Oink oink
+
+- type: language
+ id: Crab
+ obfuscateSyllables: true
+ replacement:
+ - Click
+ - Click-clack
+ - Clack
+ - Tipi-tap
+ - Clik-tap
+ - Cliliick
+
+- type: language
+ id: Kobold
+ obfuscateSyllables: true
+ replacement:
+ - Yip
+ - Grrar.
+ - Yap
+ - Bip
+ - Screet
+ - Gronk
+ - Hiss
+ - Eeee
+ - Yip
diff --git a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml
index e11f1c4165..8a0e750abd 100644
--- a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml
+++ b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml
@@ -35,6 +35,13 @@
- MobLayer
- type: Stamina
critThreshold: 115
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - Nekomimetic
+ understands:
+ - GalacticCommon
+ - Nekomimetic
- type: entity
save: false
diff --git a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml
index d9b25c5dd1..2184926b95 100644
--- a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml
+++ b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml
@@ -64,6 +64,15 @@
Unsexed: MaleFelinid
- type: Felinid
- type: NoShoesSilentFootsteps
+ - type: LanguageSpeaker
+ speaks:
+ - GalacticCommon
+ - SolCommon
+ - Nekomimetic
+ understands:
+ - GalacticCommon
+ - SolCommon
+ - Nekomimetic
- type: entity
save: false
diff --git a/Resources/Prototypes/Recipes/Lathes/language.yml b/Resources/Prototypes/Recipes/Lathes/language.yml
new file mode 100644
index 0000000000..6871ed5228
--- /dev/null
+++ b/Resources/Prototypes/Recipes/Lathes/language.yml
@@ -0,0 +1,190 @@
+- type: latheRecipe
+ id: CanilunztTranslator
+ result: CanilunztTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: BubblishTranslator
+ result: BubblishTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: NekomimeticTranslator
+ result: NekomimeticTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: DraconicTranslator
+ result: DraconicTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: SolCommonTranslator
+ result: SolCommonTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: RootSpeakTranslator
+ result: RootSpeakTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: MofficTranslator
+ result: MofficTranslator
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 100
+ Plastic: 50
+ Gold: 50
+
+- type: latheRecipe
+ id: BasicGalaticCommonTranslatorImplanter
+ result: BasicGalaticCommonTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: XenoTranslator
+ result: XenoTranslator
+ completetime: 2
+ materials:
+ Steel: 200
+ Plastic: 50
+ Gold: 50
+ Plasma: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: AdvancedGalaticCommonTranslatorImplanter
+ result: AdvancedGalaticCommonTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: BubblishTranslatorImplanter
+ result: BubblishTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: NekomimeticTranslatorImplanter
+ result: NekomimeticTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: DraconicTranslatorImplanter
+ result: DraconicTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: CanilunztTranslatorImplanter
+ result: CanilunztTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: SolCommonTranslatorImplanter
+ result: SolCommonTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: RootSpeakTranslatorImplanter
+ result: RootSpeakTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: MofficTranslatorImplanter
+ result: MofficTranslatorImplanter
+ completetime: 2
+ materials:
+ Steel: 500
+ Glass: 500
+ Plastic: 100
+ Gold: 50
+ Silver: 50
+
+- type: latheRecipe
+ id: AnimalTranslator
+ result: AnimalTranslator
+ completetime: 2
+ materials:
+ Steel: 200
+ Plastic: 50
+ Gold: 50
+ Plasma: 50
+ Silver: 5
diff --git a/Resources/Prototypes/Research/civilianservices.yml b/Resources/Prototypes/Research/civilianservices.yml
index 61f95894ee..acb6a2498d 100644
--- a/Resources/Prototypes/Research/civilianservices.yml
+++ b/Resources/Prototypes/Research/civilianservices.yml
@@ -227,3 +227,43 @@
recipeUnlocks:
- BluespaceBeaker
- SyringeBluespace
+
+- type: technology
+ id: BasicTranslation
+ name: research-technology-basic-translation
+ icon:
+ sprite: Objects/Devices/translator.rsi
+ state: icon
+ discipline: CivilianServices
+ tier: 2
+ cost: 10000
+ recipeUnlocks:
+ - CanilunztTranslator
+ - BubblishTranslator
+ - NekomimeticTranslator
+ - DraconicTranslator
+ - SolCommonTranslator
+ - RootSpeakTranslator
+ - BasicGalaticCommonTranslatorImplanter
+ - MofficTranslator
+
+- type: technology
+ id: AdvancedTranslation
+ name: research-technology-advanced-translation
+ icon:
+ sprite: Objects/Devices/translator.rsi
+ state: icon
+ discipline: CivilianServices
+ tier: 3
+ cost: 15000
+ recipeUnlocks:
+ - XenoTranslator
+ - AdvancedGalaticCommonTranslatorImplanter
+ - BubblishTranslatorImplanter
+ - NekomimeticTranslatorImplanter
+ - DraconicTranslatorImplanter
+ - CanilunztTranslatorImplanter
+ - SolCommonTranslatorImplanter
+ - RootSpeakTranslatorImplanter
+ - AnimalTranslator
+ - MofficTranslatorImplanter
diff --git a/Resources/Textures/Interface/language.png b/Resources/Textures/Interface/language.png
new file mode 100644
index 0000000000..2b39424d12
Binary files /dev/null and b/Resources/Textures/Interface/language.png differ
diff --git a/Resources/Textures/Objects/Devices/translator.rsi/icon.png b/Resources/Textures/Objects/Devices/translator.rsi/icon.png
new file mode 100644
index 0000000000..6871c808cc
Binary files /dev/null and b/Resources/Textures/Objects/Devices/translator.rsi/icon.png differ
diff --git a/Resources/Textures/Objects/Devices/translator.rsi/meta.json b/Resources/Textures/Objects/Devices/translator.rsi/meta.json
new file mode 100644
index 0000000000..0202c0c39c
--- /dev/null
+++ b/Resources/Textures/Objects/Devices/translator.rsi/meta.json
@@ -0,0 +1,17 @@
+{
+ "version": 2,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "baystation12",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "icon"
+ },
+ {
+ "name": "translator"
+ }
+ ]
+}
diff --git a/Resources/Textures/Objects/Devices/translator.rsi/translator.png b/Resources/Textures/Objects/Devices/translator.rsi/translator.png
new file mode 100644
index 0000000000..6c54a0b863
Binary files /dev/null and b/Resources/Textures/Objects/Devices/translator.rsi/translator.png differ
diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml
index 346156159a..24da641a07 100644
--- a/Resources/keybinds.yml
+++ b/Resources/keybinds.yml
@@ -184,6 +184,9 @@ binds:
- function: OpenCharacterMenu
type: State
key: C
+- function: OpenLanguageMenu
+ type: State
+ key: L
- function: TextCursorSelect
# TextCursorSelect HAS to be above ExamineEntity
# So that LineEdit receives it correctly.