]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Stun and Stamina Visuals (#37196)
authorPrincess Cheeseballs <66055347+Princess-Cheeseballs@users.noreply.github.com>
Fri, 11 Jul 2025 12:13:11 +0000 (05:13 -0700)
committerGitHub <noreply@github.com>
Fri, 11 Jul 2025 12:13:11 +0000 (14:13 +0200)
* Stun animation

* Commit 2

* Almost working commit

* Best commit

* Minor cleanup and value adjustments

* Fix animation data getting wasted and cleaned up some stuff

* Don't animate if dead

* AppearanceSystem is for chumps

* Cleanup

* More cleanup

* More cleanup

* Half working commit

* Documentation

* Works

* ComponentHandleState my beloved

* AppearanceComp compatibility

* Address review

* Borgar

* AND NOW THE END IS NEAR

* AppearanceSystem compliance (Real)

* Don't need to log missing there

* I actually hate mob prototypes so much you don't even know

---------

Co-authored-by: Princess Cheeseballs <66055347+Pronana@users.noreply.github.com>
12 files changed:
Content.Client/Damage/Systems/StaminaSystem.cs
Content.Client/Stunnable/StunSystem.cs
Content.Shared/Damage/Components/StaminaComponent.cs
Content.Shared/Damage/Systems/SharedStaminaSystem.cs
Content.Shared/Stunnable/SharedStunSystem.Visualizer.cs [new file with mode: 0644]
Content.Shared/Stunnable/SharedStunSystem.cs
Content.Shared/Stunnable/StunVisualsComponent.cs [new file with mode: 0644]
Content.Shared/Stunnable/StunnedComponent.cs
Resources/Prototypes/Entities/Mobs/Species/base.yml
Resources/Prototypes/Entities/Mobs/base.yml
Resources/Textures/Mobs/Effects/stunned.rsi/meta.json [new file with mode: 0644]
Resources/Textures/Mobs/Effects/stunned.rsi/stunned.png [new file with mode: 0644]

index 69eb461a7da89a06e715971770cededeaded1d00..046c72a8fa2e4a43801fb59fc715d225439215c4 100644 (file)
@@ -1,7 +1,126 @@
-using Content.Shared.Damage.Systems;
+using Content.Client.Stunnable;
+using Content.Shared.Damage.Components;
+using Content.Shared.Damage.Systems;
+using Content.Shared.Mobs;
+using Content.Shared.Mobs.Systems;
+using Robust.Client.GameObjects;
 
 namespace Content.Client.Damage.Systems;
 
 public sealed partial class StaminaSystem : SharedStaminaSystem
 {
+    [Dependency] private readonly AnimationPlayerSystem _animation = default!;
+    [Dependency] private readonly MobStateSystem _mobState = default!;
+    [Dependency] private readonly SpriteSystem _sprite = default!;
+    [Dependency] private readonly StunSystem _stun = default!; // Clientside Stun System
+
+    private const string StaminaAnimationKey = "stamina";
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<StaminaComponent, AnimationCompletedEvent>(OnAnimationCompleted);
+        SubscribeLocalEvent<ActiveStaminaComponent, ComponentShutdown>(OnActiveStaminaShutdown);
+        SubscribeLocalEvent<StaminaComponent, MobStateChangedEvent>(OnMobStateChanged);
+    }
+
+    protected override void OnStamHandleState(Entity<StaminaComponent> entity, ref AfterAutoHandleStateEvent args)
+    {
+        base.OnStamHandleState(entity, ref args);
+
+        TryStartAnimation(entity);
+    }
+
+    private void OnActiveStaminaShutdown(Entity<ActiveStaminaComponent> entity, ref ComponentShutdown args)
+    {
+        // If we don't have active stamina, we shouldn't have stamina damage. If the update loop can trust it we can trust it.
+        if (!TryComp<StaminaComponent>(entity, out var stamina))
+            return;
+
+        StopAnimation((entity, stamina));
+    }
+
+    protected override void OnShutdown(Entity<StaminaComponent> entity, ref ComponentShutdown args)
+    {
+        base.OnShutdown(entity, ref args);
+
+        StopAnimation(entity);
+    }
+
+    private void OnMobStateChanged(Entity<StaminaComponent> ent, ref MobStateChangedEvent args)
+    {
+        if (args.NewMobState == MobState.Dead)
+            StopAnimation(ent);
+    }
+
+    private void TryStartAnimation(Entity<StaminaComponent> entity)
+    {
+        if (!TryComp<SpriteComponent>(entity, out var sprite))
+            return;
+
+        // If the animation is running, the system should update it accordingly
+        // If we're below the threshold to animate, don't try to animate
+        // If we're in stamcrit don't override it
+        if (entity.Comp.AnimationThreshold > entity.Comp.StaminaDamage || _animation.HasRunningAnimation(entity, StaminaAnimationKey))
+            return;
+
+        // Don't animate if we're dead
+        if (_mobState.IsDead(entity))
+            return;
+
+        entity.Comp.StartOffset = sprite.Offset;
+
+        PlayAnimation((entity, entity.Comp, sprite));
+    }
+
+    private void StopAnimation(Entity<StaminaComponent, SpriteComponent?> entity)
+    {
+        if(!Resolve(entity, ref entity.Comp2))
+            return;
+
+        _animation.Stop(entity.Owner, StaminaAnimationKey);
+        entity.Comp1.StartOffset = entity.Comp2.Offset;
+    }
+
+    private void OnAnimationCompleted(Entity<StaminaComponent> entity, ref AnimationCompletedEvent args)
+    {
+        if (args.Key != StaminaAnimationKey || !args.Finished || !TryComp<SpriteComponent>(entity, out var sprite))
+            return;
+
+        // stop looping if we're below the threshold
+        if (entity.Comp.AnimationThreshold > entity.Comp.StaminaDamage)
+        {
+            _animation.Stop(entity.Owner, StaminaAnimationKey);
+            _sprite.SetOffset((entity, sprite), entity.Comp.StartOffset);
+            return;
+        }
+
+        if (!HasComp<AnimationPlayerComponent>(entity))
+            return;
+
+        PlayAnimation((entity, entity.Comp, sprite));
+    }
+
+    private void PlayAnimation(Entity<StaminaComponent, SpriteComponent> entity)
+    {
+        var step = Math.Clamp((entity.Comp1.StaminaDamage - entity.Comp1.AnimationThreshold) /
+                              (entity.Comp1.CritThreshold - entity.Comp1.AnimationThreshold),
+            0f,
+            1f); // The things I do for project 0 warnings
+        var frequency = entity.Comp1.FrequencyMin + step * entity.Comp1.FrequencyMod;
+        var jitter = entity.Comp1.JitterAmplitudeMin + step * entity.Comp1.JitterAmplitudeMod;
+        var breathing = entity.Comp1.BreathingAmplitudeMin + step * entity.Comp1.BreathingAmplitudeMod;
+
+        _animation.Play(entity.Owner,
+            _stun.GetFatigueAnimation(entity.Comp2,
+                frequency,
+                entity.Comp1.Jitters,
+                jitter * entity.Comp1.JitterMin,
+                jitter * entity.Comp1.JitterMax,
+                breathing,
+                entity.Comp1.StartOffset,
+                ref entity.Comp1.LastJitter),
+            StaminaAnimationKey);
+    }
 }
