Parroting Parrots part 1: Help maints! SQUAWK! Maints! (#38243)
* parrots have ears. add poly * high tech parrot functionality * adjust times * add accent to radio message * don't spam everything all at once probably * learn about the existence of prob(float) * actually use Prob(float) correctly * newline * add pet spawner for poly * move chance to talk on radio to component * missing comment * minor edits and doc additions * the reviewerrrrrrr * parrot can't learn when crit or dead * increase default memory * rename poly to polly * crude way to ignore whispers. chatcode please * This is Polly. It is set to broadcast over the engineering frequency * add missing initialize * add displacement map for parrot ears * review comments - Errant * minor things * large rework * fix attempting to talk when entity has no channels * use list of active radios again to track channels * fix bad return, some comments * fix long learn cooldown * minor adjustments * use FromMinutes * the voices told me to make these changes * remove default reassignment * Review changes * remove polly's accent * decouple radio stuff from parrotsystem * minor stuff * split vocalization and parroting * minor review work * re-add missing check * add admin verb for clearing parrot messages * minor action icon update * oops * increase icon number text size * Admin erase parrot messages associated with players * part 1 beck review * add whitelist and blacklist for parrots * Downgrade missing component error to warning * Add comment * add some missing comments * Remove active radio entity tracking, use all inventory slots * Minor changes * small review stuff * review radio stuff * swap ears displacement to invisible death displacement * remove syncsprite * vscode why do yo have to hurt my feelings * review changes * use checkboth
@@ -383,8 +383,13 @@ public sealed class AdminSystem : EntitySystem
|
||||
{
|
||||
_chat.DeleteMessagesBy(uid);
|
||||
|
||||
var eraseEvent = new EraseEvent(uid);
|
||||
|
||||
if (!_minds.TryGetMind(uid, out var mindId, out var mind) || mind.OwnedEntity == null || TerminatingOrDeleted(mind.OwnedEntity.Value))
|
||||
{
|
||||
RaiseLocalEvent(ref eraseEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
var entity = mind.OwnedEntity.Value;
|
||||
|
||||
@@ -444,6 +449,8 @@ public sealed class AdminSystem : EntitySystem
|
||||
|
||||
if (_playerManager.TryGetSessionById(uid, out var session))
|
||||
_gameTicker.SpawnObserver(session);
|
||||
|
||||
RaiseLocalEvent(ref eraseEvent);
|
||||
}
|
||||
|
||||
private void OnSessionPlayTimeUpdated(ICommonSession session)
|
||||
@@ -451,3 +458,10 @@ public sealed class AdminSystem : EntitySystem
|
||||
UpdatePlayerList(session);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event fired after a player is erased by an admin
|
||||
/// </summary>
|
||||
/// <param name="PlayerNetUserId">NetUserId of the player that was the target of the Erase</param>
|
||||
[ByRefEvent]
|
||||
public record struct EraseEvent(NetUserId PlayerNetUserId);
|
||||
|
||||
32
Content.Server/Animals/Components/ParrotListenerComponent.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Content.Shared.Whitelist;
|
||||
|
||||
namespace Content.Server.Animals.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Makes an entity able to listen to messages from IC chat and attempt to commit them to memory
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
public sealed partial class ParrotListenerComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// Whitelist for purposes of limiting which entities a parrot will listen to
|
||||
///
|
||||
/// This is here because parrots can learn via local chat or radio from other parrots. this can quickly devolve from
|
||||
/// SQUAWK! Polly wants a cracker! BRAAWK
|
||||
/// to
|
||||
/// BRAAWK! SQUAWK! RAWWK! Polly wants a cracker! AAWK! AWWK! Cracker! SQUAWK! BRAWWK! SQUAWK!
|
||||
/// This is limited somewhat by the message length limit on ParrotMemoryComponent, but can be prevented entirely here
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public EntityWhitelist? Whitelist;
|
||||
|
||||
/// <summary>
|
||||
/// Blacklist for purposes of ignoring entities
|
||||
/// As above, this is here to force parrots to ignore certain entities.
|
||||
/// For example, polly will be consistently mapped around EngiDrobes, which will consistently say stuff like
|
||||
/// "Guaranteed to protect your feet from industrial accidents!"
|
||||
/// If polly ends up constantly advertising engineering drip, this can be used to prevent it.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public EntityWhitelist? Blacklist;
|
||||
}
|
||||
57
Content.Server/Animals/Components/ParrotMemoryComponent.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
|
||||
|
||||
namespace Content.Server.Animals.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Makes an entity able to memorize chat/radio messages
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
[AutoGenerateComponentPause]
|
||||
public sealed partial class ParrotMemoryComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// List of SpeechMemory records this entity has learned
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public List<SpeechMemory> SpeechMemories = [];
|
||||
|
||||
/// <summary>
|
||||
/// The % chance an entity with this component learns a phrase when learning is off cooldown
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public float LearnChance = 0.4f;
|
||||
|
||||
/// <summary>
|
||||
/// Time after which another attempt can be made at learning a phrase
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public TimeSpan LearnCooldown = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// Next time at which the parrot can attempt to learn something
|
||||
/// </summary>
|
||||
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
|
||||
[AutoPausedField]
|
||||
public TimeSpan NextLearnInterval = TimeSpan.Zero;
|
||||
|
||||
/// <summary>
|
||||
/// The number of speech entries that are remembered
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public int MaxSpeechMemory = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Minimum length of a speech entry
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public int MinEntryLength = 4;
|
||||
|
||||
/// <summary>
|
||||
/// Maximum length of a speech entry
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public int MaxEntryLength = 50;
|
||||
}
|
||||
|
||||
public record struct SpeechMemory(NetUserId? NetUserId, string Message);
|
||||
247
Content.Server/Animals/Systems/ParrotMemorySystem.cs
Normal file
@@ -0,0 +1,247 @@
|
||||
using Content.Server.Administration.Logs;
|
||||
using Content.Server.Administration.Managers;
|
||||
using Content.Server.Administration.Systems;
|
||||
using Content.Server.Animals.Components;
|
||||
using Content.Server.Mind;
|
||||
using Content.Server.Popups;
|
||||
using Content.Server.Radio;
|
||||
using Content.Server.Speech;
|
||||
using Content.Server.Speech.Components;
|
||||
using Content.Server.Vocalization.Systems;
|
||||
using Content.Shared.Database;
|
||||
using Content.Shared.Mobs.Systems;
|
||||
using Content.Shared.Popups;
|
||||
using Content.Shared.Verbs;
|
||||
using Content.Shared.Whitelist;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Server.Animals.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// The ParrotMemorySystem handles remembering messages received through local chat (activelistener) or a radio
|
||||
/// (radiovocalizer) and stores them in a list. When an entity with a VocalizerComponent attempts to vocalize, this will
|
||||
/// try to set the message from memory.
|
||||
/// </summary>
|
||||
public sealed partial class ParrotMemorySystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly EntityWhitelistSystem _whitelist = default!;
|
||||
[Dependency] private readonly IAdminLogManager _adminLogger = default!;
|
||||
[Dependency] private readonly IAdminManager _admin = default!;
|
||||
[Dependency] private readonly IGameTiming _gameTiming = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly MindSystem _mind = default!;
|
||||
[Dependency] private readonly MobStateSystem _mobState = default!;
|
||||
[Dependency] private readonly PopupSystem _popup = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
SubscribeLocalEvent<EraseEvent>(OnErase);
|
||||
|
||||
SubscribeLocalEvent<ParrotMemoryComponent, GetVerbsEvent<Verb>>(OnGetVerbs);
|
||||
|
||||
SubscribeLocalEvent<ParrotListenerComponent, MapInitEvent>(ListenerOnMapInit);
|
||||
|
||||
SubscribeLocalEvent<ParrotListenerComponent, ListenEvent>(OnListen);
|
||||
SubscribeLocalEvent<ParrotListenerComponent, HeadsetRadioReceiveRelayEvent>(OnHeadsetReceive);
|
||||
|
||||
SubscribeLocalEvent<ParrotMemoryComponent, TryVocalizeEvent>(OnTryVocalize);
|
||||
}
|
||||
|
||||
private void OnErase(ref EraseEvent args)
|
||||
{
|
||||
DeletePlayerMessages(args.PlayerNetUserId);
|
||||
}
|
||||
|
||||
private void OnGetVerbs(Entity<ParrotMemoryComponent> entity, ref GetVerbsEvent<Verb> args)
|
||||
{
|
||||
var user = args.User;
|
||||
|
||||
// limit this to admins
|
||||
if (!_admin.IsAdmin(user))
|
||||
return;
|
||||
|
||||
// simple verb that just clears the memory list
|
||||
var clearMemoryVerb = new Verb()
|
||||
{
|
||||
Text = Loc.GetString("parrot-verb-clear-memory"),
|
||||
Category = VerbCategory.Admin,
|
||||
Icon = new SpriteSpecifier.Texture(new("/Textures/Interface/AdminActions/clear-parrot.png")),
|
||||
Act = () =>
|
||||
{
|
||||
entity.Comp.SpeechMemories.Clear();
|
||||
_popup.PopupEntity(Loc.GetString("parrot-popup-memory-cleared"), entity, user, PopupType.Medium);
|
||||
},
|
||||
};
|
||||
|
||||
args.Verbs.Add(clearMemoryVerb);
|
||||
}
|
||||
|
||||
private void ListenerOnMapInit(Entity<ParrotListenerComponent> entity, ref MapInitEvent args)
|
||||
{
|
||||
// If an entity has a ParrotListenerComponent it really ought to have an ActiveListenerComponent
|
||||
if (!HasComp<ActiveListenerComponent>(entity))
|
||||
Log.Warning($"Entity {ToPrettyString(entity)} has a ParrotListenerComponent but was not given an ActiveListenerComponent");
|
||||
}
|
||||
|
||||
private void OnListen(Entity<ParrotListenerComponent> entity, ref ListenEvent args)
|
||||
{
|
||||
|
||||
TryLearn(entity.Owner, args.Message, args.Source);
|
||||
}
|
||||
|
||||
private void OnHeadsetReceive(Entity<ParrotListenerComponent> entity, ref HeadsetRadioReceiveRelayEvent args)
|
||||
{
|
||||
var message = args.RelayedEvent.Message;
|
||||
var source = args.RelayedEvent.MessageSource;
|
||||
|
||||
TryLearn(entity.Owner, message, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an entity with a ParrotMemoryComponent tries to vocalize.
|
||||
/// This function picks a message from memory and sets the event to handled
|
||||
/// </summary>
|
||||
private void OnTryVocalize(Entity<ParrotMemoryComponent> entity, ref TryVocalizeEvent args)
|
||||
{
|
||||
// return if this was already handled
|
||||
if (args.Handled)
|
||||
return;
|
||||
|
||||
// if there are no memories, return
|
||||
if (entity.Comp.SpeechMemories.Count == 0)
|
||||
return;
|
||||
|
||||
// get a random memory from the memory list
|
||||
var memory = _random.Pick(entity.Comp.SpeechMemories);
|
||||
|
||||
args.Message = memory.Message;
|
||||
args.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Try to learn a new message, returning early if this entity cannot learn a new message,
|
||||
/// the message doesn't pass certain checks, or the chance for learning a new message fails
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity learning a new word</param>
|
||||
/// <param name="incomingMessage">Message to learn</param>
|
||||
/// <param name="source">Source EntityUid of the message</param>
|
||||
public void TryLearn(Entity<ParrotMemoryComponent?, ParrotListenerComponent?> entity, string incomingMessage, EntityUid source)
|
||||
{
|
||||
if (!Resolve(entity, ref entity.Comp1, ref entity.Comp2))
|
||||
return;
|
||||
|
||||
if (!_whitelist.CheckBoth(source, entity.Comp2.Blacklist, entity.Comp2.Whitelist))
|
||||
return;
|
||||
|
||||
if (source.Equals(entity) || _mobState.IsIncapacitated(entity))
|
||||
return;
|
||||
|
||||
// can't learn too soon after having already learnt something else
|
||||
if (_gameTiming.CurTime < entity.Comp1.NextLearnInterval)
|
||||
return;
|
||||
|
||||
// remove whitespace around message, if any
|
||||
var message = incomingMessage.Trim();
|
||||
|
||||
// ignore messages containing tildes. This is a crude way to ignore whispers that are too far away
|
||||
// TODO: this isn't great. This should be replaced with a const or we should have a better way to check faraway messages
|
||||
if (message.Contains('~'))
|
||||
return;
|
||||
|
||||
// ignore empty messages. These probably aren't sent anyway but just in case
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
return;
|
||||
|
||||
// ignore messages that are too short or too long
|
||||
if (message.Length < entity.Comp1.MinEntryLength || message.Length > entity.Comp1.MaxEntryLength)
|
||||
return;
|
||||
|
||||
// only from this point this message has a chance of being learned
|
||||
// set new time for learn interval, regardless of whether the learning succeeds
|
||||
entity.Comp1.NextLearnInterval = _gameTiming.CurTime + entity.Comp1.LearnCooldown;
|
||||
|
||||
// decide if this message passes the learning chance
|
||||
if (!_random.Prob(entity.Comp1.LearnChance))
|
||||
return;
|
||||
|
||||
// actually commit this message to memory
|
||||
Learn((entity, entity.Comp1), message, source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actually learn a message and commit it to memory
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity learning a new word</param>
|
||||
/// <param name="message">Message to learn</param>
|
||||
/// <param name="source">Source EntityUid of the message</param>
|
||||
private void Learn(Entity<ParrotMemoryComponent> entity, string message, EntityUid source)
|
||||
{
|
||||
// log a low-priority chat type log to the admin logger
|
||||
// specifies what message was learnt by what entity, and who taught the message to that entity
|
||||
_adminLogger.Add(LogType.Chat, LogImpact.Low, $"Parroting entity {ToPrettyString(entity):entity} learned the phrase \"{message}\" from {ToPrettyString(source):speaker}");
|
||||
|
||||
NetUserId? sourceNetUserId = null;
|
||||
if (_mind.TryGetMind(source, out _, out var mind))
|
||||
{
|
||||
sourceNetUserId = mind.UserId;
|
||||
}
|
||||
|
||||
var newMemory = new SpeechMemory(sourceNetUserId, message);
|
||||
|
||||
// add a new message if there is space in the memory
|
||||
if (entity.Comp.SpeechMemories.Count < entity.Comp.MaxSpeechMemory)
|
||||
{
|
||||
entity.Comp.SpeechMemories.Add(newMemory);
|
||||
return;
|
||||
}
|
||||
|
||||
// if there's no space in memory, replace something at random
|
||||
var replaceIdx = _random.Next(entity.Comp.SpeechMemories.Count);
|
||||
entity.Comp.SpeechMemories[replaceIdx] = newMemory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all messages from a specified player on all ParrotMemoryComponents
|
||||
/// </summary>
|
||||
/// <param name="playerNetUserId">The player of whom to delete messages</param>
|
||||
private void DeletePlayerMessages(NetUserId playerNetUserId)
|
||||
{
|
||||
// query to enumerate all entities with a memorycomponent
|
||||
var query = EntityQueryEnumerator<ParrotMemoryComponent>();
|
||||
while (query.MoveNext(out _, out var memory))
|
||||
{
|
||||
DeletePlayerMessages(memory, playerNetUserId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete all messages from a specified player on a given ParrotMemoryComponent
|
||||
/// </summary>
|
||||
/// <param name="memoryComponent">The ParrotMemoryComponent on which to delete messages</param>
|
||||
/// <param name="playerNetUserId">The player of whom to delete messages</param>
|
||||
private void DeletePlayerMessages(ParrotMemoryComponent memoryComponent, NetUserId playerNetUserId)
|
||||
{
|
||||
// this is a sort of expensive operation that is hopefully rare and performed on just a few parrots
|
||||
// with limited memory
|
||||
for (var i = 0; i < memoryComponent.SpeechMemories.Count; i++)
|
||||
{
|
||||
var memory = memoryComponent.SpeechMemories[i];
|
||||
|
||||
// netuserid may be null if the message was learnt from a non-player entity
|
||||
if (memory.NetUserId is null)
|
||||
continue;
|
||||
|
||||
// skip if this memory was not learnt from the target user
|
||||
if (!memory.NetUserId.Equals(playerNetUserId))
|
||||
continue;
|
||||
|
||||
// order isn't important in this list so we can use the faster means of removing
|
||||
memoryComponent.SpeechMemories.RemoveSwap(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,19 @@ public sealed class HeadsetSystem : SharedHeadsetSystem
|
||||
|
||||
private void OnHeadsetReceive(EntityUid uid, HeadsetComponent component, ref RadioReceiveEvent args)
|
||||
{
|
||||
if (TryComp(Transform(uid).ParentUid, out ActorComponent? actor))
|
||||
// TODO: change this when a code refactor is done
|
||||
// this is currently done this way because receiving radio messages on an entity otherwise requires that entity
|
||||
// to have an ActiveRadioComponent
|
||||
|
||||
var parent = Transform(uid).ParentUid;
|
||||
|
||||
if (parent.IsValid())
|
||||
{
|
||||
var relayEvent = new HeadsetRadioReceiveRelayEvent(args);
|
||||
RaiseLocalEvent(parent, ref relayEvent);
|
||||
}
|
||||
|
||||
if (TryComp(parent, out ActorComponent? actor))
|
||||
_netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,12 @@ namespace Content.Server.Radio;
|
||||
[ByRefEvent]
|
||||
public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg);
|
||||
|
||||
/// <summary>
|
||||
/// Event raised on the parent entity of a headset radio when a radio message is received
|
||||
/// </summary>
|
||||
[ByRefEvent]
|
||||
public readonly record struct HeadsetRadioReceiveRelayEvent(RadioReceiveEvent RelayedEvent);
|
||||
|
||||
/// <summary>
|
||||
/// Use this event to cancel sending message per receiver
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Content.Server.Vocalization.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Makes an entity able to vocalize through an equipped radio
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
public sealed partial class RadioVocalizerComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// chance the vocalizing entity speaks on the radio.
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public float RadioAttemptChance = 0.3f;
|
||||
}
|
||||
30
Content.Server/Vocalization/Components/VocalizerComponent.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
|
||||
|
||||
namespace Content.Server.Vocalization.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Makes an entity vocalize at set intervals
|
||||
/// </summary>
|
||||
[RegisterComponent]
|
||||
[AutoGenerateComponentPause]
|
||||
public sealed partial class VocalizerComponent : Component
|
||||
{
|
||||
/// <summary>
|
||||
/// Minimum time to wait after speaking to vocalize again
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public TimeSpan MinVocalizeInterval = TimeSpan.FromMinutes(2);
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time to wait after speaking to vocalize again
|
||||
/// </summary>
|
||||
[DataField]
|
||||
public TimeSpan MaxVocalizeInterval = TimeSpan.FromMinutes(6);
|
||||
|
||||
/// <summary>
|
||||
/// Next time at which to vocalize
|
||||
/// </summary>
|
||||
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer))]
|
||||
[AutoPausedField]
|
||||
public TimeSpan NextVocalizeInterval = TimeSpan.Zero;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using Content.Server.Chat.Systems;
|
||||
using Content.Server.Radio.Components;
|
||||
using Content.Server.Vocalization.Components;
|
||||
using Content.Shared.Chat;
|
||||
using Content.Shared.Inventory;
|
||||
using Content.Shared.Radio;
|
||||
using Robust.Shared.Prototypes;
|
||||
using Robust.Shared.Random;
|
||||
|
||||
namespace Content.Server.Vocalization.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// RadioVocalizationSystem handles vocalizing things via equipped radios when a VocalizeEvent is fired
|
||||
/// </summary>
|
||||
public sealed partial class RadioVocalizationSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly ChatSystem _chat = default!;
|
||||
[Dependency] private readonly IPrototypeManager _proto = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
[Dependency] private readonly InventorySystem _inventory = default!;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
base.Initialize();
|
||||
|
||||
SubscribeLocalEvent<RadioVocalizerComponent, VocalizeEvent>(OnVocalize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called whenever an entity with a VocalizerComponent tries to speak
|
||||
/// </summary>
|
||||
private void OnVocalize(Entity<RadioVocalizerComponent> entity, ref VocalizeEvent args)
|
||||
{
|
||||
if (args.Handled)
|
||||
return;
|
||||
|
||||
// set to handled if we succeed in speaking on the radio
|
||||
args.Handled = TrySpeakRadio(entity.Owner, args.Message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Selects a random radio channel from all ActiveRadio entities in a given entity's inventory
|
||||
/// If no channels are found, this returns false and sets channel to an empty string
|
||||
/// </summary>
|
||||
private bool TryPickRandomRadioChannel(EntityUid entity, out string channel)
|
||||
{
|
||||
HashSet<string> potentialChannels = [];
|
||||
|
||||
// we don't have to check if this entity has an inventory. GetHandOrInventoryEntities will not yield anything
|
||||
// if an entity has no inventory or inventory slots
|
||||
foreach (var item in _inventory.GetHandOrInventoryEntities(entity))
|
||||
{
|
||||
if (!TryComp<ActiveRadioComponent>(item, out var radio))
|
||||
continue;
|
||||
|
||||
potentialChannels.UnionWith(radio.Channels);
|
||||
}
|
||||
|
||||
if (potentialChannels.Count == 0)
|
||||
{
|
||||
channel = string.Empty;
|
||||
return false;
|
||||
}
|
||||
|
||||
channel = _random.Pick(potentialChannels);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to speak on the radio. Returns false if there is no radio or talking on radio fails somehow
|
||||
/// </summary>
|
||||
/// <param name="entity">Entity to try and make speak on the radio</param>
|
||||
/// <param name="message">Message to speak</param>
|
||||
private bool TrySpeakRadio(Entity<RadioVocalizerComponent?> entity, string message)
|
||||
{
|
||||
if (!Resolve(entity, ref entity.Comp))
|
||||
return false;
|
||||
|
||||
if (!_random.Prob(entity.Comp.RadioAttemptChance))
|
||||
return false;
|
||||
|
||||
if (!TryPickRandomRadioChannel(entity, out var channel))
|
||||
return false;
|
||||
|
||||
var channelPrefix = _proto.Index<RadioChannelPrototype>(channel).KeyCode;
|
||||
|
||||
// send a whisper using the radio channel prefix and whatever relevant radio channel character
|
||||
// along with the message. This is analogous to how radio messages are sent by players
|
||||
_chat.TrySendInGameICMessage(
|
||||
entity,
|
||||
$"{SharedChatSystem.RadioChannelPrefix}{channelPrefix} {message}",
|
||||
InGameICChatType.Whisper,
|
||||
ChatTransmitRange.Normal);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
111
Content.Server/Vocalization/Systems/VocalizationSystem.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using Content.Server.Chat.Systems;
|
||||
using Content.Server.Vocalization.Components;
|
||||
using Content.Shared.ActionBlocker;
|
||||
using Robust.Shared.Random;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Content.Server.Vocalization.Systems;
|
||||
|
||||
/// <summary>
|
||||
/// VocalizationSystem raises VocalizeEvents to make entities speak at certain intervals
|
||||
/// This is used in combination with systems like ParrotMemorySystem to randomly say messages from memory,
|
||||
/// or can be used by other systems to speak pre-set messages
|
||||
/// </summary>
|
||||
public sealed partial class VocalizationSystem : EntitySystem
|
||||
{
|
||||
[Dependency] private readonly ActionBlockerSystem _actionBlocker = default!;
|
||||
[Dependency] private readonly ChatSystem _chat = default!;
|
||||
[Dependency] private readonly IGameTiming _gameTiming = default!;
|
||||
[Dependency] private readonly IRobustRandom _random = default!;
|
||||
|
||||
/// <summary>
|
||||
/// Try speaking by raising a TryVocalizeEvent
|
||||
/// This event is passed to systems adding a message to it and setting it to handled
|
||||
/// </summary>
|
||||
private void TrySpeak(Entity<VocalizerComponent> entity)
|
||||
{
|
||||
var tryVocalizeEvent = new TryVocalizeEvent();
|
||||
RaiseLocalEvent(entity.Owner, ref tryVocalizeEvent);
|
||||
|
||||
// if the event was never handled, return
|
||||
// this happens if there are no components that trigger systems to add a message to this event
|
||||
if (!tryVocalizeEvent.Handled)
|
||||
return;
|
||||
|
||||
// if the event's message is null for whatever reason, return.
|
||||
// this would mean a system didn't set the message properly but did set the event to handled
|
||||
if (tryVocalizeEvent.Message is not { } message)
|
||||
return;
|
||||
|
||||
Speak(entity, message);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Actually say something.
|
||||
/// </summary>
|
||||
private void Speak(Entity<VocalizerComponent> entity, string message)
|
||||
{
|
||||
// raise a VocalizeEvent
|
||||
// this can be handled by other systems to speak using a method other than local chat
|
||||
var vocalizeEvent = new VocalizeEvent(message);
|
||||
RaiseLocalEvent(entity.Owner, ref vocalizeEvent);
|
||||
|
||||
// if the event is handled, don't try speaking
|
||||
if (vocalizeEvent.Handled)
|
||||
return;
|
||||
|
||||
// default to local chat if no other system handles the event
|
||||
// first check if the entity can speak
|
||||
if (!_actionBlocker.CanSpeak(entity))
|
||||
return;
|
||||
|
||||
// send the message
|
||||
_chat.TrySendInGameICMessage(entity, message, InGameICChatType.Speak, ChatTransmitRange.Normal);
|
||||
}
|
||||
|
||||
public override void Update(float frameTime)
|
||||
{
|
||||
base.Update(frameTime);
|
||||
|
||||
// get current game time for delay
|
||||
var currentGameTime = _gameTiming.CurTime;
|
||||
|
||||
// query to get all entities with a VocalizeComponent
|
||||
var query = EntityQueryEnumerator<VocalizerComponent>();
|
||||
while (query.MoveNext(out var uid, out var vocalizer))
|
||||
{
|
||||
// go to next entity if it is too early for this one to speak
|
||||
if (currentGameTime < vocalizer.NextVocalizeInterval)
|
||||
continue;
|
||||
|
||||
// set a new time for the speak interval, regardless of whether speaking works
|
||||
var randomSpeakInterval = _random.Next(vocalizer.MinVocalizeInterval, vocalizer.MaxVocalizeInterval);
|
||||
vocalizer.NextVocalizeInterval += randomSpeakInterval;
|
||||
|
||||
// if an admin updates the speak interval to be immediate, this loop will spam messages until the
|
||||
// nextspeakinterval catches up with the current game time. Prevent this from happening
|
||||
if (vocalizer.NextVocalizeInterval < _gameTiming.CurTime)
|
||||
vocalizer.NextVocalizeInterval = _gameTiming.CurTime + randomSpeakInterval;
|
||||
|
||||
// try to speak
|
||||
TrySpeak((uid, vocalizer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the entity wants to try vocalizing, but doesn't have a message yet
|
||||
/// </summary>
|
||||
/// <param name="Message">Message to send, this is null when the event is just fired and should be set by a system</param>
|
||||
/// <param name="Handled">Whether the message was handled by a system</param>
|
||||
[ByRefEvent]
|
||||
public record struct TryVocalizeEvent(string? Message = null, bool Handled = false);
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the entity wants to vocalize and has a message. Allows for interception by other systems if the
|
||||
/// vocalization needs to be done some other way
|
||||
/// </summary>
|
||||
/// <param name="Message">Message to send</param>
|
||||
/// <param name="Handled">Whether the message was handled by a system</param>
|
||||
[ByRefEvent]
|
||||
public record struct VocalizeEvent(string Message, bool Handled = false);
|
||||
2
Resources/Locale/en-US/animals/parrot/parrot.ftl
Normal file
@@ -0,0 +1,2 @@
|
||||
parrot-verb-clear-memory = Clear parrot memory
|
||||
parrot-popup-memory-cleared = Parrot memory cleared
|
||||
@@ -151,7 +151,7 @@
|
||||
- type: cargoProduct
|
||||
id: LivestockParrot
|
||||
icon:
|
||||
sprite: Mobs/Animals/parrot.rsi
|
||||
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||
state: parrot
|
||||
product: CrateNPCParrot
|
||||
cost: 3000
|
||||
|
||||
@@ -210,7 +210,7 @@
|
||||
layers:
|
||||
- state: green
|
||||
- state: parrot
|
||||
sprite: Mobs/Animals/parrot.rsi
|
||||
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||
- type: ConditionalSpawner
|
||||
prototypes:
|
||||
- MobParrot
|
||||
|
||||
@@ -218,6 +218,21 @@
|
||||
prototypes:
|
||||
- MobMonkeyPunpun
|
||||
|
||||
- type: entity
|
||||
parent: MarkerBase
|
||||
id: SpawnMobPollyParrot
|
||||
name: Polly the parrot Spawner
|
||||
suffix: CE Pet
|
||||
components:
|
||||
- type: Sprite
|
||||
layers:
|
||||
- state: green
|
||||
- state: parrot
|
||||
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||
- type: ConditionalSpawner
|
||||
prototypes:
|
||||
- MobPollyParrot
|
||||
|
||||
- type: entity
|
||||
parent: MarkerBase
|
||||
id: SpawnMobPossumMorty
|
||||
|
||||
@@ -2174,9 +2174,9 @@
|
||||
|
||||
# Would be cool to have some functionality for the parrot to be able to sit on stuff
|
||||
- type: entity
|
||||
name: parrot
|
||||
parent: [ SimpleMobBase, FlyingMobBase ]
|
||||
id: MobParrot
|
||||
id: MobParrotBase
|
||||
abstract: true
|
||||
description: Infiltrates your domain, spies on you, and somehow still a cool pet.
|
||||
components:
|
||||
- type: MovementSpeedModifier
|
||||
@@ -2187,7 +2187,7 @@
|
||||
layers:
|
||||
- map: ["enum.DamageStateVisualLayers.Base"]
|
||||
state: parrot
|
||||
sprite: Mobs/Animals/parrot.rsi
|
||||
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||
- type: Fixtures
|
||||
fixtures:
|
||||
fix1:
|
||||
@@ -2217,7 +2217,29 @@
|
||||
- type: Vocal
|
||||
sounds:
|
||||
Unsexed: Parrot
|
||||
- type: ParrotAccent
|
||||
- type: ActiveListener
|
||||
- type: Vocalizer
|
||||
- type: RadioVocalizer
|
||||
- type: ParrotListener
|
||||
- type: ParrotMemory
|
||||
- type: Inventory
|
||||
templateId: parrot
|
||||
speciesId: parrot
|
||||
# transparent displacement map to hide the headset. this should be an animated displacement map to follow the parrot
|
||||
# as it bobs up and down, but this in turn needs some way to change the displacement map on inventory entities when
|
||||
# the parrot dies, otherwise the visuals will be broken
|
||||
displacements:
|
||||
ears:
|
||||
sizeMaps:
|
||||
32:
|
||||
sprite: Mobs/Animals/parrot/displacement.rsi
|
||||
state: ears
|
||||
- type: InventorySlots
|
||||
- type: Strippable
|
||||
- type: UserInterface
|
||||
interfaces:
|
||||
enum.StrippingUiKey.Key:
|
||||
type: StrippableBoundUserInterface
|
||||
- type: InteractionPopup
|
||||
successChance: 0.6
|
||||
interactSuccessString: petting-success-bird
|
||||
@@ -2228,6 +2250,13 @@
|
||||
- type: Bloodstream
|
||||
bloodMaxVolume: 50
|
||||
|
||||
- type: entity
|
||||
name: parrot
|
||||
parent: MobParrotBase
|
||||
id: MobParrot
|
||||
components:
|
||||
- type: ParrotAccent # regular parrots have a parrotaccent. Polly is special and does not
|
||||
|
||||
- type: entity
|
||||
name: penguin
|
||||
parent: SimpleMobBase
|
||||
|
||||
@@ -858,3 +858,21 @@
|
||||
# - type: AlwaysRevolutionaryConvertible
|
||||
- type: StealTarget
|
||||
stealGroup: AnimalTropico
|
||||
|
||||
- type: entity
|
||||
name: Polly the parrot
|
||||
parent: MobParrotBase
|
||||
id: MobPollyParrot
|
||||
description: An expert in quantum cracker theory
|
||||
components:
|
||||
- type: ParrotMemory
|
||||
learnChance: 0.5 # polly is smarter
|
||||
- type: Vocalizer
|
||||
maxVocalizeInterval: 240 # polly is chattier
|
||||
- type: Grammar
|
||||
attributes:
|
||||
proper: true
|
||||
gender: male
|
||||
- type: Loadout
|
||||
prototypes: [ MobPollyGear ]
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
- type: inventoryTemplate
|
||||
id: parrot
|
||||
slots:
|
||||
- name: ears
|
||||
slotTexture: ears
|
||||
slotFlags: EARS
|
||||
stripTime: 3
|
||||
uiWindowPos: 0,2
|
||||
strippingWindowPos: 0,0
|
||||
displayName: Ears
|
||||
@@ -103,6 +103,11 @@
|
||||
jumpsuit: ClothingUniformJumpsuitJacketMonkey
|
||||
id: PunPunIDCard
|
||||
|
||||
- type: startingGear
|
||||
id: MobPollyGear
|
||||
equipment:
|
||||
ears: ClothingHeadsetEngineering
|
||||
|
||||
# Emotional Support Scurret
|
||||
|
||||
- type: startingGear
|
||||
|
||||
BIN
Resources/Textures/Interface/AdminActions/clear-parrot.png
Normal file
|
After Width: | Height: | Size: 539 B |
|
Before Width: | Height: | Size: 16 KiB |
BIN
Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png
Normal file
|
After Width: | Height: | Size: 96 B |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"version": 1,
|
||||
"license": "CC-BY-3.0",
|
||||
"copyright": "Made by Crude Oil",
|
||||
"size": {
|
||||
"x": 32,
|
||||
"y": 32
|
||||
},
|
||||
"load": {
|
||||
"srgb": false
|
||||
},
|
||||
"states": [
|
||||
{
|
||||
"name": "ears",
|
||||
"directions": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
|
Before Width: | Height: | Size: 647 B After Width: | Height: | Size: 647 B |
|
Before Width: | Height: | Size: 593 B After Width: | Height: | Size: 593 B |
BIN
Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |