Implement cyborg TTS voice changing with robot effect and sponsor voice validation (#2936)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: VigersRay <60344369+VigersRay@users.noreply.github.com>
Co-authored-by: Vigers Ray <vigersray@gmail.com>
This commit is contained in:
Copilot 2025-08-25 11:55:33 +03:00 committed by GitHub
parent 5d3d6a484c
commit 90870e609a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 508 additions and 90 deletions

View file

@ -0,0 +1,53 @@
using Content.Client._Sunrise.TTS;
using Content.Shared._Sunrise.TTS;
using Robust.Client.GameObjects;
using Robust.Client.UserInterface;
namespace Content.Client.Silicons.Borgs;
public sealed class BorgVoiceBoundUserInterface : BoundUserInterface
{
private BorgVoiceWindow? _window;
public BorgVoiceBoundUserInterface(EntityUid owner, Enum uiKey) : base(owner, uiKey)
{
IoCManager.InjectDependencies(this);
}
protected override void Open()
{
base.Open();
_window = this.CreateWindow<BorgVoiceWindow>();
_window.OnVoiceSelected += OnVoiceSelected;
_window.OnVoicePreview += OnVoicePreview;
}
protected override void UpdateState(BoundUserInterfaceState state)
{
base.UpdateState(state);
if (state is not BorgVoiceChangeState borgState || _window == null)
return;
_window.UpdateState(borgState);
}
private void OnVoiceSelected(string voiceId)
{
SendMessage(new BorgVoiceChangeMessage(voiceId));
}
private void OnVoicePreview(string voiceId)
{
EntMan.System<TTSSystem>().RequestPreviewTts(voiceId);
}
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
if (!disposing) return;
_window?.Dispose();
}
}

View file

@ -0,0 +1,85 @@
using System.Linq;
using Content.Shared._Sunrise.TTS;
using Content.Shared.Humanoid;
using Robust.Client.AutoGenerated;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.CustomControls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Localization;
using Robust.Shared.Prototypes;
namespace Content.Client.Silicons.Borgs;
[GenerateTypedNameReferences]
public sealed partial class BorgVoiceWindow : DefaultWindow
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
public event Action<string>? OnVoiceSelected;
public event Action<string>? OnVoicePreview;
private readonly List<TTSVoicePrototype> _voiceList = new();
public BorgVoiceWindow()
{
RobustXamlLoader.Load(this);
IoCManager.InjectDependencies(this);
Title = Loc.GetString("borg-voice-window-title");
LoadVoiceList();
SetupButtons();
}
private void LoadVoiceList()
{
_voiceList.Clear();
_voiceList.AddRange(_prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.Where(v => v.RoundStart)
.OrderBy(v => Loc.GetString(v.Name)));
}
private void SetupButtons()
{
VoiceOptionButton.OnItemSelected += args =>
{
VoiceOptionButton.SelectId(args.Id);
var voice = _voiceList[args.Id];
OnVoiceSelected?.Invoke(voice.ID);
};
VoicePlayButton.OnPressed += _ =>
{
if (VoiceOptionButton.SelectedId != null)
{
var voice = _voiceList[VoiceOptionButton.SelectedId];
OnVoicePreview?.Invoke(voice.ID);
}
};
}
public void UpdateState(BorgVoiceChangeState state)
{
VoiceOptionButton.Clear();
var selectedIndex = 0;
for (var i = 0; i < _voiceList.Count; i++)
{
var voice = _voiceList[i];
var name = Loc.GetString(voice.Name);
VoiceOptionButton.AddItem(name, i);
if (voice.ID == state.CurrentVoiceId)
{
selectedIndex = i;
}
}
if (_voiceList.Count > 0)
{
VoiceOptionButton.SelectId(selectedIndex);
}
}
}

View file