index e519a0d5f0fdc5f83dcda5c962dba10eb34f112c..cb59eda4824bfbfadcb3c60021fd726d1ebdbf2d 100644 (file)
@@ -1,9 +1,164 @@
+using System.Numerics;
+using Content.Shared.Mobs;
 using Content.Shared.Stunnable;
+using Robust.Client.Animations;
+using Robust.Client.GameObjects;
+using Robust.Shared.Animations;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 
-namespace Content.Client.Stunnable
+namespace Content.Client.Stunnable;
+
+public sealed class StunSystem : SharedStunSystem
 {
-    public sealed class StunSystem : SharedStunSystem
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly SpriteSystem _spriteSystem = default!;
+
+    private readonly int[] _sign = [-1, 1];
+
+    public override void Initialize()
     {
+        base.Initialize();
 
+        SubscribeLocalEvent<StunVisualsComponent, ComponentInit>(OnComponentInit);
+        SubscribeLocalEvent<StunVisualsComponent, AppearanceChangeEvent>(OnAppearanceChanged);
     }
+
+    /// <summary>
+    ///     Add stun visual layers
+    /// </summary>
+    private void OnComponentInit(Entity<StunVisualsComponent> entity, ref ComponentInit args)
+    {
+        if (!TryComp<SpriteComponent>(entity, out var sprite))
+            return;
+
+        var spriteEntity = (entity.Owner, sprite);
+
+        _spriteSystem.LayerMapReserve(spriteEntity, StunVisualLayers.StamCrit);
+        _spriteSystem.LayerSetVisible(spriteEntity, StunVisualLayers.StamCrit, false);
+        _spriteSystem.LayerSetOffset(spriteEntity, StunVisualLayers.StamCrit, new Vector2(0, 0.3125f));
+
+        _spriteSystem.LayerSetRsi(spriteEntity, StunVisualLayers.StamCrit, entity.Comp.StarsPath);
+
+        UpdateAppearance((entity, sprite), entity.Comp.State);
+    }
+
+    private void OnAppearanceChanged(Entity<StunVisualsComponent> entity, ref AppearanceChangeEvent args)
+    {
+        if (args.Sprite != null)
+            UpdateAppearance((entity, args.Sprite), entity.Comp.State);
+    }
+
+    private void UpdateAppearance(Entity<SpriteComponent?> entity, string state)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return;
+
+        if (!_spriteSystem.LayerMapTryGet((entity, entity.Comp), StunVisualLayers.StamCrit, out var index, false))
+            return;
+
+        var visible = Appearance.TryGetData<bool>(entity, StunVisuals.SeeingStars, out var stars) && stars;
+
+        _spriteSystem.LayerSetVisible((entity, entity.Comp), index, visible);
+        _spriteSystem.LayerSetRsiState((entity, entity.Comp), index, state);
+    }
+
+    /// <summary>
+    /// A simple fatigue animation, a mild modification of the jittering animation. The animation constructor is
+    /// quite complex, but that's because the AnimationSystem doesn't have proper adjustment layers. In a potential
+    /// future where proper adjustment layers are added feel free to clean this up to be an animation with two adjustment
+    /// layers rather than one mega layer.
+    /// </summary>
+    /// <param name="sprite">The spriteComponent we're adjusting the offset of</param>
+    /// <param name="frequency">How many times per second does the animation run?</param>
+    /// <param name="jitters">How many times should we jitter during the animation? Also determines breathing frequency</param>
+    /// <param name="minJitter">Mininum jitter offset multiplier for X and Y directions</param>
+    /// <param name="maxJitter">Maximum jitter offset multiplier for X and Y directions</param>
+    /// <param name="breathing">Maximum breathing offset, this is in the Y direction</param>
+    /// <param name="startOffset">Starting offset because we don't have adjustment layers</param>
+    /// <param name="lastJitter">Last jitter so we don't jitter to the same quadrant</param>
+    /// <returns></returns>
+    public Animation GetFatigueAnimation(SpriteComponent sprite,
+        float frequency,
+        int jitters,
+        Vector2 minJitter,
+        Vector2 maxJitter,
+        float breathing,
+        Vector2 startOffset,
+        ref Vector2 lastJitter)
+    {
+        // avoid animations with negative length or infinite length
+        if (frequency <= 0)
+            return new Animation();
+
+        var breaths = new Vector2(0, breathing * 2) / jitters;
+
+        var length =  1 / frequency;
+        var frames = length / jitters;
+
+        var keyFrames = new List<AnimationTrackProperty.KeyFrame> { new(sprite.Offset, 0f) };
+
+        // Spits out a list of keyframes to feed to the AnimationPlayer based on the variables we've inputted
+        for (var i = 1; i <= jitters; i++)
+        {
+            var offset = new Vector2(_random.NextFloat(minJitter.X, maxJitter.X),
+                _random.NextFloat(minJitter.Y, maxJitter.Y));
+            offset.X *= _random.Pick(_sign);
+            offset.Y *= _random.Pick(_sign);
+
+            if (i == 1 && Math.Sign(offset.X) == Math.Sign(lastJitter.X)
+                       && Math.Sign(offset.Y) == Math.Sign(lastJitter.Y))
+            {
+                // If the sign is the same as last time on both axis we flip one randomly
+                // to avoid jitter staying in one quadrant too much.
+                if (_random.Prob(0.5f))
+                    offset.X *= -1;
+                else
+                    offset.Y *= -1;
+            }
+
+            lastJitter = offset;
+
+            // For the first half of the jitter, we vertically displace the sprite upwards to simulate breathing in
+            if (i <= jitters / 2)
+            {
+                keyFrames.Add(new AnimationTrackProperty.KeyFrame(startOffset + breaths * i + offset, frames));
+            }
+            // For the next quarter we displace the sprite down, to about 12.5% breathing offset below our starting position
+            // Simulates breathing out
+            else if (i < jitters * 3 / 4)
+            {
+                keyFrames.Add(
+                    new AnimationTrackProperty.KeyFrame(startOffset + breaths * ( jitters - i * 1.5f ) + offset, frames));
+            }
+            // Return to our starting position for breathing, jitter reaches its final position
+            else
+            {
+                keyFrames.Add(
+                    new AnimationTrackProperty.KeyFrame(startOffset + breaths * ( i - jitters ) + offset, frames));
+            }
+        }
+
+        return new Animation
+        {
+            Length = TimeSpan.FromSeconds(length),
+            AnimationTracks =
+            {
+                new AnimationTrackComponentProperty
+                {
+                    // Heavy Breathing
+                    ComponentType = typeof(SpriteComponent),
+                    Property = nameof(SpriteComponent.Offset),
+                    InterpolationMode = AnimationInterpolationMode.Cubic,
+                    KeyFrames = keyFrames,
+                },
+            }
+        };
+    }
+}
+
+public enum StunVisualLayers : byte
+{
+    StamCrit,
 }
