From ac895a0db4f9478999940353f5359b976fc3e3f8 Mon Sep 17 00:00:00 2001 From: Princess Cheeseballs <66055347+Princess-Cheeseballs@users.noreply.github.com> Date: Fri, 11 Jul 2025 05:13:11 -0700 Subject: [PATCH] Stun and Stamina Visuals (#37196) * 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> --- .../Damage/Systems/StaminaSystem.cs | 121 ++++++++++++- Content.Client/Stunnable/StunSystem.cs | 159 +++++++++++++++++- .../Damage/Components/StaminaComponent.cs | 79 +++++++++ .../Damage/Systems/SharedStaminaSystem.cs | 84 +++++---- .../Stunnable/SharedStunSystem.Visualizer.cs | 55 ++++++ Content.Shared/Stunnable/SharedStunSystem.cs | 20 ++- .../Stunnable/StunVisualsComponent.cs | 16 ++ Content.Shared/Stunnable/StunnedComponent.cs | 4 +- .../Prototypes/Entities/Mobs/Species/base.yml | 1 + Resources/Prototypes/Entities/Mobs/base.yml | 1 + .../Mobs/Effects/stunned.rsi/meta.json | 18 ++ .../Mobs/Effects/stunned.rsi/stunned.png | Bin 0 -> 8718 bytes 12 files changed, 513 insertions(+), 45 deletions(-) create mode 100644 Content.Shared/Stunnable/SharedStunSystem.Visualizer.cs create mode 100644 Content.Shared/Stunnable/StunVisualsComponent.cs create mode 100644 Resources/Textures/Mobs/Effects/stunned.rsi/meta.json create mode 100644 Resources/Textures/Mobs/Effects/stunned.rsi/stunned.png diff --git a/Content.Client/Damage/Systems/StaminaSystem.cs b/Content.Client/Damage/Systems/StaminaSystem.cs index 69eb461a7d..046c72a8fa 100644 --- a/Content.Client/Damage/Systems/StaminaSystem.cs +++ b/Content.Client/Damage/Systems/StaminaSystem.cs @@ -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(OnAnimationCompleted); + SubscribeLocalEvent(OnActiveStaminaShutdown); + SubscribeLocalEvent(OnMobStateChanged); + } + + protected override void OnStamHandleState(Entity entity, ref AfterAutoHandleStateEvent args) + { + base.OnStamHandleState(entity, ref args); + + TryStartAnimation(entity); + } + + private void OnActiveStaminaShutdown(Entity 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(entity, out var stamina)) + return; + + StopAnimation((entity, stamina)); + } + + protected override void OnShutdown(Entity entity, ref ComponentShutdown args) + { + base.OnShutdown(entity, ref args); + + StopAnimation(entity); + } + + private void OnMobStateChanged(Entity ent, ref MobStateChangedEvent args) + { + if (args.NewMobState == MobState.Dead) + StopAnimation(ent); + } + + private void TryStartAnimation(Entity entity) + { + if (!TryComp(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 entity) + { + if(!Resolve(entity, ref entity.Comp2)) + return; + + _animation.Stop(entity.Owner, StaminaAnimationKey); + entity.Comp1.StartOffset = entity.Comp2.Offset; + } + + private void OnAnimationCompleted(Entity entity, ref AnimationCompletedEvent args) + { + if (args.Key != StaminaAnimationKey || !args.Finished || !TryComp(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(entity)) + return; + + PlayAnimation((entity, entity.Comp, sprite)); + } + + private void PlayAnimation(Entity 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); + } } diff --git a/Content.Client/Stunnable/StunSystem.cs b/Content.Client/Stunnable/StunSystem.cs index e519a0d5f0..cb59eda482 100644 --- a/Content.Client/Stunnable/StunSystem.cs +++ b/Content.Client/Stunnable/StunSystem.cs @@ -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(OnComponentInit); + SubscribeLocalEvent(OnAppearanceChanged); } + + /// + /// Add stun visual layers + /// + private void OnComponentInit(Entity entity, ref ComponentInit args) + { + if (!TryComp(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 entity, ref AppearanceChangeEvent args) + { + if (args.Sprite != null) + UpdateAppearance((entity, args.Sprite), entity.Comp.State); + } + + private void UpdateAppearance(Entity 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(entity, StunVisuals.SeeingStars, out var stars) && stars; + + _spriteSystem.LayerSetVisible((entity, entity.Comp), index, visible); + _spriteSystem.LayerSetRsiState((entity, entity.Comp), index, state); + } + + /// + /// 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. + /// + /// The spriteComponent we're adjusting the offset of + /// How many times per second does the animation run? + /// How many times should we jitter during the animation? Also determines breathing frequency + /// Mininum jitter offset multiplier for X and Y directions + /// Maximum jitter offset multiplier for X and Y directions + /// Maximum breathing offset, this is in the Y direction + /// Starting offset because we don't have adjustment layers + /// Last jitter so we don't jitter to the same quadrant + /// + 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 { 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, } diff --git a/Content.Shared/Damage/Components/StaminaComponent.cs b/Content.Shared/Damage/Components/StaminaComponent.cs index ec086625ea..3a85f3f8dc 100644 --- a/Content.Shared/Damage/Components/StaminaComponent.cs +++ b/Content.Shared/Damage/Components/StaminaComponent.cs @@ -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 /// [DataField] public Dictionary StunModifierThresholds = new() { {0, 1f }, { 60, 0.7f }, { 80, 0.5f } }; + + #region Animation Data + + /// + /// 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). + /// + [DataField] + public float AnimationThreshold = 50; + + /// + /// Minimum y vector displacement for breathing at AnimationThreshold + /// + [DataField] + public float BreathingAmplitudeMin = 0.04f; + + /// + /// Maximum y vector amount we add to the BreathingAmplitudeMin + /// + [DataField] + public float BreathingAmplitudeMod = 0.04f; + + /// + /// Minimum vector displacement for jittering at AnimationThreshold + /// + [DataField] + public float JitterAmplitudeMin; + + /// + /// Maximum vector amount we add to the JitterAmplitudeMin + /// + [DataField] + public float JitterAmplitudeMod = 0.04f; + + /// + /// Min multipliers for JitterAmplitude in the X and Y directions, animation randomly chooses between these min and max multipliers + /// + [DataField] + public Vector2 JitterMin = Vector2.Create(0.5f, 0.125f); + + /// + /// Max multipliers for JitterAmplitude in the X and Y directions, animation randomly chooses between these min and max multipliers + /// + [DataField] + public Vector2 JitterMax = Vector2.Create(1f, 0.25f); + + /// + /// Minimum total animations per second + /// + [DataField] + public float FrequencyMin = 0.25f; + + /// + /// Maximum amount we add to the Frequency min just before crit + /// + [DataField] + public float FrequencyMod = 1.75f; + + /// + /// Jitter keyframes per animation + /// + [DataField] + public int Jitters = 4; + + /// + /// Vector of the last Jitter so we can make sure we don't jitter in the same quadrant twice in a row. + /// + [DataField] + public Vector2 LastJitter; + + /// + /// The offset that an entity had before jittering started, + /// so that we can reset it properly. + /// + [DataField] + public Vector2 StartOffset = Vector2.Zero; + + #endregion } diff --git a/Content.Shared/Damage/Systems/SharedStaminaSystem.cs b/Content.Shared/Damage/Systems/SharedStaminaSystem.cs index 0da09d0f6d..ae4562e690 100644 --- a/Content.Shared/Damage/Systems/SharedStaminaSystem.cs +++ b/Content.Shared/Damage/Systems/SharedStaminaSystem.cs @@ -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!; /// /// How much of a buffer is there between the stun duration and when stuns can be re-applied. /// - 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 entity, ref AfterAutoHandleStateEvent args) { - if (component.Critical) - EnterStamCrit(uid, component); + if (entity.Comp.Critical) + EnterStamCrit(entity); else { - if (component.StaminaDamage > 0f) - EnsureComp(uid); + if (entity.Comp.StaminaDamage > 0f) + EnsureComp(entity); - ExitStamCrit(uid, component); + ExitStamCrit(entity); } } - private void OnShutdown(EntityUid uid, StaminaComponent component, ComponentShutdown args) + protected virtual void OnShutdown(Entity entity, ref ComponentShutdown args) { - if (MetaData(uid).EntityLifeStage < EntityLifeStage.Terminating) + if (MetaData(entity).EntityLifeStage < EntityLifeStage.Terminating) { - RemCompDeferred(uid); + RemCompDeferred(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 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 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(uid); - SetStaminaAlert(uid, component); - Dirty(uid, component); + entity.Comp.StaminaDamage = 0; + AdjustSlowdown(entity.Owner); + RemComp(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 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 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(); var query = EntityQueryEnumerator(); - 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(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 index 0000000000..4d49621a8a --- /dev/null +++ b/Content.Shared/Stunnable/SharedStunSystem.Visualizer.cs @@ -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(OnStunMobStateChanged); + SubscribeLocalEvent(OnSleepStateChanged); + } + + private bool GetStarsData(Entity entity) + { + if (!Resolve(entity, ref entity.Comp2, false)) + return false; + + return Blocker.CanConsciouslyPerformAction(entity); + } + + private void OnStunMobStateChanged(Entity entity, ref MobStateChangedEvent args) + { + Appearance.SetData(entity, StunVisuals.SeeingStars, GetStarsData(entity)); + } + + private void OnSleepStateChanged(Entity entity, ref SleepStateChangedEvent args) + { + Appearance.SetData(entity, StunVisuals.SeeingStars, GetStarsData(entity)); + } + + public void TrySeeingStars(Entity 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(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, + } +} diff --git a/Content.Shared/Stunnable/SharedStunSystem.cs b/Content.Shared/Stunnable/SharedStunSystem.cs index e18a7f3439..c46dd10ed7 100644 --- a/Content.Shared/Stunnable/SharedStunSystem.cs +++ b/Content.Shared/Stunnable/SharedStunSystem.cs @@ -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(OnSlowRemove); SubscribeLocalEvent(UpdateCanMove); - SubscribeLocalEvent(UpdateCanMove); + SubscribeLocalEvent(OnStunShutdown); SubscribeLocalEvent(OnStunOnContactCollide); @@ -71,6 +73,9 @@ public abstract class SharedStunSystem : EntitySystem SubscribeLocalEvent(OnEquipAttempt); SubscribeLocalEvent(OnUnequipAttempt); SubscribeLocalEvent(OnMobStateChanged); + + // Stun Appearance Data + InitializeAppearance(); } private void OnAttemptInteract(Entity ent, ref InteractionAttemptEvent args) @@ -107,9 +112,16 @@ public abstract class SharedStunSystem : EntitySystem } + private void OnStunShutdown(Entity 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 ent, ref StartCollideEvent args) diff --git a/Content.Shared/Stunnable/StunVisualsComponent.cs b/Content.Shared/Stunnable/StunVisualsComponent.cs new file mode 100644 index 0000000000..6f1070a558 --- /dev/null +++ b/Content.Shared/Stunnable/StunVisualsComponent.cs @@ -0,0 +1,16 @@ +using Robust.Shared.Utility; + +namespace Content.Shared.Stunnable; + +/// +/// This is used to listen to incoming events from the AppearanceSystem +/// +[RegisterComponent] +public sealed partial class StunVisualsComponent : Component +{ + [DataField] + public ResPath StarsPath = new ("Mobs/Effects/stunned.rsi"); + + [DataField] + public string State = "stunned"; +} diff --git a/Content.Shared/Stunnable/StunnedComponent.cs b/Content.Shared/Stunnable/StunnedComponent.cs index 51a27ac90a..037a3cd3ac 100644 --- a/Content.Shared/Stunnable/StunnedComponent.cs +++ b/Content.Shared/Stunnable/StunnedComponent.cs @@ -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; diff --git a/Resources/Prototypes/Entities/Mobs/Species/base.yml b/Resources/Prototypes/Entities/Mobs/Species/base.yml index 5bf9a9ede0..f2ac678f52 100644 --- a/Resources/Prototypes/Entities/Mobs/Species/base.yml +++ b/Resources/Prototypes/Entities/Mobs/Species/base.yml @@ -278,6 +278,7 @@ Asphyxiation: -1.0 - type: FireVisuals alternateState: Standing + - type: StunVisuals - type: entity save: false diff --git a/Resources/Prototypes/Entities/Mobs/base.yml b/Resources/Prototypes/Entities/Mobs/base.yml index d7b82b5d95..cfd9c13631 100644 --- a/Resources/Prototypes/Entities/Mobs/base.yml +++ b/Resources/Prototypes/Entities/Mobs/base.yml @@ -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 index 0000000000..ea721b943f --- /dev/null +++ b/Resources/Textures/Mobs/Effects/stunned.rsi/meta.json @@ -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 index 0000000000000000000000000000000000000000..7ad891b4f6dcb08c5c88e5943cdcb3a9ae722a79 GIT binary patch literal 8718 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 literal 0 HcmV?d00001 -- 2.52.0