diff --git a/Content.Client/Clothing/Systems/CursedMaskSystem.cs b/Content.Client/Clothing/Systems/CursedMaskSystem.cs new file mode 100644 index 0000000000..bc931d15fd --- /dev/null +++ b/Content.Client/Clothing/Systems/CursedMaskSystem.cs @@ -0,0 +1,6 @@ +using Content.Shared.Clothing; + +namespace Content.Client.Clothing.Systems; + +/// +public sealed class CursedMaskSystem : SharedCursedMaskSystem; diff --git a/Content.Server/Clothing/Systems/CursedMaskSystem.cs b/Content.Server/Clothing/Systems/CursedMaskSystem.cs new file mode 100644 index 0000000000..2045ff5ccd --- /dev/null +++ b/Content.Server/Clothing/Systems/CursedMaskSystem.cs @@ -0,0 +1,92 @@ +using Content.Server.Administration.Logs; +using Content.Server.GameTicking; +using Content.Server.Mind; +using Content.Server.NPC; +using Content.Server.NPC.HTN; +using Content.Server.NPC.Systems; +using Content.Server.Popups; +using Content.Shared.Clothing; +using Content.Shared.Clothing.Components; +using Content.Shared.Database; +using Content.Shared.NPC.Components; +using Content.Shared.NPC.Systems; +using Content.Shared.Players; +using Content.Shared.Popups; +using Robust.Shared.Player; +using Robust.Shared.Prototypes; + +namespace Content.Server.Clothing.Systems; + +/// +public sealed class CursedMaskSystem : SharedCursedMaskSystem +{ + [Dependency] private readonly IAdminLogManager _adminLog = default!; + [Dependency] private readonly GameTicker _ticker = default!; + [Dependency] private readonly HTNSystem _htn = default!; + [Dependency] private readonly MindSystem _mind = default!; + [Dependency] private readonly NPCSystem _npc = default!; + [Dependency] private readonly NpcFactionSystem _npcFaction = default!; + [Dependency] private readonly PopupSystem _popup = default!; + + // We can't store this info on the component easily + private static readonly ProtoId TakeoverRootTask = "SimpleHostileCompound"; + + protected override void TryTakeover(Entity ent, EntityUid wearer) + { + if (ent.Comp.CurrentState != CursedMaskExpression.Anger) + return; + + if (TryComp(wearer, out var actor) && actor.PlayerSession.GetMind() is { } mind) + { + var session = actor.PlayerSession; + if (!_ticker.OnGhostAttempt(mind, false)) + return; + + ent.Comp.StolenMind = mind; + + _popup.PopupEntity(Loc.GetString("cursed-mask-takeover-popup"), wearer, session, PopupType.LargeCaution); + _adminLog.Add(LogType.Action, + LogImpact.Extreme, + $"{ToPrettyString(wearer):player} had their body taken over and turned into an enemy through the cursed mask {ToPrettyString(ent):entity}"); + } + + var npcFaction = EnsureComp(wearer); + ent.Comp.OldFactions = npcFaction.Factions; + _npcFaction.ClearFactions((wearer, npcFaction), false); + _npcFaction.AddFaction((wearer, npcFaction), ent.Comp.CursedMaskFaction); + + ent.Comp.HasNpc = !EnsureComp(wearer, out var htn); + htn.RootTask = new HTNCompoundTask { Task = TakeoverRootTask }; + htn.Blackboard.SetValue(NPCBlackboard.Owner, wearer); + _npc.WakeNPC(wearer, htn); + _htn.Replan(htn); + } + + protected override void OnClothingUnequip(Entity ent, ref ClothingGotUnequippedEvent args) + { + // If we are taking off the cursed mask + if (ent.Comp.CurrentState == CursedMaskExpression.Anger) + { + if (ent.Comp.HasNpc) + RemComp(args.Wearer); + + var npcFaction = EnsureComp(args.Wearer); + _npcFaction.RemoveFaction((args.Wearer, npcFaction), ent.Comp.CursedMaskFaction, false); + _npcFaction.AddFactions((args.Wearer, npcFaction), ent.Comp.OldFactions); + + ent.Comp.HasNpc = false; + ent.Comp.OldFactions.Clear(); + + if (Exists(ent.Comp.StolenMind)) + { + _mind.TransferTo(ent.Comp.StolenMind.Value, args.Wearer); + _adminLog.Add(LogType.Action, + LogImpact.Extreme, + $"{ToPrettyString(args.Wearer):player} was restored to their body after the removal of {ToPrettyString(ent):entity}."); + ent.Comp.StolenMind = null; + } + } + + RandomizeCursedMask(ent, args.Wearer); + } +} diff --git a/Content.Shared/Clothing/Components/CursedMaskComponent.cs b/Content.Shared/Clothing/Components/CursedMaskComponent.cs new file mode 100644 index 0000000000..6073bdf5fc --- /dev/null +++ b/Content.Shared/Clothing/Components/CursedMaskComponent.cs @@ -0,0 +1,65 @@ +using Content.Shared.Damage; +using Content.Shared.NPC.Prototypes; +using Robust.Shared.GameStates; +using Robust.Shared.Prototypes; +using Robust.Shared.Serialization; + +namespace Content.Shared.Clothing.Components; + +/// +/// This is used for a mask that takes over the host when worn. +/// +[RegisterComponent, NetworkedComponent, Access(typeof(SharedCursedMaskSystem))] +public sealed partial class CursedMaskComponent : Component +{ + /// + /// The current expression shown. Used to determine which effect is applied. + /// + [DataField] + public CursedMaskExpression CurrentState = CursedMaskExpression.Neutral; + + /// + /// Speed modifier applied when the "Joy" expression is present. + /// + [DataField] + public float JoySpeedModifier = 1.15f; + + /// + /// Damage modifier applied when the "Despair" expression is present. + /// + [DataField] + public DamageModifierSet DespairDamageModifier = new(); + + /// + /// Whether or not the mask is currently attached to an NPC. + /// + [DataField] + public bool HasNpc; + + /// + /// The mind that was booted from the wearer when the mask took over. + /// + [DataField] + public EntityUid? StolenMind; + + [DataField] + public ProtoId CursedMaskFaction = "SimpleHostile"; + + [DataField] + public HashSet> OldFactions = new(); +} + +[Serializable, NetSerializable] +public enum CursedMaskVisuals : byte +{ + State +} + +[Serializable, NetSerializable] +public enum CursedMaskExpression : byte +{ + Neutral, + Joy, + Despair, + Anger +} diff --git a/Content.Shared/Clothing/SharedCursedMaskSystem.cs b/Content.Shared/Clothing/SharedCursedMaskSystem.cs new file mode 100644 index 0000000000..8ba83be151 --- /dev/null +++ b/Content.Shared/Clothing/SharedCursedMaskSystem.cs @@ -0,0 +1,73 @@ +using Content.Shared.Clothing.Components; +using Content.Shared.Damage; +using Content.Shared.Examine; +using Content.Shared.Inventory; +using Content.Shared.Movement.Systems; +using Robust.Shared.Random; +using Robust.Shared.Timing; + +namespace Content.Shared.Clothing; + +/// +/// This handles +/// +public abstract class SharedCursedMaskSystem : EntitySystem +{ + [Dependency] private readonly IGameTiming _timing = default!; + [Dependency] private readonly SharedAppearanceSystem _appearance = default!; + [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!; + + /// + public override void Initialize() + { + base.Initialize(); + + SubscribeLocalEvent(OnClothingEquip); + SubscribeLocalEvent(OnClothingUnequip); + SubscribeLocalEvent(OnExamine); + + SubscribeLocalEvent>(OnMovementSpeedModifier); + SubscribeLocalEvent>(OnModifyDamage); + } + + private void OnClothingEquip(Entity ent, ref ClothingGotEquippedEvent args) + { + RandomizeCursedMask(ent, args.Wearer); + TryTakeover(ent, args.Wearer); + } + + protected virtual void OnClothingUnequip(Entity ent, ref ClothingGotUnequippedEvent args) + { + RandomizeCursedMask(ent, args.Wearer); + } + + private void OnExamine(Entity ent, ref ExaminedEvent args) + { + args.PushMarkup(Loc.GetString($"cursed-mask-examine-{ent.Comp.CurrentState.ToString()}")); + } + + private void OnMovementSpeedModifier(Entity ent, ref InventoryRelayedEvent args) + { + if (ent.Comp.CurrentState == CursedMaskExpression.Joy) + args.Args.ModifySpeed(ent.Comp.JoySpeedModifier); + } + + private void OnModifyDamage(Entity ent, ref InventoryRelayedEvent args) + { + if (ent.Comp.CurrentState == CursedMaskExpression.Despair) + args.Args.Damage = DamageSpecifier.ApplyModifierSet(args.Args.Damage, ent.Comp.DespairDamageModifier); + } + + protected void RandomizeCursedMask(Entity ent, EntityUid wearer) + { + var random = new System.Random((int) _timing.CurTick.Value); + ent.Comp.CurrentState = random.Pick(Enum.GetValues()); + _appearance.SetData(ent, CursedMaskVisuals.State, ent.Comp.CurrentState); + _movementSpeedModifier.RefreshMovementSpeedModifiers(wearer); + } + + protected virtual void TryTakeover(Entity ent, EntityUid wearer) + { + + } +} diff --git a/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs b/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs index d8d0a2a23b..b647ee8e92 100644 --- a/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs +++ b/Content.Shared/Inventory/Events/UnequipAttemptEvent.cs @@ -17,6 +17,11 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs /// public readonly EntityUid Equipment; + /// + /// The slotFlags of the slot this item is being removed from. + /// + public readonly SlotFlags SlotFlags; + /// /// The slot the entity is being unequipped from. /// @@ -33,6 +38,7 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs UnEquipTarget = unEquipTarget; Equipment = equipment; Unequipee = unequipee; + SlotFlags = slotDefinition.SlotFlags; Slot = slotDefinition.Name; } } diff --git a/Content.Shared/Inventory/SelfEquipOnlyComponent.cs b/Content.Shared/Inventory/SelfEquipOnlyComponent.cs new file mode 100644 index 0000000000..ee1980ef8a --- /dev/null +++ b/Content.Shared/Inventory/SelfEquipOnlyComponent.cs @@ -0,0 +1,16 @@ +using Robust.Shared.GameStates; + +namespace Content.Shared.Inventory; + +/// +/// This is used for an item that can only be equipped/unequipped by the user. +/// +[RegisterComponent, NetworkedComponent, Access(typeof(SelfEquipOnlySystem))] +public sealed partial class SelfEquipOnlyComponent : Component +{ + /// + /// Whether or not the self-equip only condition requires the person to be conscious. + /// + [DataField] + public bool UnequipRequireConscious = true; +} diff --git a/Content.Shared/Inventory/SelfEquipOnlySystem.cs b/Content.Shared/Inventory/SelfEquipOnlySystem.cs new file mode 100644 index 0000000000..2bd113e22b --- /dev/null +++ b/Content.Shared/Inventory/SelfEquipOnlySystem.cs @@ -0,0 +1,45 @@ +using Content.Shared.ActionBlocker; +using Content.Shared.Clothing.Components; +using Content.Shared.Inventory.Events; + +namespace Content.Shared.Inventory; + +public sealed class SelfEquipOnlySystem : EntitySystem +{ + [Dependency] private readonly ActionBlockerSystem _actionBlocker = default!; + + /// + public override void Initialize() + { + SubscribeLocalEvent(OnBeingEquipped); + SubscribeLocalEvent(OnBeingUnequipped); + } + + private void OnBeingEquipped(Entity ent, ref BeingEquippedAttemptEvent args) + { + if (args.Cancelled) + return; + + if (TryComp(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE) + return; + + if (args.Equipee != args.EquipTarget) + args.Cancel(); + } + + private void OnBeingUnequipped(Entity ent, ref BeingUnequippedAttemptEvent args) + { + if (args.Cancelled) + return; + + if (args.Unequipee == args.UnEquipTarget) + return; + + if (TryComp(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE) + return; + + if (ent.Comp.UnequipRequireConscious && !_actionBlocker.CanConsciouslyPerformAction(args.UnEquipTarget)) + return; + args.Cancel(); + } +} diff --git a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs index 7c793d5eb8..8e89e4b62b 100644 --- a/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs +++ b/Content.Shared/Movement/Systems/MovementSpeedModifierSystem.cs @@ -69,5 +69,10 @@ namespace Content.Shared.Movement.Systems WalkSpeedModifier *= walk; SprintSpeedModifier *= sprint; } + + public void ModifySpeed(float mod) + { + ModifySpeed(mod, mod); + } } } diff --git a/Content.Shared/NPC/Systems/NpcFactionSystem.cs b/Content.Shared/NPC/Systems/NpcFactionSystem.cs index 355f5bbb3a..98f14afe2a 100644 --- a/Content.Shared/NPC/Systems/NpcFactionSystem.cs +++ b/Content.Shared/NPC/Systems/NpcFactionSystem.cs @@ -100,6 +100,28 @@ public sealed partial class NpcFactionSystem : EntitySystem RefreshFactions((ent, ent.Comp)); } + /// + /// Adds this entity to the particular faction. + /// + public void AddFactions(Entity ent, HashSet> factions, bool dirty = true) + { + ent.Comp ??= EnsureComp(ent); + + foreach (var faction in factions) + { + if (!_proto.HasIndex(faction)) + { + Log.Error($"Unable to find faction {faction}"); + continue; + } + + ent.Comp.Factions.Add(faction); + } + + if (dirty) + RefreshFactions((ent, ent.Comp)); + } + /// /// Removes this entity from the particular faction. /// diff --git a/Resources/Locale/en-US/clothing/components/cursed-mask.ftl b/Resources/Locale/en-US/clothing/components/cursed-mask.ftl new file mode 100644 index 0000000000..c93a6cfb87 --- /dev/null +++ b/Resources/Locale/en-US/clothing/components/cursed-mask.ftl @@ -0,0 +1,5 @@ +cursed-mask-examine-Neutral = It depicts an entirely unremarkable visage. +cursed-mask-examine-Joy = It depicts a face basking in joy. +cursed-mask-examine-Despair = It depicts a face wraught with despair. +cursed-mask-examine-Anger = It depicts a furious expression locked in rage. +cursed-mask-takeover-popup = The mask seizes control over your body! diff --git a/Resources/Prototypes/Entities/Clothing/Masks/specific.yml b/Resources/Prototypes/Entities/Clothing/Masks/specific.yml index c3a07fa8e9..64a1adcebd 100644 --- a/Resources/Prototypes/Entities/Clothing/Masks/specific.yml +++ b/Resources/Prototypes/Entities/Clothing/Masks/specific.yml @@ -62,3 +62,41 @@ - type: HideLayerClothing slots: - Snout + +- type: entity + parent: ClothingMaskBase + id: ClothingMaskGoldenCursed + name: golden mask + description: Previously used in strange pantomimes, after one of the actors went mad on stage these masks have avoided use. You swear its face contorts when you're not looking. + components: + - type: Sprite + sprite: Clothing/Mask/goldenmask.rsi + layers: + - state: icon + map: [ "mask" ] + - type: Clothing + sprite: Clothing/Mask/goldenmask.rsi + - type: Appearance + - type: GenericVisualizer + visuals: + enum.CursedMaskVisuals.State: + mask: + Neutral: { state: icon } + Despair: { state: icon-despair } + Joy: { state: icon-joy } + Anger: { state: icon-anger } + - type: Tag + tags: [] # ignore "WhitelistChameleon" tag + - type: SelfEquipOnly + - type: CursedMask + despairDamageModifier: + coefficients: + Blunt: 0.6 + Slash: 0.6 + Piercing: 0.4 + - type: HideLayerClothing + slots: + - Snout + - type: IngestionBlocker + - type: StaticPrice + price: 5000 diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png new file mode 100644 index 0000000000..1b4db3d093 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK-vox.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png new file mode 100644 index 0000000000..d1353c4980 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/equipped-MASK.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png new file mode 100644 index 0000000000..5e002cadcf Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-anger.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png new file mode 100644 index 0000000000..71bdd72fc6 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-despair.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png new file mode 100644 index 0000000000..93d0e26ddd Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon-joy.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png new file mode 100644 index 0000000000..1da86e3c82 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/icon.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png new file mode 100644 index 0000000000..3ce2895a39 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-left.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png new file mode 100644 index 0000000000..ba71330276 Binary files /dev/null and b/Resources/Textures/Clothing/Mask/goldenmask.rsi/inhand-right.png differ diff --git a/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json b/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json new file mode 100644 index 0000000000..62072e7107 --- /dev/null +++ b/Resources/Textures/Clothing/Mask/goldenmask.rsi/meta.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "license": "CC-BY-SA-3.0", + "copyright": "Taken from tgstation at commit https://github.com/vgstation-coders/vgstation13/blob/HEAD/icons/obj/clothing/masks.dmi. Vox and Reptilian edits by EmoGarbage404 (Github)", + "size": { + "x": 32, + "y": 32 + }, + "states": [ + { + "name": "icon" + }, + { + "name": "icon-joy" + }, + { + "name": "icon-despair" + }, + { + "name": "icon-anger" + }, + { + "name": "equipped-MASK", + "directions": 4 + }, + { + "name": "equipped-MASK-vox", + "directions": 4 + }, + { + "name": "inhand-left", + "directions": 4 + }, + { + "name": "inhand-right", + "directions": 4 + } + ] +}