index ec086625ea044dc299ba6fec544b54a4bee8bc12..3a85f3f8dc72e84589c4e8ea8b534036403474fe 100644 (file)
@@ -1,3 +1,4 @@
+using System.Numerics;
 using Content.Shared.Alert;
 using Content.Shared.FixedPoint;
 using Robust.Shared.GameStates;
@@ -75,4 +76,82 @@ public sealed partial class StaminaComponent : Component
     /// </summary>
     [DataField]
     public Dictionary<FixedPoint2, float> StunModifierThresholds = new() { {0, 1f }, { 60, 0.7f }, { 80, 0.5f } };
+
+    #region Animation Data
+
+    /// <summary>
+    /// Threshold at which low stamina animations begin playing. This should be set to a value that means something.
+    /// At 50, it is aligned so when you hit 60 stun the entity will be breathing once per second (well above hyperventilation).
+    /// </summary>
+    [DataField]
+    public float AnimationThreshold = 50;
+
+    /// <summary>
+    /// Minimum y vector displacement for breathing at AnimationThreshold
+    /// </summary>
+    [DataField]
+    public float BreathingAmplitudeMin = 0.04f;
+
+    /// <summary>
+    /// Maximum y vector amount we add to the BreathingAmplitudeMin
+    /// </summary>
+    [DataField]
+    public float BreathingAmplitudeMod = 0.04f;
+
+    /// <summary>
+    /// Minimum vector displacement for jittering at AnimationThreshold
+    /// </summary>
+    [DataField]
+    public float JitterAmplitudeMin;
+
+    /// <summary>
+    /// Maximum vector amount we add to the JitterAmplitudeMin
+    /// </summary>
+    [DataField]
+    public float JitterAmplitudeMod = 0.04f;
+
+    /// <summary>
+    /// Min multipliers for JitterAmplitude in the X and Y directions, animation randomly chooses between these min and max multipliers
+    /// </summary>
+    [DataField]
+    public Vector2 JitterMin = Vector2.Create(0.5f, 0.125f);
+
+    /// <summary>
+    /// Max multipliers for JitterAmplitude in the X and Y directions, animation randomly chooses between these min and max multipliers
+    /// </summary>
+    [DataField]
+    public Vector2 JitterMax = Vector2.Create(1f, 0.25f);
+
+    /// <summary>
+    /// Minimum total animations per second
+    /// </summary>
+    [DataField]
+    public float FrequencyMin = 0.25f;
+
+    /// <summary>
+    /// Maximum amount we add to the Frequency min just before crit
+    /// </summary>
+    [DataField]
+    public float FrequencyMod = 1.75f;
+
+    /// <summary>
+    /// Jitter keyframes per animation
+    /// </summary>
+    [DataField]
+    public int Jitters = 4;
+
+    /// <summary>
+    /// Vector of the last Jitter so we can make sure we don't jitter in the same quadrant twice in a row.
+    /// </summary>
+    [DataField]
+    public Vector2 LastJitter;
+
+    /// <summary>
+    ///     The offset that an entity had before jittering started,
+    ///     so that we can reset it properly.
+    /// </summary>
+    [DataField]
+    public Vector2 StartOffset = Vector2.Zero;
+
+    #endregion
 }
