Files
wylab-station-14/Content.Shared/Damage/Systems/SharedStaminaSystem.cs
wylab 0a5b85f717 feat: add RemoveStaminaDamage method for vampire healing
Port RemoveStaminaDamage from wega fork to enable proper stamina
reset in vampire abilities. Enables vampires and thralls to have
their stamina damage cleared during healing abilities.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-23 05:45:10 +01:00

479 lines
17 KiB
C#

using System.Linq;
using Content.Shared.Administration.Logs;
using Content.Shared.Alert;
using Content.Shared.CCVar;
using Content.Shared.CombatMode;
using Content.Shared.Damage.Components;
using Content.Shared.Damage.Events;
using Content.Shared.Database;
using Content.Shared.Effects;
using Content.Shared.FixedPoint;
using Content.Shared.Movement.Components;
using Content.Shared.Movement.Systems;
using Content.Shared.Projectiles;
using Content.Shared.Rejuvenate;
using Content.Shared.Rounding;
using Content.Shared.StatusEffectNew;
using Content.Shared.Stunnable;
using Content.Shared.Throwing;
using Content.Shared.Weapons.Melee.Events;
using JetBrains.Annotations;
using Robust.Shared.Audio;
using Robust.Shared.Audio.Systems;
using Robust.Shared.Configuration;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
namespace Content.Shared.Damage.Systems;
public abstract partial class SharedStaminaSystem : EntitySystem
{
public static readonly EntProtoId StaminaLow = "StatusEffectStaminaLow";
[Dependency] private readonly IConfigurationManager _config = default!;
[Dependency] protected readonly IGameTiming Timing = default!;
[Dependency] private readonly INetManager _net = default!;
[Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
[Dependency] private readonly AlertsSystem _alerts = default!;
[Dependency] private readonly MetaDataSystem _metadata = default!;
[Dependency] private readonly MovementModStatusSystem _movementMod = default!;
[Dependency] private readonly SharedAudioSystem _audio = default!;
[Dependency] private readonly SharedColorFlashEffectSystem _color = default!;
[Dependency] private readonly StatusEffectsSystem _status = default!;
[Dependency] protected readonly SharedStunSystem StunSystem = default!;
/// <summary>
/// How much of a buffer is there between the stun duration and when stuns can be re-applied.
/// </summary>
protected static readonly TimeSpan StamCritBufferTime = TimeSpan.FromSeconds(3f);
public float UniversalStaminaDamageModifier { get; private set; } = 1f;
public override void Initialize()
{
base.Initialize();
InitializeModifier();
InitializeResistance();
SubscribeLocalEvent<StaminaComponent, ComponentStartup>(OnStartup);
SubscribeLocalEvent<StaminaComponent, ComponentShutdown>(OnShutdown);
SubscribeLocalEvent<StaminaComponent, AfterAutoHandleStateEvent>(OnStamHandleState);
SubscribeLocalEvent<StaminaComponent, DisarmedEvent>(OnDisarmed);
SubscribeLocalEvent<StaminaComponent, RejuvenateEvent>(OnRejuvenate);
SubscribeLocalEvent<StaminaDamageOnEmbedComponent, EmbedEvent>(OnProjectileEmbed);
SubscribeLocalEvent<StaminaDamageOnCollideComponent, ProjectileHitEvent>(OnProjectileHit);
SubscribeLocalEvent<StaminaDamageOnCollideComponent, ThrowDoHitEvent>(OnThrowHit);
SubscribeLocalEvent<StaminaDamageOnHitComponent, MeleeHitEvent>(OnMeleeHit);
Subs.CVar(_config, CCVars.PlaytestStaminaDamageModifier, value => UniversalStaminaDamageModifier = value, true);
}
protected virtual void OnStamHandleState(Entity<StaminaComponent> entity, ref AfterAutoHandleStateEvent args)
{
if (entity.Comp.Critical)
EnterStamCrit(entity);
else
{
if (entity.Comp.StaminaDamage > 0f)
EnsureComp<ActiveStaminaComponent>(entity);
ExitStamCrit(entity);
}
}
protected virtual void OnShutdown(Entity<StaminaComponent> entity, ref ComponentShutdown args)
{
if (MetaData(entity).EntityLifeStage < EntityLifeStage.Terminating)
{
RemCompDeferred<ActiveStaminaComponent>(entity);
}
_alerts.ClearAlert(entity.Owner, entity.Comp.StaminaAlert);
}
private void OnStartup(Entity<StaminaComponent> entity, ref ComponentStartup args)
{
// Set the base threshold here since ModifiedCritThreshold can't be modified via yaml.
entity.Comp.CritThreshold = entity.Comp.BaseCritThreshold;
UpdateStaminaVisuals(entity);
}
[PublicAPI]
public float GetStaminaDamage(EntityUid uid, StaminaComponent? component = null)
{
if (!Resolve(uid, ref component))
return 0f;
var curTime = Timing.CurTime;
var pauseTime = _metadata.GetPauseTime(uid);
return MathF.Max(0f, component.StaminaDamage - MathF.Max(0f, (float) (curTime - (component.NextUpdate + pauseTime)).TotalSeconds * component.Decay));
}
private void OnRejuvenate(Entity<StaminaComponent> entity, ref RejuvenateEvent args)
{
if (entity.Comp.StaminaDamage >= entity.Comp.CritThreshold)
{
ExitStamCrit(entity, entity.Comp);
}
entity.Comp.StaminaDamage = 0;
AdjustStatus(entity.Owner);
RemComp<ActiveStaminaComponent>(entity);
_status.TryRemoveStatusEffect(entity, StaminaLow);
UpdateStaminaVisuals(entity);
Dirty(entity);
}
private void OnDisarmed(EntityUid uid, StaminaComponent component, ref DisarmedEvent args)
{
if (args.Handled)
return;
if (component.Critical)
return;
var damage = args.PushProbability * component.CritThreshold;
TakeStaminaDamage(uid, damage, component, source: args.Source);
args.PopupPrefix = "disarm-action-shove-";
args.IsStunned = component.Critical;
args.Handled = true;
}
private void OnMeleeHit(EntityUid uid, StaminaDamageOnHitComponent component, MeleeHitEvent args)
{
if (!args.IsHit ||
!args.HitEntities.Any() ||
component.Damage <= 0f)
{
return;
}
var ev = new StaminaDamageOnHitAttemptEvent();
RaiseLocalEvent(uid, ref ev);
if (ev.Cancelled)
return;
var stamQuery = GetEntityQuery<StaminaComponent>();
var toHit = new List<(EntityUid Entity, StaminaComponent Component)>();
// Split stamina damage between all eligible targets.
foreach (var ent in args.HitEntities)
{
if (!stamQuery.TryGetComponent(ent, out var stam))
continue;
toHit.Add((ent, stam));
}
var hitEvent = new StaminaMeleeHitEvent(toHit);
RaiseLocalEvent(uid, hitEvent);
if (hitEvent.Handled)
return;
var damage = component.Damage;
damage *= hitEvent.Multiplier;
damage += hitEvent.FlatModifier;
foreach (var (ent, comp) in toHit)
{
TakeStaminaDamage(ent, damage / toHit.Count, comp, source: args.User, with: args.Weapon, sound: component.Sound);
}
}
private void OnProjectileHit(EntityUid uid, StaminaDamageOnCollideComponent component, ref ProjectileHitEvent args)
{
OnCollide(uid, component, args.Target);
}
private void OnProjectileEmbed(EntityUid uid, StaminaDamageOnEmbedComponent component, ref EmbedEvent args)
{
if (!TryComp<StaminaComponent>(args.Embedded, out var stamina))
return;
TakeStaminaDamage(args.Embedded, component.Damage, stamina, source: uid);
}
private void OnThrowHit(EntityUid uid, StaminaDamageOnCollideComponent component, ThrowDoHitEvent args)
{
OnCollide(uid, component, args.Target);
}
private void OnCollide(EntityUid uid, StaminaDamageOnCollideComponent component, EntityUid target)
{
// you can't inflict stamina damage on things with no stamina component
// this prevents stun batons from using up charges when throwing it at lockers or lights
if (!HasComp<StaminaComponent>(target))
return;
var ev = new StaminaDamageOnHitAttemptEvent();
RaiseLocalEvent(uid, ref ev);
if (ev.Cancelled)
return;
TakeStaminaDamage(target, component.Damage, source: uid, sound: component.Sound);
}
private void UpdateStaminaVisuals(Entity<StaminaComponent> entity)
{
SetStaminaAlert(entity, entity.Comp);
SetStaminaAnimation(entity);
}
// Here so server can properly tell all clients in PVS range to start the animation
protected virtual void SetStaminaAnimation(Entity<StaminaComponent> entity){}
private void SetStaminaAlert(EntityUid uid, StaminaComponent? component = null)
{
if (!Resolve(uid, ref component, false) || component.Deleted)
return;
var severity = ContentHelpers.RoundToLevels(MathF.Max(0f, component.CritThreshold - component.StaminaDamage), component.CritThreshold, 7);
_alerts.ShowAlert(uid, component.StaminaAlert, (short) severity);
}
/// <summary>
/// Tries to take stamina damage without raising the entity over the crit threshold.
/// </summary>
public bool TryTakeStamina(EntityUid uid, float value, StaminaComponent? component = null, EntityUid? source = null, EntityUid? with = null, bool visual = false)
{
// Something that has no Stamina component automatically passes stamina checks
if (!Resolve(uid, ref component, false))
return true;
var oldStam = component.StaminaDamage;
if (oldStam + value >= component.CritThreshold || component.Critical)
return false;
TakeStaminaDamage(uid, value, component, source, with, visual: visual);
return true;
}
public void TakeStaminaDamage(EntityUid uid, float value, StaminaComponent? component = null,
EntityUid? source = null, EntityUid? with = null, bool visual = true, SoundSpecifier? sound = null, bool ignoreResist = false)
{
if (!Resolve(uid, ref component, false))
return;
var ev = new BeforeStaminaDamageEvent(value);
RaiseLocalEvent(uid, ref ev);
if (ev.Cancelled)
return;
// Allow stamina resistance to be applied.
if (!ignoreResist)
{
value = ev.Value;
}
value = UniversalStaminaDamageModifier * value;
// Have we already reached the point of max stamina damage?
if (component.Critical)
return;
var oldDamage = component.StaminaDamage;
component.StaminaDamage = MathF.Max(0f, component.StaminaDamage + value);
// Reset the decay cooldown upon taking damage.
if (oldDamage < component.StaminaDamage)
{
var nextUpdate = Timing.CurTime + TimeSpan.FromSeconds(component.Cooldown);
if (component.NextUpdate < nextUpdate)
component.NextUpdate = nextUpdate;
}
AdjustStatus(uid);
UpdateStaminaVisuals((uid, component));
// Checking if the stamina damage has decreased to zero after exiting the stamcrit
if (component.AfterCritical && oldDamage > component.StaminaDamage && component.StaminaDamage <= 0f)
{
component.AfterCritical = false; // Since the recovery from the crit has been completed, we are no longer 'after crit'
_status.TryRemoveStatusEffect(uid, StaminaLow);
}
if (!component.Critical)
{
if (component.StaminaDamage >= component.CritThreshold)
{
EnterStamCrit(uid, component);
}
}
else
{
if (component.StaminaDamage < component.CritThreshold)
{
ExitStamCrit(uid, component);
}
}
EnsureComp<ActiveStaminaComponent>(uid);
Dirty(uid, component);
if (value <= 0)
return;
if (source != null)
{
_adminLogger.Add(LogType.Stamina, $"{ToPrettyString(source.Value):user} caused {value} stamina damage to {ToPrettyString(uid):target}{(with != null ? $" using {ToPrettyString(with.Value):using}" : "")}");
}
else
{
_adminLogger.Add(LogType.Stamina, $"{ToPrettyString(uid):target} took {value} stamina damage");
}
if (visual)
{
_color.RaiseEffect(Color.Aqua, new List<EntityUid>() { uid }, Filter.Pvs(uid, entityManager: EntityManager));
}
if (_net.IsServer)
{
_audio.PlayPvs(sound, uid);
}
}
public override void Update(float frameTime)
{
base.Update(frameTime);
var stamQuery = GetEntityQuery<StaminaComponent>();
var query = EntityQueryEnumerator<ActiveStaminaComponent>();
var curTime = Timing.CurTime;
while (query.MoveNext(out var uid, out _))
{
// Just in case we have active but not stamina we'll check and account for it.
if (!stamQuery.TryGetComponent(uid, out var comp) ||
comp.StaminaDamage <= 0f && !comp.Critical)
{
RemComp<ActiveStaminaComponent>(uid);
continue;
}
// Shouldn't need to consider paused time as we're only iterating non-paused stamina components.
var nextUpdate = comp.NextUpdate;
if (nextUpdate > curTime)
continue;
// Handle exiting critical condition and restoring stamina damage
if (comp.Critical)
ExitStamCrit(uid, comp);
comp.NextUpdate += TimeSpan.FromSeconds(1f);
TakeStaminaDamage(
uid,
comp.AfterCritical ? -comp.Decay * comp.AfterCritDecayMultiplier : -comp.Decay, // Recover faster after crit
comp);
Dirty(uid, comp);
}
}
private void EnterStamCrit(EntityUid uid, StaminaComponent? component = null)
{
if (!Resolve(uid, ref component) ||
component.Critical)
{
return;
}
component.Critical = true;
component.StaminaDamage = component.CritThreshold;
if (StunSystem.TryUpdateParalyzeDuration(uid, component.StunTime))
StunSystem.TrySeeingStars(uid);
// Give them buffer before being able to be re-stunned
component.NextUpdate = Timing.CurTime + component.StunTime + StamCritBufferTime;
EnsureComp<ActiveStaminaComponent>(uid);
Dirty(uid, component);
_adminLogger.Add(LogType.Stamina, LogImpact.Medium, $"{ToPrettyString(uid):user} entered stamina crit");
}
private void ExitStamCrit(EntityUid uid, StaminaComponent? component = null)
{
if (!Resolve(uid, ref component) ||
!component.Critical)
{
return;
}
component.Critical = false;
component.AfterCritical = true; // Set to true to indicate that stamina will be restored after exiting stamcrit
component.NextUpdate = Timing.CurTime;
UpdateStaminaVisuals((uid, component));
Dirty(uid, component);
_adminLogger.Add(LogType.Stamina, LogImpact.Low, $"{ToPrettyString(uid):user} recovered from stamina crit");
}
/// <summary>
/// Adjusts the modifiers of the <see cref="StaminaLow"/> status effect entity and applies relevant statuses.
/// System iterates through the <see cref="StaminaComponent.StunModifierThresholds"/> to find correct movement modifer.
/// This modifier is saved to the Stamina Low Status Effect entity's <see cref="MovementModStatusEffectComponent"/>.
/// </summary>
/// <param name="ent">Entity to update</param>
private void AdjustStatus(Entity<StaminaComponent?> ent)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (!_status.TrySetStatusEffectDuration(ent, StaminaLow, out var status))
return;
var closest = FixedPoint2.Zero;
// Iterate through the dictionary in the similar way as in Damage.SlowOnDamageSystem.OnRefreshMovespeed
foreach (var thres in ent.Comp.StunModifierThresholds)
{
var key = thres.Key.Float();
if ((ent.Comp.StaminaDamage / ent.Comp.CritThreshold) >= key && key > closest && closest < 1f)
closest = thres.Key;
}
_movementMod.TryUpdateMovementStatus(ent.Owner, status.Value, ent.Comp.StunModifierThresholds[closest]);
}
// WyLab-Wega-Start
public void RemoveStaminaDamage(Entity<StaminaComponent?> ent)
{
if (!Resolve(ent, ref ent.Comp))
return;
if (ent.Comp.StaminaDamage >= ent.Comp.CritThreshold)
ExitStamCrit(ent);
ent.Comp.StaminaDamage = 0;
AdjustStatus(ent.Owner);
RemComp<ActiveStaminaComponent>(ent);
_status.TryRemoveStatusEffect(ent, StaminaLow);
UpdateStaminaVisuals((ent.Owner, ent.Comp));
Dirty(ent);
}
// WyLab-Wega-End
[Serializable, NetSerializable]
public sealed class StaminaAnimationEvent(NetEntity entity) : EntityEventArgs
{
public NetEntity Entity = entity;
}
}