diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 03f4f3f38b7..fa631938100 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -55,6 +55,7 @@ public static void SetupContexts(IInputContextContainer contexts) human.AddFunction(ContentKeyFunctions.UseItemInHand); human.AddFunction(ContentKeyFunctions.AltUseItemInHand); human.AddFunction(ContentKeyFunctions.OpenCharacterMenu); + human.AddFunction(ContentKeyFunctions.OpenLanguageMenu); human.AddFunction(ContentKeyFunctions.ActivateItemInWorld); human.AddFunction(ContentKeyFunctions.ThrowItemInHand); human.AddFunction(ContentKeyFunctions.AltActivateItemInWorld); diff --git a/Content.Client/Language/LanguageMenuWindow.xaml b/Content.Client/Language/LanguageMenuWindow.xaml new file mode 100644 index 00000000000..ff33a6ddf56 --- /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 00000000000..312814aca35 --- /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 00000000000..9714078b2c5 --- /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 00000000000..da19b3decf9 --- /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 f0537079b97..49e8099e0fb 100644 --- a/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs +++ b/Content.Client/Options/UI/Tabs/KeyRebindTab.xaml.cs @@ -212,6 +212,7 @@ void AddCheckBox(string checkBoxName, bool currentState, Action, IOnStateExited +{ + public LanguageMenuWindow? LanguageWindow; + private MenuButton? LanguageButton => UIManager.GetActiveUIWidgetOrNull()?.LanguageButton; + + public override void Initialize() + { + SubscribeNetworkEvent((LanguagesUpdatedMessage message, EntitySessionEventArgs _) => + LanguageWindow?.UpdateState(message.CurrentLanguage, message.Spoken)); + } + + public void OnStateEntered(GameplayState state) + { + DebugTools.Assert(LanguageWindow == null); + + LanguageWindow = UIManager.CreateWindow(); + LayoutContainer.SetAnchorPreset(LanguageWindow, LayoutContainer.LayoutPreset.CenterTop); + + CommandBinds.Builder.Bind(ContentKeyFunctions.OpenLanguageMenu, + InputCmdHandler.FromDelegate(_ => ToggleWindow())).Register(); + } + + public void OnStateExited(GameplayState state) + { + if (LanguageWindow != null) + { + LanguageWindow.Dispose(); + LanguageWindow = null; + } + + CommandBinds.Unregister(); + } + + public void UnloadButton() + { + if (LanguageButton == null) + return; + + LanguageButton.OnPressed -= LanguageButtonPressed; + } + + public void LoadButton() + { + if (LanguageButton == null) + return; + + LanguageButton.OnPressed += LanguageButtonPressed; + + if (LanguageWindow == null) + return; + + LanguageWindow.OnClose += () => LanguageButton.Pressed = false; + LanguageWindow.OnOpen += () => LanguageButton.Pressed = true; + } + + private void LanguageButtonPressed(ButtonEventArgs args) + { + ToggleWindow(); + } + + private void ToggleWindow() + { + if (LanguageWindow == null) + return; + + if (LanguageButton != null) + LanguageButton.SetClickPressed(!LanguageWindow.IsOpen); + + if (LanguageWindow.IsOpen) + LanguageWindow.Close(); + else + LanguageWindow.Open(); + } +} diff --git a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs index 1505db48a79..156fa63884e 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs +++ b/Content.Client/UserInterface/Systems/MenuBar/GameTopMenuBarUIController.cs @@ -9,6 +9,7 @@ using Content.Client.UserInterface.Systems.MenuBar.Widgets; using Content.Client.UserInterface.Systems.Sandbox; using Robust.Client.UserInterface.Controllers; +using Content.Client.UserInterface.Systems.Language; namespace Content.Client.UserInterface.Systems.MenuBar; @@ -22,6 +23,7 @@ public sealed class GameTopMenuBarUIController : UIController [Dependency] private readonly ActionUIController _action = default!; [Dependency] private readonly SandboxUIController _sandbox = default!; [Dependency] private readonly GuidebookUIController _guidebook = default!; + [Dependency] private readonly LanguageMenuUIController _language = default!; private GameTopMenuBar? GameTopMenuBar => UIManager.GetActiveUIWidgetOrNull(); @@ -44,6 +46,7 @@ public void UnloadButtons() _ahelp.UnloadButton(); _action.UnloadButton(); _sandbox.UnloadButton(); + _language.UnloadButton(); } public void LoadButtons() @@ -56,5 +59,6 @@ public void LoadButtons() _ahelp.LoadButton(); _action.LoadButton(); _sandbox.LoadButton(); + _language.LoadButton(); } } diff --git a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml index 3c8cd1d164f..a76943ace85 100644 --- a/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml +++ b/Content.Client/UserInterface/Systems/MenuBar/Widgets/GameTopMenuBar.xaml @@ -63,6 +63,16 @@ HorizontalExpand="True" AppendStyleClass="{x:Static style:StyleBase.ButtonSquare}" /> + (source)) @@ -249,10 +255,10 @@ public void TrySendInGameICMessage( switch (desiredType) { case InGameICChatType.Speak: - SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker); + SendEntitySpeak(source, message, range, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride); break; case InGameICChatType.Whisper: - SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker); + SendEntityWhisper(source, message, range, null, nameOverride, hideLog, ignoreActionBlocker, languageOverride: languageOverride); break; case InGameICChatType.Emote: SendEntityEmote(source, message, range, nameOverride, hideLog: hideLog, ignoreActionBlocker: ignoreActionBlocker); @@ -382,12 +388,14 @@ private void SendEntitySpeak( ChatTransmitRange range, string? nameOverride, bool hideLog = false, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + LanguagePrototype? languageOverride = null ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) return; + // The original message var message = TransformSpeech(source, FormattedMessage.RemoveMarkup(originalMessage)); if (message.Length == 0) @@ -411,18 +419,19 @@ private void SendEntitySpeak( speech = proto; } - name = FormattedMessage.EscapeText(name); + var language = languageOverride ?? _language.GetLanguage(source); - var wrappedMessage = Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message", - ("entityName", name), - ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))), - ("fontType", speech.FontId), - ("fontSize", speech.FontSize), - ("message", FormattedMessage.EscapeText(message))); + name = FormattedMessage.EscapeText(name); + // The chat message wrapped in a "x says y" string + var wrappedMessage = WrapPublicMessage(source, name, message); + // The chat message obfuscated via language obfuscation + var obfuscated = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en")); + // The language-obfuscated message wrapped in a "x says y" string + var wrappedObfuscated = WrapPublicMessage(source, name, obfuscated); - SendInVoiceRange(ChatChannel.Local, message, wrappedMessage, source, range); + SendInVoiceRange(ChatChannel.Local, name, message, wrappedMessage, obfuscated, wrappedObfuscated, source, range, languageOverride: language); - var ev = new EntitySpokeEvent(source, message, null, null); + var ev = new EntitySpokeEvent(source, message, null, false, language); RaiseLocalEvent(source, ev, true); // To avoid logging any messages sent by entities that are not players, like vendors, cloning, etc. @@ -455,7 +464,8 @@ private void SendEntityWhisper( RadioChannelPrototype? channel, string? nameOverride, bool hideLog = false, - bool ignoreActionBlocker = false + bool ignoreActionBlocker = false, + LanguagePrototype? languageOverride = null ) { if (!_actionBlocker.CanSpeak(source) && !ignoreActionBlocker) @@ -465,8 +475,6 @@ private void SendEntityWhisper( if (message.Length == 0) return; - var obfuscatedMessage = ObfuscateMessageReadability(message, 0.2f); - // get the entity's name by visual identity (if no override provided). string nameIdentity = FormattedMessage.EscapeText(nameOverride ?? Identity.Name(source, EntityManager)); // get the entity's name by voice (if no override provided). @@ -483,41 +491,57 @@ private void SendEntityWhisper( } name = FormattedMessage.EscapeText(name); - var wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - - var wrappedobfuscatedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", - ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(obfuscatedMessage))); - - var wrappedUnknownMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", - ("message", FormattedMessage.EscapeText(obfuscatedMessage))); - + var language = languageOverride ?? _language.GetLanguage(source); + var languageObfuscatedMessage = SanitizeInGameICMessage(source, _language.ObfuscateSpeech(message, language), out var emoteStr, true, _configurationManager.GetCVar(CCVars.ChatPunctuation), (!CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Parent.Name == "en") || (CultureInfo.CurrentCulture.IsNeutralCulture && CultureInfo.CurrentCulture.Name == "en")); foreach (var (session, data) in GetRecipients(source, WhisperMuffledRange)) { - EntityUid listener; - - if (session.AttachedEntity is not { Valid: true } playerEntity) + if (session.AttachedEntity is not { Valid: true } listener) continue; - listener = session.AttachedEntity.Value; if (MessageRangeCheck(session, data, range) != MessageRangeCheckResult.Full) continue; // Won't get logged to chat, and ghosts are too far away to see the pop-up, so we just won't send it to them. + var canUnderstandLanguage = _language.CanUnderstand(listener, language); + // How the entity perceives the message depends on whether it can understand its language + var perceivedMessage = canUnderstandLanguage ? message : languageObfuscatedMessage; + + // Result is the intermediate message derived from the perceived one via obfuscation + // Wrapped message is the result wrapped in an "x says y" string + string result, wrappedMessage; if (data.Range <= WhisperClearRange) - _chatManager.ChatMessageToOne(ChatChannel.Whisper, message, wrappedMessage, source, false, session.Channel); - //If listener is too far, they only hear fragments of the message - //Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind - else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) //Shared.Physics.CollisionGroup.Opaque - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedobfuscatedMessage, source, false, session.Channel); - //If listener is too far and has no line of sight, they can't identify the whisperer's identity + { + // Scenario 1: the listener can clearly understand the message + result = perceivedMessage; + wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", name), + ("message", FormattedMessage.EscapeText(result))); + } + else if (_interactionSystem.InRangeUnobstructed(source, listener, WhisperMuffledRange, Shared.Physics.CollisionGroup.Opaque)) + { + // Scenerio 2: if the listener is too far, they only hear fragments of the message + // Collisiongroup.Opaque is not ideal for this use. Preferably, there should be a check specifically with "Can Ent1 see Ent2" in mind + result = ObfuscateMessageReadability(perceivedMessage); + wrappedMessage = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", nameIdentity), ("message", FormattedMessage.EscapeText(result))); + } else - _chatManager.ChatMessageToOne(ChatChannel.Whisper, obfuscatedMessage, wrappedUnknownMessage, source, false, session.Channel); + { + // Scenario 3: If listener is too far and has no line of sight, they can't identify the whisperer's identity + result = ObfuscateMessageReadability(perceivedMessage); + wrappedMessage = Loc.GetString("chat-manager-entity-whisper-unknown-wrap-message", + ("message", FormattedMessage.EscapeText(result))); + } + + _chatManager.ChatMessageToOne(ChatChannel.Whisper, result, wrappedMessage, source, false, session.Channel); } - _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); + var replayWrap = Loc.GetString("chat-manager-entity-whisper-wrap-message", + ("entityName", name), + ("message", FormattedMessage.EscapeText(message))); + _replay.RecordServerMessage(new ChatMessage(ChatChannel.Whisper, message, replayWrap, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); - var ev = new EntitySpokeEvent(source, message, channel, obfuscatedMessage); + var ev = new EntitySpokeEvent(source, message, channel, true, language); RaiseLocalEvent(source, ev, true); if (!hideLog) if (originalMessage == message) @@ -564,7 +588,7 @@ private void SendEntityEmote( if (checkEmote) TryEmoteChatInput(source, action); - SendInVoiceRange(ChatChannel.Emotes, action, wrappedMessage, source, range, author); + SendInVoiceRange(ChatChannel.Emotes, name, action, wrappedMessage, obfuscated: "", obfuscatedWrappedMessage: "", source, range, author); if (!hideLog) if (name != Name(source)) _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Emote from {ToPrettyString(source):user} as {name}: {action}"); @@ -591,7 +615,13 @@ private void SendLOOC(EntityUid source, ICommonSession player, string message, b ("entityName", name), ("message", FormattedMessage.EscapeText(message))); - SendInVoiceRange(ChatChannel.LOOC, message, wrappedMessage, source, hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, player.UserId); + SendInVoiceRange(ChatChannel.LOOC, name, message, wrappedMessage, + obfuscated: string.Empty, + obfuscatedWrappedMessage: string.Empty, // will be skipped anyway + source, + hideChat ? ChatTransmitRange.HideChat : ChatTransmitRange.Normal, + player.UserId, + languageOverride: LanguageSystem.Universal); _adminLogger.Add(LogType.Chat, LogImpact.Low, $"LOOC from {player:Player}: {message}"); } @@ -672,15 +702,29 @@ private MessageRangeCheckResult MessageRangeCheck(ICommonSession session, ICChat /// /// Sends a chat message to the given players in range of the source entity. /// - private void SendInVoiceRange(ChatChannel channel, string message, string wrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null) + private void SendInVoiceRange(ChatChannel channel, string name, string message, string wrappedMessage, string obfuscated, string obfuscatedWrappedMessage, EntityUid source, ChatTransmitRange range, NetUserId? author = null, LanguagePrototype? languageOverride = null) { + var language = languageOverride ?? _language.GetLanguage(source); foreach (var (session, data) in GetRecipients(source, VoiceRange)) { var entRange = MessageRangeCheck(session, data, range); if (entRange == MessageRangeCheckResult.Disallowed) continue; var entHideChat = entRange == MessageRangeCheckResult.HideChat; - _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author); + if (session.AttachedEntity is not { Valid: true } playerEntity) + continue; + EntityUid listener = session.AttachedEntity.Value; + + + // If the channel does not support languages, or the entity can understand the message, send the original message, otherwise send the obfuscated version + if (channel == ChatChannel.LOOC || channel == ChatChannel.Emotes || _language.CanUnderstand(listener, language)) + { + _chatManager.ChatMessageToOne(channel, message, wrappedMessage, source, entHideChat, session.Channel, author: author); + } + else + { + _chatManager.ChatMessageToOne(channel, obfuscated, obfuscatedWrappedMessage, source, entHideChat, session.Channel, author: author); + } } _replay.RecordServerMessage(new ChatMessage(channel, message, wrappedMessage, GetNetEntity(source), null, MessageRangeHideChatForReplay(range))); @@ -790,6 +834,21 @@ public string SanitizeMessageReplaceWords(string message) return msg; } + /// + /// Wraps a message sent by the specified entity into an "x says y" string. + /// + public string WrapPublicMessage(EntityUid source, string name, string message) + { + var speech = GetSpeechVerb(source, message); + var verbName = Loc.GetString(_random.Pick(speech.SpeechVerbStrings)); + return Loc.GetString(speech.Bold ? "chat-manager-entity-say-bold-wrap-message" : "chat-manager-entity-say-wrap-message", + ("entityName", name), + ("verb", verbName), + ("fontType", speech.FontId), + ("fontSize", speech.FontSize), + ("message", FormattedMessage.EscapeText(message))); + } + /// /// Returns list of players and ranges for all players withing some range. Also returns observers with a range of -1. /// @@ -836,7 +895,7 @@ public readonly record struct ICChatRecipientData(float Range, bool Observer, bo { } - private string ObfuscateMessageReadability(string message, float chance) + public string ObfuscateMessageReadability(string message, float chance = DefaultObfuscationFactor) { var modifiedMessage = new StringBuilder(message); @@ -925,7 +984,8 @@ public sealed class EntitySpokeEvent : EntityEventArgs { public readonly EntityUid Source; public readonly string Message; - public readonly string? ObfuscatedMessage; // not null if this was a whisper + public readonly bool IsWhisper; + public readonly LanguagePrototype Language; /// /// If the entity was trying to speak into a radio, this was the channel they were trying to access. If a radio @@ -933,12 +993,13 @@ public sealed class EntitySpokeEvent : EntityEventArgs /// public RadioChannelPrototype? Channel; - public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, string? obfuscatedMessage) + public EntitySpokeEvent(EntityUid source, string message, RadioChannelPrototype? channel, bool isWhisper, LanguagePrototype language) { Source = source; Message = message; Channel = channel; - ObfuscatedMessage = obfuscatedMessage; + IsWhisper = isWhisper; + Language = language; } } diff --git a/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs b/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs index bf7691fe375..da16529d515 100644 --- a/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs +++ b/Content.Server/Chemistry/ReagentEffects/MakeSentient.cs @@ -1,10 +1,14 @@ +using System.Linq; using Content.Server.Ghost.Roles.Components; using Content.Server.Speech.Components; using Content.Shared.Chemistry.Reagent; +using Content.Shared.Language; +using Content.Shared.Language.Systems; using Content.Shared.Mind.Components; using Robust.Shared.Prototypes; using Content.Server.Psionics; //Nyano - Summary: pulls in the ability for the sentient creature to become psionic. using Content.Shared.Humanoid; //Delta-V - Banning humanoids from becoming ghost roles. +using Content.Shared.Language.Events; namespace Content.Server.Chemistry.ReagentEffects; @@ -24,6 +28,20 @@ public override void Effect(ReagentEffectArgs args) entityManager.RemoveComponent(uid); entityManager.RemoveComponent(uid); + var speaker = entityManager.EnsureComponent(uid); + var fallback = SharedLanguageSystem.FallbackLanguagePrototype; + + if (!speaker.UnderstoodLanguages.Contains(fallback)) + speaker.UnderstoodLanguages.Add(fallback); + + if (!speaker.SpokenLanguages.Contains(fallback)) + { + speaker.CurrentLanguage = fallback; + speaker.SpokenLanguages.Add(fallback); + } + + args.EntityManager.EventBus.RaiseLocalEvent(uid, new LanguagesUpdateEvent(), true); + // Stops from adding a ghost role to things like people who already have a mind if (entityManager.TryGetComponent(uid, out var mindContainer) && mindContainer.HasMind) { @@ -47,7 +65,7 @@ public override void Effect(ReagentEffectArgs args) ghostRole = entityManager.AddComponent(uid); entityManager.EnsureComponent(uid); - entityManager.EnsureComponent(uid); //Nyano - Summary:. Makes the animated body able to get psionics. + entityManager.EnsureComponent(uid); //Nyano - Summary:. Makes the animated body able to get psionics. var entityData = entityManager.GetComponent(uid); ghostRole.RoleName = entityData.EntityName; diff --git a/Content.Server/Language/Commands/ListLanguagesCommand.cs b/Content.Server/Language/Commands/ListLanguagesCommand.cs new file mode 100644 index 00000000000..6698e1b6453 --- /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 00000000000..2e4a27b1dcc --- /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 00000000000..e3363846539 --- /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 00000000000..13ab2cac279 --- /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 00000000000..7517b4185e3 --- /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 00000000000..f1bf44c1f4f --- /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 00000000000..1e0c13375e4 --- /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 00000000000..3b7704b9a71 --- /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 5e19d135b6f..cacd499ab8d 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 d18b044205c..53517da6cb4 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 ace7d8ae31a..fc3f69a3ba2 100644 --- a/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioDeviceSystem.cs @@ -1,5 +1,6 @@ using Content.Server.Chat.Systems; using Content.Server.Interaction; +using Content.Server.Language; using Content.Server.Popups; using Content.Server.Power.Components; using Content.Server.Power.EntitySystems; @@ -29,6 +30,7 @@ public sealed class RadioDeviceSystem : EntitySystem [Dependency] private readonly InteractionSystem _interaction = default!; [Dependency] private readonly SharedAppearanceSystem _appearance = default!; [Dependency] private readonly UserInterfaceSystem _ui = default!; + [Dependency] private readonly LanguageSystem _language = default!; // Used to prevent a shitter from using a bunch of radios to spam chat. private HashSet<(string, EntityUid)> _recentlySent = new(); @@ -208,7 +210,8 @@ private void OnReceiveRadio(EntityUid uid, RadioSpeakerComponent component, ref ("originalName", nameEv.Name)); // log to chat so people can identity the speaker/source, but avoid clogging ghost chat if there are many radios - _chat.TrySendInGameICMessage(uid, args.Message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false); + var message = args.OriginalChatMsg.Message; // The chat system will handle the rest and re-obfuscate if needed. + _chat.TrySendInGameICMessage(uid, message, InGameICChatType.Whisper, ChatTransmitRange.GhostRangeLimit, nameOverride: name, checkRadioPrefix: false, languageOverride: args.Language); } private void OnBeforeIntercomUiOpen(EntityUid uid, IntercomComponent component, BeforeActivatableUIOpenEvent args) diff --git a/Content.Server/Radio/EntitySystems/RadioSystem.cs b/Content.Server/Radio/EntitySystems/RadioSystem.cs index e2a61b5022b..60aa7c2f4fb 100644 --- a/Content.Server/Radio/EntitySystems/RadioSystem.cs +++ b/Content.Server/Radio/EntitySystems/RadioSystem.cs @@ -1,10 +1,13 @@ using Content.Server.Administration.Logs; using Content.Server.Chat.Systems; +using Content.Server.Language; using Content.Server.Power.Components; using Content.Server.Radio.Components; +using Content.Server.Speech; using Content.Server.VoiceMask; using Content.Shared.Chat; using Content.Shared.Database; +using Content.Shared.Language; using Content.Shared.Radio; using Content.Shared.Radio.Components; using Content.Shared.Speech; @@ -29,6 +32,7 @@ public sealed class RadioSystem : EntitySystem [Dependency] private readonly IPrototypeManager _prototype = default!; [Dependency] private readonly IRobustRandom _random = default!; [Dependency] private readonly ChatSystem _chat = default!; + [Dependency] private readonly LanguageSystem _language = default!; // set used to prevent radio feedback loops. private readonly HashSet _messages = new(); @@ -44,7 +48,7 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent { if (args.Channel != null && component.Channels.Contains(args.Channel.ID)) { - SendRadioMessage(uid, args.Message, args.Channel, uid); + SendRadioMessage(uid, args.Message, args.Channel, uid, args.Language); args.Channel = null; // prevent duplicate messages from other listeners. } } @@ -52,15 +56,23 @@ private void OnIntrinsicSpeak(EntityUid uid, IntrinsicRadioTransmitterComponent private void OnIntrinsicReceive(EntityUid uid, IntrinsicRadioReceiverComponent component, ref RadioReceiveEvent args) { if (TryComp(uid, out ActorComponent? actor)) - _netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel); + { + // Einstein-Engines - languages mechanic + var listener = component.Owner; + var msg = args.OriginalChatMsg; + if (listener != null && !_language.CanUnderstand(listener, args.Language)) + msg = args.LanguageObfuscatedChatMsg; + + _netMan.ServerSendMessage(new MsgChatMessage { Message = msg}, actor.PlayerSession.Channel); + } } /// /// Send radio message to all active radio listeners /// - public void SendRadioMessage(EntityUid messageSource, string message, ProtoId channel, EntityUid radioSource, bool escapeMarkup = true) + public void SendRadioMessage(EntityUid messageSource, string message, ProtoId channel, EntityUid radioSource, LanguagePrototype? language = null, bool escapeMarkup = true) { - SendRadioMessage(messageSource, message, _prototype.Index(channel), radioSource, escapeMarkup: escapeMarkup); + SendRadioMessage(messageSource, message, _prototype.Index(channel), radioSource, escapeMarkup: escapeMarkup, language: language); } /// @@ -68,8 +80,11 @@ public void SendRadioMessage(EntityUid messageSource, string message, ProtoId /// Entity that spoke the message /// Entity that picked up the message and will send it, e.g. headset - public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, bool escapeMarkup = true) + public void SendRadioMessage(EntityUid messageSource, string message, RadioChannelPrototype channel, EntityUid radioSource, LanguagePrototype? language = null, bool escapeMarkup = true) { + if (language == null) + language = _language.GetLanguage(messageSource); + // TODO if radios ever garble / modify messages, feedback-prevention needs to be handled better than this. if (!_messages.Add(message)) return; @@ -84,6 +99,7 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann name = FormattedMessage.EscapeText(name); + // most radios are relayed to chat, so lets parse the chat message beforehand SpeechVerbPrototype speech; if (mask != null && mask.Enabled @@ -99,24 +115,15 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann ? FormattedMessage.EscapeText(message) : message; - var wrappedMessage = Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap", - ("color", channel.Color), - ("fontType", speech.FontId), - ("fontSize", speech.FontSize), - ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))), - ("channel", $"\\[{channel.LocalizedName}\\]"), - ("name", name), - ("message", content)); + var wrappedMessage = WrapRadioMessage(messageSource, channel, name, content); + var msg = new ChatMessage(ChatChannel.Radio, content, wrappedMessage, NetEntity.Invalid, null); - // most radios are relayed to chat, so lets parse the chat message beforehand - var chat = new ChatMessage( - ChatChannel.Radio, - message, - wrappedMessage, - NetEntity.Invalid, - null); - var chatMsg = new MsgChatMessage { Message = chat }; - var ev = new RadioReceiveEvent(message, messageSource, channel, chatMsg); + // ... you guess it + var obfuscated = _language.ObfuscateSpeech(content, language); + var obfuscatedWrapped = WrapRadioMessage(messageSource, channel, name, obfuscated); + var notUdsMsg = new ChatMessage(ChatChannel.Radio, obfuscated, obfuscatedWrapped, NetEntity.Invalid, null); + + var ev = new RadioReceiveEvent(messageSource, channel, msg, notUdsMsg, language); var sendAttemptEv = new RadioSendAttemptEvent(channel, radioSource); RaiseLocalEvent(ref sendAttemptEv); @@ -162,10 +169,23 @@ public void SendRadioMessage(EntityUid messageSource, string message, RadioChann else _adminLogger.Add(LogType.Chat, LogImpact.Low, $"Radio message from {ToPrettyString(messageSource):user} on {channel.LocalizedName}: {message}"); - _replay.RecordServerMessage(chat); + _replay.RecordServerMessage(msg); _messages.Remove(message); } + private string WrapRadioMessage(EntityUid source, RadioChannelPrototype channel, string name, string message) + { + var speech = _chat.GetSpeechVerb(source, message); + return Loc.GetString(speech.Bold ? "chat-radio-message-wrap-bold" : "chat-radio-message-wrap", + ("color", channel.Color), + ("fontType", speech.FontId), + ("fontSize", speech.FontSize), + ("verb", Loc.GetString(_random.Pick(speech.SpeechVerbStrings))), + ("channel", $"\\[{channel.LocalizedName}\\]"), + ("name", name), + ("message", FormattedMessage.EscapeText(message))); + } + /// private bool HasActiveServer(MapId mapId, string channelId) { diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs index 69d764ffe67..35220d1d757 100644 --- a/Content.Server/Radio/RadioEvent.cs +++ b/Content.Server/Radio/RadioEvent.cs @@ -1,10 +1,22 @@ using Content.Shared.Chat; +using Content.Shared.Language; using Content.Shared.Radio; namespace Content.Server.Radio; +/// +/// The message to display when the speaker can understand "language" +/// The message to display when the speaker cannot understand "language" +/// [ByRefEvent] -public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, MsgChatMessage ChatMsg); +public readonly record struct RadioReceiveEvent( + // Einstein-Engines - languages mechanic + EntityUid MessageSource, + RadioChannelPrototype Channel, + ChatMessage OriginalChatMsg, + ChatMessage LanguageObfuscatedChatMsg, + LanguagePrototype Language +); /// /// Use this event to cancel sending message per receiver diff --git a/Content.Server/Speech/EntitySystems/ListeningSystem.cs b/Content.Server/Speech/EntitySystems/ListeningSystem.cs index ea3569e055c..f2a625600ca 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 ee4a4e9023b..0b72ac5ee74 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -25,6 +25,7 @@ public static class ContentKeyFunctions public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward"; public static readonly BoundKeyFunction EscapeContext = "EscapeContext"; public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu"; + public static readonly BoundKeyFunction OpenLanguageMenu = "OpenLanguageMenu"; public static readonly BoundKeyFunction OpenCraftingMenu = "OpenCraftingMenu"; public static readonly BoundKeyFunction OpenGuidebook = "OpenGuidebook"; public static readonly BoundKeyFunction OpenInventoryMenu = "OpenInventoryMenu"; diff --git a/Content.Shared/Language/Components/LanguageSpeakerComponent.cs b/Content.Shared/Language/Components/LanguageSpeakerComponent.cs new file mode 100644 index 00000000000..95232ffe6ff --- /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 00000000000..401e8a8b8aa --- /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 00000000000..a66c9be082e --- /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 00000000000..f900603f01d --- /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 00000000000..caea9b9a948 --- /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 00000000000..d1d72e83ed7 --- /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 00000000000..d8def4ac1de --- /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 00000000000..6f5ad1178b8 --- /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 00000000000..f7a78210aaf --- /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 00000000000..90ce2f4446b --- /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 00000000000..563f036df6d --- /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 00000000000..aead1f4cd1a --- /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 00000000000..801ab8a393b --- /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 00000000000..e2eeb8bb493 --- /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 00000000000..a13225378cd --- /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 00000000000..08a016efa9c --- /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 00000000000..32fa5415b8c --- /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 00000000000..83687d0f1a6 --- /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 00000000000..69c5d0a4a76 --- /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 00000000000..901a48061c5 --- /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 00000000000..b2a1e9b2b8c --- /dev/null +++ b/Resources/Locale/en-US/language/translator.ftl @@ -0,0 +1,8 @@ +translator-component-shutoff = The {$translator} shuts off. +translator-component-turnon = The {$translator} turns on. +translator-enabled = It appears to be active. +translator-disabled = It appears to be disabled. +translator-implanter-refuse = The {$implanter} has no effect on {$target}. +translator-implanter-success = The {$implanter} successfully injected {$target}. +translator-implanter-ready = This implanter appears to be ready to use. +translator-implanter-used = This implanter seems empty. diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml index 92615131f05..e932974a0f4 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/animals.yml @@ -66,6 +66,11 @@ - type: Tag tags: - VimPilot + - type: LanguageSpeaker + speaks: + - Fox + understands: + - Fox - type: entity name: security dog @@ -154,8 +159,6 @@ spawned: - id: FoodMeat amount: 2 - - type: ReplacementAccent - accent: dog - type: InteractionPopup successChance: 0.5 interactSuccessString: petting-success-dog @@ -176,3 +179,9 @@ tags: - DoorBumpOpener - VimPilot + - type: LanguageSpeaker + speaks: + - Dog + understands: + - Dog + - GalacticCommon diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml index 771da36719f..fa51b99325c 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/familiars.yml @@ -95,6 +95,11 @@ factions: - PsionicInterloper - NanoTrasen + - type: LanguageSpeaker + speaks: + - GalacticCommon + understands: + - GalacticCommon - type: GhostTakeoverAvailable - type: GhostRole makeSentient: true diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml index 96950317c1f..c2ae33ec0ba 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/NPCs/nukiemouse.yml @@ -96,8 +96,12 @@ spawned: - id: FoodMeat amount: 1 - - type: ReplacementAccent - accent: mouse + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - Mouse + - GalacticCommon - type: Tag tags: - VimPilot @@ -163,4 +167,4 @@ interactFailureString: petting-failure-nukie-mouse interactSuccessSpawn: EffectHearts interactSuccessSound: - path: /Audio/Animals/mouse_squeak.ogg \ No newline at end of file + path: /Audio/Animals/mouse_squeak.ogg diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml index a4498299c9a..18437e074dd 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/harpy.yml @@ -122,12 +122,19 @@ - type: MovementSpeedModifier baseWalkSpeed: 2.5 baseSprintSpeed: 5.0 - - type: Inventory + - type: Inventory speciesId: harpy templateId: digitigrade - type: HarpyVisuals - type: UltraVision - + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon + - type: entity save: false name: Urist McHands @@ -138,7 +145,7 @@ components: - type: HumanoidAppearance species: Harpy - - type: Inventory + - type: Inventory speciesId: harpy - type: Sprite scale: 0.9, 0.9 diff --git a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml index 4a187d51b33..52853d696a2 100644 --- a/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml +++ b/Resources/Prototypes/DeltaV/Entities/Mobs/Species/vulpkanin.yml @@ -97,6 +97,13 @@ Female: FemaleVulpkanin Unsexed: MaleVulpkanin - type: DogVision + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Canilunzt + understands: + - GalacticCommon + - Canilunzt - type: entity save: false diff --git a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml index dec46df0b53..0645e451af2 100644 --- a/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml +++ b/Resources/Prototypes/Entities/Mobs/Cyborgs/base_borg_chassis.yml @@ -213,6 +213,13 @@ visMask: - PsionicInvisibility - Normal + - type: LanguageSpeaker + speaks: + - GalacticCommon + - RobotTalk + understands: + - GalacticCommon + - RobotTalk - type: entity id: BaseBorgChassisNT diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index 378b3f8a9d8..369544fdc1b 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -48,8 +48,11 @@ flavorKind: station-event-random-sentience-flavor-organic - type: Bloodstream bloodMaxVolume: 50 - - type: ReplacementAccent - accent: mouse + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - Mouse - type: MeleeWeapon soundHit: path: /Audio/Effects/bite.ogg @@ -229,8 +232,11 @@ - type: EggLayer eggSpawn: - id: FoodEgg - - type: ReplacementAccent - accent: chicken + - type: LanguageSpeaker + speaks: + - Chicken + understands: + - Chicken - type: SentienceTarget flavorKind: station-event-random-sentience-flavor-organic - type: NpcFactionMember @@ -504,8 +510,11 @@ prob: 0.5 - type: Extractable grindableSolutionName: food - - type: ReplacementAccent - accent: mothroach + - type: LanguageSpeaker + speaks: + - Moffic + understands: + - Moffic - type: ZombieAccentOverride accent: zombieMoth - type: Vocal @@ -601,8 +610,11 @@ - type: EggLayer eggSpawn: - id: FoodEgg - - type: ReplacementAccent - accent: duck + - type: LanguageSpeaker + speaks: + - Duck + understands: + - Duck - type: SentienceTarget flavorKind: station-event-random-sentience-flavor-organic - type: NpcFactionMember @@ -839,8 +851,11 @@ interactSuccessSpawn: EffectHearts interactSuccessSound: path: /Audio/Voice/Arachnid/arachnid_chitter.ogg - - type: ReplacementAccent - accent: crab + - type: LanguageSpeaker + speaks: + - Crab + understands: + - Crab - type: Bloodstream bloodMaxVolume: 50 bloodReagent: CopperBlood @@ -1076,8 +1091,11 @@ - type: Inventory speciesId: kangaroo templateId: kangaroo - - type: ReplacementAccent - accent: kangaroo + - type: LanguageSpeaker + speaks: + - Kangaroo + understands: + - Kangaroo - type: InventorySlots - type: Strippable - type: Butcherable @@ -1266,7 +1284,12 @@ - type: Speech speechSounds: Monkey speechVerb: Monkey - - type: MonkeyAccent + - type: LanguageSpeaker + speaks: + - Monkey + understands: + - Monkey + - Kobold - type: SentienceTarget flavorKind: station-event-random-sentience-flavor-primate - type: AlwaysRevolutionaryConvertible @@ -1300,7 +1323,13 @@ - type: Speech speechSounds: Monkey speechVerb: Monkey - - type: MonkeyAccent + - type: LanguageSpeaker + speaks: + - Monkey + understands: + - Monkey + - Kobold + - GalacticCommon - type: NpcFactionMember factions: - Syndicate @@ -1339,8 +1368,12 @@ - type: NameIdentifier group: Kobold - type: LizardAccent - - type: ReplacementAccent - accent: kobold + - type: LanguageSpeaker + speaks: + - Kobold + understands: + - Kobold + - Monkey - type: Speech speechSounds: Lizard speechVerb: Reptilian @@ -1568,8 +1601,11 @@ spawned: - id: FoodMeatRat amount: 1 - - type: ReplacementAccent - accent: mouse + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - Mouse - type: Tag tags: - Trash @@ -1894,6 +1930,11 @@ path: /Audio/Animals/parrot_raught.ogg - type: Bloodstream bloodMaxVolume: 50 + - type: LanguageSpeaker + speaks: + - GalacticCommon + understands: + - GalacticCommon - type: entity name: penguin @@ -2140,8 +2181,11 @@ - type: MeleeChemicalInjector transferAmount: 0.75 solution: melee - - type: ReplacementAccent - accent: xeno + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno - type: InteractionPopup successChance: 0.5 interactSuccessString: petting-success-tarantula @@ -2472,6 +2516,11 @@ - type: Tag tags: - VimPilot + - type: LanguageSpeaker + speaks: + - Fox + understands: + - Fox - type: entity name: corgi @@ -2518,8 +2567,11 @@ spawned: - id: FoodMeat amount: 2 - - type: ReplacementAccent - accent: dog + - type: LanguageSpeaker + speaks: + - Dog + understands: + - Dog - type: InteractionPopup interactSuccessString: petting-success-dog interactFailureString: petting-failure-generic @@ -2671,8 +2723,11 @@ spawned: - id: FoodMeat amount: 3 - - type: ReplacementAccent - accent: cat + - type: LanguageSpeaker + speaks: + - Cat + understands: + - Cat - type: InteractionPopup successChance: 0.7 interactSuccessString: petting-success-cat @@ -2739,6 +2794,12 @@ - type: NpcFactionMember factions: - Syndicate + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno + - GalacticCommon - type: entity name: space cat @@ -3034,8 +3095,11 @@ spawned: - id: FoodMeat amount: 1 - - type: ReplacementAccent - accent: mouse + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - Mouse - type: Tag tags: - VimPilot @@ -3141,8 +3205,11 @@ interactSuccessSpawn: EffectHearts interactSuccessSound: path: /Audio/Animals/pig_oink.ogg - - type: ReplacementAccent - accent: pig + - type: LanguageSpeaker + speaks: + - Pig + understands: + - Pig - type: SentienceTarget flavorKind: station-event-random-sentience-flavor-organic - type: NpcFactionMember @@ -3228,6 +3295,12 @@ reformTime: 10 popupText: diona-reform-attempt reformPrototype: MobDionaReformed + - type: LanguageSpeaker + speaks: + - RootSpeak + understands: + - GalacticCommon + - RootSpeak - type: entity parent: MobDionaNymph diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml b/Resources/Prototypes/Entities/Mobs/NPCs/argocyte.yml index 39e68b63a78..3bcf8e7a16f 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 9981bb8bd92..8ca1b2d2f0e 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml @@ -36,6 +36,12 @@ - VimPilot - type: StealTarget stealGroup: AnimalIan + - type: LanguageSpeaker + speaks: + - Dog + understands: + - GalacticCommon + - Dog - type: entity name: Old Ian @@ -121,6 +127,12 @@ tags: - CannotSuicide - VimPilot + - type: LanguageSpeaker + speaks: + - Cat + understands: + - GalacticCommon + - Cat - type: entity name: Exception @@ -139,6 +151,12 @@ tags: - CannotSuicide - VimPilot + - type: LanguageSpeaker + speaks: + - Cat + understands: + - GalacticCommon + - Cat - type: entity name: Floppa @@ -288,8 +306,12 @@ spawned: - id: FoodMeat amount: 2 - - type: ReplacementAccent - accent: dog + - type: LanguageSpeaker + speaks: + - Dog + understands: + - GalacticCommon + - Dog - type: InteractionPopup successChance: 0.5 interactSuccessString: petting-success-dog @@ -387,8 +409,12 @@ spawned: - id: FoodMeat amount: 3 - - type: ReplacementAccent - accent: dog + - type: LanguageSpeaker + speaks: + - Dog + understands: + - GalacticCommon + - Dog - type: InteractionPopup successChance: 0.7 interactSuccessString: petting-success-dog @@ -546,6 +572,12 @@ - VimPilot - type: StealTarget stealGroup: AnimalRenault + - type: LanguageSpeaker + speaks: + - Fox + understands: + - GalacticCommon + - Fox - type: entity name: Hamlet @@ -593,6 +625,12 @@ - CannotSuicide - Hamster - VimPilot + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - GalacticCommon + - Mouse - type: entity name: Shiva @@ -765,6 +803,12 @@ attributes: proper: true gender: female + - type: LanguageSpeaker + speaks: + - Bubblish + understands: + - GalacticCommon + - Bubblish - type: entity name: Pun Pun @@ -799,6 +843,13 @@ attributes: proper: true gender: male + - type: LanguageSpeaker + speaks: + - Monkey + understands: + - GalacticCommon + - Monkey + - Kobold - type: entity name: Tropico @@ -826,3 +877,9 @@ # - type: AlwaysRevolutionaryConvertible - type: StealTarget stealGroup: AnimalTropico + - type: LanguageSpeaker + speaks: + - Crab + understands: + - GalacticCommon + - Crab diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml b/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml index 89a6f16e525..50fe3b6765e 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/regalrat.yml @@ -119,6 +119,13 @@ attributes: gender: male - type: PotentialPsionic # Nyano + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Mouse + understands: + - GalacticCommon + - Mouse - type: entity id: MobRatKingBuff @@ -289,6 +296,12 @@ - type: Food - type: Item size: Tiny # Delta V - Make them eatable and pickable. + - type: LanguageSpeaker + speaks: + - Mouse + understands: + - GalacticCommon + - Mouse - type: weightedRandomEntity id: RatKingLoot diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml b/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml index ec1ed3a58f6..1316aefc50b 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/revenant.yml @@ -97,6 +97,7 @@ - RevenantTheme - type: Speech speechVerb: Ghost + - type: UniversalLanguageSpeaker - type: Tag tags: - NoPaint diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml b/Resources/Prototypes/Entities/Mobs/NPCs/shadows.yml index f08fe36544e..9559ae3a0c0 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 42b7ff9e211..e3166c15f6e 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/silicon.yml @@ -107,6 +107,13 @@ - type: TypingIndicator proto: robot - type: ZombieImmune + - type: LanguageSpeaker + speaks: + - GalacticCommon + - RobotTalk + understands: + - GalacticCommon + - RobotTalk - type: entity parent: MobSiliconBase diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml b/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml index c64479369a6..901bf149cbc 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/slimes.yml @@ -111,8 +111,11 @@ successChance: 0.5 interactSuccessString: petting-success-slimes interactFailureString: petting-failure-generic - - type: ReplacementAccent - accent: slimes + - type: LanguageSpeaker + speaks: + - Bubblish + understands: + - Bubblish - type: GhostTakeoverAvailable - type: GhostRole makeSentient: true diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/space.yml b/Resources/Prototypes/Entities/Mobs/NPCs/space.yml index 0a294805cfd..9ea2d784dbb 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/space.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/space.yml @@ -165,8 +165,11 @@ - type: FootstepModifier footstepSoundCollection: collection: FootstepBounce - - type: ReplacementAccent - accent: kangaroo + - type: LanguageSpeaker + speaks: + - Kangaroo + understands: + - Kangaroo - type: InventorySlots - type: Strippable - type: UserInterface @@ -248,8 +251,11 @@ - type: MeleeChemicalInjector solution: melee transferAmount: 4 - - type: ReplacementAccent - accent: xeno + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno - type: InteractionPopup successChance: 0.20 interactSuccessString: petting-success-tarantula @@ -351,8 +357,11 @@ - type: MeleeChemicalInjector solution: melee transferAmount: 6 - - type: ReplacementAccent - accent: xeno + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno - type: InteractionPopup successChance: 0.2 interactSuccessString: petting-success-snake @@ -373,4 +382,4 @@ parent: MobCobraSpace suffix: "Salvage Ruleset" components: - - type: SalvageMobRestrictions \ No newline at end of file + - type: SalvageMobRestrictions diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml index d0ac6fc0265..26553a2f1f2 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/xeno.yml @@ -125,6 +125,11 @@ chance: -2 - type: Psionic #Nyano - Summary: makes psionic by default. removable: false + - type: LanguageSpeaker + speaks: + - Xeno + understands: + - Xeno - type: entity name: Praetorian @@ -234,6 +239,13 @@ - type: Tag tags: - CannotSuicide + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Xeno + understands: + - GalacticCommon + - Xeno - type: entity name: Ravager diff --git a/Resources/Prototypes/Entities/Mobs/Player/observer.yml b/Resources/Prototypes/Entities/Mobs/Player/observer.yml index 8f3e6c13466..0086be81d9a 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/observer.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/observer.yml @@ -53,6 +53,7 @@ - type: Tag tags: - BypassInteractionRangeChecks + - type: UniversalLanguageSpeaker # Ghosts should understand any language. - type: entity id: ActionGhostBoo diff --git a/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml b/Resources/Prototypes/Entities/Mobs/Player/replay_observer.yml index ad9b37f63e1..07deef857c3 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 a610e04d6dd..67212d416fe 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -141,7 +141,7 @@ - Pacified - StaminaModifier - PsionicsDisabled #Nyano - Summary: PCs can have psionics disabled. - - PsionicallyInsulated #Nyano - Summary: PCs can be made insulated from psionic powers. + - PsionicallyInsulated #Nyano - Summary: PCs can be made insulated from psionic powers. - type: Reflect enabled: false reflectProb: 0 @@ -218,7 +218,12 @@ - type: MobPrice price: 1500 # Kidnapping a living person and selling them for cred is a good move. deathPenalty: 0.01 # However they really ought to be living and intact, otherwise they're worth 100x less. - - type: CanEscapeInventory # Carrying system from nyanotrasen. + - type: CanEscapeInventory # Carrying system from nyanotrasen. + - type: LanguageSpeaker # This is here so all with no LanguageSpeaker at least spawn with the default languages. + speaks: + - GalacticCommon + understands: + - GalacticCommon - type: Tag tags: - CanPilot diff --git a/Resources/Prototypes/Entities/Mobs/Species/diona.yml b/Resources/Prototypes/Entities/Mobs/Species/diona.yml index 3d405c4dd91..5cb3de6f168 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/diona.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/diona.yml @@ -102,6 +102,13 @@ actionPrototype: DionaGibAction allowedStates: - Dead + - type: LanguageSpeaker + speaks: + - GalacticCommon + - RootSpeak + understands: + - GalacticCommon + - RootSpeak - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml index fe36754b9b5..7afc5cddd70 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/dwarf.yml @@ -52,6 +52,13 @@ accent: dwarf - type: Speech speechSounds: Bass + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/human.yml b/Resources/Prototypes/Entities/Mobs/Species/human.yml index 7bf96efe2cc..7c3f857c001 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/human.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/human.yml @@ -17,6 +17,13 @@ - id: FoodMeatHuman amount: 5 - type: PotentialPsionic #Nyano - Summary: makes potentially psionic. + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + understands: + - GalacticCommon + - SolCommon - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/moth.yml b/Resources/Prototypes/Entities/Mobs/Species/moth.yml index 1680dd6cda6..39aa0ab8dea 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/moth.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/moth.yml @@ -23,6 +23,13 @@ accent: zombieMoth - type: Speech speechVerb: Moth + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Moffic + understands: + - GalacticCommon + - Moffic - type: TypingIndicator proto: moth - type: Butcherable diff --git a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml index 09e86b19968..bdea4499ed1 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/reptilian.yml @@ -59,6 +59,13 @@ types: Heat : 1.5 #per second, scales with temperature & other constants - type: Wagging + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Draconic + understands: + - GalacticCommon + - Draconic - type: entity parent: BaseSpeciesDummy diff --git a/Resources/Prototypes/Entities/Mobs/Species/slime.yml b/Resources/Prototypes/Entities/Mobs/Species/slime.yml index 481afd06a3c..a601010ef94 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/slime.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/slime.yml @@ -74,6 +74,13 @@ types: Asphyxiation: -1.0 maxSaturation: 15 + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Bubblish + understands: + - GalacticCommon + - Bubblish - type: entity parent: MobHumanDummy diff --git a/Resources/Prototypes/Entities/Objects/Devices/translator_implants.yml b/Resources/Prototypes/Entities/Objects/Devices/translator_implants.yml new file mode 100644 index 00000000000..fc947efe9a3 --- /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 00000000000..e5ad824c5d9 --- /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 011f2a3b649..7300c0b9ec3 100644 --- a/Resources/Prototypes/Entities/Structures/Machines/lathe.yml +++ b/Resources/Prototypes/Entities/Structures/Machines/lathe.yml @@ -341,6 +341,24 @@ - FauxTileAstroSnow - OreBagOfHolding - DeviceQuantumSpinInverter + - CanilunztTranslator + - BubblishTranslator + - NekomimeticTranslator + - DraconicTranslator + - SolCommonTranslator + - RootSpeakTranslator + - XenoTranslator + - BasicGalaticCommonTranslatorImplanter + - AdvancedGalaticCommonTranslatorImplanter + - BubblishTranslatorImplanter + - NekomimeticTranslatorImplanter + - DraconicTranslatorImplanter + - CanilunztTranslatorImplanter + - SolCommonTranslatorImplanter + - RootSpeakTranslatorImplanter + - AnimalTranslator + - MofficTranslatorImplanter + - MofficTranslator - type: EmagLatheRecipes emagDynamicRecipes: - ExplosivePayload diff --git a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml index 64b6b068c71..6efa5a63711 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 00000000000..90bce1baed2 --- /dev/null +++ b/Resources/Prototypes/Language/languages.yml @@ -0,0 +1,493 @@ +# The universal language, assumed if the entity has a UniversalLanguageSpeakerComponent. +# Do not use otherwise. Try to use the respective component instead of this language. +- type: language + id: Universal + obfuscateSyllables: false + replacement: + - "*incomprehensible*" + +# The common galactic tongue. +- type: language + id: GalacticCommon + obfuscateSyllables: true + replacement: + - Blah + - Blah + - Blah + - dingle-doingle + - dingle + - dangle + - jibber-jabber + - jubber + - bleh + - zippity + - zoop + - wibble + - wobble + - wiggle + - yada + - meh + - neh + - nah + - wah + +# Spoken by slimes. +- type: language + id: Bubblish + obfuscateSyllables: true + replacement: + - blob + - plop + - pop + - bop + - boop + +# Spoken by moths. +- type: language + id: Moffic + obfuscateSyllables: true + replacement: + - år + - i + - går + - sek + - mo + - ff + - ok + - gj + - ø + - gå + - la + - le + - lit + - ygg + - van + - dår + - næ + - møt + - idd + - hvo + - ja + - på + - han + - så + - ån + - det + - att + - nå + - gö + - bra + - int + - tyc + - om + - när + - två + - må + - dag + - sjä + - vii + - vuo + - eil + - tun + - käyt + - teh + - vä + - hei + - huo + - suo + - ää + - ten + - ja + - heu + - stu + - uhr + - kön + - we + - hön + + # Spoken by dionas. +- type: language + id: RootSpeak + obfuscateSyllables: true + replacement: + - hs + - zt + - kr + - st + - sh + +# A mess of broken Japanese, spoken by Felinds and Oni +- type: language + id: Nekomimetic + obfuscateSyllables: true + replacement: + - neko + - nyan + - mimi + - moe + - mofu + - fuwa + - kyaa + - kawaii + - poka + - munya + - puni + - munyu + - ufufu + - icha + - doki + - kyun + - kusu + - nya + - nyaa + - desu + - kis + - ama + - chuu + - baka + - hewo + - boop + - gato + - kit + - sune + - yori + - sou + - baka + - chan + - san + - kun + - mahou + - yatta + - suki + - usagi + - domo + - ori + - uwa + - zaazaa + - shiku + - puru + - ira + - heto + - etto + +# Spoken by the Lizard race. +- type: language + id: Draconic + obfuscateSyllables: true + replacement: + - za + - az + - ze + - ez + - zi + - iz + - zo + - oz + - zu + - uz + - zs + - sz + - ha + - ah + - he + - eh + - hi + - ih + - ho + - oh + - hu + - uh + - hs + - sh + - la + - al + - le + - el + - li + - il + - lo + - ol + - lu + - ul + - ls + - sl + - ka + - ak + - ke + - ek + - ki + - ik + - ko + - ok + - ku + - uk + - ks + - sk + - sa + - as + - se + - es + - si + - is + - so + - os + - su + - us + - ss + - ss + - ra + - ar + - re + - er + - ri + - ir + - ro + - or + - ru + - ur + - rs + - sr + - a + - a + - e + - e + - i + - i + - o + - o + - u + - u + - s + - s + +# Spoken by the Vulpkanin race. +- type: language + id: Canilunzt + obfuscateSyllables: true + replacement: + - rur + - ya + - cen + - rawr + - bar + - kuk + - tek + - qat + - uk + - wu + - vuh + - tah + - tch + - schz + - auch + - ist + - ein + - entch + - zwichs + - tut + - mir + - wo + - bis + - es + - vor + - nic + - gro + - lll + - enem + - zandt + - tzch + - noch + - hel + - ischt + - far + - wa + - baram + - iereng + - tech + - lach + - sam + - mak + - lich + - gen + - or + - ag + - eck + - gec + - stag + - onn + - bin + - ket + - jarl + - vulf + - einech + - cresthz + - azunein + - ghzth + +# The common language of the Sol system. +- type: language + id: SolCommon + obfuscateSyllables: true + replacement: + - tao + - shi + - tzu + - yi + - com + - be + - is + - i + - op + - vi + - ed + - lec + - mo + - cle + - te + - dis + - e + +- type: language + id: RobotTalk + obfuscateSyllables: true + replacement: + - 0 + - 1 + - 01 + - 10 + - 001 + - 100 + - 011 + - 110 + - 101 + - 010 + +# Languages spoken by various critters. +- type: language + id: Cat + obfuscateSyllables: true + replacement: + - murr + - meow + - purr + - mrow + +- type: language + id: Dog + obfuscateSyllables: true + replacement: + - woof + - bark + - ruff + - bork + - raff + - garr + +- type: language + id: Fox + obfuscateSyllables: true + replacement: + - bark + - gecker + - ruff + - raff + - garr + +- type: language + id: Xeno + obfuscateSyllables: true + replacement: + - sss + - sSs + - SSS + +- type: language + id: Monkey + obfuscateSyllables: true + replacement: + - ok + - ook + - oook + - ooook + - oooook + +- type: language + id: Mouse + obfuscateSyllables: true + replacement: + - Squeak + - Piep + - Chuu + - Eeee + - Pip + - Fwiep + - Heep + +- type: language + id: Chicken + obfuscateSyllables: true + replacement: + - Coo + - Coot + - Cooot + +- type: language + id: Duck + obfuscateSyllables: true + replacement: + - Quack + - Quack quack + +- type: language + id: Cow + obfuscateSyllables: true + replacement: + - Moo + - Mooo + +- type: language + id: Sheep + obfuscateSyllables: true + replacement: + - Ba + - Baa + - Baaa + +- type: language + id: Kangaroo + obfuscateSyllables: true + replacement: + - Shreak + - Chuu + +- type: language + id: Pig + obfuscateSyllables: true + replacement: + - Oink + - Oink oink + +- type: language + id: Crab + obfuscateSyllables: true + replacement: + - Click + - Click-clack + - Clack + - Tipi-tap + - Clik-tap + - Cliliick + +- type: language + id: Kobold + obfuscateSyllables: true + replacement: + - Yip + - Grrar. + - Yap + - Bip + - Screet + - Gronk + - Hiss + - Eeee + - Yip diff --git a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml index e11f1c4165f..8a0e750abd6 100644 --- a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml +++ b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/Oni.yml @@ -35,6 +35,13 @@ - MobLayer - type: Stamina critThreshold: 115 + - type: LanguageSpeaker + speaks: + - GalacticCommon + - Nekomimetic + understands: + - GalacticCommon + - Nekomimetic - type: entity save: false diff --git a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml index d9b25c5dd1b..2184926b95a 100644 --- a/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml +++ b/Resources/Prototypes/Nyanotrasen/Entities/Mobs/Species/felinid.yml @@ -64,6 +64,15 @@ Unsexed: MaleFelinid - type: Felinid - type: NoShoesSilentFootsteps + - type: LanguageSpeaker + speaks: + - GalacticCommon + - SolCommon + - Nekomimetic + understands: + - GalacticCommon + - SolCommon + - Nekomimetic - type: entity save: false diff --git a/Resources/Prototypes/Recipes/Lathes/language.yml b/Resources/Prototypes/Recipes/Lathes/language.yml new file mode 100644 index 00000000000..6871ed5228d --- /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 61f95894ee6..acb6a2498d4 100644 --- a/Resources/Prototypes/Research/civilianservices.yml +++ b/Resources/Prototypes/Research/civilianservices.yml @@ -227,3 +227,43 @@ recipeUnlocks: - BluespaceBeaker - SyringeBluespace + +- type: technology + id: BasicTranslation + name: research-technology-basic-translation + icon: + sprite: Objects/Devices/translator.rsi + state: icon + discipline: CivilianServices + tier: 2 + cost: 10000 + recipeUnlocks: + - CanilunztTranslator + - BubblishTranslator + - NekomimeticTranslator + - DraconicTranslator + - SolCommonTranslator + - RootSpeakTranslator + - BasicGalaticCommonTranslatorImplanter + - MofficTranslator + +- type: technology + id: AdvancedTranslation + name: research-technology-advanced-translation + icon: + sprite: Objects/Devices/translator.rsi + state: icon + discipline: CivilianServices + tier: 3 + cost: 15000 + recipeUnlocks: + - XenoTranslator + - AdvancedGalaticCommonTranslatorImplanter + - BubblishTranslatorImplanter + - NekomimeticTranslatorImplanter + - DraconicTranslatorImplanter + - CanilunztTranslatorImplanter + - SolCommonTranslatorImplanter + - RootSpeakTranslatorImplanter + - AnimalTranslator + - MofficTranslatorImplanter diff --git a/Resources/Textures/Interface/language.png b/Resources/Textures/Interface/language.png new file mode 100644 index 00000000000..2b39424d12d 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 00000000000..6871c808ccd 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 00000000000..0202c0c39c7 --- /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 00000000000..6c54a0b8636 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 346156159a7..24da641a078 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -184,6 +184,9 @@ binds: - function: OpenCharacterMenu type: State key: C +- function: OpenLanguageMenu + type: State + key: L - function: TextCursorSelect # TextCursorSelect HAS to be above ExamineEntity # So that LineEdit receives it correctly.