diff --git a/Content.Server/Administration/Systems/AdminSystem.cs b/Content.Server/Administration/Systems/AdminSystem.cs index 56bbc225cc..78433db129 100644 --- a/Content.Server/Administration/Systems/AdminSystem.cs +++ b/Content.Server/Administration/Systems/AdminSystem.cs @@ -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); } } + +/// +/// Event fired after a player is erased by an admin +/// +/// NetUserId of the player that was the target of the Erase +[ByRefEvent] +public record struct EraseEvent(NetUserId PlayerNetUserId); diff --git a/Content.Server/Animals/Components/ParrotListenerComponent.cs b/Content.Server/Animals/Components/ParrotListenerComponent.cs new file mode 100644 index 0000000000..9d0c8a2d01 --- /dev/null +++ b/Content.Server/Animals/Components/ParrotListenerComponent.cs @@ -0,0 +1,32 @@ +using Content.Shared.Whitelist; + +namespace Content.Server.Animals.Components; + +/// +/// Makes an entity able to listen to messages from IC chat and attempt to commit them to memory +/// +[RegisterComponent] +public sealed partial class ParrotListenerComponent : Component +{ + /// + /// 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 + /// + [DataField] + public EntityWhitelist? Whitelist; + + /// + /// 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. + /// + [DataField] + public EntityWhitelist? Blacklist; +} diff --git a/Content.Server/Animals/Components/ParrotMemoryComponent.cs b/Content.Server/Animals/Components/ParrotMemoryComponent.cs new file mode 100644 index 0000000000..2c3771cfac --- /dev/null +++ b/Content.Server/Animals/Components/ParrotMemoryComponent.cs @@ -0,0 +1,57 @@ +using Robust.Shared.Network; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Server.Animals.Components; + +/// +/// Makes an entity able to memorize chat/radio messages +/// +[RegisterComponent] +[AutoGenerateComponentPause] +public sealed partial class ParrotMemoryComponent : Component +{ + /// + /// List of SpeechMemory records this entity has learned + /// + [DataField] + public List SpeechMemories = []; + + /// + /// The % chance an entity with this component learns a phrase when learning is off cooldown + /// + [DataField] + public float LearnChance = 0.4f; + + /// + /// Time after which another attempt can be made at learning a phrase + /// + [DataField] + public TimeSpan LearnCooldown = TimeSpan.FromMinutes(1); + + /// + /// Next time at which the parrot can attempt to learn something + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] + [AutoPausedField] + public TimeSpan NextLearnInterval = TimeSpan.Zero; + + /// + /// The number of speech entries that are remembered + /// + [DataField] + public int MaxSpeechMemory = 50; + + /// + /// Minimum length of a speech entry + /// + [DataField] + public int MinEntryLength = 4; + + /// + /// Maximum length of a speech entry + /// + [DataField] + public int MaxEntryLength = 50; +} + +public record struct SpeechMemory(NetUserId? NetUserId, string Message); diff --git a/Content.Server/Animals/Systems/ParrotMemorySystem.cs b/Content.Server/Animals/Systems/ParrotMemorySystem.cs new file mode 100644 index 0000000000..eb3429821c --- /dev/null +++ b/Content.Server/Animals/Systems/ParrotMemorySystem.cs @@ -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; + +/// +/// 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. +/// +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(OnErase); + + SubscribeLocalEvent>(OnGetVerbs); + + SubscribeLocalEvent(ListenerOnMapInit); + + SubscribeLocalEvent(OnListen); + SubscribeLocalEvent(OnHeadsetReceive); + + SubscribeLocalEvent(OnTryVocalize); + } + + private void OnErase(ref EraseEvent args) + { + DeletePlayerMessages(args.PlayerNetUserId); + } + + private void OnGetVerbs(Entity entity, ref GetVerbsEvent 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 entity, ref MapInitEvent args) + { + // If an entity has a ParrotListenerComponent it really ought to have an ActiveListenerComponent + if (!HasComp(entity)) + Log.Warning($"Entity {ToPrettyString(entity)} has a ParrotListenerComponent but was not given an ActiveListenerComponent"); + } + + private void OnListen(Entity entity, ref ListenEvent args) + { + + TryLearn(entity.Owner, args.Message, args.Source); + } + + private void OnHeadsetReceive(Entity entity, ref HeadsetRadioReceiveRelayEvent args) + { + var message = args.RelayedEvent.Message; + var source = args.RelayedEvent.MessageSource; + + TryLearn(entity.Owner, message, source); + } + + /// + /// Called when an entity with a ParrotMemoryComponent tries to vocalize. + /// This function picks a message from memory and sets the event to handled + /// + private void OnTryVocalize(Entity 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; + } + + /// + /// 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 + /// + /// Entity learning a new word + /// Message to learn + /// Source EntityUid of the message + public void TryLearn(Entity 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); + } + + /// + /// Actually learn a message and commit it to memory + /// + /// Entity learning a new word + /// Message to learn + /// Source EntityUid of the message + private void Learn(Entity 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; + } + + /// + /// Delete all messages from a specified player on all ParrotMemoryComponents + /// + /// The player of whom to delete messages + private void DeletePlayerMessages(NetUserId playerNetUserId) + { + // query to enumerate all entities with a memorycomponent + var query = EntityQueryEnumerator(); + while (query.MoveNext(out _, out var memory)) + { + DeletePlayerMessages(memory, playerNetUserId); + } + } + + /// + /// Delete all messages from a specified player on a given ParrotMemoryComponent + /// + /// The ParrotMemoryComponent on which to delete messages + /// The player of whom to delete messages + 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); + } + } +} diff --git a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs index d18b044205..e3f8070311 100644 --- a/Content.Server/Radio/EntitySystems/HeadsetSystem.cs +++ b/Content.Server/Radio/EntitySystems/HeadsetSystem.cs @@ -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); } diff --git a/Content.Server/Radio/RadioEvent.cs b/Content.Server/Radio/RadioEvent.cs index fafa66674e..49ff63f824 100644 --- a/Content.Server/Radio/RadioEvent.cs +++ b/Content.Server/Radio/RadioEvent.cs @@ -6,6 +6,12 @@ namespace Content.Server.Radio; [ByRefEvent] public readonly record struct RadioReceiveEvent(string Message, EntityUid MessageSource, RadioChannelPrototype Channel, EntityUid RadioSource, MsgChatMessage ChatMsg); +/// +/// Event raised on the parent entity of a headset radio when a radio message is received +/// +[ByRefEvent] +public readonly record struct HeadsetRadioReceiveRelayEvent(RadioReceiveEvent RelayedEvent); + /// /// Use this event to cancel sending message per receiver /// diff --git a/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs b/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs new file mode 100644 index 0000000000..b2414f3796 --- /dev/null +++ b/Content.Server/Vocalization/Components/RadioVocalizerComponent.cs @@ -0,0 +1,14 @@ +namespace Content.Server.Vocalization.Components; + +/// +/// Makes an entity able to vocalize through an equipped radio +/// +[RegisterComponent] +public sealed partial class RadioVocalizerComponent : Component +{ + /// + /// chance the vocalizing entity speaks on the radio. + /// + [DataField] + public float RadioAttemptChance = 0.3f; +} diff --git a/Content.Server/Vocalization/Components/VocalizerComponent.cs b/Content.Server/Vocalization/Components/VocalizerComponent.cs new file mode 100644 index 0000000000..0dfde9751b --- /dev/null +++ b/Content.Server/Vocalization/Components/VocalizerComponent.cs @@ -0,0 +1,30 @@ +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Server.Vocalization.Components; + +/// +/// Makes an entity vocalize at set intervals +/// +[RegisterComponent] +[AutoGenerateComponentPause] +public sealed partial class VocalizerComponent : Component +{ + /// + /// Minimum time to wait after speaking to vocalize again + /// + [DataField] + public TimeSpan MinVocalizeInterval = TimeSpan.FromMinutes(2); + + /// + /// Maximum time to wait after speaking to vocalize again + /// + [DataField] + public TimeSpan MaxVocalizeInterval = TimeSpan.FromMinutes(6); + + /// + /// Next time at which to vocalize + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer))] + [AutoPausedField] + public TimeSpan NextVocalizeInterval = TimeSpan.Zero; +} diff --git a/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs b/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs new file mode 100644 index 0000000000..bdb14168bb --- /dev/null +++ b/Content.Server/Vocalization/Systems/RadioVocalizationSystem.cs @@ -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; + +/// +/// RadioVocalizationSystem handles vocalizing things via equipped radios when a VocalizeEvent is fired +/// +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(OnVocalize); + } + + /// + /// Called whenever an entity with a VocalizerComponent tries to speak + /// + private void OnVocalize(Entity 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); + } + + /// + /// 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 + /// + private bool TryPickRandomRadioChannel(EntityUid entity, out string channel) + { + HashSet 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(item, out var radio)) + continue; + + potentialChannels.UnionWith(radio.Channels); + } + + if (potentialChannels.Count == 0) + { + channel = string.Empty; + return false; + } + + channel = _random.Pick(potentialChannels); + + return true; + } + + /// + /// Attempts to speak on the radio. Returns false if there is no radio or talking on radio fails somehow + /// + /// Entity to try and make speak on the radio + /// Message to speak + private bool TrySpeakRadio(Entity 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(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; + } +} diff --git a/Content.Server/Vocalization/Systems/VocalizationSystem.cs b/Content.Server/Vocalization/Systems/VocalizationSystem.cs new file mode 100644 index 0000000000..b0a2e232a4 --- /dev/null +++ b/Content.Server/Vocalization/Systems/VocalizationSystem.cs @@ -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; + +/// +/// 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 +/// +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!; + + /// + /// Try speaking by raising a TryVocalizeEvent + /// This event is passed to systems adding a message to it and setting it to handled + /// + private void TrySpeak(Entity 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); + } + + /// + /// Actually say something. + /// + private void Speak(Entity 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(); + 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)); + } + } +} + +/// +/// Fired when the entity wants to try vocalizing, but doesn't have a message yet +/// +/// Message to send, this is null when the event is just fired and should be set by a system +/// Whether the message was handled by a system +[ByRefEvent] +public record struct TryVocalizeEvent(string? Message = null, bool Handled = false); + +/// +/// 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 +/// +/// Message to send +/// Whether the message was handled by a system +[ByRefEvent] +public record struct VocalizeEvent(string Message, bool Handled = false); diff --git a/Resources/Locale/en-US/animals/parrot/parrot.ftl b/Resources/Locale/en-US/animals/parrot/parrot.ftl new file mode 100644 index 0000000000..8262324d81 --- /dev/null +++ b/Resources/Locale/en-US/animals/parrot/parrot.ftl @@ -0,0 +1,2 @@ +parrot-verb-clear-memory = Clear parrot memory +parrot-popup-memory-cleared = Parrot memory cleared diff --git a/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml b/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml index 667b83ec3f..68b4b2dc58 100644 --- a/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml +++ b/Resources/Prototypes/Catalog/Cargo/cargo_livestock.yml @@ -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 diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml index 5691d984b7..ea39785032 100644 --- a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml +++ b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/animals.yml @@ -210,7 +210,7 @@ layers: - state: green - state: parrot - sprite: Mobs/Animals/parrot.rsi + sprite: Mobs/Animals/parrot/parrot.rsi - type: ConditionalSpawner prototypes: - MobParrot diff --git a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml index 0708f9a108..f590bcb508 100644 --- a/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml +++ b/Resources/Prototypes/Entities/Markers/Spawners/Mobs/pets.yml @@ -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 diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml index 2a599d70be..2b837b768e 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/animals.yml @@ -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 diff --git a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml index 738b1ac129..86be44dd05 100644 --- a/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml +++ b/Resources/Prototypes/Entities/Mobs/NPCs/pets.yml @@ -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 ] + diff --git a/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml b/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml new file mode 100644 index 0000000000..5460d730eb --- /dev/null +++ b/Resources/Prototypes/InventoryTemplates/parrot_inventory_template.yml @@ -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 diff --git a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml index 1448a07cba..13455651a2 100644 --- a/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml +++ b/Resources/Prototypes/Roles/Jobs/Fun/misc_startinggear.yml @@ -103,6 +103,11 @@ jumpsuit: ClothingUniformJumpsuitJacketMonkey id: PunPunIDCard +- type: startingGear + id: MobPollyGear + equipment: + ears: ClothingHeadsetEngineering + # Emotional Support Scurret - type: startingGear diff --git a/Resources/Textures/Interface/AdminActions/clear-parrot.png b/Resources/Textures/Interface/AdminActions/clear-parrot.png new file mode 100644 index 0000000000..bf027c421d Binary files /dev/null and b/Resources/Textures/Interface/AdminActions/clear-parrot.png differ diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png b/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png deleted file mode 100644 index fd9079fd66..0000000000 Binary files a/Resources/Textures/Mobs/Animals/parrot.rsi/parrot.png and /dev/null differ diff --git a/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png new file mode 100644 index 0000000000..7244b37f5c Binary files /dev/null and b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/ears.png differ diff --git a/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json new file mode 100644 index 0000000000..ad46a0b6ed --- /dev/null +++ b/Resources/Textures/Mobs/Animals/parrot/displacement.rsi/meta.json @@ -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 + } + ] +} diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/dead.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/dead.png similarity index 100% rename from Resources/Textures/Mobs/Animals/parrot.rsi/dead.png rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/dead.png diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/icon.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/icon.png similarity index 100% rename from Resources/Textures/Mobs/Animals/parrot.rsi/icon.png rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/icon.png diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/meta.json b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/meta.json similarity index 100% rename from Resources/Textures/Mobs/Animals/parrot.rsi/meta.json rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/meta.json diff --git a/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png new file mode 100644 index 0000000000..48b71f7b25 Binary files /dev/null and b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/parrot.png differ diff --git a/Resources/Textures/Mobs/Animals/parrot.rsi/sit.png b/Resources/Textures/Mobs/Animals/parrot/parrot.rsi/sit.png similarity index 100% rename from Resources/Textures/Mobs/Animals/parrot.rsi/sit.png rename to Resources/Textures/Mobs/Animals/parrot/parrot.rsi/sit.png