diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 21a79df74b..6ff3de6067 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -61,6 +61,7 @@ public static void SetupContexts(IInputContextContainer contexts) human.AddFunction(ContentKeyFunctions.AltUseItemInHand); human.AddFunction(ContentKeyFunctions.OpenCharacterMenu); human.AddFunction(ContentKeyFunctions.OpenEmotesMenu); + 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 d5a5c40599..b2d96bafc6 100644 --- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs +++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs @@ -226,6 +226,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 e314310bc0..6f9545288f 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs +++ b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs @@ -10,6 +10,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; @@ -24,6 +25,7 @@ public sealed class GameTopMenuBarUIController : UIController [Dependency] private readonly SandboxUIController _sandbox = default!; [Dependency] private readonly GuidebookUIController _guidebook = default!; [Dependency] private readonly EmotesUIController _emotes = default!; + [Dependency] private readonly LanguageMenuUIController _language = default!; private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull(); @@ -47,6 +49,7 @@ public void UnloadButtons() _action.UnloadButton(); _sandbox.UnloadButton(); _emotes.UnloadButton(); + _language.UnloadButton(); } public void LoadButtons() @@ -60,5 +63,6 @@ public void LoadButtons() _action.LoadButton(); _sandbox.LoadButton(); _emotes.LoadButton(); + _language.LoadButton(); } } diff --git a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml index dc8972970a..c7cf1b3f3d 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml +++ b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml @@ -73,6 +73,16 @@ HorizontalExpand="True" AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}" /> + (source)) @@ -249,10 +258,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); @@ -416,13 +425,15 @@ 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; - var message = TransformSpeech(source, originalMessage); + // The original message + var message = TransformSpeech(source, FormattedMessage.RemoveMarkup(originalMessage)); if (message.Length == 0) return; @@ -445,18 +456,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. @@ -489,7 +501,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) @@ -499,8 +512,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). @@ -517,40 +528,58 @@ 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 - else if (_examineSystem.InRangeUnOccluded(source, listener, WhisperMuffledRange)) - _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) @@ -597,7 +626,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}"); @@ -624,7 +653,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}"); } @@ -705,15 +740,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))); @@ -823,6 +872,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. /// @@ -869,7 +933,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); @@ -958,7 +1022,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 @@ -966,12 +1031,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/EntityEffects/Effects/MakeSentient.cs b/Content.Server/EntityEffects/Effects/MakeSentient.cs index c487043848..06cf5c19ce 100644 --- a/Content.Server/EntityEffects/Effects/MakeSentient.cs +++ b/Content.Server/EntityEffects/Effects/MakeSentient.cs @@ -1,8 +1,16 @@ +using System.Linq; using Content.Server.Ghost.Roles.Components; using Content.Server.Speech.Components; using Content.Shared.EntityEffects; using Content.Shared.Mind.Components; using Robust.Shared.Prototypes; +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.Shared.Humanoid; //Delta-V - Banning humanoids from becoming ghost roles. +using Content.Shared.Language.Events; namespace Content.Server.EntityEffects.Effects; @@ -22,6 +30,20 @@ public override void Effect(EntityEffectBaseArgs 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) { 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 c977fbc048..33e74e7f5d 100644 --- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -1,6 +1,7 @@ using System.Linq; 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; @@ -27,6 +28,8 @@ public sealed class RadioDeviceSystem : EntitySystem [Dependency] private readonly RadioSystem _radio = default!; [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(); @@ -216,7 +219,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 OnIntercomEncryptionChannelsChanged(Entity ent, ref EncryptionChannelsChangedEvent args) diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index 3ad101e62d..4497f986e6 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(); @@ -48,7 +52,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. } } @@ -56,15 +60,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); } /// @@ -72,8 +84,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, radioSource, 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); @@ -161,10 +168,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 fafa66674e..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, EntityUid RadioSource, 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 79503a7c0b..87667c0e15 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -26,6 +26,7 @@ public static class ContentKeyFunctions public static readonly BoundKeyFunction EscapeContext = "EscapeContext"; public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu"; public static readonly BoundKeyFunction OpenEmotesMenu = "OpenEmotesMenu"; + 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/Entities/Mobs/NPCs/argocyte.yml b/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml index 3b6c4e8ed9..5a5b31b347 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 29e81b092a..831a6f0e45 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 @@ -125,6 +131,12 @@ - VimPilot - type: StealTarget stealGroup: AnimalNamedCat + - type: LanguageSpeaker + speaks: + - Cat + understands: + - GalacticCommon + - Cat - type: entity name: Exception @@ -145,6 +157,12 @@ - VimPilot - type: StealTarget stealGroup: AnimalNamedCat + - type: LanguageSpeaker + speaks: + - Cat + understands: + - GalacticCommon + - Cat - type: entity name: Floppa @@ -284,8 +302,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 @@ -377,8 +399,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 @@ -536,6 +562,12 @@ - VimPilot - type: StealTarget stealGroup: AnimalRenault + - type: LanguageSpeaker + speaks: + - Fox + understands: + - GalacticCommon + - Fox - type: entity name: Hamlet @@ -585,6 +617,12 @@ - Hamster - VimPilot - ChefPilot + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - GalacticCommon + - Mouse - type: entity name: Shiva @@ -753,6 +791,12 @@ attributes: proper: true gender: female + - type: LanguageSpeaker + speaks: + - Bubblish + understands: + - GalacticCommon + - Bubblish - type: entity name: Pun Pun @@ -787,6 +831,13 @@ attributes: proper: true gender: male + - type: LanguageSpeaker + speaks: + - Monkey + understands: + - GalacticCommon + - Monkey + - Kobold - type: entity name: Tropico @@ -814,3 +865,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 f62d8011d9..5ecad647f4 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml @@ -121,6 +121,13 @@ - type: Grammar attributes: gender: male + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Mouse + understands: + - GalacticCommon + - Mouse - type: entity id: MobRatKingBuff @@ -291,6 +298,15 @@ - type: FireVisuals sprite: Mobs/Effects/onfire.rsi normalState: Mouse_burning + - 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 19f43bf550..9fe8afb3b8 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml @@ -75,3 +75,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 eae5114883..859f9aeb85 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml @@ -110,6 +110,13 @@ - type: StepTriggerImmune - type: NoSlip - type: Insulated + - 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 9d8a851551..6892aaba51 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml @@ -122,6 +122,11 @@ components: - 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 91f5e952e9..1891c54dcd 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/space.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/space.yml @@ -165,8 +165,17 @@ - type: FootstepModifier footstepSoundCollection: collection: FootstepBounce - - type: ReplacementAccent - accent: kangaroo + - type: LanguageSpeaker + speaks: + - Kangaroo + understands: + - Kangaroo + - type: InventorySlots + - type: Strippable + - type: UserInterface + interfaces: + - key: enum.StrippingUiKey.Key + type: StrippableBoundUserInterface - type: entity id: MobKangarooSpaceSalvage @@ -242,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 @@ -346,8 +358,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 diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml index 23d3e838e2..2c4b5a991b 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml @@ -119,6 +119,11 @@ molsPerSecondPerUnitMass: 0.0005 - type: Speech speechVerb: LargeMob + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno - type: entity name: praetorian @@ -228,6 +233,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 52994a72c6..0506e10373 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml @@ -59,6 +59,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 ffbc46e94c..f309707132 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 cbe09c29ad..2ddab9a2d4 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -135,6 +135,9 @@ - Flashed - RadiationProtection - Drowsiness + - type: Reflect + enabled: false + reflectProb: 0 - type: Body prototype: Human requiredLegs: 2 @@ -206,6 +209,11 @@ - 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: 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 fdb7ac1e95..2354b17cb5 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/diona.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/diona.yml @@ -109,6 +109,13 @@ 32: sprite: Mobs/Species/Human/displacement.rsi state: jumpsuit-female + - 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 6ce9c80a25..d07a0944cd 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml @@ -63,6 +63,13 @@ 32: sprite: Mobs/Species/Human/displacement.rsi state: jumpsuit-female + - 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 bf357e1f10..7f2ff1bb49 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml @@ -28,6 +28,13 @@ sprite: Mobs/Species/Human/displacement.rsi state: jumpsuit-female + - 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 ad09fd5757..a14eda1e43 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/moth.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/moth.yml @@ -25,6 +25,13 @@ - type: Speech speechVerb: Moth allowedEmotes: ['Chitter', 'Squeak'] + - 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 c023dc1c25..d42c8ebe34 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml @@ -74,6 +74,13 @@ sprite: Mobs/Species/Human/displacement.rsi state: jumpsuit-female + - 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 23d6833839..2bac6c700f 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/slime.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/slime.yml @@ -118,6 +118,13 @@ 32: sprite: Mobs/Species/Human/displacement.rsi state: jumpsuit-female + - 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 c606e26f8f..86f2e3a779 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml @@ -351,6 +351,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: - BoxBeanbag diff --git a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml index 87a673609b..a1ef0e7360 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/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 b990eb6ae4..26b519e1ef 100644 --- a/Resources/Prototypes/Research/civilianservices.yml +++ b/Resources/Prototypes/Research/civilianservices.yml @@ -229,3 +229,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 a6789b46ce..c0fe605ca3 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -190,6 +190,9 @@ binds: - function: OpenEmotesMenu type: State key: Y +- function: OpenLanguageMenu + type: State + key: L - function: TextCursorSelect # TextCursorSelect HAS to be above ExamineEntity # So that LineEdit receives it correctly.