@ -0,0 +1,23 @@
<DefaultWindow xmlns="https://spacestation14.io"
xmlns:customControls="clr-namespace:Content.Client.Administration.UI.CustomControls"
Title="{Loc 'borg-voice-window-title'}"
MinSize="600 180"
SetSize="600 180"
Resizable="False">
<BoxContainer Orientation="Vertical">
<Label Text="{Loc 'borg-voice-window-description'}"
Margin="5 5 5 0" HorizontalAlignment="Center"/>
<customControls:HSeparator Margin="5 5 5 5" />
<OptionButton Name="VoiceOptionButton"
HorizontalExpand="True" />
<BoxContainer Orientation="Horizontal" Margin="5 10 5 5" HorizontalAlignment="Center">
<Button Name="VoicePlayButton"
Text="{Loc 'borg-voice-window-play-button'}"
Margin="0 0 5 0" />
</BoxContainer>
</BoxContainer>
</DefaultWindow>

View file

@ -25,7 +25,6 @@ public sealed class TTSSystem : EntitySystem
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IResourceManager _res = default!;
[Dependency] private readonly AudioSystem _audio = default!;
[Dependency] private readonly SharedAudioSystem _sharedAudio = default!;
[Dependency] private readonly IResourceCache _resourceCache = default!;
[Dependency] private readonly IDependencyCollection _dependencyCollection = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
@ -169,39 +168,12 @@ public sealed class TTSSystem : EntitySystem
PlayTTSBytes(ev.Data, entity, audioParams);
}
private void OnPlayMultiSpeakerTTS(PlayMultiSpeakerTTSEvent ev)
{
if (_volume == 0)
return;
var volume = SharedAudioSystem.GainToVolume(_volume);
var audioParams = AudioParams.Default.WithVolume(volume);
foreach (var uid in ev.Speakers)
{
PlayTTSBytes(ev.SoundData, GetEntity(uid), audioParams);
}
}
private (EntityUid Entity, AudioComponent Component)? PlayTTSBytes(byte[] data, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
private (AudioResource Resource, ResPath FilePath)? AddTtsAudioResource(byte[] data)
{
if (data.Length == 0)
return null;
// если sourceUid.Value.Id == 0 то значит эта сущность не прогружена на стороне клиента
if (sourceUid is { Id: 0 } && !globally)
return null;
_sawmill.Debug($"Play TTS audio {data.Length} bytes from {sourceUid} entity");
var finalParams = audioParams ?? AudioParams.Default;
var filePath = new ResPath($"{_fileIdx}.ogg");
ContentRoot.AddOrUpdateFile(filePath, data);
var res = new AudioResource();
res.Load(_dependencyCollection, Prefix / filePath);
try
{
ContentRoot.AddOrUpdateFile(filePath, data);
@ -212,10 +184,16 @@ public sealed class TTSSystem : EntitySystem
_fileIdx++;
return null;
}
var res = new AudioResource();
res.Load(_dependencyCollection, Prefix / filePath);
_resourceCache.CacheResource(Prefix / filePath, res);
return (res, filePath);
}
private (EntityUid Entity, AudioComponent Component)? PlayTTSResource(AudioResource res, ResPath filePath, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
{
var finalParams = audioParams ?? AudioParams.Default;
(EntityUid Entity, AudioComponent Component)? playing;
if (globally)
{
playing = _audio.PlayGlobal(res.AudioStream, null, finalParams);
@ -231,13 +209,47 @@ public sealed class TTSSystem : EntitySystem
playing = _audio.PlayGlobal(res.AudioStream, null, finalParams);
}
}
RemoveFileCursed(filePath);
_fileIdx++;
return playing;
}
private void OnPlayMultiSpeakerTTS(PlayMultiSpeakerTTSEvent ev)
{
if (_volume == 0)
return;
var volume = SharedAudioSystem.GainToVolume(_volume);
var audioParams = AudioParams.Default.WithVolume(volume).WithMaxDistance(30f);
var audioRes = AddTtsAudioResource(ev.SoundData);
if (audioRes == null)
return;
foreach (var uid in ev.Speakers)
{
PlayTTSResource(audioRes.Value.Resource, audioRes.Value.FilePath, GetEntity(uid), audioParams);
}
}
private (EntityUid Entity, AudioComponent Component)? PlayTTSBytes(byte[] data, EntityUid? sourceUid = null, AudioParams? audioParams = null, bool globally = false)
{
if (data.Length == 0)
return null;
// если sourceUid.Value.Id == 0 то значит эта сущность не прогружена на стороне клиента
if (sourceUid is { Id: 0 } && !globally)
return null;
_sawmill.Debug($"Play TTS audio {data.Length} bytes from {sourceUid} entity");
var audioRes = AddTtsAudioResource(data);
if (audioRes == null)
return null;
return PlayTTSResource(audioRes.Value.Resource, audioRes.Value.FilePath, sourceUid, audioParams, globally);
}
private void RemoveFileCursed(ResPath resPath)
{
ContentRoot.RemoveFile(resPath);

View file

@ -0,0 +1,157 @@
using System.Linq;
using Content.Server._Sunrise.TTS;
using Content.Shared._Sunrise.TTS;
using Content.Shared.Popups;
using Content.Shared.Preferences;
using Content.Shared.Silicons.Borgs.Components;
using Content.Shared.UserInterface;
using Content.Sunrise.Interfaces.Shared;
using Robust.Server.GameObjects;
using Robust.Server.Player;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
namespace Content.Server.Silicons.Borgs;
/// <summary>
/// System that handles cyborg voice changing functionality.
/// </summary>
public sealed class BorgVoiceSystem : EntitySystem
{
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
[Dependency] private readonly UserInterfaceSystem _uiSystem = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
private ISharedSponsorsManager? _sponsorsManager;
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<BorgVoiceComponent, BorgVoiceChangeActionEvent>(OnBorgVoiceChangeAction);
SubscribeLocalEvent<BorgVoiceComponent, ComponentStartup>(OnBorgVoiceStartup);
// Subscribe to TTS voice transformation
SubscribeLocalEvent<BorgVoiceComponent, TransformSpeakerVoiceEvent>(OnTransformSpeakerVoice);
// Initialize sponsors manager
IoCManager.Instance!.TryResolveType(out _sponsorsManager);
// Subscribe to UI events
Subs.BuiEvents<BorgVoiceComponent>(BorgVoiceUiKey.Key, subs =>
{
subs.Event<BorgVoiceChangeMessage>(OnBorgVoiceChangeMessage);
});
}
private void OnBorgVoiceChangeAction(EntityUid uid, BorgVoiceComponent component, BorgVoiceChangeActionEvent args)
{
if (!TryComp<BorgChassisComponent>(uid, out _))
return;
// Open the voice selection UI
if (!_uiSystem.HasUi(uid, BorgVoiceUiKey.Key))
return;
// Get the player session for the performer
if (!_playerManager.TryGetSessionByEntity(args.Performer, out var session))
return;
var state = CreateVoiceChangeState(uid, component, session);
_uiSystem.SetUiState(uid, BorgVoiceUiKey.Key, state);
_uiSystem.OpenUi(uid, BorgVoiceUiKey.Key, session);
}
private void OnBorgVoiceChangeMessage(EntityUid uid, BorgVoiceComponent component, BorgVoiceChangeMessage args)
{
if (!TryComp<BorgChassisComponent>(uid, out _))
return;
// Get the player session for the actor
if (!_playerManager.TryGetSessionByEntity(args.Actor, out var session))
return;
// Validate the voice prototype exists and player can use it
if (!CanUseVoice(args.VoiceId, session))
{
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(args.VoiceId, out var voicePrototype))
{
_popup.PopupEntity(Loc.GetString("borg-voice-popup-invalid"), uid, args.Actor, PopupType.MediumCaution);
return;
}
if (voicePrototype.SponsorOnly)
{
_popup.PopupEntity(Loc.GetString("borg-voice-popup-sponsor-only"), uid, args.Actor, PopupType.MediumCaution);
return;
}
}
// Get the voice prototype for the success message
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(args.VoiceId, out var voice))
return;
// Set the new voice
component.SelectedVoiceId = args.VoiceId;
Dirty(uid, component);
_popup.PopupEntity(Loc.GetString("borg-voice-popup-changed", ("voice", Loc.GetString(voice.Name))), uid, args.Actor, PopupType.Medium);
// Update UI
var state = CreateVoiceChangeState(uid, component, session);
_uiSystem.SetUiState(uid, BorgVoiceUiKey.Key, state);
}
private void OnBorgVoiceStartup(EntityUid uid, BorgVoiceComponent component, ComponentStartup args)
{
// Set default voice if not already set
if (component.SelectedVoiceId == null)
{
var defaultVoice = _prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.FirstOrDefault(v => v.RoundStart && !v.SponsorOnly);
if (defaultVoice != null)
{
component.SelectedVoiceId = defaultVoice.ID;
Dirty(uid, component);
}
}
}
private void OnTransformSpeakerVoice(EntityUid uid, BorgVoiceComponent component, TransformSpeakerVoiceEvent args)
{
// Use the borg's selected voice instead of the default
if (component.SelectedVoiceId != null)
{
args.VoiceId = component.SelectedVoiceId;
}
args.Effect = component.VoiceEffect;
}
private BorgVoiceChangeState CreateVoiceChangeState(EntityUid uid, BorgVoiceComponent component, ICommonSession player)
{
var availableVoices = _prototypeManager
.EnumeratePrototypes<TTSVoicePrototype>()
.Where(v => v.RoundStart && CanUseVoice(v.ID, player))
.Select(v => v.ID)
.ToList();
return new BorgVoiceChangeState(component.SelectedVoiceId, availableVoices);
}
private bool CanUseVoice(string voiceId, ICommonSession player)
{
if (!_prototypeManager.TryIndex<TTSVoicePrototype>(voiceId, out var voice))
return false;
if (!voice.SponsorOnly)
return true;
if (_sponsorsManager == null)
return true;
return _sponsorsManager.TryGetPrototypes(player.UserId, out var allowedPrototypes) && allowedPrototypes.Contains(voiceId);
}
}