index 0da09d0f6d872f8d7daedee9429daba3f2fda408..ae4562e690e73e63c6d8f42658085ee07d7c6629 100644 (file)
@@ -20,26 +20,27 @@ using Robust.Shared.Audio.Systems;
 using Robust.Shared.Configuration;
 using Robust.Shared.Network;
 using Robust.Shared.Player;
+using Robust.Shared.Serialization;
 using Robust.Shared.Timing;
 
 namespace Content.Shared.Damage.Systems;
 
 public abstract partial class SharedStaminaSystem : EntitySystem
 {
-    [Dependency] private readonly IGameTiming _timing = 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 SharedColorFlashEffectSystem _color = default!;
-    [Dependency] private readonly SharedStunSystem _stunSystem = default!;
+    [Dependency] protected readonly SharedStunSystem StunSystem = default!;
     [Dependency] private readonly SharedAudioSystem _audio = default!;
     [Dependency] private readonly IConfigurationManager _config = default!;
 
     /// <summary>
     /// How much of a buffer is there between the stun duration and when stuns can be re-applied.
     /// </summary>
-    private static readonly TimeSpan StamCritBufferTime = TimeSpan.FromSeconds(3f);
+    protected static readonly TimeSpan StamCritBufferTime = TimeSpan.FromSeconds(3f);
 
     public float UniversalStaminaDamageModifier { get; private set; } = 1f;
 
@@ -66,31 +67,31 @@ public abstract partial class SharedStaminaSystem : EntitySystem
         Subs.CVar(_config, CCVars.PlaytestStaminaDamageModifier, value => UniversalStaminaDamageModifier = value, true);
     }
 
