Cursed Mask (#29659)

* Cursed Mask

* extra expressions

* block ingestion

* mind returning

* okay fix the removal shit
This commit is contained in:
Nemanja
2024-08-10 11:14:58 -04:00
committed by GitHub
parent 53058df8b9
commit fc1446e73a
20 changed files with 412 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
using Content.Shared.Clothing;
namespace Content.Client.Clothing.Systems;
/// <inheritdoc/>
public sealed class CursedMaskSystem : SharedCursedMaskSystem;

View File

@@ -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;
/// <inheritdoc/>
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<HTNCompoundPrototype> TakeoverRootTask = "SimpleHostileCompound";
protected override void TryTakeover(Entity<CursedMaskComponent> ent, EntityUid wearer)
{
if (ent.Comp.CurrentState != CursedMaskExpression.Anger)
return;
if (TryComp<ActorComponent>(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<NpcFactionMemberComponent>(wearer);
ent.Comp.OldFactions = npcFaction.Factions;
_npcFaction.ClearFactions((wearer, npcFaction), false);
_npcFaction.AddFaction((wearer, npcFaction), ent.Comp.CursedMaskFaction);
ent.Comp.HasNpc = !EnsureComp<HTNComponent>(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<CursedMaskComponent> ent, ref ClothingGotUnequippedEvent args)
{
// If we are taking off the cursed mask
if (ent.Comp.CurrentState == CursedMaskExpression.Anger)
{
if (ent.Comp.HasNpc)
RemComp<HTNComponent>(args.Wearer);
var npcFaction = EnsureComp<NpcFactionMemberComponent>(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);
}
}

View File

@@ -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;
/// <summary>
/// This is used for a mask that takes over the host when worn.
/// </summary>
[RegisterComponent, NetworkedComponent, Access(typeof(SharedCursedMaskSystem))]
public sealed partial class CursedMaskComponent : Component
{
/// <summary>
/// The current expression shown. Used to determine which effect is applied.
/// </summary>
[DataField]
public CursedMaskExpression CurrentState = CursedMaskExpression.Neutral;
/// <summary>
/// Speed modifier applied when the "Joy" expression is present.
/// </summary>
[DataField]
public float JoySpeedModifier = 1.15f;
/// <summary>
/// Damage modifier applied when the "Despair" expression is present.
/// </summary>
[DataField]
public DamageModifierSet DespairDamageModifier = new();
/// <summary>
/// Whether or not the mask is currently attached to an NPC.
/// </summary>
[DataField]
public bool HasNpc;
/// <summary>
/// The mind that was booted from the wearer when the mask took over.
/// </summary>
[DataField]
public EntityUid? StolenMind;
[DataField]
public ProtoId<NpcFactionPrototype> CursedMaskFaction = "SimpleHostile";
[DataField]
public HashSet<ProtoId<NpcFactionPrototype>> OldFactions = new();
}
[Serializable, NetSerializable]
public enum CursedMaskVisuals : byte
{
State
}
[Serializable, NetSerializable]
public enum CursedMaskExpression : byte
{
Neutral,
Joy,
Despair,
Anger
}

View File

@@ -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;
/// <summary>
/// This handles <see cref="CursedMaskComponent"/>
/// </summary>
public abstract class SharedCursedMaskSystem : EntitySystem
{
[Dependency] private readonly IGameTiming _timing = default!;
[Dependency] private readonly SharedAppearanceSystem _appearance = default!;
[Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!;
/// <inheritdoc/>
public override void Initialize()
{
base.Initialize();
SubscribeLocalEvent<CursedMaskComponent, ClothingGotEquippedEvent>(OnClothingEquip);
SubscribeLocalEvent<CursedMaskComponent, ClothingGotUnequippedEvent>(OnClothingUnequip);
SubscribeLocalEvent<CursedMaskComponent, ExaminedEvent>(OnExamine);
SubscribeLocalEvent<CursedMaskComponent, InventoryRelayedEvent<RefreshMovementSpeedModifiersEvent>>(OnMovementSpeedModifier);
SubscribeLocalEvent<CursedMaskComponent, InventoryRelayedEvent<DamageModifyEvent>>(OnModifyDamage);
}
private void OnClothingEquip(Entity<CursedMaskComponent> ent, ref ClothingGotEquippedEvent args)
{
RandomizeCursedMask(ent, args.Wearer);
TryTakeover(ent, args.Wearer);
}
protected virtual void OnClothingUnequip(Entity<CursedMaskComponent> ent, ref ClothingGotUnequippedEvent args)
{
RandomizeCursedMask(ent, args.Wearer);
}
private void OnExamine(Entity<CursedMaskComponent> ent, ref ExaminedEvent args)
{
args.PushMarkup(Loc.GetString($"cursed-mask-examine-{ent.Comp.CurrentState.ToString()}"));
}
private void OnMovementSpeedModifier(Entity<CursedMaskComponent> ent, ref InventoryRelayedEvent<RefreshMovementSpeedModifiersEvent> args)
{
if (ent.Comp.CurrentState == CursedMaskExpression.Joy)
args.Args.ModifySpeed(ent.Comp.JoySpeedModifier);
}
private void OnModifyDamage(Entity<CursedMaskComponent> ent, ref InventoryRelayedEvent<DamageModifyEvent> args)
{
if (ent.Comp.CurrentState == CursedMaskExpression.Despair)
args.Args.Damage = DamageSpecifier.ApplyModifierSet(args.Args.Damage, ent.Comp.DespairDamageModifier);
}
protected void RandomizeCursedMask(Entity<CursedMaskComponent> ent, EntityUid wearer)
{
var random = new System.Random((int) _timing.CurTick.Value);
ent.Comp.CurrentState = random.Pick(Enum.GetValues<CursedMaskExpression>());
_appearance.SetData(ent, CursedMaskVisuals.State, ent.Comp.CurrentState);
_movementSpeedModifier.RefreshMovementSpeedModifiers(wearer);
}
protected virtual void TryTakeover(Entity<CursedMaskComponent> ent, EntityUid wearer)
{
}
}

View File

@@ -17,6 +17,11 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs
/// </summary>
public readonly EntityUid Equipment;
/// <summary>
/// The slotFlags of the slot this item is being removed from.
/// </summary>
public readonly SlotFlags SlotFlags;
/// <summary>
/// The slot the entity is being unequipped from.
/// </summary>
@@ -33,6 +38,7 @@ public abstract class UnequipAttemptEventBase : CancellableEntityEventArgs
UnEquipTarget = unEquipTarget;
Equipment = equipment;
Unequipee = unequipee;
SlotFlags = slotDefinition.SlotFlags;
Slot = slotDefinition.Name;
}
}

View File

@@ -0,0 +1,16 @@
using Robust.Shared.GameStates;
namespace Content.Shared.Inventory;
/// <summary>
/// This is used for an item that can only be equipped/unequipped by the user.
/// </summary>
[RegisterComponent, NetworkedComponent, Access(typeof(SelfEquipOnlySystem))]
public sealed partial class SelfEquipOnlyComponent : Component
{
/// <summary>
/// Whether or not the self-equip only condition requires the person to be conscious.
/// </summary>
[DataField]
public bool UnequipRequireConscious = true;
}

View File

@@ -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!;
/// <inheritdoc/>
public override void Initialize()
{
SubscribeLocalEvent<SelfEquipOnlyComponent, BeingEquippedAttemptEvent>(OnBeingEquipped);
SubscribeLocalEvent<SelfEquipOnlyComponent, BeingUnequippedAttemptEvent>(OnBeingUnequipped);
}
private void OnBeingEquipped(Entity<SelfEquipOnlyComponent> ent, ref BeingEquippedAttemptEvent args)
{
if (args.Cancelled)
return;
if (TryComp<ClothingComponent>(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE)
return;
if (args.Equipee != args.EquipTarget)
args.Cancel();
}
private void OnBeingUnequipped(Entity<SelfEquipOnlyComponent> ent, ref BeingUnequippedAttemptEvent args)
{
if (args.Cancelled)
return;
if (args.Unequipee == args.UnEquipTarget)
return;
if (TryComp<ClothingComponent>(ent, out var clothing) && (clothing.Slots & args.SlotFlags) == SlotFlags.NONE)
return;
if (ent.Comp.UnequipRequireConscious && !_actionBlocker.CanConsciouslyPerformAction(args.UnEquipTarget))
return;
args.Cancel();
}
}

View File

@@ -69,5 +69,10 @@ namespace Content.Shared.Movement.Systems
WalkSpeedModifier *= walk;
SprintSpeedModifier *= sprint;
}
public void ModifySpeed(float mod)
{
ModifySpeed(mod, mod);
}
}
}

View File

@@ -100,6 +100,28 @@ public sealed partial class NpcFactionSystem : EntitySystem
RefreshFactions((ent, ent.Comp));
}
/// <summary>
/// Adds this entity to the particular faction.
/// </summary>
public void AddFactions(Entity<NpcFactionMemberComponent?> ent, HashSet<ProtoId<NpcFactionPrototype>> factions, bool dirty = true)
{
ent.Comp ??= EnsureComp<NpcFactionMemberComponent>(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));
}
/// <summary>
/// Removes this entity from the particular faction.
/// </summary>

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

View File

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