View file

@ -53,6 +53,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
private bool _isEnabled;
private string _defaultAnnounceVoice = "Hanson";
private string _announceEffect = string.Empty;
// Queue system for preventing overlapping announcements
private readonly Queue<QueuedAnnouncement> _announcementQueue = new();
@ -64,6 +65,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
{
base.Initialize();
_cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
_cfg.OnValueChanged(SunriseCCVars.TTSAnnounceEffect, OnAnnounceEffectChanged, true);
}
public override void Update(float frameTime)
@ -72,6 +74,11 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
ProcessAnnouncementQueue();
}
private void OnAnnounceEffectChanged(string value)
{
_announceEffect = value;
}
/// <summary>
/// Processes the announcement queue and plays the next announcement if none is currently playing.
/// </summary>
@ -225,7 +232,7 @@ public sealed class AnnouncementSpeakerSystem : EntitySystem
{
try
{
return await _ttsSystem.GenerateTTS(text, voicePrototype, isAnnounce: true);
return await _ttsSystem.GenerateTTS(text, voicePrototype, _announceEffect);
}
catch (Exception e)
{

View file

@ -28,30 +28,18 @@ public sealed class TTSManager
"tts_wanted_count",
"Amount of wanted TTS audio.");
private static readonly Counter WantedRadioCount = Metrics.CreateCounter(
"tts_wanted_radio_count",
"Amount of wanted TTS radio audio.");
private static readonly Counter WantedAnnounceCount = Metrics.CreateCounter(
"tts_wanted_announce_count",
"Amount of wanted TTS Announce audio.");
[Dependency] private readonly IConfigurationManager _cfg = default!;
private readonly HttpClient _httpClient = new();
private ISawmill _sawmill = default!;
private string _apiUrl = string.Empty;
private string _radioEffect = string.Empty;
private string _announceEffect = string.Empty;
public void Initialize()
{
_sawmill = Logger.GetSawmill("tts");
_cfg.OnValueChanged(SunriseCCVars.TTSApiUrl, OnApiUrlChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSApiToken, OnApiTokenChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true);
_cfg.OnValueChanged(SunriseCCVars.TTSAnnounceEffect, OnAnnounceEffectChanged, true);
}
private void OnApiUrlChanged(string value)
@ -64,16 +52,6 @@ public sealed class TTSManager
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", value);
}
private void OnRadioEffectChanged(string value)
{
_radioEffect = value;
}
private void OnAnnounceEffectChanged(string value)
{
_announceEffect = value;
}
public async Task<byte[]?> ConvertTextToSpeech(TTSVoicePrototype voicePrototype, string text, string? effect = null)
{
WantedCount.Inc();
@ -148,20 +126,6 @@ public sealed class TTSManager
return uriBuilder.ToString();
}
public async Task<byte[]?> ConvertTextToSpeechRadio(TTSVoicePrototype voicePrototype, string text)
{
WantedRadioCount.Inc();
var soundData = await ConvertTextToSpeech(voicePrototype, text, _radioEffect);
return soundData;
}
public async Task<byte[]?> ConvertTextToSpeechAnnounce(TTSVoicePrototype voicePrototype, string text)
{
WantedAnnounceCount.Inc();
var soundData = await ConvertTextToSpeech(voicePrototype, text, _announceEffect);
return soundData;
}
private record GenerateVoiceRequest
{
[JsonPropertyName("text")]

View file

@ -11,6 +11,7 @@ using Content.Shared._Sunrise.AnnouncementSpeaker.Components;
using Content.Shared._Sunrise.AnnouncementSpeaker.Events;
using Robust.Server.GameObjects;
using Robust.Shared.Audio;
using Content.Shared.Silicons.Borgs.Components;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Player;
@ -54,10 +55,12 @@ public sealed partial class TTSSystem : EntitySystem
private List<ICommonSession> _ignoredRecipients = new();
private const float WhisperVoiceVolumeModifier = 0.6f; // how far whisper goes in world units
private const int WhisperVoiceRange = 3; // how far whisper goes in world units
private string _radioEffect = string.Empty;
public override void Initialize()
{
_cfg.OnValueChanged(SunriseCCVars.TTSEnabled, v => _isEnabled = v, true);
_cfg.OnValueChanged(SunriseCCVars.TTSRadioEffect, OnRadioEffectChanged, true);
SubscribeLocalEvent<TransformSpeechEvent>(OnTransformSpeech);
SubscribeLocalEvent<TTSComponent, EntitySpokeEvent>(OnEntitySpoke);
@ -68,6 +71,11 @@ public sealed partial class TTSSystem : EntitySystem
SubscribeNetworkEvent<ClientOptionTTSEvent>(OnClientOptionTTS);
}
private void OnRadioEffectChanged(string value)
{
_radioEffect = value;
}
private async void OnRequestPreviewTTS(RequestPreviewTTSEvent ev, EntitySessionEventArgs args)
{
if (!_isEnabled ||
@ -115,7 +123,7 @@ public sealed partial class TTSSystem : EntitySystem
RaiseLocalEvent(args.Source, accentEvent);
var message = accentEvent.Text;
HandleRadio(args.Receivers, message, protoVoice);
HandleRadio(args.Receivers, message, protoVoice, voiceEv.Effect);
}
private bool GetVoicePrototype(string voiceId, [NotNullWhen(true)] out TTSVoicePrototype? voicePrototype)
@ -237,10 +245,10 @@ public sealed partial class TTSSystem : EntitySystem
return;
}
HandleSay(uid, message, protoVoice);
HandleSay(uid, message, protoVoice, voiceEv.Effect);
}
private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype)
private async void HandleSay(EntityUid uid, string message, TTSVoicePrototype voicePrototype, string? effect)
{
var recipients = Filter.Pvs(uid, 1F).RemovePlayers(_ignoredRecipients);
@ -248,7 +256,7 @@ public sealed partial class TTSSystem : EntitySystem
if (!recipients.Recipients.Any())
return;
var soundData = await GenerateTTS(message, voicePrototype);
var soundData = await GenerateTTS(message, voicePrototype, effect);
if (soundData is null)
return;
@ -293,9 +301,9 @@ public sealed partial class TTSSystem : EntitySystem
}
}
private async void HandleRadio(EntityUid[] uids, string message, TTSVoicePrototype voicePrototype)
private async void HandleRadio(EntityUid[] uids, string message, TTSVoicePrototype voicePrototype, string? effect = null)
{
var soundData = await GenerateTTS(message, voicePrototype, isRadio: true);
var soundData = await GenerateTTS(message, voicePrototype, _radioEffect);
if (soundData is null)
return;
@ -303,7 +311,7 @@ public sealed partial class TTSSystem : EntitySystem
}
// ReSharper disable once InconsistentNaming
public async Task<byte[]?> GenerateTTS(string text, TTSVoicePrototype voicePrototype, bool isRadio = false, bool isAnnounce = false)
public async Task<byte[]?> GenerateTTS(string text, TTSVoicePrototype voicePrototype, string? effect = null)
{
try
{
@ -312,17 +320,7 @@ public sealed partial class TTSSystem : EntitySystem
if (char.IsLetter(textSanitized[^1]))
textSanitized += ".";
if (isRadio)
{
return await _ttsManager.ConvertTextToSpeechRadio(voicePrototype, textSanitized);
}
if (isAnnounce)
{
return await _ttsManager.ConvertTextToSpeechAnnounce(voicePrototype, textSanitized);
}
return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized);
return await _ttsManager.ConvertTextToSpeech(voicePrototype, textSanitized, effect);
}
catch (Exception e)
{
@ -338,11 +336,13 @@ public sealed class TransformSpeakerVoiceEvent : EntityEventArgs
{
public EntityUid Sender;
public string VoiceId;
public string? Effect;
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId)
public TransformSpeakerVoiceEvent(EntityUid sender, string voiceId, string? effect = null)
{
Sender = sender;
VoiceId = voiceId;
Effect = effect;
}
}