-    private void OnStamHandleState(EntityUid uid, StaminaComponent component, ref AfterAutoHandleStateEvent args)
+    protected virtual void OnStamHandleState(Entity<StaminaComponent> entity, ref AfterAutoHandleStateEvent args)
     {
-        if (component.Critical)
-            EnterStamCrit(uid, component);
+        if (entity.Comp.Critical)
+            EnterStamCrit(entity);
         else
         {
-            if (component.StaminaDamage > 0f)
-                EnsureComp<ActiveStaminaComponent>(uid);
+            if (entity.Comp.StaminaDamage > 0f)
+                EnsureComp<ActiveStaminaComponent>(entity);
 
-            ExitStamCrit(uid, component);
+            ExitStamCrit(entity);
         }
     }
 
-    private void OnShutdown(EntityUid uid, StaminaComponent component, ComponentShutdown args)
+    protected virtual void OnShutdown(Entity<StaminaComponent> entity, ref ComponentShutdown args)
     {
-        if (MetaData(uid).EntityLifeStage < EntityLifeStage.Terminating)
+        if (MetaData(entity).EntityLifeStage < EntityLifeStage.Terminating)
         {
-            RemCompDeferred<ActiveStaminaComponent>(uid);
+            RemCompDeferred<ActiveStaminaComponent>(entity);
         }
-        _alerts.ClearAlert(uid, component.StaminaAlert);
+        _alerts.ClearAlert(entity, entity.Comp.StaminaAlert);
     }
 
-    private void OnStartup(EntityUid uid, StaminaComponent component, ComponentStartup args)
+    private void OnStartup(Entity<StaminaComponent> entity, ref ComponentStartup args)
     {
-        SetStaminaAlert(uid, component);
+        UpdateStaminaVisuals(entity);
     }
 
     [PublicAPI]
@@ -99,23 +100,23 @@ public abstract partial class SharedStaminaSystem : EntitySystem
         if (!Resolve(uid, ref component))
             return 0f;
 
