changeling devour cleanup (#43451)

* devour cleanup

* fix audio cancel

* fix for transform as well
This commit is contained in:
slarticodefast 2026-04-04 02:29:43 +02:00 • committed by GitHub
parent 4fb78b777c
commit 85b4a5d0f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 75 additions and 91 deletions

View file

@ -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
/// </summary>
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
[RegisterComponent, NetworkedComponent, AutoGenerateComponentState]
[Access(typeof(ChangelingDevourSystem))]
public sealed partial class ChangelingDevourComponent : Component
{
/// <summary>
/// The Action for devouring
/// The action for devouring.
/// </summary>
[DataField]
public EntProtoId? ChangelingDevourAction = "ActionChangelingDevour";
/// <summary>
/// The action entity associated with devouring
/// The action entity associated with devouring.
/// </summary>
[DataField, AutoNetworkedField]
public EntityUid? ChangelingDevourActionEntity;
/// <summary>
/// The whitelist of targets for devouring
/// The whitelist of targets for devouring.
/// </summary>
[DataField, AutoNetworkedField]
public EntityWhitelist? Whitelist = new()
@ -44,7 +42,7 @@ public sealed partial class ChangelingDevourComponent : Component
};
/// <summary>
/// The Sound to use during consumption of a victim
/// The sound to use during consumption of a victim.
/// </summary>
/// <remarks>
/// 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));
/// <summary>
/// The Sound to use during the windup before consuming a victim
/// The sound to use during the windup before consuming a victim.
/// </summary>
/// <remarks>
/// 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));
/// <summary>
/// The time between damage ticks
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan DamageTimeBetweenTicks = TimeSpan.FromSeconds(1);
/// <summary>
/// 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.
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan DevourWindupTime = TimeSpan.FromSeconds(2);
/// <summary>
/// The time it takes to FULLY consume someones identity.
/// The time it takes to consume someones identity.
/// Starts after the windup.
/// </summary>
[DataField, AutoNetworkedField]
public TimeSpan DevourConsumeTime = TimeSpan.FromSeconds(10);
/// <summary>
/// The Currently active devour sound in the world
/// The currently active devour sound in the world.
/// </summary>
[DataField]
public EntityUid? CurrentDevourSound;
/// <summary>
/// The damage profile for a single tick of devour damage
/// The damage dealt after the windup finished and devouring started.
/// </summary>
[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
};
/// <summary>
/// The list of protective damage types capable of preventing a devour if over the threshold
/// The damage dealt after the devouring is fully finished.
/// </summary>
[DataField, AutoNetworkedField]
public DamageSpecifier DevourDamage = new()
{
DamageDict = new()
{
{ "Slash", 20},
{ "Piercing", 20 },
{ "Blunt", 10 },
},
};
/// <summary>
/// The list of protective damage types capable of preventing a devour if over the threshold.
/// </summary>
[DataField, AutoNetworkedField]
public List<ProtoId<DamageTypePrototype>> ProtectiveDamageTypes = new()
@ -113,13 +120,7 @@ public sealed partial class ChangelingDevourComponent : Component
};
/// <summary>
/// The next Tick to deal damage on (utilized during the consumption "do-during" (a do after with an attempt event))
/// </summary>
[DataField(customTypeSerializer: typeof(TimeOffsetSerializer)), AutoNetworkedField, AutoPausedField]
public TimeSpan NextTick = TimeSpan.Zero;
/// <summary>
/// The percentage of ANY brute damage resistance that will prevent devouring
/// The percentage of ANY brute damage resistance that will prevent devouring.
/// </summary>
[DataField, AutoNetworkedField]
public float DevourPreventionPercentageThreshold = 0.1f;

View file

@ -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<ChangelingDevourComponent, ChangelingDevourActionEvent>(OnDevourAction);
SubscribeLocalEvent<ChangelingDevourComponent, ChangelingDevourWindupDoAfterEvent>(OnDevourWindup);
SubscribeLocalEvent<ChangelingDevourComponent, ChangelingDevourConsumeDoAfterEvent>(OnDevourConsume);
SubscribeLocalEvent<ChangelingDevourComponent, DoAfterAttemptEvent<ChangelingDevourConsumeDoAfterEvent>>(OnConsumeAttemptTick);
SubscribeLocalEvent<ChangelingDevourComponent, ComponentShutdown>(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<ChangelingDevourComponent> ent,
ref DoAfterAttemptEvent<ChangelingDevourConsumeDoAfterEvent> 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);
}
/// <summary>
/// Checkes if the targets outerclothing is beyond a DamageCoefficientThreshold to protect them from being devoured.
/// </summary>
@ -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<ChangelingDevourComponent> ent, ref ChangelingDevourActionEvent args)
{
if (args.Handled || _whitelistSystem.IsWhitelistFailOrNull(ent.Comp.Whitelist, args.Target)
|| !HasComp<ChangelingIdentityComponent>(ent))
if (args.Handled
|| _whitelistSystem.IsWhitelistFailOrNull(ent.Comp.Whitelist, args.Target)
|| !HasComp<ChangelingIdentityComponent>(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<RottingComponent>(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<ChangelingDevourComponent> 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<ChangelingDevourComponent> 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<BodyComponent>(target, out var body)
if (_mobState.IsDead(target)
&& HasComp<HumanoidProfileComponent>(target)
&& TryComp<ChangelingIdentityComponent>(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<ButcherableComponent>(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<ButcherableComponent> item)
{
var spawnEntities = EntitySpawnCollection.GetSpawns(item.Comp.SpawnedEntities, _robustRandom);

View file

@ -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<ChangelingStoredIdentityComponent>(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;

View file

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