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
This commit is contained in:
Crude Oil
2025-07-09 21:04:57 +02:00
committed by GitHub
parent e08e920c62
commit 9ebf6a24c4
27 changed files with 725 additions and 7 deletions

View File

@@ -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);

View 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;
}

View 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);

View 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);
}
}
}

View File

@@ -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);
} }

View File

@@ -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>

View File

@@ -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;
}

View 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;
}

View File

@@ -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;
}
}

View 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);

View File

@@ -0,0 +1,2 @@
parrot-verb-clear-memory = Clear parrot memory
parrot-popup-memory-cleared = Parrot memory cleared

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 ]

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

View File

@@ -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
}
]
}

View File

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 647 B

View File

Before

Width:  |  Height:  |  Size: 593 B

After

Width:  |  Height:  |  Size: 593 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB