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);
|
_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))
|
if (!_minds.TryGetMind(uid, out var mindId, out var mind) || mind.OwnedEntity == null || TerminatingOrDeleted(mind.OwnedEntity.Value))
|
||||||
|
{
|
||||||
|
RaiseLocalEvent(ref eraseEvent);
|
||||||
return;
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var entity = mind.OwnedEntity.Value;
|
var entity = mind.OwnedEntity.Value;
|
||||||
|
|
||||||
@@ -444,6 +449,8 @@ public sealed class AdminSystem : EntitySystem
|
|||||||
|
|
||||||
if (_playerManager.TryGetSessionById(uid, out var session))
|
if (_playerManager.TryGetSessionById(uid, out var session))
|
||||||
_gameTicker.SpawnObserver(session);
|
_gameTicker.SpawnObserver(session);
|
||||||
|
|
||||||
|
RaiseLocalEvent(ref eraseEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnSessionPlayTimeUpdated(ICommonSession session)
|
private void OnSessionPlayTimeUpdated(ICommonSession session)
|
||||||
@@ -451,3 +458,10 @@ public sealed class AdminSystem : EntitySystem
|
|||||||
UpdatePlayerList(session);
|
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)
|
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);
|
_netMan.ServerSendMessage(args.ChatMsg, actor.PlayerSession.Channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ namespace Content.Server.Radio;
|
|||||||
[ByRefEvent]
|
[ByRefEvent]
|
||||||
public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg);
|
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>
|
/// <summary>
|
||||||
/// Use this event to cancel sending message per receiver
|
/// Use this event to cancel sending message per receiver
|
||||||
/// </summary>
|
/// </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
|
- type: cargoProduct
|
||||||
id: LivestockParrot
|
id: LivestockParrot
|
||||||
icon:
|
icon:
|
||||||
sprite: Mobs/Animals/parrot.rsi
|
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||||
state: parrot
|
state: parrot
|
||||||
product: CrateNPCParrot
|
product: CrateNPCParrot
|
||||||
cost: 3000
|
cost: 3000
|
||||||
|
|||||||
@@ -210,7 +210,7 @@
|
|||||||
layers:
|
layers:
|
||||||
- state: green
|
- state: green
|
||||||
- state: parrot
|
- state: parrot
|
||||||
sprite: Mobs/Animals/parrot.rsi
|
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||||
- type: ConditionalSpawner
|
- type: ConditionalSpawner
|
||||||
prototypes:
|
prototypes:
|
||||||
- MobParrot
|
- MobParrot
|
||||||
|
|||||||
@@ -218,6 +218,21 @@
|
|||||||
prototypes:
|
prototypes:
|
||||||
- MobMonkeyPunpun
|
- 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
|
- type: entity
|
||||||
parent: MarkerBase
|
parent: MarkerBase
|
||||||
id: SpawnMobPossumMorty
|
id: SpawnMobPossumMorty
|
||||||
|
|||||||
@@ -2174,9 +2174,9 @@
|
|||||||
|
|
||||||
# Would be cool to have some functionality for the parrot to be able to sit on stuff
|
# Would be cool to have some functionality for the parrot to be able to sit on stuff
|
||||||
- type: entity
|
- type: entity
|
||||||
name: parrot
|
|
||||||
parent: [ SimpleMobBase, FlyingMobBase ]
|
parent: [ SimpleMobBase, FlyingMobBase ]
|
||||||
id: MobParrot
|
id: MobParrotBase
|
||||||
|
abstract: true
|
||||||
description: Infiltrates your domain, spies on you, and somehow still a cool pet.
|
description: Infiltrates your domain, spies on you, and somehow still a cool pet.
|
||||||
components:
|
components:
|
||||||
- type: MovementSpeedModifier
|
- type: MovementSpeedModifier
|
||||||
@@ -2187,7 +2187,7 @@
|
|||||||
layers:
|
layers:
|
||||||
- map: ["enum.DamageStateVisualLayers.Base"]
|
- map: ["enum.DamageStateVisualLayers.Base"]
|
||||||
state: parrot
|
state: parrot
|
||||||
sprite: Mobs/Animals/parrot.rsi
|
sprite: Mobs/Animals/parrot/parrot.rsi
|
||||||
- type: Fixtures
|
- type: Fixtures
|
||||||
fixtures:
|
fixtures:
|
||||||
fix1:
|
fix1:
|
||||||
@@ -2217,7 +2217,29 @@
|
|||||||
- type: Vocal
|
- type: Vocal
|
||||||
sounds:
|
sounds:
|
||||||
Unsexed: Parrot
|
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
|
- type: InteractionPopup
|
||||||
successChance: 0.6
|
successChance: 0.6
|
||||||
interactSuccessString: petting-success-bird
|
interactSuccessString: petting-success-bird
|
||||||
@@ -2228,6 +2250,13 @@
|
|||||||
- type: Bloodstream
|
- type: Bloodstream
|
||||||
bloodMaxVolume: 50
|
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
|
- type: entity
|
||||||
name: penguin
|
name: penguin
|
||||||
parent: SimpleMobBase
|
parent: SimpleMobBase
|
||||||
|
|||||||
@@ -858,3 +858,21 @@
|
|||||||
# - type: AlwaysRevolutionaryConvertible
|
# - type: AlwaysRevolutionaryConvertible
|
||||||
- type: StealTarget
|
- type: StealTarget
|
||||||
stealGroup: AnimalTropico
|
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
|
jumpsuit: ClothingUniformJumpsuitJacketMonkey
|
||||||
id: PunPunIDCard
|
id: PunPunIDCard
|
||||||
|
|
||||||
|
- type: startingGear
|
||||||
|
id: MobPollyGear
|
||||||
|
equipment:
|
||||||
|
ears: ClothingHeadsetEngineering
|
||||||
|
|
||||||
# Emotional Support Scurret
|
# Emotional Support Scurret
|
||||||
|
|
||||||
- type: startingGear
|
- 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 |