diff --git a/Content.Client/_ES/Lighting/Components/ESTimedDespawnLightFadeComponent.cs b/Content.Client/_ES/Lighting/Components/ESTimedDespawnLightFadeComponent.cs
new file mode 100644
index 00000000000..0e02dfe79ab
--- /dev/null
+++ b/Content.Client/_ES/Lighting/Components/ESTimedDespawnLightFadeComponent.cs
@@ -0,0 +1,14 @@
+using Content.Shared._ES.Core.Timer.Components;
+
+namespace Content.Client._ES.Lighting.Components;
+
+///
+/// Handles a point light that fades out while synced to a
+///
+[RegisterComponent]
+[Access(typeof(ESTimedDespawnLightFadeSystem))]
+public sealed partial class ESTimedDespawnLightFadeComponent : Component
+{
+ [DataField]
+ public TimeSpan FadeTime = TimeSpan.FromSeconds(1);
+}
diff --git a/Content.Client/_ES/Lighting/ESTimedDespawnLightFadeSystem.cs b/Content.Client/_ES/Lighting/ESTimedDespawnLightFadeSystem.cs
new file mode 100644
index 00000000000..3cc547628b2
--- /dev/null
+++ b/Content.Client/_ES/Lighting/ESTimedDespawnLightFadeSystem.cs
@@ -0,0 +1,52 @@
+using Content.Client._ES.Lighting.Components;
+using Content.Shared._ES.Core.Timer.Components;
+using Robust.Client.Animations;
+using Robust.Client.GameObjects;
+using Robust.Shared.Animations;
+using Robust.Shared.Timing;
+
+namespace Content.Client._ES.Lighting;
+
+public sealed class ESTimedDespawnLightFadeSystem : VisualizerSystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly AnimationPlayerSystem _animationPlayer = default!;
+
+ private const string FadeTrack = "light-fade";
+
+ protected override void OnAppearanceChange(EntityUid uid, ESTimedDespawnLightFadeComponent component, ref AppearanceChangeEvent args)
+ {
+ base.OnAppearanceChange(uid, component, ref args);
+
+ if (_animationPlayer.HasRunningAnimation(uid, FadeTrack))
+ return;
+
+ if (!AppearanceSystem.TryGetData(uid, ESTimedDespawnVisuals.DespawnTime, out var time, args.Component) ||
+ !TryComp(uid, out var light))
+ return;
+
+ var duration = time - _timing.CurTime;
+
+ var animation = new Animation
+ {
+ Length = duration,
+ AnimationTracks =
+ {
+ new AnimationTrackComponentProperty
+ {
+ Property = nameof(PointLightComponent.Energy),
+ ComponentType = typeof(PointLightComponent),
+ InterpolationMode = AnimationInterpolationMode.Linear,
+ KeyFrames =
+ {
+ new AnimationTrackProperty.KeyFrame(light.Energy, 0f),
+ new AnimationTrackProperty.KeyFrame(light.Energy, (float) (duration - component.FadeTime).TotalSeconds),
+ new AnimationTrackProperty.KeyFrame(0f, (float) component.FadeTime.TotalSeconds, Easings.OutSine),
+ }
+ }
+ }
+ };
+
+ _animationPlayer.Play(uid, animation, FadeTrack);
+ }
+}
diff --git a/Content.Client/_ES/Sprite/Components/ESTimedDespawnSpriteFadeComponent.cs b/Content.Client/_ES/Sprite/Components/ESTimedDespawnSpriteFadeComponent.cs
new file mode 100644
index 00000000000..a3f2320048c
--- /dev/null
+++ b/Content.Client/_ES/Sprite/Components/ESTimedDespawnSpriteFadeComponent.cs
@@ -0,0 +1,14 @@
+using Content.Shared._ES.Core.Timer.Components;
+
+namespace Content.Client._ES.Sprite.Components;
+
+///
+/// Handles a sprite that fades out while synced to a
+///
+[RegisterComponent]
+[Access(typeof(ESTimedDespawnSpriteFadeSystem))]
+public sealed partial class ESTimedDespawnSpriteFadeComponent : Component
+{
+ [DataField]
+ public TimeSpan FadeTime = TimeSpan.FromSeconds(1);
+}
diff --git a/Content.Client/_ES/Sprite/ESTimedDespawnSpriteFadeSystem.cs b/Content.Client/_ES/Sprite/ESTimedDespawnSpriteFadeSystem.cs
new file mode 100644
index 00000000000..f394b9315b6
--- /dev/null
+++ b/Content.Client/_ES/Sprite/ESTimedDespawnSpriteFadeSystem.cs
@@ -0,0 +1,57 @@
+using Content.Client._ES.Sprite.Components;
+using Content.Shared._ES.Core.Timer.Components;
+using Robust.Client.Animations;
+using Robust.Client.GameObjects;
+using Robust.Shared.Animations;
+using Robust.Shared.Timing;
+
+namespace Content.Client._ES.Sprite;
+
+///
+/// This handles
+///
+public sealed class ESTimedDespawnSpriteFadeSystem : VisualizerSystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly AnimationPlayerSystem _animationPlayer = default!;
+
+ private const string FadeTrack = "es-sprite-fade";
+
+ protected override void OnAppearanceChange(EntityUid uid, ESTimedDespawnSpriteFadeComponent component, ref AppearanceChangeEvent args)
+ {
+ base.OnAppearanceChange(uid, component, ref args);
+
+ if (args.Sprite is not { } sprite)
+ return;
+
+ if (_animationPlayer.HasRunningAnimation(uid, FadeTrack))
+ return;
+
+ if (!AppearanceSystem.TryGetData(uid, ESTimedDespawnVisuals.DespawnTime, out var time, args.Component))
+ return;
+
+ var duration = time - _timing.CurTime;
+
+ var animation = new Animation
+ {
+ Length = duration,
+ AnimationTracks =
+ {
+ new AnimationTrackComponentProperty
+ {
+ Property = nameof(SpriteComponent.Color),
+ ComponentType = typeof(SpriteComponent),
+ InterpolationMode = AnimationInterpolationMode.Linear,
+ KeyFrames =
+ {
+ new AnimationTrackProperty.KeyFrame(sprite.Color, 0f),
+ new AnimationTrackProperty.KeyFrame(sprite.Color, MathF.Max((float) (duration - component.FadeTime).TotalSeconds, 0f)),
+ new AnimationTrackProperty.KeyFrame(sprite.Color.WithAlpha(0f), (float) component.FadeTime.TotalSeconds, Easings.OutSine),
+ },
+ },
+ },
+ };
+
+ _animationPlayer.Play(uid, animation, FadeTrack);
+ }
+}
diff --git a/Content.Server/Entry/IgnoredComponents.cs b/Content.Server/Entry/IgnoredComponents.cs
index 28089cbd47f..44b35001dd5 100644
--- a/Content.Server/Entry/IgnoredComponents.cs
+++ b/Content.Server/Entry/IgnoredComponents.cs
@@ -4,6 +4,10 @@ namespace Content.Server.Entry
public static class IgnoredComponents
{
public static string[] List => new[] {
+ // ES START
+ "ESTimedDespawnLightFade",
+ "ESTimedDespawnSpriteFade",
+ // ES END
"ConstructionGhost",
"IconSmooth",
"InteractionOutline",
diff --git a/Content.Shared/_ES/Core/Timer/Components/ESTimedDespawnComponent.cs b/Content.Shared/_ES/Core/Timer/Components/ESTimedDespawnComponent.cs
new file mode 100644
index 00000000000..4766d841246
--- /dev/null
+++ b/Content.Shared/_ES/Core/Timer/Components/ESTimedDespawnComponent.cs
@@ -0,0 +1,37 @@
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
+using Robust.Shared.Spawners;
+
+namespace Content.Shared._ES.Core.Timer.Components;
+
+///
+/// ES-specific version of with networking capabilities
+///
+[RegisterComponent, NetworkedComponent, AutoGenerateComponentState, AutoGenerateComponentPause]
+[Access(typeof(ESTimedDespawnSystem), Other = AccessPermissions.None)]
+public sealed partial class ESTimedDespawnComponent : Component
+{
+ ///
+ /// How long the entity will exist before despawning
+ ///
+ [DataField, AutoNetworkedField]
+ public TimeSpan Lifetime;
+
+ ///
+ /// The time at which the entity will despawn
+ ///
+ [DataField, AutoNetworkedField, AutoPausedField]
+ public TimeSpan DespawnTime;
+
+ ///
+ /// The time at which the entity spawned
+ ///
+ [ViewVariables]
+ public TimeSpan SpawnTime => DespawnTime - Lifetime;
+}
+
+[Serializable, NetSerializable]
+public enum ESTimedDespawnVisuals : byte
+{
+ DespawnTime,
+}
diff --git a/Content.Shared/_ES/Core/Timer/ESTimedDespawnSystem.cs b/Content.Shared/_ES/Core/Timer/ESTimedDespawnSystem.cs
new file mode 100644
index 00000000000..8843ed45403
--- /dev/null
+++ b/Content.Shared/_ES/Core/Timer/ESTimedDespawnSystem.cs
@@ -0,0 +1,111 @@
+using Content.Shared._ES.Core.Timer.Components;
+using JetBrains.Annotations;
+using Robust.Shared.Spawners;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Content.Shared._ES.Core.Timer;
+
+///
+/// This handles
+///
+public sealed class ESTimedDespawnSystem : EntitySystem
+{
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+ private readonly HashSet _toDelete = [];
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnMapInit);
+ }
+
+ private void OnMapInit(Entity ent, ref MapInitEvent args)
+ {
+ SetLifetime(ent.AsNullable(), ent.Comp.Lifetime);
+ }
+
+ ///
+ /// Sets the lifetime of the entity, adjusting the despawn time to compensate
+ ///
+ [PublicAPI]
+ public void SetLifetime(Entity ent, TimeSpan lifetime)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return;
+ DebugTools.Assert(lifetime >= TimeSpan.Zero, "Lifetime must be positive");
+
+ ent.Comp.Lifetime = lifetime;
+ ent.Comp.DespawnTime = _timing.CurTime + ent.Comp.Lifetime;
+ _appearance.SetData(ent, ESTimedDespawnVisuals.DespawnTime, ent.Comp.DespawnTime);
+ Dirty(ent);
+ }
+
+ ///
+ /// Gets the amount of time this entity will be alive before despawning
+ ///
+ [PublicAPI]
+ public TimeSpan GetLifetime(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return TimeSpan.Zero;
+ return ent.Comp.Lifetime;
+ }
+
+ ///
+ /// Sets the lifetime of the entity, adjusting the despawn time to compensate
+ ///
+ [PublicAPI]
+ public void SetDespawnTime(Entity ent, TimeSpan despawnTime)
+ {
+ SetLifetime(ent, despawnTime - _timing.CurTime);
+ }
+
+ ///
+ /// Gets the time at which the entity will despawn
+ ///
+ [PublicAPI]
+ public TimeSpan GetDespawnTime(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return TimeSpan.Zero;
+ return ent.Comp.DespawnTime;
+ }
+
+ ///
+ /// Returns how far along through the timedDespawn the entity is (as a percentage [0, 1])
+ ///
+ [PublicAPI]
+ public double GetProgress(Entity ent)
+ {
+ if (!Resolve(ent, ref ent.Comp))
+ return 0;
+
+ return Math.Clamp((_timing.CurTime - ent.Comp.SpawnTime) / ent.Comp.Lifetime, 0, 1);
+ }
+
+ public override void Update(float frameTime)
+ {
+ base.Update(frameTime);
+
+ _toDelete.Clear();
+ var query = EntityQueryEnumerator();
+ while (query.MoveNext(out var uid, out var comp))
+ {
+ if (_timing.CurTime < comp.DespawnTime)
+ continue;
+
+ _toDelete.Add(uid);
+ }
+
+ foreach (var toDelete in _toDelete)
+ {
+ // Same event as engine TimedDespawn
+ var ev = new TimedDespawnEvent();
+ RaiseLocalEvent(toDelete, ref ev);
+ PredictedQueueDel(toDelete);
+ }
+ }
+}
diff --git a/Content.Shared/_ES/Sparks/Components/ESSparkOnHitComponent.cs b/Content.Shared/_ES/Sparks/Components/ESSparkOnHitComponent.cs
new file mode 100644
index 00000000000..14b721998d9
--- /dev/null
+++ b/Content.Shared/_ES/Sparks/Components/ESSparkOnHitComponent.cs
@@ -0,0 +1,31 @@
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+using Robust.Shared.Prototypes;
+
+namespace Content.Shared._ES.Sparks.Components;
+
+///
+/// An entity that sparks when damaged by something
+///
+[RegisterComponent, NetworkedComponent]
+[Access(typeof(ESSparkOnHitSystem))]
+public sealed partial class ESSparkOnHitComponent : Component
+{
+ ///
+ /// Amount of damage that needs to be dealt to cause sparks
+ ///
+ [DataField]
+ public FixedPoint2 Threshold = 1;
+
+ ///
+ /// Number of sparks
+ ///
+ [DataField]
+ public int Count = 3;
+
+ ///
+ /// Spark prototypes
+ ///
+ [DataField]
+ public EntProtoId SparkPrototype = ESSparksSystem.DefaultSparks;
+}
diff --git a/Content.Shared/_ES/Sparks/ESSparkOnHitSystem.cs b/Content.Shared/_ES/Sparks/ESSparkOnHitSystem.cs
new file mode 100644
index 00000000000..04730fc2300
--- /dev/null
+++ b/Content.Shared/_ES/Sparks/ESSparkOnHitSystem.cs
@@ -0,0 +1,26 @@
+using Content.Shared._ES.Sparks.Components;
+using Content.Shared.Damage.Systems;
+
+namespace Content.Shared._ES.Sparks;
+
+public sealed class ESSparkOnHitSystem : EntitySystem
+{
+ [Dependency] private readonly ESSparksSystem _sparks = default!;
+
+ ///
+ public override void Initialize()
+ {
+ SubscribeLocalEvent(OnDamaged);
+ }
+
+ private void OnDamaged(Entity ent, ref DamageChangedEvent args)
+ {
+ if (args.DamageDelta is null)
+ return;
+
+ if (args.DamageDelta.GetTotal() < ent.Comp.Threshold)
+ return;
+
+ _sparks.DoSparks(ent, ent.Comp.Count, ent.Comp.SparkPrototype);
+ }
+}
diff --git a/Content.Shared/_ES/Sparks/ESSparksSystem.cs b/Content.Shared/_ES/Sparks/ESSparksSystem.cs
new file mode 100644
index 00000000000..15a28d4c590
--- /dev/null
+++ b/Content.Shared/_ES/Sparks/ESSparksSystem.cs
@@ -0,0 +1,57 @@
+using Content.Shared.Physics;
+using Content.Shared.Throwing;
+using Robust.Shared.Map;
+using Robust.Shared.Network;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Content.Shared._ES.Sparks;
+
+public sealed class ESSparksSystem : EntitySystem
+{
+ [Dependency] private readonly INetManager _net = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly ThrowingSystem _throwing = default!;
+ [Dependency] private readonly SharedTransformSystem _transform = default!;
+
+ public static readonly EntProtoId DefaultSparks = "ESEffectSparks";
+
+ public void DoSparks(EntityUid source, int number = 4, EntProtoId? sparksPrototype = null)
+ {
+ var coords = _transform.GetMapCoordinates(source);
+ DoSparks(coords, number, sparksPrototype, source);
+ }
+
+ public void DoSparks(EntityCoordinates coordinates, int number = 4, EntProtoId? sparksPrototype = null, EntityUid? ignored = null)
+ {
+ var mapCoordinates = _transform.ToMapCoordinates(coordinates);
+ DoSparks(mapCoordinates, number, sparksPrototype, ignored);
+ }
+
+ public void DoSparks(MapCoordinates coordinates, int number = 4, EntProtoId? sparksPrototype = null, EntityUid? ignored = null)
+ {
+ if (_net.IsClient)
+ return;
+
+ sparksPrototype ??= DefaultSparks;
+
+ var angleDelta = (Angle) (MathF.Tau / number);
+ var angle = _random.NextAngle();
+ for (var i = 0; i < number; i++)
+ {
+ var sparks = EntityManager.Spawn(sparksPrototype, coordinates, rotation: angle);
+ angle += angleDelta;
+ _throwing.TryThrow(sparks, angle.ToVec(), 2f, animated: false);
+ PreventCollide(sparks, ignored);
+ }
+ }
+
+ private void PreventCollide(EntityUid sparks, EntityUid? ignored)
+ {
+ if (!ignored.HasValue || TerminatingOrDeleted(ignored))
+ return;
+ var comp = EnsureComp(sparks);
+ comp.Uid = ignored.Value;
+ Dirty(sparks, comp);
+ }
+}
diff --git a/Resources/Prototypes/Entities/Structures/Lighting/base_lighting.yml b/Resources/Prototypes/Entities/Structures/Lighting/base_lighting.yml
index 3519a36f895..b34b00a34c6 100644
--- a/Resources/Prototypes/Entities/Structures/Lighting/base_lighting.yml
+++ b/Resources/Prototypes/Entities/Structures/Lighting/base_lighting.yml
@@ -88,6 +88,9 @@
collection: GlassBreak
- type: PlacementReplacement
key: lights
+# ES START
+ - type: ESSparkOnHit
+# ES END
placement:
mode: SnapgridCenter
snap:
diff --git a/Resources/Prototypes/_ES/Entities/Effects/sparks.yml b/Resources/Prototypes/_ES/Entities/Effects/sparks.yml
new file mode 100644
index 00000000000..4f8833b975a
--- /dev/null
+++ b/Resources/Prototypes/_ES/Entities/Effects/sparks.yml
@@ -0,0 +1,50 @@
+- type: entity
+ id: ESEffectSparks
+ name: sparks
+ components:
+ - type: Sprite
+ sprite: _ES/Effects/sparks.rsi
+ state: sparks
+ drawdepth: Effects
+ noRot: true
+ loop: false
+ - type: Appearance
+ - type: Physics
+ bodyType: Dynamic
+ bodyStatus: InAir
+ sleepingAllowed: false
+ - type: Fixtures
+ fixtures:
+ fix1:
+ shape:
+ !type:PhysShapeCircle
+ radius: 0.25
+ hard: true
+ restitution: 0.0
+ density: 100
+ mask:
+ - Impassable
+ - type: ESTimedDespawn
+ lifetime: 1.1
+ - type: EmitSoundOnSpawn
+ positional: true
+ sound:
+ collection: sparks
+ params:
+ variation: 0.250
+ - type: ESTimedDespawnSpriteFade
+ fadeTime: 0.3
+ - type: ESTimedDespawnLightFade
+ fadeTime: 0.5
+ - type: AnimationPlayer
+ - type: PointLight
+ radius: 1.5
+ energy: 2.4
+ falloff: 6
+ color: "#FAA019"
+ netsync: false
+ - type: IgnitionSource
+ ignited: true
+ - type: Tag
+ tags:
+ - HideContextMenu
diff --git a/Resources/Textures/_ES/Effects/sparks.rsi/meta.json b/Resources/Textures/_ES/Effects/sparks.rsi/meta.json
new file mode 100644
index 00000000000..aa22d92aa20
--- /dev/null
+++ b/Resources/Textures/_ES/Effects/sparks.rsi/meta.json
@@ -0,0 +1,57 @@
+{
+ "version": 1,
+ "license": "CC-BY-SA-3.0",
+ "copyright": "Taken from tgstation at https://github.com/tgstation/tgstation/blob/29b6d6c129c56124200b7fbe46e41178f5373ca4/icons/effects/effects.dmi",
+ "size": {
+ "x": 32,
+ "y": 32
+ },
+ "states": [
+ {
+ "name": "sparks",
+ "directions": 4,
+ "delays": [
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 1
+ ],
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 1
+ ],
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 1
+ ],
+ [
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 0.1,
+ 1
+ ]
+ ]
+ }
+ ]
+}
diff --git a/Resources/Textures/_ES/Effects/sparks.rsi/sparks.png b/Resources/Textures/_ES/Effects/sparks.rsi/sparks.png
new file mode 100644
index 00000000000..44c08e50930
Binary files /dev/null and b/Resources/Textures/_ES/Effects/sparks.rsi/sparks.png differ