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.