View file

@ -14,6 +14,7 @@ public sealed class InnateItemSystem : EntitySystem
[Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
[Dependency] private readonly SharedContainerSystem _containerSystem = default!;
[Dependency] private readonly ActionContainerSystem _actionContainer = default!;
[Dependency] private readonly MetaDataSystem _metadata = default!;
private static readonly EntProtoId InnateEntityTargetAction = "InnateEntityTargetAction";
private static readonly EntProtoId InnateInstantActionAction = "InnateInstantActionAction";
@ -81,7 +82,8 @@ public sealed class InnateItemSystem : EntitySystem
{
foreach (var itemProto in prototypeIds)
{
if (itemProto == null) continue;
if (itemProto == null)
continue;
var spawnedItem = Spawn(itemProto);
@ -97,7 +99,8 @@ public sealed class InnateItemSystem : EntitySystem
var action = Spawn(actionPrototypeId);
_actionsSystem.SetIcon(action, new SpriteSpecifier.EntityPrototype(MetaData(spawnedItem).EntityPrototype!.ID));
_actionsSystem.SetIcon(action,
new SpriteSpecifier.EntityPrototype(MetaData(spawnedItem).EntityPrototype!.ID));
// Устанавливаем соответствующий тип события в зависимости от типа действия
if (isEntityTarget)
@ -105,6 +108,10 @@ public sealed class InnateItemSystem : EntitySystem
else
_actionsSystem.SetEvent(action, new InnateInstantActionEvent(spawnedItem));
_metadata.SetEntityName(action, MetaData(spawnedItem).EntityName);
_metadata.SetEntityDescription(action, MetaData(spawnedItem).EntityDescription);
_actionContainer.AddAction(uid, action);
_actionsSystem.AddAction(uid, action, uid);
component.Actions.Add(action);
}

View file

@ -0,0 +1,47 @@
using Content.Shared.Actions;
using Robust.Shared.Serialization;
namespace Content.Shared._Sunrise.TTS;
/// <summary>
/// Event raised when a cyborg wants to change their TTS voice.
/// </summary>
public sealed partial class BorgVoiceChangeActionEvent : InstantActionEvent
{
}
/// <summary>
/// Event sent from client to server to change borg voice.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class BorgVoiceChangeMessage : BoundUserInterfaceMessage
{
public string VoiceId;
public BorgVoiceChangeMessage(string voiceId)
{
VoiceId = voiceId;
}
}
/// <summary>
/// Event sent from server to client to update borg voice UI.
/// </summary>
[Serializable, NetSerializable]
public sealed partial class BorgVoiceChangeState : BoundUserInterfaceState
{
public string? CurrentVoiceId;
public List<string> AvailableVoices;
public BorgVoiceChangeState(string? currentVoiceId, List<string> availableVoices)
{
CurrentVoiceId = currentVoiceId;
AvailableVoices = availableVoices;
}
}
[Serializable, NetSerializable]
public enum BorgVoiceUiKey : byte
{
Key
}

View file

@ -0,0 +1,22 @@
using Robust.Shared.GameStates;
using Robust.Shared.Prototypes;
namespace Content.Shared._Sunrise.TTS;
/// <summary>
/// Component for cyborgs that allows them to change their TTS voice.
/// </summary>
[RegisterComponent, NetworkedComponent]
public sealed partial class BorgVoiceComponent : Component
{
/// <summary>
/// The currently selected voice prototype ID.
/// </summary>
[ViewVariables(VVAccess.ReadWrite)]
[DataField]
public ProtoId<TTSVoicePrototype>? SelectedVoiceId { get; set; }
[ViewVariables(VVAccess.ReadWrite)]
[DataField]
public string VoiceEffect = "robot";
}

View file

@ -0,0 +1,9 @@
# Borg Voice Change UI
borg-voice-window-title = Voice Settings
borg-voice-window-description = Select your TTS voice. Cyborgs can speak with any voice due to their speakers.
borg-voice-window-voice-label = Voice:
borg-voice-window-play-button = Preview
# Action
action-name-change-borg-voice = Change Voice
action-description-change-borg-voice = Change your TTS voice. Cyborgs can speak with any voice due to their speakers.

View file

@ -0,0 +1,13 @@
# Borg Voice Change UI
borg-voice-window-title = Настройки голоса
borg-voice-window-description = Выберите голос TTS.
borg-voice-window-play-button = Прослушать
# Action
action-name-change-borg-voice = Сменить голос
action-description-change-borg-voice = Измените голос TTS. Киборги могут говорить любым голосом благодаря динамикам.
# Popups
borg-voice-popup-sponsor-only = Этот голос доступен только спонсорам!
borg-voice-popup-changed = Голос изменён на { $voice }!
borg-voice-popup-invalid = Выбран несуществующий голос!

View file

@ -74,12 +74,15 @@
# Only used for NT borgs that can switch type, defined here to avoid copy-pasting the rest of this component.
enum.BorgSwitchableTypeUiKey.SelectBorgType:
type: BorgSelectTypeUserInterface
enum.BorgVoiceUiKey.Key:
type: BorgVoiceBoundUserInterface
- type: ActivatableUI
key: enum.BorgUiKey.Key
- type: SiliconLawBound
- type: ActionGrant
actions:
- ActionViewLaws
- ActionChangeBorgVoice
- type: EmagSiliconLaw
stunTime: 5
- type: SiliconLawProvider
@ -117,6 +120,7 @@
allowSelfRepair: false
# Sunrise-End
- type: BorgChassis
- type: BorgVoice # Sunrise-TTS: Allow cyborgs to change their voice
- type: LockingWhitelist
blacklist:
components:

View file

@ -110,3 +110,18 @@
priority: 11
- type: InstantAction
event: !type:CrewManifestOpenActionEvent
- type: entity
parent: BaseAction
id: ActionChangeBorgVoice
name: Change Voice
description: Change your TTS voice. Cyborgs can speak with any voice due to their speakers.
categories: [ HideSpawnMenu ]
components:
- type: Action
icon: { sprite: Interface/Actions/actions_borg.rsi, state: select-type }
itemIconStyle: NoItem
useDelay: 0.5
priority: 10
- type: InstantAction
event: !type:BorgVoiceChangeActionEvent