-        var curTime = _timing.CurTime;
+        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(EntityUid uid, StaminaComponent component, RejuvenateEvent args)
+    private void OnRejuvenate(Entity<StaminaComponent> entity, ref RejuvenateEvent args)
     {
-        if (component.StaminaDamage >= component.CritThreshold)
+        if (entity.Comp.StaminaDamage >= entity.Comp.CritThreshold)
         {
-            ExitStamCrit(uid, component);
+            ExitStamCrit(entity, entity.Comp);
         }
 
-        component.StaminaDamage = 0;
-        AdjustSlowdown(uid);
-        RemComp<ActiveStaminaComponent>(uid);
-        SetStaminaAlert(uid, component);
-        Dirty(uid, component);
+        entity.Comp.StaminaDamage = 0;
+        AdjustSlowdown(entity.Owner);
+        RemComp<ActiveStaminaComponent>(entity);
+        UpdateStaminaVisuals(entity);
+        Dirty(entity);
     }
 
     private void OnDisarmed(EntityUid uid, StaminaComponent component, ref DisarmedEvent args)
@@ -212,6 +213,15 @@ public abstract partial class SharedStaminaSystem : EntitySystem
         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)
@@ -268,7 +278,7 @@ public abstract partial class SharedStaminaSystem : EntitySystem
         // Reset the decay cooldown upon taking damage.
         if (oldDamage < component.StaminaDamage)
         {
-            var nextUpdate = _timing.CurTime + TimeSpan.FromSeconds(component.Cooldown);
+            var nextUpdate = Timing.CurTime + TimeSpan.FromSeconds(component.Cooldown);
 
             if (component.NextUpdate < nextUpdate)
                 component.NextUpdate = nextUpdate;
@@ -276,7 +286,7 @@ public abstract partial class SharedStaminaSystem : EntitySystem
 
         AdjustSlowdown(uid);
 
-        SetStaminaAlert(uid, component);
+        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)
@@ -330,7 +340,7 @@ public abstract partial class SharedStaminaSystem : EntitySystem
 
         var stamQuery = GetEntityQuery<StaminaComponent>();
         var query = EntityQueryEnumerator<ActiveStaminaComponent>();
-        var curTime = _timing.CurTime;
+        var curTime = Timing.CurTime;
 
         while (query.MoveNext(out var uid, out _))
         {
@@ -371,16 +381,14 @@ public abstract partial class SharedStaminaSystem : EntitySystem
             return;
         }
 
-        // To make the difference between a stun and a stamcrit clear
-        // TODO: Mask?
-
         component.Critical = true;
         component.StaminaDamage = component.CritThreshold;
 
-        _stunSystem.TryParalyze(uid, component.StunTime, true);
+        if (StunSystem.TryParalyze(uid, component.StunTime, true))
+            StunSystem.TrySeeingStars(uid);
 
         // Give them buffer before being able to be re-stunned
-        component.NextUpdate = _timing.CurTime + component.StunTime + StamCritBufferTime;
+        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");
@@ -396,9 +404,9 @@ public abstract partial class SharedStaminaSystem : EntitySystem
 
         component.Critical = false;
         component.AfterCritical = true;  // Set to true to indicate that stamina will be restored after exiting stamcrit
-        component.NextUpdate = _timing.CurTime;
+        component.NextUpdate = Timing.CurTime;
 
-        SetStaminaAlert(uid, component);
+        UpdateStaminaVisuals((uid, component));
         Dirty(uid, component);
         _adminLogger.Add(LogType.Stamina, LogImpact.Low, $"{ToPrettyString(uid):user} recovered from stamina crit");
     }
@@ -427,6 +435,12 @@ public abstract partial class SharedStaminaSystem : EntitySystem
                 closest = thres.Key;
         }
 
-        _stunSystem.UpdateStunModifiers(ent, ent.Comp.StunModifierThresholds[closest]);
+        StunSystem.UpdateStunModifiers(ent, ent.Comp.StunModifierThresholds[closest]);
+    }
+
+    [Serializable, NetSerializable]
+    public sealed class StaminaAnimationEvent(NetEntity entity) : EntityEventArgs
+    {
+        public NetEntity Entity = entity;
     }
 }
