diff --git a/Content.Shared/Changeling/Components/ChangelingDevourComponent.cs b/Content.Shared/Changeling/Components/ChangelingDevourComponent.cs
index f2c5c82ca9..f0a7bb44ba 100644
--- a/Content.Shared/Changeling/Components/ChangelingDevourComponent.cs
+++ b/Content.Shared/Changeling/Components/ChangelingDevourComponent.cs
@@ -1,12 +1,10 @@
using Content.Shared.Changeling.Systems;
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.Components;
@@ -14,24 +12,24 @@ namespace Content.Shared.Changeling.Components;
/// Component responsible for Changelings Devour attack. Including the amount of damage
/// and how long it takes to devour someone
///
-[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(ChangelingDevourSystem))]
public sealed partial class ChangelingDevourComponent : Component
{
///
- /// The Action for devouring
+ /// The action for devouring.
///
[DataField]
public EntProtoId? ChangelingDevourAction = "ActionChangelingDevour";
///
- /// The action entity associated with devouring
+ /// The action entity associated with devouring.
///
[DataField, AutoNetworkedField]
public EntityUid? ChangelingDevourActionEntity;
///
- /// The whitelist of targets for devouring
+ /// The whitelist of targets for devouring.
///
[DataField, AutoNetworkedField]
public EntityWhitelist? Whitelist = new()
@@ -44,7 +42,7 @@ public sealed partial class ChangelingDevourComponent : Component
};
///
- /// The Sound to use during consumption of a victim
+ /// 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.
@@ -54,7 +52,7 @@ public sealed partial class ChangelingDevourComponent : Component
public SoundSpecifier? ConsumeNoise = new SoundCollectionSpecifier("ChangelingDevourConsume", AudioParams.Default.WithMaxDistance(6));
///
- /// The Sound to use during the windup before consuming a victim
+ /// 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.
@@ -64,36 +62,31 @@ public sealed partial class ChangelingDevourComponent : Component
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
+ /// 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.
+ /// The time it takes to consume someones identity.
+ /// Starts after the windup.
///
[DataField, AutoNetworkedField]
public TimeSpan DevourConsumeTime = TimeSpan.FromSeconds(10);
///
- /// The Currently active devour sound in the world
+ /// The currently active devour sound in the world.
///
[DataField]
public EntityUid? CurrentDevourSound;
///
- /// The damage profile for a single tick of devour damage
+ /// The damage dealt after the windup finished and devouring started.
///
[DataField, AutoNetworkedField]
- public DamageSpecifier DamagePerTick = new()
+ public DamageSpecifier WindupDamage = new()
{
- DamageDict = new ()
+ DamageDict = new()
{
{ "Slash", 10},
{ "Piercing", 10 },
@@ -102,7 +95,21 @@ public sealed partial class ChangelingDevourComponent : Component
};
///
- /// The list of protective damage types capable of preventing a devour if over the threshold
+ /// The damage dealt after the devouring is fully finished.
+ ///
+ [DataField, AutoNetworkedField]
+ public DamageSpecifier DevourDamage = new()
+ {
+ DamageDict = new()
+ {
+ { "Slash", 20},
+ { "Piercing", 20 },
+ { "Blunt", 10 },
+ },
+ };
+
+ ///
+ /// The list of protective damage types capable of preventing a devour if over the threshold.
///
[DataField, AutoNetworkedField]
public List> ProtectiveDamageTypes = new()
@@ -113,13 +120,7 @@ public sealed partial class ChangelingDevourComponent : Component
};
///
- /// 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
+ /// The percentage of ANY brute damage resistance that will prevent devouring.
///
[DataField, AutoNetworkedField]
public float DevourPreventionPercentageThreshold = 0.1f;
diff --git a/Content.Shared/Changeling/Systems/ChangelingDevourSystem.cs b/Content.Shared/Changeling/Systems/ChangelingDevourSystem.cs
index 3406038e9c..d08e6315ce 100644
--- a/Content.Shared/Changeling/Systems/ChangelingDevourSystem.cs
+++ b/Content.Shared/Changeling/Systems/ChangelingDevourSystem.cs
@@ -2,9 +2,7 @@ using Content.Shared.Actions;
using Content.Shared.Administration.Logs;
using Content.Shared.Armor;
using Content.Shared.Atmos.Rotting;
-using Content.Shared.Body;
using Content.Shared.Changeling.Components;
-using Content.Shared.Damage.Components;
using Content.Shared.Damage.Systems;
using Content.Shared.Database;
using Content.Shared.DoAfter;
@@ -19,13 +17,11 @@ 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.Systems;
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!;
@@ -47,7 +43,6 @@ public sealed class ChangelingDevourSystem : EntitySystem
SubscribeLocalEvent(OnDevourAction);
SubscribeLocalEvent(OnDevourWindup);
SubscribeLocalEvent(OnDevourConsume);
- SubscribeLocalEvent>(OnConsumeAttemptTick);
SubscribeLocalEvent(OnShutdown);
}
@@ -64,29 +59,6 @@ public sealed class ChangelingDevourSystem : EntitySystem
}
}
- //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;
-
- _damageable.ChangeDamage(target.Value, comp.DamagePerTick, true, true, user);
- }
-
///
/// Checkes if the targets outerclothing is beyond a DamageCoefficientThreshold to protect them from being devoured.
///
@@ -110,10 +82,13 @@ public sealed class ChangelingDevourSystem : EntitySystem
return false;
}
+ // The action was used.
+ // Start the first doafter for the windup.
private void OnDevourAction(Entity ent, ref ChangelingDevourActionEvent args)
{
- if (args.Handled || _whitelistSystem.IsWhitelistFailOrNull(ent.Comp.Whitelist, args.Target)
- || !HasComp(ent))
+ if (args.Handled
+ || _whitelistSystem.IsWhitelistFailOrNull(ent.Comp.Whitelist, args.Target)
+ || !HasComp(ent))
return;
args.Handled = true;
@@ -122,6 +97,12 @@ public sealed class ChangelingDevourSystem : EntitySystem
if (target == ent.Owner)
return; // don't eat yourself
+ if (!_mobState.IsDead(target))
+ {
+ _popupSystem.PopupClient(Loc.GetString("changeling-devour-attempt-failed-not-dead"), args.Performer, args.Performer, PopupType.Medium);
+ return;
+ }
+
if (HasComp(target))
{
_popupSystem.PopupClient(Loc.GetString("changeling-devour-attempt-failed-rotting"), args.Performer, args.Performer, PopupType.Medium);
@@ -136,6 +117,7 @@ public sealed class ChangelingDevourSystem : EntitySystem
if (_net.IsServer)
{
+ ent.Comp.CurrentDevourSound = _audio.Stop(ent.Comp.CurrentDevourSound);
var pvsSound = _audio.PlayPvs(ent.Comp.DevourWindupNoise, ent);
if (pvsSound != null)
ent.Comp.CurrentDevourSound = pvsSound.Value.Entity;
@@ -146,7 +128,7 @@ public sealed class ChangelingDevourSystem : EntitySystem
_doAfterSystem.TryStartDoAfter(new DoAfterArgs(EntityManager, ent, ent.Comp.DevourWindupTime, new ChangelingDevourWindupDoAfterEvent(), ent, target: target, used: ent)
{
BreakOnMove = true,
- BlockDuplicate = true,
+ CancelDuplicate = true,
DuplicateCondition = DuplicateConditions.None,
});
@@ -160,17 +142,21 @@ public sealed class ChangelingDevourSystem : EntitySystem
PopupType.MediumCaution);
}
+ // First doafter finished.
+ // Start the second doafter for the actual consumption and deal a small amount of damage.
private void OnDevourWindup(Entity ent, ref ChangelingDevourWindupDoAfterEvent args)
{
- var curTime = _timing.CurTime;
args.Handled = true;
-
- if (!Exists(ent.Comp.CurrentDevourSound))
- _audio.Stop(ent.Comp.CurrentDevourSound!);
+ ent.Comp.CurrentDevourSound = _audio.Stop(ent.Comp.CurrentDevourSound);
if (args.Cancelled)
return;
+ if (args.Target is not { } target)
+ return;
+
+ _damageable.ChangeDamage(target, ent.Comp.WindupDamage, true, true, ent.Owner);
+
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(
@@ -188,44 +174,41 @@ public sealed class ChangelingDevourSystem : EntitySystem
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");
+ _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} began to devour {ToPrettyString(target):player}'s identity");
_doAfterSystem.TryStartDoAfter(new DoAfterArgs(EntityManager,
ent,
ent.Comp.DevourConsumeTime,
new ChangelingDevourConsumeDoAfterEvent(),
ent,
- target: args.Target,
+ target: target,
used: ent)
{
- AttemptFrequency = AttemptFrequency.EveryTick,
BreakOnMove = true,
- BlockDuplicate = true,
+ CancelDuplicate = true,
DuplicateCondition = DuplicateConditions.None,
});
}
+ // Second doafter finished.
+ // Save the identity and deal more damage.
private void OnDevourConsume(Entity ent, ref ChangelingDevourConsumeDoAfterEvent args)
{
args.Handled = true;
- var target = args.Target;
-
- if (target == null)
- return;
-
- if (Exists(ent.Comp.CurrentDevourSound))
- _audio.Stop(ent.Comp.CurrentDevourSound!);
+ ent.Comp.CurrentDevourSound = _audio.Stop(ent.Comp.CurrentDevourSound);
if (args.Cancelled)
return;
- if (!_mobState.IsDead((EntityUid)target))
+ if (args.Target is not { } target)
+ return;
+
+ _damageable.ChangeDamage(target, ent.Comp.DevourDamage, true, true, ent.Owner);
+
+ if (!_mobState.IsDead(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);
+ _popupSystem.PopupClient(Loc.GetString("changeling-devour-attempt-failed-not-dead"), args.User, args.User, PopupType.Medium);
+ _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} unsuccessfully devoured {ToPrettyString(args.Target):player}'s identity");
return;
}
@@ -238,22 +221,22 @@ public sealed class ChangelingDevourSystem : EntitySystem
args.User,
PopupType.LargeCaution);
- if (_mobState.IsDead(target.Value)
- && TryComp(target, out var body)
+ if (_mobState.IsDead(target)
&& 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);
+ _adminLogger.Add(LogType.Action, LogImpact.Medium, $"{ToPrettyString(ent.Owner):player} successfully devoured {ToPrettyString(target):player}'s identity");
+ _changelingIdentitySystem.CloneToPausedMap((ent, identityStorage), target);
- if (_inventorySystem.TryGetSlotEntity(target.Value, "jumpsuit", out var item)
+ if (_inventorySystem.TryGetSlotEntity(target, "jumpsuit", out var item)
&& TryComp(item, out var butcherable))
- RipClothing(target.Value, (item.Value, butcherable));
+ RipClothing(target, (item.Value, butcherable));
}
Dirty(ent);
}
+ // TODO: This should just be an API method in the butcher system
private void RipClothing(EntityUid victim, Entity item)
{
var spawnEntities = EntitySpawnCollection.GetSpawns(item.Comp.SpawnedEntities, _robustRandom);
diff --git a/Content.Shared/Changeling/Systems/ChangelingTransformSystem.cs b/Content.Shared/Changeling/Systems/ChangelingTransformSystem.cs
index 5f9cc9526b..94d2cd77a8 100644
--- a/Content.Shared/Changeling/Systems/ChangelingTransformSystem.cs
+++ b/Content.Shared/Changeling/Systems/ChangelingTransformSystem.cs
@@ -99,7 +99,10 @@ public sealed partial class ChangelingTransformSystem : EntitySystem
PopupType.MediumCaution);
if (_net.IsServer)
+ {
+ ent.Comp.CurrentTransformSound = _audio.Stop(ent.Comp.CurrentTransformSound); // cancel any previous sounds first
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}) ");
@@ -146,9 +149,7 @@ public sealed partial class ChangelingTransformSystem : EntitySystem
ref ChangelingTransformDoAfterEvent args)
{
args.Handled = true;
-
- if (Exists(ent.Comp.CurrentTransformSound))
- _audio.Stop(ent.Comp.CurrentTransformSound);
+ ent.Comp.CurrentTransformSound = _audio.Stop(ent.Comp.CurrentTransformSound);
if (args.Cancelled)
return;
diff --git a/Resources/Locale/en-US/changeling/changeling.ftl b/Resources/Locale/en-US/changeling/changeling.ftl
index 57ad3550bf..e33e5bc715 100644
--- a/Resources/Locale/en-US/changeling/changeling.ftl
+++ b/Resources/Locale/en-US/changeling/changeling.ftl
@@ -1,6 +1,7 @@
roles-antag-changeling-name = Changeling
roles-antag-changeling-objective = A intelligent predator that assumes the identities of its victims.
+changeling-devour-attempt-failed-not-dead = This body yet lives! We cannot consume it alive!
changeling-devour-attempt-failed-rotting = This corpse has only rotted biomass.
changeling-devour-attempt-failed-protected = This victim's biomass is protected by armor!
@@ -8,8 +9,6 @@ changeling-devour-begin-windup-self = Our uncanny mouth reveals itself with othe
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.