diff --git a/Content.Client/Changeling/Transform/ChangelingTransformBoundUserInterface.cs b/Content.Client/Changeling/Transform/ChangelingTransformBoundUserInterface.cs new file mode 100644 index 0000000000..8e383bc967 --- /dev/null +++ b/Content.Client/Changeling/Transform/ChangelingTransformBoundUserInterface.cs @@ -0,0 +1,35 @@ +using Content.Shared.Changeling.Transform; +using JetBrains.Annotations; +using Robust.Client.UserInterface; + +namespace Content.Client.Changeling.Transform; + +[UsedImplicitly] +public sealed partial class ChangelingTransformBoundUserInterface(EntityUid owner, Enum uiKey) : BoundUserInterface(owner, uiKey) +{ + private ChangelingTransformMenu? _window; + + protected override void Open() + { + base.Open(); + + _window = this.CreateWindow(); + + _window.OnIdentitySelect += SendIdentitySelect; + } + + protected override void UpdateState(BoundUserInterfaceState state) + { + base.UpdateState(state); + + if (state is not ChangelingTransformBoundUserInterfaceState current) + return; + + _window?.UpdateState(current); + } + + public void SendIdentitySelect(NetEntity identityId) + { + SendPredictedMessage(new ChangelingTransformIdentitySelectMessage(identityId)); + } +} diff --git a/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml b/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml new file mode 100644 index 0000000000..38ae0ec715 --- /dev/null +++ b/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml @@ -0,0 +1,8 @@ + + + + diff --git a/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml.cs b/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml.cs new file mode 100644 index 0000000000..beef9ae427 --- /dev/null +++ b/Content.Client/Changeling/Transform/ChangelingTransformMenu.xaml.cs @@ -0,0 +1,60 @@ +using System.Numerics; +using Content.Client.UserInterface.Controls; +using Content.Shared.Changeling.Transform; +using Robust.Client.AutoGenerated; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.XAML; + +namespace Content.Client.Changeling.Transform; + +[GenerateTypedNameReferences] +public sealed partial class ChangelingTransformMenu : RadialMenu +{ + [Dependency] private readonly IEntityManager _entity = default!; + public event Action? OnIdentitySelect; + + public ChangelingTransformMenu() + { + RobustXamlLoader.Load(this); + IoCManager.InjectDependencies(this); + } + + public void UpdateState(ChangelingTransformBoundUserInterfaceState state) + { + Main.DisposeAllChildren(); + foreach (var identity in state.Identites) + { + var identityUid = _entity.GetEntity(identity); + + if (!_entity.TryGetComponent(identityUid, out var metadata)) + continue; + + var identityName = metadata.EntityName; + + var button = new ChangelingTransformMenuButton() + { + StyleClasses = { "RadialMenuButton" }, + SetSize = new Vector2(64, 64), + ToolTip = identityName, + }; + + var entView = new SpriteView() + { + SetSize = new Vector2(48, 48), + VerticalAlignment = VAlignment.Center, + HorizontalAlignment = HAlignment.Center, + Stretch = SpriteView.StretchMode.Fill, + }; + entView.SetEntity(identityUid); + button.OnButtonUp += _ => + { + OnIdentitySelect?.Invoke(identity); + Close(); + }; + button.AddChild(entView); + Main.AddChild(button); + } + } +} + +public sealed class ChangelingTransformMenuButton : RadialMenuTextureButtonWithSector; diff --git a/Content.Client/Cloning/CloningSystem.cs b/Content.Client/Cloning/CloningSystem.cs new file mode 100644 index 0000000000..9bfa230295 --- /dev/null +++ b/Content.Client/Cloning/CloningSystem.cs @@ -0,0 +1,5 @@ +using Content.Shared.Cloning; + +namespace Content.Client.Cloning; + +public sealed partial class CloningSystem : SharedCloningSystem; diff --git a/Content.Client/Storage/Systems/StorageSystem.cs b/Content.Client/Storage/Systems/StorageSystem.cs index bd6659de01..1a21de4b99 100644 --- a/Content.Client/Storage/Systems/StorageSystem.cs +++ b/Content.Client/Storage/Systems/StorageSystem.cs @@ -40,6 +40,11 @@ public sealed class StorageSystem : SharedStorageSystem component.MaxItemSize = state.MaxItemSize; component.Whitelist = state.Whitelist; component.Blacklist = state.Blacklist; + component.StorageInsertSound = state.StorageInsertSound; + component.StorageRemoveSound = state.StorageRemoveSound; + component.StorageOpenSound = state.StorageOpenSound; + component.StorageCloseSound = state.StorageCloseSound; + component.DefaultStorageOrientation = state.DefaultStorageOrientation; _oldStoredItems.Clear(); diff --git a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs index 672ae695bf..2b5ea90b12 100644 --- a/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs +++ b/Content.Server/Administration/Systems/AdminVerbSystem.Antags.cs @@ -27,9 +27,9 @@ public sealed partial class AdminVerbSystem private static readonly EntProtoId DefaultNukeOpRule = "LoneOpsSpawn"; private static readonly EntProtoId DefaultRevsRule = "Revolutionary"; private static readonly EntProtoId DefaultThiefRule = "Thief"; - private static readonly ProtoId PirateGearId = "PirateGear"; - + private static readonly EntProtoId DefaultChangelingRule = "Changeling"; private static readonly EntProtoId ParadoxCloneRuleId = "ParadoxCloneSpawn"; + private static readonly ProtoId PirateGearId = "PirateGear"; // All antag verbs have names so invokeverb works. private void AddAntagVerbs(GetVerbsEvent args) @@ -58,7 +58,7 @@ public sealed partial class AdminVerbSystem _antag.ForceMakeAntag(targetPlayer, DefaultTraitorRule); }, Impact = LogImpact.High, - Message = string.Join(": ", traitorName, Loc.GetString("admin-verb-make-traitor")), + Message = string.Join(": ", traitorName, Loc.GetString("admin-verb-make-traitor")), }; args.Verbs.Add(traitor); @@ -153,6 +153,21 @@ public sealed partial class AdminVerbSystem }; args.Verbs.Add(thief); + var changelingName = Loc.GetString("admin-verb-text-make-changeling"); + Verb changeling = new() + { + Text = changelingName, + Category = VerbCategory.Antag, + Icon = new SpriteSpecifier.Rsi(new ResPath("/Textures/Objects/Weapons/Melee/armblade.rsi"), "icon"), + Act = () => + { + _antag.ForceMakeAntag(targetPlayer, DefaultChangelingRule); + }, + Impact = LogImpact.High, + Message = string.Join(": ", changelingName, Loc.GetString("admin-verb-make-changeling")), + }; + args.Verbs.Add(changeling); + var paradoxCloneName = Loc.GetString("admin-verb-text-make-paradox-clone"); Verb paradox = new() { diff --git a/Content.Server/Cloning/CloningSystem.Subscriptions.cs b/Content.Server/Cloning/CloningSystem.Subscriptions.cs index eba806ceb8..84ef050305 100644 --- a/Content.Server/Cloning/CloningSystem.Subscriptions.cs +++ b/Content.Server/Cloning/CloningSystem.Subscriptions.cs @@ -1,11 +1,16 @@ using Content.Server.Forensics; +using Content.Server.Speech.EntitySystems; using Content.Shared.Cloning.Events; -using Content.Shared.Clothing.Components; using Content.Shared.FixedPoint; +using Content.Shared.Inventory; using Content.Shared.Labels.Components; using Content.Shared.Labels.EntitySystems; +using Content.Shared.Movement.Components; +using Content.Shared.Movement.Systems; using Content.Shared.Paper; using Content.Shared.Stacks; +using Content.Shared.Speech.Components; +using Content.Shared.Storage; using Content.Shared.Store; using Content.Shared.Store.Components; using Robust.Shared.Prototypes; @@ -13,47 +18,58 @@ using Robust.Shared.Prototypes; namespace Content.Server.Cloning; /// -/// The part of item cloning responsible for copying over important components. -/// This is used for . -/// Anything not copied over here gets reverted to the values the item had in its prototype. +/// The part of item cloning responsible for copying over important components. /// /// -/// This method of copying items is of course not perfect as we cannot clone every single component, which would be pretty much impossible with our ECS. -/// We only consider the most important components so the paradox clone gets similar equipment. -/// This method of using subscriptions was chosen to make it easy for forks to add their own custom components that need to be copied. +/// These are all not part of their corresponding systems because we don't want systems every system to depend on a CloningSystem namespace import, which is still heavily coupled to med code. +/// TODO: Create a more generic "CopyEntity" method/event (probably in RT) that doesn't have this problem and then move all these subscriptions. /// -public sealed partial class CloningSystem : EntitySystem +public sealed partial class CloningSystem { [Dependency] private readonly SharedStackSystem _stack = default!; [Dependency] private readonly LabelSystem _label = default!; [Dependency] private readonly ForensicsSystem _forensics = default!; [Dependency] private readonly PaperSystem _paper = default!; + [Dependency] private readonly VocalSystem _vocal = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!; public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnCloneStack); - SubscribeLocalEvent(OnCloneLabel); - SubscribeLocalEvent(OnClonePaper); - SubscribeLocalEvent(OnCloneForensics); - SubscribeLocalEvent(OnCloneStore); + // These are used for . + // Anything not copied over here gets reverted to the values the item had in its prototype. + // This method of copying items is of course not perfect as we cannot clone every single component, which would be pretty much impossible with our ECS. + // We only consider the most important components so the paradox clone gets similar equipment. + // This method of using subscriptions was chosen to make it easy for forks to add their own custom components that need to be copied. + SubscribeLocalEvent(OnCloneItemStack); + SubscribeLocalEvent(OnCloneItemLabel); + SubscribeLocalEvent(OnCloneItemPaper); + SubscribeLocalEvent(OnCloneItemForensics); + SubscribeLocalEvent(OnCloneItemStore); + + // These are for cloning components that cannot be cloned using CopyComp. + // Put them into CloningSettingsPrototype.EventComponents to have them be applied to the clone. + SubscribeLocalEvent(OnCloneVocal); + SubscribeLocalEvent(OnCloneStorage); + SubscribeLocalEvent(OnCloneInventory); + SubscribeLocalEvent(OnCloneInventory); } - private void OnCloneStack(Entity ent, ref CloningItemEvent args) + private void OnCloneItemStack(Entity ent, ref CloningItemEvent args) { // if the clone is a stack as well, adjust the count of the copy if (TryComp(args.CloneUid, out var cloneStackComp)) _stack.SetCount(args.CloneUid, ent.Comp.Count, cloneStackComp); } - private void OnCloneLabel(Entity ent, ref CloningItemEvent args) + private void OnCloneItemLabel(Entity ent, ref CloningItemEvent args) { // copy the label _label.Label(args.CloneUid, ent.Comp.CurrentLabel); } - private void OnClonePaper(Entity ent, ref CloningItemEvent args) + private void OnCloneItemPaper(Entity ent, ref CloningItemEvent args) { // copy the text and any stamps if (TryComp(args.CloneUid, out var clonePaperComp)) @@ -63,13 +79,13 @@ public sealed partial class CloningSystem : EntitySystem } } - private void OnCloneForensics(Entity ent, ref CloningItemEvent args) + private void OnCloneItemForensics(Entity ent, ref CloningItemEvent args) { // copy any forensics to the cloned item _forensics.CopyForensicsFrom(ent.Comp, args.CloneUid); } - private void OnCloneStore(Entity ent, ref CloningItemEvent args) + private void OnCloneItemStore(Entity ent, ref CloningItemEvent args) { // copy the current amount of currency in the store // at the moment this takes care of uplink implants and the portable nukie uplinks @@ -80,4 +96,35 @@ public sealed partial class CloningSystem : EntitySystem } } + private void OnCloneVocal(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + _vocal.CopyComponent(ent.AsNullable(), args.CloneUid); + } + + private void OnCloneStorage(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + _storage.CopyComponent(ent.AsNullable(), args.CloneUid); + } + + private void OnCloneInventory(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + _inventory.CopyComponent(ent.AsNullable(), args.CloneUid); + } + + private void OnCloneInventory(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + _movementSpeedModifier.CopyComponent(ent.AsNullable(), args.CloneUid); + } } diff --git a/Content.Server/Cloning/CloningSystem.cs b/Content.Server/Cloning/CloningSystem.cs index 97ab41e7b1..b0d62be523 100644 --- a/Content.Server/Cloning/CloningSystem.cs +++ b/Content.Server/Cloning/CloningSystem.cs @@ -24,7 +24,7 @@ namespace Content.Server.Cloning; /// System responsible for making a copy of a humanoid's body. /// For the cloning machines themselves look at CloningPodSystem, CloningConsoleSystem and MedicalScannerSystem instead. /// -public sealed partial class CloningSystem : EntitySystem +public sealed partial class CloningSystem : SharedCloningSystem { [Dependency] private readonly HumanoidAppearanceSystem _humanoidSystem = default!; [Dependency] private readonly InventorySystem _inventory = default!; @@ -84,13 +84,7 @@ public sealed partial class CloningSystem : EntitySystem return true; } - /// - /// Copy components from one entity to another based on a CloningSettingsPrototype. - /// - /// The orignal Entity to clone components from. - /// The target Entity to clone components to. - /// The clone settings prototype containing the list of components to clone. - public void CloneComponents(EntityUid original, EntityUid clone, CloningSettingsPrototype settings) + public override void CloneComponents(EntityUid original, EntityUid clone, CloningSettingsPrototype settings) { var componentsToCopy = settings.Components; var componentsToEvent = settings.EventComponents; @@ -128,7 +122,8 @@ public sealed partial class CloningSystem : EntitySystem } // If the original does not have the component, then the clone shouldn't have it either. - RemComp(clone, componentRegistration.Type); + if (!HasComp(original, componentRegistration.Type)) + RemComp(clone, componentRegistration.Type); } var cloningEv = new CloningEvent(settings, clone); diff --git a/Content.Server/Emoting/Components/BodyEmotesComponent.cs b/Content.Server/Emoting/Components/BodyEmotesComponent.cs index 3fd71def0d..d911a89ec9 100644 --- a/Content.Server/Emoting/Components/BodyEmotesComponent.cs +++ b/Content.Server/Emoting/Components/BodyEmotesComponent.cs @@ -1,6 +1,6 @@ using Content.Server.Emoting.Systems; using Content.Shared.Chat.Prototypes; -using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype; +using Robust.Shared.Prototypes; namespace Content.Server.Emoting.Components; @@ -14,11 +14,6 @@ public sealed partial class BodyEmotesComponent : Component /// /// Emote sounds prototype id for body emotes. /// - [DataField("soundsId", customTypeSerializer: typeof(PrototypeIdSerializer))] - public string? SoundsId; - - /// - /// Loaded emote sounds prototype used for body emotes. - /// - public EmoteSoundsPrototype? Sounds; + [DataField] + public ProtoId? SoundsId; } diff --git a/Content.Server/Emoting/Systems/BodyEmotesSystem.cs b/Content.Server/Emoting/Systems/BodyEmotesSystem.cs index 594eb0ec6d..aef79f1419 100644 --- a/Content.Server/Emoting/Systems/BodyEmotesSystem.cs +++ b/Content.Server/Emoting/Systems/BodyEmotesSystem.cs @@ -14,15 +14,8 @@ public sealed class BodyEmotesSystem : EntitySystem public override void Initialize() { base.Initialize(); - SubscribeLocalEvent(OnStartup); - SubscribeLocalEvent(OnEmote); - } - private void OnStartup(EntityUid uid, BodyEmotesComponent component, ComponentStartup args) - { - if (component.SoundsId == null) - return; - _proto.TryIndex(component.SoundsId, out component.Sounds); + SubscribeLocalEvent(OnEmote); } private void OnEmote(EntityUid uid, BodyEmotesComponent component, ref EmoteEvent args) @@ -43,6 +36,9 @@ public sealed class BodyEmotesSystem : EntitySystem if (!TryComp(uid, out HandsComponent? hands) || hands.Count <= 0) return false; - return _chat.TryPlayEmoteSound(uid, component.Sounds, emote); + if (!_proto.Resolve(component.SoundsId, out var sounds)) + return false; + + return _chat.TryPlayEmoteSound(uid, sounds, emote); } } diff --git a/Content.Server/GameTicking/Rules/ChangelingRuleSystem.cs b/Content.Server/GameTicking/Rules/ChangelingRuleSystem.cs new file mode 100644 index 0000000000..a64b0e904a --- /dev/null +++ b/Content.Server/GameTicking/Rules/ChangelingRuleSystem.cs @@ -0,0 +1,23 @@ +using Content.Server.GameTicking.Rules.Components; +using Content.Server.Roles; +using Content.Shared.Changeling; + +namespace Content.Server.GameTicking.Rules; + +/// +/// Game rule system for Changelings +/// +public sealed class ChangelingRuleSystem : GameRuleSystem +{ + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnGetBriefing); + } + + private void OnGetBriefing(Entity ent, ref GetBriefingEvent args) + { + args.Append(Loc.GetString("changeling-briefing")); + } +} diff --git a/Content.Server/GameTicking/Rules/Components/ChangelingRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ChangelingRuleComponent.cs new file mode 100644 index 0000000000..13891c1988 --- /dev/null +++ b/Content.Server/GameTicking/Rules/Components/ChangelingRuleComponent.cs @@ -0,0 +1,7 @@ +namespace Content.Server.GameTicking.Rules.Components; + +/// +/// Gamerule component for handling a changeling antagonist. +/// +[RegisterComponent] +public sealed partial class ChangelingRuleComponent : Component; diff --git a/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs b/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs index e28a0bc35f..f1e8e41258 100644 --- a/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs +++ b/Content.Server/GameTicking/Rules/Components/ParadoxCloneRuleComponent.cs @@ -14,7 +14,7 @@ public sealed partial class ParadoxCloneRuleComponent : Component /// Cloning settings to be used. /// [DataField] - public ProtoId Settings = "Antag"; + public ProtoId Settings = "ParadoxCloningSettings"; /// /// Visual effect spawned when gibbing at round end. diff --git a/Content.Server/Speech/EntitySystems/VocalSystem.cs b/Content.Server/Speech/EntitySystems/VocalSystem.cs index fb88238288..275140ff5b 100644 --- a/Content.Server/Speech/EntitySystems/VocalSystem.cs +++ b/Content.Server/Speech/EntitySystems/VocalSystem.cs @@ -2,6 +2,7 @@ using Content.Server.Actions; using Content.Server.Chat.Systems; using Content.Server.Speech.Components; using Content.Shared.Chat.Prototypes; +using Content.Shared.Cloning.Events; using Content.Shared.Humanoid; using Content.Shared.Speech; using Content.Shared.Speech.Components; @@ -31,6 +32,25 @@ public sealed class VocalSystem : EntitySystem SubscribeLocalEvent(OnScreamAction); } + /// + /// Copy this component's datafields from one entity to another. + /// This can't use CopyComp because of the ScreamActionEntity DataField, which should not be copied. + /// + public void CopyComponent(Entity source, EntityUid target) + { + if (!Resolve(source, ref source.Comp)) + return; + + var targetComp = EnsureComp(target); + targetComp.Sounds = source.Comp.Sounds; + targetComp.ScreamId = source.Comp.ScreamId; + targetComp.Wilhelm = source.Comp.Wilhelm; + targetComp.WilhelmProbability = source.Comp.WilhelmProbability; + LoadSounds(target, targetComp); + + Dirty(target, targetComp); + } + private void OnMapInit(EntityUid uid, VocalComponent component, MapInitEvent args) { // try to add scream action when vocal comp added diff --git a/Content.Server/Wagging/WaggingSystem.cs b/Content.Server/Wagging/WaggingSystem.cs index 7ccc19e20c..88b82a9ca5 100644 --- a/Content.Server/Wagging/WaggingSystem.cs +++ b/Content.Server/Wagging/WaggingSystem.cs @@ -1,5 +1,6 @@ using Content.Server.Actions; using Content.Server.Humanoid; +using Content.Shared.Cloning.Events; using Content.Shared.Humanoid; using Content.Shared.Humanoid.Markings; using Content.Shared.Mobs; @@ -26,6 +27,15 @@ public sealed class WaggingSystem : EntitySystem SubscribeLocalEvent(OnWaggingShutdown); SubscribeLocalEvent(OnWaggingToggle); SubscribeLocalEvent(OnMobStateChanged); + SubscribeLocalEvent(OnCloning); + } + + private void OnCloning(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + EnsureComp(args.CloneUid); } private void OnWaggingMapInit(EntityUid uid, WaggingComponent component, MapInitEvent args) diff --git a/Content.Shared/Changeling/ChangelingIdentityComponent.cs b/Content.Shared/Changeling/ChangelingIdentityComponent.cs new file mode 100644 index 0000000000..461315f4ce --- /dev/null +++ b/Content.Shared/Changeling/ChangelingIdentityComponent.cs @@ -0,0 +1,35 @@ +using Content.Shared.Cloning; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Changeling; + +/// +/// The storage component for Changelings, it handles the link between a changeling and its consumed identities +/// that exist on a paused map. +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +public sealed partial class ChangelingIdentityComponent : Component +{ + /// + /// The list of entities that exist on a paused map. They are paused clones of the victims that the ling has consumed, with all relevant components copied from the original. + /// + // TODO: Store a reference to the original entity as well so you cannot infinitely devour somebody. Currently very tricky due the inability to send over EntityUid if the original is ever deleted. Can be fixed by something like WeakEntityReference. + [DataField, AutoNetworkedField] + public List ConsumedIdentities = new(); + + + /// + /// The currently assumed identity. + /// + [DataField, AutoNetworkedField] + public EntityUid? CurrentIdentity; + + /// + /// The cloning settings passed to the CloningSystem, contains a list of all components to copy or have handled by their + /// respective systems. + /// + public ProtoId IdentityCloningSettings = "ChangelingCloningSettings"; + + public override bool SendOnlyToOwner => true; +} diff --git a/Content.Shared/Changeling/ChangelingIdentitySystem.cs b/Content.Shared/Changeling/ChangelingIdentitySystem.cs new file mode 100644 index 0000000000..8f704c49be --- /dev/null +++ b/Content.Shared/Changeling/ChangelingIdentitySystem.cs @@ -0,0 +1,180 @@ +using System.Numerics; +using Content.Shared.Cloning; +using Content.Shared.Humanoid; +using Content.Shared.Mind.Components; +using Content.Shared.NameModifier.EntitySystems; +using Robust.Shared.GameStates; +using Robust.Shared.Map; +using Robust.Shared.Network; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Changeling; + +public sealed class ChangelingIdentitySystem : EntitySystem +{ + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + [Dependency] private readonly MetaDataSystem _metaSystem = default!; + [Dependency] private readonly NameModifierSystem _nameMod = default!; + [Dependency] private readonly SharedCloningSystem _cloningSystem = default!; + [Dependency] private readonly SharedHumanoidAppearanceSystem _humanoidSystem = default!; + [Dependency] private readonly SharedMapSystem _map = default!; + [Dependency] private readonly SharedPvsOverrideSystem _pvsOverrideSystem = default!; + + public MapId? PausedMapId; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnShutdown); + SubscribeLocalEvent(OnMindAdded); + SubscribeLocalEvent(OnMindRemoved); + SubscribeLocalEvent(OnStoredRemove); + } + + private void OnMindAdded(Entity ent, ref MindAddedMessage args) + { + if (!TryComp(args.Container.Owner, out var actor)) + return; + + HandOverPvsOverride(actor.PlayerSession, ent.Comp); + } + + private void OnMindRemoved(Entity ent, ref MindRemovedMessage args) + { + CleanupPvsOverride(ent, args.Container.Owner); + } + + private void OnMapInit(Entity ent, ref MapInitEvent args) + { + // Make a backup of our current identity so we can transform back. + var clone = CloneToPausedMap(ent, ent.Owner); + ent.Comp.CurrentIdentity = clone; + } + + private void OnShutdown(Entity ent, ref ComponentShutdown args) + { + CleanupPvsOverride(ent, ent.Owner); + CleanupChangelingNullspaceIdentities(ent); + } + + private void OnStoredRemove(Entity ent, ref ComponentRemove args) + { + // The last stored identity is being deleted, we can clean up the map. + if (_net.IsServer && PausedMapId != null && Count() <= 1) + _map.QueueDeleteMap(PausedMapId.Value); + } + + /// + /// Cleanup all nullspaced Identities when the changeling no longer exists + /// + /// the changeling + public void CleanupChangelingNullspaceIdentities(Entity ent) + { + if (_net.IsClient) + return; + + foreach (var consumedIdentity in ent.Comp.ConsumedIdentities) + { + QueueDel(consumedIdentity); + } + } + + /// + /// Clone a target humanoid into nullspace and add it to the Changelings list of identities. + /// It creates a perfect copy of the target and can be used to pull components down for future use + /// + /// the Changeling + /// the targets uid + public EntityUid? CloneToPausedMap(Entity ent, EntityUid target) + { + // Don't create client side duplicate clones or a clientside map. + if (_net.IsClient) + return null; + + if (!TryComp(target, out var humanoid) + || !_prototype.Resolve(humanoid.Species, out var speciesPrototype) + || !_prototype.Resolve(ent.Comp.IdentityCloningSettings, out var settings)) + return null; + + EnsurePausedMap(); + var mob = Spawn(speciesPrototype.Prototype, new MapCoordinates(Vector2.Zero, PausedMapId!.Value)); + + var storedIdentity = EnsureComp(mob); + storedIdentity.OriginalEntity = target; // TODO: network this once we have WeakEntityReference or the autonetworking source gen is fixed + + if (TryComp(target, out var actor)) + storedIdentity.OriginalSession = actor.PlayerSession; + + _humanoidSystem.CloneAppearance(target, mob); + _cloningSystem.CloneComponents(target, mob, settings); + + var targetName = _nameMod.GetBaseName(target); + _metaSystem.SetEntityName(mob, targetName); + ent.Comp.ConsumedIdentities.Add(mob); + + Dirty(ent); + HandlePvsOverride(ent, mob); + + return mob; + } + + /// + /// Simple helper to add a PVS override to a Nullspace Identity + /// + /// + /// + private void HandlePvsOverride(EntityUid uid, EntityUid target) + { + if (!TryComp(uid, out var actor)) + return; + + _pvsOverrideSystem.AddSessionOverride(target, actor.PlayerSession); + } + + /// + /// Cleanup all Pvs Overrides for the owner of the ChangelingIdentity + /// + /// the Changeling itself + /// Who specifically to cleanup from, usually just the same owner, but in the case of a mindswap we want to clean up the victim + private void CleanupPvsOverride(Entity ent, EntityUid entityUid) + { + if (!TryComp(entityUid, out var actor)) + return; + + foreach (var identity in ent.Comp.ConsumedIdentities) + { + _pvsOverrideSystem.RemoveSessionOverride(identity, actor.PlayerSession); + } + } + + /// + /// Inform another Session of the entities stored for Transformation + /// + /// The Session you wish to inform + /// The Target storage of identities + public void HandOverPvsOverride(ICommonSession session, ChangelingIdentityComponent comp) + { + foreach (var entity in comp.ConsumedIdentities) + { + _pvsOverrideSystem.AddSessionOverride(entity, session); + } + } + + /// + /// Create a paused map for storing devoured identities as a clone of the player. + /// + private void EnsurePausedMap() + { + if (_map.MapExists(PausedMapId)) + return; + + var mapUid = _map.CreateMap(out var newMapId); + _metaSystem.SetEntityName(mapUid, "Changeling identity storage map"); + PausedMapId = newMapId; + _map.SetPaused(mapUid, true); + } +} diff --git a/Content.Shared/Changeling/ChangelingRoleComponent.cs b/Content.Shared/Changeling/ChangelingRoleComponent.cs new file mode 100644 index 0000000000..d2e9c1eccb --- /dev/null +++ b/Content.Shared/Changeling/ChangelingRoleComponent.cs @@ -0,0 +1,9 @@ +using Content.Shared.Roles; + +namespace Content.Shared.Changeling; + +/// +/// The Mindrole for Changeling Antags +/// +[RegisterComponent] +public sealed partial class ChangelingRoleComponent : BaseMindRoleComponent; diff --git a/Content.Shared/Changeling/ChangelingStoredIdentityComponent.cs b/Content.Shared/Changeling/ChangelingStoredIdentityComponent.cs new file mode 100644 index 0000000000..44583190e6 --- /dev/null +++ b/Content.Shared/Changeling/ChangelingStoredIdentityComponent.cs @@ -0,0 +1,29 @@ +using Robust.Shared.GameStates; +using Robust.Shared.Player; + +namespace Content.Shared.Changeling; + +/// +/// Marker component for cloned identities devoured by a changeling. +/// These are stored on a paused map so that the changeling can transform into them. +/// +[RegisterComponent, NetworkedComponent] +public sealed partial class ChangelingStoredIdentityComponent : Component +{ + /// + /// The original entity the identity was cloned from. + /// + /// + /// TODO: Not networked at the moment because it will create PVS errors when the original is somehow deleted. + /// Use WeakEntityReference once it's merged. + /// + [DataField] + public EntityUid? OriginalEntity; + + /// + /// The player session of the original entity, if any. + /// Used for admin logging purposes. + /// + [ViewVariables] + public ICommonSession? OriginalSession; +} diff --git a/Content.Shared/Changeling/Devour/ChangelingDevourComponent.cs b/Content.Shared/Changeling/Devour/ChangelingDevourComponent.cs new file mode 100644 index 0000000000..7798c6fec9 --- /dev/null +++ b/Content.Shared/Changeling/Devour/ChangelingDevourComponent.cs @@ -0,0 +1,133 @@ +using Content.Shared.Damage; +using Content.Shared.Damage.Prototypes; +using Content.Shared.FixedPoint; +using Content.Shared.Whitelist; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom; + +namespace Content.Shared.Changeling.Devour; + +/// +/// Component responsible for Changelings Devour attack. Including the amount of damage +/// and how long it takes to devour someone +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause] +[Access(typeof(ChangelingDevourSystem))] +public sealed partial class ChangelingDevourComponent : Component +{ + /// + /// The Action for devouring + /// + [DataField] + public EntProtoId? ChangelingDevourAction = "ActionChangelingDevour"; + + /// + /// The action entity associated with devouring + /// + [DataField, AutoNetworkedField] + public EntityUid? ChangelingDevourActionEntity; + + /// + /// The whitelist of targets for devouring + /// + [DataField, AutoNetworkedField] + public EntityWhitelist? Whitelist = new() + { + Components = + [ + "MobState", + "HumanoidAppearance", + ], + }; + + /// + /// The Sound to use during consumption of a victim + /// + /// + /// 6 distance due to the default 15 being hearable all the way across PVS. Changeling is meant to be stealthy. + /// 6 still allows the sound to be hearable, but not across an entire department. + /// + [DataField, AutoNetworkedField] + public SoundSpecifier? ConsumeNoise = new SoundCollectionSpecifier("ChangelingDevourConsume", AudioParams.Default.WithMaxDistance(6)); + + /// + /// The Sound to use during the windup before consuming a victim + /// + /// + /// 6 distance due to the default 15 being hearable all the way across PVS. Changeling is meant to be stealthy. + /// 6 still allows the sound to be hearable, but not across an entire department. + /// + [DataField, AutoNetworkedField] + public SoundSpecifier? DevourWindupNoise = new SoundCollectionSpecifier("ChangelingDevourWindup", AudioParams.Default.WithMaxDistance(6)); + + /// + /// The time between damage ticks + /// + [DataField, AutoNetworkedField] + public TimeSpan DamageTimeBetweenTicks = TimeSpan.FromSeconds(1); + + /// + /// The windup time before the changeling begins to engage in devouring the identity of a target + /// + [DataField, AutoNetworkedField] + public TimeSpan DevourWindupTime = TimeSpan.FromSeconds(2); + + /// + /// The time it takes to FULLY consume someones identity. + /// + [DataField, AutoNetworkedField] + public TimeSpan DevourConsumeTime = TimeSpan.FromSeconds(10); + + /// + /// Damage cap that a target is allowed to be caused due to IdentityConsumption + /// + [DataField, AutoNetworkedField] + public float DevourConsumeDamageCap = 350f; + + /// + /// The Currently active devour sound in the world + /// + [DataField] + public EntityUid? CurrentDevourSound; + + /// + /// The damage profile for a single tick of devour damage + /// + [DataField, AutoNetworkedField] + public DamageSpecifier DamagePerTick = new() + { + DamageDict = new Dictionary + { + { "Slash", 10}, + { "Piercing", 10 }, + { "Blunt", 5 }, + }, + }; + + /// + /// The list of protective damage types capable of preventing a devour if over the threshold + /// + [DataField, AutoNetworkedField] + public List> ProtectiveDamageTypes = new() + { + "Slash", + "Piercing", + "Blunt", + }; + + /// + /// The next Tick to deal damage on (utilized during the consumption "do-during" (a do after with an attempt event)) + /// + [DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField] + public TimeSpan NextTick = TimeSpan.Zero; + + /// + /// The percentage of ANY brute damage resistance that will prevent devouring + /// + [DataField, AutoNetworkedField] + public float DevourPreventionPercentageThreshold = 0.1f; + + public override bool SendOnlyToOwner => true; +} diff --git a/Content.Shared/Changeling/Devour/ChangelingDevourSystem.Events.cs b/Content.Shared/Changeling/Devour/ChangelingDevourSystem.Events.cs new file mode 100644 index 0000000000..d063737e5c --- /dev/null +++ b/Content.Shared/Changeling/Devour/ChangelingDevourSystem.Events.cs @@ -0,0 +1,22 @@ +using Content.Shared.Actions; +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared.Changeling.Devour; + +/// +/// Action event for Devour, someone has initiated a devour on someone, begin to windup. +/// +public sealed partial class ChangelingDevourActionEvent : EntityTargetActionEvent; + +/// +/// A windup has either successfully been completed or has been canceled. If successful start the devouring DoAfter. +/// +[Serializable, NetSerializable] +public sealed partial class ChangelingDevourWindupDoAfterEvent : SimpleDoAfterEvent; + +/// +/// The Consumption DoAfter has either successfully been completed or was canceled. +/// +[Serializable, NetSerializable] +public sealed partial class ChangelingDevourConsumeDoAfterEvent : SimpleDoAfterEvent; diff --git a/Content.Shared/Changeling/Devour/ChangelingDevourSystem.cs b/Content.Shared/Changeling/Devour/ChangelingDevourSystem.cs new file mode 100644 index 0000000000..83a589a8e3 --- /dev/null +++ b/Content.Shared/Changeling/Devour/ChangelingDevourSystem.cs @@ -0,0 +1,276 @@ +using Content.Shared.Actions; +using Content.Shared.Administration.Logs; +using Content.Shared.Armor; +using Content.Shared.Atmos.Rotting; +using Content.Shared.Body.Components; +using Content.Shared.Damage; +using Content.Shared.Database; +using Content.Shared.DoAfter; +using Content.Shared.Humanoid; +using Content.Shared.IdentityManagement; +using Content.Shared.Inventory; +using Content.Shared.Mobs.Systems; +using Content.Shared.Nutrition.Components; +using Content.Shared.Popups; +using Content.Shared.Storage; +using Content.Shared.Whitelist; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Network; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Shared.Changeling.Devour; + +public sealed class ChangelingDevourSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!; + [Dependency] private readonly SharedPopupSystem _popupSystem = default!; + [Dependency] private readonly SharedActionsSystem _actionsSystem = default!; + [Dependency] private readonly EntityWhitelistSystem _whitelistSystem = default!; + [Dependency] private readonly DamageableSystem _damageable = default!; + [Dependency] private readonly MobStateSystem _mobState = default!; + [Dependency] private readonly ChangelingIdentitySystem _changelingIdentitySystem = default!; + [Dependency] private readonly InventorySystem _inventorySystem = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly IRobustRandom _robustRandom = default!; + + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnDevourAction); + SubscribeLocalEvent(OnDevourWindup); + SubscribeLocalEvent(OnDevourConsume); + SubscribeLocalEvent>(OnConsumeAttemptTick); + SubscribeLocalEvent(OnShutdown); + } + + private void OnMapInit(Entity ent, ref MapInitEvent args) + { + _actionsSystem.AddAction(ent, ref ent.Comp.ChangelingDevourActionEntity, ent.Comp.ChangelingDevourAction); + } + + private void OnShutdown(Entity ent, ref ComponentShutdown args) + { + if (ent.Comp.ChangelingDevourActionEntity != null) + { + _actionsSystem.RemoveAction(ent.Owner, ent.Comp.ChangelingDevourActionEntity); + } + } + + //TODO: Allow doafters to have proper update loop support. Attempt events should not be doing state changes. + private void OnConsumeAttemptTick(Entity ent, + ref DoAfterAttemptEvent eventData) + { + + var curTime = _timing.CurTime; + + if (curTime < ent.Comp.NextTick) + return; + + ConsumeDamageTick(eventData.Event.Target, ent.Comp, eventData.Event.User); + ent.Comp.NextTick += ent.Comp.DamageTimeBetweenTicks; + Dirty(ent, ent.Comp); + } + + private void ConsumeDamageTick(EntityUid? target, ChangelingDevourComponent comp, EntityUid? user) + { + if (target == null) + return; + + if (!TryComp(target, out var damage)) + return; + + foreach (var damagePoints in comp.DamagePerTick.DamageDict) + { + + if (damage.Damage.DamageDict.TryGetValue(damagePoints.Key, out var val) && val > comp.DevourConsumeDamageCap) + return; + } + _damageable.TryChangeDamage(target, comp.DamagePerTick, true, true, damage, user); + } + + /// + /// Checkes if the targets outerclothing is beyond a DamageCoefficientThreshold to protect them from being devoured. + /// + /// The Targeted entity + /// Changelings Devour Component + /// Is the target Protected from the attack + private bool IsTargetProtected(EntityUid target, Entity ent) + { + var ev = new CoefficientQueryEvent(SlotFlags.OUTERCLOTHING); + + RaiseLocalEvent(target, ev); + + foreach (var compProtectiveDamageType in ent.Comp.ProtectiveDamageTypes) + { + if (!ev.DamageModifiers.Coefficients.TryGetValue(compProtectiveDamageType, out var coefficient)) + continue; + if (coefficient < 1f - ent.Comp.DevourPreventionPercentageThreshold) + return true; + } + + return false; + } + + private void OnDevourAction(Entity ent, ref ChangelingDevourActionEvent args) + { + if (args.Handled || _whitelistSystem.IsWhitelistFailOrNull(ent.Comp.Whitelist, args.Target) + || !HasComp(ent)) + return; + + args.Handled = true; + var target = args.Target; + + if (target == ent.Owner) + return; // don't eat yourself + + if (HasComp(target)) + { + _popupSystem.PopupClient(Loc.GetString("changeling-devour-attempt-failed-rotting"), args.Performer, args.Performer, PopupType.Medium); + return; + } + + if (IsTargetProtected(target, ent)) + { + _popupSystem.PopupClient(Loc.GetString("changeling-devour-attempt-failed-protected"), ent, ent, PopupType.Medium); + return; + } + + if (_net.IsServer) + { + var pvsSound = _audio.PlayPvs(ent.Comp.DevourWindupNoise, ent); + if (pvsSound != null) + ent.Comp.CurrentDevourSound = pvsSound.Value.Entity; + } + + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ent:player} started changeling devour windup against {target:player}"); + + _doAfterSystem.TryStartDoAfter(new DoAfterArgs(EntityManager, ent, ent.Comp.DevourWindupTime, new ChangelingDevourWindupDoAfterEvent(), ent, target: target, used: ent) + { + BreakOnMove = true, + BlockDuplicate = true, + DuplicateCondition = DuplicateConditions.None, + }); + + var selfMessage = Loc.GetString("changeling-devour-begin-windup-self", ("user", Identity.Entity(ent.Owner, EntityManager))); + var othersMessage = Loc.GetString("changeling-devour-begin-windup-others", ("user", Identity.Entity(ent.Owner, EntityManager))); + _popupSystem.PopupPredicted( + selfMessage, + othersMessage, + args.Performer, + args.Performer, + PopupType.MediumCaution); + } + + private void OnDevourWindup(Entity ent, ref ChangelingDevourWindupDoAfterEvent args) + { + var curTime = _timing.CurTime; + args.Handled = true; + + if (!EntityManager.EntityExists(ent.Comp.CurrentDevourSound)) + _audio.Stop(ent.Comp.CurrentDevourSound!); + + if (args.Cancelled) + return; + + var selfMessage = Loc.GetString("changeling-devour-begin-consume-self", ("user", Identity.Entity(ent.Owner, EntityManager))); + var othersMessage = Loc.GetString("changeling-devour-begin-consume-others", ("user", Identity.Entity(ent.Owner, EntityManager))); + _popupSystem.PopupPredicted( + selfMessage, + othersMessage, + args.User, + args.User, + PopupType.LargeCaution); + + if (_net.IsServer) + { + var pvsSound = _audio.PlayPvs(ent.Comp.ConsumeNoise, ent); + + if (pvsSound != null) + ent.Comp.CurrentDevourSound = pvsSound.Value.Entity; + } + + + ent.Comp.NextTick = curTime + ent.Comp.DamageTimeBetweenTicks; + + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} began to devour {ToPrettyString(args.Target):player} identity"); + + _doAfterSystem.TryStartDoAfter(new DoAfterArgs(EntityManager, + ent, + ent.Comp.DevourConsumeTime, + new ChangelingDevourConsumeDoAfterEvent(), + ent, + target: args.Target, + used: ent) + { + AttemptFrequency = AttemptFrequency.EveryTick, + BreakOnMove = true, + BlockDuplicate = true, + DuplicateCondition = DuplicateConditions.None, + }); + } + + private void OnDevourConsume(Entity ent, ref ChangelingDevourConsumeDoAfterEvent args) + { + args.Handled = true; + var target = args.Target; + + if (target == null) + return; + + if (EntityManager.EntityExists(ent.Comp.CurrentDevourSound)) + _audio.Stop(ent.Comp.CurrentDevourSound!); + + if (args.Cancelled) + return; + + if (!_mobState.IsDead((EntityUid)target)) + { + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} unsuccessfully devoured {ToPrettyString(args.Target):player}'s identity"); + _popupSystem.PopupClient(Loc.GetString("changeling-devour-consume-failed-not-dead"), args.User, args.User, PopupType.Medium); + return; + } + + var selfMessage = Loc.GetString("changeling-devour-consume-complete-self", ("user", Identity.Entity(args.User, EntityManager))); + var othersMessage = Loc.GetString("changeling-devour-consume-complete-others", ("user", Identity.Entity(args.User, EntityManager))); + _popupSystem.PopupPredicted( + selfMessage, + othersMessage, + args.User, + args.User, + PopupType.LargeCaution); + + if (_mobState.IsDead(target.Value) + && TryComp(target, out var body) + && HasComp(target) + && TryComp(args.User, out var identityStorage)) + { + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} successfully devoured {ToPrettyString(args.Target):player}'s identity"); + _changelingIdentitySystem.CloneToPausedMap((ent, identityStorage), target.Value); + + if (_inventorySystem.TryGetSlotEntity(target.Value, "jumpsuit", out var item) + && TryComp(item, out var butcherable)) + RipClothing(target.Value, (item.Value, butcherable)); + } + + Dirty(ent); + } + + private void RipClothing(EntityUid victim, Entity item) + { + var spawnEntities = EntitySpawnCollection.GetSpawns(item.Comp.SpawnedEntities, _robustRandom); + + foreach (var proto in spawnEntities) + { + // TODO: once predictedRandom is in, make this a Coordinate offset of 0.25f from the victims position + PredictedSpawnNextToOrDrop(proto, victim); + } + + PredictedQueueDel(item.Owner); + } +} diff --git a/Content.Shared/Changeling/Transform/ChangelingTransformComponent.cs b/Content.Shared/Changeling/Transform/ChangelingTransformComponent.cs new file mode 100644 index 0000000000..0a3b3f1985 --- /dev/null +++ b/Content.Shared/Changeling/Transform/ChangelingTransformComponent.cs @@ -0,0 +1,54 @@ +using Content.Shared.Cloning; +using Robust.Shared.Audio; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Changeling.Transform; + +/// +/// The component containing information about Changelings Transformation action +/// Like how long their windup is, the sounds as well as the Target Cloning settings for changing between identities +/// +[RegisterComponent, NetworkedComponent, AutoGenerateComponentState] +[Access(typeof(ChangelingTransformSystem))] +public sealed partial class ChangelingTransformComponent : Component +{ + /// + /// The action Prototype for Transforming + /// + [DataField] + public EntProtoId? ChangelingTransformAction = "ActionChangelingTransform"; + + /// + /// The Action Entity for transforming associated with this Component + /// + [DataField, AutoNetworkedField] + public EntityUid? ChangelingTransformActionEntity; + + /// + /// Time it takes to Transform + /// + [DataField, AutoNetworkedField] + public TimeSpan TransformWindup = TimeSpan.FromSeconds(5); + + /// + /// The noise used when attempting to transform + /// + [DataField, AutoNetworkedField] + public SoundSpecifier? TransformAttemptNoise = new SoundCollectionSpecifier("ChangelingTransformAttempt", AudioParams.Default.WithMaxDistance(6)); // 6 distance due to the default 15 being hearable all the way across PVS. Changeling is meant to be stealthy. 6 still allows the sound to be hearable, but not across an entire department. + + /// + /// The currently active transform in the world + /// + [DataField] + public EntityUid? CurrentTransformSound; + + /// + /// The cloning settings passed to the CloningSystem, contains a list of all components to copy or have handled by their + /// respective systems. + /// + public ProtoId TransformCloningSettings = "ChangelingCloningSettings"; + + public override bool SendOnlyToOwner => true; +} + diff --git a/Content.Shared/Changeling/Transform/ChangelingTransformSystem.Events.cs b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.Events.cs new file mode 100644 index 0000000000..cfe2a56933 --- /dev/null +++ b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.Events.cs @@ -0,0 +1,16 @@ +using Content.Shared.Actions; +using Content.Shared.DoAfter; +using Robust.Shared.Serialization; + +namespace Content.Shared.Changeling.Transform; + +/// +/// Action event for opening the changeling transformation radial menu. +/// +public sealed partial class ChangelingTransformActionEvent : InstantActionEvent; + +/// +/// DoAfterevent used to transform a changeling into one of their stored identities. +/// +[Serializable, NetSerializable] +public sealed partial class ChangelingTransformDoAfterEvent : SimpleDoAfterEvent; diff --git a/Content.Shared/Changeling/Transform/ChangelingTransformSystem.UI.cs b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.UI.cs new file mode 100644 index 0000000000..0383867698 --- /dev/null +++ b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.UI.cs @@ -0,0 +1,33 @@ +using Robust.Shared.Serialization; + +namespace Content.Shared.Changeling.Transform; + +/// +/// Send when a player selects an intentity to transform into in the radial menu. +/// +[Serializable, NetSerializable] +public sealed class ChangelingTransformIdentitySelectMessage(NetEntity targetIdentity) : BoundUserInterfaceMessage +{ + /// + /// The uid of the cloned identity. + /// + public readonly NetEntity TargetIdentity = targetIdentity; +} + +// TODO: Replace with component states. +// We are already networking the ChangelingIdentityComponent, which contains all this information, +// so we can just read it from them from the component and update the UI in an AfterAuotHandleState subscription. +[Serializable, NetSerializable] +public sealed class ChangelingTransformBoundUserInterfaceState(List identities) : BoundUserInterfaceState +{ + /// + /// The uids of the cloned identities. + /// + public readonly List Identites = identities; +} + +[Serializable, NetSerializable] +public enum TransformUI : byte +{ + Key, +} diff --git a/Content.Shared/Changeling/Transform/ChangelingTransformSystem.cs b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.cs new file mode 100644 index 0000000000..dbc5356448 --- /dev/null +++ b/Content.Shared/Changeling/Transform/ChangelingTransformSystem.cs @@ -0,0 +1,180 @@ +using Content.Shared.Actions; +using Content.Shared.Administration.Logs; +using Content.Shared.Cloning; +using Content.Shared.Database; +using Content.Shared.DoAfter; +using Content.Shared.Humanoid; +using Content.Shared.IdentityManagement; +using Content.Shared.Popups; +using Robust.Shared.Audio.Systems; +using Robust.Shared.Network; +using Robust.Shared.Prototypes; + +namespace Content.Shared.Changeling.Transform; + +public sealed partial class ChangelingTransformSystem : EntitySystem +{ + [Dependency] private readonly INetManager _net = default!; + [Dependency] private readonly SharedActionsSystem _actionsSystem = default!; + [Dependency] private readonly SharedUserInterfaceSystem _uiSystem = default!; + [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!; + [Dependency] private readonly SharedHumanoidAppearanceSystem _humanoidAppearanceSystem = default!; + [Dependency] private readonly MetaDataSystem _metaSystem = default!; + [Dependency] private readonly SharedPopupSystem _popupSystem = default!; + [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!; + [Dependency] private readonly SharedAudioSystem _audio = default!; + [Dependency] private readonly SharedCloningSystem _cloningSystem = default!; + [Dependency] private readonly IPrototypeManager _prototype = default!; + + private const string ChangelingBuiXmlGeneratedName = "ChangelingTransformBoundUserInterface"; + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnMapInit); + SubscribeLocalEvent(OnTransformAction); + SubscribeLocalEvent(OnSuccessfulTransform); + SubscribeLocalEvent(OnTransformSelected); + SubscribeLocalEvent(OnShutdown); + } + + private void OnMapInit(Entity ent, ref MapInitEvent init) + { + _actionsSystem.AddAction(ent, ref ent.Comp.ChangelingTransformActionEntity, ent.Comp.ChangelingTransformAction); + + var userInterfaceComp = EnsureComp(ent); + _uiSystem.SetUi((ent, userInterfaceComp), TransformUI.Key, new InterfaceData(ChangelingBuiXmlGeneratedName)); + } + + private void OnShutdown(Entity ent, ref ComponentShutdown args) + { + if (ent.Comp.ChangelingTransformActionEntity != null) + { + _actionsSystem.RemoveAction(ent.Owner, ent.Comp.ChangelingTransformActionEntity); + } + } + + private void OnTransformAction(Entity ent, + ref ChangelingTransformActionEvent args) + { + if (!TryComp(ent, out var userInterfaceComp)) + return; + + if (!TryComp(ent, out var userIdentity)) + return; + + if (!_uiSystem.IsUiOpen((ent, userInterfaceComp), TransformUI.Key, args.Performer)) + { + _uiSystem.OpenUi((ent, userInterfaceComp), TransformUI.Key, args.Performer); + + var identityData = new List(); + + foreach (var consumedIdentity in userIdentity.ConsumedIdentities) + { + identityData.Add(GetNetEntity(consumedIdentity)); + } + + _uiSystem.SetUiState((ent, userInterfaceComp), TransformUI.Key, new ChangelingTransformBoundUserInterfaceState(identityData)); + } //TODO: Can add a Else here with TransformInto and CloseUI to make a quick switch, + // issue right now is that Radials cover the Action buttons so clicking the action closes the UI (due to clicking off a radial causing it to close, even with UI) + // but pressing the number does. + } + + /// + /// Transform the changeling into another identity. + /// This can be any cloneable humanoid and doesn't have to be stored in the ChangelingIdentiyComponent, + /// so make sure to validate the target before. + /// + public void TransformInto(Entity ent, EntityUid targetIdentity) + { + if (!Resolve(ent, ref ent.Comp)) + return; + + var selfMessage = Loc.GetString("changeling-transform-attempt-self", ("user", Identity.Entity(ent.Owner, EntityManager))); + var othersMessage = Loc.GetString("changeling-transform-attempt-others", ("user", Identity.Entity(ent.Owner, EntityManager))); + _popupSystem.PopupPredicted( + selfMessage, + othersMessage, + ent, + ent, + PopupType.MediumCaution); + + if (_net.IsServer) + ent.Comp.CurrentTransformSound = _audio.PlayPvs(ent.Comp.TransformAttemptNoise, ent)?.Entity; + + if(TryComp(targetIdentity, out var storedIdentity) && storedIdentity.OriginalSession != null) + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} begun an attempt to transform into \"{Name(targetIdentity)}\" ({storedIdentity.OriginalSession:player}) "); + else + _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} begun an attempt to transform into \"{Name(targetIdentity)}\""); + + var result = _doAfterSystem.TryStartDoAfter(new DoAfterArgs( + EntityManager, + ent, + ent.Comp.TransformWindup, + new ChangelingTransformDoAfterEvent(), + ent, + target: targetIdentity) + { + BreakOnMove = true, + BreakOnWeightlessMove = true, + DuplicateCondition = DuplicateConditions.None, + RequireCanInteract = false, + DistanceThreshold = null, + }); + } + + private void OnTransformSelected(Entity ent, + ref ChangelingTransformIdentitySelectMessage args) + { + _uiSystem.CloseUi(ent.Owner, TransformUI.Key, ent); + + if (!TryGetEntity(args.TargetIdentity, out var targetIdentity)) + return; + + if (!TryComp(ent, out var identity)) + return; + + if (identity.CurrentIdentity == targetIdentity) + return; // don't transform into ourselves + + if (!identity.ConsumedIdentities.Contains(targetIdentity.Value)) + return; // this identity does not belong to this player + + TransformInto(ent.AsNullable(), targetIdentity.Value); + } + + private void OnSuccessfulTransform(Entity ent, + ref ChangelingTransformDoAfterEvent args) + { + args.Handled = true; + + if (EntityManager.EntityExists(ent.Comp.CurrentTransformSound)) + _audio.Stop(ent.Comp.CurrentTransformSound); + + if (args.Cancelled) + return; + + if (!_prototype.Resolve(ent.Comp.TransformCloningSettings, out var settings)) + return; + + if (args.Target is not { } targetIdentity) + return; + + _humanoidAppearanceSystem.CloneAppearance(targetIdentity, args.User); + _cloningSystem.CloneComponents(targetIdentity, args.User, settings); + + if(TryComp(targetIdentity, out var storedIdentity) && storedIdentity.OriginalSession != null) + _adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(ent.Owner):player} successfully transformed into \"{Name(targetIdentity)}\" ({storedIdentity.OriginalSession:player})"); + else + _adminLogger.Add(LogType.Action, LogImpact.High, $"{ToPrettyString(ent.Owner):player} successfully transformed into \"{Name(targetIdentity)}\""); + _metaSystem.SetEntityName(ent, Name(targetIdentity), raiseEvents: false); + + Dirty(ent); + + if (TryComp(ent, out var identity)) // in case we ever get changelings that don't store identities + { + identity.CurrentIdentity = targetIdentity; + Dirty(ent.Owner, identity); + } + } +} diff --git a/Content.Shared/Cloning/SharedCloningSystem.cs b/Content.Shared/Cloning/SharedCloningSystem.cs new file mode 100644 index 0000000000..d8ab8a2aa1 --- /dev/null +++ b/Content.Shared/Cloning/SharedCloningSystem.cs @@ -0,0 +1,14 @@ +namespace Content.Shared.Cloning; + +public abstract partial class SharedCloningSystem : EntitySystem +{ + /// + /// Copy components from one entity to another based on a CloningSettingsPrototype. + /// + /// The orignal Entity to clone components from. + /// The target Entity to clone components to. + /// The clone settings prototype containing the list of components to clone. + public virtual void CloneComponents(EntityUid original, EntityUid clone, CloningSettingsPrototype settings) + { + } +} diff --git a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs index 3d3af84a30..1df46e53d6 100644 --- a/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs +++ b/Content.Shared/Humanoid/SharedHumanoidAppearanceSystem.cs @@ -11,6 +11,7 @@ using Content.Shared.Inventory; using Content.Shared.Preferences; using Robust.Shared; using Robust.Shared.Configuration; +using Robust.Shared.Enums; using Robust.Shared.GameObjects.Components.Localization; using Robust.Shared.Network; using Robust.Shared.Player; @@ -152,16 +153,12 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem targetHumanoid.SkinColor = sourceHumanoid.SkinColor; targetHumanoid.EyeColor = sourceHumanoid.EyeColor; targetHumanoid.Age = sourceHumanoid.Age; - SetSex(target, sourceHumanoid.Sex, false, targetHumanoid); targetHumanoid.CustomBaseLayers = new(sourceHumanoid.CustomBaseLayers); targetHumanoid.MarkingSet = new(sourceHumanoid.MarkingSet); - targetHumanoid.Gender = sourceHumanoid.Gender; + SetSex(target, sourceHumanoid.Sex, false, targetHumanoid); + SetGender((target, targetHumanoid), sourceHumanoid.Gender); - if (TryComp(target, out var grammar)) - _grammarSystem.SetGender((target, grammar), sourceHumanoid.Gender); - - _identity.QueueIdentityUpdate(target); Dirty(target, targetHumanoid); } @@ -264,6 +261,23 @@ public abstract class SharedHumanoidAppearanceSystem : EntitySystem Dirty(uid, humanoid); } + /// + /// Sets the gender in the entity's HumanoidAppearanceComponent and GrammarComponent. + /// + public void SetGender(Entity ent, Gender gender) + { + if (!Resolve(ent, ref ent.Comp)) + return; + + ent.Comp.Gender = gender; + Dirty(ent); + + if (TryComp(ent, out var grammar)) + _grammarSystem.SetGender((ent, grammar), gender); + + _identity.QueueIdentityUpdate(ent); + } + /// /// Sets the skin color of this humanoid mob. Will only affect base layers that are not custom, /// custom base layers should use instead. diff --git a/Content.Shared/Inventory/InventorySystem.Slots.cs b/Content.Shared/Inventory/InventorySystem.Slots.cs index e9fb62f2ad..09c3bbc45b 100644 --- a/Content.Shared/Inventory/InventorySystem.Slots.cs +++ b/Content.Shared/Inventory/InventorySystem.Slots.cs @@ -1,5 +1,6 @@ using System.Diagnostics.CodeAnalysis; using System.Linq; +using Content.Shared.DisplacementMap; using Content.Shared.Inventory.Events; using Content.Shared.Storage; using Robust.Shared.Containers; @@ -55,6 +56,24 @@ public partial class InventorySystem : EntitySystem return false; } + /// + /// Copy this component's datafields from one entity to another. + /// This can't use CopyComp because the template needs to be applied using the API method. + /// + public void CopyComponent(Entity source, EntityUid target) + { + if (!Resolve(source, ref source.Comp)) + return; + + var targetComp = EnsureComp(target); + targetComp.SpeciesId = source.Comp.SpeciesId; + targetComp.Displacements = new Dictionary(source.Comp.Displacements); + targetComp.FemaleDisplacements = new Dictionary(source.Comp.FemaleDisplacements); + targetComp.MaleDisplacements = new Dictionary(source.Comp.MaleDisplacements); + SetTemplateId((target, targetComp), source.Comp.TemplateId); + Dirty(target, targetComp); + } + private void OnInit(Entity ent, ref ComponentInit args) { UpdateInventoryTemplate(ent); diff --git a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs index 4584e4401a..d0faad8b50 100644 --- a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs +++ b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs @@ -54,6 +54,21 @@ namespace Content.Shared.Movement.Systems RefreshMovementSpeedModifiers(entity); } + /// + /// Copy this component's datafields from one entity to another. + /// This needs to refresh the modifiers after using CopyComp. + /// + public void CopyComponent(Entity source, EntityUid target) + { + if (!Resolve(source, ref source.Comp)) + return; + + CopyComp(source, target, source.Comp); + RefreshWeightlessModifiers(target); + RefreshMovementSpeedModifiers(target); + RefreshFrictionModifiers(target); + } + public void RefreshWeightlessModifiers(EntityUid uid, MovementSpeedModifierComponent? move = null) { if (!Resolve(uid, ref move, false)) diff --git a/Content.Shared/Rootable/SharedRootableSystem.cs b/Content.Shared/Rootable/SharedRootableSystem.cs index c3deca0769..9165c3c111 100644 --- a/Content.Shared/Rootable/SharedRootableSystem.cs +++ b/Content.Shared/Rootable/SharedRootableSystem.cs @@ -1,6 +1,7 @@ using Content.Shared.Actions; using Content.Shared.Actions.Components; using Content.Shared.Alert; +using Content.Shared.Cloning.Events; using Content.Shared.Coordinates; using Content.Shared.Fluids.Components; using Content.Shared.Gravity; @@ -50,6 +51,20 @@ public abstract class SharedRootableSystem : EntitySystem SubscribeLocalEvent(OnIsWeightless); SubscribeLocalEvent(OnSlipAttempt); SubscribeLocalEvent(OnRefreshMovementSpeed); + SubscribeLocalEvent(OnCloning); + } + + private void OnCloning(Entity ent, ref CloningEvent args) + { + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + return; + + var cloneComp = EnsureComp(args.CloneUid); + cloneComp.TransferRate = ent.Comp.TransferRate; + cloneComp.TransferFrequency = ent.Comp.TransferFrequency; + cloneComp.SpeedModifier = ent.Comp.SpeedModifier; + cloneComp.RootSound = ent.Comp.RootSound; + Dirty(args.CloneUid, cloneComp); } private void OnRootableMapInit(Entity entity, ref MapInitEvent args) @@ -68,6 +83,7 @@ public abstract class SharedRootableSystem : EntitySystem var actions = new Entity(entity, comp); _actions.RemoveAction(actions, entity.Comp.ActionEntity); + _alerts.ClearAlert(entity, entity.Comp.RootedAlert); } private void OnRootableToggle(Entity entity, ref ToggleActionEvent args) diff --git a/Content.Shared/Sericulture/SericultureSystem.cs b/Content.Shared/Sericulture/SericultureSystem.cs index e5942a433e..e6086e67c2 100644 --- a/Content.Shared/Sericulture/SericultureSystem.cs +++ b/Content.Shared/Sericulture/SericultureSystem.cs @@ -38,7 +38,7 @@ public abstract partial class SharedSericultureSystem : EntitySystem private void OnClone(Entity ent, ref CloningEvent args) { - if(!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) + if (!args.Settings.EventComponents.Contains(Factory.GetRegistration(ent.Comp.GetType()).Name)) return; var comp = EnsureComp(args.CloneUid); diff --git a/Content.Shared/Speech/SpeechComponent.cs b/Content.Shared/Speech/SpeechComponent.cs index 8c12fc918a..fddb41753e 100644 --- a/Content.Shared/Speech/SpeechComponent.cs +++ b/Content.Shared/Speech/SpeechComponent.cs @@ -16,29 +16,26 @@ namespace Content.Shared.Speech [Access(typeof(SpeechSystem), Friend = AccessPermissions.ReadWrite, Other = AccessPermissions.Read)] public bool Enabled = true; - [ViewVariables(VVAccess.ReadWrite)] - [DataField] + [DataField, AutoNetworkedField] public ProtoId? SpeechSounds; /// /// What speech verb prototype should be used by default for displaying this entity's messages? /// - [ViewVariables(VVAccess.ReadWrite)] - [DataField] + [DataField, AutoNetworkedField] public ProtoId SpeechVerb = "Default"; /// /// What emotes allowed to use event if emote is false /// - [ViewVariables(VVAccess.ReadWrite)] - [DataField] + [DataField, AutoNetworkedField] public List> AllowedEmotes = new(); /// /// A mapping from chat suffixes loc strings to speech verb prototypes that should be conditionally used. /// For things like '?' changing to 'asks' or '!!' making text bold and changing to 'yells'. Can be overridden if necessary. /// - [DataField] + [DataField, AutoNetworkedField] public Dictionary> SuffixSpeechVerbs = new() { { "chat-speech-verb-suffix-exclamation-strong", "DefaultExclamationStrong" }, @@ -51,7 +48,6 @@ namespace Content.Shared.Speech [DataField] public AudioParams AudioParams = AudioParams.Default.WithVolume(-2f).WithRolloffFactor(4.5f); - [ViewVariables(VVAccess.ReadWrite)] [DataField] public float SoundCooldownTime { get; set; } = 0.5f; diff --git a/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs b/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs index f3c9055910..d2d80a632f 100644 --- a/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs +++ b/Content.Shared/Storage/EntitySystems/SharedStorageSystem.cs @@ -232,7 +232,14 @@ public abstract class SharedStorageSystem : EntitySystem StoredItems = storedItems, SavedLocations = component.SavedLocations, Whitelist = component.Whitelist, - Blacklist = component.Blacklist + Blacklist = component.Blacklist, + QuickInsert = component.QuickInsert, + AreaInsert = component.AreaInsert, + StorageInsertSound = component.StorageInsertSound, + StorageRemoveSound = component.StorageRemoveSound, + StorageOpenSound = component.StorageOpenSound, + StorageCloseSound = component.StorageCloseSound, + DefaultStorageOrientation = component.DefaultStorageOrientation, }; } @@ -348,6 +355,44 @@ public abstract class SharedStorageSystem : EntitySystem args.Verbs.Add(verb); } + /// + /// Copy this component's datafields from one entity to another. + /// This can't use CopyComp because we don't want to copy the references to the items inside the storage. + /// + public void CopyComponent(Entity source, EntityUid target) + { + if (!Resolve(source, ref source.Comp)) + return; + + var targetComp = EnsureComp(target); + targetComp.Grid = new List(source.Comp.Grid); + targetComp.MaxItemSize = source.Comp.MaxItemSize; + targetComp.QuickInsert = source.Comp.QuickInsert; + targetComp.QuickInsertCooldown = source.Comp.QuickInsertCooldown; + targetComp.OpenUiCooldown = source.Comp.OpenUiCooldown; + targetComp.ClickInsert = source.Comp.ClickInsert; + targetComp.OpenOnActivate = source.Comp.OpenOnActivate; + targetComp.AreaInsert = source.Comp.AreaInsert; + targetComp.AreaInsertRadius = source.Comp.AreaInsertRadius; + targetComp.Whitelist = source.Comp.Whitelist; + targetComp.Blacklist = source.Comp.Blacklist; + targetComp.StorageInsertSound = source.Comp.StorageInsertSound; + targetComp.StorageRemoveSound = source.Comp.StorageRemoveSound; + targetComp.StorageOpenSound = source.Comp.StorageOpenSound; + targetComp.StorageCloseSound = source.Comp.StorageCloseSound; + targetComp.DefaultStorageOrientation = source.Comp.DefaultStorageOrientation; + targetComp.HideStackVisualsWhenClosed = source.Comp.HideStackVisualsWhenClosed; + targetComp.SilentStorageUserTag = source.Comp.SilentStorageUserTag; + targetComp.ShowVerb = source.Comp.ShowVerb; + + UpdateOccupied((target, targetComp)); + Dirty(target, targetComp); + + var targetUI = EnsureComp(target); + + UI.SetUi((target, targetUI), StorageComponent.StorageUiKey.Key, new InterfaceData("StorageBoundUserInterface")); + } + /// /// Tries to get the storage location of an item. /// @@ -1957,15 +2002,17 @@ public abstract class SharedStorageSystem : EntitySystem protected sealed class StorageComponentState : ComponentState { public Dictionary StoredItems = new(); - public Dictionary> SavedLocations = new(); - public List Grid = new(); - public ProtoId? MaxItemSize; - public EntityWhitelist? Whitelist; - public EntityWhitelist? Blacklist; + public bool QuickInsert; + public bool AreaInsert; + public SoundSpecifier? StorageInsertSound; + public SoundSpecifier? StorageRemoveSound; + public SoundSpecifier? StorageOpenSound; + public SoundSpecifier? StorageCloseSound; + public StorageDefaultOrientation? DefaultStorageOrientation; } } diff --git a/Resources/Audio/Effects/Changeling/attributions.yml b/Resources/Audio/Effects/Changeling/attributions.yml new file mode 100644 index 0000000000..d7d7931cd6 --- /dev/null +++ b/Resources/Audio/Effects/Changeling/attributions.yml @@ -0,0 +1,19 @@ +- files: ["devour_suck.ogg"] + license: "CC0-1.0" + copyright: "4Cairnz on Freesound: June 5th 2023" + source: "https://freesound.org/people/4Cairnz/sounds/689640/" + +- files: ["devour_windup.ogg "] + license: "CC-BY-SA-3.0" + copyright: "Made by @DarkIcedCoffee on Discord for SS14, utilizing sounds from Caitlin_100, jedg and EricsSoundschmiede on freesound" + source: "https://youtu.be/iviCUO2xH_E" + +- files: ["devour_consume.ogg"] + license: "CC-BY-SA-3.0" + copyright: "Made by @DarkIcedCoffee on Discord for SS14, utilizing sounds from jedg and reg7783 on freesound." + source: "https://youtu.be/iviCUO2xH_E" + +- files: ["changeling_transform.ogg"] + license: "CC-BY-SA-3.0" + copyright: "Made by @DarkIcedCoffee on Discord for SS14" + source: "https://youtu.be/iviCUO2xH_E" diff --git a/Resources/Audio/Effects/Changeling/changeling_transform.ogg b/Resources/Audio/Effects/Changeling/changeling_transform.ogg new file mode 100644 index 0000000000..23379d246d Binary files /dev/null and b/Resources/Audio/Effects/Changeling/changeling_transform.ogg differ diff --git a/Resources/Audio/Effects/Changeling/devour_consume.ogg b/Resources/Audio/Effects/Changeling/devour_consume.ogg new file mode 100644 index 0000000000..9d05c9f541 Binary files /dev/null and b/Resources/Audio/Effects/Changeling/devour_consume.ogg differ diff --git a/Resources/Audio/Effects/Changeling/devour_suck.ogg b/Resources/Audio/Effects/Changeling/devour_suck.ogg new file mode 100644 index 0000000000..d756b0a55d Binary files /dev/null and b/Resources/Audio/Effects/Changeling/devour_suck.ogg differ diff --git a/Resources/Audio/Effects/Changeling/devour_windup.ogg b/Resources/Audio/Effects/Changeling/devour_windup.ogg new file mode 100644 index 0000000000..7c81d9a076 Binary files /dev/null and b/Resources/Audio/Effects/Changeling/devour_windup.ogg differ diff --git a/Resources/Locale/en-US/administration/antag.ftl b/Resources/Locale/en-US/administration/antag.ftl index 1433cc1dc4..161054aca2 100644 --- a/Resources/Locale/en-US/administration/antag.ftl +++ b/Resources/Locale/en-US/administration/antag.ftl @@ -7,6 +7,8 @@ admin-verb-make-pirate = Make the target into a pirate. Note this doesn't config admin-verb-make-head-rev = Make the target into a Head Revolutionary. admin-verb-make-thief = Make the target into a thief. admin-verb-make-paradox-clone = Create a Paradox Clone ghost role of the target. +admin-verb-make-changeling = Make the target into a Changeling. + admin-verb-text-make-traitor = Make Traitor admin-verb-text-make-initial-infected = Make Initial Infected @@ -16,5 +18,6 @@ admin-verb-text-make-pirate = Make Pirate admin-verb-text-make-head-rev = Make Head Rev admin-verb-text-make-thief = Make Thief admin-verb-text-make-paradox-clone = Create Paradox Clone +admin-verb-text-make-changeling = Make Changeling (WIP) admin-overlay-antag-classic = ANTAG diff --git a/Resources/Locale/en-US/changeling/changeling.ftl b/Resources/Locale/en-US/changeling/changeling.ftl new file mode 100644 index 0000000000..aa3843691a --- /dev/null +++ b/Resources/Locale/en-US/changeling/changeling.ftl @@ -0,0 +1,20 @@ +roles-antag-changeling-name = Changeling +roles-antag-changeling-objective = A intelligent predator that assumes the identities of its victims. + +changeling-role-greeting = You are a Changeling, a highly intelligent predator. Your only goal is to escape the station alive via assuming the identities of the denizens of this station. You are hungry and will not make it long without sustenance... kill, consume, hide, survive. +changeling-briefing = You are a changeling, your goal is to survive. Consume humanoids to gain biomass and utilize it to evade termination. You are able to utilize and assume the identities of those you consume to evade a grim fate. + +changeling-devour-attempt-failed-rotting = This corpse has only rotted biomass. +changeling-devour-attempt-failed-protected = This victim's biomass is protected. + +changeling-devour-begin-windup-self = Our uncanny mouth reveals itself with otherworldly hunger. +changeling-devour-begin-windup-others = { CAPITALIZE(POSS-ADJ($user)) } uncanny mouth reveals itself with otherworldly hunger. +changeling-devour-begin-consume-self = The uncanny mouth digs deep into its victim. +changeling-devour-begin-consume-others = { CAPITALIZE(POSS-ADJ($user)) } uncanny mouth digs deep into { POSS-ADJ($user) } victim. + +changeling-devour-consume-failed-not-dead = This body yet lives! We cannot consume it alive! +changeling-devour-consume-complete-self = Our uncanny mouth retreats, biomass consumed. +changeling-devour-consume-complete-others = { CAPITALIZE(POSS-ADJ($user)) } uncanny mouth retreats. + +changeling-transform-attempt-self = Our bones snap, muscles tear, one flesh becomes another. +changeling-transform-attempt-others = { CAPITALIZE(POSS-ADJ($user)) } bones snap, muscles tear, body shifts into another. diff --git a/Resources/Locale/en-US/mind/role-types.ftl b/Resources/Locale/en-US/mind/role-types.ftl index 7d568fd686..4614d20a47 100644 --- a/Resources/Locale/en-US/mind/role-types.ftl +++ b/Resources/Locale/en-US/mind/role-types.ftl @@ -33,3 +33,4 @@ role-subtype-survivor = Survivor role-subtype-subverted = Subverted role-subtype-paradox-clone = Paradox role-subtype-wizard = Wizard +role-subtype-changeling = Changeling diff --git a/Resources/Prototypes/Antag/changeling.yml b/Resources/Prototypes/Antag/changeling.yml new file mode 100644 index 0000000000..b4c8b2e3dc --- /dev/null +++ b/Resources/Prototypes/Antag/changeling.yml @@ -0,0 +1,35 @@ +- type: entity + parent: MobHuman + id: MobLing + name: Urist McLing + suffix: Non-Antag + components: + - type: ChangelingDevour + - type: ChangelingIdentity + - type: ChangelingTransform + - type: ActionGrant + actions: + - ActionRetractableItemArmBlade # Temporary addition, will inevitably be a purchasable in the bio-store + +- type: entity + id: ActionChangelingDevour + name: "[color=red]Devour[/color]" + description: Consume the essence of your victims and subsume their identity and mind into your own. + components: + - type: Action + icon: { sprite : Interface/Actions/changeling.rsi, state: "devour" } + iconOn: { sprite : Interface/Actions/changeling.rsi, state: "devour_on" } + priority: 1 + - type: TargetAction + - type: EntityTargetAction + event: !type:ChangelingDevourActionEvent + +- type: entity + id: ActionChangelingTransform + name: "[color=red]Transform[/color]" + description: Transform and assume the identities of those you have devoured. + components: + - type: Action + icon: { sprite : Interface/Actions/changeling.rsi, state: "transform" } + - type: InstantAction + event: !type:ChangelingTransformActionEvent diff --git a/Resources/Prototypes/Entities/Mobs/Player/clone.yml b/Resources/Prototypes/Entities/Mobs/Player/clone.yml index bdf741e58a..49c05ba3f1 100644 --- a/Resources/Prototypes/Entities/Mobs/Player/clone.yml +++ b/Resources/Prototypes/Entities/Mobs/Player/clone.yml @@ -1,13 +1,12 @@ # Settings for cloning bodies # If you add a new trait, job specific component or a component doing visual/examination changes for humanoids # then add it here to the correct prototype. -# The datafields of the components are only shallow copied using CopyComp. +# The datafields of the components copied using CopyComp. # Subscribe to CloningEvent instead if that is not enough. -# for basic traits etc. -# used by the random clone spawner +# for basic physical traits - type: cloningSettings - id: BaseClone + id: Body components: # general - DetailExaminable @@ -15,8 +14,9 @@ - Fingerprint - NpcFactionMember # traits - # - LegsParalyzed (you get healed) - BlackAndWhiteOverlay + - Clumsy + # - LegsParalyzed (you get healed) - LightweightDrunk - Muted - Narcolepsy @@ -26,14 +26,6 @@ - PermanentBlindness - Snoring - Unrevivable - # job specific - - BibleUser - - CommandStaff - - Clumsy - - MindShield - - MimePowers - - SpaceNinja - - Thieving # accents - Accentless - BackwardsAccent @@ -62,6 +54,30 @@ - SouthernAccent - SpanishAccent - StutteringAccent + +# for job-specific traits etc. +- type: cloningSettings + id: Special + components: + - BibleUser + - CommandStaff + - MindShield + - MimePowers + - SpaceNinja + - Thieving + +# antag roles +- type: cloningSettings + id: Antag + components: + - HeadRevolutionary + - Revolutionary + - NukeOperative + +# a full clone with all traits and items, but no antag roles +- type: cloningSettings + id: BaseClone + parent: [Body, Special] blacklist: components: - AttachedClothing # helmets, which are part of the suit @@ -69,26 +85,47 @@ - Implanter # they will spawn full again, but you already get the implant. And we can't do item slot copying yet - VirtualItem -# all antagonist roles -- type: cloningSettings - id: Antag - parent: BaseClone - components: - - HeadRevolutionary - - Revolutionary - - NukeOperative - # for cloning pods - type: cloningSettings id: CloningPod - parent: Antag + parent: [BaseClone, Antag] forceCloning: false copyEquipment: null copyInternalStorage: false copyImplants: false -# spawner +# for paradox clones +- type: cloningSettings + id: ParadoxCloningSettings + parent: [BaseClone, Antag] +# changeling identity copying +- type: cloningSettings + id: ChangelingCloningSettings + parent: Body + components: + # These are already part of the base species prototype that is spawned for the clone, + # that means we only need to copy them over when switching between species. + # So these don't need to be part of the Body settings, unless someone makes a trait that adjusts these components. + - BodyEmotes + - Fixtures + - Speech + - TypingIndicator + - ScaleVisuals # for dwarf height + eventComponents: + # these need special treatment in the event subscription + - Inventory # arachnid pockets and diona feet + - Wagging # lizard tails + - Vocal # voice sounds + - Storage # slime storage + - Rootable # diona + - Sericulture # arachnids + - MovementSpeedModifier # moths when weightless + copyEquipment: null + copyInternalStorage: false + copyImplants: false + +# spawner - type: entity id: RandomCloneSpawner name: Random Clone diff --git a/Resources/Prototypes/GameRules/roundstart.yml b/Resources/Prototypes/GameRules/roundstart.yml index 08d088a08c..71a249ed48 100644 --- a/Resources/Prototypes/GameRules/roundstart.yml +++ b/Resources/Prototypes/GameRules/roundstart.yml @@ -225,6 +225,30 @@ mindRoles: - MindRoleTraitorReinforcement +- type: entity + id: Changeling + parent: BaseGameRule + components: + - type: GameRule + minPlayers: 25 + - type: AntagSelection + definitions: + - prefRoles: [ Changeling ] + max: 3 + playerRatio: 15 + briefing: + text: changeling-role-greeting + color: Red + components: + - type: ChangelingDevour + - type: ChangelingIdentity + - type: ChangelingTransform + - type: ActionGrant + actions: + - ActionRetractableItemArmBlade # Temporary addition, will inevitably be a purchasable in the bio-store + mindRoles: + - MindRoleChangeling + - type: entity id: Revolutionary parent: BaseGameRule diff --git a/Resources/Prototypes/Roles/Antags/changeling.yml b/Resources/Prototypes/Roles/Antags/changeling.yml new file mode 100644 index 0000000000..3b19c993e6 --- /dev/null +++ b/Resources/Prototypes/Roles/Antags/changeling.yml @@ -0,0 +1,6 @@ +- type: antag + id: Changeling + name: roles-antag-changeling-name + antagonist: true + setPreference: false # TODO: set this to true once Changeling exits WIP status + objective: roles-antag-changeling-objective diff --git a/Resources/Prototypes/Roles/MindRoles/mind_roles.yml b/Resources/Prototypes/Roles/MindRoles/mind_roles.yml index 79223ed67d..95d49c1b83 100644 --- a/Resources/Prototypes/Roles/MindRoles/mind_roles.yml +++ b/Resources/Prototypes/Roles/MindRoles/mind_roles.yml @@ -307,3 +307,16 @@ exclusiveAntag: true subtype: role-subtype-zombie - type: ZombieRole + +# Changeling +- type: entity + parent: BaseMindRoleAntag + id: MindRoleChangeling + name: Changeling Role + components: + - type: MindRole + antagPrototype: Changeling + exclusiveAntag: true + roleType: SoloAntagonist + subtype: role-subtype-changeling + - type: ChangelingRole diff --git a/Resources/Prototypes/SoundCollections/changeling.yml b/Resources/Prototypes/SoundCollections/changeling.yml new file mode 100644 index 0000000000..4edb07dc16 --- /dev/null +++ b/Resources/Prototypes/SoundCollections/changeling.yml @@ -0,0 +1,14 @@ +- type: soundCollection + id: ChangelingDevourConsume + files: + - /Audio/Effects/Changeling/devour_consume.ogg + +- type: soundCollection + id: ChangelingDevourWindup + files: + - /Audio/Effects/Changeling/devour_windup.ogg + +- type: soundCollection + id: ChangelingTransformAttempt + files: + - /Audio/Effects/Changeling/changeling_transform.ogg diff --git a/Resources/Textures/Interface/Actions/changeling.rsi/devour.png b/Resources/Textures/Interface/Actions/changeling.rsi/devour.png new file mode 100644 index 0000000000..f5acc7dcee Binary files /dev/null and b/Resources/Textures/Interface/Actions/changeling.rsi/devour.png differ diff --git a/Resources/Textures/Interface/Actions/changeling.rsi/devour_on.png b/Resources/Textures/Interface/Actions/changeling.rsi/devour_on.png new file mode 100644 index 0000000000..65d63eb250 Binary files /dev/null and b/Resources/Textures/Interface/Actions/changeling.rsi/devour_on.png differ diff --git a/Resources/Textures/Interface/Actions/changeling.rsi/meta.json b/Resources/Textures/Interface/Actions/changeling.rsi/meta.json index babe776612..20c9ed1720 100644 --- a/Resources/Textures/Interface/Actions/changeling.rsi/meta.json +++ b/Resources/Textures/Interface/Actions/changeling.rsi/meta.json @@ -7,6 +7,15 @@ "y": 32 }, "states": [ + { + "name": "transform" + }, + { + "name": "devour" + }, + { + "name": "devour_on" + }, { "name": "armblade" } diff --git a/Resources/Textures/Interface/Actions/changeling.rsi/transform.png b/Resources/Textures/Interface/Actions/changeling.rsi/transform.png new file mode 100644 index 0000000000..6657790012 Binary files /dev/null and b/Resources/Textures/Interface/Actions/changeling.rsi/transform.png differ