diff --git a/Content.Shared/Stunnable/SharedStunSystem.Visualizer.cs b/Content.Shared/Stunnable/SharedStunSystem.Visualizer.cs
new file mode 100644 (file)
index 0000000..4d49621
--- /dev/null
@@ -0,0 +1,55 @@
+using Content.Shared.Bed.Sleep;
+using Content.Shared.Mobs;
+using Robust.Shared.Serialization;
+
+namespace Content.Shared.Stunnable;
+
+public abstract partial class SharedStunSystem
+{
+    public void InitializeAppearance()
+    {
+        SubscribeLocalEvent<StunVisualsComponent, MobStateChangedEvent>(OnStunMobStateChanged);
+        SubscribeLocalEvent<StunVisualsComponent, SleepStateChangedEvent>(OnSleepStateChanged);
+    }
+
+    private bool GetStarsData(Entity<StunVisualsComponent, StunnedComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp2, false))
+            return false;
+
+        return Blocker.CanConsciouslyPerformAction(entity);
+    }
+
+    private void OnStunMobStateChanged(Entity<StunVisualsComponent> entity, ref MobStateChangedEvent args)
+    {
+        Appearance.SetData(entity, StunVisuals.SeeingStars, GetStarsData(entity));
+    }
+
+    private void OnSleepStateChanged(Entity<StunVisualsComponent> entity, ref SleepStateChangedEvent args)
+    {
+        Appearance.SetData(entity, StunVisuals.SeeingStars, GetStarsData(entity));
+    }
+
+    public void TrySeeingStars(Entity<AppearanceComponent?> entity)
+    {
+        if (!Resolve(entity, ref entity.Comp))
+            return;
+
+        // Here so server can tell the client to do things
+        // Don't dirty the component if we don't need to
+        if (!Appearance.TryGetData<bool>(entity, StunVisuals.SeeingStars, out var stars, entity.Comp) && stars)
+            return;
+
+        if (!Blocker.CanConsciouslyPerformAction(entity))
+            return;
+
+        Appearance.SetData(entity, StunVisuals.SeeingStars, true);
+        Dirty(entity);
+    }
+
+    [Serializable, NetSerializable, Flags]
+    public enum StunVisuals
+    {
+        SeeingStars,
+    }
+}
index e18a7f343977a7ec87e3a36aa671a19783a15861..c46dd10ed7849a55180e4426406bd124ff103758 100644 (file)
@@ -20,15 +20,17 @@ using Robust.Shared.Audio.Systems;
 using Robust.Shared.Physics.Components;
 using Robust.Shared.Physics.Events;
 using Robust.Shared.Physics.Systems;
+using Robust.Shared.Timing;
 
 namespace Content.Shared.Stunnable;
 
-public abstract class SharedStunSystem : EntitySystem
+public abstract partial class SharedStunSystem : EntitySystem
 {
-    [Dependency] private readonly ActionBlockerSystem _blocker = default!;
+    [Dependency] protected readonly ActionBlockerSystem Blocker = default!;
     [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
     [Dependency] private readonly MovementSpeedModifierSystem _movementSpeedModifier = default!;
     [Dependency] private readonly SharedAudioSystem _audio = default!;
+    [Dependency] protected readonly SharedAppearanceSystem Appearance = default!;
     [Dependency] private readonly EntityWhitelistSystem _entityWhitelist = default!;
     [Dependency] private readonly StandingStateSystem _standingState = default!;
     [Dependency] private readonly StatusEffectsSystem _statusEffect = default!;
@@ -49,7 +51,7 @@ public abstract class SharedStunSystem : EntitySystem
         SubscribeLocalEvent<SlowedDownComponent, ComponentShutdown>(OnSlowRemove);
 
         SubscribeLocalEvent<StunnedComponent, ComponentStartup>(UpdateCanMove);
-        SubscribeLocalEvent<StunnedComponent, ComponentShutdown>(UpdateCanMove);
+        SubscribeLocalEvent<StunnedComponent, ComponentShutdown>(OnStunShutdown);
 
         SubscribeLocalEvent<StunOnContactComponent, StartCollideEvent>(OnStunOnContactCollide);
 
@@ -71,6 +73,9 @@ public abstract class SharedStunSystem : EntitySystem
         SubscribeLocalEvent<StunnedComponent, IsEquippingAttemptEvent>(OnEquipAttempt);
         SubscribeLocalEvent<StunnedComponent, IsUnequippingAttemptEvent>(OnUnequipAttempt);
         SubscribeLocalEvent<MobStateComponent, MobStateChangedEvent>(OnMobStateChanged);
+
+        // Stun Appearance Data
+        InitializeAppearance();
     }
 
     private void OnAttemptInteract(Entity<StunnedComponent> ent, ref InteractionAttemptEvent args)
@@ -107,9 +112,16 @@ public abstract class SharedStunSystem : EntitySystem
 
     }
 
