]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Puddles & spreader refactor (#15191)
authormetalgearsloth <31366439+metalgearsloth@users.noreply.github.com>
Mon, 10 Apr 2023 05:37:03 +0000 (15:37 +1000)
committerGitHub <noreply@github.com>
Mon, 10 Apr 2023 05:37:03 +0000 (15:37 +1000)
141 files changed:
Content.Client/Atmos/UI/GasAnalyzerWindow.xaml
Content.Client/Atmos/UI/GasAnalyzerWindow.xaml.cs
Content.Client/Chemistry/Visualizers/FoamVisualizerSystem.cs
Content.Client/Fluids/AbsorbentSystem.cs [moved from Content.Client/Fluids/MoppingSystem.cs with 84% similarity]
Content.Client/Fluids/PuddleSystem.cs [new file with mode: 0644]
Content.Client/Fluids/PuddleVisualizerComponent.cs [deleted file]
Content.Client/Fluids/PuddleVisualizerSystem.cs [deleted file]
Content.Client/Fluids/UI/AbsorbentItemStatus.xaml
Content.Client/Fluids/UI/AbsorbentItemStatus.xaml.cs
Content.Client/IconSmoothing/IconSmoothComponent.cs
Content.Client/IconSmoothing/IconSmoothSystem.cs
Content.Client/UserInterface/Controls/SplitBar.xaml [new file with mode: 0644]
Content.Client/UserInterface/Controls/SplitBar.xaml.cs [new file with mode: 0644]
Content.Client/Weapons/Melee/MeleeWeaponSystem.Effects.cs
Content.IntegrationTests/Tests/Fluids/FluidSpillTest.cs
Content.IntegrationTests/Tests/Fluids/PuddleTest.cs
Content.Server/AME/AMENodeGroup.cs
Content.Server/Animals/Systems/UdderSystem.cs
Content.Server/Atmos/Reactions/WaterVaporReaction.cs
Content.Server/Body/Systems/BloodstreamSystem.cs
Content.Server/Chemistry/Components/FoamSolutionAreaEffectComponent.cs [deleted file]
Content.Server/Chemistry/Components/SmokeComponent.cs [new file with mode: 0644]
Content.Server/Chemistry/Components/SmokeDissipateSpawnComponent.cs [new file with mode: 0644]
Content.Server/Chemistry/Components/SmokeSolutionAreaEffectComponent.cs [deleted file]
Content.Server/Chemistry/Components/SolutionAreaEffectComponent.cs [deleted file]
Content.Server/Chemistry/Components/SolutionAreaEffectInceptionComponent.cs [deleted file]
Content.Server/Chemistry/Components/SolutionManager/DrainableSolutionComponent.cs [deleted file]
Content.Server/Chemistry/Components/SolutionManager/RefillableSolutionComponent.cs [deleted file]
Content.Server/Chemistry/EntitySystems/SolutionAreaEffectSystem.cs [deleted file]
Content.Server/Chemistry/EntitySystems/SolutionContainerSystem.cs
Content.Server/Chemistry/ReactionEffects/AreaReactionEffect.cs
Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs [deleted file]
Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs [deleted file]
Content.Server/Chemistry/TileReactions/SpillIfPuddlePresentTileReaction.cs
Content.Server/Chemistry/TileReactions/SpillTileReaction.cs
Content.Server/Cloning/CloningSystem.cs
Content.Server/Destructible/DestructibleSystem.cs
Content.Server/Destructible/Thresholds/Behaviors/SolutionExplosionBehavior.cs
Content.Server/Destructible/Thresholds/Behaviors/SpillBehavior.cs
Content.Server/Entry/IgnoredComponents.cs
Content.Server/Fluids/Components/DrainComponent.cs [deleted file]
Content.Server/Fluids/Components/EvaporationComponent.cs
Content.Server/Fluids/Components/EvaporationSparkleComponent.cs [new file with mode: 0644]
Content.Server/Fluids/Components/FluidMapDataComponent.cs [deleted file]
Content.Server/Fluids/Components/FootstepTrackComponent.cs [new file with mode: 0644]
Content.Server/Fluids/Components/PuddleComponent.cs [deleted file]
Content.Server/Fluids/EntitySystems/AbsorbentSystem.cs [new file with mode: 0644]
Content.Server/Fluids/EntitySystems/DrainSystem.cs
Content.Server/Fluids/EntitySystems/EvaporationSystem.cs [deleted file]
Content.Server/Fluids/EntitySystems/FluidSpreaderSystem.cs [deleted file]
Content.Server/Fluids/EntitySystems/MoppingSystem.cs [deleted file]
Content.Server/Fluids/EntitySystems/PuddleDebugDebugOverlaySystem.cs
Content.Server/Fluids/EntitySystems/PuddleSystem.Evaporation.cs [new file with mode: 0644]
Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs [new file with mode: 0644]
Content.Server/Fluids/EntitySystems/PuddleSystem.Transfers.cs [new file with mode: 0644]
Content.Server/Fluids/EntitySystems/PuddleSystem.cs
Content.Server/Fluids/EntitySystems/SmokeSystem.cs [new file with mode: 0644]
Content.Server/Fluids/EntitySystems/SpillableSystem.cs [deleted file]
Content.Server/Kudzu/GrowingKudzuComponent.cs [deleted file]
Content.Server/Kudzu/GrowingKudzuSystem.cs [deleted file]
Content.Server/Kudzu/SpreaderComponent.cs [deleted file]
Content.Server/Kudzu/SpreaderSystem.cs [deleted file]
Content.Server/Medical/BiomassReclaimer/BiomassReclaimerSystem.cs
Content.Server/Medical/VomitSystem.cs
Content.Server/NodeContainer/EntitySystems/NodeGroupSystem.cs
Content.Server/NodeContainer/NodeGroups/NodeGroupFactory.cs
Content.Server/Nutrition/EntitySystems/CreamPieSystem.cs
Content.Server/Nutrition/EntitySystems/DrinkSystem.cs
Content.Server/Spreader/EdgeSpreaderComponent.cs [new file with mode: 0644]
Content.Server/Spreader/EdgeSpreaderPrototype.cs [new file with mode: 0644]
Content.Server/Spreader/GrowingKudzuComponent.cs [new file with mode: 0644]
Content.Server/Spreader/KudzuComponent.cs [new file with mode: 0644]
Content.Server/Spreader/KudzuSystem.cs [new file with mode: 0644]
Content.Server/Spreader/SpreadGroupUpdateRate.cs [new file with mode: 0644]
Content.Server/Spreader/SpreadNeighborsEvent.cs [new file with mode: 0644]
Content.Server/Spreader/SpreaderGridComponent.cs [new file with mode: 0644]
Content.Server/Spreader/SpreaderNode.cs [new file with mode: 0644]
Content.Server/Spreader/SpreaderNodeGroup.cs [new file with mode: 0644]
Content.Server/Spreader/SpreaderSystem.cs [new file with mode: 0644]
Content.Server/StationEvents/Events/VentClog.cs
Content.Server/Tools/ToolSystem.TilePrying.cs
Content.Server/Weapons/Melee/MeleeWeaponSystem.cs
Content.Server/Xenoarchaeology/XenoArtifacts/Effects/Components/ChemicalPuddleArtifactComponent.cs
Content.Server/Xenoarchaeology/XenoArtifacts/Effects/Systems/ChemicalPuddleArtifactSystem.cs
Content.Server/Xenoarchaeology/XenoArtifacts/Effects/Systems/FoamArtifactSystem.cs
Content.Shared/Access/Components/IdCardComponent.cs
Content.Shared/Chemistry/Components/DrainableSolutionComponent.cs [new file with mode: 0644]
Content.Shared/Chemistry/Components/RefillableSolutionComponent.cs [new file with mode: 0644]
Content.Shared/Chemistry/Components/Solution.cs
Content.Shared/Chemistry/Reaction/SharedChemicalReactionSystem.cs
Content.Shared/Chemistry/Reagent/ReagentPrototype.cs
Content.Shared/Fluids/AbsorbentComponent.cs
Content.Shared/Fluids/Components/DrainComponent.cs [new file with mode: 0644]
Content.Shared/Fluids/Components/PuddleComponent.cs [new file with mode: 0644]
Content.Shared/Fluids/PuddleVisuals.cs
Content.Shared/Fluids/SharedAbsorbentSystem.cs [moved from Content.Shared/Fluids/SharedMoppingSystem.cs with 56% similarity]
Content.Shared/Fluids/SharedPuddleSystem.cs [new file with mode: 0644]
Content.Shared/Foam/FoamVisuals.cs [deleted file]
Content.Shared/Movement/Events/GetFootstepSoundEvent.cs [new file with mode: 0644]
Content.Shared/Movement/Systems/SharedMoverController.cs
Content.Shared/Spawners/Components/TimedDespawnComponent.cs
Content.Shared/Spawners/EntitySystems/SharedTimedDespawnSystem.cs
Content.Shared/Spawners/TimedDespawnEvent.cs [new file with mode: 0644]
Content.Shared/Weapons/Melee/SharedMeleeWeaponSystem.cs
Resources/Locale/en-US/fluids/components/absorbent-component.ftl
Resources/Locale/en-US/fluids/components/puddle-component.ftl
Resources/Maps/Test/dev_map.yml
Resources/Prototypes/Entities/Effects/chemistry_effects.yml
Resources/Prototypes/Entities/Effects/puddle.yml
Resources/Prototypes/Entities/Objects/Consumable/Food/egg.yml
Resources/Prototypes/Entities/Objects/Consumable/Food/ingredients.yml
Resources/Prototypes/Entities/Objects/Consumable/Food/produce.yml
Resources/Prototypes/Entities/Objects/Misc/kudzu.yml
Resources/Prototypes/Entities/Objects/Specific/Janitorial/janitor.yml
Resources/Prototypes/Reagents/Consumable/Drink/alcohol.yml
Resources/Prototypes/Reagents/Consumable/Drink/base_drink.yml
Resources/Prototypes/Reagents/Consumable/Drink/drinks.yml
Resources/Prototypes/Reagents/biological.yml
Resources/Prototypes/Reagents/pyrotechnic.yml
Resources/Prototypes/Recipes/Reactions/chemicals.yml
Resources/Prototypes/edge_spreaders.yml [new file with mode: 0644]
Resources/Textures/Fluids/newliquid.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/meta.json [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat0.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat1.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat10.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat11.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat12.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat13.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat14.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat15.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat2.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat3.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat4.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat5.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat6.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat7.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat8.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splat9.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splata.png [new file with mode: 0644]
Resources/Textures/Fluids/puddle.rsi/splatb.png [new file with mode: 0644]

index c8eb42cf56ef96a3758c7cd7ad65613062271e61..160b6fa281bcdded0744891791cb32377512a2c2 100644 (file)
@@ -1,6 +1,6 @@
 <DefaultWindow xmlns="https://spacestation14.io"
                MinSize="270 420"
-               SetSize="315 420" Title="{Loc 'gas-analyzer-window-name'}">
+               SetSize="360 420" Title="{Loc 'gas-analyzer-window-name'}">
     <BoxContainer Orientation="Vertical" Margin="5 5 5 5">
         <BoxContainer Name="CTopBox" Orientation="Horizontal"/>
         <!-- Gas Mix Data, Populated by function -->
index 81c35096b505ae4d51ec3b2144b037ec5a2d7ff9..fa012760855371fde010e7ffbba2b413ba235891 100644 (file)
@@ -1,3 +1,4 @@
+using Content.Client.UserInterface.Controls;
 using Content.Shared.Atmos;
 using Content.Shared.Temperature;
 using Robust.Client.Graphics;
@@ -261,11 +262,9 @@ namespace Content.Client.Atmos.UI
             // This is the gas bar thingy
             var height = 30;
             var minSize = 24; // This basically allows gases which are too small, to be shown properly
-            var gasBar = new BoxContainer
+            var gasBar = new SplitBar()
             {
-                Orientation = BoxContainer.LayoutOrientation.Horizontal,
-                HorizontalExpand = true,
-                MinSize = new Vector2(0, height)
+                MinHeight = height
             };
             // Separator
             dataContainer.AddChild(new Control
@@ -299,25 +298,10 @@ namespace Content.Client.Atmos.UI
                 });
 
                 // Add to the gas bar //TODO: highlight the currently hover one
-                var left = (j == 0) ? 0f : 2f;
-                var right = (j == gasMix.Gases.Length - 1) ? 0f : 2f;
-                gasBar.AddChild(new PanelContainer
-                {
-                    ToolTip = Loc.GetString("gas-analyzer-window-molarity-percentage-text",
-                        ("gasName", gas.Name),
-                        ("amount", $"{gas.Amount:0.##}"),
-                        ("percentage", $"{(gas.Amount / totalGasAmount * 100):0.#}")),
-                    HorizontalExpand = true,
-                    SizeFlagsStretchRatio = gas.Amount,
-                    MouseFilter = MouseFilterMode.Stop,
-                    PanelOverride = new StyleBoxFlat
-                    {
-                        BackgroundColor = color,
-                        PaddingLeft = left,
-                        PaddingRight = right
-                    },
-                    MinSize = new Vector2(minSize, 0)
-                });
+                gasBar.AddEntry(gas.Amount, color, tooltip: Loc.GetString("gas-analyzer-window-molarity-percentage-text",
+                    ("gasName", gas.Name),
+                    ("amount", $"{gas.Amount:0.##}"),
+                    ("percentage", $"{(gas.Amount / totalGasAmount * 100):0.#}")));
             }
 
             dataContainer.AddChild(gasBar);
index e5a2f5b68946484b219a6cf2eb8734ff4fd2c9a9..5cef1db805c833bcf9ce41d71d9c5657f9aebb65 100644 (file)
@@ -1,6 +1,9 @@
-using Content.Shared.Foam;
+using Content.Shared.Smoking;
+using Content.Shared.Spawners.Components;
 using Robust.Client.Animations;
 using Robust.Client.GameObjects;
+using Robust.Shared.Network;
+using Robust.Shared.Timing;
 
 namespace Content.Client.Chemistry.Visualizers;
 
@@ -9,23 +12,48 @@ namespace Content.Client.Chemistry.Visualizers;
 /// </summary>
 public sealed class FoamVisualizerSystem : VisualizerSystem<FoamVisualsComponent>
 {
+    [Dependency] private readonly IGameTiming _timing = default!;
+
     public override void Initialize()
     {
         base.Initialize();
         SubscribeLocalEvent<FoamVisualsComponent, ComponentInit>(OnComponentInit);
     }
 
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+
+        if (!_timing.IsFirstTimePredicted)
+            return;
+
+        var query = EntityQueryEnumerator<FoamVisualsComponent, TimedDespawnComponent>();
+
+        while (query.MoveNext(out var uid, out var comp, out var despawn))
+        {
+            if (despawn.Lifetime > 1f)
+                continue;
+
+            // Despawn animation.
+            if (TryComp(uid, out AnimationPlayerComponent? animPlayer)
+                && !AnimationSystem.HasRunningAnimation(uid, animPlayer, FoamVisualsComponent.AnimationKey))
+            {
+                AnimationSystem.Play(uid, animPlayer, comp.Animation, FoamVisualsComponent.AnimationKey);
+            }
+        }
+    }
+
     /// <summary>
     /// Generates the animation used by foam visuals when the foam dissolves.
     /// </summary>
     private void OnComponentInit(EntityUid uid, FoamVisualsComponent comp, ComponentInit args)
     {
-        comp.Animation = new Animation()
+        comp.Animation = new Animation
         {
             Length = TimeSpan.FromSeconds(comp.AnimationTime),
             AnimationTracks =
             {
-                new AnimationTrackSpriteFlick()
+                new AnimationTrackSpriteFlick
                 {
                     LayerKey = FoamVisualLayers.Base,
                     KeyFrames =
@@ -36,25 +64,6 @@ public sealed class FoamVisualizerSystem : VisualizerSystem<FoamVisualsComponent
             }
         };
     }
-
-    /// <summary>
-    /// Plays the animation used by foam visuals when the foam dissolves.
-    /// </summary>
-    protected override void OnAppearanceChange(EntityUid uid, FoamVisualsComponent comp, ref AppearanceChangeEvent args)
-    {
-        if (AppearanceSystem.TryGetData<bool>(uid, FoamVisuals.State, out var state, args.Component) && state)
-        {
-            if (TryComp(uid, out AnimationPlayerComponent? animPlayer)
-            && !AnimationSystem.HasRunningAnimation(uid, animPlayer, FoamVisualsComponent.AnimationKey))
-                AnimationSystem.Play(uid, animPlayer, comp.Animation, FoamVisualsComponent.AnimationKey);
-        }
-
-        if (AppearanceSystem.TryGetData<Color>(uid, FoamVisuals.Color, out var color, args.Component))
-        {
-            if (args.Sprite != null)
-                args.Sprite.Color = color;
-        }
-    }
 }
 
 public enum FoamVisualLayers : byte
similarity index 84%
rename from Content.Client/Fluids/MoppingSystem.cs
rename to Content.Client/Fluids/AbsorbentSystem.cs
index 90f8ba4e8bc677dd9198178bab047d6c96898756..97c1a8005720afe3607bfb201c4d6a3f1438bfc1 100644 (file)
@@ -5,7 +5,8 @@ using Robust.Client.UserInterface;
 
 namespace Content.Client.Fluids;
 
-public sealed class MoppingSystem : SharedMoppingSystem
+/// <inheritdoc/>
+public sealed class AbsorbentSystem : SharedAbsorbentSystem
 {
     public override void Initialize()
     {
diff --git a/Content.Client/Fluids/PuddleSystem.cs b/Content.Client/Fluids/PuddleSystem.cs
new file mode 100644 (file)
index 0000000..12004d7
--- /dev/null
@@ -0,0 +1,67 @@
+using Content.Client.IconSmoothing;
+using Content.Shared.Fluids;
+using Content.Shared.Fluids.Components;
+using Robust.Client.GameObjects;
+
+namespace Content.Client.Fluids;
+
+public sealed class PuddleSystem : SharedPuddleSystem
+{
+    [Dependency] private readonly IconSmoothSystem _smooth = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+
+        SubscribeLocalEvent<PuddleComponent, AppearanceChangeEvent>(OnPuddleAppearance);
+    }
+
+    private void OnPuddleAppearance(EntityUid uid, PuddleComponent component, ref AppearanceChangeEvent args)
+    {
+        if (args.Sprite == null)
+            return;
+
+        float volume = 1f;
+
+        if (args.AppearanceData.TryGetValue(PuddleVisuals.CurrentVolume, out var volumeObj))
+        {
+            volume = (float) volumeObj;
+        }
+
+        // Update smoothing and sprite based on volume.
+        if (TryComp<IconSmoothComponent>(uid, out var smooth))
+        {
+            if (volume < LowThreshold)
+            {
+                args.Sprite.LayerSetState(0, $"{smooth.StateBase}a");
+                _smooth.SetEnabled(uid, false, smooth);
+            }
+            else if (volume < 0.6f)
+            {
+                args.Sprite.LayerSetState(0, $"{smooth.StateBase}b");
+                _smooth.SetEnabled(uid, false, smooth);
+            }
+            else
+            {
+                if (!smooth.Enabled)
+                {
+                    args.Sprite.LayerSetState(0, $"{smooth.StateBase}0");
+                    _smooth.SetEnabled(uid, true, smooth);
+                    _smooth.DirtyNeighbours(uid);
+                }
+            }
+        }
+
+        var baseColor = Color.White;
+
+        if (args.AppearanceData.TryGetValue(PuddleVisuals.SolutionColor, out var colorObj))
+        {
+            var color = (Color) colorObj;
+            args.Sprite.Color = color * baseColor;
+        }
+        else
+        {
+            args.Sprite.Color *= baseColor;
+        }
+    }
+}
diff --git a/Content.Client/Fluids/PuddleVisualizerComponent.cs b/Content.Client/Fluids/PuddleVisualizerComponent.cs
deleted file mode 100644 (file)
index 6d1bb0b..0000000
+++ /dev/null
@@ -1,30 +0,0 @@
-using Content.Shared.FixedPoint;
-using Robust.Client.Graphics;
-
-namespace Content.Client.Fluids
-{
-    [RegisterComponent]
-    public sealed class PuddleVisualizerComponent : Component
-    {
-        // Whether the underlying solution color should be used. True in most cases.
-        [DataField("recolor")] public bool Recolor = true;
-
-        // Whether the puddle has a unique sprite we don't want to overwrite
-        [DataField("customPuddleSprite")] public bool CustomPuddleSprite;
-
-        // Puddles may change which RSI they use for their sprites (e.g. wet floor effects). This field will store the original RSI they used.
-        [DataField("originalRsi")] public RSI? OriginalRsi;
-
-        /// <summary>
-        /// Puddles with volume below this threshold are able to have their sprite changed to a wet floor effect, though this is not the only factor.
-        /// </summary>
-        [DataField("wetFloorEffectThreshold")]
-        public FixedPoint2 WetFloorEffectThreshold = FixedPoint2.New(5);
-
-        /// <summary>
-        /// Alpha (opacity) of the wet floor sparkle effect. Higher alpha = more opaque/visible.
-        /// </summary>
-        [DataField("wetFloorEffectAlpha")]
-        public float WetFloorEffectAlpha = 0.75f; //should be somewhat transparent by default.
-    }
-}
diff --git a/Content.Client/Fluids/PuddleVisualizerSystem.cs b/Content.Client/Fluids/PuddleVisualizerSystem.cs
deleted file mode 100644 (file)
index 4710caa..0000000
+++ /dev/null
@@ -1,105 +0,0 @@
-using System.Linq;
-using Content.Shared.Fluids;
-using Content.Shared.FixedPoint;
-using JetBrains.Annotations;
-using Robust.Client.GameObjects;
-using Robust.Client.Graphics;
-using Robust.Shared.Random;
-
-namespace Content.Client.Fluids
-{
-    [UsedImplicitly]
-    public sealed class PuddleVisualizerSystem : VisualizerSystem<PuddleVisualizerComponent>
-    {
-        [Dependency] private readonly IRobustRandom _random = default!;
-
-        public override void Initialize()
-        {
-            base.Initialize();
-
-            SubscribeLocalEvent<PuddleVisualizerComponent, ComponentInit>(OnComponentInit);
-        }
-
-        private void OnComponentInit(EntityUid uid, PuddleVisualizerComponent puddleVisuals, ComponentInit args)
-        {
-            if (!TryComp(uid, out SpriteComponent? sprite))
-            {
-                return;
-            }
-
-            puddleVisuals.OriginalRsi = sprite.BaseRSI; //Back up the original RSI upon initialization
-            RandomizeState(sprite, puddleVisuals.OriginalRsi);
-            RandomizeRotation(sprite);
-        }
-
-        protected override void OnAppearanceChange(EntityUid uid, PuddleVisualizerComponent component, ref AppearanceChangeEvent args)
-        {
-            if (args.Sprite == null)
-            {
-                Logger.Warning($"Missing SpriteComponent for PuddleVisualizerSystem on entityUid = {uid}");
-                return;
-            }
-
-            if (!AppearanceSystem.TryGetData<float>(uid, PuddleVisuals.VolumeScale, out var volumeScale)
-                || !AppearanceSystem.TryGetData<FixedPoint2>(uid, PuddleVisuals.CurrentVolume, out var currentVolume)
-                || !AppearanceSystem.TryGetData<Color>(uid, PuddleVisuals.SolutionColor, out var solutionColor)
-                || !AppearanceSystem.TryGetData<bool>(uid, PuddleVisuals.IsEvaporatingVisual, out var isEvaporating))
-            {
-                return;
-            }
-
-            // volumeScale is our opacity based on level of fullness to overflow. The lower bound is hard-capped for visibility reasons.
-            var cappedScale = Math.Min(1.0f, volumeScale * 0.75f + 0.25f);
-
-            var newColor = component.Recolor ? solutionColor.WithAlpha(cappedScale) : args.Sprite.Color.WithAlpha(cappedScale);
-
-            args.Sprite.LayerSetColor(0, newColor);
-
-            // Don't consider wet floor effects if we're using a custom sprite.
-            if (component.CustomPuddleSprite)
-                return;
-
-            if (isEvaporating && currentVolume <= component.WetFloorEffectThreshold)
-            {
-                // If we need the effect but don't already have it - start it
-                if (args.Sprite.LayerGetState(0) != "sparkles")
-                    StartWetFloorEffect(args.Sprite, component.WetFloorEffectAlpha);
-            }
-            else
-            {
-                // If we have the effect but don't need it - end it
-                if (args.Sprite.LayerGetState(0) == "sparkles")
-                    EndWetFloorEffect(args.Sprite, component.OriginalRsi);
-            }
-        }
-
-        private void StartWetFloorEffect(SpriteComponent sprite, float alpha)
-        {
-            sprite.LayerSetState(0, "sparkles", "Fluids/wet_floor_sparkles.rsi");
-            sprite.Color = sprite.Color.WithAlpha(alpha);
-            sprite.LayerSetAutoAnimated(0, false);
-            sprite.LayerSetAutoAnimated(0, true); //fixes a bug where the sparkle effect would sometimes freeze on a single frame.
-        }
-
-        private void EndWetFloorEffect(SpriteComponent sprite, RSI? originalRSI)
-        {
-            RandomizeState(sprite, originalRSI);
-            sprite.LayerSetAutoAnimated(0, false);
-        }
-
-        private void RandomizeState(SpriteComponent sprite, RSI? rsi)
-        {
-            var maxStates = rsi?.ToArray();
-            if (maxStates is not { Length: > 0 }) return;
-
-            var selectedState = _random.Next(0, maxStates.Length - 1); //randomly select an index for which RSI state to use.
-            sprite.LayerSetState(0, maxStates[selectedState].StateId, rsi); // sets the sprite's state via our randomly selected index.
-        }
-
-        private void RandomizeRotation(SpriteComponent sprite)
-        {
-            float rotationDegrees = _random.Next(0, 359); // randomly select a rotation for our puddle sprite.
-            sprite.Rotation = Angle.FromDegrees(rotationDegrees); // sets the sprite's rotation to the one we randomly selected.
-        }
-    }
-}
index d8cb4fc89b62a24824ff75ee00f7230b7ecd1554..b3c9f57daf17b5910c4d008dbfc4c77b89398a6b 100644 (file)
@@ -1,13 +1,4 @@
-<Control xmlns="https://spacestation14.io">
-    <BoxContainer Orientation="Horizontal">
-        <ProgressBar
-            HorizontalExpand="True"
-            Name="PercentBar"
-            MinSize="20 20"
-            VerticalAlignment="Center"
-            Margin="2 8 4 2"
-            MaxValue="1.0"
-            MinValue="0.0">
-        </ProgressBar>
-    </BoxContainer>
-</Control>
+<controls:SplitBar xmlns="https://spacestation14.io"
+                   xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+                   Name="Bar">
+</controls:SplitBar>
index 772be44efd7d7de44ecab454a4f30b2a3b04953f..88c13fab68c74a823584df9ed5d8c1b51abb6e5d 100644 (file)
@@ -1,16 +1,20 @@
-using Content.Shared.Fluids;
+using System.Linq;
+using Content.Client.UserInterface.Controls;
+using Content.Shared.Fluids;
 using Robust.Client.AutoGenerated;
 using Robust.Client.UserInterface;
 using Robust.Client.UserInterface.XAML;
 using Robust.Shared.Timing;
+using Robust.Shared.Utility;
 
 namespace Content.Client.Fluids.UI
 {
     [GenerateTypedNameReferences]
-    public sealed partial class AbsorbentItemStatus : Control
+    public sealed partial class AbsorbentItemStatus : SplitBar
     {
         private readonly IEntityManager _entManager;
         private readonly EntityUid _uid;
+        private Dictionary<Color, float> _progress = new();
 
         public AbsorbentItemStatus(EntityUid uid, IEntityManager entManager)
         {
@@ -25,7 +29,23 @@ namespace Content.Client.Fluids.UI
             if (!_entManager.TryGetComponent<AbsorbentComponent>(_uid, out var absorbent))
                 return;
 
-            PercentBar.Value = absorbent.Progress;
+            var oldProgress = _progress.ShallowClone();
+            _progress.Clear();
+
+            foreach (var item in absorbent.Progress)
+            {
+                _progress[item.Key] = item.Value;
+            }
+
+            if (oldProgress.OrderBy(x => x.Key.ToArgb()).SequenceEqual(_progress))
+                return;
+
+            Bar.Clear();
+
+            foreach (var (key, value) in absorbent.Progress)
+            {
+                Bar.AddEntry(value, key);
+            }
         }
     }
 }
index f6cd53b9634ed0a4d487cf17e1aaef564c19f456..0ba77018905cb01e5a4c90bfe38e245d26ab7f2a 100644 (file)
@@ -15,6 +15,9 @@ namespace Content.Client.IconSmoothing
     [RegisterComponent]
     public sealed class IconSmoothComponent : Component
     {
+        [ViewVariables(VVAccess.ReadWrite), DataField("enabled")]
+        public bool Enabled = true;
+
         public (EntityUid?, Vector2i)? LastPosition;
 
         /// <summary>
index 781089e665723a2ab7e4c29c467132acd1069920..529d0dd1ae283eadd7911a6dfbd271522c165207 100644 (file)
@@ -3,6 +3,7 @@ using JetBrains.Annotations;
 using Robust.Client.GameObjects;
 using Robust.Shared.Map;
 using Robust.Shared.Map.Components;
+using Robust.Shared.Map.Enumerators;
 using static Robust.Client.GameObjects.SpriteComponent;
 
 namespace Content.Client.IconSmoothing
@@ -21,6 +22,15 @@ namespace Content.Client.IconSmoothing
 
         private int _generation;
 
+        public void SetEnabled(EntityUid uid, bool value, IconSmoothComponent? component = null)
+        {
+            if (!Resolve(uid, ref component, false) || value == component.Enabled)
+                return;
+
+            component.Enabled = value;
+            DirtyNeighbours(uid, component);
+        }
+
         public override void Initialize()
         {
             base.Initialize();
@@ -67,6 +77,7 @@ namespace Content.Client.IconSmoothing
 
         private void OnShutdown(EntityUid uid, IconSmoothComponent component, ComponentShutdown args)
         {
+            _dirtyEntities.Enqueue(uid);
             DirtyNeighbours(uid, component);
         }
 
@@ -139,28 +150,28 @@ namespace Content.Client.IconSmoothing
             }
 
             // Yes, we updates ALL smoothing entities surrounding us even if they would never smooth with us.
-            DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(1, 0)));
-            DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(-1, 0)));
-            DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(0, 1)));
-            DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(0, -1)));
+            DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(1, 0)));
+            DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(-1, 0)));
+            DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(0, 1)));
+            DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(0, -1)));
 
             if (comp.Mode is IconSmoothingMode.Corners or IconSmoothingMode.NoSprite or IconSmoothingMode.Diagonal)
             {
-                DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(1, 1)));
-                DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(-1, -1)));
-                DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(-1, 1)));
-                DirtyEntities(grid.GetAnchoredEntities(pos + new Vector2i(1, -1)));
+                DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(1, 1)));
+                DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(-1, -1)));
+                DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(-1, 1)));
+                DirtyEntities(grid.GetAnchoredEntitiesEnumerator(pos + new Vector2i(1, -1)));
             }
         }
 
-        private void DirtyEntities(IEnumerable<EntityUid> entities)
+        private void DirtyEntities(AnchoredEntitiesEnumerator entities)
         {
             // Instead of doing HasComp -> Enqueue -> TryGetComp, we will just enqueue all entities. Generally when
             // dealing with walls neighboring anchored entities will also be walls, and in those instances that will
             // require one less component fetch/check.
-            foreach (var entity in entities)
+            while (entities.MoveNext(out var entity))
             {
-                _dirtyEntities.Enqueue(entity);
+                _dirtyEntities.Enqueue(entity.Value);
             }
         }
 
@@ -184,9 +195,10 @@ namespace Content.Client.IconSmoothing
             // Generation on the component is set after an update so we can cull updates that happened this generation.
             if (!smoothQuery.Resolve(uid, ref smooth, false)
                 || smooth.Mode == IconSmoothingMode.NoSprite
-                || smooth.UpdateGeneration == _generation)
+                || smooth.UpdateGeneration == _generation ||
+                !smooth.Enabled)
             {
-                if (smooth != null &&
+                if (smooth is { Enabled: true } &&
                     TryComp<SmoothEdgeComponent>(uid, out var edge) &&
                     xformQuery.TryGetComponent(uid, out xform))
                 {
@@ -196,13 +208,13 @@ namespace Content.Client.IconSmoothing
                     {
                         var pos = grid.TileIndicesFor(xform.Coordinates);
 
-                        if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.North)), smoothQuery))
+                        if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.North)), smoothQuery))
                             directions |= DirectionFlag.North;
-                        if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.South)), smoothQuery))
+                        if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.South)), smoothQuery))
                             directions |= DirectionFlag.South;
-                        if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.East)), smoothQuery))
+                        if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.East)), smoothQuery))
                             directions |= DirectionFlag.East;
-                        if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.West)), smoothQuery))
+                        if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.West)), smoothQuery))
                             directions |= DirectionFlag.West;
                     }
 
@@ -218,7 +230,7 @@ namespace Content.Client.IconSmoothing
             if (!spriteQuery.TryGetComponent(uid, out var sprite))
             {
                 Logger.Error($"Encountered a icon-smoothing entity without a sprite: {ToPrettyString(uid)}");
-                RemComp(uid, smooth);
+                RemCompDeferred(uid, smooth);
                 return;
             }
 
@@ -270,7 +282,7 @@ namespace Content.Client.IconSmoothing
             for (var i = 0; i < neighbors.Length; i++)
             {
                 var neighbor = (Vector2i) rotation.RotateVec(neighbors[i]);
-                matching = matching && MatchingEntity(smooth, grid.GetAnchoredEntities(pos + neighbor), smoothQuery);
+                matching = matching && MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos + neighbor), smoothQuery);
             }
 
             if (matching)
@@ -294,13 +306,13 @@ namespace Content.Client.IconSmoothing
             }
 
             var pos = grid.TileIndicesFor(xform.Coordinates);
-            if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.North)), smoothQuery))
+            if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.North)), smoothQuery))
                 dirs |= CardinalConnectDirs.North;
-            if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.South)), smoothQuery))
+            if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.South)), smoothQuery))
                 dirs |= CardinalConnectDirs.South;
-            if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.East)), smoothQuery))
+            if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.East)), smoothQuery))
                 dirs |= CardinalConnectDirs.East;
-            if (MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.West)), smoothQuery))
+            if (MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.West)), smoothQuery))
                 dirs |= CardinalConnectDirs.West;
 
             sprite.LayerSetState(0, $"{smooth.StateBase}{(int) dirs}");
@@ -319,12 +331,16 @@ namespace Content.Client.IconSmoothing
             CalculateEdge(sprite.Owner, directions, sprite);
         }
 
-        private bool MatchingEntity(IconSmoothComponent smooth, IEnumerable<EntityUid> candidates, EntityQuery<IconSmoothComponent> smoothQuery)
+        private bool MatchingEntity(IconSmoothComponent smooth, AnchoredEntitiesEnumerator candidates, EntityQuery<IconSmoothComponent> smoothQuery)
         {
-            foreach (var entity in candidates)
+            while (candidates.MoveNext(out var entity))
             {
-                if (smoothQuery.TryGetComponent(entity, out var other) && other.SmoothKey == smooth.SmoothKey)
+                if (smoothQuery.TryGetComponent(entity, out var other) &&
+                    other.SmoothKey == smooth.SmoothKey &&
+                    other.Enabled)
+                {
                     return true;
+                }
             }
 
             return false;
@@ -366,14 +382,14 @@ namespace Content.Client.IconSmoothing
         private (CornerFill ne, CornerFill nw, CornerFill sw, CornerFill se) CalculateCornerFill(MapGridComponent grid, IconSmoothComponent smooth, TransformComponent xform, EntityQuery<IconSmoothComponent> smoothQuery)
         {
             var pos = grid.TileIndicesFor(xform.Coordinates);
-            var n = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.North)), smoothQuery);
-            var ne = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.NorthEast)), smoothQuery);
-            var e = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.East)), smoothQuery);
-            var se = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.SouthEast)), smoothQuery);
-            var s = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.South)), smoothQuery);
-            var sw = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.SouthWest)), smoothQuery);
-            var w = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.West)), smoothQuery);
-            var nw = MatchingEntity(smooth, grid.GetAnchoredEntities(pos.Offset(Direction.NorthWest)), smoothQuery);
+            var n = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.North)), smoothQuery);
+            var ne = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.NorthEast)), smoothQuery);
+            var e = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.East)), smoothQuery);
+            var se = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.SouthEast)), smoothQuery);
+            var s = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.South)), smoothQuery);
+            var sw = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.SouthWest)), smoothQuery);
+            var w = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.West)), smoothQuery);
+            var nw = MatchingEntity(smooth, grid.GetAnchoredEntitiesEnumerator(pos.Offset(Direction.NorthWest)), smoothQuery);
 
             // ReSharper disable InconsistentNaming
             var cornerNE = CornerFill.None;
diff --git a/Content.Client/UserInterface/Controls/SplitBar.xaml b/Content.Client/UserInterface/Controls/SplitBar.xaml
new file mode 100644 (file)
index 0000000..46383cd
--- /dev/null
@@ -0,0 +1,7 @@
+<controls:SplitBar xmlns="https://spacestation14.io"
+                      xmlns:controls="clr-namespace:Content.Client.UserInterface.Controls"
+                      MouseFilter="Stop"
+                      Orientation="Horizontal"
+                      HorizontalExpand="True"
+                      MinSize="0 30">
+</controls:SplitBar>
diff --git a/Content.Client/UserInterface/Controls/SplitBar.xaml.cs b/Content.Client/UserInterface/Controls/SplitBar.xaml.cs
new file mode 100644 (file)
index 0000000..b60cedf
--- /dev/null
@@ -0,0 +1,39 @@
+using Robust.Client.AutoGenerated;
+using Robust.Client.Graphics;
+using Robust.Client.UserInterface.Controls;
+using Robust.Client.UserInterface.XAML;
+
+namespace Content.Client.UserInterface.Controls
+{
+    [GenerateTypedNameReferences]
+    public partial class SplitBar : BoxContainer
+    {
+        public SplitBar()
+        {
+            RobustXamlLoader.Load(this);
+        }
+
+        public void Clear()
+        {
+            DisposeAllChildren();
+        }
+
+        public void AddEntry(float amount, Color color, string? tooltip = null)
+        {
+            AddChild(new PanelContainer
+            {
+                ToolTip = tooltip,
+                HorizontalExpand = true,
+                SizeFlagsStretchRatio = amount,
+                MouseFilter = MouseFilterMode.Stop,
+                PanelOverride = new StyleBoxFlat
+                {
+                    BackgroundColor = color,
+                    PaddingLeft = 2f,
+                    PaddingRight = 2f,
+                },
+                MinSize = new Vector2(24, 0)
+            });
+        }
+    }
+}
index 6631197f814e2592d6a5e66a4dec480f7e94867d..c2a494296afb1f52f59a9fd33297abfd3e73e574 100644 (file)
@@ -113,7 +113,7 @@ public sealed partial class MeleeWeaponSystem
     /// <summary>
     /// Does all of the melee effects for a player that are predicted, i.e. character lunge and weapon animation.
     /// </summary>
-    public override void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation)
+    public override void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation, bool predicted = true)
     {
         if (!Timing.IsFirstTimePredicted)
             return;
index b55febd83928ae259633268080f26f7295ca852e..8d3417c90f7bc8f379ba386a11afd17ed8a76c32 100644 (file)
@@ -4,8 +4,10 @@ using System.Collections.Generic;
 using System.Threading.Tasks;
 using Content.Server.Fluids.Components;
 using Content.Server.Fluids.EntitySystems;
+using Content.Server.Spreader;
 using Content.Shared.Chemistry.Components;
 using Content.Shared.FixedPoint;
+using Content.Shared.Fluids.Components;
 using NUnit.Framework;
 using Robust.Shared.GameObjects;
 using Robust.Shared.Map;
@@ -16,7 +18,7 @@ using Robust.Shared.Timing;
 namespace Content.IntegrationTests.Tests.Fluids;
 
 [TestFixture]
-[TestOf(typeof(FluidSpreaderSystem))]
+[TestOf(typeof(SpreaderSystem))]
 public sealed class FluidSpill
 {
     private static PuddleComponent? GetPuddle(IEntityManager entityManager, MapGridComponent mapGrid, Vector2i pos)
@@ -30,78 +32,6 @@ public sealed class FluidSpill
         return null;
     }
 
-    private readonly Direction[] _dirs =
-    {
-        Direction.East,
-        Direction.South,
-        Direction.West,
-        Direction.North,
-    };
-
-
-    private readonly Vector2i _origin = new(1, 1);
-
-    [Test]
-    public async Task SpillEvenlyTest()
-    {
-        await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings { NoClient = true });
-        var server = pairTracker.Pair.Server;
-        var mapManager = server.ResolveDependency<IMapManager>();
-        var entityManager = server.ResolveDependency<IEntityManager>();
-        var spillSystem = server.ResolveDependency<IEntitySystemManager>().GetEntitySystem<SpillableSystem>();
-        var gameTiming = server.ResolveDependency<IGameTiming>();
-        var puddleSystem = server.ResolveDependency<IEntitySystemManager>().GetEntitySystem<PuddleSystem>();
-        MapId mapId;
-        EntityUid gridId = default;
-
-        await server.WaitPost(() =>
-        {
-            mapId = mapManager.CreateMap();
-            var grid = mapManager.CreateGrid(mapId);
-            gridId = grid.Owner;
-
-            for (var x = 0; x < 3; x++)
-            {
-                for (var y = 0; y < 3; y++)
-                {
-                    grid.SetTile(new Vector2i(x, y), new Tile(1));
-                }
-            }
-        });
-
-        await server.WaitAssertion(() =>
-        {
-            var grid = mapManager.GetGrid(gridId);
-            var solution = new Solution("Water", FixedPoint2.New(100));
-            var tileRef = grid.GetTileRef(_origin);
-            var puddle = spillSystem.SpillAt(tileRef, solution, "PuddleSmear");
-            Assert.That(puddle, Is.Not.Null);
-            Assert.That(GetPuddle(entityManager, grid, _origin), Is.Not.Null);
-        });
-
-        var sTimeToWait = (int) Math.Ceiling(2f * gameTiming.TickRate);
-        await server.WaitRunTicks(sTimeToWait);
-
-        await server.WaitAssertion(() =>
-        {
-            var grid = mapManager.GetGrid(gridId);
-            var puddle = GetPuddle(entityManager, grid, _origin);
-
-            Assert.That(puddle, Is.Not.Null);
-            Assert.That(puddleSystem.CurrentVolume(puddle!.Owner, puddle), Is.EqualTo(FixedPoint2.New(20)));
-
-            foreach (var direction in _dirs)
-            {
-                var newPos = _origin.Offset(direction);
-                var sidePuddle = GetPuddle(entityManager, grid, newPos);
-                Assert.That(sidePuddle, Is.Not.Null);
-                Assert.That(puddleSystem.CurrentVolume(sidePuddle!.Owner, sidePuddle), Is.EqualTo(FixedPoint2.New(20)));
-            }
-        });
-
-        await pairTracker.CleanReturnAsync();
-    }
-
     [Test]
     public async Task SpillCorner()
     {
@@ -109,7 +39,6 @@ public sealed class FluidSpill
         var server = pairTracker.Pair.Server;
         var mapManager = server.ResolveDependency<IMapManager>();
         var entityManager = server.ResolveDependency<IEntityManager>();
-        var spillSystem = server.ResolveDependency<IEntitySystemManager>().GetEntitySystem<SpillableSystem>();
         var puddleSystem = server.ResolveDependency<IEntitySystemManager>().GetEntitySystem<PuddleSystem>();
         var gameTiming = server.ResolveDependency<IGameTiming>();
         MapId mapId;
@@ -117,9 +46,9 @@ public sealed class FluidSpill
 
         /*
          In this test, if o is spillage puddle and # are walls, we want to ensure all tiles are empty (`.`)
-            o # .
-            # . .
             . . .
+            # . .
+            o # .
         */
         await server.WaitPost(() =>
         {
@@ -144,10 +73,9 @@ public sealed class FluidSpill
         await server.WaitAssertion(() =>
         {
             var grid = mapManager.GetGrid(gridId);
-            var solution = new Solution("Water", FixedPoint2.New(100));
+            var solution = new Solution("Blood", FixedPoint2.New(100));
             var tileRef = grid.GetTileRef(puddleOrigin);
-            var puddle = spillSystem.SpillAt(tileRef, solution, "PuddleSmear");
-            Assert.That(puddle, Is.Not.Null);
+            Assert.That(puddleSystem.TrySpillAt(tileRef, solution, out _), Is.True);
             Assert.That(GetPuddle(entityManager, grid, puddleOrigin), Is.Not.Null);
         });
 
index 30f4cd9020d34ef2ba828d8fca6f0594f6384ea1..8e534da6933cfb2c82ebd90234c6973a25889feb 100644 (file)
@@ -5,6 +5,7 @@ using Content.Server.Fluids.EntitySystems;
 using Content.Shared.Chemistry.Components;
 using Content.Shared.Coordinates;
 using Content.Shared.FixedPoint;
+using Content.Shared.Fluids.Components;
 using NUnit.Framework;
 using Robust.Shared.GameObjects;
 using Robust.Shared.Map;
@@ -26,7 +27,7 @@ namespace Content.IntegrationTests.Tests.Fluids
             var testMap = await PoolManager.CreateTestMap(pairTracker);
 
             var entitySystemManager = server.ResolveDependency<IEntitySystemManager>();
-            var spillSystem = entitySystemManager.GetEntitySystem<SpillableSystem>();
+            var spillSystem = entitySystemManager.GetEntitySystem<PuddleSystem>();
 
             await server.WaitAssertion(() =>
             {
@@ -35,9 +36,9 @@ namespace Content.IntegrationTests.Tests.Fluids
                 var gridUid = tile.GridUid;
                 var (x, y) = tile.GridIndices;
                 var coordinates = new EntityCoordinates(gridUid, x, y);
-                var puddle = spillSystem.SpillAt(solution, coordinates, "PuddleSmear");
+                var puddle = spillSystem.TrySpillAt(coordinates, solution, out _);
 
-                Assert.NotNull(puddle);
+                Assert.True(puddle);
             });
             await PoolManager.RunTicksSync(pairTracker.Pair, 5);
 
@@ -53,7 +54,7 @@ namespace Content.IntegrationTests.Tests.Fluids
             var testMap = await PoolManager.CreateTestMap(pairTracker);
 
             var entitySystemManager = server.ResolveDependency<IEntitySystemManager>();
-            var spillSystem = entitySystemManager.GetEntitySystem<SpillableSystem>();
+            var spillSystem = entitySystemManager.GetEntitySystem<PuddleSystem>();
 
             MapGridComponent grid = null;
 
@@ -74,124 +75,11 @@ namespace Content.IntegrationTests.Tests.Fluids
             {
                 var coordinates = grid.ToCoordinates();
                 var solution = new Solution("Water", FixedPoint2.New(20));
-                var puddle = spillSystem.SpillAt(solution, coordinates, "PuddleSmear");
-                Assert.Null(puddle);
+                var puddle = spillSystem.TrySpillAt(coordinates, solution, out _);
+                Assert.False(puddle);
             });
 
             await pairTracker.CleanReturnAsync();
         }
-
-        [Test]
-        public async Task PuddlePauseTest()
-        {
-            await using var pairTracker = await PoolManager.GetServerClient(new PoolSettings{NoClient = true});
-            var server = pairTracker.Pair.Server;
-
-            var sMapManager = server.ResolveDependency<IMapManager>();
-            var sTileDefinitionManager = server.ResolveDependency<ITileDefinitionManager>();
-            var sGameTiming = server.ResolveDependency<IGameTiming>();
-            var entityManager = server.ResolveDependency<IEntityManager>();
-            var metaSystem = entityManager.EntitySysManager.GetEntitySystem<MetaDataSystem>();
-
-            MapId sMapId = default;
-            MapGridComponent sGrid;
-            EntityUid sGridId = default;
-            EntityCoordinates sCoordinates = default;
-
-            // Spawn a paused map with one tile to spawn puddles on
-            await server.WaitPost(() =>
-            {
-                sMapId = sMapManager.CreateMap();
-                sMapManager.SetMapPaused(sMapId, true);
-                sGrid = sMapManager.CreateGrid(sMapId);
-                sGridId = sGrid.Owner;
-                metaSystem.SetEntityPaused(sGridId, true); // See https://github.com/space-wizards/RobustToolbox/issues/1444
-
-                var tileDefinition = sTileDefinitionManager["UnderPlating"];
-                var tile = new Tile(tileDefinition.TileId);
-                sCoordinates = sGrid.ToCoordinates();
-
-                sGrid.SetTile(sCoordinates, tile);
-            });
-
-            // Check that the map and grid are paused
-            await server.WaitAssertion(() =>
-            {
-                Assert.True(metaSystem.EntityPaused(sGridId));
-                Assert.True(sMapManager.IsMapPaused(sMapId));
-            });
-
-            float evaporateTime = default;
-            PuddleComponent puddle = null;
-            MetaDataComponent meta = null;
-            EvaporationComponent evaporation;
-
-            var amount = 2;
-
-            var entitySystemManager = server.ResolveDependency<IEntitySystemManager>();
-            var spillSystem = entitySystemManager.GetEntitySystem<SpillableSystem>();
-
-            // Spawn a puddle
-            await server.WaitAssertion(() =>
-            {
-                var solution = new Solution("Water", FixedPoint2.New(amount));
-                puddle = spillSystem.SpillAt(solution, sCoordinates, "PuddleSmear");
-                meta = entityManager.GetComponent<MetaDataComponent>(puddle.Owner);
-
-                // Check that the puddle was created
-                Assert.NotNull(puddle);
-
-                evaporation = entityManager.GetComponent<EvaporationComponent>(puddle.Owner);
-                metaSystem.SetEntityPaused(puddle.Owner, true, meta); // See https://github.com/space-wizards/RobustToolbox/issues/1445
-
-                Assert.True(metaSystem.EntityPaused(puddle.Owner, meta));
-
-                // Check that the puddle is going to evaporate
-                Assert.Positive(evaporation.EvaporateTime);
-
-                // Should have a timer component added to it for evaporation
-                Assert.That(evaporation.Accumulator, Is.EqualTo(0f));
-
-                evaporateTime = evaporation.EvaporateTime;
-            });
-
-            // Wait enough time for it to evaporate if it was unpaused
-            var sTimeToWait = 5 + (int)Math.Ceiling(amount * evaporateTime * sGameTiming.TickRate);
-            await PoolManager.RunTicksSync(pairTracker.Pair, sTimeToWait);
-
-            // No evaporation due to being paused
-            await server.WaitAssertion(() =>
-            {
-                Assert.True(meta.EntityPaused);
-
-                // Check that the puddle still exists
-                Assert.False(meta.EntityDeleted);
-            });
-
-            // Unpause the map
-            await server.WaitPost(() => { sMapManager.SetMapPaused(sMapId, false); });
-
-            // Check that the map, grid and puddle are unpaused
-            await server.WaitAssertion(() =>
-            {
-                Assert.False(sMapManager.IsMapPaused(sMapId));
-                Assert.False(metaSystem.EntityPaused(sGridId));
-                Assert.False(meta.EntityPaused);
-
-                // Check that the puddle still exists
-                Assert.False(meta.EntityDeleted);
-            });
-
-            // Wait enough time for it to evaporate
-            await PoolManager.RunTicksSync(pairTracker.Pair, sTimeToWait);
-
-            // Puddle evaporation should have ticked
-            await server.WaitAssertion(() =>
-            {
-                // Check that puddle has been deleted
-                Assert.True(puddle.Deleted);
-            });
-            await pairTracker.CleanReturnAsync();
-        }
     }
 }
index 7a347ffb6fae2e2f34026a87b0528bdacac13bd1..444d3cc47b3d48ae99f890ae501b7811dc77b37e 100644 (file)
@@ -25,11 +25,10 @@ namespace Content.Server.AME
         [ViewVariables]
         private AMEControllerComponent? _masterController;
 
-        [Dependency] private readonly IRobustRandom _random = default!;
-
-        [Dependency] private readonly IEntityManager _entMan = default!;
-
         [Dependency] private readonly IChatManager _chat = default!;
+        [Dependency] private readonly IEntityManager _entMan = default!;
+        [Dependency] private readonly IMapManager _mapManager = default!;
+        [Dependency] private readonly IRobustRandom _random = default!;
 
         public AMEControllerComponent? MasterController => _masterController;
 
@@ -41,7 +40,6 @@ namespace Content.Server.AME
         {
             base.LoadNodes(groupNodes);
 
-            var mapManager = IoCManager.Resolve<IMapManager>();
             MapGridComponent? grid = null;
 
             foreach (var node in groupNodes)
@@ -50,7 +48,7 @@ namespace Content.Server.AME
                 if (_entMan.TryGetComponent(nodeOwner, out AMEShieldComponent? shield))
                 {
                     var xform = _entMan.GetComponent<TransformComponent>(nodeOwner);
-                    if (xform.GridUid != grid?.Owner && !mapManager.TryGetGrid(xform.GridUid, out grid))
+                    if (xform.GridUid != grid?.Owner && !_mapManager.TryGetGrid(xform.GridUid, out grid))
                         continue;
 
                     if (grid == null)
index dd774b8999ec4f841dfca17dba6db4a74f76701d..ce4d1762baa0911514a19a5aba7955d9c484d76d 100644 (file)
@@ -4,6 +4,7 @@ using Content.Server.Chemistry.EntitySystems;
 using Content.Server.DoAfter;
 using Content.Server.Nutrition.Components;
 using Content.Server.Popups;
+using Content.Shared.Chemistry.Components;
 using Content.Shared.DoAfter;
 using Content.Shared.IdentityManagement;
 using Content.Shared.Nutrition.Components;
index 6d4bb783fff680fec92a64a753ef681f4031a0c3..57ffcac28a2ba6ae4b20dbfd7e22fb4a7c3dd794 100644 (file)
@@ -17,25 +17,26 @@ namespace Content.Server.Atmos.Reactions
 
         [DataField("molesPerUnit")] public float MolesPerUnit { get; } = 1;
 
-        [DataField("puddlePrototype")] public string? PuddlePrototype { get; } = "PuddleSmear";
-
         public ReactionResult React(GasMixture mixture, IGasMixtureHolder? holder, AtmosphereSystem atmosphereSystem)
         {
             // If any of the prototypes is invalid, we do nothing.
-            if (string.IsNullOrEmpty(Reagent) || string.IsNullOrEmpty(PuddlePrototype)) return ReactionResult.NoReaction;
+            if (string.IsNullOrEmpty(Reagent))
+                return ReactionResult.NoReaction;
 
             // If we're not reacting on a tile, do nothing.
-            if (holder is not TileAtmosphere tile) return ReactionResult.NoReaction;
+            if (holder is not TileAtmosphere tile)
+                return ReactionResult.NoReaction;
 
             // If we don't have enough moles of the specified gas, do nothing.
-            if (mixture.GetMoles(GasId) < MolesPerUnit) return ReactionResult.NoReaction;
+            if (mixture.GetMoles(GasId) < MolesPerUnit)
+                return ReactionResult.NoReaction;
 
             // Remove the moles from the mixture...
             mixture.AdjustMoles(GasId, -MolesPerUnit);
 
             var tileRef = tile.GridIndices.GetTileRef(tile.GridIndex);
-            EntitySystem.Get<SpillableSystem>()
-                .SpillAt(tileRef, new Solution(Reagent, FixedPoint2.New(MolesPerUnit)), PuddlePrototype, sound: false);
+            EntitySystem.Get<PuddleSystem>()
+                .TrySpillAt(tileRef, new Solution(Reagent, FixedPoint2.New(MolesPerUnit)), out _, sound: false);
 
             return ReactionResult.Reacting;
         }
index f152c6b08819584471249a1549bf387f571327ec..b5958d8561e32c26d145b72476277efb37b7ce94 100644 (file)
@@ -26,15 +26,15 @@ namespace Content.Server.Body.Systems;
 
 public sealed class BloodstreamSystem : EntitySystem
 {
-    [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
-    [Dependency] private readonly DamageableSystem _damageableSystem = default!;
-    [Dependency] private readonly SpillableSystem _spillableSystem = default!;
-    [Dependency] private readonly PopupSystem _popupSystem = default!;
-    [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
     [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
     [Dependency] private readonly IRobustRandom _robustRandom = default!;
     [Dependency] private readonly AudioSystem _audio = default!;
+    [Dependency] private readonly DamageableSystem _damageableSystem = default!;
+    [Dependency] private readonly PopupSystem _popupSystem = default!;
+    [Dependency] private readonly PuddleSystem _puddleSystem = default!;
+    [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
     [Dependency] private readonly SharedDrunkSystem _drunkSystem = default!;
+    [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
 
     public override void Initialize()
     {
@@ -307,12 +307,13 @@ public sealed class BloodstreamSystem : EntitySystem
             // Pass some of the chemstream into the spilled blood.
             var temp = component.ChemicalSolution.SplitSolution(component.BloodTemporarySolution.Volume / 10);
             component.BloodTemporarySolution.AddSolution(temp, _prototypeManager);
-            var puddle = _spillableSystem.SpillAt(uid, component.BloodTemporarySolution, "PuddleBlood", false);
-            if (puddle != null)
+            if (_puddleSystem.TrySpillAt(uid, component.BloodTemporarySolution, out var puddleUid, false))
             {
-                var comp = EnsureComp<ForensicsComponent>(puddle.Owner); //TODO: Get rid of .Owner
                 if (TryComp<DnaComponent>(uid, out var dna))
+                {
+                    var comp = EnsureComp<ForensicsComponent>(puddleUid);
                     comp.DNAs.Add(dna.DNA);
+                }
             }
 
             component.BloodTemporarySolution.RemoveAllSolution();
@@ -353,13 +354,14 @@ public sealed class BloodstreamSystem : EntitySystem
         component.BloodTemporarySolution.RemoveAllSolution();
         tempSol.AddSolution(component.ChemicalSolution, _prototypeManager);
         component.ChemicalSolution.RemoveAllSolution();
-        var puddle = _spillableSystem.SpillAt(uid, tempSol, "PuddleBlood", true);
 
-        if (puddle != null)
+        if (_puddleSystem.TrySpillAt(uid, tempSol, out var puddleUid))
         {
-            var comp = EnsureComp<ForensicsComponent>(puddle.Owner); //TODO: Get rid of .Owner
             if (TryComp<DnaComponent>(uid, out var dna))
+            {
+                var comp = EnsureComp<ForensicsComponent>(puddleUid);
                 comp.DNAs.Add(dna.DNA);
+            }
         }
     }
 }
diff --git a/Content.Server/Chemistry/Components/FoamSolutionAreaEffectComponent.cs b/Content.Server/Chemistry/Components/FoamSolutionAreaEffectComponent.cs
deleted file mode 100644 (file)
index 380f92c..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-using Content.Server.Body.Components;
-using Content.Server.Body.Systems;
-using Content.Server.Chemistry.EntitySystems;
-using Content.Shared.Administration.Logs;
-using Content.Shared.Database;
-using Content.Shared.FixedPoint;
-using Content.Shared.Foam;
-using Content.Shared.Inventory;
-using Robust.Shared.Prototypes;
-
-namespace Content.Server.Chemistry.Components
-{
-    [RegisterComponent]
-    [ComponentReference(typeof(SolutionAreaEffectComponent))]
-    public sealed class FoamSolutionAreaEffectComponent : SolutionAreaEffectComponent
-    {
-        [Dependency] private readonly IEntityManager _entMan = default!;
-        [Dependency] private readonly IPrototypeManager _proto = default!;
-        [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
-
-        public new const string SolutionName = "solutionArea";
-
-        [DataField("foamedMetalPrototype")] private string? _foamedMetalPrototype;
-
-        protected override void UpdateVisuals()
-        {
-            if (_entMan.TryGetComponent(Owner, out AppearanceComponent? appearance) &&
-                EntitySystem.Get<SolutionContainerSystem>().TryGetSolution(Owner, SolutionName, out var solution))
-            {
-                appearance.SetData(FoamVisuals.Color, solution.GetColor(_proto).WithAlpha(0.80f));
-            }
-        }
-
-        protected override void ReactWithEntity(EntityUid entity, double solutionFraction)
-        {
-            if (!EntitySystem.Get<SolutionContainerSystem>().TryGetSolution(Owner, SolutionName, out var solution))
-                return;
-
-            if (!_entMan.TryGetComponent(entity, out BloodstreamComponent? bloodstream))
-                return;
-
-            var invSystem = EntitySystem.Get<InventorySystem>();
-
-            // TODO: Add a permeability property to clothing
-            // For now it just adds to protection for each clothing equipped
-            var protection = 0f;
-            if (invSystem.TryGetSlots(entity, out var slotDefinitions))
-            {
-                foreach (var slot in slotDefinitions)
-                {
-                    if (slot.Name == "back" ||
-                        slot.Name == "pocket1" ||
-                        slot.Name == "pocket2" ||
-                        slot.Name == "id")
-                        continue;
-
-                    if (invSystem.TryGetSlotEntity(entity, slot.Name, out _))
-                        protection += 0.025f;
-                }
-            }
-
-            var bloodstreamSys = EntitySystem.Get<BloodstreamSystem>();
-
-            var cloneSolution = solution.Clone();
-            var transferAmount = FixedPoint2.Min(cloneSolution.Volume * solutionFraction * (1 - protection),
-                bloodstream.ChemicalSolution.AvailableVolume);
-            var transferSolution = cloneSolution.SplitSolution(transferAmount);
-
-            if (bloodstreamSys.TryAddToChemicals(entity, transferSolution, bloodstream))
-            {
-                // Log solution addition by foam
-                _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{_entMan.ToPrettyString(entity):target} was affected by foam {SolutionContainerSystem.ToPrettyString(transferSolution)}");
-            }
-        }
-
-        protected override void OnKill()
-        {
-            if (_entMan.Deleted(Owner))
-                return;
-            if (_entMan.TryGetComponent(Owner, out AppearanceComponent? appearance))
-            {
-                appearance.SetData(FoamVisuals.State, true);
-            }
-
-            Owner.SpawnTimer(600, () =>
-            {
-                if (!string.IsNullOrEmpty(_foamedMetalPrototype))
-                {
-                    _entMan.SpawnEntity(_foamedMetalPrototype, _entMan.GetComponent<TransformComponent>(Owner).Coordinates);
-                }
-
-                _entMan.QueueDeleteEntity(Owner);
-            });
-        }
-    }
-}
diff --git a/Content.Server/Chemistry/Components/SmokeComponent.cs b/Content.Server/Chemistry/Components/SmokeComponent.cs
new file mode 100644 (file)
index 0000000..088287b
--- /dev/null
@@ -0,0 +1,26 @@
+using Content.Shared.Fluids.Components;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Chemistry.Components;
+
+/// <summary>
+/// Stores solution on an anchored entity that has touch and ingestion reactions
+/// to entities that collide with it. Similar to <see cref="PuddleComponent"/>
+/// </summary>
+[RegisterComponent]
+public sealed class SmokeComponent : Component
+{
+    public const string SolutionName = "solutionArea";
+
+    [DataField("nextReact", customTypeSerializer:typeof(TimeOffsetSerializer))]
+    public TimeSpan NextReact = TimeSpan.Zero;
+
+    [DataField("spreadAmount")]
+    public int SpreadAmount = 0;
+
+    /// <summary>
+    ///     Have we reacted with our tile yet?
+    /// </summary>
+    [DataField("reactedTile")]
+    public bool ReactedTile = false;
+}
diff --git a/Content.Server/Chemistry/Components/SmokeDissipateSpawnComponent.cs b/Content.Server/Chemistry/Components/SmokeDissipateSpawnComponent.cs
new file mode 100644 (file)
index 0000000..a791880
--- /dev/null
@@ -0,0 +1,16 @@
+using Content.Server.Fluids.EntitySystems;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
+
+namespace Content.Server.Chemistry.Components;
+
+
+/// <summary>
+/// When a <see cref="SmokeComponent"/> despawns this will spawn another entity in its place.
+/// </summary>
+[RegisterComponent, Access(typeof(SmokeSystem))]
+public sealed class SmokeDissipateSpawnComponent : Component
+{
+    [DataField("prototype", required: true, customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
+    public string Prototype = string.Empty;
+}
diff --git a/Content.Server/Chemistry/Components/SmokeSolutionAreaEffectComponent.cs b/Content.Server/Chemistry/Components/SmokeSolutionAreaEffectComponent.cs
deleted file mode 100644 (file)
index 2aae150..0000000
+++ /dev/null
@@ -1,72 +0,0 @@
-using Content.Server.Body.Components;
-using Content.Server.Body.Systems;
-using Content.Server.Chemistry.EntitySystems;
-using Content.Shared.Administration.Logs;
-using Content.Shared.Chemistry;
-using Content.Shared.Chemistry.Reagent;
-using Content.Shared.Database;
-using Content.Shared.FixedPoint;
-using Content.Shared.Smoking;
-using Robust.Shared.Prototypes;
-
-namespace Content.Server.Chemistry.Components
-{
-    [RegisterComponent]
-    [ComponentReference(typeof(SolutionAreaEffectComponent))]
-    public sealed class SmokeSolutionAreaEffectComponent : SolutionAreaEffectComponent
-    {
-        [Dependency] private readonly IEntityManager _entMan = default!;
-        [Dependency] private readonly IPrototypeManager _proto = default!;
-        [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
-
-        public new const string SolutionName = "solutionArea";
-
-        protected override void UpdateVisuals()
-        {
-            if (_entMan.TryGetComponent(Owner, out AppearanceComponent? appearance) &&
-                EntitySystem.Get<SolutionContainerSystem>().TryGetSolution(Owner, SolutionName, out var solution))
-            {
-                appearance.SetData(SmokeVisuals.Color, solution.GetColor(_proto));
-            }
-        }
-
-        protected override void ReactWithEntity(EntityUid entity, double solutionFraction)
-        {
-            if (!EntitySystem.Get<SolutionContainerSystem>().TryGetSolution(Owner, SolutionName, out var solution))
-                return;
-
-            if (!_entMan.TryGetComponent(entity, out BloodstreamComponent? bloodstream))
-                return;
-
-            if (_entMan.TryGetComponent(entity, out InternalsComponent? internals) &&
-                IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<InternalsSystem>().AreInternalsWorking(internals))
-                return;
-
-            var chemistry = EntitySystem.Get<ReactiveSystem>();
-            var cloneSolution = solution.Clone();
-            var transferAmount = FixedPoint2.Min(cloneSolution.Volume * solutionFraction, bloodstream.ChemicalSolution.AvailableVolume);
-            var transferSolution = cloneSolution.SplitSolution(transferAmount);
-
-            foreach (var reagentQuantity in transferSolution.Contents.ToArray())
-            {
-                if (reagentQuantity.Quantity == FixedPoint2.Zero) continue;
-                chemistry.ReactionEntity(entity, ReactionMethod.Ingestion, reagentQuantity.ReagentId, reagentQuantity.Quantity, transferSolution);
-            }
-
-            var bloodstreamSys = EntitySystem.Get<BloodstreamSystem>();
-            if (bloodstreamSys.TryAddToChemicals(entity, transferSolution, bloodstream))
-            {
-                // Log solution addition by smoke
-                _adminLogger.Add(LogType.ForceFeed, LogImpact.Medium, $"{_entMan.ToPrettyString(entity):target} was affected by smoke {SolutionContainerSystem.ToPrettyString(transferSolution)}");
-            }
-        }
-
-
-        protected override void OnKill()
-        {
-            if (_entMan.Deleted(Owner))
-                return;
-            _entMan.DeleteEntity(Owner);
-        }
-    }
-}
diff --git a/Content.Server/Chemistry/Components/SolutionAreaEffectComponent.cs b/Content.Server/Chemistry/Components/SolutionAreaEffectComponent.cs
deleted file mode 100644 (file)
index 459c07d..0000000
+++ /dev/null
@@ -1,219 +0,0 @@
-using System.Linq;
-using Content.Server.Atmos.Components;
-using Content.Server.Chemistry.EntitySystems;
-using Content.Shared.Chemistry;
-using Content.Shared.Chemistry.Components;
-using Content.Shared.Chemistry.Reagent;
-using Content.Shared.FixedPoint;
-using Robust.Shared.Map;
-using Robust.Shared.Map.Components;
-using Robust.Shared.Prototypes;
-using Robust.Shared.Utility;
-
-namespace Content.Server.Chemistry.Components
-{
-    /// <summary>
-    /// Used to clone its owner repeatedly and group up them all so they behave like one unit, that way you can have
-    /// effects that cover an area. Inherited by <see cref="SmokeSolutionAreaEffectComponent"/> and <see cref="FoamSolutionAreaEffectComponent"/>.
-    /// </summary>
-    public abstract class SolutionAreaEffectComponent : Component
-    {
-        public const string SolutionName = "solutionArea";
-
-        [Dependency] protected readonly IMapManager MapManager = default!;
-        [Dependency] protected readonly IPrototypeManager PrototypeManager = default!;
-        [Dependency] private readonly IEntityManager _entities = default!;
-        [Dependency] private readonly IEntitySystemManager _systems = default!;
-
-        public int Amount { get; set; }
-        public SolutionAreaEffectInceptionComponent? Inception { get; set; }
-
-        /// <summary>
-        ///     Have we reacted with our tile yet?
-        /// </summary>
-        public bool ReactedTile = false;
-
-        /// <summary>
-        /// Adds an <see cref="SolutionAreaEffectInceptionComponent"/> to owner so the effect starts spreading and reacting.
-        /// </summary>
-        /// <param name="amount">The range of the effect</param>
-        /// <param name="duration"></param>
-        /// <param name="spreadDelay"></param>
-        /// <param name="removeDelay"></param>
-        public void Start(int amount, float duration, float spreadDelay, float removeDelay)
-        {
-            if (Inception != null)
-                return;
-
-            if (_entities.HasComponent<SolutionAreaEffectInceptionComponent>(Owner))
-                return;
-
-            Amount = amount;
-            var inception = _entities.AddComponent<SolutionAreaEffectInceptionComponent>(Owner);
-
-            inception.Add(this);
-            inception.Setup(amount, duration, spreadDelay, removeDelay);
-        }
-
-        /// <summary>
-        /// Gets called by an AreaEffectInceptionComponent. "Clones" Owner into the four directions and copies the
-        /// solution into each of them.
-        /// </summary>
-        public void Spread()
-        {
-            var meta = _entities.GetComponent<MetaDataComponent>(Owner);
-            if (meta.EntityPrototype == null)
-            {
-                Logger.Error("AreaEffectComponent needs its owner to be spawned by a prototype.");
-                return;
-            }
-
-            var xform = _entities.GetComponent<TransformComponent>(Owner);
-            var solSys = _systems.GetEntitySystem<SolutionContainerSystem>();
-
-            if (!_entities.TryGetComponent(xform.GridUid, out MapGridComponent? gridComp))
-                return;
-
-            var origin = gridComp.TileIndicesFor(xform.Coordinates);
-
-            DebugTools.Assert(xform.Anchored, "Area effect entity prototypes must be anchored.");
-
-            void SpreadToDir(Direction dir)
-            {
-                // Currently no support for spreading off or across grids.
-                var index = origin + dir.ToIntVec();
-                if (!gridComp.TryGetTileRef(index, out var tile) || tile.Tile.IsEmpty)
-                    return;
-
-                foreach (var neighbor in gridComp.GetAnchoredEntities(index))
-                {
-                    if (_entities.TryGetComponent(neighbor,
-                        out SolutionAreaEffectComponent? comp) && comp.Inception == Inception)
-                        return;
-
-                    // TODO for thindows and the like, need to check the directions that are being blocked.
-                    // --> would then also mean you need to check for blockers on the origin tile.
-                    if (_entities.TryGetComponent(neighbor,
-                        out AirtightComponent? airtight) && airtight.AirBlocked)
-                        return;
-                }
-
-                var newEffect = _entities.SpawnEntity(
-                    meta.EntityPrototype.ID,
-                    gridComp.GridTileToLocal(index));
-
-                if (!_entities.TryGetComponent(newEffect, out SolutionAreaEffectComponent? effectComponent))
-                {
-                    _entities.DeleteEntity(newEffect);
-                    return;
-                }
-
-                if (solSys.TryGetSolution(Owner, SolutionName, out var solution))
-                {
-                    effectComponent.TryAddSolution(solution.Clone());
-                }
-
-                effectComponent.Amount = Amount - 1;
-                Inception?.Add(effectComponent);
-            }
-
-            SpreadToDir(Direction.North);
-            SpreadToDir(Direction.East);
-            SpreadToDir(Direction.South);
-            SpreadToDir(Direction.West);
-        }
-
-        /// <summary>
-        /// Gets called by an AreaEffectInceptionComponent.
-        /// Removes this component from its inception and calls OnKill(). The implementation of OnKill() should
-        /// eventually delete the entity.
-        /// </summary>
-        public void Kill()
-        {
-            Inception?.Remove(this);
-            OnKill();
-        }
-
-        protected abstract void OnKill();
-
-        /// <summary>
-        /// Gets called by an AreaEffectInceptionComponent.
-        /// Makes this effect's reagents react with the tile its on and with the entities it covers. Also calls
-        /// ReactWithEntity on the entities so inheritors can implement more specific behavior.
-        /// </summary>
-        /// <param name="averageExposures">How many times will this get called over this area effect's duration, averaged
-        /// with the other area effects from the inception.</param>
-        public void React(float averageExposures)
-        {
-            if (!_entities.EntitySysManager.GetEntitySystem<SolutionContainerSystem>()
-                    .TryGetSolution(Owner, SolutionName, out var solution) ||
-                solution.Contents.Count == 0)
-            {
-                return;
-            }
-
-            var xform = _entities.GetComponent<TransformComponent>(Owner);
-            if (!MapManager.TryGetGrid(xform.GridUid, out var mapGrid))
-                return;
-
-            var tile = mapGrid.GetTileRef(xform.Coordinates.ToVector2i(_entities, MapManager));
-            var chemistry = _entities.EntitySysManager.GetEntitySystem<ReactiveSystem>();
-            var lookup = _entities.EntitySysManager.GetEntitySystem<EntityLookupSystem>();
-
-            var solutionFraction = 1 / Math.Floor(averageExposures);
-            var ents = lookup.GetEntitiesIntersecting(tile, LookupFlags.Uncontained).ToArray();
-
-            foreach (var reagentQuantity in solution.Contents.ToArray())
-            {
-                if (reagentQuantity.Quantity == FixedPoint2.Zero) continue;
-                var reagent = PrototypeManager.Index<ReagentPrototype>(reagentQuantity.ReagentId);
-
-                // React with the tile the effect is on
-                // We don't multiply by solutionFraction here since the tile is only ever reacted once
-                if (!ReactedTile)
-                {
-                    reagent.ReactionTile(tile, reagentQuantity.Quantity);
-                    ReactedTile = true;
-                }
-
-                // Touch every entity on the tile
-                foreach (var entity in ents)
-                {
-                    chemistry.ReactionEntity(entity, ReactionMethod.Touch, reagent,
-                        reagentQuantity.Quantity * solutionFraction, solution);
-                }
-            }
-
-            foreach (var entity in ents)
-            {
-                ReactWithEntity(entity, solutionFraction);
-            }
-        }
-
-        protected abstract void ReactWithEntity(EntityUid entity, double solutionFraction);
-
-        public void TryAddSolution(Solution solution)
-        {
-            if (solution.Volume == 0)
-                return;
-
-            if (!EntitySystem.Get<SolutionContainerSystem>().TryGetSolution(Owner, SolutionName, out var solutionArea))
-                return;
-
-            var addSolution =
-                solution.SplitSolution(FixedPoint2.Min(solution.Volume, solutionArea.AvailableVolume));
-
-            EntitySystem.Get<SolutionContainerSystem>().TryAddSolution(Owner, solutionArea, addSolution);
-
-            UpdateVisuals();
-        }
-
-        protected abstract void UpdateVisuals();
-
-        protected override void OnRemove()
-        {
-            base.OnRemove();
-            Inception?.Remove(this);
-        }
-    }
-}
diff --git a/Content.Server/Chemistry/Components/SolutionAreaEffectInceptionComponent.cs b/Content.Server/Chemistry/Components/SolutionAreaEffectInceptionComponent.cs
deleted file mode 100644 (file)
index a03da22..0000000
+++ /dev/null
@@ -1,136 +0,0 @@
-using System.Linq;
-
-namespace Content.Server.Chemistry.Components
-{
-    /// <summary>
-    /// The "mastermind" of a SolutionAreaEffect group. It gets updated by the SolutionAreaEffectSystem and tells the
-    /// group when to spread, react and remove itself. This makes the group act like a single unit.
-    /// </summary>
-    /// <remarks> It should only be manually added to an entity by the <see cref="SolutionAreaEffectComponent"/> and not with a prototype.</remarks>
-    [RegisterComponent]
-    public sealed class SolutionAreaEffectInceptionComponent : Component
-    {
-        private const float ReactionDelay = 1.5f;
-
-        private readonly HashSet<SolutionAreaEffectComponent> _group = new();
-
-        [ViewVariables] private float _lifeTimer;
-        [ViewVariables] private float _spreadTimer;
-        [ViewVariables] private float _reactionTimer;
-
-        [ViewVariables] private int _amountCounterSpreading;
-        [ViewVariables] private int _amountCounterRemoving;
-
-        /// <summary>
-        /// How much time to wait after fully spread before starting to remove itself.
-        /// </summary>
-        [ViewVariables] private float _duration;
-
-        /// <summary>
-        /// Time between each spread step. Decreasing this makes spreading faster.
-        /// </summary>
-        [ViewVariables] private float _spreadDelay;
-
-        /// <summary>
-        /// Time between each remove step. Decreasing this makes removing faster.
-        /// </summary>
-        [ViewVariables] private float _removeDelay;
-
-        /// <summary>
-        /// How many times will the effect react. As some entities from the group last a different amount of time than
-        /// others, they will react a different amount of times, so we calculate the average to make the group behave
-        /// a bit more uniformly.
-        /// </summary>
-        [ViewVariables] private float _averageExposures;
-
-        public void Setup(int amount, float duration, float spreadDelay, float removeDelay)
-        {
-            _amountCounterSpreading = amount;
-            _duration = duration;
-            _spreadDelay = spreadDelay;
-            _removeDelay = removeDelay;
-
-            // So the first square reacts immediately after spawning
-            _reactionTimer = ReactionDelay;
-            /*
-            The group takes amount*spreadDelay seconds to fully spread, same with fully disappearing.
-            The outer squares will last duration seconds.
-            The first square will last duration + how many seconds the group takes to fully spread and fully disappear, so
-            it will last duration + amount*(spreadDelay+removeDelay).
-            Thus, the average lifetime of the smokes will be (outerSmokeLifetime + firstSmokeLifetime)/2 = duration + amount*(spreadDelay+removeDelay)/2
-            */
-            _averageExposures = (duration + amount * (spreadDelay+removeDelay) / 2)/ReactionDelay;
-        }
-
-        public void InceptionUpdate(float frameTime)
-        {
-            _group.RemoveWhere(effect => effect.Deleted);
-            if (_group.Count == 0)
-                return;
-
-            // Make every outer square from the group spread
-            if (_amountCounterSpreading > 0)
-            {
-                _spreadTimer += frameTime;
-                if (_spreadTimer > _spreadDelay)
-                {
-                    _spreadTimer -= _spreadDelay;
-
-                    var outerEffects = new HashSet<SolutionAreaEffectComponent>(_group.Where(effect => effect.Amount == _amountCounterSpreading));
-                    foreach (var effect in outerEffects)
-                    {
-                        effect.Spread();
-                    }
-
-                    _amountCounterSpreading -= 1;
-                }
-            }
-            // Start counting for _duration after fully spreading
-            else
-            {
-                _lifeTimer += frameTime;
-            }
-
-            // Delete every outer square
-            if (_lifeTimer > _duration)
-            {
-                _spreadTimer += frameTime;
-                if (_spreadTimer > _removeDelay)
-                {
-                    _spreadTimer -= _removeDelay;
-
-                    var outerEffects = new HashSet<SolutionAreaEffectComponent>(_group.Where(effect => effect.Amount == _amountCounterRemoving));
-                    foreach (var effect in outerEffects)
-                    {
-                        effect.Kill();
-                    }
-
-                    _amountCounterRemoving += 1;
-                }
-            }
-
-            // Make every square from the group react with the tile and entities
-            _reactionTimer += frameTime;
-            if (_reactionTimer > ReactionDelay)
-            {
-                _reactionTimer -= ReactionDelay;
-                foreach (var effect in _group)
-                {
-                    effect.React(_averageExposures);
-                }
-            }
-        }
-
-        public void Add(SolutionAreaEffectComponent effect)
-        {
-            _group.Add(effect);
-            effect.Inception = this;
-        }
-
-        public void Remove(SolutionAreaEffectComponent effect)
-        {
-            _group.Remove(effect);
-            effect.Inception = null;
-        }
-    }
-}
diff --git a/Content.Server/Chemistry/Components/SolutionManager/DrainableSolutionComponent.cs b/Content.Server/Chemistry/Components/SolutionManager/DrainableSolutionComponent.cs
deleted file mode 100644 (file)
index 7d33b5d..0000000
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace Content.Server.Chemistry.Components.SolutionManager
-{
-    /// <summary>
-    ///     Denotes the solution that can be easily removed through any reagent container.
-    ///     Think pouring this or draining from a water tank.
-    /// </summary>
-    [RegisterComponent]
-    public sealed class DrainableSolutionComponent : Component
-    {
-        /// <summary>
-        /// Solution name that can be drained.
-        /// </summary>
-        [ViewVariables(VVAccess.ReadWrite)]
-        [DataField("solution")]
-        public string Solution { get; set; } = "default";
-    }
-}
diff --git a/Content.Server/Chemistry/Components/SolutionManager/RefillableSolutionComponent.cs b/Content.Server/Chemistry/Components/SolutionManager/RefillableSolutionComponent.cs
deleted file mode 100644 (file)
index 4389608..0000000
+++ /dev/null
@@ -1,29 +0,0 @@
-using Content.Shared.FixedPoint;
-
-namespace Content.Server.Chemistry.Components.SolutionManager
-{
-    /// <summary>
-    ///     Reagents that can be added easily. For example like
-    ///     pouring something into another beaker, glass, or into the gas
-    ///     tank of a car.
-    /// </summary>
-    [RegisterComponent]
-    public sealed class RefillableSolutionComponent : Component
-    {
-        /// <summary>
-        /// Solution name that can added to easily.
-        /// </summary>
-        [ViewVariables(VVAccess.ReadWrite)]
-        [DataField("solution")]
-        public string Solution { get; set; } = "default";
-
-        /// <summary>
-        /// The maximum amount that can be transferred to the solution at once
-        /// </summary>
-        [DataField("maxRefill")]
-        [ViewVariables(VVAccess.ReadWrite)]
-        public FixedPoint2? MaxRefill { get; set; } = null;
-
-
-    }
-}
diff --git a/Content.Server/Chemistry/EntitySystems/SolutionAreaEffectSystem.cs b/Content.Server/Chemistry/EntitySystems/SolutionAreaEffectSystem.cs
deleted file mode 100644 (file)
index 2064a1e..0000000
+++ /dev/null
@@ -1,43 +0,0 @@
-using System.Linq;
-using Content.Server.Chemistry.Components;
-using Content.Server.Chemistry.ReactionEffects;
-using Content.Shared.Chemistry.Reaction;
-using JetBrains.Annotations;
-
-namespace Content.Server.Chemistry.EntitySystems
-{
-    [UsedImplicitly]
-    public sealed class SolutionAreaEffectSystem : EntitySystem
-    {
-        public override void Initialize()
-        {
-            base.Initialize();
-
-            SubscribeLocalEvent<SolutionAreaEffectComponent, ReactionAttemptEvent>(OnReactionAttempt);
-        }
-
-        public override void Update(float frameTime)
-        {
-            foreach (var inception in EntityManager.EntityQuery<SolutionAreaEffectInceptionComponent>().ToArray())
-            {
-                inception.InceptionUpdate(frameTime);
-            }
-        }
-
-        private void OnReactionAttempt(EntityUid uid, SolutionAreaEffectComponent component, ReactionAttemptEvent args)
-        {
-            if (args.Solution.Name != SolutionAreaEffectComponent.SolutionName)
-                return;
-
-            // Prevent smoke/foam fork bombs (smoke creating more smoke).
-            foreach (var effect in args.Reaction.Effects)
-            {
-                if (effect is AreaReactionEffect)
-                {
-                    args.Cancel();
-                    return;
-                }
-            }
-        }
-    }
-}
index 8f9a20e239bd730263032c6d7f33aa8a24acefc6..2166b9c7f3ef6dc41f5637238b5bb37111ca5964 100644 (file)
@@ -8,6 +8,7 @@ using Content.Shared.Chemistry.Reagent;
 using Content.Shared.Examine;
 using Content.Shared.FixedPoint;
 using JetBrains.Annotations;
+using Robust.Shared.Audio;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Utility;
 
@@ -127,6 +128,17 @@ public sealed partial class SolutionContainerSystem : EntitySystem
         return splitSol;
     }
 
+    /// <summary>
+    /// Splits a solution without the specified reagent.
+    /// </summary>
+    public Solution SplitSolutionWithout(EntityUid targetUid, Solution solutionHolder, FixedPoint2 quantity,
+        string reagent)
+    {
+        var splitSol = solutionHolder.SplitSolutionWithout(quantity, reagent);
+        UpdateChemicals(targetUid, solutionHolder);
+        return splitSol;
+    }
+
     public void UpdateChemicals(EntityUid uid, Solution solutionHolder, bool needsReactionsProcessing = false, ReactionMixerComponent? mixerComponent = null)
     {
         DebugTools.Assert(solutionHolder.Name != null && TryGetSolution(uid, solutionHolder.Name, out var tmp) && tmp == solutionHolder);
@@ -491,6 +503,37 @@ public sealed partial class SolutionContainerSystem : EntitySystem
         return false;
     }
 
+    /// <summary>
+    /// Gets the most common reagent across all solutions by volume.
+    /// </summary>
+    /// <param name="component"></param>
+    public ReagentPrototype? GetMaxReagent(SolutionContainerManagerComponent component)
+    {
+        if (component.Solutions.Count == 0)
+            return null;
+
+        var reagentCounts = new Dictionary<string, FixedPoint2>();
+
+        foreach (var solution in component.Solutions.Values)
+        {
+            foreach (var reagent in solution.Contents)
+            {
+                reagentCounts.TryGetValue(reagent.ReagentId, out var existing);
+                existing += reagent.Quantity;
+                reagentCounts[reagent.ReagentId] = existing;
+            }
+        }
+
+        var max = reagentCounts.Max();
+
+        return _prototypeManager.Index<ReagentPrototype>(max.Key);
+    }
+
+    public SoundSpecifier? GetSound(SolutionContainerManagerComponent component)
+    {
+        var max = GetMaxReagent(component);
+        return max?.FootstepSound;
+    }
 
     // Thermal energy and temperature management.
 
index 6967a8fb823946bf5af70ecd81f375a66ac3839f..60481ba9292ee57c11ebff6bfe9d3a795e1f1be4 100644 (file)
@@ -1,14 +1,18 @@
 using Content.Server.Chemistry.Components;
 using Content.Server.Chemistry.EntitySystems;
 using Content.Server.Coordinates.Helpers;
+using Content.Server.Fluids.EntitySystems;
 using Content.Shared.Audio;
 using Content.Shared.Chemistry.Reagent;
 using Content.Shared.Database;
+using Content.Shared.FixedPoint;
+using Content.Shared.Maps;
 using JetBrains.Annotations;
 using Robust.Shared.Audio;
 using Robust.Shared.Map;
 using Robust.Shared.Player;
-using Robust.Shared.Serialization;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom.Prototype;
 
 namespace Content.Server.Chemistry.ReactionEffects
 {
@@ -16,47 +20,23 @@ namespace Content.Server.Chemistry.ReactionEffects
     /// Basically smoke and foam reactions.
     /// </summary>
     [UsedImplicitly]
-    [ImplicitDataDefinitionForInheritors]
-    public abstract class AreaReactionEffect : ReagentEffect, ISerializationHooks
+    [DataDefinition]
+    public sealed class AreaReactionEffect : ReagentEffect
     {
-        [Dependency] private readonly IMapManager _mapManager = default!;
-
-        /// <summary>
-        /// Used for calculating the spread range of the effect based on the intensity of the reaction.
-        /// </summary>
-        [DataField("rangeConstant")] private float _rangeConstant;
-        [DataField("rangeMultiplier")] private float _rangeMultiplier = 1.1f;
-        [DataField("maxRange")] private int _maxRange = 10;
-
-        /// <summary>
-        /// If true the reagents get diluted or concentrated depending on the range of the effect
-        /// </summary>
-        [DataField("diluteReagents")] private bool _diluteReagents;
-
-        /// <summary>
-        /// Used to calculate dilution. Increasing this makes the reagents more diluted.
-        /// </summary>
-        [DataField("reagentDilutionFactor")] private float _reagentDilutionFactor = 1f;
-
         /// <summary>
         /// How many seconds will the effect stay, counting after fully spreading.
         /// </summary>
         [DataField("duration")] private float _duration = 10;
 
         /// <summary>
-        /// How many seconds between each spread step.
-        /// </summary>
-        [DataField("spreadDelay")] private float _spreadDelay = 0.5f;
-
-        /// <summary>
-        /// How many seconds between each remove step.
+        /// How many units of reaction for 1 smoke entity.
         /// </summary>
-        [DataField("removeDelay")] private float _removeDelay = 0.5f;
+        [DataField("overflowThreshold")] public FixedPoint2 OverflowThreshold = FixedPoint2.New(2.5);
 
         /// <summary>
-        /// The entity prototype that will be spawned as the effect. It needs a component derived from SolutionAreaEffectComponent.
+        /// The entity prototype that will be spawned as the effect.
         /// </summary>
-        [DataField("prototypeId", required: true)]
+        [DataField("prototypeId", required: true, customTypeSerializer:typeof(PrototypeIdSerializer<EntityPrototype>))]
         private string _prototypeId = default!;
 
         /// <summary>
@@ -67,55 +47,38 @@ namespace Content.Server.Chemistry.ReactionEffects
         public override bool ShouldLog => true;
         public override LogImpact LogImpact => LogImpact.High;
 
-        void ISerializationHooks.AfterDeserialization()
-        {
-            IoCManager.InjectDependencies(this);
-        }
-
         public override void Effect(ReagentEffectArgs args)
         {
             if (args.Source == null)
                 return;
 
-            var splitSolution = EntitySystem.Get<SolutionContainerSystem>().SplitSolution(args.SolutionEntity, args.Source, args.Source.Volume);
-            // We take the square root so it becomes harder to reach higher amount values
-            var amount = (int) Math.Round(_rangeConstant + _rangeMultiplier*Math.Sqrt(args.Quantity.Float()));
-            amount = Math.Min(amount, _maxRange);
+            var spreadAmount = (int) Math.Max(0, Math.Ceiling((args.Quantity / OverflowThreshold).Float()));
+            var splitSolution = args.EntityManager.System<SolutionContainerSystem>().SplitSolution(args.SolutionEntity, args.Source, args.Source.Volume);
+            var transform = args.EntityManager.GetComponent<TransformComponent>(args.SolutionEntity);
+            var mapManager = IoCManager.Resolve<IMapManager>();
 
-            if (_diluteReagents)
+            if (!mapManager.TryFindGridAt(transform.MapPosition, out var grid) ||
+                !grid.TryGetTileRef(transform.Coordinates, out var tileRef) ||
+                tileRef.Tile.IsSpace())
             {
-                // The maximum value of solutionFraction is _reagentMaxConcentrationFactor, achieved when amount = 0
-                // The infimum of solutionFraction is 0, which is approached when amount tends to infinity
-                // solutionFraction is equal to 1 only when amount equals _reagentDilutionStart
-                // Weird formulas here but basically when amount increases, solutionFraction gets closer to 0 in a reciprocal manner
-                // _reagentDilutionFactor defines how fast solutionFraction gets closer to 0
-                float solutionFraction = 1 / (_reagentDilutionFactor*(amount) + 1);
-                splitSolution.RemoveSolution(splitSolution.Volume * (1 - solutionFraction));
+                return;
             }
 
-            var transform = args.EntityManager.GetComponent<TransformComponent>(args.SolutionEntity);
-
-            if (!_mapManager.TryFindGridAt(transform.MapPosition, out var grid)) return;
-
             var coords = grid.MapToGrid(transform.MapPosition);
-
             var ent = args.EntityManager.SpawnEntity(_prototypeId, coords.SnapToGrid());
 
-            var areaEffectComponent = GetAreaEffectComponent(ent);
-
-            if (areaEffectComponent == null)
+            if (!args.EntityManager.TryGetComponent<SmokeComponent>(ent, out var smokeComponent))
             {
                 Logger.Error("Couldn't get AreaEffectComponent from " + _prototypeId);
-                IoCManager.Resolve<IEntityManager>().QueueDeleteEntity(ent);
+                args.EntityManager.QueueDeleteEntity(ent);
                 return;
             }
 
-            areaEffectComponent.TryAddSolution(splitSolution);
-            areaEffectComponent.Start(amount, _duration, _spreadDelay, _removeDelay);
+            var smoke = args.EntityManager.System<SmokeSystem>();
+            smokeComponent.SpreadAmount = spreadAmount;
+            smoke.Start(ent, smokeComponent, splitSolution, _duration);
 
             SoundSystem.Play(_sound.GetSound(), Filter.Pvs(args.SolutionEntity), args.SolutionEntity, AudioHelpers.WithVariation(0.125f));
         }
-
-        protected abstract SolutionAreaEffectComponent? GetAreaEffectComponent(EntityUid entity);
     }
 }
diff --git a/Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs b/Content.Server/Chemistry/ReactionEffects/FoamAreaReactionEffect.cs
deleted file mode 100644 (file)
index a1a1243..0000000
+++ /dev/null
@@ -1,45 +0,0 @@
-using Content.Server.Chemistry.Components;
-using Content.Server.Coordinates.Helpers;
-using Content.Shared.Audio;
-using Content.Shared.Chemistry.Components;
-using JetBrains.Annotations;
-using Robust.Server.GameObjects;
-using Robust.Shared.Audio;
-using Robust.Shared.Map;
-using Robust.Shared.Player;
-
-namespace Content.Server.Chemistry.ReactionEffects
-{
-    [UsedImplicitly]
-    [DataDefinition]
-    public sealed class FoamAreaReactionEffect : AreaReactionEffect
-    {
-        protected override SolutionAreaEffectComponent? GetAreaEffectComponent(EntityUid entity)
-        {
-            return IoCManager.Resolve<IEntityManager>().GetComponentOrNull<FoamSolutionAreaEffectComponent>(entity);
-        }
-
-        public static void SpawnFoam(string entityPrototype, EntityCoordinates coords, Solution? contents, int amount, float duration, float spreadDelay,
-            float removeDelay, SoundSpecifier? sound = null, IEntityManager? entityManager = null)
-        {
-            entityManager ??= IoCManager.Resolve<IEntityManager>();
-            var ent = entityManager.SpawnEntity(entityPrototype, coords.SnapToGrid());
-
-            var areaEffectComponent = entityManager.GetComponentOrNull<FoamSolutionAreaEffectComponent>(ent);
-
-            if (areaEffectComponent == null)
-            {
-                Logger.Error("Couldn't get AreaEffectComponent from " + entityPrototype);
-                IoCManager.Resolve<IEntityManager>().QueueDeleteEntity(ent);
-                return;
-            }
-
-            if (contents != null)
-                areaEffectComponent.TryAddSolution(contents);
-            areaEffectComponent.Start(amount, duration, spreadDelay, removeDelay);
-
-            entityManager.EntitySysManager.GetEntitySystem<AudioSystem>()
-                .PlayPvs(sound, ent, AudioParams.Default.WithVariation(0.125f));
-        }
-    }
-}
diff --git a/Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs b/Content.Server/Chemistry/ReactionEffects/SmokeAreaReactionEffect.cs
deleted file mode 100644 (file)
index 3557dbe..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-using Content.Server.Chemistry.Components;
-using JetBrains.Annotations;
-
-namespace Content.Server.Chemistry.ReactionEffects
-{
-    [UsedImplicitly]
-    [DataDefinition]
-    public sealed class SmokeAreaReactionEffect : AreaReactionEffect
-    {
-        protected override SolutionAreaEffectComponent? GetAreaEffectComponent(EntityUid entity)
-        {
-            return IoCManager.Resolve<IEntityManager>().GetComponentOrNull<SmokeSolutionAreaEffectComponent>(entity);
-        }
-    }
-}
index 2cf2915fed5576ac59b698ca0381c92b6993b098..c79d8d41eb0b6fdcaca036ac773c9a3570cae443 100644 (file)
@@ -1,4 +1,4 @@
-using Content.Server.Fluids.EntitySystems;
+using Content.Server.Fluids.EntitySystems;
 using Content.Shared.Chemistry.Components;
 using Content.Shared.Chemistry.Reaction;
 using Content.Shared.Chemistry.Reagent;
@@ -14,10 +14,11 @@ namespace Content.Server.Chemistry.TileReactions
     {
         public FixedPoint2 TileReact(TileRef tile, ReagentPrototype reagent, FixedPoint2 reactVolume)
         {
-            var spillSystem = EntitySystem.Get<SpillableSystem>();
-            if (reactVolume < 5 || !spillSystem.TryGetPuddle(tile, out _)) return FixedPoint2.Zero;
+            var spillSystem = EntitySystem.Get<PuddleSystem>();
+            if (reactVolume < 5 || !spillSystem.TryGetPuddle(tile, out _))
+                return FixedPoint2.Zero;
 
-            return spillSystem.SpillAt(tile,new Solution(reagent.ID, reactVolume), "PuddleSmear", true, false, true) != null
+            return spillSystem.TrySpillAt(tile, new Solution(reagent.ID, reactVolume), out _, sound: false, tileReact: false)
                 ? reactVolume
                 : FixedPoint2.Zero;
         }
index 4ba3efe658b21709ce60be69e90111f07c37764e..e04ea30cb64b9ef6be82ce0dd074dbd48e40ce94 100644 (file)
@@ -19,7 +19,6 @@ namespace Content.Server.Chemistry.TileReactions
         [DataField("launchForwardsMultiplier")] private float _launchForwardsMultiplier = 1;
         [DataField("requiredSlipSpeed")] private float _requiredSlipSpeed = 6;
         [DataField("paralyzeTime")] private float _paralyzeTime = 1;
-        [DataField("overflow")] private bool _overflow;
 
         public FixedPoint2 TileReact(TileRef tile, ReagentPrototype reagent, FixedPoint2 reactVolume)
         {
@@ -27,19 +26,16 @@ namespace Content.Server.Chemistry.TileReactions
 
             var entityManager = IoCManager.Resolve<IEntityManager>();
 
-            // TODO Make this not puddle smear.
-            var puddle = entityManager.EntitySysManager.GetEntitySystem<SpillableSystem>()
-                .SpillAt(tile, new Solution(reagent.ID, reactVolume), "PuddleSmear", _overflow, false, true);
-
-            if (puddle != null)
+            if (entityManager.EntitySysManager.GetEntitySystem<PuddleSystem>()
+                .TrySpillAt(tile, new Solution(reagent.ID, reactVolume), out var puddleUid, false, false))
             {
-                var slippery = entityManager.EnsureComponent<SlipperyComponent>(puddle.Owner);
+                var slippery = entityManager.EnsureComponent<SlipperyComponent>(puddleUid);
                 slippery.LaunchForwardsMultiplier = _launchForwardsMultiplier;
                 slippery.ParalyzeTime = _paralyzeTime;
                 entityManager.Dirty(slippery);
 
-                var step = entityManager.EnsureComponent<StepTriggerComponent>(puddle.Owner);
-                entityManager.EntitySysManager.GetEntitySystem<StepTriggerSystem>().SetRequiredTriggerSpeed(puddle.Owner, _requiredSlipSpeed, step);
+                var step = entityManager.EnsureComponent<StepTriggerComponent>(puddleUid);
+                entityManager.EntitySysManager.GetEntitySystem<StepTriggerSystem>().SetRequiredTriggerSpeed(puddleUid, _requiredSlipSpeed, step);
 
                 return reactVolume;
             }
index 45ffc4a967546104d219e698f82c0af39f887169..0483a50d9d3a1392819943ca5c1d503c093d7860 100644 (file)
@@ -50,7 +50,7 @@ namespace Content.Server.Cloning
         [Dependency] private readonly AtmosphereSystem _atmosphereSystem = default!;
         [Dependency] private readonly TransformSystem _transformSystem = default!;
         [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
-        [Dependency] private readonly SpillableSystem _spillableSystem = default!;
+        [Dependency] private readonly PuddleSystem _puddleSystem = default!;
         [Dependency] private readonly ChatSystem _chatSystem = default!;
         [Dependency] private readonly IConfigurationManager _configManager = default!;
         [Dependency] private readonly MaterialStorageSystem _material = default!;
@@ -316,7 +316,7 @@ namespace Content.Server.Cloning
                 if (_robustRandom.Prob(0.2f))
                     i++;
             }
-            _spillableSystem.SpillAt(uid, bloodSolution, "PuddleBlood");
+            _puddleSystem.TrySpillAt(uid, bloodSolution, out _);
 
             _material.SpawnMultipleFromMaterial(_robustRandom.Next(1, (int) (clonePod.UsedBiomass / 2.5)), clonePod.RequiredMaterial, Transform(uid).Coordinates);
 
index 8227926f35780c14af8b55c9aab0791f2e2a28c4..07668ba0638b8a98a318c10d949e42458ed36240 100644 (file)
@@ -30,7 +30,7 @@ namespace Content.Server.Destructible
         [Dependency] public readonly StackSystem StackSystem = default!;
         [Dependency] public readonly TriggerSystem TriggerSystem = default!;
         [Dependency] public readonly SolutionContainerSystem SolutionContainerSystem = default!;
-        [Dependency] public readonly SpillableSystem SpillableSystem = default!;
+        [Dependency] public readonly PuddleSystem PuddleSystem = default!;
         [Dependency] public readonly IPrototypeManager PrototypeManager = default!;
         [Dependency] public readonly IComponentFactory ComponentFactory = default!;
 
index 490be2360f55da9c365fca8325eb9d1c2463cd7b..76b76c4dc604b1cbc90b5110940cc8509226d921 100644 (file)
@@ -33,7 +33,7 @@ namespace Content.Server.Destructible.Thresholds.Behaviors
                 // Spill the solution out into the world
                 // Spill before exploding in anticipation of a future where the explosion can light the solution on fire.
                 var coordinates = system.EntityManager.GetComponent<TransformComponent>(owner).Coordinates;
-                system.SpillableSystem.SpillAt(explodingSolution, coordinates, "PuddleSmear", combine: true);
+                system.PuddleSystem.TrySpillAt(coordinates, explodingSolution, out _);
 
                 // Explode
                 // Don't delete the object here - let other processes like physical damage from the
index 293e2e33538b3d13e1985fcbe917e06e3ea935d5..99b6171f7e5561a18aff4bb55d442b35ef375c86 100644 (file)
@@ -23,7 +23,7 @@ namespace Content.Server.Destructible.Thresholds.Behaviors
         public void Execute(EntityUid owner, DestructibleSystem system, EntityUid? cause = null)
         {
             var solutionContainerSystem = EntitySystem.Get<SolutionContainerSystem>();
-            var spillableSystem = EntitySystem.Get<SpillableSystem>();
+            var spillableSystem = EntitySystem.Get<PuddleSystem>();
 
             var coordinates = system.EntityManager.GetComponent<TransformComponent>(owner).Coordinates;
 
@@ -31,12 +31,12 @@ namespace Content.Server.Destructible.Thresholds.Behaviors
                 solutionContainerSystem.TryGetSolution(owner, spillableComponent.SolutionName,
                     out var compSolution))
             {
-                spillableSystem.SplashSpillAt(owner, compSolution, coordinates, "PuddleSmear", false, user: cause);
+                spillableSystem.TrySplashSpillAt(owner, coordinates, compSolution, out _, false, user: cause);
             }
             else if (Solution != null &&
                      solutionContainerSystem.TryGetSolution(owner, Solution, out var behaviorSolution))
             {
-                spillableSystem.SplashSpillAt(owner, behaviorSolution, coordinates, "PuddleSmear", user: cause);
+                spillableSystem.TrySplashSpillAt(owner, coordinates, behaviorSolution, out _, user: cause);
             }
         }
     }
index a0d8e41f685bfac35b9ac6e3844a6015e845d646..906dcdcdaf1ea02e6e06a6de716f923a31e7d8ea 100644 (file)
@@ -17,7 +17,6 @@ namespace Content.Server.Entry
             "ClientEntitySpawner",
             "HandheldGPS",
             "CableVisualizer",
-            "PuddleVisualizer",
             "UIFragment",
             "PDABorderColor",
         };
diff --git a/Content.Server/Fluids/Components/DrainComponent.cs b/Content.Server/Fluids/Components/DrainComponent.cs
deleted file mode 100644 (file)
index ec89807..0000000
+++ /dev/null
@@ -1,39 +0,0 @@
-namespace Content.Server.Fluids.Components
-{
-    [RegisterComponent]
-    public sealed class DrainComponent : Component
-    {
-        public const string SolutionName = "drainBuffer";
-
-        [DataField("accumulator")]
-        public float Accumulator = 0f;
-
-        /// <summary>
-        /// How many units per second the drain can absorb from the surrounding puddles.
-        /// Divided by puddles, so if there are 5 puddles this will take 1/5 from each puddle.
-        /// This will stay fixed to 1 second no matter what DrainFrequency is.
-        /// </summary>
-        [DataField("unitsPerSecond")]
-        public float UnitsPerSecond = 6f;
-
-        /// <summary>
-        /// How many units are ejected from the buffer per second.
-        /// </summary>
-        [DataField("unitsDestroyedPerSecond")]
-        public float UnitsDestroyedPerSecond = 1f;
-
-        /// <summary>
-        /// How many (unobstructed) tiles away the drain will
-        /// drain puddles from.
-        /// </summary>
-        [DataField("range")]
-        public float Range = 2f;
-
-        /// <summary>
-        /// How often in seconds the drain checks for puddles around it.
-        /// If the EntityQuery seems a bit unperformant this can be increased.
-        /// </summary>
-        [DataField("drainFrequency")]
-        public float DrainFrequency = 1f;
-    }
-}
index eaa3a1ad88f1415c9d25ffa7ca1377ca938f7e6f..0c7a497e0007e14e7c258774160977d16b94ed7b 100644 (file)
@@ -1,48 +1,24 @@
 using Content.Server.Fluids.EntitySystems;
 using Content.Shared.FixedPoint;
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
 
-namespace Content.Server.Fluids.Components
-{
-    [RegisterComponent]
-    [Access(typeof(EvaporationSystem))]
-    public sealed class EvaporationComponent : Component
-    {
-        /// <summary>
-        ///     Is this entity actively evaporating? This toggle lets us pause evaporation under certain conditions.
-        /// </summary>
-        [DataField("evaporationToggle")]
-        public bool EvaporationToggle = true;
-
-        /// <summary>
-        ///     The time that it will take this puddle to lose one fixed unit of solution, in seconds.
-        /// </summary>
-        [DataField("evaporateTime")]
-        public float EvaporateTime { get; set; } = 5f;
-
-        /// <summary>
-        ///     Name of referenced solution. Defaults to <see cref="PuddleComponent.DefaultSolutionName"/>
-        /// </summary>
-        [DataField("solution")]
-        public string SolutionName { get; set; } = PuddleComponent.DefaultSolutionName;
+namespace Content.Server.Fluids.Components;
 
-        /// <summary>
-        ///     Lower limit below which puddle won't evaporate. Useful when wanting to leave a stain.
-        ///     Defaults to evaporate completely.
-        /// </summary>
-        [DataField("lowerLimit")]
-        public FixedPoint2 LowerLimit = FixedPoint2.Zero;
-
-        /// <summary>
-        ///     Upper limit above which puddle won't evaporate. Useful when wanting to make sure large puddle will
-        ///     remain forever. Defaults to 100.
-        /// </summary>
-        [DataField("upperLimit")]
-        public FixedPoint2 UpperLimit = FixedPoint2.New(100); //TODO: Consider setting this back to PuddleComponent.DefaultOverflowVolume once that behaviour is fixed.
+/// <summary>
+/// Added to puddles that contain water so it may evaporate over time.
+/// </summary>
+[RegisterComponent, Access(typeof(PuddleSystem))]
+public sealed class EvaporationComponent : Component
+{
+    /// <summary>
+    /// The next time we remove the EvaporationSystem reagent amount from this entity.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite), DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))]
+    public TimeSpan NextTick = TimeSpan.Zero;
 
-        /// <summary>
-        ///     The time accumulated since the start.
-        /// </summary>
-        [DataField("accumulator")]
-        public float Accumulator = 0f;
-    }
+    /// <summary>
+    /// How much evaporation occurs every tick.
+    /// </summary>
+    [DataField("evaporationAmount")]
+    public FixedPoint2 EvaporationAmount = FixedPoint2.New(0.3);
 }
diff --git a/Content.Server/Fluids/Components/EvaporationSparkleComponent.cs b/Content.Server/Fluids/Components/EvaporationSparkleComponent.cs
new file mode 100644 (file)
index 0000000..393328e
--- /dev/null
@@ -0,0 +1,10 @@
+namespace Content.Server.Fluids.Components;
+
+/// <summary>
+/// Used to track evaporation sparkles so we can delete if necessary.
+/// </summary>
+[RegisterComponent]
+public sealed class EvaporationSparkleComponent : Component
+{
+
+}
diff --git a/Content.Server/Fluids/Components/FluidMapDataComponent.cs b/Content.Server/Fluids/Components/FluidMapDataComponent.cs
deleted file mode 100644 (file)
index 30ab0a3..0000000
+++ /dev/null
@@ -1,35 +0,0 @@
-using Content.Server.Fluids.EntitySystems;
-using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
-
-namespace Content.Server.Fluids.Components;
-
-[RegisterComponent]
-[Access(typeof(FluidSpreaderSystem))]
-public sealed class FluidMapDataComponent : Component
-{
-    /// <summary>
-    /// At what time will <see cref="FluidSpreaderSystem"/> be checked next
-    /// </summary>
-    [DataField("goalTime", customTypeSerializer:typeof(TimeOffsetSerializer))]
-    public TimeSpan GoalTime;
-
-    /// <summary>
-    /// Delay between two runs of <see cref="FluidSpreaderSystem"/>
-    /// </summary>
-    [DataField("delay")]
-    public TimeSpan Delay = TimeSpan.FromSeconds(2);
-
-    /// <summary>
-    /// Puddles to be expanded.
-    /// </summary>
-    [DataField("puddles")] public HashSet<EntityUid> Puddles = new();
-
-    /// <summary>
-    /// Convenience method for setting GoalTime to <paramref name="start"/> + <see cref="Delay"/>
-    /// </summary>
-    /// <param name="start">Time to which to add <see cref="Delay"/>, defaults to current <see cref="GoalTime"/></param>
-    public void UpdateGoal(TimeSpan? start = null)
-    {
-        GoalTime = (start ?? GoalTime) + Delay;
-    }
-}
diff --git a/Content.Server/Fluids/Components/FootstepTrackComponent.cs b/Content.Server/Fluids/Components/FootstepTrackComponent.cs
new file mode 100644 (file)
index 0000000..04df4de
--- /dev/null
@@ -0,0 +1,7 @@
+namespace Content.Server.Fluids.Components;
+
+[RegisterComponent]
+public sealed class FootstepTrackComponent : Component
+{
+
+}
diff --git a/Content.Server/Fluids/Components/PuddleComponent.cs b/Content.Server/Fluids/Components/PuddleComponent.cs
deleted file mode 100644 (file)
index 84b35af..0000000
+++ /dev/null
@@ -1,50 +0,0 @@
-using Content.Server.Fluids.EntitySystems;
-using Content.Shared.FixedPoint;
-using Robust.Shared.Audio;
-
-namespace Content.Server.Fluids.Components
-{
-    /// <summary>
-    /// Puddle on a floor
-    /// </summary>
-    [RegisterComponent]
-    [Access(typeof(PuddleSystem))]
-    public sealed class PuddleComponent : Component
-    {
-        public const string DefaultSolutionName = "puddle";
-        private static readonly FixedPoint2 DefaultSlipThreshold = FixedPoint2.New(-1); //Not slippery by default. Set specific slipThresholds in YAML if you want your puddles to be slippery. Lower = more slippery, and zero means any volume can slip.
-        public static readonly FixedPoint2 DefaultOverflowVolume = FixedPoint2.New(20);
-
-        // Current design: Something calls the SpillHelper.Spill, that will either
-        // A) Add to an existing puddle at the location (normalised to tile-center) or
-        // B) add a new one
-        // From this every time a puddle is spilt on it will try and overflow to its neighbours if possible,
-        // and also update its appearance based on volume level (opacity) and chemistry color
-        // Small puddles will evaporate after a set delay
-
-        // TODO: 'leaves fluidtracks', probably in a separate component for stuff like gibb chunks?;
-
-        // based on behaviour (e.g. someone being punched vs slashed with a sword would have different blood sprite)
-        // to check for low volumes for evaporation or whatever
-
-        /// <summary>
-        /// Puddles with volume above this threshold can slip players.
-        /// </summary>
-        [DataField("slipThreshold")]
-        public FixedPoint2 SlipThreshold = DefaultSlipThreshold;
-
-        [DataField("spillSound")]
-        public SoundSpecifier SpillSound = new SoundPathSpecifier("/Audio/Effects/Fluids/splat.ogg");
-
-        [DataField("overflowVolume")]
-        public FixedPoint2 OverflowVolume = DefaultOverflowVolume;
-
-        /// <summary>
-        ///     How much should this puddle's opacity be multiplied by?
-        ///     Useful for puddles that have a high overflow volume but still want to be mostly opaque.
-        /// </summary>
-        [DataField("opacityModifier")] public float OpacityModifier = 1.0f;
-
-        [DataField("solution")] public string SolutionName { get; set; } = DefaultSolutionName;
-    }
-}
diff --git a/Content.Server/Fluids/EntitySystems/AbsorbentSystem.cs b/Content.Server/Fluids/EntitySystems/AbsorbentSystem.cs
new file mode 100644 (file)
index 0000000..a63cc35
--- /dev/null
@@ -0,0 +1,211 @@
+using Content.Server.Chemistry.EntitySystems;
+using Content.Server.Popups;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.FixedPoint;
+using Content.Shared.Fluids;
+using Content.Shared.Fluids.Components;
+using Content.Shared.Interaction;
+using Content.Shared.Timing;
+using Content.Shared.Weapons.Melee;
+using Robust.Server.GameObjects;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Utility;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+/// <inheritdoc/>
+public sealed class AbsorbentSystem : SharedAbsorbentSystem
+{
+    [Dependency] private readonly IPrototypeManager _prototype = default!;
+    [Dependency] private readonly AudioSystem _audio = default!;
+    [Dependency] private readonly PopupSystem _popups = default!;
+    [Dependency] private readonly PuddleSystem _puddleSystem = default!;
+    [Dependency] private readonly SharedMeleeWeaponSystem _melee = default!;
+    [Dependency] private readonly SharedTransformSystem _transform = default!;
+    [Dependency] private readonly SolutionContainerSystem _solutionSystem = default!;
+    [Dependency] private readonly UseDelaySystem _useDelay = default!;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+        SubscribeLocalEvent<AbsorbentComponent, ComponentInit>(OnAbsorbentInit);
+        SubscribeLocalEvent<AbsorbentComponent, AfterInteractEvent>(OnAfterInteract);
+        SubscribeLocalEvent<AbsorbentComponent, SolutionChangedEvent>(OnAbsorbentSolutionChange);
+    }
+
+    private void OnAbsorbentInit(EntityUid uid, AbsorbentComponent component, ComponentInit args)
+    {
+        // TODO: I know dirty on init but no prediction moment.
+        UpdateAbsorbent(uid, component);
+    }
+
+    private void OnAbsorbentSolutionChange(EntityUid uid, AbsorbentComponent component, SolutionChangedEvent args)
+    {
+        UpdateAbsorbent(uid, component);
+    }
+
+    private void UpdateAbsorbent(EntityUid uid, AbsorbentComponent component)
+    {
+        if (!_solutionSystem.TryGetSolution(uid, AbsorbentComponent.SolutionName, out var solution))
+            return;
+
+        var oldProgress = component.Progress.ShallowClone();
+        component.Progress.Clear();
+
+        if (solution.TryGetReagent(PuddleSystem.EvaporationReagent, out var water))
+        {
+            component.Progress[_prototype.Index<ReagentPrototype>(PuddleSystem.EvaporationReagent).SubstanceColor] = water.Float();
+        }
+
+        var otherColor = solution.GetColorWithout(_prototype, PuddleSystem.EvaporationReagent);
+        var other = (solution.Volume - water).Float();
+
+        if (other > 0f)
+        {
+            component.Progress[otherColor] = other;
+        }
+
+        var remainder = solution.AvailableVolume;
+
+        if (remainder > FixedPoint2.Zero)
+        {
+            component.Progress[Color.DarkGray] = remainder.Float();
+        }
+
+        if (component.Progress.Equals(oldProgress))
+            return;
+
+        Dirty(component);
+    }
+
+    private void OnAfterInteract(EntityUid uid, AbsorbentComponent component, AfterInteractEvent args)
+    {
+        if (!args.CanReach || args.Handled || _useDelay.ActiveDelay(uid))
+            return;
+
+        if (!_solutionSystem.TryGetSolution(args.Used, AbsorbentComponent.SolutionName, out var absorberSoln))
+            return;
+
+        // Didn't click anything so don't do anything.
+        if (args.Target is not { Valid: true } target)
+        {
+            return;
+        }
+
+        // If it's a puddle try to grab from
+        if (!TryPuddleInteract(args.User, uid, target, component, absorberSoln))
+        {
+            // Do a transfer, try to get water onto us and transfer anything else to them.
+
+            // If it's anything else transfer to
+            if (!TryTransferAbsorber(args.User, uid, target, component, absorberSoln))
+                return;
+        }
+
+        args.Handled = true;
+    }
+
+    /// <summary>
+    ///     Attempt to fill an absorber from some refillable solution.
+    /// </summary>
+    private bool TryTransferAbsorber(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent component, Solution absorberSoln)
+    {
+        if (!TryComp(target, out RefillableSolutionComponent? refillable))
+            return false;
+
+        if (!_solutionSystem.TryGetRefillableSolution(target, out var refillableSolution, refillable: refillable))
+            return false;
+
+        if (refillableSolution.Volume <= 0)
+        {
+            var msg = Loc.GetString("mopping-system-target-container-empty", ("target", target));
+            _popups.PopupEntity(msg, user, user);
+            return false;
+        }
+
+        // Remove the non-water reagents.
+        // Remove water on target
+        // Then do the transfer.
+        var nonWater = absorberSoln.SplitSolutionWithout(component.PickupAmount, PuddleSystem.EvaporationReagent);
+
+        if (nonWater.Volume == FixedPoint2.Zero && absorberSoln.AvailableVolume == FixedPoint2.Zero)
+        {
+            _popups.PopupEntity(Loc.GetString("mopping-system-puddle-space", ("used", used)), user, user);
+            return false;
+        }
+
+        var transferAmount = component.PickupAmount < absorberSoln.AvailableVolume ?
+            component.PickupAmount :
+            absorberSoln.AvailableVolume;
+
+        var water = refillableSolution.RemoveReagent(PuddleSystem.EvaporationReagent, transferAmount);
+
+        if (water == FixedPoint2.Zero && nonWater.Volume == FixedPoint2.Zero)
+        {
+            _popups.PopupEntity(Loc.GetString("mopping-system-target-container-empty-water", ("target", target)), user, user);
+            return false;
+        }
+
+        absorberSoln.AddReagent(PuddleSystem.EvaporationReagent, water);
+        refillableSolution.AddSolution(nonWater, _prototype);
+
+        _solutionSystem.UpdateChemicals(used, absorberSoln);
+        _solutionSystem.UpdateChemicals(target, refillableSolution);
+        _audio.PlayPvs(component.TransferSound, target);
+        _useDelay.BeginDelay(used);
+        return true;
+    }
+
+    /// <summary>
+    ///     Logic for an absorbing entity interacting with a puddle.
+    /// </summary>
+    private bool TryPuddleInteract(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent absorber, Solution absorberSoln)
+    {
+        if (!TryComp(target, out PuddleComponent? puddle))
+            return false;
+
+        if (!_solutionSystem.TryGetSolution(target, puddle.SolutionName, out var puddleSolution) || puddleSolution.Volume <= 0)
+            return false;
+
+        // Check if the puddle has any non-evaporative reagents
+        if (_puddleSystem.CanFullyEvaporate(puddleSolution))
+        {
+            _popups.PopupEntity(Loc.GetString("mopping-system-puddle-evaporate", ("target", target)), user, user);
+            return true;
+        }
+
+        // Check if we have any evaporative reagents on our absorber to transfer
+        absorberSoln.TryGetReagent(PuddleSystem.EvaporationReagent, out var available);
+
+        // No material
+        if (available == FixedPoint2.Zero)
+        {
+            _popups.PopupEntity(Loc.GetString("mopping-system-no-water", ("used", used)), user, user);
+            return true;
+        }
+
+        var transferMax = absorber.PickupAmount;
+        var transferAmount = available > transferMax ? transferMax : available;
+
+        var split = puddleSolution.SplitSolutionWithout(transferAmount, PuddleSystem.EvaporationReagent);
+
+        absorberSoln.RemoveReagent(PuddleSystem.EvaporationReagent, split.Volume);
+        puddleSolution.AddReagent(PuddleSystem.EvaporationReagent, split.Volume);
+        absorberSoln.AddSolution(split, _prototype);
+
+        _solutionSystem.UpdateChemicals(used, absorberSoln);
+        _solutionSystem.UpdateChemicals(target, puddleSolution);
+        _audio.PlayPvs(absorber.PickupSound, target);
+        _useDelay.BeginDelay(used);
+
+        var userXform = Transform(user);
+        var targetPos = _transform.GetWorldPosition(target);
+        var localPos = _transform.GetInvWorldMatrix(userXform).Transform(targetPos);
+        localPos = userXform.LocalRotation.RotateVec(localPos);
+
+        _melee.DoLunge(user, Angle.Zero, localPos, null, false);
+
+        return true;
+    }
+}
index 0e855c6c7712e9863c8dd2625ea5cd259f08b9b2..50f359ddc5b6444d4753a53ee26e668dec9c60b9 100644 (file)
@@ -3,6 +3,7 @@ using Content.Server.Fluids.Components;
 using Content.Server.Chemistry.EntitySystems;
 using Content.Shared.FixedPoint;
 using Content.Shared.Audio;
+using Content.Shared.Fluids.Components;
 using Robust.Shared.Collections;
 
 namespace Content.Server.Fluids.EntitySystems
diff --git a/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs b/Content.Server/Fluids/EntitySystems/EvaporationSystem.cs
deleted file mode 100644 (file)
index 01a0feb..0000000
+++ /dev/null
@@ -1,60 +0,0 @@
-using Content.Server.Chemistry.EntitySystems;
-using Content.Server.Fluids.Components;
-using Content.Shared.FixedPoint;
-using JetBrains.Annotations;
-
-namespace Content.Server.Fluids.EntitySystems
-{
-    [UsedImplicitly]
-    public sealed class EvaporationSystem : EntitySystem
-    {
-        [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
-
-        public override void Update(float frameTime)
-        {
-            base.Update(frameTime);
-            foreach (var evaporationComponent in EntityManager.EntityQuery<EvaporationComponent>())
-            {
-                var uid = evaporationComponent.Owner;
-                evaporationComponent.Accumulator += frameTime;
-
-                if (!_solutionContainerSystem.TryGetSolution(uid, evaporationComponent.SolutionName, out var solution))
-                {
-                    // If no solution, delete the entity
-                    EntityManager.QueueDeleteEntity(uid);
-                    continue;
-                }
-
-                if (evaporationComponent.Accumulator < evaporationComponent.EvaporateTime)
-                    continue;
-
-                evaporationComponent.Accumulator -= evaporationComponent.EvaporateTime;
-
-                if (evaporationComponent.EvaporationToggle)
-                {
-                    _solutionContainerSystem.SplitSolution(uid, solution,
-                        FixedPoint2.Min(FixedPoint2.New(1), solution.Volume)); // removes 1 unit, or solution current volume, whichever is lower.
-                }
-
-                evaporationComponent.EvaporationToggle =
-                    solution.Volume > evaporationComponent.LowerLimit
-                    && solution.Volume < evaporationComponent.UpperLimit;
-            }
-        }
-
-        /// <summary>
-        ///  Copy constructor to copy initial fields from source to destination.
-        /// </summary>
-        /// <param name="destUid">Entity to which we copy <paramref name="srcEvaporation"/> properties</param>
-        /// <param name="srcEvaporation">Component that contains relevant properties</param>
-        public void CopyConstruct(EntityUid destUid, EvaporationComponent srcEvaporation)
-        {
-            var destEvaporation = EntityManager.EnsureComponent<EvaporationComponent>(destUid);
-            destEvaporation.EvaporateTime = srcEvaporation.EvaporateTime;
-            destEvaporation.EvaporationToggle = srcEvaporation.EvaporationToggle;
-            destEvaporation.SolutionName = srcEvaporation.SolutionName;
-            destEvaporation.LowerLimit = srcEvaporation.LowerLimit;
-            destEvaporation.UpperLimit = srcEvaporation.UpperLimit;
-        }
-    }
-}
diff --git a/Content.Server/Fluids/EntitySystems/FluidSpreaderSystem.cs b/Content.Server/Fluids/EntitySystems/FluidSpreaderSystem.cs
deleted file mode 100644 (file)
index 20f786d..0000000
+++ /dev/null
@@ -1,157 +0,0 @@
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using Content.Server.Fluids.Components;
-using Content.Shared;
-using Content.Shared.Directions;
-using Content.Shared.Maps;
-using Content.Shared.Physics;
-using JetBrains.Annotations;
-using Robust.Shared.Map;
-using Robust.Shared.Map.Components;
-using Robust.Shared.Physics;
-using Robust.Shared.Physics.Systems;
-using Robust.Shared.Timing;
-
-namespace Content.Server.Fluids.EntitySystems;
-
-/// <summary>
-/// Component that governs overflowing puddles. Controls how Puddles spread and updat
-/// </summary>
-[UsedImplicitly]
-public sealed class FluidSpreaderSystem : EntitySystem
-{
-    [Dependency] private readonly IGameTiming _gameTiming = default!;
-    [Dependency] private readonly IMapManager _mapManager = default!;
-    [Dependency] private readonly PuddleSystem _puddleSystem = default!;
-    [Dependency] private readonly SharedTransformSystem _transform = default!;
-    [Dependency] private readonly SharedPhysicsSystem _physics = default!;
-
-    /// <summary>
-    /// Adds an overflow component to the map data component tracking overflowing puddles
-    /// </summary>
-    /// <param name="puddleUid">EntityUid of overflowing puddle</param>
-    /// <param name="puddle">Optional PuddleComponent</param>
-    /// <param name="xform">Optional TransformComponent</param>
-    public void AddOverflowingPuddle(EntityUid puddleUid, PuddleComponent? puddle = null,
-        TransformComponent? xform = null)
-    {
-        if (!Resolve(puddleUid, ref puddle, ref xform, false) || xform.MapUid == null)
-            return;
-
-        var mapId = xform.MapUid.Value;
-
-        EntityManager.EnsureComponent<FluidMapDataComponent>(mapId, out var component);
-        component.Puddles.Add(puddleUid);
-    }
-
-    public override void Update(float frameTime)
-    {
-        base.Update(frameTime);
-        Span<Direction> exploreDirections = stackalloc Direction[]
-        {
-            Direction.North,
-            Direction.East,
-            Direction.South,
-            Direction.West,
-        };
-        var puddles = new List<PuddleComponent>(4);
-        var puddleQuery = GetEntityQuery<PuddleComponent>();
-        var xFormQuery = GetEntityQuery<TransformComponent>();
-
-        foreach (var fluidMapData in EntityQuery<FluidMapDataComponent>())
-        {
-            if (fluidMapData.Puddles.Count == 0 || _gameTiming.CurTime <= fluidMapData.GoalTime)
-                continue;
-
-            var newIteration = new HashSet<EntityUid>();
-            foreach (var puddleUid in fluidMapData.Puddles)
-            {
-                if (!puddleQuery.TryGetComponent(puddleUid, out var puddle)
-                    || !xFormQuery.TryGetComponent(puddleUid, out var transform)
-                    || !_mapManager.TryGetGrid(transform.GridUid, out var mapGrid))
-                    continue;
-
-                puddles.Clear();
-                var pos = transform.Coordinates;
-
-                var totalVolume = _puddleSystem.CurrentVolume(puddleUid, puddle);
-                exploreDirections.Shuffle();
-                foreach (var direction in exploreDirections)
-                {
-                    var newPos = pos.Offset(direction);
-                    if (CheckTile(puddleUid, puddle, newPos, mapGrid, puddleQuery, out var uid, out var component))
-                    {
-                        puddles.Add(component);
-                        totalVolume += _puddleSystem.CurrentVolume(uid.Value, component);
-                    }
-                }
-
-                _puddleSystem.EqualizePuddles(puddleUid, puddles, totalVolume, newIteration, puddle);
-            }
-
-            fluidMapData.Puddles.Clear();
-            fluidMapData.Puddles.UnionWith(newIteration);
-            fluidMapData.UpdateGoal(_gameTiming.CurTime);
-        }
-    }
-
-
-    /// <summary>
-    /// Check a tile is valid for solution allocation.
-    /// </summary>
-    /// <param name="srcUid">Entity Uid of original puddle</param>
-    /// <param name="srcPuddle">PuddleComponent attached to srcUid</param>
-    /// <param name="dstPos">at which to check tile</param>
-    /// <param name="mapGrid">helper param needed to extract entities</param>
-    /// <param name="newPuddleUid">either found or newly created PuddleComponent.</param>
-    /// <returns>true if tile is empty or occupied by a non-overflowing puddle (or a puddle close to being overflowing)</returns>
-    private bool CheckTile(EntityUid srcUid, PuddleComponent srcPuddle, EntityCoordinates dstPos, 
-        MapGridComponent mapGrid, EntityQuery<PuddleComponent> puddleQuery,
-        [NotNullWhen(true)] out EntityUid? newPuddleUid, [NotNullWhen(true)] out PuddleComponent? newPuddleComp)
-    {
-        if (!mapGrid.TryGetTileRef(dstPos, out var tileRef)
-            || tileRef.Tile.IsEmpty)
-        {
-            newPuddleUid = null;
-            newPuddleComp = null;
-            return false;
-        }
-
-        // check if puddle can spread there at all
-        var dstMap = dstPos.ToMap(EntityManager, _transform);
-        var dst = dstMap.Position;
-        var src = Transform(srcUid).MapPosition.Position;
-        var dir = src - dst;
-        var ray = new CollisionRay(dst, dir.Normalized, (int) (CollisionGroup.Impassable | CollisionGroup.HighImpassable));
-        var mapId = dstMap.MapId;
-        var results = _physics.IntersectRay(mapId, ray, dir.Length, returnOnFirstHit: true);
-        if (results.Any())
-        {
-            newPuddleUid = null;
-            newPuddleComp = null;
-            return false;
-        }
-
-        var puddleCurrentVolume = _puddleSystem.CurrentVolume(srcUid, srcPuddle);
-        foreach (var entity in dstPos.GetEntitiesInTile())
-        {
-            if (puddleQuery.TryGetComponent(entity, out var existingPuddle))
-            {
-                if (_puddleSystem.CurrentVolume(entity, existingPuddle) >= puddleCurrentVolume)
-                {
-                    newPuddleUid = null;
-                    newPuddleComp = null;
-                    return false;
-                }
-                newPuddleUid = entity;
-                newPuddleComp = existingPuddle;
-                return true;
-            }
-        }
-
-        _puddleSystem.SpawnPuddle(srcUid, dstPos, srcPuddle, out var uid, out var comp);
-        newPuddleUid = uid;
-        newPuddleComp = comp;
-        return true;
-    }
-}
diff --git a/Content.Server/Fluids/EntitySystems/MoppingSystem.cs b/Content.Server/Fluids/EntitySystems/MoppingSystem.cs
deleted file mode 100644 (file)
index 9aab13b..0000000
+++ /dev/null
@@ -1,287 +0,0 @@
-using Content.Server.Chemistry.Components.SolutionManager;
-using Content.Server.Chemistry.EntitySystems;
-using Content.Server.Fluids.Components;
-using Content.Server.Popups;
-using Content.Shared.Chemistry.Components;
-using Content.Shared.DoAfter;
-using Content.Shared.FixedPoint;
-using Content.Shared.Fluids;
-using Content.Shared.Interaction;
-using Content.Shared.Tag;
-using JetBrains.Annotations;
-using Robust.Server.GameObjects;
-using Robust.Shared.Audio;
-using Robust.Shared.Map;
-
-namespace Content.Server.Fluids.EntitySystems;
-
-[UsedImplicitly]
-public sealed class MoppingSystem : SharedMoppingSystem
-{
-    [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!;
-    [Dependency] private readonly SpillableSystem _spillableSystem = default!;
-    [Dependency] private readonly TagSystem _tagSystem = default!;
-    [Dependency] private readonly IMapManager _mapManager = default!;
-    [Dependency] private readonly SolutionContainerSystem _solutionSystem = default!;
-    [Dependency] private readonly PopupSystem _popups = default!;
-    [Dependency] private readonly AudioSystem _audio = default!;
-
-    const string PuddlePrototypeId = "PuddleSmear"; // The puddle prototype to use when releasing liquid to the floor, making a new puddle
-
-    public override void Initialize()
-    {
-        base.Initialize();
-        SubscribeLocalEvent<AbsorbentComponent, ComponentInit>(OnAbsorbentInit);
-        SubscribeLocalEvent<AbsorbentComponent, AfterInteractEvent>(OnAfterInteract);
-        SubscribeLocalEvent<AbsorbentComponent, AbsorbantDoAfterEvent>(OnDoAfter);
-        SubscribeLocalEvent<AbsorbentComponent, SolutionChangedEvent>(OnAbsorbentSolutionChange);
-    }
-
-    private void OnAbsorbentInit(EntityUid uid, AbsorbentComponent component, ComponentInit args)
-    {
-        // TODO: I know dirty on init but no prediction moment.
-        UpdateAbsorbent(uid, component);
-    }
-
-    private void OnAbsorbentSolutionChange(EntityUid uid, AbsorbentComponent component, SolutionChangedEvent args)
-    {
-        UpdateAbsorbent(uid, component);
-    }
-
-    private void UpdateAbsorbent(EntityUid uid, AbsorbentComponent component)
-    {
-        if (!_solutionSystem.TryGetSolution(uid, AbsorbentComponent.SolutionName, out var solution))
-            return;
-
-        var oldProgress = component.Progress;
-
-        component.Progress = (float) (solution.Volume / solution.MaxVolume);
-        if (component.Progress.Equals(oldProgress))
-            return;
-        Dirty(component);
-    }
-
-    private void OnAfterInteract(EntityUid uid, AbsorbentComponent component, AfterInteractEvent args)
-    {
-        if (!args.CanReach || args.Handled)
-            return;
-
-        if (!_solutionSystem.TryGetSolution(args.Used, AbsorbentComponent.SolutionName, out var absorberSoln))
-            return;
-
-        if (args.Target is not { Valid: true } target)
-        {
-            // Add liquid to an empty floor tile
-            args.Handled = TryCreatePuddle(args.User, args.ClickLocation, component, absorberSoln);
-            return;
-        }
-
-        args.Handled = TryPuddleInteract(args.User, uid, target, component, absorberSoln)
-            || TryEmptyAbsorber(args.User, uid, target, component, absorberSoln)
-            || TryFillAbsorber(args.User, uid, target, component, absorberSoln);
-    }
-
-    /// <summary>
-    ///     Tries to create a puddle using solutions stored in the absorber entity.
-    /// </summary>
-    private bool TryCreatePuddle(EntityUid user, EntityCoordinates clickLocation, AbsorbentComponent absorbent, Solution absorberSoln)
-    {
-        if (absorberSoln.Volume <= 0)
-            return false;
-
-        if (!_mapManager.TryGetGrid(clickLocation.GetGridUid(EntityManager), out var mapGrid))
-            return false;
-
-        var releaseAmount = FixedPoint2.Min(absorbent.ResidueAmount, absorberSoln.Volume);
-        var releasedSolution = _solutionSystem.SplitSolution(absorbent.Owner, absorberSoln, releaseAmount);
-        _spillableSystem.SpillAt(mapGrid.GetTileRef(clickLocation), releasedSolution, PuddlePrototypeId);
-        _popups.PopupEntity(Loc.GetString("mopping-system-release-to-floor"), user, user);
-        return true;
-    }
-
-    /// <summary>
-    ///     Attempt to fill an absorber from some drainable solution.
-    /// </summary>
-    private bool TryFillAbsorber(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent component, Solution absorberSoln)
-    {
-        if (absorberSoln.AvailableVolume <= 0 || !TryComp(target, out DrainableSolutionComponent? drainable))
-            return false;
-
-        if (!_solutionSystem.TryGetDrainableSolution(target, out var drainableSolution))
-            return false;
-
-        if (drainableSolution.Volume <= 0)
-        {
-            var msg = Loc.GetString("mopping-system-target-container-empty", ("target", target));
-            _popups.PopupEntity(msg, user, user);
-            return true;
-        }
-
-        // Let's transfer up to to half the tool's available capacity to the tool.
-        var quantity = FixedPoint2.Max(component.PickupAmount, absorberSoln.AvailableVolume / 2);
-        quantity = FixedPoint2.Min(quantity, drainableSolution.Volume);
-
-        DoMopInteraction(user, used, target, component, drainable.Solution, quantity, 1, "mopping-system-drainable-success", component.TransferSound);
-        return true;
-    }
-
-    /// <summary>
-    ///     Empty an absorber into a refillable solution.
-    /// </summary>
-    private bool TryEmptyAbsorber(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent component, Solution absorberSoln)
-    {
-        if (absorberSoln.Volume <= 0 || !TryComp(target, out RefillableSolutionComponent? refillable))
-            return false;
-
-        if (!_solutionSystem.TryGetRefillableSolution(target, out var targetSolution))
-            return false;
-
-        string msg;
-        if (targetSolution.AvailableVolume <= 0)
-        {
-            msg = Loc.GetString("mopping-system-target-container-full", ("target", target));
-            _popups.PopupEntity(msg, user, user);
-            return true;
-        }
-
-        // check if the target container is too small (e.g. syringe)
-        // TODO this should really be a tag or something, not a capacity check.
-        if (targetSolution.MaxVolume <= FixedPoint2.New(20))
-        {
-            msg = Loc.GetString("mopping-system-target-container-too-small", ("target", target));
-            _popups.PopupEntity(msg, user, user);
-            return true;
-        }
-
-        float delay;
-        FixedPoint2 quantity = absorberSoln.Volume;
-
-        // TODO this really needs cleaning up. Less magic numbers, more data-fields.
-
-        if (_tagSystem.HasTag(used, "Mop") // if the tool used is a literal mop (and not a sponge, rag, etc.)
-            && !_tagSystem.HasTag(target, "Wringer")) // and if the target does not have a wringer for properly drying the mop
-        {
-            delay = 5.0f; // Should take much longer if you don't have a wringer
-
-            var frac = quantity / absorberSoln.MaxVolume;
-
-            // squeeze up to 60% of the solution from the mop if the mop is more than one-quarter full
-            if (frac > 0.25)
-                quantity *= 0.6;
-
-            if (frac > 0.5)
-                msg = "mopping-system-hand-squeeze-still-wet";
-            else if (frac > 0.5)
-                msg = "mopping-system-hand-squeeze-little-wet";
-            else
-                msg = "mopping-system-hand-squeeze-dry";
-        }
-        else
-        {
-            msg = "mopping-system-refillable-success";
-            delay = 1.0f;
-        }
-
-        // negative quantity as we are removing solutions from the mop
-        quantity = -FixedPoint2.Min(targetSolution.AvailableVolume, quantity);
-
-        DoMopInteraction(user, used, target, component, refillable.Solution, quantity, delay, msg, component.TransferSound);
-        return true;
-    }
-
-    /// <summary>
-    ///     Logic for an absorbing entity interacting with a puddle.
-    /// </summary>
-    private bool TryPuddleInteract(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent absorber, Solution absorberSoln)
-    {
-        if (!TryComp(target, out PuddleComponent? puddle))
-            return false;
-
-        if (!_solutionSystem.TryGetSolution(target, puddle.SolutionName, out var puddleSolution) || puddleSolution.Volume <= 0)
-            return false;
-
-        FixedPoint2 quantity;
-
-        // Get lower limit for mopping
-        FixedPoint2 lowerLimit = FixedPoint2.Zero;
-        if (TryComp(target, out EvaporationComponent? evaporation)
-            && evaporation.EvaporationToggle
-            && evaporation.LowerLimit == 0)
-        {
-            lowerLimit = absorber.LowerLimit;
-        }
-
-        // Can our absorber even absorb any liquid?
-        if (puddleSolution.Volume <= lowerLimit)
-        {
-            // Cannot absorb any more liquid. So clearly the user wants to add liquid to the puddle... right?
-            // This is the old behavior and I CBF fixing this, for the record I don't like this.
-
-            quantity = FixedPoint2.Min(absorber.ResidueAmount, absorberSoln.Volume);
-            if (quantity <= 0)
-                return false;
-
-            // Dilutes the puddle with some solution from the tool
-            _solutionSystem.TryTransferSolution(used, target, absorberSoln, puddleSolution, quantity);
-            _audio.PlayPvs(absorber.TransferSound, used);
-            _popups.PopupEntity(Loc.GetString("mopping-system-puddle-diluted"), user);
-            return true;
-        }
-
-        if (absorberSoln.AvailableVolume < 0)
-        {
-            _popups.PopupEntity(Loc.GetString("mopping-system-tool-full", ("used", used)), user, user);
-            return true;
-        }
-
-        quantity = FixedPoint2.Min(absorber.PickupAmount, puddleSolution.Volume - lowerLimit, absorberSoln.AvailableVolume);
-        if (quantity <= 0)
-            return false;
-
-        var delay = absorber.PickupAmount.Float() / absorber.Speed;
-        DoMopInteraction(user, used, target, absorber, puddle.SolutionName, quantity, delay, "mopping-system-puddle-success", absorber.PickupSound);
-        return true;
-    }
-
-    private void DoMopInteraction(EntityUid user, EntityUid used, EntityUid target, AbsorbentComponent component, string targetSolution,
-                                  FixedPoint2 transferAmount, float delay, string msg, SoundSpecifier sfx)
-    {
-        // Can't interact with too many entities at once.
-        if (component.MaxInteractingEntities < component.InteractingEntities.Count + 1)
-            return;
-
-        // Can't interact with the same container multiple times at once.
-        if (!component.InteractingEntities.Add(target))
-            return;
-
-        var ev = new AbsorbantDoAfterEvent(targetSolution, msg, sfx, transferAmount);
-
-        var doAfterArgs = new DoAfterArgs(user, delay, ev, used, target: target, used: used)
-        {
-            BreakOnUserMove = true,
-            BreakOnDamage = true,
-            MovementThreshold = 0.2f
-        };
-
-        _doAfterSystem.TryStartDoAfter(doAfterArgs);
-    }
-
-    private void OnDoAfter(EntityUid uid, AbsorbentComponent component, AbsorbantDoAfterEvent args)
-    {
-        if (args.Target == null)
-            return;
-
-        component.InteractingEntities.Remove(args.Target.Value);
-
-        if (args.Cancelled || args.Handled)
-            return;
-
-        _audio.PlayPvs(args.Sound, uid);
-        _popups.PopupEntity(Loc.GetString(args.Message, ("target", args.Target.Value), ("used", uid)), uid);
-        _solutionSystem.TryTransferSolution(args.Target.Value, uid, args.TargetSolution,
-            AbsorbentComponent.SolutionName, args.TransferAmount);
-        component.InteractingEntities.Remove(args.Target.Value);
-
-        args.Handled = true;
-    }
-}
index 2db1789da60de28eadfb4e0c8550ea8b23590c22..ce28dad0c107fb31c832144fba4a9ea4d1091857 100644 (file)
@@ -1,5 +1,6 @@
 using Content.Server.Fluids.Components;
 using Content.Shared.Fluids;
+using Content.Shared.Fluids.Components;
 using Robust.Server.Player;
 using Robust.Shared.Map;
 using Robust.Shared.Timing;
diff --git a/Content.Server/Fluids/EntitySystems/PuddleSystem.Evaporation.cs b/Content.Server/Fluids/EntitySystems/PuddleSystem.Evaporation.cs
new file mode 100644 (file)
index 0000000..65eacba
--- /dev/null
@@ -0,0 +1,68 @@
+using Content.Server.Fluids.Components;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.FixedPoint;
+using Content.Shared.Fluids.Components;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+public sealed partial class PuddleSystem
+{
+    private static readonly TimeSpan EvaporationCooldown = TimeSpan.FromSeconds(1);
+
+    public const string EvaporationReagent = "Water";
+
+    private void OnEvaporationMapInit(EntityUid uid, EvaporationComponent component, MapInitEvent args)
+    {
+        component.NextTick = _timing.CurTime + EvaporationCooldown;
+    }
+
+    private void UpdateEvaporation(EntityUid uid, Solution solution)
+    {
+        if (HasComp<EvaporationComponent>(uid))
+        {
+            return;
+        }
+
+        if (solution.ContainsReagent(EvaporationReagent))
+        {
+            var evaporation = AddComp<EvaporationComponent>(uid);
+            evaporation.NextTick = _timing.CurTime + EvaporationCooldown;
+            return;
+        }
+
+        RemComp<EvaporationComponent>(uid);
+    }
+
+    private void TickEvaporation()
+    {
+        var query = EntityQueryEnumerator<EvaporationComponent, PuddleComponent>();
+        var xformQuery = GetEntityQuery<TransformComponent>();
+        var curTime = _timing.CurTime;
+        while (query.MoveNext(out var uid, out var evaporation, out var puddle))
+        {
+            if (evaporation.NextTick > curTime)
+                continue;
+
+            evaporation.NextTick += EvaporationCooldown;
+
+            if (!_solutionContainerSystem.TryGetSolution(uid, puddle.SolutionName, out var puddleSolution))
+                continue;
+
+            var reagentTick = evaporation.EvaporationAmount * EvaporationCooldown.TotalSeconds;
+            puddleSolution.RemoveReagent(EvaporationReagent, reagentTick);
+
+            // Despawn if we're done
+            if (puddleSolution.Volume == FixedPoint2.Zero)
+            {
+                // Spawn a *sparkle*
+                Spawn("PuddleSparkle", xformQuery.GetComponent(uid).Coordinates);
+                QueueDel(uid);
+            }
+        }
+    }
+
+    public bool CanFullyEvaporate(Solution solution)
+    {
+        return solution.Contents.Count == 1 && solution.ContainsReagent(EvaporationReagent);
+    }
+}
diff --git a/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs b/Content.Server/Fluids/EntitySystems/PuddleSystem.Spillable.cs
new file mode 100644 (file)
index 0000000..31deaf8
--- /dev/null
@@ -0,0 +1,139 @@
+using Content.Server.Chemistry.EntitySystems;
+using Content.Server.Fluids.Components;
+using Content.Server.Nutrition.Components;
+using Content.Shared.Clothing.Components;
+using Content.Shared.Database;
+using Content.Shared.DoAfter;
+using Content.Shared.FixedPoint;
+using Content.Shared.Inventory.Events;
+using Content.Shared.Spillable;
+using Content.Shared.Throwing;
+using Content.Shared.Verbs;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+public sealed partial class PuddleSystem
+{
+    private void InitializeSpillable()
+    {
+        SubscribeLocalEvent<SpillableComponent, LandEvent>(SpillOnLand);
+        SubscribeLocalEvent<SpillableComponent, GetVerbsEvent<Verb>>(AddSpillVerb);
+        SubscribeLocalEvent<SpillableComponent, GotEquippedEvent>(OnGotEquipped);
+        SubscribeLocalEvent<SpillableComponent, SolutionSpikeOverflowEvent>(OnSpikeOverflow);
+        SubscribeLocalEvent<SpillableComponent, SpillDoAfterEvent>(OnDoAfter);
+    }
+
+    private void OnSpikeOverflow(EntityUid uid, SpillableComponent component, SolutionSpikeOverflowEvent args)
+    {
+        if (!args.Handled)
+        {
+            TrySpillAt(Transform(uid).Coordinates, args.Overflow, out _);
+        }
+
+        args.Handled = true;
+    }
+
+    private void OnGotEquipped(EntityUid uid, SpillableComponent component, GotEquippedEvent args)
+    {
+        if (!component.SpillWorn)
+            return;
+
+        if (!TryComp(uid, out ClothingComponent? clothing))
+            return;
+
+        // check if entity was actually used as clothing
+        // not just taken in pockets or something
+        var isCorrectSlot = clothing.Slots.HasFlag(args.SlotFlags);
+        if (!isCorrectSlot)
+            return;
+
+        if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution))
+            return;
+
+        if (solution.Volume == 0)
+            return;
+
+        // spill all solution on the player
+        var drainedSolution = _solutionContainerSystem.Drain(uid, solution, solution.Volume);
+        TrySpillAt(args.Equipee, drainedSolution, out _);
+    }
+
+    private void SpillOnLand(EntityUid uid, SpillableComponent component, ref LandEvent args)
+    {
+        if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution))
+            return;
+
+        if (TryComp<DrinkComponent>(uid, out var drink) && !drink.Opened)
+            return;
+
+        if (args.User != null)
+        {
+            _adminLogger.Add(LogType.Landed,
+                $"{ToPrettyString(uid):entity} spilled a solution {SolutionContainerSystem.ToPrettyString(solution):solution} on landing");
+        }
+
+        var drainedSolution = _solutionContainerSystem.Drain(uid, solution, solution.Volume);
+        TrySplashSpillAt(uid, Transform(uid).Coordinates, drainedSolution, out _);
+    }
+
+    private void AddSpillVerb(EntityUid uid, SpillableComponent component, GetVerbsEvent<Verb> args)
+    {
+        if (!args.CanAccess || !args.CanInteract)
+            return;
+
+        if (!_solutionContainerSystem.TryGetSolution(args.Target, component.SolutionName, out var solution))
+            return;
+
+        if (TryComp<DrinkComponent>(args.Target, out var drink) && (!drink.Opened))
+            return;
+
+        if (solution.Volume == FixedPoint2.Zero)
+            return;
+
+        Verb verb = new()
+        {
+            Text = Loc.GetString("spill-target-verb-get-data-text")
+        };
+
+        // TODO VERB ICONS spill icon? pouring out a glass/beaker?
+        if (component.SpillDelay == null)
+        {
+            verb.Act = () =>
+            {
+                var puddleSolution = _solutionContainerSystem.SplitSolution(args.Target,
+                    solution, solution.Volume);
+                TrySpillAt(Transform(args.Target).Coordinates, puddleSolution, out _);
+            };
+        }
+        else
+        {
+            verb.Act = () =>
+            {
+                _doAfterSystem.TryStartDoAfter(new DoAfterArgs(args.User, component.SpillDelay ?? 0, new SpillDoAfterEvent(), uid, target: uid)
+                {
+                    BreakOnTargetMove = true,
+                    BreakOnUserMove = true,
+                    BreakOnDamage = true,
+                    NeedHand = true,
+                });
+            };
+        }
+        verb.Impact = LogImpact.Medium; // dangerous reagent reaction are logged separately.
+        verb.DoContactInteraction = true;
+        args.Verbs.Add(verb);
+    }
+
+    private void OnDoAfter(EntityUid uid, SpillableComponent component, DoAfterEvent args)
+    {
+        if (args.Handled || args.Cancelled || args.Args.Target == null)
+            return;
+
+        //solution gone by other means before doafter completes
+        if (!_solutionContainerSystem.TryGetDrainableSolution(uid, out var solution) || solution.Volume == 0)
+            return;
+
+        var puddleSolution = _solutionContainerSystem.SplitSolution(uid, solution, solution.Volume);
+        TrySpillAt(Transform(uid).Coordinates, puddleSolution, out _);
+        args.Handled = true;
+    }
+}
diff --git a/Content.Server/Fluids/EntitySystems/PuddleSystem.Transfers.cs b/Content.Server/Fluids/EntitySystems/PuddleSystem.Transfers.cs
new file mode 100644 (file)
index 0000000..4ecdefa
--- /dev/null
@@ -0,0 +1,69 @@
+using Content.Shared.Chemistry.Components;
+using Content.Shared.DragDrop;
+using Content.Shared.FixedPoint;
+using Content.Shared.Fluids;
+using Content.Shared.Fluids.Components;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+public sealed partial class PuddleSystem
+{
+    private void InitializeTransfers()
+    {
+        SubscribeLocalEvent<RefillableSolutionComponent, DragDropDraggedEvent>(OnRefillableDragged);
+    }
+
+    private void OnRefillableDragged(EntityUid uid, RefillableSolutionComponent component, ref DragDropDraggedEvent args)
+    {
+        _solutionContainerSystem.TryGetSolution(uid, component.Solution, out var solution);
+
+        if (solution?.Volume == FixedPoint2.Zero)
+        {
+            _popups.PopupEntity(Loc.GetString("mopping-system-empty", ("used", uid)), uid, args.User);
+            return;
+        }
+
+        TryComp<DrainableSolutionComponent>(args.Target, out var drainable);
+
+        _solutionContainerSystem.TryGetDrainableSolution(args.Target, out var drainableSolution, drainable);
+
+        // Dump reagents into drain
+        if (TryComp<DrainComponent>(args.Target, out var drain) && drainable != null)
+        {
+            if (drainableSolution == null || solution == null)
+                return;
+
+            var split = _solutionContainerSystem.SplitSolution(uid, solution, drainableSolution.AvailableVolume);
+
+            // TODO: Drane refactor
+            if (_solutionContainerSystem.TryAddSolution(args.Target, drainableSolution, split))
+            {
+                _audio.PlayPvs(AbsorbentComponent.DefaultTransferSound, args.Target);
+            }
+            else
+            {
+                _popups.PopupEntity(Loc.GetString("mopping-system-full", ("used", args.Target)), args.Target, args.User);
+            }
+
+            return;
+        }
+
+        // Take reagents from target
+        if (drainable != null)
+        {
+            if (drainableSolution == null || solution == null)
+                return;
+
+            var split = _solutionContainerSystem.SplitSolution(args.Target, drainableSolution, solution.AvailableVolume);
+
+            if (_solutionContainerSystem.TryAddSolution(uid, solution, split))
+            {
+                _audio.PlayPvs(AbsorbentComponent.DefaultTransferSound, uid);
+            }
+            else
+            {
+                _popups.PopupEntity(Loc.GetString("mopping-system-full", ("used", uid)), uid, args.User);
+            }
+        }
+    }
+}
index dd0de51db19ca7bd0779bcaf41f8565b61907291..6dc6a64f8ffe86a48d7c7b15f81c076121699ae5 100644 (file)
+using Content.Server.Administration.Logs;
 using Content.Server.Chemistry.EntitySystems;
+using Content.Server.DoAfter;
 using Content.Server.Fluids.Components;
 using Content.Shared.Chemistry;
 using Content.Shared.Chemistry.Reaction;
+using Content.Server.Spreader;
 using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Database;
 using Content.Shared.Examine;
 using Content.Shared.FixedPoint;
 using Content.Shared.Fluids;
 using Content.Shared.Popups;
 using Content.Shared.Slippery;
+using Content.Shared.Fluids.Components;
 using Content.Shared.StepTrigger.Components;
 using Content.Shared.StepTrigger.Systems;
-using JetBrains.Annotations;
+using Robust.Server.GameObjects;
 using Robust.Shared.Audio;
 using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
 using Robust.Shared.Player;
 using Solution = Content.Shared.Chemistry.Components.Solution;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Random;
+using Robust.Shared.Timing;
 
-namespace Content.Server.Fluids.EntitySystems
+namespace Content.Server.Fluids.EntitySystems;
+
+/// <summary>
+/// Handles solutions on floors. Also handles the spreader logic for where the solution overflows a specified volume.
+/// </summary>
+public sealed partial class PuddleSystem : SharedPuddleSystem
 {
-    [UsedImplicitly]
-    public sealed class PuddleSystem : EntitySystem
+    [Dependency] private readonly IAdminLogManager _adminLogger= default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly IMapManager _mapManager = default!;
+    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
+    [Dependency] private readonly IRobustRandom _random = default!;
+    [Dependency] private readonly AudioSystem _audio = default!;
+    [Dependency] private readonly DoAfterSystem _doAfterSystem = default!;
+    [Dependency] private readonly EntityLookupSystem _lookup = default!;
+    [Dependency] private readonly ReactiveSystem _reactive = default!;
+    [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+    [Dependency] private readonly SharedPopupSystem _popups = default!;
+    [Dependency] private readonly StepTriggerSystem _stepTrigger = default!;
+    [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
+
+    public static float PuddleVolume = 1000;
+
+    // Using local deletion queue instead of the standard queue so that we can easily "undelete" if a puddle
+    // loses & then gains reagents in a single tick.
+    private HashSet<EntityUid> _deletionQueue = new();
+
+    /*
+     * TODO: Need some sort of way to do blood slash / vomit solution spill on its own
+     * This would then evaporate into the puddle tile below
+     */
+
+    /// <inheritdoc/>
+    public override void Initialize()
     {
-        [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
-        [Dependency] private readonly FluidSpreaderSystem _fluidSpreaderSystem = default!;
-        [Dependency] private readonly StepTriggerSystem _stepTrigger = default!;
-        [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
-        [Dependency] private readonly ReactiveSystem _reactive = default!;
-        [Dependency] private readonly SharedPopupSystem _popup = default!;
-        [Dependency] private readonly IPrototypeManager _protoMan = default!;
-        [Dependency] private readonly IRobustRandom _random = default!;
+        base.Initialize();
+
+        // Shouldn't need re-anchoring.
+        SubscribeLocalEvent<PuddleComponent, AnchorStateChangedEvent>(OnAnchorChanged);
+        SubscribeLocalEvent<PuddleComponent, ExaminedEvent>(HandlePuddleExamined);
+        SubscribeLocalEvent<PuddleComponent, SolutionChangedEvent>(OnSolutionUpdate);
+        SubscribeLocalEvent<PuddleComponent, ComponentInit>(OnPuddleInit);
+        SubscribeLocalEvent<PuddleComponent, SpreadNeighborsEvent>(OnPuddleSpread);
+        SubscribeLocalEvent<PuddleComponent, SlipEvent>(OnPuddleSlip);
+
+        SubscribeLocalEvent<EvaporationComponent, MapInitEvent>(OnEvaporationMapInit);
 
-        public static float PuddleVolume = 1000;
+        InitializeSpillable();
+        InitializeTransfers();
+    }
 
-        // Using local deletion queue instead of the standard queue so that we can easily "undelete" if a puddle
-        // loses & then gains reagents in a single tick.
-        private HashSet<EntityUid> _deletionQueue = new();
+    private void OnPuddleSpread(EntityUid uid, PuddleComponent component, ref SpreadNeighborsEvent args)
+    {
+        var overflow = GetOverflowSolution(uid, component);
 
-        public override void Initialize()
+        if (overflow.Volume == FixedPoint2.Zero)
         {
-            base.Initialize();
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
+        }
 
-            // Shouldn't need re-anchoring.
-            SubscribeLocalEvent<PuddleComponent, AnchorStateChangedEvent>(OnAnchorChanged);
-            SubscribeLocalEvent<PuddleComponent, ExaminedEvent>(HandlePuddleExamined);
-            SubscribeLocalEvent<PuddleComponent, SolutionChangedEvent>(OnSolutionUpdate);
-            SubscribeLocalEvent<PuddleComponent, ComponentInit>(OnPuddleInit);
-            SubscribeLocalEvent<PuddleComponent, SlipEvent>(OnPuddleSlip);
+        var xform = Transform(uid);
+
+        if (!TryComp<MapGridComponent>(xform.GridUid, out var grid))
+        {
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
         }
 
-        public override void Update(float frameTime)
+        var puddleQuery = GetEntityQuery<PuddleComponent>();
+
+        // First we overflow to neighbors with overflow capacity
+        // Then we go to free tiles
+        // Then we go to anything else.
+        if (args.Neighbors.Count > 0)
         {
-            base.Update(frameTime);
-            foreach (var ent in _deletionQueue)
+            _random.Shuffle(args.Neighbors);
+
+            // Overflow to neighbors with remaining space.
+            foreach (var neighbor in args.Neighbors)
+            {
+                if (!puddleQuery.TryGetComponent(neighbor, out var puddle) ||
+                    !_solutionContainerSystem.TryGetSolution(neighbor, puddle.SolutionName, out var neighborSolution))
+                {
+                    continue;
+                }
+
+                var remaining = neighborSolution.Volume - puddle.OverflowVolume;
+
+                if (remaining <= FixedPoint2.Zero)
+                    continue;
+
+                var split = overflow.SplitSolution(remaining);
+
+                if (!_solutionContainerSystem.TryAddSolution(neighbor, neighborSolution, split))
+                    continue;
+
+                args.Updates--;
+                EnsureComp<EdgeSpreaderComponent>(neighbor);
+
+                if (args.Updates <= 0)
+                    break;
+            }
+
+            if (overflow.Volume == FixedPoint2.Zero)
             {
-                Del(ent);
+                RemCompDeferred<EdgeSpreaderComponent>(uid);
+                return;
             }
-            _deletionQueue.Clear();
         }
 
-        private void OnPuddleInit(EntityUid uid, PuddleComponent component, ComponentInit args)
+        if (args.NeighborFreeTiles.Count > 0 && args.Updates > 0)
         {
-            _solutionContainerSystem.EnsureSolution(uid, component.SolutionName, FixedPoint2.New(PuddleVolume), out _);
+            _random.Shuffle(args.NeighborFreeTiles);
+            var spillAmount = overflow.Volume / args.NeighborFreeTiles.Count;
+
+            foreach (var tile in args.NeighborFreeTiles)
+            {
+                var split = overflow.SplitSolution(spillAmount);
+                TrySpillAt(grid.GridTileToLocal(tile), split, out _, false);
+                args.Updates--;
+
+                if (args.Updates <= 0)
+                    break;
+            }
+
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
         }
 
-        private void OnPuddleSlip(EntityUid uid, PuddleComponent component, ref SlipEvent args)
+        if (overflow.Volume > FixedPoint2.Zero && args.Neighbors.Count > 0 && args.Updates > 0)
         {
-            // Reactive entities have a chance to get a touch reaction from slipping on a puddle
-            // (i.e. it is implied they fell face first onto it or something)
-            if (!HasComp<ReactiveComponent>(args.Slipped))
-                return;
+            var spillPerNeighbor = overflow.Volume / args.Neighbors.Count;
 
-            // Eventually probably have some system of 'body coverage' to tweak the probability but for now just 0.5
-            // (implying that spacemen have a 50% chance to either land on their ass or their face)
-            if (!_random.Prob(0.5f))
-                return;
+            foreach (var neighbor in args.Neighbors)
+            {
+                // Overflow to neighbours but not if they're already at the cap
+                // This is to avoid diluting solutions too much.
+                if (!puddleQuery.TryGetComponent(neighbor, out var puddle) ||
+                    !_solutionContainerSystem.TryGetSolution(neighbor, puddle.SolutionName, out var neighborSolution) ||
+                    neighborSolution.Volume >= puddle.OverflowVolume)
+                {
+                    continue;
+                }
 
-            if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution))
-                return;
+                var split = overflow.SplitSolution(spillPerNeighbor);
+
+                if (!_solutionContainerSystem.TryAddSolution(neighbor, neighborSolution, split))
+                    continue;
 
-            _popup.PopupEntity(Loc.GetString("puddle-component-slipped-touch-reaction", ("puddle", uid)),
-                args.Slipped, args.Slipped, PopupType.SmallCaution);
+                EnsureComp<EdgeSpreaderComponent>(neighbor);
+                args.Updates--;
 
-            // Take 15% of the puddle solution
-            var splitSol = _solutionContainerSystem.SplitSolution(uid, solution, solution.Volume * 0.15f);
-            _reactive.DoEntityReaction(args.Slipped, splitSol, ReactionMethod.Touch);
+                if (args.Updates <= 0)
+                    break;
+            }
         }
 
-        private void OnSolutionUpdate(EntityUid uid, PuddleComponent component, SolutionChangedEvent args)
+        // Add the remainder back
+        if (_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var puddleSolution))
         {
-            if (args.Solution.Name != component.SolutionName)
-                return;
+            _solutionContainerSystem.TryAddSolution(uid, puddleSolution, overflow);
+        }
+    }
 
-            if (args.Solution.Volume <= 0)
-            {
-                _deletionQueue.Add(uid);
-                return;
-            }
+    private void OnPuddleSlip(EntityUid uid, PuddleComponent component, ref SlipEvent args)
+    {
+        // Reactive entities have a chance to get a touch reaction from slipping on a puddle
+        // (i.e. it is implied they fell face first onto it or something)
+        if (!HasComp<ReactiveComponent>(args.Slipped))
+            return;
+
+        // Eventually probably have some system of 'body coverage' to tweak the probability but for now just 0.5
+        // (implying that spacemen have a 50% chance to either land on their ass or their face)
+        if (!_random.Prob(0.5f))
+            return;
+
+        if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution))
+            return;
+
+        _popups.PopupEntity(Loc.GetString("puddle-component-slipped-touch-reaction", ("puddle", uid)),
+            args.Slipped, args.Slipped, PopupType.SmallCaution);
+
+        // Take 15% of the puddle solution
+        var splitSol = _solutionContainerSystem.SplitSolution(uid, solution, solution.Volume * 0.15f);
+        _reactive.DoEntityReaction(args.Slipped, splitSol, ReactionMethod.Touch);
+    }
+
+    /// <inheritdoc/>
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+        foreach (var ent in _deletionQueue)
+        {
+            Del(ent);
+        }
+        _deletionQueue.Clear();
+
+        TickEvaporation();
+    }
 
-            _deletionQueue.Remove(uid);
-            UpdateSlip(uid, component);
-            UpdateAppearance(uid, component);
+    private void OnPuddleInit(EntityUid uid, PuddleComponent component, ComponentInit args)
+    {
+        _solutionContainerSystem.EnsureSolution(uid, component.SolutionName, FixedPoint2.New(PuddleVolume), out _);
+    }
+
+    private void OnSolutionUpdate(EntityUid uid, PuddleComponent component, SolutionChangedEvent args)
+    {
+        if (args.Solution.Name != component.SolutionName)
+            return;
+
+        if (args.Solution.Volume <= 0)
+        {
+            _deletionQueue.Add(uid);
+            return;
+        }
+
+        _deletionQueue.Remove(uid);
+        UpdateSlip(uid, component, args.Solution);
+        UpdateEvaporation(uid, args.Solution);
+        UpdateAppearance(uid, component);
+    }
+
+    private void UpdateAppearance(EntityUid uid, PuddleComponent? puddleComponent = null, AppearanceComponent? appearance = null)
+    {
+        if (!Resolve(uid, ref puddleComponent, ref appearance, false))
+        {
+            return;
         }
 
-        private void UpdateAppearance(EntityUid uid, PuddleComponent? puddleComponent = null, AppearanceComponent? appearance = null)
+        var volume = FixedPoint2.Zero;
+        Color color = Color.White;
+
+        if (_solutionContainerSystem.TryGetSolution(uid, puddleComponent.SolutionName, out var solution))
         {
-            if (!Resolve(uid, ref puddleComponent, ref appearance, false)
-                || EmptyHolder(uid, puddleComponent))
+            volume = solution.Volume / puddleComponent.OverflowVolume;
+
+            // Make blood stand out more
+            // Kinda EH
+            // Could potentially do alpha per-solution but future problem.
+            var standoutReagents = new string[] { "Blood", "Slime" };
+
+            color = solution.GetColorWithout(_prototypeManager, standoutReagents);
+            color = color.WithAlpha(0.7f);
+
+            foreach (var standout in standoutReagents)
             {
-                return;
+                if (!solution.TryGetReagent(standout, out var quantity))
+                    continue;
+
+                var interpolateValue = quantity.Float() / solution.Volume.Float();
+                color = Color.InterpolateBetween(color, _prototypeManager.Index<ReagentPrototype>(standout).SubstanceColor, interpolateValue);
             }
+        }
 
-            // Opacity based on level of fullness to overflow
-            // Hard-cap lower bound for visibility reasons
-            var puddleSolution = _solutionContainerSystem.EnsureSolution(uid, puddleComponent.SolutionName);
-            var volumeScale = puddleSolution.Volume.Float() /
-                              puddleComponent.OverflowVolume.Float() *
-                              puddleComponent.OpacityModifier;
+        _appearance.SetData(uid, PuddleVisuals.CurrentVolume, volume.Float(), appearance);
+        _appearance.SetData(uid, PuddleVisuals.SolutionColor, color, appearance);
+    }
 
-            bool isEvaporating;
+    private void UpdateSlip(EntityUid entityUid, PuddleComponent component, Solution solution)
+    {
+        var isSlippery = false;
+        // The base sprite is currently at 0.3 so we require at least 2nd tier to be slippery or else it's too hard to see.
+        var amountRequired = FixedPoint2.New(component.OverflowVolume.Float() * LowThreshold);
+        var slipperyAmount = FixedPoint2.Zero;
 
-            if (TryComp(uid, out EvaporationComponent? evaporation)
-                && evaporation.EvaporationToggle)// if puddle is evaporating.
+        foreach (var reagent in solution.Contents)
+        {
+            var reagentProto = _prototypeManager.Index<ReagentPrototype>(reagent.ReagentId);
+
+            if (reagentProto.Slippery)
             {
-                isEvaporating = true;
+                slipperyAmount += reagent.Quantity;
+
+                if (slipperyAmount > amountRequired)
+                {
+                    isSlippery = true;
+                    break;
+                }
             }
-            else isEvaporating = false;
+        }
 
-            var color = puddleSolution.GetColor(_protoMan);
+        if (isSlippery)
+        {
+            var comp = EnsureComp<StepTriggerComponent>(entityUid);
+            _stepTrigger.SetActive(entityUid, true, comp);
+        }
+        else if (TryComp<StepTriggerComponent>(entityUid, out var comp))
+        {
+            _stepTrigger.SetActive(entityUid, false, comp);
+        }
+    }
 
-            _appearance.SetData(uid, PuddleVisuals.VolumeScale, volumeScale, appearance);
-            _appearance.SetData(uid, PuddleVisuals.CurrentVolume, puddleSolution.Volume, appearance);
-            _appearance.SetData(uid, PuddleVisuals.SolutionColor, color, appearance);
-            _appearance.SetData(uid, PuddleVisuals.IsEvaporatingVisual, isEvaporating, appearance);
+    private void HandlePuddleExamined(EntityUid uid, PuddleComponent component, ExaminedEvent args)
+    {
+        if (TryComp<StepTriggerComponent>(uid, out var slippery) && slippery.Active)
+        {
+            args.PushMarkup(Loc.GetString("puddle-component-examine-is-slipper-text"));
         }
 
-        private void UpdateSlip(EntityUid entityUid, PuddleComponent puddleComponent)
+        if (HasComp<EvaporationComponent>(uid))
         {
-            var vol = CurrentVolume(puddleComponent.Owner, puddleComponent);
-            if ((puddleComponent.SlipThreshold == FixedPoint2.New(-1) ||
-                 vol < puddleComponent.SlipThreshold) &&
-                TryComp(entityUid, out StepTriggerComponent? stepTrigger))
+            if (_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution) &&
+                CanFullyEvaporate(solution))
             {
-                _stepTrigger.SetActive(entityUid, false, stepTrigger);
+                args.PushMarkup(Loc.GetString("puddle-component-examine-evaporating"));
             }
-            else if (vol >= puddleComponent.SlipThreshold)
+            else if (solution?.ContainsReagent(EvaporationReagent) == true)
             {
-                var comp = EnsureComp<StepTriggerComponent>(entityUid);
-                _stepTrigger.SetActive(entityUid, true, comp);
+                args.PushMarkup(Loc.GetString("puddle-component-examine-evaporating-partial"));
             }
-        }
-
-        private void HandlePuddleExamined(EntityUid uid, PuddleComponent component, ExaminedEvent args)
-        {
-            if (TryComp<StepTriggerComponent>(uid, out var slippery) && slippery.Active)
+            else
             {
-                args.PushText(Loc.GetString("puddle-component-examine-is-slipper-text"));
+                args.PushMarkup(Loc.GetString("puddle-component-examine-evaporating-no"));
             }
         }
-
-        private void OnAnchorChanged(EntityUid uid, PuddleComponent puddle, ref AnchorStateChangedEvent args)
+        else
         {
-            if (!args.Anchored)
-                QueueDel(uid);
+            args.PushMarkup(Loc.GetString("puddle-component-examine-evaporating-no"));
         }
+    }
 
-        public bool EmptyHolder(EntityUid uid, PuddleComponent? puddleComponent = null)
+    private void OnAnchorChanged(EntityUid uid, PuddleComponent puddle, ref AnchorStateChangedEvent args)
+    {
+        if (!args.Anchored)
+            QueueDel(uid);
+    }
+
+    /// <summary>
+    ///     Gets the current volume of the given puddle, which may not necessarily be PuddleVolume.
+    /// </summary>
+    public FixedPoint2 CurrentVolume(EntityUid uid, PuddleComponent? puddleComponent = null)
+    {
+        if (!Resolve(uid, ref puddleComponent))
+            return FixedPoint2.Zero;
+
+        return _solutionContainerSystem.TryGetSolution(uid, puddleComponent.SolutionName,
+            out var solution)
+            ? solution.Volume
+            : FixedPoint2.Zero;
+    }
+
+    /// <summary>
+    /// Try to add solution to <paramref name="puddleUid"/>.
+    /// </summary>
+    /// <param name="puddleUid">Puddle to which we add</param>
+    /// <param name="addedSolution">Solution that is added to puddleComponent</param>
+    /// <param name="sound">Play sound on overflow</param>
+    /// <param name="checkForOverflow">Overflow on encountered values</param>
+    /// <param name="puddleComponent">Optional resolved PuddleComponent</param>
+    /// <returns></returns>
+    public bool TryAddSolution(EntityUid puddleUid,
+        Solution addedSolution,
+        bool sound = true,
+        bool checkForOverflow = true,
+        PuddleComponent? puddleComponent = null)
+    {
+        if (!Resolve(puddleUid, ref puddleComponent))
+            return false;
+
+        if (addedSolution.Volume == 0 ||
+            !_solutionContainerSystem.TryGetSolution(puddleUid, puddleComponent.SolutionName,
+                out var solution))
         {
-            if (!Resolve(uid, ref puddleComponent))
-                return true;
+            return false;
+        }
+
+        solution.AddSolution(addedSolution, _prototypeManager);
+        _solutionContainerSystem.UpdateChemicals(puddleUid, solution, true);
 
-            return !_solutionContainerSystem.TryGetSolution(puddleComponent.Owner, puddleComponent.SolutionName,
-                       out var solution)
-                   || solution.Contents.Count == 0;
+        if (checkForOverflow && IsOverflowing(puddleUid, puddleComponent))
+        {
+            EnsureComp<EdgeSpreaderComponent>(puddleUid);
         }
 
-        public FixedPoint2 CurrentVolume(EntityUid uid, PuddleComponent? puddleComponent = null)
+        if (!sound)
         {
-            if (!Resolve(uid, ref puddleComponent))
-                return FixedPoint2.Zero;
+            return true;
+        }
+
+        SoundSystem.Play(puddleComponent.SpillSound.GetSound(),
+            Filter.Pvs(puddleUid), puddleUid);
+        return true;
+    }
+
+    /// <summary>
+    ///     Whether adding this solution to this puddle would overflow.
+    /// </summary>
+    public bool WouldOverflow(EntityUid uid, Solution solution, PuddleComponent? puddle = null)
+    {
+        if (!Resolve(uid, ref puddle))
+            return false;
+
+        return CurrentVolume(uid, puddle) + solution.Volume > puddle.OverflowVolume;
+    }
+
+    /// <summary>
+    ///     Whether adding this solution to this puddle would overflow.
+    /// </summary>
+    private bool IsOverflowing(EntityUid uid, PuddleComponent? puddle = null)
+    {
+        if (!Resolve(uid, ref puddle))
+            return false;
+
+        return CurrentVolume(uid, puddle) > puddle.OverflowVolume;
+    }
 
-            return _solutionContainerSystem.TryGetSolution(puddleComponent.Owner, puddleComponent.SolutionName,
-                out var solution)
-                ? solution.Volume
-                : FixedPoint2.Zero;
+    /// <summary>
+    /// Gets the solution amount above the overflow threshold for the puddle.
+    /// </summary>
+    public Solution GetOverflowSolution(EntityUid uid, PuddleComponent? puddle = null)
+    {
+        if (!Resolve(uid, ref puddle) || !_solutionContainerSystem.TryGetSolution(uid, puddle.SolutionName,
+                out var solution))
+        {
+            return new Solution(0);
         }
 
-        /// <summary>
-        /// Try to add solution to <paramref name="puddleUid"/>.
-        /// </summary>
-        /// <param name="puddleUid">Puddle to which we add</param>
-        /// <param name="addedSolution">Solution that is added to puddleComponent</param>
-        /// <param name="sound">Play sound on overflow</param>
-        /// <param name="checkForOverflow">Overflow on encountered values</param>
-        /// <param name="puddleComponent">Optional resolved PuddleComponent</param>
-        /// <returns></returns>
-        public bool TryAddSolution(EntityUid puddleUid,
-            Solution addedSolution,
-            bool sound = true,
-            bool checkForOverflow = true,
-            PuddleComponent? puddleComponent = null)
+        // TODO: This is going to fail with struct solutions.
+        var remaining = puddle.OverflowVolume;
+        var split = _solutionContainerSystem.SplitSolution(uid, solution, CurrentVolume(uid, puddle) - remaining);
+        return split;
+    }
+
+    #region Spill
+
+    /// <summary>
+    ///     First splashes reagent on reactive entities near the spilling entity, then spills the rest regularly to a
+    ///     puddle. This is intended for 'destructive' spills, like when entities are destroyed or thrown.
+    /// </summary>
+    public bool TrySplashSpillAt(EntityUid uid,
+        EntityCoordinates coordinates,
+        Solution solution,
+        out EntityUid puddleUid,
+        bool sound = true,
+        EntityUid? user = null)
+    {
+        puddleUid = EntityUid.Invalid;
+
+        if (solution.Volume == 0)
+            return false;
+
+        // Get reactive entities nearby--if there are some, it'll spill a bit on them instead.
+        foreach (var ent in _lookup.GetComponentsInRange<ReactiveComponent>(coordinates, 1.0f))
         {
-            if (!Resolve(puddleUid, ref puddleComponent))
-                return false;
+            // sorry! no overload for returning uid, so .owner must be used
+            var owner = ent.Owner;
+
+            // between 5 and 30%
+            var splitAmount = solution.Volume * _random.NextFloat(0.05f, 0.30f);
+            var splitSolution = solution.SplitSolution(splitAmount);
 
-            if (addedSolution.Volume == 0 ||
-                !_solutionContainerSystem.TryGetSolution(puddleComponent.Owner, puddleComponent.SolutionName,
-                    out var solution))
+            if (user != null)
             {
-                return false;
+                _adminLogger.Add(LogType.Landed,
+                    $"{ToPrettyString(user.Value):user} threw {ToPrettyString(uid):entity} which splashed a solution {SolutionContainerSystem.ToPrettyString(solution):solution} onto {ToPrettyString(owner):target}");
             }
 
-            solution.AddSolution(addedSolution, _protoMan);
-            _solutionContainerSystem.UpdateChemicals(puddleUid, solution, true);
+            _reactive.DoEntityReaction(owner, splitSolution, ReactionMethod.Touch);
+            _popups.PopupEntity(Loc.GetString("spill-land-spilled-on-other", ("spillable", uid), ("target", owner)), owner, PopupType.SmallCaution);
+        }
 
-            if (checkForOverflow && IsOverflowing(puddleUid, puddleComponent))
-            {
-                _fluidSpreaderSystem.AddOverflowingPuddle(puddleComponent.Owner, puddleComponent);
-            }
+        return TrySpillAt(coordinates, solution, out puddleUid, sound);
+    }
 
-            if (!sound)
-            {
-                return true;
-            }
+    /// <summary>
+    ///     Spills solution at the specified coordinates.
+    /// Will add to an existing puddle if present or create a new one if not.
+    /// </summary>
+    public bool TrySpillAt(EntityCoordinates coordinates, Solution solution, out EntityUid puddleUid, bool sound = true)
+    {
+        if (solution.Volume == 0)
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
+        }
 
-            SoundSystem.Play(puddleComponent.SpillSound.GetSound(),
-                Filter.Pvs(puddleComponent.Owner), puddleComponent.Owner);
-            return true;
+        if (!_mapManager.TryGetGrid(coordinates.GetGridUid(EntityManager), out var mapGrid))
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
         }
 
-        /// <summary>
-        /// Given a large srcPuddle and smaller destination puddles, this method will equalize their <see cref="Solution.CurrentVolume"/>
-        /// </summary>
-        /// <param name="srcPuddle">puddle that donates liquids to other puddles</param>
-        /// <param name="destinationPuddles">List of puddles that we want to equalize, their puddle <see cref="Solution.CurrentVolume"/> should be less than sourcePuddleComponent</param>
-        /// <param name="totalVolume">Total volume of src and destination puddle</param>
-        /// <param name="stillOverflowing">optional parameter, that after equalization adds all still overflowing puddles.</param>
-        /// <param name="sourcePuddleComponent">puddleComponent for <paramref name="srcPuddle"/></param>
-        public void EqualizePuddles(EntityUid srcPuddle, List<PuddleComponent> destinationPuddles,
-            FixedPoint2 totalVolume,
-            HashSet<EntityUid>? stillOverflowing = null,
-            PuddleComponent? sourcePuddleComponent = null)
-        {
-            if (!Resolve(srcPuddle, ref sourcePuddleComponent)
-                || !_solutionContainerSystem.TryGetSolution(srcPuddle, sourcePuddleComponent.SolutionName,
-                    out var srcSolution))
-                return;
+        return TrySpillAt(mapGrid.GetTileRef(coordinates), solution, out puddleUid, sound);
+    }
 
-            var dividedVolume = totalVolume / (destinationPuddles.Count + 1);
+    /// <summary>
+    /// <see cref="TrySpillAt(Robust.Shared.Map.EntityCoordinates,Content.Shared.Chemistry.Components.Solution,out Robust.Shared.GameObjects.EntityUid,bool)"/>
+    /// </summary>
+    public bool TrySpillAt(EntityUid uid, Solution solution, out EntityUid puddleUid, bool sound = true, TransformComponent? transformComponent = null)
+    {
+        if (!Resolve(uid, ref transformComponent, false))
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
+        }
 
-            foreach (var destPuddle in destinationPuddles)
-            {
-                if (!_solutionContainerSystem.TryGetSolution(destPuddle.Owner, destPuddle.SolutionName,
-                        out var destSolution))
-                    continue;
+        return TrySpillAt(transformComponent.Coordinates, solution, out puddleUid, sound: sound);
+    }
 
-                var takeAmount = FixedPoint2.Max(0, dividedVolume - destSolution.Volume);
-                TryAddSolution(destPuddle.Owner, srcSolution.SplitSolution(takeAmount), false, false, destPuddle);
-                if (stillOverflowing != null && IsOverflowing(destPuddle.Owner, destPuddle))
-                {
-                    stillOverflowing.Add(destPuddle.Owner);
-                }
-            }
+    /// <summary>
+    /// <see cref="TrySpillAt(Robust.Shared.Map.EntityCoordinates,Content.Shared.Chemistry.Components.Solution,out Robust.Shared.GameObjects.EntityUid,bool)"/>
+    /// </summary>
+    public bool TrySpillAt(TileRef tileRef, Solution solution, out EntityUid puddleUid, bool sound = true, bool tileReact = true)
+    {
+        if (solution.Volume <= 0)
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
+        }
+
+        // If space return early, let that spill go out into the void
+        if (tileRef.Tile.IsEmpty)
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
+        }
+
+        // Let's not spill to invalid grids.
+        var gridId = tileRef.GridUid;
+        if (!_mapManager.TryGetGrid(gridId, out var mapGrid))
+        {
+            puddleUid = EntityUid.Invalid;
+            return false;
+        }
 
-            if (stillOverflowing != null && srcSolution.Volume > sourcePuddleComponent.OverflowVolume)
+        if (tileReact)
+        {
+            // First, do all tile reactions
+            for (var i = 0; i < solution.Contents.Count; i++)
             {
-                stillOverflowing.Add(srcPuddle);
+                var (reagentId, quantity) = solution.Contents[i];
+                var proto = _prototypeManager.Index<ReagentPrototype>(reagentId);
+                var removed = proto.ReactionTile(tileRef, quantity);
+                if (removed <= FixedPoint2.Zero)
+                    continue;
+
+                solution.RemoveReagent(reagentId, removed);
             }
         }
 
-        /// <summary>
-        ///     Whether adding this solution to this puddle would overflow.
-        /// </summary>
-        /// <param name="uid">Uid of owning entity</param>
-        /// <param name="puddle">Puddle to which we are adding solution</param>
-        /// <param name="solution">Solution we intend to add</param>
-        /// <returns></returns>
-        public bool WouldOverflow(EntityUid uid, Solution solution, PuddleComponent? puddle = null)
+        // Tile reactions used up everything.
+        if (solution.Volume == FixedPoint2.Zero)
         {
-            if (!Resolve(uid, ref puddle))
-                return false;
-
-            return CurrentVolume(uid, puddle) + solution.Volume > puddle.OverflowVolume;
+            puddleUid = EntityUid.Invalid;
+            return false;
         }
 
-        /// <summary>
-        ///     Whether adding this solution to this puddle would overflow.
-        /// </summary>
-        /// <param name="uid">Uid of owning entity</param>
-        /// <param name="puddle">Puddle ref param</param>
-        /// <returns></returns>
-        private bool IsOverflowing(EntityUid uid, PuddleComponent? puddle = null)
+        // Get normalized co-ordinate for spill location and spill it in the centre
+        // TODO: Does SnapGrid or something else already do this?
+        var anchored = mapGrid.GetAnchoredEntitiesEnumerator(tileRef.GridIndices);
+        var puddleQuery = GetEntityQuery<PuddleComponent>();
+        var sparklesQuery = GetEntityQuery<EvaporationSparkleComponent>();
+
+        while (anchored.MoveNext(out var ent))
         {
-            if (!Resolve(uid, ref puddle))
-                return false;
+            // If there's existing sparkles then delete it
+            if (sparklesQuery.TryGetComponent(ent, out var sparkles))
+            {
+                QueueDel(ent.Value);
+                continue;
+            }
+
+            if (!puddleQuery.TryGetComponent(ent, out var puddle))
+                continue;
+
+            if (TryAddSolution(ent.Value, solution, sound, puddleComponent: puddle))
+            {
+                EnsureComp<EdgeSpreaderComponent>(ent.Value);
+            }
 
-            return CurrentVolume(uid, puddle) > puddle.OverflowVolume;
+            puddleUid = ent.Value;
+            return true;
         }
 
-        public void SpawnPuddle(EntityUid srcUid, EntityCoordinates pos, PuddleComponent srcPuddleComponent, out EntityUid uid, out PuddleComponent component)
+        var coords = mapGrid.GridTileToLocal(tileRef.GridIndices);
+        puddleUid = EntityManager.SpawnEntity("Puddle", coords);
+        EnsureComp<PuddleComponent>(puddleUid);
+        if (TryAddSolution(puddleUid, solution, sound))
         {
-            MetaDataComponent? metadata = null;
-            Resolve(srcUid, ref metadata);
+            EnsureComp<EdgeSpreaderComponent>(puddleUid);
+        }
+        return true;
+    }
+
+    #endregion
+
+    /// <summary>
+    /// Tries to get the relevant puddle entity for a tile.
+    /// </summary>
+    public bool TryGetPuddle(TileRef tile, out EntityUid puddleUid)
+    {
+        puddleUid = EntityUid.Invalid;
 
-            var prototype = metadata?.EntityPrototype?.ID ?? "PuddleSmear"; // TODO Spawn a entity based on another entity
+        if (!TryComp<MapGridComponent>(tile.GridUid, out var grid))
+            return false;
 
-            uid = EntityManager.SpawnEntity(prototype, pos);
-            component = EntityManager.EnsureComponent<PuddleComponent>(uid);
+        var anc = grid.GetAnchoredEntitiesEnumerator(tile.GridIndices);
+        var puddleQuery = GetEntityQuery<PuddleComponent>();
+
+        while (anc.MoveNext(out var ent))
+        {
+            if (!puddleQuery.HasComponent(ent.Value))
+                continue;
+
+            puddleUid = ent.Value;
+            return true;
         }
+
+        return false;
     }
 }
diff --git a/Content.Server/Fluids/EntitySystems/SmokeSystem.cs b/Content.Server/Fluids/EntitySystems/SmokeSystem.cs
new file mode 100644 (file)
index 0000000..2a6aa93
--- /dev/null
@@ -0,0 +1,326 @@
+using System.Linq;
+using Content.Server.Administration.Logs;
+using Content.Server.Body.Components;
+using Content.Server.Body.Systems;
+using Content.Server.Chemistry.Components;
+using Content.Server.Chemistry.EntitySystems;
+using Content.Server.Chemistry.ReactionEffects;
+using Content.Server.Coordinates.Helpers;
+using Content.Server.Spreader;
+using Content.Shared.Chemistry;
+using Content.Shared.Chemistry.Components;
+using Content.Shared.Chemistry.Reaction;
+using Content.Shared.Chemistry.Reagent;
+using Content.Shared.Database;
+using Content.Shared.FixedPoint;
+using Content.Shared.Smoking;
+using Content.Shared.Spawners;
+using Content.Shared.Spawners.Components;
+using Robust.Server.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Fluids.EntitySystems;
+
+/// <summary>
+/// Handles non-atmos solution entities similar to puddles.
+/// </summary>
+public sealed class SmokeSystem : EntitySystem
+{
+    // If I could do it all again this could probably use a lot more of puddles.
+    [Dependency] private readonly IAdminLogManager _logger = default!;
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly IMapManager _mapManager = default!;
+    [Dependency] private readonly IPrototypeManager _prototype = default!;
+    [Dependency] private readonly AppearanceSystem _appearance = default!;
+    [Dependency] private readonly BloodstreamSystem _blood = default!;
+    [Dependency] private readonly EntityLookupSystem _lookup = default!;
+    [Dependency] private readonly InternalsSystem _internals = default!;
+    [Dependency] private readonly ReactiveSystem _reactive = default!;
+    [Dependency] private readonly SolutionContainerSystem _solutionSystem = default!;
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        base.Initialize();
+        SubscribeLocalEvent<SmokeComponent, EntityUnpausedEvent>(OnSmokeUnpaused);
+        SubscribeLocalEvent<SmokeComponent, MapInitEvent>(OnSmokeMapInit);
+        SubscribeLocalEvent<SmokeComponent, ReactionAttemptEvent>(OnReactionAttempt);
+        SubscribeLocalEvent<SmokeComponent, SpreadNeighborsEvent>(OnSmokeSpread);
+        SubscribeLocalEvent<SmokeDissipateSpawnComponent, TimedDespawnEvent>(OnSmokeDissipate);
+        SubscribeLocalEvent<SpreadGroupUpdateRate>(OnSpreadUpdateRate);
+    }
+
+    private void OnSpreadUpdateRate(ref SpreadGroupUpdateRate ev)
+    {
+        if (ev.Name != "smoke")
+            return;
+
+        ev.UpdatesPerSecond = 8;
+    }
+
+    private void OnSmokeDissipate(EntityUid uid, SmokeDissipateSpawnComponent component, ref TimedDespawnEvent args)
+    {
+        if (!TryComp<TransformComponent>(uid, out var xform))
+        {
+            return;
+        }
+
+        Spawn(component.Prototype, xform.Coordinates);
+    }
+
+    private void OnSmokeSpread(EntityUid uid, SmokeComponent component, ref SpreadNeighborsEvent args)
+    {
+        if (component.SpreadAmount == 0 ||
+            args.Grid == null ||
+            !_solutionSystem.TryGetSolution(uid, SmokeComponent.SolutionName, out var solution) ||
+            args.NeighborFreeTiles.Count == 0)
+        {
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
+        }
+
+        var prototype = MetaData(uid).EntityPrototype;
+
+        if (prototype == null)
+        {
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
+        }
+
+        TryComp<TimedDespawnComponent>(uid, out var timer);
+
+        var smokePerSpread = component.SpreadAmount / args.NeighborFreeTiles.Count;
+        component.SpreadAmount -= smokePerSpread;
+
+        foreach (var tile in args.NeighborFreeTiles)
+        {
+            var coords = args.Grid.GridTileToLocal(tile);
+            var ent = Spawn(prototype.ID, coords.SnapToGrid());
+            var neighborSmoke = EnsureComp<SmokeComponent>(ent);
+            neighborSmoke.SpreadAmount = Math.Max(0, smokePerSpread - 1);
+            args.Updates--;
+
+            // Listen this is the old behaviour iunno
+            Start(ent, neighborSmoke, solution.Clone(), timer?.Lifetime ?? 10f);
+
+            if (_appearance.TryGetData(uid, SmokeVisuals.Color, out var color))
+            {
+                _appearance.SetData(ent, SmokeVisuals.Color, color);
+            }
+
+            // Only 1 spread then ig?
+            if (smokePerSpread == 0)
+            {
+                component.SpreadAmount--;
+
+                if (component.SpreadAmount == 0)
+                {
+                    RemCompDeferred<EdgeSpreaderComponent>(uid);
+                    break;
+                }
+            }
+
+            if (args.Updates <= 0)
+                break;
+        }
+
+        // Give our spread to neighbor tiles.
+        if (args.NeighborFreeTiles.Count == 0 && args.Neighbors.Count > 0 && component.SpreadAmount > 0)
+        {
+            var smokeQuery = GetEntityQuery<SmokeComponent>();
+
+            foreach (var neighbor in args.Neighbors)
+            {
+                if (!smokeQuery.TryGetComponent(neighbor, out var smoke))
+                    continue;
+
+                smoke.SpreadAmount++;
+                args.Updates--;
+
+                if (component.SpreadAmount == 0)
+                {
+                    RemCompDeferred<EdgeSpreaderComponent>(uid);
+                    break;
+                }
+
+                if (args.Updates <= 0)
+                    break;
+            }
+        }
+    }
+
+    private void OnReactionAttempt(EntityUid uid, SmokeComponent component, ReactionAttemptEvent args)
+    {
+        if (args.Solution.Name != SmokeComponent.SolutionName)
+            return;
+
+        // Prevent smoke/foam fork bombs (smoke creating more smoke).
+        foreach (var effect in args.Reaction.Effects)
+        {
+            if (effect is AreaReactionEffect)
+            {
+                args.Cancel();
+                return;
+            }
+        }
+    }
+
+    private void OnSmokeMapInit(EntityUid uid, SmokeComponent component, MapInitEvent args)
+    {
+        component.NextReact = _timing.CurTime;
+    }
+
+    private void OnSmokeUnpaused(EntityUid uid, SmokeComponent component, ref EntityUnpausedEvent args)
+    {
+        component.NextReact += args.PausedTime;
+    }
+
+    /// <inheritdoc/>
+    public override void Update(float frameTime)
+    {
+        base.Update(frameTime);
+        var query = EntityQueryEnumerator<SmokeComponent>();
+        var curTime = _timing.CurTime;
+
+        while (query.MoveNext(out var uid, out var smoke))
+        {
+            if (smoke.NextReact > curTime)
+                continue;
+
+            smoke.NextReact += TimeSpan.FromSeconds(1.5);
+
+            SmokeReact(uid, 1f, smoke);
+        }
+    }
+
+    /// <summary>
+    /// Does the relevant smoke reactions for an entity for the specified exposure duration.
+    /// </summary>
+    public void SmokeReact(EntityUid uid, float frameTime, SmokeComponent? component = null, TransformComponent? xform = null)
+    {
+        if (!Resolve(uid, ref component, ref xform))
+            return;
+
+        if (!_solutionSystem.TryGetSolution(uid, SmokeComponent.SolutionName, out var solution) ||
+            solution.Contents.Count == 0)
+        {
+            return;
+        }
+
+        if (!_mapManager.TryGetGrid(xform.GridUid, out var mapGrid))
+            return;
+
+        var tile = mapGrid.GetTileRef(xform.Coordinates.ToVector2i(EntityManager, _mapManager));
+
+        var solutionFraction = 1 / Math.Floor(frameTime);
+        var ents = _lookup.GetEntitiesIntersecting(tile, LookupFlags.Uncontained).ToArray();
+
+        foreach (var reagentQuantity in solution.Contents.ToArray())
+        {
+            if (reagentQuantity.Quantity == FixedPoint2.Zero)
+                continue;
+
+            var reagent = _prototype.Index<ReagentPrototype>(reagentQuantity.ReagentId);
+
+            // React with the tile the effect is on
+            // We don't multiply by solutionFraction here since the tile is only ever reacted once
+            if (!component.ReactedTile)
+            {
+                reagent.ReactionTile(tile, reagentQuantity.Quantity);
+                component.ReactedTile = true;
+            }
+
+            // Touch every entity on tile.
+            foreach (var entity in ents)
+            {
+                if (entity == uid)
+                    continue;
+
+                _reactive.ReactionEntity(entity, ReactionMethod.Touch, reagent,
+                    reagentQuantity.Quantity * solutionFraction, solution);
+            }
+        }
+
+        foreach (var entity in ents)
+        {
+            if (entity == uid)
+                continue;
+
+            ReactWithEntity(entity, solution, solutionFraction);
+        }
+
+        UpdateVisuals(uid);
+    }
+
+    private void UpdateVisuals(EntityUid uid)
+    {
+        if (TryComp(uid, out AppearanceComponent? appearance) &&
+            _solutionSystem.TryGetSolution(uid, SmokeComponent.SolutionName, out var solution))
+        {
+            var color = solution.GetColor(_prototype);
+            _appearance.SetData(uid, SmokeVisuals.Color, color, appearance);
+        }
+    }
+
+    private void ReactWithEntity(EntityUid entity, Solution solution, double solutionFraction)
+    {
+        if (!TryComp<BloodstreamComponent>(entity, out var bloodstream))
+            return;
+
+        if (TryComp<InternalsComponent>(entity, out var internals) &&
+            _internals.AreInternalsWorking(internals))
+        {
+            return;
+        }
+
+        var cloneSolution = solution.Clone();
+        var transferAmount = FixedPoint2.Min(cloneSolution.Volume * solutionFraction, bloodstream.ChemicalSolution.AvailableVolume);
+        var transferSolution = cloneSolution.SplitSolution(transferAmount);
+
+        foreach (var reagentQuantity in transferSolution.Contents.ToArray())
+        {
+            if (reagentQuantity.Quantity == FixedPoint2.Zero)
+                continue;
+
+            _reactive.ReactionEntity(entity, ReactionMethod.Ingestion, reagentQuantity.ReagentId, reagentQuantity.Quantity, transferSolution);
+        }
+
+        if (_blood.TryAddToChemicals(entity, transferSolution, bloodstream))
+        {
+            // Log solution addition by smoke
+            _logger.Add(LogType.ForceFeed, LogImpact.Medium, $"{ToPrettyString(entity):target} was affected by smoke {SolutionContainerSystem.ToPrettyString(transferSolution)}");
+        }
+    }
+
+    /// <summary>
+    /// Sets up a smoke component for spreading.
+    /// </summary>
+    public void Start(EntityUid uid, SmokeComponent component, Solution solution, float duration)
+    {
+        TryAddSolution(uid, component, solution);
+        EnsureComp<EdgeSpreaderComponent>(uid);
+        var timer = EnsureComp<TimedDespawnComponent>(uid);
+        timer.Lifetime = duration;
+    }
+
+    /// <summary>
+    /// Adds the specified solution to the relevant smoke solution.
+    /// </summary>
+    public void TryAddSolution(EntityUid uid, SmokeComponent component, Solution solution)
+    {
+        if (solution.Volume == FixedPoint2.Zero)
+            return;
+
+        if (!_solutionSystem.TryGetSolution(uid, SmokeComponent.SolutionName, out var solutionArea))
+            return;
+
+        var addSolution =
+            solution.SplitSolution(FixedPoint2.Min(solution.Volume, solutionArea.AvailableVolume));
+
+        _solutionSystem.TryAddSolution(uid, solutionArea, addSolution);
+
+        UpdateVisuals(uid);
+    }
+}
diff --git a/Content.Server/Fluids/EntitySystems/SpillableSystem.cs b/Content.Server/Fluids/EntitySystems/SpillableSystem.cs
deleted file mode 100644 (file)
index 54d3691..0000000
+++ /dev/null
@@ -1,366 +0,0 @@
-using Content.Server.Administration.Logs;
-using Content.Server.Chemistry.EntitySystems;
-using Content.Server.Fluids.Components;
-using Content.Server.Nutrition.Components;
-using Content.Shared.Chemistry.Components;
-using Content.Shared.Chemistry.Reagent;
-using Content.Shared.Clothing.Components;
-using Content.Shared.Database;
-using Content.Shared.FixedPoint;
-using Content.Shared.Inventory.Events;
-using Content.Shared.Throwing;
-using Content.Shared.Verbs;
-using JetBrains.Annotations;
-using Robust.Shared.Map;
-using Robust.Shared.Prototypes;
-using System.Diagnostics.CodeAnalysis;
-using System.Linq;
-using Content.Shared.Chemistry;
-using Content.Shared.Chemistry.Reaction;
-using Content.Shared.DoAfter;
-using Content.Shared.Examine;
-using Content.Shared.IdentityManagement;
-using Content.Shared.Popups;
-using Content.Shared.Spillable;
-using Content.Shared.Weapons.Melee;
-using Content.Shared.Weapons.Melee.Events;
-using Robust.Shared.Player;
-using Robust.Shared.Random;
-
-namespace Content.Server.Fluids.EntitySystems;
-
-[UsedImplicitly]
-public sealed class SpillableSystem : EntitySystem
-{
-    [Dependency] private readonly SolutionContainerSystem _solutionContainerSystem = default!;
-    [Dependency] private readonly PuddleSystem _puddleSystem = default!;
-    [Dependency] private readonly IMapManager _mapManager = default!;
-    [Dependency] private readonly IPrototypeManager _prototypeManager = default!;
-    [Dependency] private readonly EntityLookupSystem _entityLookup = default!;
-    [Dependency] private readonly IAdminLogManager _adminLogger = default!;
-    [Dependency] private readonly IRobustRandom _random = default!;
-    [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!;
-    [Dependency] private readonly ReactiveSystem _reactive = default!;
-    [Dependency] private readonly SharedPopupSystem _popup = default!;
-
-    public override void Initialize()
-    {
-        base.Initialize();
-        SubscribeLocalEvent<SpillableComponent, ExaminedEvent>(OnExamined);
-        SubscribeLocalEvent<SpillableComponent, LandEvent>(SpillOnLand);
-        SubscribeLocalEvent<SpillableComponent, MeleeHitEvent>(SplashOnMeleeHit);
-        SubscribeLocalEvent<SpillableComponent, GetVerbsEvent<Verb>>(AddSpillVerb);
-        SubscribeLocalEvent<SpillableComponent, GotEquippedEvent>(OnGotEquipped);
-        SubscribeLocalEvent<SpillableComponent, SolutionSpikeOverflowEvent>(OnSpikeOverflow);
-        SubscribeLocalEvent<SpillableComponent, SpillDoAfterEvent>(OnDoAfter);
-    }
-
-    private void OnExamined(EntityUid uid, SpillableComponent component, ExaminedEvent args)
-    {
-        args.PushMarkup(Loc.GetString("spill-examine-is-spillable"));
-
-        if (HasComp<MeleeWeaponComponent>(uid))
-            args.PushMarkup(Loc.GetString("spill-examine-spillable-weapon"));
-    }
-
-    private void OnSpikeOverflow(EntityUid uid, SpillableComponent component, SolutionSpikeOverflowEvent args)
-    {
-        if (!args.Handled)
-        {
-            SpillAt(args.Overflow, Transform(uid).Coordinates, "PuddleSmear");
-        }
-
-        args.Handled = true;
-    }
-
-    private void OnGotEquipped(EntityUid uid, SpillableComponent component, GotEquippedEvent args)
-    {
-        if (!component.SpillWorn)
-            return;
-
-        if (!TryComp(uid, out ClothingComponent? clothing))
-            return;
-
-        // check if entity was actually used as clothing
-        // not just taken in pockets or something
-        var isCorrectSlot = clothing.Slots.HasFlag(args.SlotFlags);
-        if (!isCorrectSlot) return;
-
-        if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution))
-            return;
-        if (solution.Volume == 0)
-            return;
-
-        // spill all solution on the player
-        var drainedSolution = _solutionContainerSystem.Drain(uid, solution, solution.Volume);
-        SpillAt(args.Equipee, drainedSolution, "PuddleSmear");
-    }
-
-    /// <summary>
-    ///     Spills the specified solution at the entity's location if possible.
-    /// </summary>
-    /// <param name="uid">
-    ///     The entity to use as a location to spill the solution at.
-    /// </param>
-    /// <param name="solution">Initial solution for the prototype.</param>
-    /// <param name="prototype">The prototype to use.</param>
-    /// <param name="sound">Play the spill sound.</param>
-    /// <param name="combine">Whether to attempt to merge with existing puddles</param>
-    /// <param name="transformComponent">Optional Transform component</param>
-    /// <returns>The puddle if one was created, null otherwise.</returns>
-    public PuddleComponent? SpillAt(EntityUid uid, Solution solution, string prototype,
-        bool sound = true, bool combine = true, TransformComponent? transformComponent = null)
-    {
-        return !Resolve(uid, ref transformComponent, false)
-            ? null
-            : SpillAt(solution, transformComponent.Coordinates, prototype, sound: sound, combine: combine);
-    }
-
-    private void SpillOnLand(EntityUid uid, SpillableComponent component, ref LandEvent args)
-    {
-        if (!_solutionContainerSystem.TryGetSolution(uid, component.SolutionName, out var solution)) return;
-
-        if (TryComp<DrinkComponent>(uid, out var drink) && (!drink.Opened))
-            return;
-
-        if (args.User != null)
-        {
-            _adminLogger.Add(LogType.Landed,
-                $"{ToPrettyString(args.User.Value):user} threw {ToPrettyString(uid):entity} which spilled a solution {SolutionContainerSystem.ToPrettyString(solution):solution} on landing");
-        }
-
-        var drainedSolution = _solutionContainerSystem.Drain(uid, solution, solution.Volume);
-        SplashSpillAt(uid, drainedSolution, Transform(uid).Coordinates, "PuddleSmear");
-    }
-
-    private void SplashOnMeleeHit(EntityUid uid, SpillableComponent component, MeleeHitEvent args)
-    {
-        // When attacking someone reactive with a spillable entity,
-        // splash a little on them (touch react)
-        // If this also has solution transfer, then assume the transfer amount is how much we want to spill.
-        // Otherwise let's say they want to spill a quarter of its max volume.
-
-        if (!_solutionContainerSystem.TryGetDrainableSolution(uid, out var solution))
-            return;
-
-        if (TryComp<DrinkComponent>(uid, out var drink) && !drink.Opened)
-            return;
-
-        var hitCount = args.HitEntities.Count;
-
-        var totalSplit = FixedPoint2.Min(solution.MaxVolume * 0.25, solution.Volume);
-        if (TryComp<SolutionTransferComponent>(uid, out var transfer))
-        {
-            totalSplit = FixedPoint2.Min(transfer.TransferAmount, solution.Volume);
-        }
-
-        // a little lame, but reagent quantity is not very balanced and we don't want people
-        // spilling like 100u of reagent on someone at once!
-        totalSplit = FixedPoint2.Min(totalSplit, component.MaxMeleeSpillAmount);
-
-        foreach (var hit in args.HitEntities)
-        {
-            if (!HasComp<ReactiveComponent>(hit))
-            {
-                hitCount -= 1; // so we don't undershoot solution calculation for actual reactive entities
-                continue;
-            }
-
-            var splitSolution = _solutionContainerSystem.SplitSolution(uid, solution, totalSplit / hitCount);
-
-            _adminLogger.Add(LogType.MeleeHit, $"{ToPrettyString(args.User)} splashed {SolutionContainerSystem.ToPrettyString(splitSolution):solution} from {ToPrettyString(uid):entity} onto {ToPrettyString(hit):target}");
-            _reactive.DoEntityReaction(hit, splitSolution, ReactionMethod.Touch);
-
-            _popup.PopupEntity(
-                Loc.GetString("spill-melee-hit-attacker", ("amount", totalSplit / hitCount), ("spillable", uid),
-                    ("target", Identity.Entity(hit, EntityManager))),
-                hit, args.User);
-
-            _popup.PopupEntity(
-                Loc.GetString("spill-melee-hit-others", ("attacker", args.User), ("spillable", uid),
-                    ("target", Identity.Entity(hit, EntityManager))),
-                hit, Filter.PvsExcept(args.User), true, PopupType.SmallCaution);
-        }
-    }
-
-    private void AddSpillVerb(EntityUid uid, SpillableComponent component, GetVerbsEvent<Verb> args)
-    {
-        if (!args.CanAccess || !args.CanInteract)
-            return;
-
-        if (!_solutionContainerSystem.TryGetDrainableSolution(args.Target, out var solution))
-            return;
-
-        if (TryComp<DrinkComponent>(args.Target, out var drink) && (!drink.Opened))
-            return;
-
-        if (solution.Volume == FixedPoint2.Zero)
-            return;
-
-        Verb verb = new();
-        verb.Text = Loc.GetString("spill-target-verb-get-data-text");
-        // TODO VERB ICONS spill icon? pouring out a glass/beaker?
-
-        verb.Act = () =>
-        {
-            _doAfterSystem.TryStartDoAfter(new DoAfterArgs(args.User, component.SpillDelay ?? 0, new SpillDoAfterEvent(), uid, target: uid)
-            {
-                BreakOnTargetMove = true,
-                BreakOnUserMove = true,
-                BreakOnDamage = true,
-                NeedHand = true,
-            });
-        };
-        verb.Impact = LogImpact.Medium; // dangerous reagent reaction are logged separately.
-        verb.DoContactInteraction = true;
-        args.Verbs.Add(verb);
-    }
-
-    /// <summary>
-    ///     First splashes reagent on reactive entities near the spilling entity, then spills the rest regularly to a
-    ///     puddle. This is intended for 'destructive' spills, like when entities are destroyed or thrown.
-    /// </summary>
-    public PuddleComponent? SplashSpillAt(EntityUid uid, Solution solution, EntityCoordinates coordinates, string prototype,
-        bool overflow = true, bool sound = true, bool combine = true, EntityUid? user=null)
-    {
-        if (solution.Volume == 0)
-            return null;
-
-        // Get reactive entities nearby--if there are some, it'll spill a bit on them instead.
-        foreach (var ent in _entityLookup.GetComponentsInRange<ReactiveComponent>(coordinates, 1.0f))
-        {
-            // sorry! no overload for returning uid, so .owner must be used
-            var owner = ent.Owner;
-
-            // between 5 and 30%
-            var splitAmount = solution.Volume * _random.NextFloat(0.05f, 0.30f);
-            var splitSolution = solution.SplitSolution(splitAmount);
-
-            if (user != null)
-            {
-                _adminLogger.Add(LogType.Landed,
-                    $"{ToPrettyString(user.Value):user} threw {ToPrettyString(uid):entity} which splashed a solution {SolutionContainerSystem.ToPrettyString(solution):solution} onto {ToPrettyString(owner):target}");
-            }
-
-            _reactive.DoEntityReaction(owner, splitSolution, ReactionMethod.Touch);
-            _popup.PopupEntity(Loc.GetString("spill-land-spilled-on-other", ("spillable", uid), ("target", owner)), owner, PopupType.SmallCaution);
-        }
-
-        return SpillAt(solution, coordinates, prototype, overflow, sound, combine: combine);
-    }
-
-    /// <summary>
-    ///     Spills solution at the specified grid coordinates.
-    /// </summary>
-    /// <param name="solution">Initial solution for the prototype.</param>
-    /// <param name="coordinates">The coordinates to spill the solution at.</param>
-    /// <param name="prototype">The prototype to use.</param>
-    /// <param name="overflow">If the puddle overflow will be calculated. Defaults to true.</param>
-    /// <param name="sound">Whether or not to play the spill sound.</param>
-    /// <param name="combine">Whether to attempt to merge with existing puddles</param>
-    /// <returns>The puddle if one was created, null otherwise.</returns>
-    public PuddleComponent? SpillAt(Solution solution, EntityCoordinates coordinates, string prototype,
-        bool overflow = true, bool sound = true, bool combine = true)
-    {
-        if (solution.Volume == 0) return null;
-
-        if (!_mapManager.TryGetGrid(coordinates.GetGridUid(EntityManager), out var mapGrid))
-            return null; // Let's not spill to space.
-
-        return SpillAt(mapGrid.GetTileRef(coordinates), solution, prototype, overflow, sound,
-            combine: combine);
-    }
-
-    public bool TryGetPuddle(TileRef tileRef, [NotNullWhen(true)] out PuddleComponent? puddle)
-    {
-        foreach (var entity in _entityLookup.GetEntitiesIntersecting(tileRef))
-        {
-            if (EntityManager.TryGetComponent(entity, out PuddleComponent? p))
-            {
-                puddle = p;
-                return true;
-            }
-        }
-
-        puddle = null;
-        return false;
-    }
-
-    public PuddleComponent? SpillAt(TileRef tileRef, Solution solution, string prototype,
-        bool overflow = true, bool sound = true, bool noTileReact = false, bool combine = true)
-    {
-        if (solution.Volume <= 0) return null;
-
-        // If space return early, let that spill go out into the void
-        if (tileRef.Tile.IsEmpty) return null;
-
-        var gridId = tileRef.GridUid;
-        if (!_mapManager.TryGetGrid(gridId, out var mapGrid)) return null; // Let's not spill to invalid grids.
-
-        if (!noTileReact)
-        {
-            // First, do all tile reactions
-            for (var i = 0; i < solution.Contents.Count; i++)
-            {
-                var (reagentId, quantity) = solution.Contents[i];
-                var proto = _prototypeManager.Index<ReagentPrototype>(reagentId);
-                var removed = proto.ReactionTile(tileRef, quantity);
-                if (removed <= FixedPoint2.Zero) continue;
-                solution.RemoveReagent(reagentId, removed);
-            }
-        }
-
-        // Tile reactions used up everything.
-        if (solution.Volume == FixedPoint2.Zero)
-            return null;
-
-        // Get normalized co-ordinate for spill location and spill it in the centre
-        // TODO: Does SnapGrid or something else already do this?
-        var spillGridCoords = mapGrid.GridTileToLocal(tileRef.GridIndices);
-        var startEntity = EntityUid.Invalid;
-        PuddleComponent? puddleComponent = null;
-
-        if (combine)
-        {
-            var spillEntities = _entityLookup.GetEntitiesIntersecting(tileRef).ToArray();
-
-            foreach (var spillEntity in spillEntities)
-            {
-                if (!EntityManager.TryGetComponent(spillEntity, out puddleComponent)) continue;
-
-                if (!overflow && _puddleSystem.WouldOverflow(puddleComponent.Owner, solution, puddleComponent))
-                    return null;
-
-                if (!_puddleSystem.TryAddSolution(puddleComponent.Owner, solution, sound, overflow)) continue;
-
-                startEntity = puddleComponent.Owner;
-                break;
-            }
-        }
-
-        if (startEntity != EntityUid.Invalid)
-            return puddleComponent;
-
-        startEntity = EntityManager.SpawnEntity(prototype, spillGridCoords);
-        puddleComponent = EntityManager.EnsureComponent<PuddleComponent>(startEntity);
-        _puddleSystem.TryAddSolution(startEntity, solution, sound, overflow);
-
-        return puddleComponent;
-    }
-
-    private void OnDoAfter(EntityUid uid, SpillableComponent component, DoAfterEvent args)
-    {
-        if (args.Handled || args.Cancelled || args.Args.Target == null)
-            return;
-
-        //solution gone by other means before doafter completes
-        if (!_solutionContainerSystem.TryGetDrainableSolution(uid, out var solution) || solution.Volume == 0)
-            return;
-
-        var puddleSolution = _solutionContainerSystem.SplitSolution(uid, solution, solution.Volume);
-
-        SpillAt(puddleSolution, Transform(uid).Coordinates, "PuddleSmear");
-
-        args.Handled = true;
-    }
-}
diff --git a/Content.Server/Kudzu/GrowingKudzuComponent.cs b/Content.Server/Kudzu/GrowingKudzuComponent.cs
deleted file mode 100644 (file)
index 11f6853..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-namespace Content.Server.Kudzu;
-
-[RegisterComponent]
-public sealed class GrowingKudzuComponent : Component
-{
-    [DataField("growthLevel")]
-    public int GrowthLevel = 1;
-
-    [DataField("growthTickSkipChance")]
-    public float GrowthTickSkipChange = 0.0f;
-}
diff --git a/Content.Server/Kudzu/GrowingKudzuSystem.cs b/Content.Server/Kudzu/GrowingKudzuSystem.cs
deleted file mode 100644 (file)
index 78ed38d..0000000
+++ /dev/null
@@ -1,54 +0,0 @@
-using Content.Shared.Kudzu;
-using Robust.Server.GameObjects;
-using Robust.Shared.Random;
-
-namespace Content.Server.Kudzu;
-
-public sealed class GrowingKudzuSystem : EntitySystem
-{
-    [Dependency] private readonly IRobustRandom _robustRandom = default!;
-    [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
-
-    private float _accumulatedFrameTime = 0.0f;
-
-    public override void Initialize()
-    {
-        SubscribeLocalEvent<GrowingKudzuComponent, ComponentAdd>(SetupKudzu);
-    }
-
-    private void SetupKudzu(EntityUid uid, GrowingKudzuComponent component, ComponentAdd args)
-    {
-        if (!EntityManager.TryGetComponent<AppearanceComponent>(uid, out var appearance))
-        {
-            return;
-        }
-
-        _appearance.SetData(uid, KudzuVisuals.Variant, _robustRandom.Next(1, 3), appearance);
-        _appearance.SetData(uid, KudzuVisuals.GrowthLevel, 1, appearance);
-    }
-
-    public override void Update(float frameTime)
-    {
-        _accumulatedFrameTime += frameTime;
-
-        if (!(_accumulatedFrameTime >= 0.5f))
-            return;
-
-        _accumulatedFrameTime -= 0.5f;
-
-        foreach (var (kudzu, appearance) in EntityManager.EntityQuery<GrowingKudzuComponent, AppearanceComponent>())
-        {
-            if (kudzu.GrowthLevel >= 3 || !_robustRandom.Prob(kudzu.GrowthTickSkipChange)) continue;
-            kudzu.GrowthLevel += 1;
-
-            if (kudzu.GrowthLevel == 3 &&
-                EntityManager.TryGetComponent<SpreaderComponent>((kudzu).Owner, out var spreader))
-            {
-                // why cache when you can simply cease to be? Also saves a bit of memory/time.
-                EntityManager.RemoveComponent<GrowingKudzuComponent>((kudzu).Owner);
-            }
-
-            _appearance.SetData(kudzu.Owner, KudzuVisuals.GrowthLevel, kudzu.GrowthLevel, appearance);
-        }
-    }
-}
diff --git a/Content.Server/Kudzu/SpreaderComponent.cs b/Content.Server/Kudzu/SpreaderComponent.cs
deleted file mode 100644 (file)
index 88c9ba8..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-namespace Content.Server.Kudzu;
-
-/// <summary>
-/// Component for rapidly spreading objects, like Kudzu.
-/// ONLY USE THIS FOR ANCHORED OBJECTS. An error will be logged if not anchored/static.
-/// Currently does not support growing in space.
-/// </summary>
-[RegisterComponent, Access(typeof(SpreaderSystem))]
-public sealed class SpreaderComponent : Component
-{
-    /// <summary>
-    /// Chance for it to grow on any given tick, after the normal growth rate-limit (if it doesn't grow, SpreaderSystem will pick another one.).
-    /// </summary>
-    [DataField("chance", required: true)]
-    public float Chance;
-
-    /// <summary>
-    /// Prototype spawned on growth success.
-    /// </summary>
-    [DataField("growthResult", required: true)]
-    public string GrowthResult = default!;
-
-    [DataField("enabled")]
-    public bool Enabled = true;
-}
diff --git a/Content.Server/Kudzu/SpreaderSystem.cs b/Content.Server/Kudzu/SpreaderSystem.cs
deleted file mode 100644 (file)
index d75c8ad..0000000
+++ /dev/null
@@ -1,140 +0,0 @@
-using System.Linq;
-using Content.Server.Atmos.Components;
-using Content.Server.Atmos.EntitySystems;
-using Content.Shared.Atmos;
-using Robust.Shared.Map;
-using Robust.Shared.Map.Components;
-using Robust.Shared.Random;
-
-namespace Content.Server.Kudzu;
-
-// Future work includes making the growths per interval thing not global, but instead per "group"
-public sealed class SpreaderSystem : EntitySystem
-{
-    [Dependency] private readonly IRobustRandom _robustRandom = default!;
-    [Dependency] private readonly IMapManager _mapManager = default!;
-
-    /// <summary>
-    /// Maximum number of edges that can grow out every interval.
-    /// </summary>
-    private const int GrowthsPerInterval = 1;
-
-    private float _accumulatedFrameTime = 0.0f;
-
-    private readonly HashSet<EntityUid> _edgeGrowths = new ();
-
-    public override void Initialize()
-    {
-        SubscribeLocalEvent<SpreaderComponent, ComponentAdd>(SpreaderAddHandler);
-        SubscribeLocalEvent<AirtightChanged>(OnAirtightChanged);
-    }
-
-    private void OnAirtightChanged(ref AirtightChanged ev)
-    {
-        UpdateNearbySpreaders(ev.Entity, ev.Airtight);
-    }
-
-    private void SpreaderAddHandler(EntityUid uid, SpreaderComponent component, ComponentAdd args)
-    {
-        if (component.Enabled)
-            _edgeGrowths.Add(uid); // ez
-    }
-
-    public void UpdateNearbySpreaders(EntityUid blocker, AirtightComponent comp)
-    {
-        if (!EntityManager.TryGetComponent<TransformComponent>(blocker, out var transform))
-            return; // how did we get here?
-
-        if (!_mapManager.TryGetGrid(transform.GridUid, out var grid)) return;
-
-        var spreaderQuery = GetEntityQuery<SpreaderComponent>();
-        var tile = grid.TileIndicesFor(transform.Coordinates);
-
-        for (var i = 0; i < Atmospherics.Directions; i++)
-        {
-            var direction = (AtmosDirection) (1 << i);
-            if (!comp.AirBlockedDirection.IsFlagSet(direction)) continue;
-
-            var directionEnumerator =
-                grid.GetAnchoredEntitiesEnumerator(SharedMapSystem.GetDirection(tile, direction.ToDirection()));
-
-            while (directionEnumerator.MoveNext(out var ent))
-            {
-                if (spreaderQuery.TryGetComponent(ent, out var s) && s.Enabled)
-                    _edgeGrowths.Add(ent.Value);
-            }
-        }
-    }
-
-    public override void Update(float frameTime)
-    {
-        _accumulatedFrameTime += frameTime;
-
-        if (!(_accumulatedFrameTime >= 1.0f))
-            return;
-
-        _accumulatedFrameTime -= 1.0f;
-
-        var growthList = _edgeGrowths.ToList();
-        _robustRandom.Shuffle(growthList);
-
-        var successes = 0;
-        foreach (var entity in growthList)
-        {
-            if (!TryGrow(entity)) continue;
-
-            successes += 1;
-            if (successes >= GrowthsPerInterval)
-                break;
-        }
-    }
-
-    private bool TryGrow(EntityUid ent, TransformComponent? transform = null, SpreaderComponent? spreader = null)
-    {
-        if (!Resolve(ent, ref transform, ref spreader, false))
-            return false;
-
-        if (spreader.Enabled == false) return false;
-
-        if (!_mapManager.TryGetGrid(transform.GridUid, out var grid)) return false;
-
-        var didGrow = false;
-
-        for (var i = 0; i < 4; i++)
-        {
-            var direction = (DirectionFlag) (1 << i);
-            var coords = transform.Coordinates.Offset(direction.AsDir().ToVec());
-            if (grid.GetTileRef(coords).Tile.IsEmpty || _robustRandom.Prob(1 - spreader.Chance)) continue;
-            var ents = grid.GetLocal(coords);
-
-            if (ents.Any(x => IsTileBlockedFrom(x, direction))) continue;
-
-            // Ok, spawn a plant
-            didGrow = true;
-            EntityManager.SpawnEntity(spreader.GrowthResult, transform.Coordinates.Offset(direction.AsDir().ToVec()));
-        }
-
-        return didGrow;
-    }
-
-    public void EnableSpreader(EntityUid ent, SpreaderComponent? component = null)
-    {
-        if (!Resolve(ent, ref component))
-            return;
-        component.Enabled = true;
-        _edgeGrowths.Add(ent);
-    }
-
-    private bool IsTileBlockedFrom(EntityUid ent, DirectionFlag dir)
-    {
-        if (EntityManager.TryGetComponent<SpreaderComponent>(ent, out _))
-            return true;
-
-        if (!EntityManager.TryGetComponent<AirtightComponent>(ent, out var airtight))
-            return false;
-
-        var oppositeDir = dir.AsDir().GetOpposite().ToAtmosDirection();
-
-        return airtight.AirBlocked && airtight.AirBlockedDirection.IsFlagSet(oppositeDir);
-    }
-}
index a36bc01ed665b028ade5aa82a34f15735d1bd04d..57e8a257dd7eb54fc67b8d55f486f8540ced71dd 100644 (file)
@@ -37,7 +37,7 @@ namespace Content.Server.Medical.BiomassReclaimer
         [Dependency] private readonly SharedAudioSystem _sharedAudioSystem = default!;
         [Dependency] private readonly SharedAmbientSoundSystem _ambientSoundSystem = default!;
         [Dependency] private readonly SharedPopupSystem _popup = default!;
-        [Dependency] private readonly SpillableSystem _spillableSystem = default!;
+        [Dependency] private readonly PuddleSystem _puddleSystem = default!;
         [Dependency] private readonly ThrowingSystem _throwing = default!;
         [Dependency] private readonly IRobustRandom _robustRandom = default!;
         [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
@@ -60,7 +60,7 @@ namespace Content.Server.Medical.BiomassReclaimer
                     {
                         Solution blood = new();
                         blood.AddReagent(reclaimer.BloodReagent, 50);
-                        _spillableSystem.SpillAt(reclaimer.Owner, blood, "PuddleBlood");
+                        _puddleSystem.TrySpillAt(reclaimer.Owner, blood, out _);
                     }
                     if (_robustRandom.Prob(0.03f) && reclaimer.SpawnedEntities.Count > 0)
                     {
index af7989d7aedee3ec1598e387da56a168d8f0232f..897ecb1ce9d533908075ea07709897e0aef9c138 100644 (file)
@@ -7,6 +7,8 @@ using Content.Server.Nutrition.Components;
 using Content.Server.Nutrition.EntitySystems;
 using Content.Server.Popups;
 using Content.Server.Stunnable;
+using Content.Shared.Audio;
+using Content.Shared.Fluids.Components;
 using Content.Shared.IdentityManagement;
 using Content.Shared.Nutrition.Components;
 using Content.Shared.Nutrition.EntitySystems;
index b014f5dba056d457bfd8a2afb0fc1bc03772afed..56b1cee3d1eb3206afb06daabee49093b46bc6df 100644 (file)
@@ -345,7 +345,7 @@ namespace Content.Server.NodeContainer.EntitySystems
             _mapManager.TryGetGrid(xform.GridUid, out var grid);
 
             if (!node.Connectable(EntityManager, xform))
-                    yield break;
+                yield break;
 
             foreach (var reachable in node.GetReachableNodes(xform, nodeQuery, xformQuery, grid, EntityManager))
             {
index b91a866084aa9f767c5566ae828eeef4bd515d66..e4dac00683c1426eb3f94d35469888de32fb96f2 100644 (file)
@@ -59,6 +59,7 @@ namespace Content.Server.NodeContainer.NodeGroups
         Apc,
         AMEngine,
         Pipe,
-        WireNet
+        WireNet,
+        Spreader,
     }
 }
index d983b3e1471c9560e535e539c62ad02ef8290aa5..1f1164f3d444ee37612f1f4af806e3ed1fd5ff75 100644 (file)
@@ -21,7 +21,7 @@ namespace Content.Server.Nutrition.EntitySystems
     public sealed class CreamPieSystem : SharedCreamPieSystem
     {
         [Dependency] private readonly SolutionContainerSystem _solutions = default!;
-        [Dependency] private readonly SpillableSystem _spillable = default!;
+        [Dependency] private readonly PuddleSystem _puddle = default!;
         [Dependency] private readonly ItemSlotsSystem _itemSlots = default!;
         [Dependency] private readonly TriggerSystem _trigger = default!;
         [Dependency] private readonly SharedAudioSystem _audio = default!;
@@ -43,7 +43,7 @@ namespace Content.Server.Nutrition.EntitySystems
             {
                 if (_solutions.TryGetSolution(uid, foodComp.SolutionName, out var solution))
                 {
-                    _spillable.SpillAt(uid, solution, "PuddleSmear", false);
+                    _puddle.TrySpillAt(uid, solution, out _, false);
                 }
                 if (!string.IsNullOrEmpty(foodComp.TrashPrototype))
                 {
@@ -62,9 +62,9 @@ namespace Content.Server.Nutrition.EntitySystems
 
         private void ActivatePayload(EntityUid uid)
         {
-            if (_itemSlots.TryGetSlot(uid, CreamPieComponent.PayloadSlotName, out var itemSlot)) 
+            if (_itemSlots.TryGetSlot(uid, CreamPieComponent.PayloadSlotName, out var itemSlot))
             {
-                if (_itemSlots.TryEject(uid, itemSlot, user: null, out var item)) 
+                if (_itemSlots.TryEject(uid, itemSlot, user: null, out var item))
                 {
                     if (TryComp<OnUseTimerTriggerComponent>(item.Value, out var timerTrigger))
                     {
@@ -85,7 +85,7 @@ namespace Content.Server.Nutrition.EntitySystems
         {
             _popup.PopupEntity(Loc.GetString("cream-pied-component-on-hit-by-message",("thrower", args.Thrown)), uid, args.Target);
             var otherPlayers = Filter.Empty().AddPlayersByPvs(uid);
-            if (TryComp<ActorComponent>(args.Target, out var actor)) 
+            if (TryComp<ActorComponent>(args.Target, out var actor))
             {
                 otherPlayers.RemovePlayer(actor.PlayerSession);
             }
index 64efdc89fe54126495cc1958efcf1e40c7f45d6b..470803298bb84a1bebf6a2d4200b87edcf0f37a5 100644 (file)
@@ -9,6 +9,7 @@ using Content.Server.Popups;
 using Content.Shared.Administration.Logs;
 using Content.Shared.Body.Components;
 using Content.Shared.Chemistry;
+using Content.Shared.Chemistry.Components;
 using Content.Shared.Chemistry.Reagent;
 using Content.Shared.Database;
 using Content.Shared.DoAfter;
@@ -44,7 +45,7 @@ namespace Content.Server.Nutrition.EntitySystems
         [Dependency] private readonly SharedDoAfterSystem _doAfterSystem = default!;
         [Dependency] private readonly ISharedAdminLogManager _adminLogger = default!;
         [Dependency] private readonly MobStateSystem _mobStateSystem = default!;
-        [Dependency] private readonly SpillableSystem _spillableSystem = default!;
+        [Dependency] private readonly PuddleSystem _puddleSystem = default!;
         [Dependency] private readonly SharedInteractionSystem _interactionSystem = default!;
         [Dependency] private readonly SharedAppearanceSystem _appearanceSystem = default!;
         [Dependency] private readonly SharedAudioSystem _audio = default!;
@@ -85,7 +86,7 @@ namespace Content.Server.Nutrition.EntitySystems
             args.Message.AddMarkup($"\n{Loc.GetString("drink-component-on-examine-details-text", ("colorName", color), ("text", openedText))}");
             if (!IsEmpty(uid, component))
             {
-                if (TryComp<ExaminableSolutionComponent>(component.Owner, out var comp))
+                if (TryComp<ExaminableSolutionComponent>(uid, out var comp))
                 {
                     //provide exact measurement for beakers
                     args.Message.AddMarkup($" - {Loc.GetString("drink-component-on-examine-exact-volume", ("amount", _solutionContainerSystem.DrainAvailable(uid)))}");
@@ -160,7 +161,7 @@ namespace Content.Server.Nutrition.EntitySystems
                 UpdateAppearance(component);
 
                 var solution = _solutionContainerSystem.Drain(uid, interactions, interactions.Volume);
-                _spillableSystem.SpillAt(uid, solution, "PuddleSmear");
+                _puddleSystem.TrySpillAt(uid, solution, out _);
 
                 _audio.PlayPvs(_audio.GetSound(component.BurstSound), uid, AudioParams.Default.WithVolume(-4));
             }
@@ -314,7 +315,7 @@ namespace Content.Server.Nutrition.EntitySystems
 
                 if (HasComp<RefillableSolutionComponent>(args.Args.Target.Value))
                 {
-                    _spillableSystem.SpillAt(args.Args.User, drained, "PuddleSmear");
+                    _puddleSystem.TrySpillAt(args.Args.User, drained, out _);
                     args.Handled = true;
                     return;
                 }
@@ -334,7 +335,7 @@ namespace Content.Server.Nutrition.EntitySystems
                 if (forceDrink)
                 {
                     _popupSystem.PopupEntity(Loc.GetString("drink-component-try-use-drink-had-enough-other"), args.Args.Target.Value, args.Args.User);
-                    _spillableSystem.SpillAt(args.Args.Target.Value, drained, "PuddleSmear");
+                    _puddleSystem.TrySpillAt(args.Args.Target.Value, drained, out _);
                 }
                 else
                     _solutionContainerSystem.TryAddSolution(uid, solution, drained);
diff --git a/Content.Server/Spreader/EdgeSpreaderComponent.cs b/Content.Server/Spreader/EdgeSpreaderComponent.cs
new file mode 100644 (file)
index 0000000..8a5c231
--- /dev/null
@@ -0,0 +1,10 @@
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Added to entities being considered for spreading via <see cref="SpreaderSystem"/>.
+/// This needs to be manually added and removed.
+/// </summary>
+[RegisterComponent, Access(typeof(SpreaderSystem))]
+public sealed class EdgeSpreaderComponent : Component
+{
+}
diff --git a/Content.Server/Spreader/EdgeSpreaderPrototype.cs b/Content.Server/Spreader/EdgeSpreaderPrototype.cs
new file mode 100644 (file)
index 0000000..c407559
--- /dev/null
@@ -0,0 +1,12 @@
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Adds this node group to <see cref="SpreaderSystem"/> for tick updates.
+/// </summary>
+[Prototype("edgeSpreader")]
+public sealed class EdgeSpreaderPrototype : IPrototype
+{
+    [IdDataField] public string ID { get; } = string.Empty;
+}
diff --git a/Content.Server/Spreader/GrowingKudzuComponent.cs b/Content.Server/Spreader/GrowingKudzuComponent.cs
new file mode 100644 (file)
index 0000000..52a122e
--- /dev/null
@@ -0,0 +1,22 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Spreader;
+
+[RegisterComponent, Access(typeof(KudzuSystem))]
+public sealed class GrowingKudzuComponent : Component
+{
+    /// <summary>
+    /// At level 3 spreading can occur; prior to that we have a chance of increasing our growth level and changing our sprite.
+    /// </summary>
+    [DataField("growthLevel")]
+    public int GrowthLevel = 1;
+
+    [DataField("growthTickChance")]
+    public float GrowthTickChance = 1f;
+
+    /// <summary>
+    /// The next time kudzu will try to tick its growth level.
+    /// </summary>
+    [DataField("nextTick", customTypeSerializer:typeof(TimeOffsetSerializer))]
+    public TimeSpan NextTick = TimeSpan.Zero;
+}
diff --git a/Content.Server/Spreader/KudzuComponent.cs b/Content.Server/Spreader/KudzuComponent.cs
new file mode 100644 (file)
index 0000000..ae4aedf
--- /dev/null
@@ -0,0 +1,14 @@
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Handles entities that spread out when they reach the relevant growth level.
+/// </summary>
+[RegisterComponent]
+public sealed class KudzuComponent : Component
+{
+    /// <summary>
+    /// Chance to spread whenever an edge spread is possible.
+    /// </summary>
+    [DataField("spreadChance")]
+    public float SpreadChance = 1f;
+}
diff --git a/Content.Server/Spreader/KudzuSystem.cs b/Content.Server/Spreader/KudzuSystem.cs
new file mode 100644 (file)
index 0000000..7e35fb3
--- /dev/null
@@ -0,0 +1,114 @@
+using Content.Shared.Kudzu;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Spreader;
+
+public sealed class KudzuSystem : EntitySystem
+{
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly IRobustRandom _robustRandom = default!;
+    [Dependency] private readonly SharedAppearanceSystem _appearance = default!;
+
+    private const string KudzuGroup = "kudzu";
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<KudzuComponent, ComponentStartup>(SetupKudzu);
+        SubscribeLocalEvent<KudzuComponent, SpreadNeighborsEvent>(OnKudzuSpread);
+        SubscribeLocalEvent<GrowingKudzuComponent, EntityUnpausedEvent>(OnKudzuUnpaused);
+        SubscribeLocalEvent<SpreadGroupUpdateRate>(OnKudzuUpdateRate);
+    }
+
+    private void OnKudzuSpread(EntityUid uid, KudzuComponent component, ref SpreadNeighborsEvent args)
+    {
+        if (TryComp<GrowingKudzuComponent>(uid, out var growing) && growing.GrowthLevel < 3)
+        {
+            return;
+        }
+
+        if (args.NeighborFreeTiles.Count == 0 || args.Grid == null)
+        {
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
+        }
+
+        var prototype = MetaData(uid).EntityPrototype?.ID;
+
+        if (prototype == null)
+        {
+            RemCompDeferred<EdgeSpreaderComponent>(uid);
+            return;
+        }
+
+        if (!_robustRandom.Prob(component.SpreadChance))
+            return;
+
+        foreach (var neighbor in args.NeighborFreeTiles)
+        {
+            var neighborUid = Spawn(prototype, args.Grid.GridTileToLocal(neighbor));
+            EnsureComp<EdgeSpreaderComponent>(neighborUid);
+            args.Updates--;
+
+            if (args.Updates <= 0)
+                return;
+        }
+    }
+
+    private void OnKudzuUpdateRate(ref SpreadGroupUpdateRate args)
+    {
+        if (args.Name != KudzuGroup)
+            return;
+
+        args.UpdatesPerSecond = 1;
+    }
+
+    private void OnKudzuUnpaused(EntityUid uid, GrowingKudzuComponent component, ref EntityUnpausedEvent args)
+    {
+        component.NextTick += args.PausedTime;
+    }
+
+    private void SetupKudzu(EntityUid uid, KudzuComponent component, ComponentStartup args)
+    {
+        if (!EntityManager.TryGetComponent<AppearanceComponent>(uid, out var appearance))
+        {
+            return;
+        }
+
+        _appearance.SetData(uid, KudzuVisuals.Variant, _robustRandom.Next(1, 3), appearance);
+        _appearance.SetData(uid, KudzuVisuals.GrowthLevel, 1, appearance);
+    }
+
+    /// <inheritdoc/>
+    public override void Update(float frameTime)
+    {
+        var query = EntityQueryEnumerator<GrowingKudzuComponent, AppearanceComponent>();
+        var curTime = _timing.CurTime;
+
+        while (query.MoveNext(out var uid, out var kudzu, out var appearance))
+        {
+            if (kudzu.NextTick > curTime)
+            {
+                continue;
+            }
+
+            kudzu.NextTick = curTime + TimeSpan.FromSeconds(0.5);
+
+            if (!_robustRandom.Prob(kudzu.GrowthTickChance))
+            {
+                continue;
+            }
+
+            kudzu.GrowthLevel += 1;
+
+            if (kudzu.GrowthLevel >= 3)
+            {
+                // why cache when you can simply cease to be? Also saves a bit of memory/time.
+                RemCompDeferred<GrowingKudzuComponent>(uid);
+            }
+
+            _appearance.SetData(uid, KudzuVisuals.GrowthLevel, kudzu.GrowthLevel, appearance);
+        }
+    }
+}
diff --git a/Content.Server/Spreader/SpreadGroupUpdateRate.cs b/Content.Server/Spreader/SpreadGroupUpdateRate.cs
new file mode 100644 (file)
index 0000000..03c7c11
--- /dev/null
@@ -0,0 +1,7 @@
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Raised every tick to determine how many updates a particular spreading node group is allowed.
+/// </summary>
+[ByRefEvent]
+public record struct SpreadGroupUpdateRate(string Name, int UpdatesPerSecond = 16);
\ No newline at end of file
diff --git a/Content.Server/Spreader/SpreadNeighborsEvent.cs b/Content.Server/Spreader/SpreadNeighborsEvent.cs
new file mode 100644 (file)
index 0000000..723e510
--- /dev/null
@@ -0,0 +1,23 @@
+using Robust.Shared.Collections;
+using Robust.Shared.Map.Components;
+
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Raised when trying to spread to neighboring tiles.
+/// If the spread is no longer able to happen you MUST cancel this event!
+/// </summary>
+[ByRefEvent]
+public record struct SpreadNeighborsEvent
+{
+    public MapGridComponent? Grid;
+    public ValueList<Vector2i> NeighborFreeTiles;
+    public ValueList<Vector2i> NeighborOccupiedTiles;
+    public ValueList<EntityUid> Neighbors;
+
+    /// <summary>
+    /// How many updates allowed are remaining.
+    /// Subscribers can handle as they wish.
+    /// </summary>
+    public int Updates;
+}
\ No newline at end of file
diff --git a/Content.Server/Spreader/SpreaderGridComponent.cs b/Content.Server/Spreader/SpreaderGridComponent.cs
new file mode 100644 (file)
index 0000000..2dff8d8
--- /dev/null
@@ -0,0 +1,10 @@
+using Robust.Shared.Serialization.TypeSerializers.Implementations.Custom;
+
+namespace Content.Server.Spreader;
+
+[RegisterComponent]
+public sealed class SpreaderGridComponent : Component
+{
+    [DataField("nextUpdate", customTypeSerializer:typeof(TimeOffsetSerializer))]
+    public TimeSpan NextUpdate = TimeSpan.Zero;
+}
diff --git a/Content.Server/Spreader/SpreaderNode.cs b/Content.Server/Spreader/SpreaderNode.cs
new file mode 100644 (file)
index 0000000..3cf753f
--- /dev/null
@@ -0,0 +1,33 @@
+using Content.Server.NodeContainer;
+using Content.Server.NodeContainer.Nodes;
+using Robust.Shared.Map.Components;
+
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Handles the node for <see cref="EdgeSpreaderComponent"/>.
+/// Functions as a generic tile-based entity spreader for systems such as puddles or smoke.
+/// </summary>
+public sealed class SpreaderNode : Node
+{
+    /// <inheritdoc/>
+    public override IEnumerable<Node> GetReachableNodes(TransformComponent xform, EntityQuery<NodeContainerComponent> nodeQuery, EntityQuery<TransformComponent> xformQuery,
+        MapGridComponent? grid, IEntityManager entMan)
+    {
+        if (grid == null)
+            yield break;
+
+        entMan.System<SpreaderSystem>().GetNeighbors(xform.Owner, Name, out _, out _, out var neighbors);
+
+        foreach (var neighbor in neighbors)
+        {
+            if (!nodeQuery.TryGetComponent(neighbor, out var nodeContainer) ||
+                !nodeContainer.TryGetNode<SpreaderNode>(Name, out var neighborNode))
+            {
+                continue;
+            }
+
+            yield return neighborNode;
+        }
+    }
+}
diff --git a/Content.Server/Spreader/SpreaderNodeGroup.cs b/Content.Server/Spreader/SpreaderNodeGroup.cs
new file mode 100644 (file)
index 0000000..a94ad83
--- /dev/null
@@ -0,0 +1,31 @@
+using Content.Server.NodeContainer.NodeGroups;
+using Content.Server.NodeContainer.Nodes;
+
+namespace Content.Server.Spreader;
+
+[NodeGroup(NodeGroupID.Spreader)]
+public sealed class SpreaderNodeGroup : BaseNodeGroup
+{
+    private IEntityManager _entManager = default!;
+
+    /// <inheritdoc/>
+    public override void Initialize(Node sourceNode, IEntityManager entMan)
+    {
+        base.Initialize(sourceNode, entMan);
+        _entManager = entMan;
+    }
+
+    /// <inheritdoc/>
+    public override void RemoveNode(Node node)
+    {
+        base.RemoveNode(node);
+
+        foreach (var neighborNode in node.ReachableNodes)
+        {
+            if (_entManager.Deleted(neighborNode.Owner))
+                continue;
+
+            _entManager.EnsureComponent<EdgeSpreaderComponent>(neighborNode.Owner);
+        }
+    }
+}
diff --git a/Content.Server/Spreader/SpreaderSystem.cs b/Content.Server/Spreader/SpreaderSystem.cs
new file mode 100644 (file)
index 0000000..03b3e45
--- /dev/null
@@ -0,0 +1,324 @@
+using Content.Server.Atmos.Components;
+using Content.Server.Atmos.EntitySystems;
+using Content.Server.NodeContainer;
+using Content.Server.NodeContainer.NodeGroups;
+using Content.Shared.Atmos;
+using Robust.Shared.Collections;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Spreader;
+
+/// <summary>
+/// Handles generic spreading logic, where one anchored entity spreads to neighboring tiles.
+/// </summary>
+public sealed class SpreaderSystem : EntitySystem
+{
+    [Dependency] private readonly IGameTiming _timing = default!;
+    [Dependency] private readonly IMapManager _mapManager = default!;
+    [Dependency] private readonly IPrototypeManager _prototype = default!;
+    [Dependency] private readonly IRobustRandom _robustRandom = default!;
+
+    private static readonly TimeSpan SpreadCooldown = TimeSpan.FromSeconds(1);
+
+    private readonly List<string> _spreaderGroups = new();
+
+    /// <inheritdoc/>
+    public override void Initialize()
+    {
+        SubscribeLocalEvent<AirtightChanged>(OnAirtightChanged);
+        SubscribeLocalEvent<GridInitializeEvent>(OnGridInit);
+        SubscribeLocalEvent<SpreaderGridComponent, EntityUnpausedEvent>(OnGridUnpaused);
+
+        SetupPrototypes();
+        _prototype.PrototypesReloaded += OnPrototypeReload;
+    }
+
+    public override void Shutdown()
+    {
+        base.Shutdown();
+        _prototype.PrototypesReloaded -= OnPrototypeReload;
+    }
+
+    private void OnPrototypeReload(PrototypesReloadedEventArgs obj)
+    {
+        if (!obj.ByType.ContainsKey(typeof(EdgeSpreaderPrototype)))
+            return;
+
+        SetupPrototypes();
+    }
+
+    private void SetupPrototypes()
+    {
+        _spreaderGroups.Clear();
+
+        foreach (var id in _prototype.EnumeratePrototypes<EdgeSpreaderPrototype>())
+        {
+            _spreaderGroups.Add(id.ID);
+        }
+    }
+
+    private void OnAirtightChanged(ref AirtightChanged ev)
+    {
+        var neighbors = GetNeighbors(ev.Entity, ev.Airtight);
+
+        foreach (var neighbor in neighbors)
+        {
+            EnsureComp<EdgeSpreaderComponent>(neighbor);
+        }
+    }
+
+    private void OnGridUnpaused(EntityUid uid, SpreaderGridComponent component, ref EntityUnpausedEvent args)
+    {
+        component.NextUpdate += args.PausedTime;
+    }
+
+    private void OnGridInit(GridInitializeEvent ev)
+    {
+        var comp = EnsureComp<SpreaderGridComponent>(ev.EntityUid);
+        var nextUpdate = _timing.CurTime;
+
+        // TODO: I believe we need grid mapinit events so we can set the time correctly only on mapinit
+        // and not touch it on regular init.
+        if (comp.NextUpdate < nextUpdate)
+            comp.NextUpdate = nextUpdate;
+    }
+
+    /// <inheritdoc/>
+    public override void Update(float frameTime)
+    {
+        var curTime = _timing.CurTime;
+
+        // Check which grids are valid for spreading.
+        var spreadable = new ValueList<EntityUid>();
+        var spreadGrids = EntityQueryEnumerator<SpreaderGridComponent>();
+
+        while (spreadGrids.MoveNext(out var uid, out var grid))
+        {
+            if (grid.NextUpdate > curTime)
+                continue;
+
+            spreadable.Add(uid);
+            grid.NextUpdate += SpreadCooldown;
+        }
+
+        if (spreadable.Count == 0)
+            return;
+
+        var query = EntityQueryEnumerator<EdgeSpreaderComponent>();
+        var nodeQuery = GetEntityQuery<NodeContainerComponent>();
+        var xformQuery = GetEntityQuery<TransformComponent>();
+        var gridQuery = GetEntityQuery<SpreaderGridComponent>();
+
+        // Events and stuff
+        var groupUpdates = new Dictionary<INodeGroup, int>();
+        var spreaders = new List<(EntityUid Uid, EdgeSpreaderComponent Comp)>(Count<EdgeSpreaderComponent>());
+
+        while (query.MoveNext(out var uid, out var comp))
+        {
+            spreaders.Add((uid, comp));
+        }
+
+        _robustRandom.Shuffle(spreaders);
+
+        foreach (var (uid, comp) in spreaders)
+        {
+            if (!xformQuery.TryGetComponent(uid, out var xform) ||
+                xform.GridUid == null ||
+                !gridQuery.HasComponent(xform.GridUid.Value))
+            {
+                RemCompDeferred<EdgeSpreaderComponent>(uid);
+                continue;
+            }
+
+            foreach (var sGroup in _spreaderGroups)
+            {
+                // Cleanup
+                if (!nodeQuery.TryGetComponent(uid, out var nodeComponent))
+                {
+                    RemCompDeferred<EdgeSpreaderComponent>(uid);
+                    continue;
+                }
+
+                if (!nodeComponent.TryGetNode<SpreaderNode>(sGroup, out var node))
+                    continue;
+
+                // Not allowed this tick?
+                if (node.NodeGroup == null ||
+                    !spreadable.Contains(xform.GridUid.Value))
+                {
+                    continue;
+                }
+
+                // While we could check if it's an edge here the subscribing system may have its own definition
+                // of an edge so we'll let them handle it.
+                if (!groupUpdates.TryGetValue(node.NodeGroup, out var updates))
+                {
+                    var spreadEv = new SpreadGroupUpdateRate()
+                    {
+                        Name = node.Name,
+                    };
+                    RaiseLocalEvent(ref spreadEv);
+                    updates = (int) (spreadEv.UpdatesPerSecond * SpreadCooldown / TimeSpan.FromSeconds(1));
+                }
+
+                if (updates <= 0)
+                {
+                    continue;
+                }
+
+                Spread(uid, node, node.NodeGroup, ref updates);
+                groupUpdates[node.NodeGroup] = updates;
+            }
+        }
+    }
+
+    private void Spread(EntityUid uid, SpreaderNode node, INodeGroup group, ref int updates)
+    {
+        GetNeighbors(uid, node.Name, out var freeTiles, out var occupiedTiles, out var neighbors);
+
+        TryComp<MapGridComponent>(Transform(uid).GridUid, out var grid);
+
+        var ev = new SpreadNeighborsEvent()
+        {
+            Grid = grid,
+            NeighborFreeTiles = freeTiles,
+            NeighborOccupiedTiles = occupiedTiles,
+            Neighbors = neighbors,
+            Updates = updates,
+        };
+
+        RaiseLocalEvent(uid, ref ev);
+        updates = ev.Updates;
+    }
+
+    /// <summary>
+    /// Gets the neighboring node data for the specified entity and the specified node group.
+    /// </summary>
+    public void GetNeighbors(EntityUid uid, string groupName, out ValueList<Vector2i> freeTiles, out ValueList<Vector2i> occupiedTiles, out ValueList<EntityUid> neighbors)
+    {
+        freeTiles = new ValueList<Vector2i>();
+        occupiedTiles = new ValueList<Vector2i>();
+        neighbors = new ValueList<EntityUid>();
+
+        if (!EntityManager.TryGetComponent<TransformComponent>(uid, out var transform))
+            return;
+
+        if (!_mapManager.TryGetGrid(transform.GridUid, out var grid))
+            return;
+
+        var tile = grid.TileIndicesFor(transform.Coordinates);
+        var nodeQuery = GetEntityQuery<NodeContainerComponent>();
+        var airtightQuery = GetEntityQuery<AirtightComponent>();
+
+        for (var i = 0; i < 4; i++)
+        {
+            var direction = (Direction) (i * 2);
+            var neighborPos = SharedMapSystem.GetDirection(tile, direction);
+
+            if (!grid.TryGetTileRef(neighborPos, out var tileRef) || tileRef.Tile.IsEmpty)
+                continue;
+
+            var directionEnumerator =
+                grid.GetAnchoredEntitiesEnumerator(neighborPos);
+            var occupied = false;
+
+            while (directionEnumerator.MoveNext(out var ent))
+            {
+                if (airtightQuery.TryGetComponent(ent, out var airtight) && airtight.AirBlocked)
+                {
+                    // Check if air direction matters.
+                    var blocked = false;
+
+                    foreach (var value in new[] { AtmosDirection.North, AtmosDirection.East})
+                    {
+                        if ((value & airtight.AirBlockedDirection) == 0x0)
+                            continue;
+
+                        var airDirection = value.ToDirection();
+                        var oppositeDirection = value.ToDirection();
+
+                        if (direction != airDirection && direction != oppositeDirection)
+                            continue;
+
+                        blocked = true;
+                        break;
+                    }
+
+                    if (!blocked)
+                        continue;
+
+                    occupied = true;
+                    break;
+                }
+            }
+
+            if (occupied)
+                continue;
+
+            var oldCount = occupiedTiles.Count;
+            directionEnumerator =
+                grid.GetAnchoredEntitiesEnumerator(neighborPos);
+
+            while (directionEnumerator.MoveNext(out var ent))
+            {
+                if (!nodeQuery.TryGetComponent(ent, out var nodeContainer))
+                    continue;
+
+                if (!nodeContainer.Nodes.ContainsKey(groupName))
+                    continue;
+
+                neighbors.Add(ent.Value);
+                occupiedTiles.Add(neighborPos);
+                break;
+            }
+
+            if (oldCount == occupiedTiles.Count)
+                freeTiles.Add(neighborPos);
+        }
+    }
+
+    public List<EntityUid> GetNeighbors(EntityUid uid, AirtightComponent comp)
+    {
+        var neighbors = new List<EntityUid>();
+
+        if (!EntityManager.TryGetComponent<TransformComponent>(uid, out var transform))
+            return neighbors; // how did we get here?
+
+        if (!_mapManager.TryGetGrid(transform.GridUid, out var grid))
+            return neighbors;
+
+        var tile = grid.TileIndicesFor(transform.Coordinates);
+        var nodeQuery = GetEntityQuery<NodeContainerComponent>();
+
+        for (var i = 0; i < Atmospherics.Directions; i++)
+        {
+            var direction = (AtmosDirection) (1 << i);
+            if (!comp.AirBlockedDirection.IsFlagSet(direction))
+                continue;
+
+            var directionEnumerator =
+                grid.GetAnchoredEntitiesEnumerator(SharedMapSystem.GetDirection(tile, direction.ToDirection()));
+
+            while (directionEnumerator.MoveNext(out var ent))
+            {
+                if (!nodeQuery.TryGetComponent(ent, out var nodeContainer))
+                    continue;
+
+                foreach (var name in _spreaderGroups)
+                {
+                    if (!nodeContainer.Nodes.ContainsKey(name))
+                        continue;
+
+                    neighbors.Add(ent.Value);
+                    break;
+                }
+            }
+        }
+
+        return neighbors;
+    }
+}
index 429596de146da2c0d39aacfd254226b438640778..04e466f66b5c77dd3b660d110354a4cda5168ef2 100644 (file)
@@ -1,5 +1,4 @@
 using Content.Server.Atmos.Piping.Unary.Components;
-using Content.Server.Chemistry.ReactionEffects;
 using Content.Server.Station.Components;
 using Content.Shared.Chemistry.Components;
 using Content.Shared.Chemistry.Reagent;
@@ -7,6 +6,9 @@ using JetBrains.Annotations;
 using Robust.Shared.Audio;
 using Robust.Shared.Random;
 using System.Linq;
+using Content.Server.Chemistry.Components;
+using Content.Server.Fluids.EntitySystems;
+using Robust.Server.GameObjects;
 
 namespace Content.Server.StationEvents.Events;
 
@@ -43,6 +45,7 @@ public sealed class VentClog : StationEventSystem
             {
                 continue;
             }
+
             var solution = new Solution();
 
             if (!RobustRandom.Prob(Math.Min(0.33f * mod, 1.0f)))
@@ -50,15 +53,18 @@ public sealed class VentClog : StationEventSystem
 
             if (RobustRandom.Prob(Math.Min(0.05f * mod, 1.0f)))
             {
-                solution.AddReagent(RobustRandom.Pick(allReagents), 100);
+                solution.AddReagent(RobustRandom.Pick(allReagents), 200);
             }
             else
             {
-                solution.AddReagent(RobustRandom.Pick(SafeishVentChemicals), 100);
+                solution.AddReagent(RobustRandom.Pick(SafeishVentChemicals), 200);
             }
 
-            FoamAreaReactionEffect.SpawnFoam("Foam", transform.Coordinates, solution, (int) (RobustRandom.Next(2, 6) * mod), 20, 1,
-                1, sound, EntityManager);
+            var foamEnt = Spawn("Foam", transform.Coordinates);
+            var smoke = EnsureComp<SmokeComponent>(foamEnt);
+            smoke.SpreadAmount = 20;
+            EntityManager.System<SmokeSystem>().Start(foamEnt, smoke, solution, 20f);
+            EntityManager.System<AudioSystem>().PlayPvs(sound, transform.Coordinates);
         }
     }
 
index 59c041010b44e4b9128e59a587dd4233349b3a89..cefeccbf749da4d5dbed6b82dc741dd248aa810b 100644 (file)
@@ -2,6 +2,7 @@ using System.Threading;
 using Content.Server.Fluids.Components;
 using Content.Server.Tools.Components;
 using Content.Shared.DoAfter;
+using Content.Shared.Fluids.Components;
 using Content.Shared.Interaction;
 using Content.Shared.Maps;
 using Content.Shared.Tools.Components;
index 212ac90e29eac773ba53dc053d9fef5e5604b412..7d8c0873e96cc075af40686490eecb3309dd1c45 100644 (file)
@@ -221,9 +221,20 @@ public sealed class MeleeWeaponSystem : SharedMeleeWeaponSystem
         return Math.Clamp(chance, 0f, 1f);
     }
 
-    public override void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation)
+    public override void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation, bool predicted = true)
     {
-        RaiseNetworkEvent(new MeleeLungeEvent(user, angle, localPos, animation), Filter.PvsExcept(user, entityManager: EntityManager));
+        Filter filter;
+
+        if (predicted)
+        {
+            filter = Filter.PvsExcept(user, entityManager: EntityManager);
+        }
+        else
+        {
+            filter = Filter.Pvs(user, entityManager: EntityManager);
+        }
+
+        RaiseNetworkEvent(new MeleeLungeEvent(user, angle, localPos, animation), filter);
     }
 
     private void OnChemicalInjectorHit(EntityUid owner, MeleeChemicalInjectorComponent comp, MeleeHitEvent args)
index fb05b425b49397824bcae785fa389a4579a0aa46..ad75e1b95d8751538972d1ed3e4843186109b910 100644 (file)
@@ -14,12 +14,6 @@ namespace Content.Server.Xenoarchaeology.XenoArtifacts.Effects.Components;
 [RegisterComponent, Access(typeof(ChemicalPuddleArtifactSystem))]
 public sealed class ChemicalPuddleArtifactComponent : Component
 {
-    /// <summary>
-    /// The prototype id of the puddle
-    /// </summary>
-    [DataField("puddlePrototype", customTypeSerializer: typeof(PrototypeIdSerializer<EntityPrototype>)), ViewVariables(VVAccess.ReadWrite)]
-    public string PuddlePrototype = "PuddleSmear";
-
     /// <summary>
     /// The solution where all the chemicals are stored
     /// </summary>
index 96729ca49f0c0c5a8b4c9bc27ecc163131d4b03a..cd312797ce737de3ec0724ffda4072f19008558a 100644 (file)
@@ -12,7 +12,7 @@ public sealed class ChemicalPuddleArtifactSystem : EntitySystem
 {
     [Dependency] private readonly IRobustRandom _random = default!;
     [Dependency] private readonly ArtifactSystem _artifact = default!;
-    [Dependency] private readonly SpillableSystem _spillable = default!;
+    [Dependency] private readonly PuddleSystem _puddle = default!;
 
     /// <summary>
     /// The key for the node data entry containing
@@ -49,6 +49,6 @@ public sealed class ChemicalPuddleArtifactSystem : EntitySystem
             component.ChemicalSolution.AddReagent(reagent, amountPerChem);
         }
 
-        _spillable.SpillAt(uid, component.ChemicalSolution, component.PuddlePrototype);
+        _puddle.TrySpillAt(uid, component.ChemicalSolution, out _);
     }
 }
index d034c4989cfa0e94dcf25f529fcb17793650b06a..667d1363d9de3de17ea85a3043ef0dac098fe448 100644 (file)
@@ -1,8 +1,11 @@
 using System.Linq;
+using Content.Server.Chemistry.Components;
 using Content.Server.Chemistry.ReactionEffects;
+using Content.Server.Fluids.EntitySystems;
 using Content.Server.Xenoarchaeology.XenoArtifacts.Effects.Components;
 using Content.Server.Xenoarchaeology.XenoArtifacts.Events;
 using Content.Shared.Chemistry.Components;
+using Robust.Server.GameObjects;
 using Robust.Shared.Random;
 
 namespace Content.Server.Xenoarchaeology.XenoArtifacts.Effects.Systems;
@@ -33,10 +36,12 @@ public sealed class FoamArtifactSystem : EntitySystem
 
         var sol = new Solution();
         var xform = Transform(uid);
-        sol.AddReagent(component.SelectedReagent, component.ReagentAmount);
+        sol.AddReagent(component.SelectedReagent, component.ReagentAmount *
+                                                  (component.MinFoamAmount +
+                                                   (component.MaxFoamAmount - component.MinFoamAmount) * _random.NextFloat()));
 
-        FoamAreaReactionEffect.SpawnFoam("Foam", xform.Coordinates, sol,
-            _random.Next(component.MinFoamAmount, component.MaxFoamAmount), component.Duration,
-            component.SpreadDuration, component.SpreadDuration, entityManager: EntityManager);
+        var foamEnt = Spawn("Foam", xform.Coordinates);
+        var smoke = EnsureComp<SmokeComponent>(foamEnt);
+        EntityManager.System<SmokeSystem>().Start(foamEnt, smoke, sol, 20f);
     }
 }
index 5c11fef78a73dd10ade1dfbd23e9581e98526c53..814227d3e5582833f031ef43d486a54f6cdb329e 100644 (file)
@@ -18,6 +18,8 @@ namespace Content.Shared.Access.Components
 
         [DataField("jobTitle")]
         [AutoNetworkedField]
+        [Access(typeof(SharedIdCardSystem), typeof(SharedPDASystem), typeof(SharedAgentIdCardSystem),
+            Other = AccessPermissions.ReadWrite)]
         public string? JobTitle;
     }
 }
diff --git a/Content.Shared/Chemistry/Components/DrainableSolutionComponent.cs b/Content.Shared/Chemistry/Components/DrainableSolutionComponent.cs
new file mode 100644 (file)
index 0000000..2a69a88
--- /dev/null
@@ -0,0 +1,18 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Chemistry.Components;
+
+/// <summary>
+///     Denotes the solution that can be easily removed through any reagent container.
+///     Think pouring this or draining from a water tank.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed class DrainableSolutionComponent : Component
+{
+    /// <summary>
+    /// Solution name that can be drained.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite)]
+    [DataField("solution")]
+    public string Solution { get; set; } = "default";
+}
diff --git a/Content.Shared/Chemistry/Components/RefillableSolutionComponent.cs b/Content.Shared/Chemistry/Components/RefillableSolutionComponent.cs
new file mode 100644 (file)
index 0000000..49f2edb
--- /dev/null
@@ -0,0 +1,27 @@
+using Content.Shared.FixedPoint;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Chemistry.Components;
+
+/// <summary>
+///     Reagents that can be added easily. For example like
+///     pouring something into another beaker, glass, or into the gas
+///     tank of a car.
+/// </summary>
+[RegisterComponent, NetworkedComponent]
+public sealed class RefillableSolutionComponent : Component
+{
+    /// <summary>
+    /// Solution name that can added to easily.
+    /// </summary>
+    [ViewVariables(VVAccess.ReadWrite)]
+    [DataField("solution")]
+    public string Solution { get; set; } = "default";
+
+    /// <summary>
+    /// The maximum amount that can be transferred to the solution at once
+    /// </summary>
+    [DataField("maxRefill")]
+    [ViewVariables(VVAccess.ReadWrite)]
+    public FixedPoint2? MaxRefill { get; set; } = null;
+}
index 5beab83ff36d495a2f85b70acbeab6b916745275..353733e715f974f0e4140d256ff42e7e8b63410f 100644 (file)
@@ -432,6 +432,18 @@ namespace Content.Shared.Chemistry.Components
             _heatCapacity = 0;
         }
 
+        /// <summary>
+        /// Splits a solution without the specified reagent.
+        /// </summary>
+        public Solution SplitSolutionWithout(FixedPoint2 toTake, string without)
+        {
+            TryGetReagent(without, out var existing);
+            RemoveReagent(without, toTake);
+            var sol = SplitSolution(toTake);
+            AddReagent(without, existing);
+            return sol;
+        }
+
         public Solution SplitSolution(FixedPoint2 toTake)
         {
             if (toTake <= FixedPoint2.Zero)
@@ -599,7 +611,7 @@ namespace Content.Shared.Chemistry.Components
             ValidateSolution();
         }
 
-        public Color GetColor(IPrototypeManager? protoMan)
+        public Color GetColorWithout(IPrototypeManager? protoMan, params string[] without)
         {
             if (Volume == FixedPoint2.Zero)
             {
@@ -614,6 +626,9 @@ namespace Content.Shared.Chemistry.Components
 
             foreach (var reagent in Contents)
             {
+                if (without.Contains(reagent.ReagentId))
+                    continue;
+
                 runningTotalQuantity += reagent.Quantity;
 
                 if (!protoMan.TryIndex(reagent.ReagentId, out ReagentPrototype? proto))
@@ -634,6 +649,11 @@ namespace Content.Shared.Chemistry.Components
             return mixColor;
         }
 
+        public Color GetColor(IPrototypeManager? protoMan)
+        {
+            return GetColorWithout(protoMan);
+        }
+
         [Obsolete("Use ReactiveSystem.DoEntityReaction")]
         public void DoEntityReaction(EntityUid uid, ReactionMethod method)
         {
index b1937819a4358d92868fecb142f3f4058466e41f..a2a4fec87a6e85944c123c126df7cab6617affa1 100644 (file)
@@ -250,7 +250,10 @@ namespace Content.Shared.Chemistry.Reaction
                 return false;
 
             // Remove any reactions that were not applicable. Avoids re-iterating over them in future.
-            reactions.Except(toRemove);
+            foreach (var proto in toRemove)
+            {
+                reactions.Remove(proto);
+            }
 
             if (products.Volume <= 0)
                 return true;
index 829b9ba31006b00657beea0502622c3d3fee3260..09bdb41beffd7e8baff675071de7997885b8c91b 100644 (file)
@@ -5,6 +5,7 @@ using Content.Shared.Chemistry.Components;
 using Content.Shared.Chemistry.Reaction;
 using Content.Shared.Database;
 using Content.Shared.FixedPoint;
+using Robust.Shared.Audio;
 using Robust.Shared.Map;
 using Robust.Shared.Prototypes;
 using Robust.Shared.Random;
@@ -73,6 +74,12 @@ namespace Content.Shared.Chemistry.Reagent
         [DataField("metamorphicSprite")]
         public SpriteSpecifier? MetamorphicSprite { get; } = null;
 
+        /// <summary>
+        /// If this reagent is part of a puddle is it slippery.
+        /// </summary>
+        [DataField("slippery")]
+        public bool Slippery = false;
+
         [DataField("metabolisms", serverOnly: true, customTypeSerializer: typeof(PrototypeIdDictionarySerializer<ReagentEffectsEntry, MetabolismGroupPrototype>))]
         public Dictionary<string, ReagentEffectsEntry>? Metabolisms = null;
 
@@ -85,26 +92,11 @@ namespace Content.Shared.Chemistry.Reagent
         [DataField("plantMetabolism", serverOnly: true)]
         public readonly List<ReagentEffect> PlantMetabolisms = new(0);
 
-        [DataField("pricePerUnit")]
-        public float PricePerUnit { get; }
+        [DataField("pricePerUnit")] public float PricePerUnit;
 
-        /// <summary>
-        /// If the substance color is too dark we user a lighter version to make the text color readable when the user examines a solution.
-        /// </summary>
-        public Color GetSubstanceTextColor()
-        {
-            var highestValue = MathF.Max(SubstanceColor.R, MathF.Max(SubstanceColor.G, SubstanceColor.B));
-            var difference = 0.5f - highestValue;
-
-            if (difference > 0f)
-            {
-                return new Color(SubstanceColor.R + difference,
-                                SubstanceColor.G + difference,
-                                SubstanceColor.B + difference);
-            }
-
-            return SubstanceColor;
-        }
+        // TODO: Pick the highest reagent for sounds and add sticky to cola, juice, etc.
+        [DataField("footstepSound")]
+        public SoundSpecifier FootstepSound = new SoundCollectionSpecifier("FootstepWater");
 
         public FixedPoint2 ReactionTile(TileRef tile, FixedPoint2 reactVolume)
         {
index 011e123e90c0ce1b128da7558854f987f5c8512b..b68f2d8b97ff07d41e26afb56c41155849c1ffa6 100644 (file)
@@ -10,48 +10,31 @@ namespace Content.Shared.Fluids;
 [RegisterComponent, NetworkedComponent]
 public sealed class AbsorbentComponent : Component
 {
-    // TODO: Predicted solutions my beloved.
-    public float Progress;
-
     public const string SolutionName = "absorbed";
 
-    [DataField("pickupAmount")]
-    public FixedPoint2 PickupAmount = FixedPoint2.New(10);
-
-    /// <summary>
-    ///     When using this tool on an empty floor tile, leave this much reagent as a new puddle.
-    /// </summary>
-    [DataField("residueAmount")]
-    public FixedPoint2 ResidueAmount = FixedPoint2.New(10); // Should be higher than MopLowerLimit
+    public Dictionary<Color, float> Progress = new();
 
     /// <summary>
-    ///     To leave behind a wet floor, this tool will be unable to take from puddles with a volume less than this
-    ///     amount. This limit is ignored if the target puddle does not evaporate.
+    /// How much solution we can transfer in one interaction.
     /// </summary>
-    [DataField("lowerLimit")]
-    public FixedPoint2 LowerLimit = FixedPoint2.New(5);
+    [DataField("pickupAmount")]
+    public FixedPoint2 PickupAmount = FixedPoint2.New(60);
 
     [DataField("pickupSound")]
-    public SoundSpecifier PickupSound = new SoundPathSpecifier("/Audio/Effects/Fluids/slosh.ogg");
-
-    [DataField("transferSound")]
-    public SoundSpecifier TransferSound = new SoundPathSpecifier("/Audio/Effects/Fluids/watersplash.ogg");
-
-    /// <summary>
-    ///     Quantity of reagent that this mop can pick up per second. Determines the length of the do-after.
-    /// </summary>
-    [DataField("speed")] public float Speed = 10;
-
-    /// <summary>
-    ///     How many entities can this tool interact with at once?
-    /// </summary>
-    [DataField("maxEntities")]
-    public int MaxInteractingEntities = 1;
-
-    /// <summary>
-    ///     What entities is this tool interacting with right now?
-    /// </summary>
-    [ViewVariables]
-    public HashSet<EntityUid> InteractingEntities = new();
-
+    public SoundSpecifier PickupSound = new SoundPathSpecifier("/Audio/Effects/Fluids/watersplash.ogg")
+    {
+        Params = AudioParams.Default.WithVariation(0.05f),
+    };
+
+    [DataField("transferSound")] public SoundSpecifier TransferSound =
+        new SoundPathSpecifier("/Audio/Effects/Fluids/slosh.ogg")
+        {
+            Params = AudioParams.Default.WithVariation(0.05f).WithVolume(-3f),
+        };
+
+    public static readonly SoundSpecifier DefaultTransferSound =
+        new SoundPathSpecifier("/Audio/Effects/Fluids/slosh.ogg")
+        {
+            Params = AudioParams.Default.WithVariation(0.05f).WithVolume(-3f),
+        };
 }
diff --git a/Content.Shared/Fluids/Components/DrainComponent.cs b/Content.Shared/Fluids/Components/DrainComponent.cs
new file mode 100644 (file)
index 0000000..e487962
--- /dev/null
@@ -0,0 +1,40 @@
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Fluids.Components;
+
+[RegisterComponent, NetworkedComponent]
+public sealed class DrainComponent : Component
+{
+    public const string SolutionName = "drainBuffer";
+
+    [DataField("accumulator")]
+    public float Accumulator = 0f;
+
+    /// <summary>
+    /// How many units per second the drain can absorb from the surrounding puddles.
+    /// Divided by puddles, so if there are 5 puddles this will take 1/5 from each puddle.
+    /// This will stay fixed to 1 second no matter what DrainFrequency is.
+    /// </summary>
+    [DataField("unitsPerSecond")]
+    public float UnitsPerSecond = 6f;
+
+    /// <summary>
+    /// How many units are ejected from the buffer per second.
+    /// </summary>
+    [DataField("unitsDestroyedPerSecond")]
+    public float UnitsDestroyedPerSecond = 1f;
+
+    /// <summary>
+    /// How many (unobstructed) tiles away the drain will
+    /// drain puddles from.
+    /// </summary>
+    [DataField("range")]
+    public float Range = 2f;
+
+    /// <summary>
+    /// How often in seconds the drain checks for puddles around it.
+    /// If the EntityQuery seems a bit unperformant this can be increased.
+    /// </summary>
+    [DataField("drainFrequency")]
+    public float DrainFrequency = 1f;
+}
diff --git a/Content.Shared/Fluids/Components/PuddleComponent.cs b/Content.Shared/Fluids/Components/PuddleComponent.cs
new file mode 100644 (file)
index 0000000..614d670
--- /dev/null
@@ -0,0 +1,21 @@
+using Content.Shared.FixedPoint;
+using Robust.Shared.Audio;
+using Robust.Shared.GameStates;
+
+namespace Content.Shared.Fluids.Components
+{
+    /// <summary>
+    /// Puddle on a floor
+    /// </summary>
+    [RegisterComponent, NetworkedComponent, Access(typeof(SharedPuddleSystem))]
+    public sealed class PuddleComponent : Component
+    {
+        [DataField("spillSound")]
+        public SoundSpecifier SpillSound = new SoundPathSpecifier("/Audio/Effects/Fluids/splat.ogg");
+
+        [DataField("overflowVolume")]
+        public FixedPoint2 OverflowVolume = FixedPoint2.New(20);
+
+        [DataField("solution")] public string SolutionName = "puddle";
+    }
+}
index 89c0eb9238b25746cae14d20b636247d80722af0..b50f084ba8eb581744fec784fbe9d77907593e7b 100644 (file)
@@ -5,9 +5,7 @@ namespace Content.Shared.Fluids
     [Serializable, NetSerializable]
     public enum PuddleVisuals : byte
     {
-        VolumeScale,
         CurrentVolume,
         SolutionColor,
-        IsEvaporatingVisual
     }
 }
similarity index 56%
rename from Content.Shared/Fluids/SharedMoppingSystem.cs
rename to Content.Shared/Fluids/SharedAbsorbentSystem.cs
index 8e6dcc829a4f806cd833d2da032ecfd2e10ca9ca..f121238d510d900eebf15b6e5480ad4cb81c3fcc 100644 (file)
@@ -1,9 +1,13 @@
+using System.Linq;
 using Robust.Shared.GameStates;
 using Robust.Shared.Serialization;
 
 namespace Content.Shared.Fluids;
 
-public abstract class SharedMoppingSystem : EntitySystem
+/// <summary>
+/// Mopping logic for interacting with puddle components.
+/// </summary>
+public abstract class SharedAbsorbentSystem : EntitySystem
 {
     public override void Initialize()
     {
@@ -17,23 +21,29 @@ public abstract class SharedMoppingSystem : EntitySystem
         if (args.Current is not AbsorbentComponentState state)
             return;
 
-        if (component.Progress.Equals(state.Progress))
+        if (component.Progress.OrderBy(x => x.Key.ToArgb()).SequenceEqual(state.Progress))
             return;
 
-        component.Progress = state.Progress;
+        component.Progress.Clear();
+        foreach (var item in state.Progress)
+        {
+            component.Progress.Add(item.Key, item.Value);
+        }
     }
 
     private void OnAbsorbentGetState(EntityUid uid, AbsorbentComponent component, ref ComponentGetState args)
     {
-        args.State = new AbsorbentComponentState()
-        {
-            Progress = component.Progress,
-        };
+        args.State = new AbsorbentComponentState(component.Progress);
     }
 
     [Serializable, NetSerializable]
     protected sealed class AbsorbentComponentState : ComponentState
     {
-        public float Progress;
+        public Dictionary<Color, float> Progress;
+
+        public AbsorbentComponentState(Dictionary<Color, float> progress)
+        {
+            Progress = progress;
+        }
     }
 }
diff --git a/Content.Shared/Fluids/SharedPuddleSystem.cs b/Content.Shared/Fluids/SharedPuddleSystem.cs
new file mode 100644 (file)
index 0000000..c693632
--- /dev/null
@@ -0,0 +1,44 @@
+using Content.Shared.Chemistry.Components;
+using Content.Shared.DragDrop;
+using Content.Shared.Fluids.Components;
+
+namespace Content.Shared.Fluids;
+
+public abstract class SharedPuddleSystem : EntitySystem
+{
+    /// <summary>
+    /// The lowest threshold to be considered for puddle sprite states as well as slipperiness of a puddle.
+    /// </summary>
+    public const float LowThreshold = 0.3f;
+
+    public override void Initialize()
+    {
+        base.Initialize();
+        SubscribeLocalEvent<RefillableSolutionComponent, CanDragEvent>(OnRefillableCanDrag);
+        SubscribeLocalEvent<RefillableSolutionComponent, CanDropDraggedEvent>(OnRefillableCanDropDragged);
+        SubscribeLocalEvent<DrainableSolutionComponent, CanDropTargetEvent>(OnDrainCanDropTarget);
+    }
+
+    private void OnRefillableCanDrag(EntityUid uid, RefillableSolutionComponent component, ref CanDragEvent args)
+    {
+        args.Handled = true;
+    }
+
+    private void OnDrainCanDropTarget(EntityUid uid, DrainableSolutionComponent component, ref CanDropTargetEvent args)
+    {
+        if (HasComp<RefillableSolutionComponent>(args.Dragged))
+        {
+            args.CanDrop = true;
+            args.Handled = true;
+        }
+    }
+
+    private void OnRefillableCanDropDragged(EntityUid uid, RefillableSolutionComponent component, ref CanDropDraggedEvent args)
+    {
+        if (!HasComp<DrainableSolutionComponent>(args.Target) && !HasComp<DrainComponent>(args.Target))
+            return;
+
+        args.CanDrop = true;
+        args.Handled = true;
+    }
+}
diff --git a/Content.Shared/Foam/FoamVisuals.cs b/Content.Shared/Foam/FoamVisuals.cs
deleted file mode 100644 (file)
index 7767d49..0000000
+++ /dev/null
@@ -1,11 +0,0 @@
-using Robust.Shared.Serialization;
-
-namespace Content.Shared.Foam
-{
-    [Serializable, NetSerializable]
-    public enum FoamVisuals : byte
-    {
-        State,
-        Color
-    }
-}
diff --git a/Content.Shared/Movement/Events/GetFootstepSoundEvent.cs b/Content.Shared/Movement/Events/GetFootstepSoundEvent.cs
new file mode 100644 (file)
index 0000000..8599637
--- /dev/null
@@ -0,0 +1,17 @@
+using Robust.Shared.Audio;
+
+namespace Content.Shared.Movement.Events;
+
+/// <summary>
+/// Raised directed on an entity when trying to get a relevant footstep sound
+/// </summary>
+[ByRefEvent]
+public record struct GetFootstepSoundEvent(EntityUid User)
+{
+    public readonly EntityUid User = User;
+
+    /// <summary>
+    /// Set the sound to specify a footstep sound and mark as handled.
+    /// </summary>
+    public SoundSpecifier? Sound;
+}
index 6bd64e89ab597c84590278ef40b0f75e133a5e11..9aec5e07f6ebd679ed731e52b442bbacca09a954 100644 (file)
@@ -466,10 +466,9 @@ namespace Content.Shared.Movement.Systems
         private bool TryGetFootstepSound(TransformComponent xform, bool haveShoes, [NotNullWhen(true)] out SoundSpecifier? sound)
         {
             sound = null;
-            MapGridComponent? grid;
 
             // Fallback to the map?
-            if (xform.GridUid == null)
+            if (!_mapManager.TryGetGrid(xform.GridUid, out var grid))
             {
                 if (TryComp<FootstepModifierComponent>(xform.MapUid, out var modifier))
                 {
@@ -480,8 +479,8 @@ namespace Content.Shared.Movement.Systems
                 return false;
             }
 
-            grid = _mapManager.GetGrid(xform.GridUid.Value);
             var position = grid.LocalToTile(xform.Coordinates);
+            var soundEv = new GetFootstepSoundEvent(xform.Owner);
 
             // If the coordinates have a FootstepModifier component
             // i.e. component that emit sound on footsteps emit that sound
@@ -489,6 +488,14 @@ namespace Content.Shared.Movement.Systems
 
             while (anchored.MoveNext(out var maybeFootstep))
             {
+                RaiseLocalEvent(maybeFootstep.Value, ref soundEv);
+
+                if (soundEv.Sound != null)
+                {
+                    sound = soundEv.Sound;
+                    return true;
+                }
+
                 if (TryComp<FootstepModifierComponent>(maybeFootstep, out var footstep))
                 {
                     sound = footstep.Sound;
index 8f3dc6edc5a46e38738a295e1fae385d1d0a033b..6a851e1756f3c4eb4a023f282841ed697d05b1b7 100644 (file)
@@ -1,9 +1,11 @@
+using Robust.Shared.GameStates;
+
 namespace Content.Shared.Spawners.Components;
 
 /// <summary>
 /// Put this component on something you would like to despawn after a certain amount of time
 /// </summary>
-[RegisterComponent]
+[RegisterComponent, NetworkedComponent]
 public sealed class TimedDespawnComponent : Component
 {
     /// <summary>
index 87f848c06d15a0bd6e60df5cdc212da3ce697f3a..bc9c41b93e06183b4fe1e794505e21772d815996 100644 (file)
@@ -1,4 +1,6 @@
 using Content.Shared.Spawners.Components;
+using Robust.Shared.GameStates;
+using Robust.Shared.Serialization;
 using Robust.Shared.Timing;
 
 namespace Content.Shared.Spawners.EntitySystems;
@@ -11,24 +13,56 @@ public abstract class SharedTimedDespawnSystem : EntitySystem
     {
         base.Initialize();
         UpdatesOutsidePrediction = true;
+        SubscribeLocalEvent<TimedDespawnComponent, ComponentGetState>(OnDespawnGetState);
+        SubscribeLocalEvent<TimedDespawnComponent, ComponentHandleState>(OnDespawnHandleState);
+    }
+
+    private void OnDespawnGetState(EntityUid uid, TimedDespawnComponent component, ref ComponentGetState args)
+    {
+        args.State = new TimedDespawnComponentState()
+        {
+            Lifetime = component.Lifetime,
+        };
+    }
+
+    private void OnDespawnHandleState(EntityUid uid, TimedDespawnComponent component, ref ComponentHandleState args)
+    {
+        if (args.Current is not TimedDespawnComponentState state)
+            return;
+
+        component.Lifetime = state.Lifetime;
     }
 
     public override void Update(float frameTime)
     {
         base.Update(frameTime);
 
-        if (!_timing.IsFirstTimePredicted) return;
+        if (!_timing.IsFirstTimePredicted)
+            return;
 
-        foreach (var comp in EntityQuery<TimedDespawnComponent>())
-        {
-            if (!CanDelete(comp.Owner)) continue;
+        var query = EntityQueryEnumerator<TimedDespawnComponent>();
 
+        while (query.MoveNext(out var uid, out var comp))
+        {
             comp.Lifetime -= frameTime;
 
+            if (!CanDelete(uid))
+                continue;
+
             if (comp.Lifetime <= 0)
-                EntityManager.QueueDeleteEntity(comp.Owner);
+            {
+                var ev = new TimedDespawnEvent();
+                RaiseLocalEvent(uid, ref ev);
+                QueueDel(uid);
+            }
         }
     }
 
     protected abstract bool CanDelete(EntityUid uid);
+
+    [Serializable, NetSerializable]
+    private sealed class TimedDespawnComponentState : ComponentState
+    {
+        public float Lifetime;
+    }
 }
diff --git a/Content.Shared/Spawners/TimedDespawnEvent.cs b/Content.Shared/Spawners/TimedDespawnEvent.cs
new file mode 100644 (file)
index 0000000..26551d8
--- /dev/null
@@ -0,0 +1,7 @@
+namespace Content.Shared.Spawners;
+
+/// <summary>
+/// Raised directed on an entity when its timed despawn is over.
+/// </summary>
+[ByRefEvent]
+public readonly record struct TimedDespawnEvent;
index e5aa4177c274b901cc21389f11ef9d3be45e988c..bd3fa842b4376dafb88547baf4a46d525fc4ed8e 100644 (file)
@@ -775,5 +775,5 @@ public abstract class SharedMeleeWeaponSystem : EntitySystem
         DoLunge(user, angle, localPos, animation);
     }
 
-    public abstract void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation);
+    public abstract void DoLunge(EntityUid user, Angle angle, Vector2 localPos, string? animation, bool predicted = true);
 }
index 9fc4981d8cb3ab7588f9e9ec93fd0eb58e3c79fd..670ac0a36ad02dbab59596dfd1f8e35cf9ef8a5c 100644 (file)
@@ -1,15 +1,8 @@
-
-mopping-system-tool-full = { CAPITALIZE(THE($used)) } is full!
-mopping-system-puddle-diluted = You dilute the puddle.
-mopping-system-puddle-success = You mop the puddle.
-mopping-system-release-to-floor = You squeeze some liquid onto the floor.
-
-mopping-system-target-container-full = { CAPITALIZE(THE($target)) } is full!
 mopping-system-target-container-empty = { CAPITALIZE(THE($target)) } is empty!
-mopping-system-target-container-too-small = { CAPITALIZE(THE($target)) } is too small for that!
-mopping-system-refillable-success = You wring { THE($used) } into { THE($target) }.
-mopping-system-drainable-success = You wet { THE($used) } from { THE($target) }.
+mopping-system-target-container-empty-water = { CAPITALIZE(THE($target)) } has no water!
+mopping-system-puddle-space = { THE($used) } is full of water
+mopping-system-puddle-evaporate = { THE($target) } is evaporating
+mopping-system-no-water = { THE($used) } has no water!
 
-mopping-system-hand-squeeze-still-wet = You wring { THE($used) } with your hands. It's still wet.
-mopping-system-hand-squeeze-little-wet = You wring { THE($used) } with your hands. It's still a little wet.
-mopping-system-hand-squeeze-dry = You wring { THE($used) } with your hands. It's pretty much dry.
+mopping-system-full = { THE($used) } is full!
+mopping-system-empty = { THE($used) } is empty!
index 4114733e897aaf38cde5b96d7fb4ef8c47d3ba40..e1ae9bab4bffead8f415762518513ce15fdd9452 100644 (file)
@@ -1,2 +1,5 @@
-puddle-component-examine-is-slipper-text = It looks slippery.
+puddle-component-examine-is-slipper-text = It looks [color=#169C9C]slippery[/color].
+puddle-component-examine-evaporating = It is [color=#5E7C16]evaporating[/color].
+puddle-component-examine-evaporating-partial = It is [color=#FED83D]partially evaporating[/color].
+puddle-component-examine-evaporating-no = It is [color=#B02E26]not evaporating[/color].
 puddle-component-slipped-touch-reaction = The chemicals in {THE($puddle)} get on your skin!
index 39ecdc3a3e8ddec1e6648d0e3eef32f6591c2ddd..380ecc1fce4c7ad7cf3245ae3b5ad83f75469b3b 100644 (file)
@@ -266,12 +266,6 @@ entities:
   - pos: 10.5,25.5
     parent: 179
     type: Transform
-- uid: 42
-  type: PuddleVomit
-  components:
-  - pos: 9.5,16.5
-    parent: 179
-    type: Transform
 - uid: 43
   type: WallSolid
   components:
@@ -536,12 +530,6 @@ entities:
   - pos: 17.5,15.5
     parent: 179
     type: Transform
-- uid: 87
-  type: PuddleVomit
-  components:
-  - pos: 8.5,15.5
-    parent: 179
-    type: Transform
 - uid: 88
   type: ChairOfficeLight
   components:
@@ -685,12 +673,6 @@ entities:
   - pos: 10.5,15.5
     parent: 179
     type: Transform
-- uid: 111
-  type: PuddleVomit
-  components:
-  - pos: 8.5,16.5
-    parent: 179
-    type: Transform
 - uid: 112
   type: WallSolid
   components:
@@ -715,12 +697,6 @@ entities:
   - pos: 4.5,18.5
     parent: 179
     type: Transform
-- uid: 116
-  type: PuddleVomit
-  components:
-  - pos: 9.5,15.5
-    parent: 179
-    type: Transform
 - uid: 117
   type: WallSolid
   components:
index 29671f4179671199e6d63250b2eb64567c573984..beea18f540f3c2675635e3cd1c9ec37f1e4fbcca 100644 (file)
@@ -3,21 +3,32 @@
   name: smoke
   noSpawn: true
   components:
+  - type: Occluder
   - type: Sprite
     drawdepth: Effects
     sprite: Effects/chemsmoke.rsi
     state: chemsmoke
   - type: Appearance
   - type: SmokeVisuals
-  - type: Occluder
   - type: Transform
     anchored: true
-  - type: SmokeSolutionAreaEffect
+  - type: Smoke
+  - type: NodeContainer
+    nodes:
+      smoke:
+        !type:SpreaderNode
+        nodeGroupID: Spreader
+  - type: EdgeSpreader
   - type: SolutionContainerManager
     solutions:
       solutionArea:
         maxVol: 600
         canReact: false
+  - type: TimedDespawn
+    lifetime: 10
+  - type: Tag
+    tags:
+      - HideContextMenu
 
 - type: entity
   id: Foam
@@ -34,7 +45,9 @@
       - state: foam
         map: ["enum.FoamVisualLayers.Base"]
   - type: AnimationPlayer
+    netsync: false
   - type: Appearance
+  - type: SmokeVisuals
   - type: FoamVisuals
     animationTime: 0.6
     animationState: foam-dissolve
       - ItemMask
       layer:
       - SlipLayer
-  - type: FoamSolutionAreaEffect
+  - type: Smoke
+  - type: NodeContainer
+    nodes:
+      smoke:
+        !type:SpreaderNode
+        nodeGroupID: Spreader
+  - type: EdgeSpreader
   - type: SolutionContainerManager
     solutions:
       solutionArea:
     - state: mfoam
       map: ["enum.FoamVisualLayers.Base"]
   - type: Appearance
+  - type: SmokeVisuals
   - type: FoamVisuals
     animationTime: 0.6
     animationState: mfoam-dissolve
-  - type: FoamSolutionAreaEffect
-    foamedMetalPrototype: FoamedIronMetal
+  - type: Smoke
+  - type: SmokeDissipateSpawn
+    prototype: FoamedIronMetal
 
 - type: entity
   id: AluminiumMetalFoam
       - state: mfoam
         map: ["enum.FoamVisualLayers.Base"]
   - type: Appearance
+  - type: SmokeVisuals
   - type: FoamVisuals
     animationTime: 0.6
     animationState: mfoam-dissolve
-  - type: FoamSolutionAreaEffect
-    foamedMetalPrototype: FoamedAluminiumMetal
+  - type: Smoke
+  - type: SmokeDissipateSpawn
+    prototype: FoamedAluminiumMetal
 
 - type: entity
   id: BaseFoamedMetal
index d7532583f2c2e4a46742cee3fab56ec5679892f6..f48ee736ea9de6c6692515c5785b44d95116274a 100644 (file)
-# TODO: Add the other mess types
+# TODO: Fix - The idea is that blood and vomit is potentially not tile-bound versions of puddles(?)
 - type: entity
-  id: PuddleBase
+  id: PuddleTemporary
+  parent: Puddle
   abstract: true
   components:
-  - type: FootstepModifier
-    footstepSoundCollection:
-      collection: FootstepWater
-  - type: Transform
-    anchored: true
-  - type: Sprite
-    drawdepth: FloorObjects
-  - type: SolutionContainerManager
-    solutions:
-      puddle: { maxVol: 1000 }
-  - type: Puddle
-    spillSound:
-      path: /Audio/Effects/Fluids/splat.ogg
-    recolor: true
-  - type: Clickable
-  - type: Physics
-  - type: Fixtures
-    fixtures:
-    - id: slipFixture
-      shape:
-        !type:PhysShapeAabb
-        bounds: "-0.4,-0.4,0.4,0.4"
-      mask:
-      - ItemMask
-      layer:
-      - SlipLayer
-      hard: false
-  - type: Appearance
-  - type: PuddleVisualizer
-    wetFloorEffectThreshold: 0 # non-evaporating puddles don't become sparkles.
+    - type: Transform
+      anchored: true
+      noRot: false
 
 - type: entity
-  id: EvaporatingPuddle
-  parent: PuddleBase
-  abstract: true
-  components:
-  - type: Evaporation
-  - type: PuddleVisualizer
-    wetFloorEffectThreshold: 5
+  id: PuddleSmear
+  parent: PuddleTemporary
 
 - type: entity
-  name: gibblets
-  id: PuddleGibblet
-  parent: PuddleBase
-  description: Gross.
+  id: PuddleVomit
+  parent: PuddleTemporary
   components:
-  - type: Sprite
-    sprite: Fluids/gibblet.rsi # Placeholder
-    state: gibblet-0
-    netsync: false
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: Water
-          Quantity: 10
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
+    - type: SolutionContainerManager
+      solutions:
+        puddle:
+          maxVol: 1000
+          reagents:
+            - ReagentId: Nutriment
+              Quantity: 5
+            - ReagentId: Water
+              Quantity: 5
 
 - type: entity
-  name: puddle
-  id: PuddleSmear
-  parent: EvaporatingPuddle
-  description: A puddle of liquid.
-  components:
-  - type: Sprite
-    sprite: Fluids/smear.rsi # Placeholder
-    state: smear-0
-    netsync: false
-  - type: Puddle
-    slipThreshold: 3
-  - type: Appearance
-  - type: PuddleVisualizer
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
+  id: PuddleEgg
+  parent: PuddleTemporary
 
 - type: entity
-  name: puddle
-  id: PuddleSplatter
-  parent: EvaporatingPuddle
-  description: A puddle of liquid.
-  components:
-  - type: Sprite
-    sprite: Fluids/splatter.rsi # Placeholder
-    state: splatter-0
-    netsync: false
-  - type: Puddle
-    slipThreshold: 3
-  - type: Appearance
-  - type: PuddleVisualizer
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
+  id: PuddleTomato
+  parent: PuddleTemporary
 
 - type: entity
-  id: PuddleBlood
-  name: blood
-  description: This can't be a good sign.
-  parent: PuddleBase
-  components:
-  - type: Sprite
-    sprite: Fluids/splatter.rsi # Placeholder
-    state: splatter-0
-    netsync: false
-  - type: Puddle
-    overflowVolume: 50
-    opacityModifier: 8
+  id: PuddleWatermelon
+  parent: PuddleTemporary
 
 - type: entity
-  name: vomit
-  id: PuddleVomit # No parent because we don't want the VisualizerSystem to behave in the standard way
-  description: Gross.
-  components:
-  - type: Transform
-    anchored: true
-  - type: Clickable
-  - type: Physics
-  - type: Fixtures
-    fixtures:
-    - id: slipFixture
-      shape:
-        !type:PhysShapeAabb
-        bounds: "-0.4,-0.4,0.4,0.4"
-      mask:
-      - ItemMask
-      layer:
-      - SlipLayer
-      hard: false
-  - type: Sprite
-    sprite: Fluids/vomit.rsi
-    state: vomit-0
-    netsync: false
-  - type: Puddle
-    slipThreshold: 5
-    recolor: false
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: Nutriment
-          Quantity: 5
-        - ReagentId: Water
-          Quantity: 5
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
-  - type: Appearance
-  - type: PuddleVisualizer
-    customPuddleSprite: true
+  id: PuddleFlour
+  parent: PuddleTemporary
 
 - type: entity
-  name: toxins vomit
-  id: PuddleVomitToxin
-  parent: PuddleVomit
-  description: You probably don't want to get too close to this.
+  id: PuddleSparkle
+  name: Sparkle
+  placement:
+    mode: SnapgridCenter
   components:
-  - type: Sprite
-    sprite: Fluids/vomit_toxin.rsi
-    state: vomit_toxin-0
-    netsync: false
-  - type: Puddle
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: Toxin
-          Quantity: 5
-        - ReagentId: Water
-          Quantity: 5
-  - type: Appearance
-  - type: PuddleVisualizer
-    customPuddleSprite: true
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
+    # Animation is like 3 something seconds so we just need to despawn it before then.
+    - type: TimedDespawn
+      lifetime: 1
+    - type: EvaporationSparkle
+    - type: Transform
+      noRot: true
+      anchored: true
+    - type: Sprite
+      layers:
+        - sprite: Fluids/wet_floor_sparkles.rsi
+          state: sparkles
+      netsync: false
+      drawdepth: FloorObjects
+      color: "#FFFFFF80"
 
 - type: entity
-  name: writing
-  id: PuddleWriting
-  parent: PuddleBase
-  description: A bit of liquid.
+  name: puddle
+  id: Puddle
+  description: A puddle of liquid.
+  placement:
+    mode: SnapgridCenter
   components:
-  - type: Sprite
-    sprite: Fluids/writing.rsi # Placeholder
-    state: writing-0
-    netsync: false
-  - type: Puddle
-  - type: Evaporation
-    evaporateTime: 10
-  - type: Appearance
-  - type: PuddleVisualizer
-  - type: Slippery
-    launchForwardsMultiplier: 2.0
-  - type: StepTrigger
+    - type: Clickable
+    - type: FootstepModifier
+      footstepSoundCollection:
+        collection: FootstepWater
+    - type: Slippery
+      launchForwardsMultiplier: 2.0
+    - type: Transform
+      noRot: true
+      anchored: true
+    - type: Sprite
+      layers:
+        - sprite: Fluids/puddle.rsi
+          state: splat0
+      netsync: false
+      drawdepth: FloorObjects
+      color: "#FFFFFF80"
+    - type: Physics
+      bodyType: Static
+    - type: Fixtures
+      fixtures:
+        - id: slipFixture
+          shape:
+            !type:PhysShapeAabb
+            bounds: "-0.4,-0.4,0.4,0.4"
+          mask:
+            - ItemMask
+          layer:
+            - SlipLayer
+          hard: false
+    - type: IconSmooth
+      key: puddles
+      base: splat
+      mode: CardinalFlags
+    - type: SolutionContainerManager
+      solutions:
+        puddle: { maxVol: 1000 }
+    - type: Puddle
+    - type: Appearance
+    - type: EdgeSpreader
+    - type: StepTrigger
+    - type: NodeContainer
+      nodes:
+        puddle:
+          !type:SpreaderNode
+          nodeGroupID: Spreader
index 9ecfae33246c3f722e9e24ba31e07f6f7683b9c6..697aa549702d9d2cf48ca01c1de25c594d4c6708 100644 (file)
         acts: [ "Destruction" ]
 
 # Splat
-
-- type: entity
-  name: egg
-  id: PuddleEgg
-  parent: PuddleBase
-  description: If the floor was a little hotter this would fry.
-  components:
-  - type: Sprite
-    sprite: Fluids/egg_splat.rsi
-    state: egg-0
-    netsync: false
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: Egg
-          Quantity: 2
-
 - type: entity
   name: eggshells
   parent: BaseItem
index 8839b4be89919300f7bec3664169c50e8c47d29d..b8794de4fd4ca75075dd24373874d704ca5b7368 100644 (file)
@@ -2,28 +2,6 @@
 
 # Powder (For when you throw stuff like flour and it explodes)
 
-- type: entity
-  name: flour
-  id: PuddleFlour
-  parent: PuddleBase
-  description: Call the janitor.
-  components:
-  - type: Sprite
-    sprite: Fluids/powder.rsi
-    state: powder
-    color: white
-    netsync: false
-  - type: Puddle
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: Flour
-          Quantity: 10
-  - type: Appearance
-  - type: PuddleVisualizer
-
 # Reagent Containers
 
 - type: entity
index d20aacdc1f27241a24ea79de404f849be265b6b1..5910191b3641c1f8098af298e5f5d3316b98c30c 100644 (file)
       - !type:DoActsBehavior
         acts: [ "Destruction" ]
 
-- type: entity
-  name: tomato
-  id: PuddleTomato
-  parent: PuddleBase
-  description: Splat.
-  components:
-  - type: Sprite
-    sprite: Fluids/tomato_splat.rsi
-    state: puddle-0
-    netsync: false
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: JuiceTomato
-          Quantity: 10
-
 - type: entity
   name: eggplant
   parent: FoodProduceBase
     spawned:
     - id: ClothingHeadClothingHeadHatWatermelon
 
-- type: entity
-  name: watermelon
-  id: PuddleWatermelon
-  parent: PuddleBase
-  description: Splat.
-  components:
-  - type: Sprite
-    sprite: Fluids/tomato_splat.rsi
-    state: puddle-0
-    netsync: false
-  - type: SolutionContainerManager
-    solutions:
-      puddle:
-        maxVol: 1000
-        reagents:
-        - ReagentId: JuiceWatermelon
-          Quantity: 20
-
 - type: entity
   name: watermelon slice
   parent: ProduceSliceBase
index 3fd82dc79838130d21b8328bf7a17c0b1aefe109..af62439c8741b5a1bef7924d92ec691db37b9cdc 100644 (file)
             types:
               Heat: 10
     - type: AtmosExposed
-    - type: Spreader
-      growthResult: Kudzu
-      chance: 1
+    - type: Kudzu
     - type: GrowingKudzu
-      growthTickSkipChance: 0.6666
+      growthTickChance: 0.3
     - type: SlowContacts
       walkSpeedModifier: 0.2
       sprintSpeedModifier: 0.2
+    - type: EdgeSpreader
+    - type: NodeContainer
+      nodes:
+        kudzu:
+          !type:SpreaderNode
+          nodeGroupID: Spreader
+
 
 - type: entity
   id: WeakKudzu
   parent: Kudzu
+  suffix: Weak
   components:
-    - type: Spreader
-      growthResult: WeakKudzu
-      chance: 0.3
+    - type: Kudzu
+      spreadChance: 0.3
 
 - type: entity
   id: FleshKudzu
         behaviors:
         - !type:DoActsBehavior
           acts: [ "Destruction" ]
-    - type: Spreader
-      growthResult: FleshKudzu
-      chance: 0.2
     - type: DamageContacts
       damage:
         types:
       ignoreWhitelist:
         tags:
         - Flesh
+    - type: Kudzu
+    - type: EdgeSpreader
+    - type: NodeContainer
+      nodes:
+        kudzu:
+          !type:SpreaderNode
+          nodeGroupID: Spreader
     - type: SlowContacts
       walkSpeedModifier: 0.3
       sprintSpeedModifier: 0.3
index 7595028cb05d99882f2cf61f0296beb6477fa647..9db467e3cecd6f6b0a5d83d2b2c2a381302206c3 100644 (file)
@@ -12,6 +12,8 @@
     damage:
       types:
         Blunt: 10
+  - type: Spillable
+    solution: absorbed
   - type: Wieldable
   - type: IncreaseDamageOnWield
     damage:
@@ -24,7 +26,9 @@
   - type: SolutionContainerManager
     solutions:
       absorbed:
-        maxVol: 50
+        maxVol: 60
+  - type: UseDelay
+    delay: 1.5
   - type: Tag
     tags:
       - DroneUsable #No bucket because it holds chems, they can drag the cart or use a drain
       damage:
         types:
           Blunt: 10
+    - type: Spillable
+      solution: absorbed
     - type: Item
       size: 15
       sprite: Objects/Specific/Janitorial/advmop.rsi
     - type: Absorbent
-      maxEntities: 3
-      pickupAmount: 25
-      speed: 12.5
+      pickupAmount: 100
+    - type: UseDelay
+      delay: 1.0
     - type: SolutionContainerManager
       solutions:
         absorbed:
   name: mop bucket
   id: MopBucket
   description: Holds water and the tears of the janitor.
-  suffix: Full
   components:
   - type: Clickable
   - type: Sprite
     sprite: Objects/Specific/Janitorial/janitorial.rsi
+    netsync: false
     noRot: true
     layers:
     - state: mopbucket
@@ -80,9 +86,6 @@
     solutions:
       bucket:
         maxVol: 500
-        reagents:
-        - ReagentId: Water
-          Quantity: 250 # half-full at roundstart to leave room for puddles
   - type: Spillable
     spillDelay: 3.0
   - type: DrainableSolution
     maxFillLevels: 3
     fillBaseName: mopbucket_water-
 
+- type: entity
+  name: mop bucket
+  id: MopBucketFull
+  parent: MopBucket
+  suffix: full
+  components:
+    - type: Sprite
+      layers:
+        - state: mopbucket
+        - state: mopbucket_water-3
+          map: [ "enum.SolutionContainerLayers.Fill" ]
+    - type: SolutionContainerManager
+      solutions:
+        bucket:
+          maxVol: 500
+          reagents:
+            - ReagentId: Water
+              Quantity: 500
+
 - type: entity
   name: wet floor sign
   id: WetFloorSign
index e9fb7540ca1163ea7e095cd2c85432b86901c0c6..9998353d6bef493f1df8f93ed661e01be114dc21 100644 (file)
   name: reagent-name-ethanol
   parent: BaseAlcohol
   desc: reagent-desc-ethanol
+  slippery: true
   physicalDesc: reagent-physical-desc-strong-smelling
   flavor: alcohol
   color: "#b05b3c"
index e0267dfca69085275f52c76e712bd68b8c6a951e..a87fc3cee01497fb0ceac081984067ba6cd81750 100644 (file)
@@ -2,6 +2,7 @@
   id: BaseDrink
   group: Drinks
   abstract: true
+  slippery: true
   metabolisms:
     Drink:
       effects:
index 89365d1d77a9ca7b54faa708669f6fabff4800f2..66f3abdca8a5e93282338a8132dd7204d60d5412 100644 (file)
   name: reagent-name-water
   parent: BaseDrink
   desc: reagent-desc-water
+  slippery: true
   physicalDesc: reagent-physical-desc-translucent
   flavor: water
   color: "#75b1f0"
index 8a7aa61d6e359f6047118e9e693e759a2492977f..7ff91a39a3678b598f09b76fb8653f913f698615 100644 (file)
@@ -6,6 +6,7 @@
   flavor: metallic
   color: "#800000"
   physicalDesc: reagent-physical-desc-ferrous
+  slippery: false
   metabolisms:
     Drink:
       # Quenching!
@@ -24,6 +25,7 @@
   flavor: slimy
   color: "#2cf274"
   physicalDesc: reagent-physical-desc-viscous
+  slippery: false
   metabolisms:
     Food:
       # Delicious!
index f4a2961464ac94b59f3d24d82b9fe8b7f610ab39..bc77f4483735fd7772fd95d4ea0ff5fde597d068 100644 (file)
   parent: BasePyrotechnic
   desc: reagent-desc-welding-fuel
   physicalDesc: reagent-physical-desc-oily
+  slippery: true
   flavor: bitter
   color: "#a76b1c"
   boilingPoint: -84.7 # Acetylene. Close enough.
index bce981a42b2ab2b480f82bbc838502b79af9ccab..5f575d2af01635311cbe9cb138e20402521b2e0d 100644 (file)
     Sugar:
       amount: 1
   effects:
-    - !type:SmokeAreaReactionEffect
-      rangeConstant: 0
-      rangeMultiplier: 1.1 #Range formula: rangeConstant + rangeMultiplier*sqrt(ReactionUnits)
-      maxRange: 10
+    - !type:AreaReactionEffect
       duration: 10
-      spreadDelay: 0.5
-      removeDelay: 0.5
-      diluteReagents: true
       prototypeId: Smoke
       sound:
         path: /Audio/Effects/smoke.ogg
     Water:
       amount: 1
   effects:
-    - !type:FoamAreaReactionEffect
-      rangeConstant: 0
-      rangeMultiplier: 1.1 #Range formula: rangeConstant + rangeMultiplier*sqrt(ReactionUnits)
-      maxRange: 10
+    - !type:AreaReactionEffect
       duration: 10
-      spreadDelay: 1
-      removeDelay: 0
-      diluteReagents: true
-      reagentDilutionStart: 4 #At what range should the reagents start diluting
-      reagentDilutionFactor: 1
-      reagentMaxConcentrationFactor: 2 #The reagents will get multiplied by this number if the range turns out to be 0
       prototypeId: Foam
       sound:
         path: /Audio/Effects/extinguish.ogg
     FluorosulfuricAcid:
       amount: 1
   effects:
-    - !type:FoamAreaReactionEffect
-      rangeConstant: 0
-      rangeMultiplier: 1.1
-      maxRange: 10
+    - !type:AreaReactionEffect
       duration: 10
-      spreadDelay: 1
-      removeDelay: 0
-      diluteReagents: true
-      reagentDilutionStart: 4
-      reagentDilutionFactor: 1
-      reagentMaxConcentrationFactor: 2
       prototypeId: IronMetalFoam
       sound:
         path: /Audio/Effects/extinguish.ogg
     FluorosulfuricAcid:
       amount: 1
   effects:
-    - !type:FoamAreaReactionEffect
-      rangeConstant: 0
-      rangeMultiplier: 1.1
-      maxRange: 10
+    - !type:AreaReactionEffect
       duration: 10
-      spreadDelay: 1
-      removeDelay: 0
-      diluteReagents: true
-      reagentDilutionStart: 4
-      reagentDilutionFactor: 1
-      reagentMaxConcentrationFactor: 2
       prototypeId: AluminiumMetalFoam
       sound:
         path: /Audio/Effects/extinguish.ogg
   id: Diphenylmethylamine
   impact: Medium
   reactants:
-    Ethyloxyephedrine: 
+    Ethyloxyephedrine:
       amount: 1
     Charcoal:
       amount: 1
     Pax:
       amount: 1
-    Coffee: 
+    Coffee:
       amount: 1
   products:
     Diphenylmethylamine: 2
-    
+
 - type: reaction
   id: SodiumCarbonate
   impact: Medium
   reactants:
-    Ammonia: 
+    Ammonia:
       amount: 1
     TableSalt:
       amount: 1
     Carbon:
       amount: 1
-    Oxygen: 
+    Oxygen:
       amount: 1
   products:
     SodiumCarbonate: 4
diff --git a/Resources/Prototypes/edge_spreaders.yml b/Resources/Prototypes/edge_spreaders.yml
new file mode 100644 (file)
index 0000000..4cd1ada
--- /dev/null
@@ -0,0 +1,8 @@
+- type: edgeSpreader
+  id: kudzu
+
+- type: edgeSpreader
+  id: puddle
+
+- type: edgeSpreader
+  id: smoke
\ No newline at end of file
diff --git a/Resources/Textures/Fluids/newliquid.png b/Resources/Textures/Fluids/newliquid.png
new file mode 100644 (file)
index 0000000..4d6b400
Binary files /dev/null and b/Resources/Textures/Fluids/newliquid.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/meta.json b/Resources/Textures/Fluids/puddle.rsi/meta.json
new file mode 100644 (file)
index 0000000..b47a24b
--- /dev/null
@@ -0,0 +1,65 @@
+{
+    "version": 1,
+    "size": {
+        "x": 32,
+        "y": 32
+    },
+    "license": "CC-BY-SA-3.0",
+    "copyright": "Made by Alekshhh on github",
+    "states": [
+        {
+            "name": "splata"
+        },
+        {
+            "name": "splatb"
+        },
+        {
+            "name": "splat0"
+        },
+        {
+            "name": "splat1"
+        },
+        {
+            "name": "splat2"
+        },
+        {
+            "name": "splat3"
+        },
+        {
+            "name": "splat4"
+        },
+        {
+            "name": "splat5"
+        },
+        {
+            "name": "splat6"
+        },
+        {
+            "name": "splat7"
+        },
+        {
+            "name": "splat8"
+        },
+        {
+            "name": "splat9"
+        },
+        {
+            "name": "splat10"
+        },
+        {
+            "name": "splat11"
+        },
+        {
+            "name": "splat12"
+        },
+        {
+            "name": "splat13"
+        },
+        {
+            "name": "splat14"
+        },
+        {
+            "name": "splat15"
+        }
+    ]
+}
\ No newline at end of file
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat0.png b/Resources/Textures/Fluids/puddle.rsi/splat0.png
new file mode 100644 (file)
index 0000000..e6635f0
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat0.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat1.png b/Resources/Textures/Fluids/puddle.rsi/splat1.png
new file mode 100644 (file)
index 0000000..8579a08
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat1.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat10.png b/Resources/Textures/Fluids/puddle.rsi/splat10.png
new file mode 100644 (file)
index 0000000..2b2abe9
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat10.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat11.png b/Resources/Textures/Fluids/puddle.rsi/splat11.png
new file mode 100644 (file)
index 0000000..b3f0497
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat11.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat12.png b/Resources/Textures/Fluids/puddle.rsi/splat12.png
new file mode 100644 (file)
index 0000000..a8a2e7f
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat12.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat13.png b/Resources/Textures/Fluids/puddle.rsi/splat13.png
new file mode 100644 (file)
index 0000000..a1cb82a
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat13.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat14.png b/Resources/Textures/Fluids/puddle.rsi/splat14.png
new file mode 100644 (file)
index 0000000..fa2c45a
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat14.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat15.png b/Resources/Textures/Fluids/puddle.rsi/splat15.png
new file mode 100644 (file)
index 0000000..f1f26f8
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat15.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat2.png b/Resources/Textures/Fluids/puddle.rsi/splat2.png
new file mode 100644 (file)
index 0000000..94d18f3
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat2.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat3.png b/Resources/Textures/Fluids/puddle.rsi/splat3.png
new file mode 100644 (file)
index 0000000..dbffc99
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat3.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat4.png b/Resources/Textures/Fluids/puddle.rsi/splat4.png
new file mode 100644 (file)
index 0000000..e99174b
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat4.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat5.png b/Resources/Textures/Fluids/puddle.rsi/splat5.png
new file mode 100644 (file)
index 0000000..ca044c8
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat5.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat6.png b/Resources/Textures/Fluids/puddle.rsi/splat6.png
new file mode 100644 (file)
index 0000000..02ca257
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat6.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat7.png b/Resources/Textures/Fluids/puddle.rsi/splat7.png
new file mode 100644 (file)
index 0000000..05bbc5b
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat7.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat8.png b/Resources/Textures/Fluids/puddle.rsi/splat8.png
new file mode 100644 (file)
index 0000000..a249287
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat8.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splat9.png b/Resources/Textures/Fluids/puddle.rsi/splat9.png
new file mode 100644 (file)
index 0000000..05122c6
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splat9.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splata.png b/Resources/Textures/Fluids/puddle.rsi/splata.png
new file mode 100644 (file)
index 0000000..f3e5d76
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splata.png differ
diff --git a/Resources/Textures/Fluids/puddle.rsi/splatb.png b/Resources/Textures/Fluids/puddle.rsi/splatb.png
new file mode 100644 (file)
index 0000000..87e9da0
Binary files /dev/null and b/Resources/Textures/Fluids/puddle.rsi/splatb.png differ