+    private void OnStunShutdown(Entity<StunnedComponent> ent, ref ComponentShutdown args)
+    {
+        // This exists so the client can end their funny animation if they're playing one.
+        UpdateCanMove(ent, ent.Comp, args);
+        Appearance.RemoveData(ent, StunVisuals.SeeingStars);
+    }
+
     private void UpdateCanMove(EntityUid uid, StunnedComponent component, EntityEventArgs args)
     {
-        _blocker.UpdateCanMove(uid);
+        Blocker.UpdateCanMove(uid);
     }
 
     private void OnStunOnContactCollide(Entity<StunOnContactComponent> ent, ref StartCollideEvent args)
diff --git a/Content.Shared/Stunnable/StunVisualsComponent.cs b/Content.Shared/Stunnable/StunVisualsComponent.cs
new file mode 100644 (file)
index 0000000..6f1070a
--- /dev/null
@@ -0,0 +1,16 @@
+using Robust.Shared.Utility;
+
+namespace Content.Shared.Stunnable;
+
+/// <summary>
+/// This is used to listen to incoming events from the AppearanceSystem
+/// </summary>
+[RegisterComponent]
+public sealed partial class StunVisualsComponent : Component
+{
+    [DataField]
+    public ResPath StarsPath = new ("Mobs/Effects/stunned.rsi");
+
+    [DataField]
+    public string State = "stunned";
+}
index 51a27ac90a6426340236c78e5f568e2e9c45e098..037a3cd3ac07776b3c70fb3e9c31598407748bfa 100644 (file)
@@ -3,6 +3,4 @@ using Robust.Shared.GameStates;
 namespace Content.Shared.Stunnable;
 
 [RegisterComponent, NetworkedComponent, Access(typeof(SharedStunSystem))]
-public sealed partial class StunnedComponent : Component
-{
-}
+public sealed partial class StunnedComponent : Component;
index 5bf9a9ede072c23ad75ab480f98444de1fd05bbf..f2ac678f5218b621562f4a455e084972ef26f44d 100644 (file)
         Asphyxiation: -1.0
   - type: FireVisuals
     alternateState: Standing
+  - type: StunVisuals
 
 - type: entity
   save: false
index d7b82b5d957eefd2ccbd0d82c7e8051ea28d89ea..cfd9c1363195d60c8afbe0d07d034dcfd3f7eaae 100644 (file)
@@ -44,6 +44,7 @@
   - type: MovementSpeedModifier
   - type: RequireProjectileTarget
     active: False
+  - type: StunVisuals
 
 - type: entity
   save: false
diff --git a/Resources/Textures/Mobs/Effects/stunned.rsi/meta.json b/Resources/Textures/Mobs/Effects/stunned.rsi/meta.json
new file mode 100644 (file)
index 0000000..ea721b9
--- /dev/null
@@ -0,0 +1,18 @@
+{
+    "version": 1,
+    "size": {
+        "x": 32,
+        "y": 32
+    },
+    "license": "CC-BY-SA-3.0",
+    "copyright": "Created by Princess Cheeseballs, https://github.com/Princess-Cheeseballs",
+    "states": [
+        {
+            "name": "stunned",
+            "directions": 1,
+            "delays": [
+                [ 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1 ]
+            ]
+        }
+    ]
+}
diff --git a/Resources/Textures/Mobs/Effects/stunned.rsi/stunned.png b/Resources/Textures/Mobs/Effects/stunned.rsi/stunned.png
new file mode 100644 (file)
index 0000000..7ad891b
Binary files /dev/null and b/Resources/Textures/Mobs/Effects/stunned.rsi/stunned.png differ